@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
|
@@ -15,11 +15,7 @@ import type {
|
|
|
15
15
|
ScreenDefinition,
|
|
16
16
|
ToolbarAction,
|
|
17
17
|
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
18
|
-
import {
|
|
19
|
-
evalFieldCondition,
|
|
20
|
-
isExtensionEditSection,
|
|
21
|
-
normalizeEditField,
|
|
22
|
-
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
18
|
+
import { evalFieldCondition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
23
19
|
import type {
|
|
24
20
|
Command,
|
|
25
21
|
FormSnapshot,
|
|
@@ -43,6 +39,8 @@ import { synthesizeConfigEditEntity, synthesizeConfigEditScreen } from "./config
|
|
|
43
39
|
import { useCustomScreenComponent } from "./custom-screens";
|
|
44
40
|
import { useDashboardBody } from "./dashboard-body";
|
|
45
41
|
import type { FeatureSchema } from "./feature-schema";
|
|
42
|
+
import { buildFormSchema } from "./form-schema";
|
|
43
|
+
import { layoutFieldNames } from "./layout-fields";
|
|
46
44
|
import { useNav } from "./nav";
|
|
47
45
|
import {
|
|
48
46
|
synthesizeProjectionDetailEntity,
|
|
@@ -310,8 +308,15 @@ function useNavigateToCreateFor(
|
|
|
310
308
|
// with a `default: true`/`default: 5` would show the form in a state
|
|
311
309
|
// the entity didn't ask for — subtle and easy to miss until a user
|
|
312
310
|
// submits and is surprised.
|
|
311
|
+
// `defaultCurrency` is only passed by entityEdit call sites — the money
|
|
312
|
+
// payload shape it enables (`{amount, currency}`) matches the entity's
|
|
313
|
+
// write schema (schema-builder.ts, kumiko-framework#1923). Callers outside
|
|
314
|
+
// that path (config-edit, action-form) synthesize their own entity and use
|
|
315
|
+
// money as a plain number against a different write contract, so they
|
|
316
|
+
// deliberately keep the old bare-`0` default by omitting the argument.
|
|
313
317
|
export function buildInitialValues(
|
|
314
318
|
fields: Readonly<Record<string, unknown>>,
|
|
319
|
+
defaultCurrency?: string,
|
|
315
320
|
): Readonly<Record<string, unknown>> {
|
|
316
321
|
const out: Record<string, unknown> = {};
|
|
317
322
|
for (const [name, def] of Object.entries(fields)) {
|
|
@@ -320,33 +325,23 @@ export function buildInitialValues(
|
|
|
320
325
|
out[name] = shape.default;
|
|
321
326
|
continue;
|
|
322
327
|
}
|
|
328
|
+
if (shape.type === "money" && defaultCurrency !== undefined) {
|
|
329
|
+
out[name] = { amount: 0, currency: defaultCurrency };
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
323
332
|
out[name] =
|
|
324
333
|
shape.type === "boolean" ? false : shape.type === "number" || shape.type === "money" ? 0 : "";
|
|
325
334
|
}
|
|
326
335
|
return out;
|
|
327
336
|
}
|
|
328
337
|
|
|
329
|
-
// Field names actually rendered by the screen's layout — a search-param
|
|
330
|
-
// merge must not set fields the form never shows the user (#1708:
|
|
331
|
-
// unrendered fields get no client-side validation and no chance to
|
|
332
|
-
// review/correct the injected value).
|
|
333
|
-
function layoutFieldNames(screen: EntityEditScreenDefinition): ReadonlySet<string> {
|
|
334
|
-
const names = new Set<string>();
|
|
335
|
-
for (const section of screen.layout.sections) {
|
|
336
|
-
if (isExtensionEditSection(section)) continue;
|
|
337
|
-
for (const spec of section.fields) {
|
|
338
|
-
names.add(normalizeEditField(spec).field);
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
return names;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
338
|
export function mergeSearchParamsIntoInitial(
|
|
345
339
|
fields: Readonly<Record<string, unknown>>,
|
|
346
340
|
searchParams: Readonly<Record<string, string>>,
|
|
347
341
|
renderableFields?: ReadonlySet<string>,
|
|
342
|
+
defaultCurrency?: string,
|
|
348
343
|
): Record<string, unknown> {
|
|
349
|
-
const defaults = buildInitialValues(fields) as Record<string, unknown>;
|
|
344
|
+
const defaults = buildInitialValues(fields, defaultCurrency) as Record<string, unknown>;
|
|
350
345
|
const merged: Record<string, unknown> = { ...defaults };
|
|
351
346
|
for (const [name, fieldDef] of Object.entries(fields)) {
|
|
352
347
|
if (renderableFields !== undefined && !renderableFields.has(name)) continue;
|
|
@@ -354,9 +349,16 @@ export function mergeSearchParamsIntoInitial(
|
|
|
354
349
|
if (shape.sensitive === true) continue;
|
|
355
350
|
const raw = searchParams[name];
|
|
356
351
|
if (raw === undefined) continue;
|
|
357
|
-
if (shape.type === "number"
|
|
352
|
+
if (shape.type === "number") {
|
|
358
353
|
const parsed = Number(raw);
|
|
359
354
|
merged[name] = Number.isNaN(parsed) ? defaults[name] : parsed;
|
|
355
|
+
} else if (shape.type === "money") {
|
|
356
|
+
const parsed = Number(raw);
|
|
357
|
+
merged[name] = Number.isNaN(parsed)
|
|
358
|
+
? defaults[name]
|
|
359
|
+
: defaultCurrency !== undefined
|
|
360
|
+
? { amount: parsed, currency: defaultCurrency }
|
|
361
|
+
: parsed;
|
|
360
362
|
} else if (shape.type === "boolean") {
|
|
361
363
|
merged[name] = raw === "true";
|
|
362
364
|
} else {
|
|
@@ -404,19 +406,19 @@ function EntityEditScreen({
|
|
|
404
406
|
/>
|
|
405
407
|
);
|
|
406
408
|
}
|
|
407
|
-
if (screen.
|
|
408
|
-
// Update-only Screen ohne entityId (Direkt-URL / verirrte Navigation):
|
|
409
|
-
// ein Create-Form würde gegen den nicht registrierten
|
|
410
|
-
// `<entity>:create`-Handler submitten — Fehler statt Falle.
|
|
409
|
+
if (screen.singleton === true) {
|
|
411
410
|
return (
|
|
412
|
-
<
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
411
|
+
<EntityEditSingletonBody
|
|
412
|
+
schema={schema}
|
|
413
|
+
screen={screen}
|
|
414
|
+
entity={entity}
|
|
415
|
+
{...(translate !== undefined && { translate })}
|
|
416
|
+
{...(onCopyLink !== undefined && { onCopyLink })}
|
|
417
|
+
/>
|
|
416
418
|
);
|
|
417
419
|
}
|
|
418
420
|
return (
|
|
419
|
-
<
|
|
421
|
+
<EntityEditCreateOrDisabled
|
|
420
422
|
schema={schema}
|
|
421
423
|
screen={screen}
|
|
422
424
|
entity={entity}
|
|
@@ -443,16 +445,23 @@ function EntityEditCreateBody({
|
|
|
443
445
|
entity.fields,
|
|
444
446
|
nav.searchParams,
|
|
445
447
|
layoutFieldNames(screen),
|
|
448
|
+
entity.defaultCurrency ?? "EUR",
|
|
446
449
|
) as FormValues,
|
|
447
|
-
[entity.fields, nav.searchParams, screen],
|
|
450
|
+
[entity.fields, nav.searchParams, screen, entity.defaultCurrency],
|
|
448
451
|
);
|
|
452
|
+
const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
|
|
449
453
|
const writeCommand = entityWriteCommand(schema.featureName, screen.entity, "create");
|
|
450
454
|
const navigateToList = useNavigateToListAfter(schema, screen.entity);
|
|
451
455
|
const handleSubmitted = useCallback(
|
|
452
456
|
(result: SubmitResult<unknown>) => {
|
|
453
|
-
if (result.isSuccess)
|
|
457
|
+
if (!result.isSuccess) return;
|
|
458
|
+
if (screen.redirect !== undefined) {
|
|
459
|
+
nav.navigate({ screenId: lastSegment(screen.redirect) });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
navigateToList();
|
|
454
463
|
},
|
|
455
|
-
[navigateToList],
|
|
464
|
+
[nav, screen.redirect, navigateToList],
|
|
456
465
|
);
|
|
457
466
|
return (
|
|
458
467
|
<RenderEdit
|
|
@@ -460,6 +469,7 @@ function EntityEditCreateBody({
|
|
|
460
469
|
entity={entity}
|
|
461
470
|
featureName={schema.featureName}
|
|
462
471
|
initial={initial}
|
|
472
|
+
schema={formSchema}
|
|
463
473
|
writeCommand={writeCommand}
|
|
464
474
|
onSubmit={handleSubmitted}
|
|
465
475
|
onCancel={navigateToList}
|
|
@@ -566,11 +576,15 @@ function EntityEditUpdateForm({
|
|
|
566
576
|
const recordVersion = (record as { version?: number }).version ?? 1;
|
|
567
577
|
const initial = useMemo(() => {
|
|
568
578
|
const out: Record<string, unknown> = {};
|
|
579
|
+
const defaultCurrency = entity.defaultCurrency ?? "EUR";
|
|
569
580
|
for (const name of Object.keys(entity.fields)) {
|
|
570
|
-
out[name] =
|
|
581
|
+
out[name] =
|
|
582
|
+
record[name] ?? buildInitialValues({ [name]: entity.fields[name] }, defaultCurrency)[name];
|
|
571
583
|
}
|
|
572
584
|
return out as FormValues;
|
|
573
|
-
}, [entity.fields, record]);
|
|
585
|
+
}, [entity.fields, entity.defaultCurrency, record]);
|
|
586
|
+
|
|
587
|
+
const formSchema = useMemo(() => buildFormSchema(entity, screen), [entity, screen]);
|
|
574
588
|
|
|
575
589
|
// Extension-Werte (z.B. customFields-jsonb) an extension-sections geben,
|
|
576
590
|
// damit sie beim Edit den Bestand zeigen statt write-only zu sein.
|
|
@@ -593,13 +607,19 @@ function EntityEditUpdateForm({
|
|
|
593
607
|
[entityId, recordVersion],
|
|
594
608
|
);
|
|
595
609
|
|
|
610
|
+
const nav = useNav();
|
|
596
611
|
const dispatcher = useDispatcher();
|
|
597
612
|
const navigateToList = useNavigateToListAfter(schema, screen.entity);
|
|
598
613
|
const handleSubmitted = useCallback(
|
|
599
614
|
(result: SubmitResult<unknown>) => {
|
|
600
|
-
if (result.isSuccess)
|
|
615
|
+
if (!result.isSuccess) return;
|
|
616
|
+
if (screen.redirect !== undefined) {
|
|
617
|
+
nav.navigate({ screenId: lastSegment(screen.redirect) });
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
navigateToList();
|
|
601
621
|
},
|
|
602
|
-
[navigateToList],
|
|
622
|
+
[nav, screen.redirect, navigateToList],
|
|
603
623
|
);
|
|
604
624
|
const handleDelete = useCallback(async () => {
|
|
605
625
|
const res = await dispatcher.write(deleteCommand, { id: entityId });
|
|
@@ -619,6 +639,7 @@ function EntityEditUpdateForm({
|
|
|
619
639
|
// customFields-Bestand an die extension-section, damit sie beim Edit
|
|
620
640
|
// die gespeicherten Werte zeigt (nicht write-only).
|
|
621
641
|
extensionInitialValues={extensionInitialValues}
|
|
642
|
+
schema={formSchema}
|
|
622
643
|
writeCommand={writeCommand}
|
|
623
644
|
payloadMode="changes"
|
|
624
645
|
buildPayload={buildPayload}
|
|
@@ -636,6 +657,100 @@ function EntityEditUpdateForm({
|
|
|
636
657
|
);
|
|
637
658
|
}
|
|
638
659
|
|
|
660
|
+
// Singleton entities (`singleton: true`, exactly one record per tenant):
|
|
661
|
+
// EntityEditScreen reaches here only without an entityId. Resolve the
|
|
662
|
+
// existing record via list(limit:1) before deciding create vs update —
|
|
663
|
+
// otherwise every visit without an id would create another record.
|
|
664
|
+
function EntityEditSingletonBody({
|
|
665
|
+
schema,
|
|
666
|
+
screen,
|
|
667
|
+
entity,
|
|
668
|
+
translate,
|
|
669
|
+
onCopyLink,
|
|
670
|
+
}: {
|
|
671
|
+
readonly schema: FeatureSchema;
|
|
672
|
+
readonly screen: EntityEditScreenDefinition;
|
|
673
|
+
readonly entity: EntityDefinition;
|
|
674
|
+
readonly translate?: Translate;
|
|
675
|
+
readonly onCopyLink?: () => Promise<void> | void;
|
|
676
|
+
}): ReactNode {
|
|
677
|
+
const { Banner } = usePrimitives();
|
|
678
|
+
const t = useTranslation();
|
|
679
|
+
const effectiveTranslate = translate ?? t;
|
|
680
|
+
const listQn = entityQueryCommand(schema.featureName, screen.entity, "list");
|
|
681
|
+
const listQuery = useQuery<PagedRows>(listQn, { limit: 1 });
|
|
682
|
+
|
|
683
|
+
if (listQuery.loading && listQuery.data === null) {
|
|
684
|
+
return (
|
|
685
|
+
<Banner padded variant="loading" testId="kumiko-screen-loading">
|
|
686
|
+
Loading…
|
|
687
|
+
</Banner>
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
if (listQuery.error) {
|
|
691
|
+
return (
|
|
692
|
+
<Banner padded variant="error" testId="kumiko-screen-error">
|
|
693
|
+
{dispatcherErrorText(listQuery.error, effectiveTranslate)}
|
|
694
|
+
</Banner>
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
const existingId = listQuery.data?.rows[0]?.["id"] as string | undefined;
|
|
698
|
+
if (existingId !== undefined) {
|
|
699
|
+
return (
|
|
700
|
+
<EntityEditUpdateBody
|
|
701
|
+
schema={schema}
|
|
702
|
+
screen={screen}
|
|
703
|
+
entity={entity}
|
|
704
|
+
entityId={existingId}
|
|
705
|
+
{...(translate !== undefined && { translate })}
|
|
706
|
+
{...(onCopyLink !== undefined && { onCopyLink })}
|
|
707
|
+
/>
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
return (
|
|
711
|
+
<EntityEditCreateOrDisabled
|
|
712
|
+
schema={schema}
|
|
713
|
+
screen={screen}
|
|
714
|
+
entity={entity}
|
|
715
|
+
{...(translate !== undefined && { translate })}
|
|
716
|
+
/>
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Shared no-entityId tail for both the plain create path and the
|
|
721
|
+
// singleton path's empty-table fallback: `allowCreate: false` blocks
|
|
722
|
+
// both the same way, since a create submit there would hit an
|
|
723
|
+
// unregistered `<entity>:create` handler.
|
|
724
|
+
function EntityEditCreateOrDisabled({
|
|
725
|
+
schema,
|
|
726
|
+
screen,
|
|
727
|
+
entity,
|
|
728
|
+
translate,
|
|
729
|
+
}: {
|
|
730
|
+
readonly schema: FeatureSchema;
|
|
731
|
+
readonly screen: EntityEditScreenDefinition;
|
|
732
|
+
readonly entity: EntityDefinition;
|
|
733
|
+
readonly translate?: Translate;
|
|
734
|
+
}): ReactNode {
|
|
735
|
+
const { Banner, Text } = usePrimitives();
|
|
736
|
+
if (screen.allowCreate === false) {
|
|
737
|
+
return (
|
|
738
|
+
<Banner padded variant="error" testId="kumiko-screen-create-disabled">
|
|
739
|
+
Screen <Text variant="code">{screen.id}</Text> is update-only (allowCreate: false) — open it
|
|
740
|
+
from a row action with an entity id.
|
|
741
|
+
</Banner>
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
return (
|
|
745
|
+
<EntityEditCreateBody
|
|
746
|
+
schema={schema}
|
|
747
|
+
screen={screen}
|
|
748
|
+
entity={entity}
|
|
749
|
+
{...(translate !== undefined && { translate })}
|
|
750
|
+
/>
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
639
754
|
// ---- entity-list ----
|
|
640
755
|
|
|
641
756
|
function entityQueryCommand(featureName: string, entity: string, verb: "list"): string {
|
|
@@ -1456,7 +1571,7 @@ function ActionFormBody({
|
|
|
1456
1571
|
// Author entscheidet bewusst ob "stay on form" (default) oder
|
|
1457
1572
|
// "back to list" (typisch bei Create-style Aktionen).
|
|
1458
1573
|
if (result.isSuccess && screen.redirect !== undefined) {
|
|
1459
|
-
nav.navigate({ screenId: screen.redirect });
|
|
1574
|
+
nav.navigate({ screenId: lastSegment(screen.redirect) });
|
|
1460
1575
|
}
|
|
1461
1576
|
},
|
|
1462
1577
|
[nav, screen.redirect],
|
|
@@ -1468,7 +1583,7 @@ function ActionFormBody({
|
|
|
1468
1583
|
const handleCancel = useMemo<(() => void) | undefined>(() => {
|
|
1469
1584
|
const target = screen.cancelTarget ?? screen.redirect;
|
|
1470
1585
|
if (target === undefined || target === false) return undefined;
|
|
1471
|
-
return () => nav.navigate({ screenId: target });
|
|
1586
|
+
return () => nav.navigate({ screenId: lastSegment(target) });
|
|
1472
1587
|
}, [nav, screen.redirect, screen.cancelTarget]);
|
|
1473
1588
|
return (
|
|
1474
1589
|
<RenderEdit
|
|
@@ -1513,6 +1628,19 @@ type ConfigValueResponse = Readonly<
|
|
|
1513
1628
|
Record<string, { value: string | number | boolean | undefined; scope: string; source: string }>
|
|
1514
1629
|
>;
|
|
1515
1630
|
|
|
1631
|
+
// A money-typed config-edit field renders through RenderField's entityEdit
|
|
1632
|
+
// `{amount, currency}` payload shape (render-field.tsx, #1923), but
|
|
1633
|
+
// `ConfigKeyType` (write-helpers.ts validateType) only ever knows
|
|
1634
|
+
// number/boolean/text/select — a config value is always a bare scalar.
|
|
1635
|
+
// Unwrap back to the amount before it hits config:write:set.
|
|
1636
|
+
function unwrapMoneyValue(value: unknown): unknown {
|
|
1637
|
+
if (typeof value === "object" && value !== null && "amount" in value) {
|
|
1638
|
+
const amount = (value as { amount?: unknown }).amount;
|
|
1639
|
+
if (typeof amount === "number") return amount;
|
|
1640
|
+
}
|
|
1641
|
+
return value;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1516
1644
|
function ConfigEditBody({
|
|
1517
1645
|
schema,
|
|
1518
1646
|
screen,
|
|
@@ -1604,9 +1732,14 @@ function ConfigEditBody({
|
|
|
1604
1732
|
for (const [shortName, value] of Object.entries(snapshot.changes)) {
|
|
1605
1733
|
const qualified = screen.configKeys[shortName];
|
|
1606
1734
|
if (qualified === undefined) continue;
|
|
1735
|
+
const ftype = (screen.fields[shortName] as { type?: string } | undefined)?.type;
|
|
1607
1736
|
commands.push({
|
|
1608
1737
|
type: "config:write:set",
|
|
1609
|
-
payload: {
|
|
1738
|
+
payload: {
|
|
1739
|
+
key: qualified,
|
|
1740
|
+
value: ftype === "money" ? unwrapMoneyValue(value) : value,
|
|
1741
|
+
scope: screen.scope,
|
|
1742
|
+
},
|
|
1610
1743
|
});
|
|
1611
1744
|
}
|
|
1612
1745
|
if (commands.length === 0) {
|
|
@@ -1623,7 +1756,14 @@ function ConfigEditBody({
|
|
|
1623
1756
|
await Promise.allSettled([valuesQuery.refetch?.(), cascadeQuery.refetch?.()]);
|
|
1624
1757
|
return { validationBlocked: false, isSuccess: true, data: undefined };
|
|
1625
1758
|
},
|
|
1626
|
-
[
|
|
1759
|
+
[
|
|
1760
|
+
dispatcher,
|
|
1761
|
+
screen.configKeys,
|
|
1762
|
+
screen.fields,
|
|
1763
|
+
screen.scope,
|
|
1764
|
+
valuesQuery.refetch,
|
|
1765
|
+
cascadeQuery.refetch,
|
|
1766
|
+
],
|
|
1627
1767
|
);
|
|
1628
1768
|
|
|
1629
1769
|
// Cascade-Disclosure (#429): Trigger sitzt in der Label-Row, das Panel
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EditFieldSpec,
|
|
3
|
+
EntityEditScreenDefinition,
|
|
4
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
5
|
+
import { isExtensionEditSection, normalizeEditField } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
|
+
|
|
7
|
+
// Normalized field specs actually rendered by the screen's layout, extension
|
|
8
|
+
// sections skipped. Both this and `layoutFieldNames` key off "rendered by
|
|
9
|
+
// the layout" for the same reason: a field the user never sees gets no
|
|
10
|
+
// chance to review/correct a value nor to fix a presence error
|
|
11
|
+
// (search-param merge, #1708; presence schema in form-schema.ts).
|
|
12
|
+
export function layoutEditFields(
|
|
13
|
+
screen: EntityEditScreenDefinition,
|
|
14
|
+
): readonly Exclude<EditFieldSpec, string>[] {
|
|
15
|
+
const specs: Exclude<EditFieldSpec, string>[] = [];
|
|
16
|
+
for (const section of screen.layout.sections) {
|
|
17
|
+
if (isExtensionEditSection(section)) continue;
|
|
18
|
+
for (const spec of section.fields) {
|
|
19
|
+
specs.push(normalizeEditField(spec));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return specs;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function layoutFieldNames(screen: EntityEditScreenDefinition): ReadonlySet<string> {
|
|
26
|
+
return new Set(layoutEditFields(screen).map((spec) => spec.field));
|
|
27
|
+
}
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
SubmitResult,
|
|
7
7
|
} from "@cosmicdrift/kumiko-headless";
|
|
8
8
|
import {
|
|
9
|
+
filterEditSections,
|
|
9
10
|
hasEditableSection,
|
|
10
11
|
resolveExtensionEntityId,
|
|
11
12
|
shouldNotifyCaller,
|
|
@@ -177,3 +178,50 @@ describe("hasEditableSection", () => {
|
|
|
177
178
|
expect(hasEditableSection([section])).toBe(false);
|
|
178
179
|
});
|
|
179
180
|
});
|
|
181
|
+
|
|
182
|
+
const namedField = (name: string): EditFieldViewModel => ({
|
|
183
|
+
field: name,
|
|
184
|
+
label: name,
|
|
185
|
+
type: "text",
|
|
186
|
+
value: "",
|
|
187
|
+
visible: true,
|
|
188
|
+
readOnly: false,
|
|
189
|
+
required: false,
|
|
190
|
+
});
|
|
191
|
+
const namedFieldsSection = (...names: string[]): EditSectionViewModel => ({
|
|
192
|
+
kind: "fields",
|
|
193
|
+
columns: 1,
|
|
194
|
+
visible: true,
|
|
195
|
+
fields: names.map(namedField),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("filterEditSections", () => {
|
|
199
|
+
test("fieldsFilter undefined → returns the same array reference (unchanged behavior)", () => {
|
|
200
|
+
const sections = [namedFieldsSection("a", "b")];
|
|
201
|
+
expect(filterEditSections(sections, undefined)).toBe(sections);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("mixed section → only the filtered-in fields remain in section.fields", () => {
|
|
205
|
+
const sections = [namedFieldsSection("a", "b", "c")];
|
|
206
|
+
const result = filterEditSections(sections, ["a", "c"]);
|
|
207
|
+
expect(result).toHaveLength(1);
|
|
208
|
+
expect(result[0]?.kind).toBe("fields");
|
|
209
|
+
expect(result[0]?.kind === "fields" ? result[0].fields.map((f) => f.field) : []).toEqual([
|
|
210
|
+
"a",
|
|
211
|
+
"c",
|
|
212
|
+
]);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("section whose filtered field list becomes empty is dropped entirely, not rendered empty", () => {
|
|
216
|
+
const sections = [namedFieldsSection("a", "b"), namedFieldsSection("c")];
|
|
217
|
+
const result = filterEditSections(sections, ["c"]);
|
|
218
|
+
expect(result).toHaveLength(1);
|
|
219
|
+
expect(result[0]?.kind === "fields" ? result[0].fields.map((f) => f.field) : []).toEqual(["c"]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("extension section always survives the filter regardless of its content", () => {
|
|
223
|
+
const sections = [extensionSection, namedFieldsSection("a")];
|
|
224
|
+
const result = filterEditSections(sections, ["zzz"]);
|
|
225
|
+
expect(result).toEqual([extensionSection]);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// kumiko-framework#1923: money didn't round-trip on the auto-wired
|
|
2
|
+
// entityEdit path — create sent a bare number against the server's
|
|
3
|
+
// `{amount, currency}` schema, update rendered the rehydrated read value as
|
|
4
|
+
// NaN, and a naive fix would have been 100x off (minor vs. major units).
|
|
5
|
+
//
|
|
6
|
+
// This test walks the whole chain with real functions, no mocks:
|
|
7
|
+
// computeEditViewModel (headless) → RenderField → simulated widget
|
|
8
|
+
// onChange → buildInsertSchema/buildUpdateSchema (framework/engine). The
|
|
9
|
+
// widget's own minor-units math (MoneyInput) is pinned separately in
|
|
10
|
+
// packages/renderer-web/src/primitives/__tests__/money-input.test.tsx —
|
|
11
|
+
// this test starts one step in, at the value RenderField hands to/from the
|
|
12
|
+
// widget contract (`number | ""` minor units).
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test } from "bun:test";
|
|
15
|
+
import { rehydrateMoney } from "@cosmicdrift/kumiko-framework/db";
|
|
16
|
+
import { buildInsertSchema, buildUpdateSchema } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import type {
|
|
18
|
+
EntityDefinition,
|
|
19
|
+
EntityEditScreenDefinition,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
21
|
+
import { computeEditViewModel, type EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
|
|
22
|
+
import { render } from "@testing-library/react";
|
|
23
|
+
import type { ComponentType, ReactNode } from "react";
|
|
24
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
25
|
+
import { type CorePrimitives, type InputProps, PrimitivesProvider } from "../../primitives";
|
|
26
|
+
import { RenderField } from "../render-field";
|
|
27
|
+
|
|
28
|
+
let captured: InputProps | undefined;
|
|
29
|
+
const captureInput: ComponentType<InputProps> = (props) => {
|
|
30
|
+
captured = props;
|
|
31
|
+
return null;
|
|
32
|
+
};
|
|
33
|
+
const noop = (): ReactNode => null;
|
|
34
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
35
|
+
|
|
36
|
+
const testPrimitives: CorePrimitives = {
|
|
37
|
+
Button: noop,
|
|
38
|
+
Banner: noop,
|
|
39
|
+
Field: passChildren,
|
|
40
|
+
Input: captureInput,
|
|
41
|
+
DataTable: noop,
|
|
42
|
+
Form: noop,
|
|
43
|
+
Section: noop,
|
|
44
|
+
Card: noop,
|
|
45
|
+
Grid: noop,
|
|
46
|
+
GridCell: noop,
|
|
47
|
+
Text: noop,
|
|
48
|
+
Heading: noop,
|
|
49
|
+
Dialog: noop,
|
|
50
|
+
Modal: noop,
|
|
51
|
+
Lightbox: noop,
|
|
52
|
+
ConfigSourceBadge: noop,
|
|
53
|
+
ConfigCascadeView: noop,
|
|
54
|
+
Link: noop,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function buildEntity(defaultCurrency: string): EntityDefinition {
|
|
58
|
+
return {
|
|
59
|
+
fields: {
|
|
60
|
+
price: { type: "money", required: true },
|
|
61
|
+
},
|
|
62
|
+
defaultCurrency,
|
|
63
|
+
} as EntityDefinition;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildScreen(): EntityEditScreenDefinition {
|
|
67
|
+
return {
|
|
68
|
+
id: "product-edit",
|
|
69
|
+
type: "entityEdit",
|
|
70
|
+
entity: "product",
|
|
71
|
+
layout: { sections: [{ columns: 1, fields: ["price"] }] },
|
|
72
|
+
} as EntityEditScreenDefinition;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function priceField(entity: EntityDefinition, values: Record<string, unknown>): EditFieldViewModel {
|
|
76
|
+
const vm = computeEditViewModel({
|
|
77
|
+
screen: buildScreen(),
|
|
78
|
+
entity,
|
|
79
|
+
values,
|
|
80
|
+
translate: (key) => key,
|
|
81
|
+
featureName: "shop",
|
|
82
|
+
});
|
|
83
|
+
const section = vm.sections[0];
|
|
84
|
+
if (section === undefined || section.kind !== "fields") {
|
|
85
|
+
throw new Error("expected a fields section");
|
|
86
|
+
}
|
|
87
|
+
const field = section.fields[0];
|
|
88
|
+
if (field === undefined) throw new Error("expected a price field");
|
|
89
|
+
return field;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function renderMoneyField(
|
|
93
|
+
entity: EntityDefinition,
|
|
94
|
+
values: Record<string, unknown>,
|
|
95
|
+
onChange: (v: unknown) => void,
|
|
96
|
+
): InputProps {
|
|
97
|
+
captured = undefined;
|
|
98
|
+
render(
|
|
99
|
+
<LocaleProvider resolver={createStaticLocaleResolver({ locale: "de-DE" })}>
|
|
100
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
101
|
+
<RenderField field={priceField(entity, values)} onChange={onChange} />
|
|
102
|
+
</PrimitivesProvider>
|
|
103
|
+
</LocaleProvider>,
|
|
104
|
+
);
|
|
105
|
+
if (captured === undefined) throw new Error("money field did not render an Input");
|
|
106
|
+
return captured;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe("RenderField money round-trip (kumiko-framework#1923)", () => {
|
|
110
|
+
test("create: untouched required field, buildInitialValues default validates against buildInsertSchema", () => {
|
|
111
|
+
const entity = buildEntity("USD");
|
|
112
|
+
// Same default shape kumiko-screen.tsx's buildInitialValues produces
|
|
113
|
+
// for a money field once a defaultCurrency is threaded through.
|
|
114
|
+
const initialPrice = { amount: 0, currency: "USD" };
|
|
115
|
+
const result = buildInsertSchema(entity).safeParse({ price: initialPrice });
|
|
116
|
+
expect(result.success).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("create: user-entered amount becomes a payload that validates against buildInsertSchema", () => {
|
|
120
|
+
const entity = buildEntity("USD");
|
|
121
|
+
let payload: unknown;
|
|
122
|
+
const field = renderMoneyField(entity, {}, (v) => {
|
|
123
|
+
payload = v;
|
|
124
|
+
});
|
|
125
|
+
expect(field.kind).toBe("money");
|
|
126
|
+
if (field.kind !== "money") return;
|
|
127
|
+
expect(field.value).toBe(""); // untouched — empty widget state, not NaN/0
|
|
128
|
+
field.onChange(1299); // widget emits minor units: 12.99 USD
|
|
129
|
+
expect(payload).toEqual({ amount: 12.99, currency: "USD" });
|
|
130
|
+
const result = buildInsertSchema(entity).safeParse({ price: payload });
|
|
131
|
+
expect(result.success).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("update: server-rehydrated value renders as a real number, not NaN (regression)", () => {
|
|
135
|
+
const entity = buildEntity("USD");
|
|
136
|
+
const row = { price: 1299, priceCurrency: "USD" };
|
|
137
|
+
const record = rehydrateMoney(row, entity);
|
|
138
|
+
const field = renderMoneyField(entity, record, () => {});
|
|
139
|
+
expect(field.kind).toBe("money");
|
|
140
|
+
if (field.kind !== "money") return;
|
|
141
|
+
expect(field.value).toBe(1299);
|
|
142
|
+
expect(Number.isNaN(field.value)).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("update: edited amount becomes a payload that validates against buildUpdateSchema", () => {
|
|
146
|
+
const entity = buildEntity("USD");
|
|
147
|
+
const row = { price: 1299, priceCurrency: "USD" };
|
|
148
|
+
const record = rehydrateMoney(row, entity);
|
|
149
|
+
let payload: unknown;
|
|
150
|
+
const field = renderMoneyField(entity, record, (v) => {
|
|
151
|
+
payload = v;
|
|
152
|
+
});
|
|
153
|
+
if (field.kind !== "money") throw new Error("expected money kind");
|
|
154
|
+
field.onChange(1500); // user edits to 15.00
|
|
155
|
+
expect(payload).toEqual({ amount: 15, currency: "USD" });
|
|
156
|
+
const result = buildUpdateSchema(entity).safeParse({ price: payload });
|
|
157
|
+
expect(result.success).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("JPY (zero-decimal currency): amountMinor is not read, so the widget value is not 100x off", () => {
|
|
161
|
+
const entity = buildEntity("JPY");
|
|
162
|
+
// DB stores minor units at the framework's flat MINOR_UNIT_SCALE=100,
|
|
163
|
+
// so ¥500 is row-stored as 50000 — rehydrateMoney's amountMinor mirrors
|
|
164
|
+
// that scale, which disagrees with JPY's real 0 decimal places. Reading
|
|
165
|
+
// amountMinor here would render 50000 instead of 500.
|
|
166
|
+
const row = { price: 50000, priceCurrency: "JPY" };
|
|
167
|
+
const record = rehydrateMoney(row, entity);
|
|
168
|
+
let payload: unknown;
|
|
169
|
+
const field = renderMoneyField(entity, record, (v) => {
|
|
170
|
+
payload = v;
|
|
171
|
+
});
|
|
172
|
+
if (field.kind !== "money") throw new Error("expected money kind");
|
|
173
|
+
expect(field.value).toBe(500);
|
|
174
|
+
field.onChange(500); // user submits the same amount unchanged
|
|
175
|
+
expect(payload).toEqual({ amount: 500, currency: "JPY" });
|
|
176
|
+
const result = buildInsertSchema(entity).safeParse({ price: payload });
|
|
177
|
+
expect(result.success).toBe(true);
|
|
178
|
+
expect(result.data?.["price"]).toEqual({ amount: 500, currency: "JPY" });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("server schema strips an unexpected amountMinor key instead of rejecting the payload", () => {
|
|
182
|
+
const entity = buildEntity("USD");
|
|
183
|
+
const result = buildUpdateSchema(entity).safeParse({
|
|
184
|
+
price: { amount: 12.99, currency: "USD", amountMinor: 1299 },
|
|
185
|
+
});
|
|
186
|
+
expect(result.success).toBe(true);
|
|
187
|
+
expect(result.data?.["price"]).toEqual({ amount: 12.99, currency: "USD" });
|
|
188
|
+
});
|
|
189
|
+
});
|