@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.
- package/package.json +5 -4
- package/src/__tests__/merge-search-params-into-initial.test.ts +13 -1
- package/src/app/__tests__/form-schema.test.ts +227 -0
- package/src/app/extension-sections.tsx +17 -0
- package/src/app/form-schema.ts +90 -0
- package/src/app/kumiko-screen.tsx +182 -42
- package/src/app/layout-fields.ts +27 -0
- package/src/components/__tests__/render-edit-logic.test.ts +48 -0
- package/src/components/__tests__/render-field-money-roundtrip.test.tsx +189 -0
- package/src/components/__tests__/render-field-unsupported-types.test.tsx +66 -2
- package/src/components/reference-create-dialog.tsx +4 -1
- package/src/components/render-edit-logic.ts +27 -0
- package/src/components/render-edit.tsx +579 -58
- package/src/components/render-field.tsx +86 -14
- package/src/context/draft-storage-context.tsx +39 -0
- package/src/i18n-defaults.ts +15 -0
- package/src/index.ts +8 -1
- package/src/primitives.tsx +35 -11
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
-
import
|
|
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
|
|
371
|
+
const currency = field.currency ?? "EUR";
|
|
334
372
|
return (
|
|
335
373
|
<Input
|
|
336
374
|
kind="money"
|
|
337
375
|
{...common}
|
|
338
|
-
value={
|
|
339
|
-
onChange={(v) => onChange(v)}
|
|
340
|
-
|
|
341
|
-
locale={
|
|
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)
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
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 "
|
|
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
|
|
447
|
-
|
|
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
|
+
}
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -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 {
|
|
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,
|
package/src/primitives.tsx
CHANGED
|
@@ -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
|
|
660
|
-
* onSubmit
|
|
661
|
-
*
|
|
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`:
|
|
665
|
-
* (
|
|
666
|
-
*
|
|
667
|
-
*
|
|
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
|