@cosmicdrift/kumiko-renderer 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- package/src/__tests__/error-i18n-defaults.test.ts +13 -0
- package/src/__tests__/format-when.test.ts +12 -0
- package/src/__tests__/i18n.test.tsx +59 -0
- package/src/__tests__/qn.test.ts +44 -1
- package/src/__tests__/sort-by-accessor.test.ts +48 -0
- package/src/app/__tests__/config-edit-shim.test.ts +40 -0
- package/src/app/__tests__/screen-access-allows.test.ts +24 -0
- package/src/app/dashboard-body.tsx +32 -0
- package/src/app/extension-sections.tsx +11 -4
- package/src/app/kumiko-screen.tsx +408 -20
- package/src/app/projection-detail-shim.ts +72 -0
- package/src/app/projection-list-shim.ts +62 -0
- package/src/app/qn.ts +13 -0
- package/src/components/__tests__/render-field-app-locale.test.tsx +2 -0
- package/src/components/render-edit-logic.ts +8 -5
- package/src/components/render-edit.tsx +25 -2
- package/src/components/render-field.tsx +2 -1
- package/src/components/render-list.tsx +24 -2
- package/src/context/user-roles-context.tsx +27 -0
- package/src/format-when.ts +11 -0
- package/src/hooks/__tests__/use-ai-text.test.tsx +177 -0
- package/src/hooks/__tests__/use-disclosure.test.tsx +25 -0
- package/src/hooks/__tests__/use-mutation.test.tsx +77 -0
- package/src/hooks/__tests__/use-stream-handler.test.tsx +107 -0
- package/src/hooks/use-ai-text.ts +172 -0
- package/src/hooks/use-disclosure.ts +20 -0
- package/src/hooks/use-mutation.ts +61 -0
- package/src/hooks/use-query.ts +5 -2
- package/src/hooks/use-reference-lookup.ts +2 -1
- package/src/hooks/use-stream-handler.ts +134 -0
- package/src/i18n-defaults.ts +56 -0
- package/src/i18n.tsx +72 -31
- package/src/index.ts +31 -0
- package/src/primitives.tsx +70 -4
- package/src/sort-by-accessor.ts +20 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Shim für ProjectionDetail-Renderer (analog zu projection-list-shim.ts /
|
|
2
|
+
// config-edit-shim.ts).
|
|
3
|
+
//
|
|
4
|
+
// RenderEdit verlangt ein `entity: EntityDefinition` + ein
|
|
5
|
+
// `screen: EntityEditScreenDefinition`-Pair, nutzt davon aber nur
|
|
6
|
+
// `entity.fields` (Feld-Typ für den Input-Renderer) und `screen.layout`
|
|
7
|
+
// (welche Felder in welchen Sections). Ein projectionDetail-Screen ist
|
|
8
|
+
// query-getrieben und hat KEINE Entity — die zwei Helper hier shapen den
|
|
9
|
+
// Input ad-hoc um, damit RenderEdit reused werden kann.
|
|
10
|
+
//
|
|
11
|
+
// Der strukturelle Read-Only-Beweis liegt in synthesizeProjectionDetailScreen:
|
|
12
|
+
// JEDES Feld wird hart auf readOnly:true gesetzt (nicht nur was der Author im
|
|
13
|
+
// Layout gesetzt hat) — hasEditableSection() liest genau dieses Flag und
|
|
14
|
+
// blendet den Save-Button aus, wenn kein Feld editierbar ist. Der Author kann
|
|
15
|
+
// diese Garantie nicht versehentlich umgehen.
|
|
16
|
+
//
|
|
17
|
+
// Selbe Schulden-Reservation wie bei den anderen Shims: greift RenderEdit
|
|
18
|
+
// künftig auf weitere EntityDefinition-Felder (transitions, idType) zu, oder
|
|
19
|
+
// cross-referenziert ein Boot-Validator die schema.entities-Map, brechen die
|
|
20
|
+
// Type-Lies hier silent — dann ist Zeit für eine echte RenderProjectionDetail-
|
|
21
|
+
// Komponente.
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
EditLayout,
|
|
25
|
+
EntityDefinition,
|
|
26
|
+
EntityEditScreenDefinition,
|
|
27
|
+
ProjectionDetailScreenDefinition,
|
|
28
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
29
|
+
import {
|
|
30
|
+
isExtensionEditSection,
|
|
31
|
+
normalizeEditField,
|
|
32
|
+
PROJECTION_DETAIL_ENTITY as PROJECTION_DETAIL_PSEUDO_ENTITY,
|
|
33
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
34
|
+
|
|
35
|
+
/** Minimale EntityDefinition aus den Layout-Feldern: jedes Feld ein Text-
|
|
36
|
+
* Feld — computeEditViewModel liest nur `fields[<f>].type`, Text reicht für
|
|
37
|
+
* eine reine Anzeige (kein Select/Number-spezifisches Rendering nötig). */
|
|
38
|
+
export function synthesizeProjectionDetailEntity(layout: EditLayout): EntityDefinition {
|
|
39
|
+
const fields: Record<string, { type: "text" }> = {};
|
|
40
|
+
for (const section of layout.sections) {
|
|
41
|
+
if (isExtensionEditSection(section)) continue; // rejected at boot, defensive here
|
|
42
|
+
for (const spec of section.fields) {
|
|
43
|
+
fields[normalizeEditField(spec).field] = { type: "text" };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { fields } as unknown as EntityDefinition;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Wandelt ein ProjectionDetailScreenDefinition in die EntityEditScreen-Shape
|
|
50
|
+
* die RenderEdit erwartet — mit jedem Feld hart auf readOnly:true erzwungen. */
|
|
51
|
+
export function synthesizeProjectionDetailScreen(
|
|
52
|
+
screen: ProjectionDetailScreenDefinition,
|
|
53
|
+
): EntityEditScreenDefinition {
|
|
54
|
+
const sections = screen.layout.sections.map((section) => {
|
|
55
|
+
if (isExtensionEditSection(section)) return section; // rejected at boot, defensive here
|
|
56
|
+
return {
|
|
57
|
+
...section,
|
|
58
|
+
fields: section.fields.map((spec) => ({ ...normalizeEditField(spec), readOnly: true })),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
id: screen.id,
|
|
63
|
+
type: "entityEdit",
|
|
64
|
+
entity: PROJECTION_DETAIL_PSEUDO_ENTITY,
|
|
65
|
+
layout: { sections },
|
|
66
|
+
allowCreate: false,
|
|
67
|
+
allowDelete: false,
|
|
68
|
+
...(screen.fieldLabels !== undefined && { fieldLabels: screen.fieldLabels }),
|
|
69
|
+
...(screen.slots !== undefined && { slots: screen.slots }),
|
|
70
|
+
...(screen.access !== undefined && { access: screen.access }),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Shim für ProjectionList-Renderer (analog action-form-shim.ts / config-edit-shim.ts).
|
|
2
|
+
//
|
|
3
|
+
// RenderList + computeListViewModel verlangen ein `entity: EntityDefinition` +
|
|
4
|
+
// ein `screen: EntityListScreenDefinition`-Pair, nutzen davon aber nur
|
|
5
|
+
// `entity.fields` (Spalten-Typ/Label) und die List-Felder des Screens
|
|
6
|
+
// (columns/rowActions/…). Ein projectionList-Screen ist query-getrieben und hat
|
|
7
|
+
// KEINE Entity — die zwei Helper hier shapen den Input ad-hoc um, damit die
|
|
8
|
+
// bestehende List-Maschinerie reused werden kann. Die Query selbst wird NICHT
|
|
9
|
+
// hierüber aufgelöst (der Body nimmt `screen.query` direkt).
|
|
10
|
+
//
|
|
11
|
+
// Selbe Schulden-Reservation wie bei den anderen Shims: greift die List-
|
|
12
|
+
// Maschinerie künftig auf weitere EntityDefinition-Felder (transitions, idType,
|
|
13
|
+
// derivedFields) oder cross-referenziert ein Boot-Validator die schema.entities-
|
|
14
|
+
// Map, brechen die Type-Lies hier silent — dann ist Zeit für eine echte
|
|
15
|
+
// query-native ListView-Komponente.
|
|
16
|
+
|
|
17
|
+
import type {
|
|
18
|
+
EntityDefinition,
|
|
19
|
+
EntityListScreenDefinition,
|
|
20
|
+
ListColumnSpec,
|
|
21
|
+
ProjectionListScreenDefinition,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
23
|
+
import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
24
|
+
|
|
25
|
+
const PROJECTION_PSEUDO_ENTITY = "__projection__";
|
|
26
|
+
|
|
27
|
+
/** Minimale EntityDefinition aus den Column-Feldern: jedes Feld ein Text-Feld,
|
|
28
|
+
* nicht sortierbar (eine Projection-Query hat keinen garantierten Server-Sort).
|
|
29
|
+
* computeListViewModel liest nur `fields[<col>].type` → Text reicht; die
|
|
30
|
+
* Präsentation kommt aus dem Column-Renderer + explizitem Label. */
|
|
31
|
+
export function synthesizeProjectionEntity(columns: readonly ListColumnSpec[]): EntityDefinition {
|
|
32
|
+
const fields: Record<string, { type: "text"; sortable: false }> = {};
|
|
33
|
+
for (const col of columns) {
|
|
34
|
+
fields[normalizeListColumn(col).field] = { type: "text", sortable: false };
|
|
35
|
+
}
|
|
36
|
+
return { fields } as unknown as EntityDefinition;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Wandelt ein ProjectionListScreenDefinition in die EntityListScreen-Shape die
|
|
40
|
+
* RenderList erwartet. `type` = "entityList" + Pseudo-Entity halten den Type-
|
|
41
|
+
* Constraint; RenderList branched nicht auf `type` und liest `entity` nur für
|
|
42
|
+
* Fehlermeldungen. Die List-Felder werden 1:1 durchgereicht. */
|
|
43
|
+
export function synthesizeProjectionScreen(
|
|
44
|
+
screen: ProjectionListScreenDefinition,
|
|
45
|
+
): EntityListScreenDefinition {
|
|
46
|
+
return {
|
|
47
|
+
id: screen.id,
|
|
48
|
+
type: "entityList",
|
|
49
|
+
entity: PROJECTION_PSEUDO_ENTITY,
|
|
50
|
+
columns: screen.columns,
|
|
51
|
+
...(screen.rowRenderer !== undefined && { rowRenderer: screen.rowRenderer }),
|
|
52
|
+
...(screen.cardRenderer !== undefined && { cardRenderer: screen.cardRenderer }),
|
|
53
|
+
...(screen.rowActions !== undefined && { rowActions: screen.rowActions }),
|
|
54
|
+
...(screen.toolbarActions !== undefined && { toolbarActions: screen.toolbarActions }),
|
|
55
|
+
...(screen.pagination !== undefined && { pagination: screen.pagination }),
|
|
56
|
+
...(screen.pageSize !== undefined && { pageSize: screen.pageSize }),
|
|
57
|
+
...(screen.defaultSort !== undefined && { defaultSort: screen.defaultSort }),
|
|
58
|
+
...(screen.searchable !== undefined && { searchable: screen.searchable }),
|
|
59
|
+
...(screen.slots !== undefined && { slots: screen.slots }),
|
|
60
|
+
...(screen.access !== undefined && { access: screen.access }),
|
|
61
|
+
};
|
|
62
|
+
}
|
package/src/app/qn.ts
CHANGED
|
@@ -21,3 +21,16 @@ export function lastSegment(qn: string): string {
|
|
|
21
21
|
const idx = qn.lastIndexOf(":");
|
|
22
22
|
return idx < 0 ? qn : qn.slice(idx + 1);
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Client-safe copy of framework `toKebab` (engine/qualified-name.ts).
|
|
27
|
+
* Must stay in sync — do NOT import from `@cosmicdrift/kumiko-framework/engine`
|
|
28
|
+
* here: that barrel pulls server deps into the browser bundle.
|
|
29
|
+
*/
|
|
30
|
+
export function toKebab(input: string): string {
|
|
31
|
+
return input
|
|
32
|
+
.replace(/\./g, "-")
|
|
33
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
|
|
34
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
35
|
+
.toLowerCase();
|
|
36
|
+
}
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { EditSectionViewModel, SubmitResult } from "@cosmicdrift/kumiko-headless";
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// Hides the Save button when no field is editable and no extension section
|
|
4
|
+
// exists (extension carries its own dirty/save; a purely read-only form has nothing to submit).
|
|
5
|
+
// Explicit-positive per kind (653/1), not `s.kind !== "fields"` — a future
|
|
6
|
+
// third EditSectionViewModel member would otherwise default to "editable"
|
|
7
|
+
// without anyone deciding that on purpose.
|
|
7
8
|
export function hasEditableSection(sections: readonly EditSectionViewModel[]): boolean {
|
|
8
|
-
return sections.some(
|
|
9
|
+
return sections.some(
|
|
10
|
+
(s) => s.kind === "extension" || (s.kind === "fields" && s.fields.some((f) => !f.readOnly)),
|
|
11
|
+
);
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
// Single source of truth for the extension-section entity-id. The section mount
|
|
@@ -76,6 +76,13 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
76
76
|
readonly onDelete?: () => Promise<void> | void;
|
|
77
77
|
readonly onCancel?: () => void;
|
|
78
78
|
readonly onReload?: () => void;
|
|
79
|
+
/** Copy-Link-Action (Issue #912) — nur in update-mode gesetzt (create-mode
|
|
80
|
+
* hat noch keine entity-id, also keinen Permalink). Der Callback ist
|
|
81
|
+
* bereits vollständig gebunden (URL-Bau + Clipboard passiert außerhalb,
|
|
82
|
+
* in `@cosmicdrift/kumiko-renderer-web`'s RoutedScreen — dieses
|
|
83
|
+
* platform-neutrale Package darf kein `navigator`/`window` anfassen,
|
|
84
|
+
* siehe guard-renderer-boundaries). undefined = kein Button. */
|
|
85
|
+
readonly onCopyLink?: () => Promise<void> | void;
|
|
79
86
|
/** i18n-key für den Submit-Button. Default: "kumiko.actions.save".
|
|
80
87
|
* Action-Forms (Tier 2.7d) übergeben hier ihren screen.submitLabel,
|
|
81
88
|
* damit "Speichern" durch domain-spezifischere Strings ersetzt
|
|
@@ -185,6 +192,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
185
192
|
onDelete,
|
|
186
193
|
onCancel,
|
|
187
194
|
onReload,
|
|
195
|
+
onCopyLink,
|
|
188
196
|
submitLabel,
|
|
189
197
|
labelAppendix,
|
|
190
198
|
fieldAppendix,
|
|
@@ -201,6 +209,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
201
209
|
const translate = translateProp ?? t;
|
|
202
210
|
|
|
203
211
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
|
212
|
+
const [linkCopied, setLinkCopied] = useState(false);
|
|
204
213
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
205
214
|
const [formError, setFormError] = useState<DispatcherError | null>(null);
|
|
206
215
|
// Composed-Save: Extension-Sections melden hier ihren dirty-State (damit der
|
|
@@ -247,7 +256,8 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
247
256
|
[screen, entity, snapshot.values, translate, featureName],
|
|
248
257
|
);
|
|
249
258
|
|
|
250
|
-
|
|
259
|
+
// true for an extension section with no fields of its own too (it carries its own dirty/save).
|
|
260
|
+
const isFormEditable = hasEditableSection(vm.sections);
|
|
251
261
|
|
|
252
262
|
// Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
|
|
253
263
|
// false = eine Section schlug fehl (ihr i18n-Key landet im Banner). Ohne
|
|
@@ -335,6 +345,19 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
335
345
|
// gegen Fehlklicks. Save bleibt rechts (primary affordance).
|
|
336
346
|
const formActions = (
|
|
337
347
|
<>
|
|
348
|
+
{onCopyLink !== undefined && (
|
|
349
|
+
<Button
|
|
350
|
+
type="button"
|
|
351
|
+
variant="secondary"
|
|
352
|
+
testId="render-edit-copy-link"
|
|
353
|
+
onClick={async () => {
|
|
354
|
+
await onCopyLink();
|
|
355
|
+
setLinkCopied(true);
|
|
356
|
+
}}
|
|
357
|
+
>
|
|
358
|
+
{translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink")}
|
|
359
|
+
</Button>
|
|
360
|
+
)}
|
|
338
361
|
{onDelete !== undefined && (
|
|
339
362
|
<Button
|
|
340
363
|
type="button"
|
|
@@ -355,7 +378,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
355
378
|
{translate("kumiko.actions.cancel")}
|
|
356
379
|
</Button>
|
|
357
380
|
)}
|
|
358
|
-
{
|
|
381
|
+
{isFormEditable && (
|
|
359
382
|
<Button
|
|
360
383
|
type="submit"
|
|
361
384
|
disabled={(snapshot.isUnchanged && !extensionDirty) || isSubmitting}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EditFieldViewModel, FieldIssue } from "@cosmicdrift/kumiko-headless";
|
|
2
2
|
import { type ReactNode, useCallback, useMemo, useState } from "react";
|
|
3
|
+
import { toKebab } from "../app/qn";
|
|
3
4
|
import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits";
|
|
4
5
|
import { useQuery } from "../hooks/use-query";
|
|
5
6
|
import { useLocale } from "../i18n";
|
|
@@ -118,7 +119,7 @@ function ReferenceInput({
|
|
|
118
119
|
// Tier 2.7e Cross-Feature: refFeature kann ≠ featureName sein
|
|
119
120
|
// (z.B. items.assignee → users:query:user:list). Default ist
|
|
120
121
|
// same-feature, kommt aus dem ViewModel (parseRefTarget).
|
|
121
|
-
const queryQn = `${refFeature}:query:${refEntity}:list`;
|
|
122
|
+
const queryQn = `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`;
|
|
122
123
|
// Tier 2.7e Remote-Search: User tippt im Combobox → Server filtert
|
|
123
124
|
// via existing list-payload `search`-Param (Tier 2.6c). Combobox
|
|
124
125
|
// debounced den keystroke selbst (300ms) und ruft onSearchChange.
|
|
@@ -148,7 +148,7 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
148
148
|
// wären Column-Header raw i18n-Keys.
|
|
149
149
|
const t = useTranslation();
|
|
150
150
|
const translate: Translate = translateProp ?? t;
|
|
151
|
-
const { DataTable, Button, Dialog, Input, Text } = usePrimitives();
|
|
151
|
+
const { DataTable, Button, Dialog, Input, Text, Banner } = usePrimitives();
|
|
152
152
|
|
|
153
153
|
// Local Search-Buffer + Debounce. Externe Änderungen (Browser-Back,
|
|
154
154
|
// Cross-Component-Reset) spiegeln wir per Sync-Effect zurück; Tipps
|
|
@@ -280,7 +280,13 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
280
280
|
{hasHeaderSlot && <ListHeaderSlotMount screen={screen} />}
|
|
281
281
|
{hasToolbarActions &&
|
|
282
282
|
toolbarActions.map((a) => (
|
|
283
|
-
<ToolbarActionView
|
|
283
|
+
<ToolbarActionView
|
|
284
|
+
key={a.id}
|
|
285
|
+
action={a}
|
|
286
|
+
Button={Button}
|
|
287
|
+
Dialog={Dialog}
|
|
288
|
+
Banner={Banner}
|
|
289
|
+
/>
|
|
284
290
|
))}
|
|
285
291
|
{onCreate !== undefined && (
|
|
286
292
|
<Button variant="primary" onClick={onCreate} testId="render-list-create">
|
|
@@ -423,18 +429,29 @@ function ToolbarActionView({
|
|
|
423
429
|
action,
|
|
424
430
|
Button,
|
|
425
431
|
Dialog,
|
|
432
|
+
Banner,
|
|
426
433
|
}: {
|
|
427
434
|
readonly action: ToolbarActionButton;
|
|
428
435
|
readonly Button: ReturnType<typeof usePrimitives>["Button"];
|
|
429
436
|
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
437
|
+
readonly Banner: ReturnType<typeof usePrimitives>["Banner"];
|
|
430
438
|
}): ReactNode {
|
|
431
439
|
const [busy, setBusy] = useState(false);
|
|
432
440
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
441
|
+
// Toolbar-Actions haben (anders als rowActions, die über die DataTable-
|
|
442
|
+
// Primitive laufen) keine Toast-Primitive zur Verfügung — ein inline
|
|
443
|
+
// Error-Banner ist der surfacing-Pfad hier. Gleicher Grund wie bei
|
|
444
|
+
// rowActions (Prod-Bug 2026-06-07): ein verschlucktes Failure-Result
|
|
445
|
+
// sieht für den User wie "nichts passiert" aus.
|
|
446
|
+
const [errorText, setErrorText] = useState<string | null>(null);
|
|
433
447
|
|
|
434
448
|
const trigger = async (): Promise<void> => {
|
|
435
449
|
setBusy(true);
|
|
450
|
+
setErrorText(null);
|
|
436
451
|
try {
|
|
437
452
|
await action.onTrigger();
|
|
453
|
+
} catch (e) {
|
|
454
|
+
setErrorText(e instanceof Error ? e.message : String(e));
|
|
438
455
|
} finally {
|
|
439
456
|
setBusy(false);
|
|
440
457
|
}
|
|
@@ -469,6 +486,11 @@ function ToolbarActionView({
|
|
|
469
486
|
onConfirm={trigger}
|
|
470
487
|
testId={`render-list-toolbar-action-${action.id}-dialog`}
|
|
471
488
|
/>
|
|
489
|
+
{errorText !== null && (
|
|
490
|
+
<Banner variant="error" testId={`render-list-toolbar-action-${action.id}-error`}>
|
|
491
|
+
{errorText}
|
|
492
|
+
</Banner>
|
|
493
|
+
)}
|
|
472
494
|
</>
|
|
473
495
|
);
|
|
474
496
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
// Threads the current user's roles through the tree so KumikoScreen can
|
|
4
|
+
// gate role-restricted screens at render time (see #1203 — nav filtering
|
|
5
|
+
// alone doesn't stop a direct-URL/screenQn hit on a role-gated screen).
|
|
6
|
+
// Apps wire this from their `shell` render-prop, the same place they
|
|
7
|
+
// already pass `user` to WorkspaceShell for nav filtering.
|
|
8
|
+
//
|
|
9
|
+
// undefined (no provider mounted, or `roles` not passed) means "roles
|
|
10
|
+
// unknown" — deliberately distinct from `[]` ("authenticated, no
|
|
11
|
+
// roles"). Both deny role-gated screens; only `undefined` also lets an
|
|
12
|
+
// app without any role-gated screens skip wiring this entirely.
|
|
13
|
+
|
|
14
|
+
const UserRolesContext = createContext<readonly string[] | undefined>(undefined);
|
|
15
|
+
|
|
16
|
+
export type UserRolesProviderProps = {
|
|
17
|
+
readonly roles: readonly string[] | undefined;
|
|
18
|
+
readonly children: ReactNode;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function UserRolesProvider({ roles, children }: UserRolesProviderProps): ReactNode {
|
|
22
|
+
return <UserRolesContext value={roles}>{children}</UserRolesContext>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useUserRoles(): readonly string[] | undefined {
|
|
26
|
+
return useContext(UserRolesContext);
|
|
27
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { toInstant } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
|
|
3
|
+
// Shared timestamp formatter for operator screens (audit log, job runs) —
|
|
4
|
+
// falls back to the raw ISO string on an unparseable value instead of "Invalid Date".
|
|
5
|
+
export function formatWhen(value: string): string {
|
|
6
|
+
try {
|
|
7
|
+
return toInstant(value).toLocaleString();
|
|
8
|
+
} catch {
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
6
|
+
import { useAiTextAction, useCompletion } from "../use-ai-text";
|
|
7
|
+
|
|
8
|
+
function makeDispatcher(query: Dispatcher["query"]): Dispatcher {
|
|
9
|
+
return {
|
|
10
|
+
query,
|
|
11
|
+
write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
|
|
12
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
13
|
+
statusStore: {
|
|
14
|
+
getState: () => "online",
|
|
15
|
+
subscribe: () => () => {},
|
|
16
|
+
} as unknown as Dispatcher["statusStore"],
|
|
17
|
+
async *stream() {},
|
|
18
|
+
pendingWrites: () => [],
|
|
19
|
+
pendingFiles: () => [],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function wrapperFor(dispatcher: Dispatcher) {
|
|
24
|
+
return ({ children }: { readonly children: ReactNode }) => (
|
|
25
|
+
<DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("useAiTextAction", () => {
|
|
30
|
+
test("success → state='success', result carries the text", async () => {
|
|
31
|
+
const dispatcher = makeDispatcher((async (_type: string, payload: unknown) => ({
|
|
32
|
+
isSuccess: true,
|
|
33
|
+
data: {
|
|
34
|
+
type: "text",
|
|
35
|
+
text: `echo:${(payload as { text: string }).text}`,
|
|
36
|
+
usage: { inputTokens: 1, outputTokens: 1 },
|
|
37
|
+
},
|
|
38
|
+
})) as unknown as Dispatcher["query"]);
|
|
39
|
+
|
|
40
|
+
const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
|
|
41
|
+
|
|
42
|
+
expect(result.current.state).toBe("idle");
|
|
43
|
+
await act(async () => {
|
|
44
|
+
await result.current.run({ mode: "correct", text: "hi" });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(result.current.state).toBe("success");
|
|
48
|
+
expect(result.current.result).toEqual({
|
|
49
|
+
type: "text",
|
|
50
|
+
text: "echo:hi",
|
|
51
|
+
usage: { inputTokens: 1, outputTokens: 1 },
|
|
52
|
+
});
|
|
53
|
+
expect(result.current.error).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("cap_exceeded error → state='cap-exceeded'", async () => {
|
|
57
|
+
const dispatcher = makeDispatcher((async () => ({
|
|
58
|
+
isSuccess: false,
|
|
59
|
+
error: { code: "cap_exceeded", message: "capped", i18nKey: "errors.cap" },
|
|
60
|
+
})) as unknown as Dispatcher["query"]);
|
|
61
|
+
|
|
62
|
+
const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
|
|
63
|
+
|
|
64
|
+
await act(async () => {
|
|
65
|
+
await result.current.run({ mode: "correct", text: "hi" });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(result.current.state).toBe("cap-exceeded");
|
|
69
|
+
expect(result.current.error?.code).toBe("cap_exceeded");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("feature_disabled error → state='unavailable' (graceful degradation)", async () => {
|
|
73
|
+
const dispatcher = makeDispatcher((async () => ({
|
|
74
|
+
isSuccess: false,
|
|
75
|
+
error: { code: "feature_disabled", message: "off", i18nKey: "errors.disabled" },
|
|
76
|
+
})) as unknown as Dispatcher["query"]);
|
|
77
|
+
|
|
78
|
+
const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
|
|
79
|
+
|
|
80
|
+
await act(async () => {
|
|
81
|
+
await result.current.run({ mode: "complete", text: "hi" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(result.current.state).toBe("unavailable");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("reset clears state/result/error back to idle", async () => {
|
|
88
|
+
const dispatcher = makeDispatcher((async () => ({
|
|
89
|
+
isSuccess: false,
|
|
90
|
+
error: { code: "conflict", message: "boom", i18nKey: "errors.conflict" },
|
|
91
|
+
})) as unknown as Dispatcher["query"]);
|
|
92
|
+
|
|
93
|
+
const { result } = renderHook(() => useAiTextAction(), { wrapper: wrapperFor(dispatcher) });
|
|
94
|
+
await act(async () => {
|
|
95
|
+
await result.current.run({ mode: "correct", text: "hi" });
|
|
96
|
+
});
|
|
97
|
+
expect(result.current.state).toBe("error");
|
|
98
|
+
|
|
99
|
+
act(() => result.current.reset());
|
|
100
|
+
expect(result.current.state).toBe("idle");
|
|
101
|
+
expect(result.current.error).toBeNull();
|
|
102
|
+
expect(result.current.result).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("useCompletion", () => {
|
|
107
|
+
test("debounces requestCompletion — only the last call within the window fires", async () => {
|
|
108
|
+
let calls = 0;
|
|
109
|
+
const dispatcher = makeDispatcher((async (_type: string, payload: unknown) => {
|
|
110
|
+
calls++;
|
|
111
|
+
return {
|
|
112
|
+
isSuccess: true,
|
|
113
|
+
data: {
|
|
114
|
+
type: "text",
|
|
115
|
+
text: `suggestion for "${(payload as { text: string }).text}"`,
|
|
116
|
+
usage: { inputTokens: 1, outputTokens: 1 },
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}) as unknown as Dispatcher["query"]);
|
|
120
|
+
|
|
121
|
+
const { result } = renderHook(() => useCompletion(20), { wrapper: wrapperFor(dispatcher) });
|
|
122
|
+
|
|
123
|
+
act(() => {
|
|
124
|
+
result.current.requestCompletion("a");
|
|
125
|
+
result.current.requestCompletion("ab");
|
|
126
|
+
result.current.requestCompletion("abc");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await waitFor(() => expect(result.current.suggestion).not.toBeNull(), { timeout: 1000 });
|
|
130
|
+
|
|
131
|
+
expect(calls).toBe(1);
|
|
132
|
+
expect(result.current.suggestion).toBe('suggestion for "abc"');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("empty text resets immediately without a request", async () => {
|
|
136
|
+
let calls = 0;
|
|
137
|
+
const dispatcher = makeDispatcher((async () => {
|
|
138
|
+
calls++;
|
|
139
|
+
return {
|
|
140
|
+
isSuccess: true,
|
|
141
|
+
data: { type: "text", text: "x", usage: { inputTokens: 1, outputTokens: 1 } },
|
|
142
|
+
};
|
|
143
|
+
}) as unknown as Dispatcher["query"]);
|
|
144
|
+
|
|
145
|
+
const { result } = renderHook(() => useCompletion(10), { wrapper: wrapperFor(dispatcher) });
|
|
146
|
+
|
|
147
|
+
act(() => {
|
|
148
|
+
result.current.requestCompletion("");
|
|
149
|
+
});
|
|
150
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
151
|
+
|
|
152
|
+
expect(calls).toBe(0);
|
|
153
|
+
expect(result.current.suggestion).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("clear cancels a pending debounce and resets the suggestion", async () => {
|
|
157
|
+
let calls = 0;
|
|
158
|
+
const dispatcher = makeDispatcher((async () => {
|
|
159
|
+
calls++;
|
|
160
|
+
return {
|
|
161
|
+
isSuccess: true,
|
|
162
|
+
data: { type: "text", text: "x", usage: { inputTokens: 1, outputTokens: 1 } },
|
|
163
|
+
};
|
|
164
|
+
}) as unknown as Dispatcher["query"]);
|
|
165
|
+
|
|
166
|
+
const { result } = renderHook(() => useCompletion(30), { wrapper: wrapperFor(dispatcher) });
|
|
167
|
+
|
|
168
|
+
act(() => {
|
|
169
|
+
result.current.requestCompletion("hello");
|
|
170
|
+
});
|
|
171
|
+
act(() => result.current.clear());
|
|
172
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
173
|
+
|
|
174
|
+
expect(calls).toBe(0);
|
|
175
|
+
expect(result.current.suggestion).toBeNull();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { act, renderHook } from "@testing-library/react";
|
|
3
|
+
import { useDisclosure } from "../use-disclosure";
|
|
4
|
+
|
|
5
|
+
describe("useDisclosure", () => {
|
|
6
|
+
test("open/close/toggle steuern den Zustand", () => {
|
|
7
|
+
const { result } = renderHook(() => useDisclosure());
|
|
8
|
+
expect(result.current.open).toBe(false);
|
|
9
|
+
act(() => result.current.onOpen());
|
|
10
|
+
expect(result.current.open).toBe(true);
|
|
11
|
+
act(() => result.current.onClose());
|
|
12
|
+
expect(result.current.open).toBe(false);
|
|
13
|
+
act(() => result.current.onToggle());
|
|
14
|
+
expect(result.current.open).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("Callbacks sind referenz-stabil über Re-Renders", () => {
|
|
18
|
+
const { result, rerender } = renderHook(() => useDisclosure(true));
|
|
19
|
+
const first = result.current;
|
|
20
|
+
rerender();
|
|
21
|
+
expect(result.current.onOpen).toBe(first.onOpen);
|
|
22
|
+
expect(result.current.onClose).toBe(first.onClose);
|
|
23
|
+
expect(result.current.onToggle).toBe(first.onToggle);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
6
|
+
import { useMutation } from "../use-mutation";
|
|
7
|
+
|
|
8
|
+
function makeDispatcher(write: Dispatcher["write"]): Dispatcher {
|
|
9
|
+
return {
|
|
10
|
+
write,
|
|
11
|
+
query: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["query"],
|
|
12
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
13
|
+
statusStore: {
|
|
14
|
+
getState: () => "online",
|
|
15
|
+
subscribe: () => () => {},
|
|
16
|
+
} as unknown as Dispatcher["statusStore"],
|
|
17
|
+
async *stream() {},
|
|
18
|
+
pendingWrites: () => [],
|
|
19
|
+
pendingFiles: () => [],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function wrapperFor(dispatcher: Dispatcher) {
|
|
24
|
+
return ({ children }: { readonly children: ReactNode }) => (
|
|
25
|
+
<DispatcherProvider dispatcher={dispatcher}>{children}</DispatcherProvider>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("useMutation", () => {
|
|
30
|
+
test("Success setzt data, pending toggelt, Result wird durchgereicht", async () => {
|
|
31
|
+
let resolve: (() => void) | undefined;
|
|
32
|
+
const gate = new Promise<void>((r) => {
|
|
33
|
+
resolve = r;
|
|
34
|
+
});
|
|
35
|
+
const dispatcher = makeDispatcher((async (_type: string, payload: unknown) => {
|
|
36
|
+
await gate;
|
|
37
|
+
return { isSuccess: true, data: { echoed: payload } };
|
|
38
|
+
}) as unknown as Dispatcher["write"]);
|
|
39
|
+
|
|
40
|
+
const { result } = renderHook(() => useMutation<{ echoed: unknown }>("f:write:x:create"), {
|
|
41
|
+
wrapper: wrapperFor(dispatcher),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(result.current.pending).toBe(false);
|
|
45
|
+
let outcome: Awaited<ReturnType<typeof result.current.mutate>> | undefined;
|
|
46
|
+
act(() => {
|
|
47
|
+
void result.current.mutate({ name: "a" }).then((r) => {
|
|
48
|
+
outcome = r;
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
await waitFor(() => expect(result.current.pending).toBe(true));
|
|
52
|
+
act(() => resolve?.());
|
|
53
|
+
await waitFor(() => expect(result.current.pending).toBe(false));
|
|
54
|
+
expect(result.current.data).toEqual({ echoed: { name: "a" } });
|
|
55
|
+
expect(result.current.error).toBeNull();
|
|
56
|
+
expect(outcome?.isSuccess).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("Failure setzt error, reset räumt auf", async () => {
|
|
60
|
+
const dispatcher = makeDispatcher((async () => ({
|
|
61
|
+
isSuccess: false,
|
|
62
|
+
error: { code: "conflict", message: "boom", i18nKey: "errors.conflict" },
|
|
63
|
+
})) as unknown as Dispatcher["write"]);
|
|
64
|
+
|
|
65
|
+
const { result } = renderHook(() => useMutation("f:write:x:create"), {
|
|
66
|
+
wrapper: wrapperFor(dispatcher),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await act(async () => {
|
|
70
|
+
await result.current.mutate({});
|
|
71
|
+
});
|
|
72
|
+
expect(result.current.error?.code).toBe("conflict");
|
|
73
|
+
act(() => result.current.reset());
|
|
74
|
+
expect(result.current.error).toBeNull();
|
|
75
|
+
expect(result.current.data).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
});
|