@cosmicdrift/kumiko-renderer 0.38.0 → 0.40.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.38.0",
3
+ "version": "0.40.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.37.0",
19
- "@cosmicdrift/kumiko-headless": "0.37.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": {
@@ -5,13 +5,13 @@ import type {
5
5
  EntityDefinition,
6
6
  EntityEditScreenDefinition,
7
7
  EntityListScreenDefinition,
8
- FieldCondition,
9
8
  RowAction,
10
9
  RowActionWriteHandler,
11
10
  RowFieldExtractor,
12
11
  ScreenDefinition,
13
12
  ToolbarAction,
14
13
  } from "@cosmicdrift/kumiko-framework/ui-types";
14
+ import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
15
15
  import type {
16
16
  Command,
17
17
  FormSnapshot,
@@ -46,13 +46,6 @@ function evalRowExtractor(
46
46
  return Object.fromEntries(Object.entries(extractor.map).map(([to, from]) => [to, row[from]]));
47
47
  }
48
48
 
49
- function evalFieldCondition(cond: FieldCondition, values: Record<string, unknown>): boolean {
50
- if (typeof cond === "boolean") return cond;
51
- const val = values[cond.field];
52
- if ("eq" in cond) return val === cond.eq;
53
- return val !== cond.ne;
54
- }
55
-
56
49
  // KumikoScreen picks up a ScreenDefinition from the schema by qn and
57
50
  // routes it to the right renderer based on `screen.type`. Command
58
51
  // qualification (`<feature>:write:<entity>:create` etc.) happens here
@@ -695,9 +688,16 @@ function EntityListBody({
695
688
  // immer da (Provider von createKumikoApp).
696
689
  if (action.kind === "navigate") {
697
690
  // Default entityId für entityEdit-Targets: row["id"] wenn
698
- // kein expliziter entityId-Feldname gesetzt ist.
691
+ // kein expliziter entityId-Feldname gesetzt ist. Nur für Targets
692
+ // DERSELBEN Entity — ein Cross-Entity-Edit-Screen bekäme sonst die
693
+ // falsche row.id injiziert, und "Duplicate → Create"-Patterns
694
+ // würden in den Update-Mode gezwungen. Cross-Entity-Navigation
695
+ // setzt action.entityId explizit.
699
696
  const targetIsEntityEdit = schema.screens.some(
700
- (s) => s.type === "entityEdit" && lastSegment(s.id) === action.screen,
697
+ (s) =>
698
+ s.type === "entityEdit" &&
699
+ s.entity === screen.entity &&
700
+ lastSegment(s.id) === action.screen,
701
701
  );
702
702
  const actionVisible = action.visible;
703
703
  return {
@@ -731,6 +731,11 @@ function EntityListBody({
731
731
  // NACH navigate: pushState trägt keine Query — Params die
732
732
  // vor dem Push gesetzt werden, kleben an der ALTEN URL und
733
733
  // sind auf dem Ziel-Screen weg (actionForm-Prefill leer).
734
+ // Bekannte Kante (bewusst offen): zielt die Action auf den
735
+ // AKTUELLEN pathname, short-circuit't pushPath ohne die Query
736
+ // zu leeren — die neuen Params mergen dann auf den alten
737
+ // ?-String. Für Row-Actions praktisch nicht erreichbar
738
+ // (Pfad differiert über entityId/screen).
734
739
  nav.setSearchParams(stringified);
735
740
  }
736
741
  },
@@ -777,7 +782,7 @@ function EntityListBody({
777
782
  };
778
783
  })
779
784
  .filter((a: DataTableRowAction | null): a is DataTableRowAction => a !== null);
780
- }, [screen.rowActions, effectiveTranslate, dispatcher, nav, schema.screens]);
785
+ }, [screen.rowActions, screen.entity, effectiveTranslate, dispatcher, nav, schema.screens]);
781
786
 
782
787
  // ToolbarActions: Schema → Resolved-Form (analog rowActions).
783
788
  // navigate-kind → useNav().navigate({ screenId }), writeHandler-kind
@@ -1055,7 +1060,7 @@ function ConfigEditBody({
1055
1060
  }, [valuesQuery.data, screen.fields, screen.configKeys]);
1056
1061
 
1057
1062
  // Sources-Lookup: qualifiedKey → ConfigValueSource für das
1058
- // ConfigSourceBadge. Wird via fieldAppendix an RenderEdit
1063
+ // ConfigSourceBadge. Wird via labelAppendix an RenderEdit
1059
1064
  // durchgereicht.
1060
1065
  const sources = useMemo<Record<string, ConfigValueSource>>(() => {
1061
1066
  if (valuesQuery.data === null) return {};
@@ -1145,25 +1150,26 @@ function ConfigEditBody({
1145
1150
  customSubmit={customSubmit}
1146
1151
  {...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
1147
1152
  {...(translate !== undefined && { translate })}
1148
- fieldAppendix={(fieldName: string) => {
1153
+ labelAppendix={(fieldName: string) => {
1149
1154
  const source = sources[fieldName];
1155
+ return source ? (
1156
+ <ConfigSourceBadge source={source} screenScope={screen.scope} />
1157
+ ) : undefined;
1158
+ }}
1159
+ fieldAppendix={(fieldName: string) => {
1150
1160
  const cascade = cascades[fieldName];
1161
+ if (cascade === undefined) return undefined;
1151
1162
  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
- </>
1163
+ <ConfigCascadeView
1164
+ cascade={cascade}
1165
+ screenScope={screen.scope}
1166
+ qualifiedKey={screen.configKeys[fieldName]}
1167
+ onReset={async (key, scope) => {
1168
+ await dispatcher.write("config:write:reset", { key, scope });
1169
+ await valuesQuery.refetch?.();
1170
+ await cascadeQuery.refetch?.();
1171
+ }}
1172
+ />
1167
1173
  );
1168
1174
  }}
1169
1175
  />
@@ -3,7 +3,11 @@ import type {
3
3
  EntityEditScreenDefinition,
4
4
  FieldCondition,
5
5
  } from "@cosmicdrift/kumiko-framework/ui-types";
6
- import { isExtensionEditSection, normalizeEditField } from "@cosmicdrift/kumiko-framework/ui-types";
6
+ import {
7
+ evalFieldCondition,
8
+ isExtensionEditSection,
9
+ normalizeEditField,
10
+ } from "@cosmicdrift/kumiko-framework/ui-types";
7
11
  import type {
8
12
  DispatcherError,
9
13
  EditExtensionSectionViewModel,
@@ -70,7 +74,11 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
70
74
  * damit "Speichern" durch domain-spezifischere Strings ersetzt
71
75
  * werden kann ("Genehmigen" / "Versenden" / etc.). */
72
76
  readonly submitLabel?: string;
73
- /** Pro-Field-Zusatz-Inhalt nach dem Label (z.B. ConfigSourceBadge).
77
+ /** Pro-Field-Zusatz-Inhalt inline nach dem Label (z.B. ConfigSourceBadge).
78
+ * Wird mit dem Field-Namen aufgerufen, returnt ReactNode oder
79
+ * undefined. */
80
+ readonly labelAppendix?: (fieldName: string) => ReactNode | undefined;
81
+ /** Pro-Field-Zusatz-Inhalt unter dem Input (z.B. ConfigCascadeView).
74
82
  * Wird mit dem Field-Namen aufgerufen, returnt ReactNode oder
75
83
  * undefined. */
76
84
  readonly fieldAppendix?: (fieldName: string) => ReactNode | undefined;
@@ -80,12 +88,8 @@ function toConditionValue<TValues extends FormValues, TCtx>(
80
88
  cond: FieldCondition,
81
89
  ): NonNullable<FieldConditions<TValues, TCtx>["visible"]> {
82
90
  if (typeof cond === "boolean") return cond;
83
- if ("eq" in cond) {
84
- const { field, eq } = cond;
85
- return (values: TValues) => (values as Record<string, unknown>)[field] === eq;
86
- }
87
- const { field, ne } = cond;
88
- return (values: TValues) => (values as Record<string, unknown>)[field] !== ne;
91
+ // @cast-boundary form-values: TValues ist strukturell ein Record.
92
+ return (values: TValues) => evalFieldCondition(cond, values as Record<string, unknown>);
89
93
  }
90
94
 
91
95
  function deriveFormFields<TValues extends FormValues, TCtx>(
@@ -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
  />
@@ -39,8 +39,9 @@ export function RenderField({
39
39
  const { Field, Input } = usePrimitives();
40
40
  // App-Locale (i18n) für money/date-Inputs — sonst fielen sie auf
41
41
  // navigator.language (Browser-Sprache) zurück statt der gewählten
42
- // App-Sprache. useLocale() wirft ohne LocaleProvider; ok, weil
43
- // RenderField nur unter RenderEdit im Kumiko-App-Tree läuft.
42
+ // App-Sprache. BEWUSSTE API-Verschärfung (seit 0.38): RenderField ist
43
+ // public exportiert und verlangt jetzt einen LocaleProvider —
44
+ // Standalone-Consumer/Tests müssen wrappen (createKumikoApp tut es).
44
45
  const appLocale = useLocale().locale();
45
46
  if (!field.visible) return null;
46
47
 
@@ -261,6 +262,7 @@ function renderInput({
261
262
  {...common}
262
263
  value={stringValue(field.value)}
263
264
  onChange={(v) => onChange(v)}
265
+ {...(field.wallClock !== undefined && { wallClock: field.wallClock })}
264
266
  />
265
267
  );
266
268
  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
  };
@@ -477,9 +486,16 @@ export type DialogProps = {
477
486
  };
478
487
 
479
488
  /** Source-badge for one cascade step (User / Tenant / System / …).
480
- * Used inline next to a config value to indicate where it came from. */
489
+ * Used inline next to a config value to indicate where it came from.
490
+ * Requires a LocaleProvider above it (labels run through useTranslation)
491
+ * — createKumikoApp wires one; standalone consumers must wrap. */
481
492
  export type ConfigSourceBadgeProps = {
482
493
  readonly source: ConfigValueSource;
494
+ /** Scope of the hosting screen. Non-system screens collapse sources
495
+ * ABOVE their scope (system-row/app-override/computed) into the neutral
496
+ * default badge — operator internals stay invisible to tenants, same
497
+ * rule as ConfigCascadeView's toDisplayLevels. */
498
+ readonly screenScope?: ConfigScope;
483
499
  };
484
500
 
485
501
  /** Collapsible cascade-view that lives under a config-edit input.