@cosmicdrift/kumiko-renderer 0.187.0 → 0.189.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.
@@ -1,5 +1,9 @@
1
1
  import type { EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
- import type { EditFieldViewModel, FieldIssue } from "@cosmicdrift/kumiko-headless";
2
+ import {
3
+ currencyDecimals,
4
+ type EditFieldViewModel,
5
+ type FieldIssue,
6
+ } from "@cosmicdrift/kumiko-headless";
3
7
  import { type ReactNode, useCallback, useMemo, useState } from "react";
4
8
  import { useAppFeatures } from "../app/app-features-context";
5
9
  import { toKebab } from "../app/qn";
@@ -319,7 +323,13 @@ function renderInput({
319
323
  } as const;
320
324
 
321
325
  switch (field.type) {
326
+ // decimal/bigInt are both plain numbers on the wire (fieldToZod:
327
+ // z.number() / z.number().int().safe()) — the number input's onChange
328
+ // already emits `number | undefined`, so no extra coercion is needed
329
+ // beyond what "number" already does (#1925).
322
330
  case "number":
331
+ case "decimal":
332
+ case "bigInt":
323
333
  return (
324
334
  <Input
325
335
  kind="number"
@@ -329,16 +339,44 @@ function renderInput({
329
339
  {...(field.icon !== undefined && { icon: field.icon })}
330
340
  />
331
341
  );
342
+ case "tz":
343
+ return (
344
+ <Input
345
+ kind="tz"
346
+ {...common}
347
+ value={stringValue(field.value)}
348
+ onChange={(v) => onChange(v)}
349
+ />
350
+ );
351
+ case "multiSelect": {
352
+ const rawOptions = field.options ?? [];
353
+ const labels = field.optionLabels;
354
+ const multiSelectOptions =
355
+ labels !== undefined
356
+ ? rawOptions.map((value: string) => ({ value, label: labels[value] ?? value }))
357
+ : rawOptions.map((value: string) => ({ value, label: value }));
358
+ const arrayValue = Array.isArray(field.value) ? (field.value as readonly string[]) : [];
359
+ return (
360
+ <Input
361
+ kind="combobox"
362
+ {...common}
363
+ multiple
364
+ value={arrayValue}
365
+ onChange={(v) => onChange(v)}
366
+ options={multiSelectOptions}
367
+ />
368
+ );
369
+ }
332
370
  case "money": {
333
- const moneyDef = field as unknown as { currency?: string; locale?: string };
371
+ const currency = field.currency ?? "EUR";
334
372
  return (
335
373
  <Input
336
374
  kind="money"
337
375
  {...common}
338
- value={numberValue(field.value)}
339
- onChange={(v) => onChange(v)}
340
- {...(moneyDef.currency !== undefined && { currency: moneyDef.currency })}
341
- locale={moneyDef.locale ?? appLocale}
376
+ value={moneyMinorValue(field.value, currency)}
377
+ onChange={(v) => onChange(moneyPayload(v, currency))}
378
+ currency={currency}
379
+ locale={appLocale}
342
380
  />
343
381
  );
344
382
  }
@@ -428,14 +466,19 @@ function renderInput({
428
466
  );
429
467
  }
430
468
  // embedded (without embeddedListCells — that's embeddedList, which has
431
- // had its own EmbeddedListField widget since #1838), jsonb and
432
- // multiSelect carry objects/arrays. Without a dedicated widget these
433
- // must NOT fall through to a text input: stringValue() turns them into
434
- // "[object Object]" / "a,b", and saving that overwrites the real data
435
- // with the mangled string (#1834).
469
+ // had its own EmbeddedListField widget since #1838) and jsonb carry
470
+ // arbitrary objects; files/images carry a FileRef-UUID array and have
471
+ // no multi-upload widget yet (deliberately deferred, #1925). Without a
472
+ // dedicated widget these must NOT fall through to a text input:
473
+ // stringValue() turns them into "[object Object]" / a comma-joined
474
+ // string, and saving that overwrites the real data with the mangled
475
+ // string (#1834). A `required: true` on any of these is caught loudly
476
+ // at boot (validateNoWidgetRequiredField in the framework package)
477
+ // instead of silently failing here.
436
478
  case "embedded":
437
479
  case "jsonb":
438
- case "multiSelect":
480
+ case "files":
481
+ case "images":
439
482
  return (
440
483
  <Banner id={id} variant="info">
441
484
  {t("kumiko.field.unsupported")}
@@ -443,8 +486,11 @@ function renderInput({
443
486
  );
444
487
  default: {
445
488
  // text + unknown scalar type → text input. If TextFieldDef.multiline
446
- // is set (the view-model carries it), the renderer switches to textarea.
447
- if (field.type === "text" && field.multiline) {
489
+ // is set (the view-model carries it), the renderer switches to
490
+ // textarea. longText always renders a textarea — that's the point of
491
+ // the type — regardless of whether `multiline` is set; `multiline`
492
+ // only supplies an optional `{ rows }` override for it (#1925).
493
+ if (field.type === "longText" || (field.type === "text" && field.multiline)) {
448
494
  const rows = typeof field.multiline === "object" ? field.multiline.rows : undefined;
449
495
  return (
450
496
  <Input
@@ -479,6 +525,32 @@ function numberValue(v: unknown): number | "" {
479
525
  return typeof v === "number" ? v : Number(v);
480
526
  }
481
527
 
528
+ // Read/initial value → MoneyInput's minor-units contract. The server's
529
+ // read shape is `{amount, currency, amountMinor}` in MAJOR units — deliberately
530
+ // NOT reading `amountMinor` here: the server derives it via a flat
531
+ // MINOR_UNIT_SCALE=100 (money.ts), which disagrees with currencyDecimals for
532
+ // zero-decimal currencies like JPY, so it would double-scale JPY amounts.
533
+ // Deriving from `amount * 10**currencyDecimals(currency)` keeps this the
534
+ // exact inverse of moneyPayload below. A bare number is passed through
535
+ // unchanged — legacy/malformed data, already in minor units.
536
+ function moneyMinorValue(v: unknown, currency: string): number | "" {
537
+ if (v === undefined || v === null || v === "") return "";
538
+ if (typeof v === "number") return v;
539
+ if (typeof v === "object") {
540
+ const amount = (v as { amount?: unknown }).amount;
541
+ if (typeof amount === "number") return Math.round(amount * 10 ** currencyDecimals(currency));
542
+ }
543
+ return "";
544
+ }
545
+
546
+ // MoneyInput's minor-units onChange → the server payload shape
547
+ // (`z.object({amount, currency})`, schema-builder.ts) — MAJOR units, no
548
+ // `amountMinor` (the server derives that itself on write).
549
+ function moneyPayload(minorUnits: number | undefined, currency: string): unknown {
550
+ if (minorUnits === undefined) return undefined;
551
+ return { amount: minorUnits / 10 ** currencyDecimals(currency), currency };
552
+ }
553
+
482
554
  // locatedTimestamp-Feldwert: das Read-Wrapper liefert `{ at, tz, utc }`; leer
483
555
  // (noch nicht gesetzt) → "" als Empty-Sentinel, analog money/timestamp.
484
556
  function locatedValue(v: unknown): { at: string; tz: string; utc?: string } | "" {
@@ -0,0 +1,39 @@
1
+ import { createContext, type ReactNode, useContext } from "react";
2
+
3
+ // Client-side draftId storage for RenderEdit's create-mode draftKey (issue
4
+ // #1913) — `${screen.id}:new:${draftId}` needs the same `draftId` across a
5
+ // same-tab reload so the wizard resumes the right row instead of the leader
6
+ // on the last-write-wins upsert. Contract only, platform-neutral — the
7
+ // concrete impl (window.sessionStorage on web) is injected by the platform
8
+ // package, same pattern as NavApi/window.history in app/nav.tsx. This
9
+ // package touches no browser storage itself.
10
+ export type DraftStorage = {
11
+ readonly getDraftId: (screenId: string) => string | null;
12
+ readonly setDraftId: (screenId: string, draftId: string) => void;
13
+ readonly clearDraftId: (screenId: string) => void;
14
+ };
15
+
16
+ // No-op default: without a mounted provider (unwired platform, or a test
17
+ // that doesn't care about drafts) same-tab reload just loses draftId
18
+ // persistence — RenderEdit falls back to its `form-draft:query:list`
19
+ // resume path, the same one a cleared sessionStorage takes on the web.
20
+ const noopDraftStorage: DraftStorage = {
21
+ getDraftId: () => null,
22
+ setDraftId: () => {},
23
+ clearDraftId: () => {},
24
+ };
25
+
26
+ const DraftStorageContext = createContext<DraftStorage>(noopDraftStorage);
27
+
28
+ export type DraftStorageProviderProps = {
29
+ readonly value: DraftStorage;
30
+ readonly children: ReactNode;
31
+ };
32
+
33
+ export function DraftStorageProvider({ value, children }: DraftStorageProviderProps): ReactNode {
34
+ return <DraftStorageContext value={value}>{children}</DraftStorageContext>;
35
+ }
36
+
37
+ export function useDraftStorage(): DraftStorage {
38
+ return useContext(DraftStorageContext);
39
+ }
@@ -22,6 +22,12 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
22
22
  "kumiko.actions.edit": "Bearbeiten",
23
23
  "kumiko.actions.copyLink": "Link kopieren",
24
24
  "kumiko.actions.copyLinkCopied": "Kopiert!",
25
+ "kumiko.actions.next": "Weiter",
26
+ "kumiko.actions.back": "Zurück",
27
+ "kumiko.actions.finish": "Abschließen",
28
+
29
+ // Wizard step chrome (RenderEdit layout.mode="wizard").
30
+ "kumiko.wizard.step": "Schritt {current} von {total}",
25
31
 
26
32
  // Version — Update-Awareness-Banner (UpdateChecker).
27
33
  "kumiko.version.update-available": "Eine neue Version ist verfügbar.",
@@ -130,6 +136,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
130
136
  "kumiko.form.error.version-conflict":
131
137
  "Datensatz wurde zwischenzeitlich geändert. Lade neu und versuche es erneut.",
132
138
  "kumiko.form.extension.save-failed": "Ein Zusatzfeld konnte nicht gespeichert werden.",
139
+ "kumiko.form.draft.resume-multiple":
140
+ "Mehrere offene Entwürfe für dieses Formular gefunden. Welchen möchtest du fortsetzen?",
133
141
 
134
142
  // Validation — Default-Reason-Codes aus dem Framework. App-Code
135
143
  // kann eigene Codes via Validation-Hooks reinwerfen; die hier sind
@@ -192,6 +200,11 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
192
200
  "kumiko.actions.edit": "Edit",
193
201
  "kumiko.actions.copyLink": "Copy link",
194
202
  "kumiko.actions.copyLinkCopied": "Copied!",
203
+ "kumiko.actions.next": "Next",
204
+ "kumiko.actions.back": "Back",
205
+ "kumiko.actions.finish": "Finish",
206
+
207
+ "kumiko.wizard.step": "Step {current} of {total}",
195
208
 
196
209
  "kumiko.version.update-available": "A new version is available.",
197
210
 
@@ -283,6 +296,8 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
283
296
  "kumiko.form.error.version-conflict":
284
297
  "Record was modified in the meantime. Reload and try again.",
285
298
  "kumiko.form.extension.save-failed": "A custom field could not be saved.",
299
+ "kumiko.form.draft.resume-multiple":
300
+ "Found multiple open drafts for this form. Which one do you want to resume?",
286
301
 
287
302
  "kumiko.validation.required": "Required.",
288
303
  "kumiko.validation.invalid": "Invalid value.",
package/src/index.ts CHANGED
@@ -69,7 +69,11 @@ export { lastSegment } from "./app/qn";
69
69
  export type { VariableChipsProps } from "./app/variable-chips";
70
70
  export { VariableChips } from "./app/variable-chips";
71
71
  export { dispatcherErrorText, WriteFailedError } from "./app/write-failed-error";
72
- export type { RenderEditProps } from "./components/render-edit";
72
+ export type {
73
+ RenderEditChangeState,
74
+ RenderEditControls,
75
+ RenderEditProps,
76
+ } from "./components/render-edit";
73
77
  export { RenderEdit } from "./components/render-edit";
74
78
  export type { RenderFieldProps } from "./components/render-field";
75
79
  export { RenderField } from "./components/render-field";
@@ -82,6 +86,8 @@ export {
82
86
  useDispatcherStatus,
83
87
  useOptionalDispatcher,
84
88
  } from "./context/dispatcher-context";
89
+ export type { DraftStorage, DraftStorageProviderProps } from "./context/draft-storage-context";
90
+ export { DraftStorageProvider, useDraftStorage } from "./context/draft-storage-context";
85
91
  export type { UserRolesProviderProps } from "./context/user-roles-context";
86
92
  export { UserRolesProvider, useUserRoles } from "./context/user-roles-context";
87
93
  export { formatWhen } from "./format-when";
@@ -169,6 +175,7 @@ export type {
169
175
  ModalProps,
170
176
  PrimitivesProviderProps,
171
177
  PrimitivesRegistry,
178
+ ProgressProps,
172
179
  RuntimeRenderer,
173
180
  SectionProps,
174
181
  TextProps,
@@ -408,6 +408,17 @@ export type InputProps =
408
408
  readonly required?: boolean;
409
409
  readonly hasError?: boolean;
410
410
  }
411
+ | {
412
+ readonly kind: "tz";
413
+ readonly id: string;
414
+ readonly name: string;
415
+ /** IANA zone name, e.g. "Europe/Berlin". Empty-state = "". */
416
+ readonly value: string;
417
+ readonly onChange: (v: string | undefined) => void;
418
+ readonly disabled?: boolean;
419
+ readonly required?: boolean;
420
+ readonly hasError?: boolean;
421
+ }
411
422
  | {
412
423
  readonly kind: "textarea";
413
424
  readonly id: string;
@@ -656,18 +667,14 @@ export type EmbeddedListInputProps = {
656
667
 
657
668
  export type { FormWidth };
658
669
 
659
- /** Submit-Wrapper. Web: `<form onSubmit>`, Native: View das einen
660
- * onSubmit-Callback via Button-Press triggert. `onSubmit` bekommt
661
- * eine abstrakte Signatur (keine FormEvent) damit Native-Impls das
662
- * sinnvoll füllen können.
670
+ /** Submit wrapper. Web: `<form onSubmit>`, native: a View that triggers an
671
+ * onSubmit callback via button press. `onSubmit` gets an abstract
672
+ * signature (no FormEvent) so native impls can fill it meaningfully.
663
673
  *
664
- * `title`: linker Slot der sticky-top Bar typisch der Screen-Titel
665
- * ("Neuer Eintrag", "Bestellung bearbeiten"). Wenn gesetzt, rendert
666
- * die Bar mit `justify-between` (Title links, Actions rechts).
667
- * `actions`: optionaler Slot für die primären Form-Aktionen (Save,
668
- * Cancel). Web rendert die Bar sticky-top, damit der Save-Button
669
- * bei langen Forms beim Scrollen erreichbar bleibt. Native-Impls
670
- * dürfen denselben Slot z. B. als Bottom-Bar rendern. */
674
+ * `title`: slot in the card header. `actions`: optional slot for the
675
+ * primary form actions (Save, Cancel) renders as a footer row at the
676
+ * end of the card (normal document flow). Native impls may render the
677
+ * same slot as a bottom bar instead. */
671
678
  export type FormProps = {
672
679
  readonly onSubmit: (e?: FormEvent) => void;
673
680
  readonly children: ReactNode;
@@ -681,6 +688,12 @@ export type FormProps = {
681
688
  * (`packages/types/src/screen.ts`, EditLayout.width, #1676). Native
682
689
  * impls may ignore this prop (no width constraint there). */
683
690
  readonly width?: FormWidth;
691
+ /** Pins `actions` to the viewport bottom on narrow screens (`<640px`)
692
+ * instead of normal document flow, so it stays reachable when a virtual
693
+ * keyboard shrinks the visible viewport (fw#1918). Desktop/tablet
694
+ * unaffected. Native impls may ignore this prop (already bottom-bar by
695
+ * convention there). */
696
+ readonly stickyActions?: boolean;
684
697
  };
685
698
 
686
699
  /** Titled Gruppe von Feldern. Web: `<fieldset>` + `<legend>`, Native:
@@ -863,6 +876,13 @@ export type CardProps = {
863
876
  readonly testId?: string;
864
877
  };
865
878
 
879
+ /** Determinate progress bar (e.g. wizard step progress). `value` is a
880
+ * 0..1 fraction, not a percentage — implementations scale for display. */
881
+ export type ProgressProps = {
882
+ readonly value: number;
883
+ readonly testId?: string;
884
+ };
885
+
866
886
  // ---- Core-Registry (Kumiko-eigene Primitives) ----
867
887
 
868
888
  export type CorePrimitives = {
@@ -888,6 +908,10 @@ export type CorePrimitives = {
888
908
  readonly ConfigSourceBadge: ComponentType<ConfigSourceBadgeProps>;
889
909
  readonly ConfigCascadeView: ComponentType<ConfigCascadeViewProps>;
890
910
  readonly Link: ComponentType<LinkProps>;
911
+ /** Optional (unlike the other Core-Primitives) so existing partial
912
+ * CorePrimitives mocks in tests keep compiling — additive rollout of
913
+ * a new primitive shouldn't force every test double to grow a stub. */
914
+ readonly Progress?: ComponentType<ProgressProps>;
891
915
  };
892
916
 
893
917
  /** Offene Extension-Zone für App-eigene Primitives. Devs erweitern