@cosmicdrift/kumiko-renderer 0.37.0 → 0.39.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 +3 -3
- package/src/app/__tests__/feature-schema.test.ts +11 -2
- package/src/app/kumiko-screen.tsx +16 -17
- package/src/app/write-failed-error.ts +2 -2
- package/src/components/__tests__/render-field-app-locale.test.tsx +24 -2
- package/src/components/render-edit.tsx +18 -3
- package/src/components/render-field.tsx +1 -0
- package/src/i18n-defaults.ts +35 -0
- package/src/i18n.tsx +15 -0
- package/src/index.ts +1 -0
- package/src/primitives.tsx +12 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
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.38.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.38.0",
|
|
20
20
|
"react": "^19.2.6"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// feature-schema Pure-Logik Tests (Phase 1, test-luecken-integration, Tier 1).
|
|
2
|
-
//
|
|
3
1
|
// toAppSchema normalisiert FeatureSchema → AppSchema (idempotent),
|
|
4
2
|
// isAppSchema diskriminiert die beiden Formen. Zero source-change.
|
|
5
3
|
|
|
@@ -29,3 +27,14 @@ describe("isAppSchema", () => {
|
|
|
29
27
|
expect(isAppSchema(feature)).toBe(false);
|
|
30
28
|
});
|
|
31
29
|
});
|
|
30
|
+
|
|
31
|
+
describe("toAppSchema — workspaces-Hoist (Legacy-Form)", () => {
|
|
32
|
+
test("hebt feature-lokale workspaces auf App-Ebene und entfernt sie vom Feature", () => {
|
|
33
|
+
const ws = [{ definition: { id: "admin", label: "Admin", navs: [] }, navMembers: [] }];
|
|
34
|
+
const withWs: FeatureSchema = { ...feature, workspaces: ws };
|
|
35
|
+
const app = toAppSchema(withWs);
|
|
36
|
+
expect(app.workspaces).toEqual(ws);
|
|
37
|
+
expect(app.features[0]).not.toHaveProperty("workspaces");
|
|
38
|
+
expect(app.features[0]?.featureName).toBe("tasks");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -1055,7 +1055,7 @@ function ConfigEditBody({
|
|
|
1055
1055
|
}, [valuesQuery.data, screen.fields, screen.configKeys]);
|
|
1056
1056
|
|
|
1057
1057
|
// Sources-Lookup: qualifiedKey → ConfigValueSource für das
|
|
1058
|
-
// ConfigSourceBadge. Wird via
|
|
1058
|
+
// ConfigSourceBadge. Wird via labelAppendix an RenderEdit
|
|
1059
1059
|
// durchgereicht.
|
|
1060
1060
|
const sources = useMemo<Record<string, ConfigValueSource>>(() => {
|
|
1061
1061
|
if (valuesQuery.data === null) return {};
|
|
@@ -1145,25 +1145,24 @@ function ConfigEditBody({
|
|
|
1145
1145
|
customSubmit={customSubmit}
|
|
1146
1146
|
{...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
|
|
1147
1147
|
{...(translate !== undefined && { translate })}
|
|
1148
|
-
|
|
1148
|
+
labelAppendix={(fieldName: string) => {
|
|
1149
1149
|
const source = sources[fieldName];
|
|
1150
|
+
return source ? <ConfigSourceBadge source={source} /> : undefined;
|
|
1151
|
+
}}
|
|
1152
|
+
fieldAppendix={(fieldName: string) => {
|
|
1150
1153
|
const cascade = cascades[fieldName];
|
|
1154
|
+
if (cascade === undefined) return undefined;
|
|
1151
1155
|
return (
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
{
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
await cascadeQuery.refetch?.();
|
|
1163
|
-
}}
|
|
1164
|
-
/>
|
|
1165
|
-
) : null}
|
|
1166
|
-
</>
|
|
1156
|
+
<ConfigCascadeView
|
|
1157
|
+
cascade={cascade}
|
|
1158
|
+
screenScope={screen.scope}
|
|
1159
|
+
qualifiedKey={screen.configKeys[fieldName]}
|
|
1160
|
+
onReset={async (key, scope) => {
|
|
1161
|
+
await dispatcher.write("config:write:reset", { key, scope });
|
|
1162
|
+
await valuesQuery.refetch?.();
|
|
1163
|
+
await cascadeQuery.refetch?.();
|
|
1164
|
+
}}
|
|
1165
|
+
/>
|
|
1167
1166
|
);
|
|
1168
1167
|
}}
|
|
1169
1168
|
/>
|
|
@@ -19,9 +19,9 @@ export class WriteFailedError extends Error {
|
|
|
19
19
|
// unverändert wenn nicht — Renderer-Convention), sonst message, zuletzt code.
|
|
20
20
|
export function dispatcherErrorText(
|
|
21
21
|
error: DispatcherError,
|
|
22
|
-
translate: (key: string) => string,
|
|
22
|
+
translate: (key: string, params?: Readonly<Record<string, unknown>>) => string,
|
|
23
23
|
): string {
|
|
24
|
-
const translated = translate(error.i18nKey);
|
|
24
|
+
const translated = translate(error.i18nKey, error.i18nParams);
|
|
25
25
|
if (translated !== error.i18nKey) return translated;
|
|
26
26
|
return error.message !== "" ? error.message : error.code;
|
|
27
27
|
}
|
|
@@ -52,12 +52,24 @@ function moneyField(): EditFieldViewModel {
|
|
|
52
52
|
};
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
function
|
|
55
|
+
function dateField(): EditFieldViewModel {
|
|
56
|
+
return {
|
|
57
|
+
field: "dueAt",
|
|
58
|
+
label: "Fällig",
|
|
59
|
+
type: "date",
|
|
60
|
+
value: "2026-01-15",
|
|
61
|
+
visible: true,
|
|
62
|
+
readOnly: false,
|
|
63
|
+
required: false,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function renderUnderLocale(locale: string, field: EditFieldViewModel = moneyField()): void {
|
|
56
68
|
captured = undefined;
|
|
57
69
|
render(
|
|
58
70
|
<LocaleProvider resolver={createStaticLocaleResolver({ locale })}>
|
|
59
71
|
<PrimitivesProvider value={testPrimitives}>
|
|
60
|
-
<RenderField field={
|
|
72
|
+
<RenderField field={field} onChange={() => {}} />
|
|
61
73
|
</PrimitivesProvider>
|
|
62
74
|
</LocaleProvider>,
|
|
63
75
|
);
|
|
@@ -72,6 +84,16 @@ describe("RenderField — App-Locale an money durchreichen", () => {
|
|
|
72
84
|
|
|
73
85
|
test("ein anderes App-Locale wird ebenso durchgereicht (en-US)", () => {
|
|
74
86
|
renderUnderLocale("en-US");
|
|
87
|
+
// Unconditional zuerst — ohne sie wäre der Test bei falschem kind leer.
|
|
88
|
+
expect(captured?.kind).toBe("money");
|
|
75
89
|
if (captured?.kind === "money") expect(captured.locale).toBe("en-US");
|
|
76
90
|
});
|
|
77
91
|
});
|
|
92
|
+
|
|
93
|
+
describe("RenderField — App-Locale an date durchreichen", () => {
|
|
94
|
+
test("date ohne field.locale bekommt das App-Locale (de-DE)", () => {
|
|
95
|
+
renderUnderLocale("de-DE", dateField());
|
|
96
|
+
expect(captured?.kind).toBe("date");
|
|
97
|
+
if (captured?.kind === "date") expect(captured.locale).toBe("de-DE");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -70,7 +70,11 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
70
70
|
* damit "Speichern" durch domain-spezifischere Strings ersetzt
|
|
71
71
|
* werden kann ("Genehmigen" / "Versenden" / etc.). */
|
|
72
72
|
readonly submitLabel?: string;
|
|
73
|
-
/** Pro-Field-Zusatz-Inhalt nach dem Label (z.B. ConfigSourceBadge).
|
|
73
|
+
/** Pro-Field-Zusatz-Inhalt inline nach dem Label (z.B. ConfigSourceBadge).
|
|
74
|
+
* Wird mit dem Field-Namen aufgerufen, returnt ReactNode oder
|
|
75
|
+
* undefined. */
|
|
76
|
+
readonly labelAppendix?: (fieldName: string) => ReactNode | undefined;
|
|
77
|
+
/** Pro-Field-Zusatz-Inhalt unter dem Input (z.B. ConfigCascadeView).
|
|
74
78
|
* Wird mit dem Field-Namen aufgerufen, returnt ReactNode oder
|
|
75
79
|
* undefined. */
|
|
76
80
|
readonly fieldAppendix?: (fieldName: string) => ReactNode | undefined;
|
|
@@ -175,6 +179,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
175
179
|
onCancel,
|
|
176
180
|
onReload,
|
|
177
181
|
submitLabel,
|
|
182
|
+
labelAppendix,
|
|
178
183
|
fieldAppendix,
|
|
179
184
|
entityId: entityIdProp,
|
|
180
185
|
extensionInitialValues,
|
|
@@ -342,8 +347,16 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
342
347
|
/>
|
|
343
348
|
);
|
|
344
349
|
}
|
|
350
|
+
// Section-Header unterdrücken wenn er den Form-Titel der
|
|
351
|
+
// Action-Bar 1:1 wiederholen würde (typisch bei Single-Section-
|
|
352
|
+
// ActionForms, deren Section-Label = Screen-Titel ist).
|
|
353
|
+
const sectionTitle = section.title === formTitle ? undefined : section.title;
|
|
345
354
|
return (
|
|
346
|
-
<Section
|
|
355
|
+
<Section
|
|
356
|
+
key={section.title}
|
|
357
|
+
{...(sectionTitle !== undefined && { title: sectionTitle })}
|
|
358
|
+
testId={`section-${section.title}`}
|
|
359
|
+
>
|
|
347
360
|
<Grid columns={section.columns}>
|
|
348
361
|
{section.fields.map((field: EditFieldViewModel) => (
|
|
349
362
|
<GridCellForField
|
|
@@ -356,8 +369,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
356
369
|
}}
|
|
357
370
|
GridCell={GridCell}
|
|
358
371
|
featureName={featureName}
|
|
372
|
+
{...(labelAppendix !== undefined && {
|
|
373
|
+
labelAppendix: labelAppendix(field.field),
|
|
374
|
+
})}
|
|
359
375
|
{...(fieldAppendix !== undefined && {
|
|
360
|
-
labelAppendix: fieldAppendix(field.field),
|
|
361
376
|
fieldAppendix: fieldAppendix(field.field),
|
|
362
377
|
})}
|
|
363
378
|
/>
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -71,6 +71,25 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
71
71
|
"kumiko.validation.too-short": "Zu kurz (mindestens {min} Zeichen).",
|
|
72
72
|
"kumiko.validation.too-long": "Zu lang (höchstens {max} Zeichen).",
|
|
73
73
|
"kumiko.validation.out-of-range": "Wert außerhalb des erlaubten Bereichs.",
|
|
74
|
+
|
|
75
|
+
// errors.validation.* — der kanonische Key-Namespace den Server
|
|
76
|
+
// (ValidationError) und Client (zod-bridge) für Field-Issues
|
|
77
|
+
// erzeugen. Codes = Zod-4-Issue-Codes + Framework-eigene.
|
|
78
|
+
"errors.validation.invalid_type": "Ungültiger Wert.",
|
|
79
|
+
"errors.validation.too_small": "Zu klein oder zu kurz (Minimum: {minimum}).",
|
|
80
|
+
"errors.validation.too_big": "Zu groß oder zu lang (Maximum: {maximum}).",
|
|
81
|
+
"errors.validation.invalid_format": "Ungültiges Format.",
|
|
82
|
+
"errors.validation.not_multiple_of": "Muss ein Vielfaches von {divisor} sein.",
|
|
83
|
+
"errors.validation.unrecognized_keys": "Unbekannte Felder.",
|
|
84
|
+
"errors.validation.invalid_union": "Ungültiger Wert.",
|
|
85
|
+
"errors.validation.invalid_key": "Ungültiger Schlüssel.",
|
|
86
|
+
"errors.validation.invalid_element": "Ungültiger Eintrag.",
|
|
87
|
+
"errors.validation.invalid_value": "Ungültige Auswahl.",
|
|
88
|
+
"errors.validation.custom": "Ungültiger Wert.",
|
|
89
|
+
"errors.validation.unexpected_field": "Unbekanntes Feld.",
|
|
90
|
+
"errors.validation.out_of_bounds": "Wert außerhalb des erlaubten Bereichs.",
|
|
91
|
+
"errors.validation.invalid_option": "Ungültige Auswahl.",
|
|
92
|
+
"errors.validation.failed": "Validierung fehlgeschlagen.",
|
|
74
93
|
},
|
|
75
94
|
en: {
|
|
76
95
|
"kumiko.actions.save": "Save",
|
|
@@ -120,5 +139,21 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
120
139
|
"kumiko.validation.too-short": "Too short (at least {min} characters).",
|
|
121
140
|
"kumiko.validation.too-long": "Too long (at most {max} characters).",
|
|
122
141
|
"kumiko.validation.out-of-range": "Value out of allowed range.",
|
|
142
|
+
|
|
143
|
+
"errors.validation.invalid_type": "Invalid value.",
|
|
144
|
+
"errors.validation.too_small": "Too small or too short (minimum: {minimum}).",
|
|
145
|
+
"errors.validation.too_big": "Too big or too long (maximum: {maximum}).",
|
|
146
|
+
"errors.validation.invalid_format": "Invalid format.",
|
|
147
|
+
"errors.validation.not_multiple_of": "Must be a multiple of {divisor}.",
|
|
148
|
+
"errors.validation.unrecognized_keys": "Unknown fields.",
|
|
149
|
+
"errors.validation.invalid_union": "Invalid value.",
|
|
150
|
+
"errors.validation.invalid_key": "Invalid key.",
|
|
151
|
+
"errors.validation.invalid_element": "Invalid entry.",
|
|
152
|
+
"errors.validation.invalid_value": "Invalid choice.",
|
|
153
|
+
"errors.validation.custom": "Invalid value.",
|
|
154
|
+
"errors.validation.unexpected_field": "Unknown field.",
|
|
155
|
+
"errors.validation.out_of_bounds": "Value out of allowed range.",
|
|
156
|
+
"errors.validation.invalid_option": "Invalid choice.",
|
|
157
|
+
"errors.validation.failed": "Validation failed.",
|
|
123
158
|
},
|
|
124
159
|
};
|
package/src/i18n.tsx
CHANGED
|
@@ -25,6 +25,21 @@ export type TranslationBundle = Readonly<Record<string, string>>;
|
|
|
25
25
|
/** Map von Locale-Code (BCP-47, z.B. `"de"`, `"en-US"`) → Bundle. */
|
|
26
26
|
export type TranslationsByLocale = Readonly<Record<string, TranslationBundle>>;
|
|
27
27
|
|
|
28
|
+
/** Merged zwei TranslationsByLocale-Maps — der override gewinnt pro Key,
|
|
29
|
+
* die Locales werden zusammengeführt. Standard-Baustein für Client-
|
|
30
|
+
* Plugins, die App-Overrides über ihre Default-Bundles legen. */
|
|
31
|
+
export function mergeTranslations(
|
|
32
|
+
base: TranslationsByLocale,
|
|
33
|
+
override: TranslationsByLocale,
|
|
34
|
+
): TranslationsByLocale {
|
|
35
|
+
const locales = new Set([...Object.keys(base), ...Object.keys(override)]);
|
|
36
|
+
const merged: Record<string, Record<string, string>> = {};
|
|
37
|
+
for (const locale of locales) {
|
|
38
|
+
merged[locale] = { ...(base[locale] ?? {}), ...(override[locale] ?? {}) };
|
|
39
|
+
}
|
|
40
|
+
return merged;
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
type LocaleContextValue = {
|
|
29
44
|
readonly resolver: LocaleResolver;
|
|
30
45
|
readonly fallbackBundles: readonly TranslationsByLocale[];
|
package/src/index.ts
CHANGED
package/src/primitives.tsx
CHANGED
|
@@ -264,10 +264,16 @@ export type InputProps =
|
|
|
264
264
|
readonly kind: "timestamp";
|
|
265
265
|
readonly id: string;
|
|
266
266
|
readonly name: string;
|
|
267
|
-
/** ISO-8601 Datetime-String
|
|
268
|
-
*
|
|
267
|
+
/** ISO-8601 Datetime-String. UTC-Instant mit `Z`
|
|
268
|
+
* ("2026-04-25T13:45:00Z") oder Wall-Clock ohne Offset
|
|
269
|
+
* ("2026-04-25T13:45", nur bei wallClock). Empty-State = `""`.
|
|
270
|
+
* Web nutzt `<input type="datetime-local">` und konvertiert. */
|
|
269
271
|
readonly value: string;
|
|
270
272
|
readonly onChange: (v: string | undefined) => void;
|
|
273
|
+
/** true = locatedTimestamp (Wall-Clock ohne Offset, Server
|
|
274
|
+
* validiert z.iso.datetime({local:true})). false/undefined =
|
|
275
|
+
* UTC-Instant, onChange MUSS mit `Z`-Suffix emittieren. */
|
|
276
|
+
readonly wallClock?: boolean;
|
|
271
277
|
readonly disabled?: boolean;
|
|
272
278
|
readonly required?: boolean;
|
|
273
279
|
readonly hasError?: boolean;
|
|
@@ -404,7 +410,10 @@ export type FormProps = {
|
|
|
404
410
|
* View mit Header-Text. Native-Impls können den Title als Accordion
|
|
405
411
|
* oder Collapsible rendern. */
|
|
406
412
|
export type SectionProps = {
|
|
407
|
-
|
|
413
|
+
/** Optional — ohne Titel rendert die Section nur die Gruppierung
|
|
414
|
+
* (kein Header). RenderEdit lässt ihn weg, wenn er den Screen-Titel
|
|
415
|
+
* der Action-Bar 1:1 wiederholen würde. */
|
|
416
|
+
readonly title?: string;
|
|
408
417
|
readonly children: ReactNode;
|
|
409
418
|
readonly testId?: string;
|
|
410
419
|
};
|