@cosmicdrift/kumiko-renderer 0.3.0 → 0.4.1
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/CHANGELOG.md +77 -0
- package/package.json +3 -3
- package/src/app/kumiko-screen.tsx +58 -2
- package/src/components/render-edit.tsx +15 -0
- package/src/components/render-field.tsx +17 -2
- package/src/primitives.tsx +25 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,82 @@
|
|
|
1
1
|
# @cosmicdrift/kumiko-renderer
|
|
2
2
|
|
|
3
|
+
## 0.4.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 010b410: feat(auth-email-password): "Bestätigungs-Mail erneut senden" im LoginScreen
|
|
8
|
+
|
|
9
|
+
LoginScreen bietet bei reason=email_not_verified jetzt einen Resend-Link
|
|
10
|
+
im Fehler-Banner — der existierende `requestEmailVerification`-Endpoint
|
|
11
|
+
wird direkt aufgerufen, der Banner wechselt nach Erfolg zum Info-Variant
|
|
12
|
+
("Wir haben dir eine neue Bestätigungs-Mail geschickt.").
|
|
13
|
+
|
|
14
|
+
UX-Details:
|
|
15
|
+
|
|
16
|
+
- Bei 429 → inline-Hint "Bitte warte kurz und versuche es erneut."
|
|
17
|
+
- Bei Netzwerk/sonstigen Fehlern → inline-Hint "Konnte nicht senden."
|
|
18
|
+
- Anti-Typo-Gate: ändert der User die Email-Eingabe nach dem Login-Fail,
|
|
19
|
+
verschwindet der Resend-Link — sonst würde Resend silent-success an die
|
|
20
|
+
geänderte (potentiell typoed) Adresse gehen ohne User-Feedback.
|
|
21
|
+
- Andere Failure-Codes (invalid_credentials etc.) zeigen weiterhin keinen
|
|
22
|
+
Resend-Link.
|
|
23
|
+
|
|
24
|
+
i18n: 4 neue Keys (DE+EN) im `auth.login.resend*`-Namespace, additive.
|
|
25
|
+
Apps die ihre Translations override-en müssen nichts ändern.
|
|
26
|
+
|
|
27
|
+
Additive UI-Feature — keine API-Breaks, keine Schema-Migration.
|
|
28
|
+
|
|
29
|
+
- Updated dependencies [010b410]
|
|
30
|
+
- @cosmicdrift/kumiko-framework@0.4.1
|
|
31
|
+
- @cosmicdrift/kumiko-headless@0.4.1
|
|
32
|
+
|
|
33
|
+
## 0.4.0
|
|
34
|
+
|
|
35
|
+
### Minor Changes
|
|
36
|
+
|
|
37
|
+
- 825e7d2: Visual-Tree V.1.4 → V.1.6 — Feature-complete Editor + Folder-Hierarchy + Roving-tabindex.
|
|
38
|
+
|
|
39
|
+
**V.1.4** — explicit `folder?: string` Schema-Field auf text-block-entity. Slug bleibt
|
|
40
|
+
kebab-only validiert, Folder explizit gesetzt. Tree gruppiert via `groupBlocksByFolder`
|
|
41
|
+
(ersetzt `groupBlocksBySlugPrefix`). `Subscribe<T>` Signature um optional `emitError`
|
|
42
|
+
erweitert für explicit async-error-Pfade. ProviderBranch zeigt Error-Banner mit
|
|
43
|
+
Retry-Button. Drift-Test pinnt seedTextBlock-vs-set.write Slug-Validation.
|
|
44
|
+
|
|
45
|
+
**V.1.4b** — URL-State-Routing für Editor-Target via `nav.searchParams`. F5 + Back-Button
|
|
46
|
+
stellen den Editor-State wieder her. Format: `?t=text-content:edit&a_slug=...&a_lang=...`.
|
|
47
|
+
Plus `useDispatchTarget` hook ersetzt globalen `dispatchTarget` als empfohlenen Production-
|
|
48
|
+
Pfad (legacy bleibt für Test-Hooks).
|
|
49
|
+
|
|
50
|
+
**V.1.5** — Arrow-Key-Navigation (`<aside role="tree">`, ARIA-tree-Pattern) + SSE-driven
|
|
51
|
+
Tree-Refresh. `ClientFeatureDefinition.treeEntities?: string[]` listet Entity-Namen pro
|
|
52
|
+
Provider; live-events triggern provider-re-mount → Stale-Tree-state="stub"→"filled"
|
|
53
|
+
flippt nach save automatisch.
|
|
54
|
+
|
|
55
|
+
**V.1.5c+d** — Active-Node-Highlight (explicit blue + 2px border-l + scrollIntoView),
|
|
56
|
+
VS-Code-Polish (compact spacing, focus-visible, folder-icon-color text-amber, indent-
|
|
57
|
+
guides per ancestor-depth), Folder-Wrapper für legal-pages ("📁 Legal" + slug-first
|
|
58
|
+
Verschachtelung) und text-content ("📁 Content").
|
|
59
|
+
|
|
60
|
+
**V.1.6** — Multi-level Folder-Splitting (`folder="page/marketing"` → nested folders,
|
|
61
|
+
walk-or-create-pattern, folder/leaf-collision-tolerant). Roving-tabindex (nur focused-
|
|
62
|
+
treeitem hat tabIndex=0, Tab cyclt aus dem Tree raus).
|
|
63
|
+
|
|
64
|
+
35/35 kumiko check PASS, 13/13 group-blocks + 22/22 text-content integration tests grün.
|
|
65
|
+
Browser + Keyboard lokal validated.
|
|
66
|
+
|
|
67
|
+
**Breaking**: `TreeContext` Type entfernt (V.1.2 SR2-Rip — war nie genutzt). Provider sind
|
|
68
|
+
session-bound: `TreeChildrenSubscribe = () => Subscribe<T>` statt `(ctx) => Subscribe<T>`.
|
|
69
|
+
|
|
70
|
+
**V.1.7-Followups**: useEffect-deps in VisualTree-focus-init (Performance), Cancellation-
|
|
71
|
+
Token in TreeProvider's fetch (emit-after-unmount-warning), inline-rename, drag-drop,
|
|
72
|
+
file-icons per slug-extension, parent-jump bei ArrowLeft auf collapsed-item.
|
|
73
|
+
|
|
74
|
+
### Patch Changes
|
|
75
|
+
|
|
76
|
+
- Updated dependencies [825e7d2]
|
|
77
|
+
- @cosmicdrift/kumiko-framework@0.4.0
|
|
78
|
+
- @cosmicdrift/kumiko-headless@0.4.0
|
|
79
|
+
|
|
3
80
|
## 0.3.0
|
|
4
81
|
|
|
5
82
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.4.1",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.4.1",
|
|
20
20
|
"react": "^19.2.6"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ConfigCascade, ConfigValueSource } from "@cosmicdrift/kumiko-framework/engine";
|
|
1
2
|
import type {
|
|
2
3
|
ActionFormScreenDefinition,
|
|
3
4
|
ConfigEditScreenDefinition,
|
|
@@ -918,8 +919,12 @@ function ActionFormBody({
|
|
|
918
919
|
// Banner/Submit-Button-State sind identisch zu entityEdit. Der einzige
|
|
919
920
|
// Pfad-Unterschied ist customSubmit das mehrere config:write:set-Calls
|
|
920
921
|
// orchestriert.
|
|
922
|
+
//
|
|
923
|
+
// Response enthält seit config-seeding auch `source` (z.B. "system-row")
|
|
924
|
+
// für das ConfigSourceBadge. Type wird lokal gehalten da er nur hier
|
|
925
|
+
// relevant ist — kein eigener Export nötig.
|
|
921
926
|
type ConfigValueResponse = Readonly<
|
|
922
|
-
Record<string, { value: string | number | boolean | undefined; scope: string }>
|
|
927
|
+
Record<string, { value: string | number | boolean | undefined; scope: string; source: string }>
|
|
923
928
|
>;
|
|
924
929
|
|
|
925
930
|
function ConfigEditBody({
|
|
@@ -931,7 +936,7 @@ function ConfigEditBody({
|
|
|
931
936
|
readonly screen: ConfigEditScreenDefinition;
|
|
932
937
|
readonly translate?: Translate;
|
|
933
938
|
}): ReactNode {
|
|
934
|
-
const { Banner } = usePrimitives();
|
|
939
|
+
const { Banner, ConfigSourceBadge, ConfigCascadeView } = usePrimitives();
|
|
935
940
|
const dispatcher = useDispatcher();
|
|
936
941
|
|
|
937
942
|
// Detail-Load: config:query:values returnt ALLE Keys des Tenants.
|
|
@@ -939,6 +944,8 @@ function ConfigEditBody({
|
|
|
939
944
|
// unsere Form-Field-Werte.
|
|
940
945
|
const valuesQuery = useQuery<ConfigValueResponse>("config:query:values", {});
|
|
941
946
|
|
|
947
|
+
const cascadeQuery = useQuery<Record<string, ConfigCascade>>("config:query:cascade", {});
|
|
948
|
+
|
|
942
949
|
const synthEntity = useMemo(() => synthesizeConfigEditEntity(screen.fields), [screen.fields]);
|
|
943
950
|
const synthScreen = useMemo(() => synthesizeConfigEditScreen(screen), [screen]);
|
|
944
951
|
|
|
@@ -978,6 +985,34 @@ function ConfigEditBody({
|
|
|
978
985
|
return out as FormValues;
|
|
979
986
|
}, [valuesQuery.data, screen.fields, screen.configKeys]);
|
|
980
987
|
|
|
988
|
+
// Sources-Lookup: qualifiedKey → ConfigValueSource für das
|
|
989
|
+
// ConfigSourceBadge. Wird via fieldAppendix an RenderEdit
|
|
990
|
+
// durchgereicht.
|
|
991
|
+
const sources = useMemo<Record<string, ConfigValueSource>>(() => {
|
|
992
|
+
if (valuesQuery.data === null) return {};
|
|
993
|
+
const out: Record<string, ConfigValueSource> = {};
|
|
994
|
+
for (const [shortName, qualified] of Object.entries(screen.configKeys)) {
|
|
995
|
+
const source = valuesQuery.data[qualified]?.source as ConfigValueSource | undefined; // @cast-boundary engine-payload
|
|
996
|
+
if (source !== undefined) out[shortName] = source;
|
|
997
|
+
}
|
|
998
|
+
return out;
|
|
999
|
+
}, [valuesQuery.data, screen.configKeys]);
|
|
1000
|
+
|
|
1001
|
+
// Cascade-Lookup: qualifiedKey → ConfigCascade für die
|
|
1002
|
+
// Cascade-Anzeige unter jedem Feld. Defensive `levels`-Shape-Check
|
|
1003
|
+
// damit fremde Query-Mocks (z.B. configEdit-Unit-Tests die nur
|
|
1004
|
+
// `config:query:values` mocken) nicht durch das Cascade-Rendering
|
|
1005
|
+
// crashen.
|
|
1006
|
+
const cascades = useMemo<Record<string, ConfigCascade>>(() => {
|
|
1007
|
+
if (!cascadeQuery.data) return {};
|
|
1008
|
+
const out: Record<string, ConfigCascade> = {};
|
|
1009
|
+
for (const [shortName, qualified] of Object.entries(screen.configKeys)) {
|
|
1010
|
+
const cascade = cascadeQuery.data[qualified];
|
|
1011
|
+
if (cascade && Array.isArray(cascade.levels)) out[shortName] = cascade;
|
|
1012
|
+
}
|
|
1013
|
+
return out;
|
|
1014
|
+
}, [cascadeQuery.data, screen.configKeys]);
|
|
1015
|
+
|
|
981
1016
|
// Multi-Write Submit: ein einzelner /api/batch Call mit N
|
|
982
1017
|
// config:write:set Commands. Server-side ist batch atomic
|
|
983
1018
|
// (transaktional: alle Writes in einer DB-TX, all-or-nothing) und
|
|
@@ -1041,6 +1076,27 @@ function ConfigEditBody({
|
|
|
1041
1076
|
customSubmit={customSubmit}
|
|
1042
1077
|
{...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
|
|
1043
1078
|
{...(translate !== undefined && { translate })}
|
|
1079
|
+
fieldAppendix={(fieldName: string) => {
|
|
1080
|
+
const source = sources[fieldName];
|
|
1081
|
+
const cascade = cascades[fieldName];
|
|
1082
|
+
return (
|
|
1083
|
+
<>
|
|
1084
|
+
{source ? <ConfigSourceBadge source={source} /> : null}
|
|
1085
|
+
{cascade !== undefined ? (
|
|
1086
|
+
<ConfigCascadeView
|
|
1087
|
+
cascade={cascade}
|
|
1088
|
+
screenScope={screen.scope}
|
|
1089
|
+
qualifiedKey={screen.configKeys[fieldName]}
|
|
1090
|
+
onReset={async (key, scope) => {
|
|
1091
|
+
await dispatcher.write("config:write:reset", { key, scope });
|
|
1092
|
+
await valuesQuery.refetch?.();
|
|
1093
|
+
await cascadeQuery.refetch?.();
|
|
1094
|
+
}}
|
|
1095
|
+
/>
|
|
1096
|
+
) : null}
|
|
1097
|
+
</>
|
|
1098
|
+
);
|
|
1099
|
+
}}
|
|
1044
1100
|
/>
|
|
1045
1101
|
);
|
|
1046
1102
|
}
|
|
@@ -56,6 +56,10 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
56
56
|
* damit "Speichern" durch domain-spezifischere Strings ersetzt
|
|
57
57
|
* werden kann ("Genehmigen" / "Versenden" / etc.). */
|
|
58
58
|
readonly submitLabel?: string;
|
|
59
|
+
/** Pro-Field-Zusatz-Inhalt nach dem Label (z.B. ConfigSourceBadge).
|
|
60
|
+
* Wird mit dem Field-Namen aufgerufen, returnt ReactNode oder
|
|
61
|
+
* undefined. */
|
|
62
|
+
readonly fieldAppendix?: (fieldName: string) => ReactNode | undefined;
|
|
59
63
|
};
|
|
60
64
|
|
|
61
65
|
function deriveFormFields<TValues extends FormValues, TCtx>(
|
|
@@ -100,6 +104,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
100
104
|
onCancel,
|
|
101
105
|
onReload,
|
|
102
106
|
submitLabel,
|
|
107
|
+
fieldAppendix,
|
|
103
108
|
} = props;
|
|
104
109
|
const { customSubmit } = props;
|
|
105
110
|
// Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
|
|
@@ -267,6 +272,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
267
272
|
}}
|
|
268
273
|
GridCell={GridCell}
|
|
269
274
|
featureName={featureName}
|
|
275
|
+
{...(fieldAppendix !== undefined && {
|
|
276
|
+
labelAppendix: fieldAppendix(field.field),
|
|
277
|
+
fieldAppendix: fieldAppendix(field.field),
|
|
278
|
+
})}
|
|
270
279
|
/>
|
|
271
280
|
))}
|
|
272
281
|
</Grid>
|
|
@@ -323,6 +332,8 @@ type GridCellForFieldProps = {
|
|
|
323
332
|
/** Tier 2.7e-3: durchgereicht damit Reference-Felder die richtige
|
|
324
333
|
* Lookup-Query-QN bauen können (`<feature>:query:<refEntity>:list`). */
|
|
325
334
|
readonly featureName: string;
|
|
335
|
+
readonly labelAppendix?: ReactNode;
|
|
336
|
+
readonly fieldAppendix?: ReactNode;
|
|
326
337
|
};
|
|
327
338
|
|
|
328
339
|
function GridCellForField({
|
|
@@ -332,6 +343,8 @@ function GridCellForField({
|
|
|
332
343
|
onChange,
|
|
333
344
|
GridCell,
|
|
334
345
|
featureName,
|
|
346
|
+
labelAppendix,
|
|
347
|
+
fieldAppendix,
|
|
335
348
|
}: GridCellForFieldProps): ReactNode {
|
|
336
349
|
const effectiveSpan = field.span !== undefined ? Math.min(field.span, columns) : 1;
|
|
337
350
|
return (
|
|
@@ -341,6 +354,8 @@ function GridCellForField({
|
|
|
341
354
|
{...(issues !== undefined && { issues })}
|
|
342
355
|
onChange={onChange}
|
|
343
356
|
featureName={featureName}
|
|
357
|
+
{...(labelAppendix !== undefined && { labelAppendix })}
|
|
358
|
+
{...(fieldAppendix !== undefined && { fieldAppendix })}
|
|
344
359
|
/>
|
|
345
360
|
</GridCell>
|
|
346
361
|
);
|
|
@@ -14,14 +14,27 @@ import { usePrimitives } from "../primitives";
|
|
|
14
14
|
export type RenderFieldProps = {
|
|
15
15
|
readonly field: EditFieldViewModel;
|
|
16
16
|
readonly issues?: readonly FieldIssue[];
|
|
17
|
-
readonly onChange: (
|
|
17
|
+
readonly onChange: (val: unknown) => void;
|
|
18
18
|
/** Nur bei type:"reference" relevant — Feature-Name für die Lookup-
|
|
19
19
|
* Query-QN (`<feature>:query:<refEntity>:list`). Andere Field-Types
|
|
20
20
|
* ignorieren das Prop. */
|
|
21
21
|
readonly featureName?: string;
|
|
22
|
+
/** Optionaler Zusatz-Inhalt der nach dem Label gerendert wird (z.B.
|
|
23
|
+
* ConfigSourceBadge). */
|
|
24
|
+
readonly labelAppendix?: ReactNode;
|
|
25
|
+
/** Optionaler Zusatz-Inhalt der nach dem Input gerendert wird (z.B.
|
|
26
|
+
* ConfigCascade). */
|
|
27
|
+
readonly fieldAppendix?: ReactNode;
|
|
22
28
|
};
|
|
23
29
|
|
|
24
|
-
export function RenderField({
|
|
30
|
+
export function RenderField({
|
|
31
|
+
field,
|
|
32
|
+
issues,
|
|
33
|
+
onChange,
|
|
34
|
+
featureName,
|
|
35
|
+
labelAppendix,
|
|
36
|
+
fieldAppendix,
|
|
37
|
+
}: RenderFieldProps): ReactNode {
|
|
25
38
|
const { Field, Input } = usePrimitives();
|
|
26
39
|
if (!field.visible) return null;
|
|
27
40
|
|
|
@@ -51,6 +64,8 @@ export function RenderField({ field, issues, onChange, featureName }: RenderFiel
|
|
|
51
64
|
label={field.label}
|
|
52
65
|
required={field.required}
|
|
53
66
|
{...(issues !== undefined && { issues })}
|
|
67
|
+
{...(labelAppendix !== undefined && { labelAppendix })}
|
|
68
|
+
{...(fieldAppendix !== undefined && { fieldAppendix })}
|
|
54
69
|
testId={`field-${field.field}`}
|
|
55
70
|
>
|
|
56
71
|
{control}
|
package/src/primitives.tsx
CHANGED
|
@@ -29,6 +29,11 @@
|
|
|
29
29
|
// nicht), aber der App-Code hat ein einheitliches Primitive-Vokabular
|
|
30
30
|
// über Core + Custom.
|
|
31
31
|
|
|
32
|
+
import type {
|
|
33
|
+
ConfigCascade,
|
|
34
|
+
ConfigScope,
|
|
35
|
+
ConfigValueSource,
|
|
36
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
32
37
|
import type {
|
|
33
38
|
FieldIssue,
|
|
34
39
|
ListColumnViewModel,
|
|
@@ -88,6 +93,8 @@ export type FieldProps = {
|
|
|
88
93
|
readonly label: string;
|
|
89
94
|
readonly required?: boolean;
|
|
90
95
|
readonly issues?: readonly FieldIssue[];
|
|
96
|
+
readonly labelAppendix?: ReactNode;
|
|
97
|
+
readonly fieldAppendix?: ReactNode;
|
|
91
98
|
readonly children: ReactNode;
|
|
92
99
|
readonly testId?: string;
|
|
93
100
|
};
|
|
@@ -466,6 +473,22 @@ export type DialogProps = {
|
|
|
466
473
|
readonly testId?: string;
|
|
467
474
|
};
|
|
468
475
|
|
|
476
|
+
/** Source-badge for one cascade step (User / Tenant / System / …).
|
|
477
|
+
* Used inline next to a config value to indicate where it came from. */
|
|
478
|
+
export type ConfigSourceBadgeProps = {
|
|
479
|
+
readonly source: ConfigValueSource;
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
/** Collapsible cascade-view that lives under a config-edit input.
|
|
483
|
+
* Shows the active level inline; click expands the full cascade and
|
|
484
|
+
* exposes a Reset-button if the active level matches `screenScope`. */
|
|
485
|
+
export type ConfigCascadeViewProps = {
|
|
486
|
+
readonly cascade: ConfigCascade;
|
|
487
|
+
readonly screenScope: ConfigScope;
|
|
488
|
+
readonly onReset?: (key: string, scope: ConfigScope) => void;
|
|
489
|
+
readonly qualifiedKey?: string;
|
|
490
|
+
};
|
|
491
|
+
|
|
469
492
|
// ---- Core-Registry (Kumiko-eigene Primitives) ----
|
|
470
493
|
|
|
471
494
|
export type CorePrimitives = {
|
|
@@ -481,6 +504,8 @@ export type CorePrimitives = {
|
|
|
481
504
|
readonly Text: ComponentType<TextProps>;
|
|
482
505
|
readonly Heading: ComponentType<HeadingProps>;
|
|
483
506
|
readonly Dialog: ComponentType<DialogProps>;
|
|
507
|
+
readonly ConfigSourceBadge: ComponentType<ConfigSourceBadgeProps>;
|
|
508
|
+
readonly ConfigCascadeView: ComponentType<ConfigCascadeViewProps>;
|
|
484
509
|
};
|
|
485
510
|
|
|
486
511
|
/** Offene Extension-Zone für App-eigene Primitives. Devs erweitern
|