@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.37.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.35.0",
19
- "@cosmicdrift/kumiko-headless": "0.35.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 fieldAppendix an RenderEdit
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
- fieldAppendix={(fieldName: string) => {
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
- {source ? <ConfigSourceBadge source={source} /> : null}
1154
- {cascade !== undefined ? (
1155
- <ConfigCascadeView
1156
- cascade={cascade}
1157
- screenScope={screen.scope}
1158
- qualifiedKey={screen.configKeys[fieldName]}
1159
- onReset={async (key, scope) => {
1160
- await dispatcher.write("config:write:reset", { key, scope });
1161
- await valuesQuery.refetch?.();
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 renderUnderLocale(locale: string): void {
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={moneyField()} onChange={() => {}} />
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 key={section.title} title={section.title} testId={`section-${section.title}`}>
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
  />
@@ -261,6 +261,7 @@ function renderInput({
261
261
  {...common}
262
262
  value={stringValue(field.value)}
263
263
  onChange={(v) => onChange(v)}
264
+ {...(field.wallClock !== undefined && { wallClock: field.wallClock })}
264
265
  />
265
266
  );
266
267
  case "select": {
@@ -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
@@ -75,6 +75,7 @@ export type {
75
75
  export {
76
76
  createStaticLocaleResolver,
77
77
  LocaleProvider,
78
+ mergeTranslations,
78
79
  useLocale,
79
80
  useTranslation,
80
81
  } from "./i18n";
@@ -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 inkl. Zeit ("2026-04-25T13:45").
268
- * Empty-State = `""`. Web nutzt `<input type="datetime-local">`. */
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
- readonly title: string;
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
  };