@cosmicdrift/kumiko-renderer 0.220.1 → 0.222.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 +4 -4
- package/src/__tests__/merge-search-params-into-initial.test.ts +22 -1
- package/src/app/__tests__/content-editors.test.tsx +2 -1
- package/src/app/__tests__/content-preview.test.tsx +22 -0
- package/src/app/__tests__/entity-list-row-action-entity-target.test.tsx +208 -1
- package/src/app/__tests__/entity-list-row-action-refetch.test.tsx +187 -1
- package/src/app/__tests__/form-schema.test.ts +9 -0
- package/src/app/__tests__/list-filter-facets.test.tsx +7 -4
- package/src/app/content-editors.tsx +3 -2
- package/src/app/content-preview.tsx +11 -3
- package/src/app/extension-form-submit.tsx +9 -4
- package/src/app/form-schema.ts +4 -6
- package/src/app/kumiko-screen.tsx +107 -51
- package/src/components/__tests__/render-edit-action-button.test.tsx +17 -1
- package/src/components/__tests__/render-edit-submit-actions.test.tsx +52 -1
- package/src/components/__tests__/render-field-money-roundtrip.test.tsx +14 -0
- package/src/components/__tests__/render-field-unit-format.test.tsx +5 -0
- package/src/components/render-edit-action-button.tsx +4 -0
- package/src/components/render-edit.tsx +123 -91
- package/src/components/render-field.tsx +23 -6
- package/src/hooks/__tests__/use-form.test.tsx +8 -4
- package/src/i18n.tsx +42 -0
- package/src/index.ts +2 -0
- package/src/primitives.tsx +5 -7
|
@@ -64,7 +64,7 @@ const FORM_DRAFT_KEY_MAX_LENGTH = 256;
|
|
|
64
64
|
// (the #1888 derived-field shape) — 500ms collapses a typing burst into one
|
|
65
65
|
// save instead of one per keystroke, while still saving well before a user
|
|
66
66
|
// abandons the tab.
|
|
67
|
-
const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
|
|
67
|
+
export const PATCH_DRAFT_SAVE_DEBOUNCE_MS = 500;
|
|
68
68
|
|
|
69
69
|
type FormDraftBlob = {
|
|
70
70
|
readonly values: Record<string, unknown>;
|
|
@@ -190,10 +190,6 @@ function ExtensionSectionMount({
|
|
|
190
190
|
);
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
// One header action + its own busy/confirm state — same pattern as
|
|
194
|
-
// render-list.tsx's ToolbarActionView (each RenderEditAction is
|
|
195
|
-
// independently bound by the caller, there is no shared trigger pipeline
|
|
196
|
-
// to hook into like the built-in onDelete/onSubmit paths have).
|
|
197
193
|
export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
198
194
|
props: RenderEditProps<TValues, TCtx>,
|
|
199
195
|
): ReactNode {
|
|
@@ -281,9 +277,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
281
277
|
// never enters `fields`/`controller.getSnapshot().values` — there is no
|
|
282
278
|
// draft-blob-covered state left for persistExtensions() to compete over.
|
|
283
279
|
const [extensionDirty, setExtensionDirty] = useState(false);
|
|
280
|
+
const [hasExtensionRegistrations, setHasExtensionRegistrations] = useState(false);
|
|
284
281
|
const [extensionErrorKey, setExtensionErrorKey] = useState<string | null>(null);
|
|
285
|
-
const { registry: extensionFormRegistry, runAll: runExtensionSubmits } =
|
|
286
|
-
|
|
282
|
+
const { registry: extensionFormRegistry, runAll: runExtensionSubmits } = useExtensionFormHost(
|
|
283
|
+
({ anyDirty, hasRegistrations }) => {
|
|
284
|
+
setExtensionDirty(anyDirty);
|
|
285
|
+
setHasExtensionRegistrations(hasRegistrations);
|
|
286
|
+
},
|
|
287
|
+
);
|
|
287
288
|
const {
|
|
288
289
|
Button,
|
|
289
290
|
Banner,
|
|
@@ -301,9 +302,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
301
302
|
const fields = useMemo(() => deriveFormFields<TValues, TCtx>(screen), [screen]);
|
|
302
303
|
|
|
303
304
|
// Must be computed before submitConfig/useForm bakes it in (the controller
|
|
304
|
-
// freezes it on first render)
|
|
305
|
-
//
|
|
306
|
-
//
|
|
305
|
+
// freezes it on first render). This is the unfiltered field-name set from
|
|
306
|
+
// `fieldsFilter` ∩ `fields` — not the value-dependent `filteredSections`
|
|
307
|
+
// (which also drops `section.visible === false`). Hidden fields still fall
|
|
308
|
+
// out via form-controller's `hiddenFields` path. undefined = unscoped, since
|
|
309
|
+
// scoping would silently drop root-level .refine() issues.
|
|
307
310
|
// A `fieldsFilter` that matches nothing in `fields` (typo, renamed field)
|
|
308
311
|
// must fall back to unscoped too — an empty scope array would filter out
|
|
309
312
|
// ALL validation issues (kumiko-framework#1907), letting submit() through
|
|
@@ -361,11 +364,6 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
361
364
|
// re-subscribes with a "new" onChange → refires.
|
|
362
365
|
const onChangeRef = useRef(onChange);
|
|
363
366
|
onChangeRef.current = onChange;
|
|
364
|
-
// `schema` is typically an inline caller prop (`schema={z.object({...})}`)
|
|
365
|
-
// with unstable identity across renders — as an effect dep it would refire
|
|
366
|
-
// on every parent render, not just on a snapshot change, risking a loop if
|
|
367
|
-
// the caller's onChange triggers a parent re-render. Held in a ref like
|
|
368
|
-
// onChangeRef so only a real snapshot mutation retriggers this effect.
|
|
369
367
|
// Guards against redelivering to the SAME callback identity on every
|
|
370
368
|
// render (an inline-arrow onControlsReady would otherwise refire the
|
|
371
369
|
// effect below on every render since the callback itself is now a dep).
|
|
@@ -373,6 +371,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
373
371
|
// real handler after mount (e.g. `onControlsReady={ready ? cb : undefined}`)
|
|
374
372
|
// gets delivered to for THIS mount instead of never (fw#1899).
|
|
375
373
|
const deliveredControlsToRef = useRef<typeof onControlsReady>(undefined);
|
|
374
|
+
const onControlsReadyRef = useRef(onControlsReady);
|
|
375
|
+
onControlsReadyRef.current = onControlsReady;
|
|
376
|
+
const hasNavigatedRef = useRef(false);
|
|
377
|
+
const submittedRef = useRef(false);
|
|
378
|
+
const isSubmittingRef = useRef(false);
|
|
376
379
|
const scopeFieldNamesRef = useRef(scopeFieldNames);
|
|
377
380
|
scopeFieldNamesRef.current = scopeFieldNames;
|
|
378
381
|
const scopedValidate = useCallback(
|
|
@@ -416,14 +419,15 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
416
419
|
const patchAndScheduleDraftSave = useCallback(
|
|
417
420
|
(partial: Partial<TValues>) => {
|
|
418
421
|
controller.setValues(partial);
|
|
419
|
-
if (!draftEnabled || dispatcher === undefined) return;
|
|
422
|
+
if (!draftEnabled || dispatcher === undefined || disabled || submittedRef.current) return;
|
|
420
423
|
if (draftSaveTimerRef.current !== null) clearTimeout(draftSaveTimerRef.current);
|
|
421
424
|
draftSaveTimerRef.current = setTimeout(() => {
|
|
422
425
|
draftSaveTimerRef.current = null;
|
|
426
|
+
if (disabled || submittedRef.current) return;
|
|
423
427
|
saveDraftRef.current(currentStepRef.current);
|
|
424
428
|
}, PATCH_DRAFT_SAVE_DEBOUNCE_MS);
|
|
425
429
|
},
|
|
426
|
-
[controller, draftEnabled, dispatcher],
|
|
430
|
+
[controller, draftEnabled, dispatcher, disabled],
|
|
427
431
|
);
|
|
428
432
|
|
|
429
433
|
// Runs before the onChange effect below (declaration order = React
|
|
@@ -434,23 +438,27 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
434
438
|
// value that should derive dependent fields on mount needs controls.patch
|
|
435
439
|
// available on the very first onChange call, not just from the second
|
|
436
440
|
// keystroke onward.
|
|
441
|
+
const hasControlsReady = onControlsReady !== undefined;
|
|
437
442
|
useEffect(() => {
|
|
438
|
-
if (
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
443
|
+
if (!hasControlsReady) return;
|
|
444
|
+
const ready = onControlsReadyRef.current;
|
|
445
|
+
if (ready === undefined) return;
|
|
446
|
+
if (deliveredControlsToRef.current === ready) return;
|
|
447
|
+
deliveredControlsToRef.current = ready;
|
|
448
|
+
ready({
|
|
442
449
|
patch: patchAndScheduleDraftSave,
|
|
443
450
|
validate: scopedValidate,
|
|
444
451
|
getValues: () => controller.getSnapshot().values,
|
|
445
452
|
submit: () => handleSubmitRef.current(),
|
|
446
453
|
});
|
|
447
|
-
//
|
|
448
|
-
|
|
449
|
-
// useCallback over mount-stable deps) — onControlsReady is the only dep
|
|
450
|
-
// that can legitimately change post-mount, and the guard above stops an
|
|
451
|
-
// unstable inline-arrow identity from redelivering on every render.
|
|
452
|
-
}, [onControlsReady, controller, scopedValidate, patchAndScheduleDraftSave]);
|
|
454
|
+
// Dep on boolean presence only — inline-arrow identity must not redeliver.
|
|
455
|
+
}, [hasControlsReady, controller, scopedValidate, patchAndScheduleDraftSave]);
|
|
453
456
|
|
|
457
|
+
// `schema` is typically an inline caller prop (`schema={z.object({...})}`)
|
|
458
|
+
// with unstable identity across renders — as an effect dep it would refire
|
|
459
|
+
// on every parent render, not just on a snapshot change, risking a loop if
|
|
460
|
+
// the caller's onChange triggers a parent re-render. Held in a ref like
|
|
461
|
+
// onChangeRef so only a real snapshot mutation retriggers this effect.
|
|
454
462
|
const schemaRef = useRef(schema);
|
|
455
463
|
schemaRef.current = schema;
|
|
456
464
|
// Controls are guaranteed ready by the time this fires (see the
|
|
@@ -489,23 +497,28 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
489
497
|
if (draftId !== null && draftId === mintedDraftIdRef.current) return;
|
|
490
498
|
let cancelled = false;
|
|
491
499
|
void (async () => {
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
500
|
+
try {
|
|
501
|
+
const result = await dispatcher.query<{ readonly draft: FormDraftBlob | null }>(
|
|
502
|
+
FORM_DRAFT_GET,
|
|
503
|
+
{ draftKey },
|
|
504
|
+
);
|
|
505
|
+
// skip: superseded by a newer mount, or the draft lookup failed — an
|
|
506
|
+
// unreachable draft store must not break the form itself.
|
|
507
|
+
if (cancelled || !result.isSuccess) return;
|
|
508
|
+
const draft = result.data?.draft ?? null;
|
|
509
|
+
// skip: nothing saved yet for this key.
|
|
510
|
+
if (draft === null) return;
|
|
511
|
+
// skip: the user already typed or wizard-navigated while the lookup
|
|
512
|
+
// was in flight — their input wins over the stored draft.
|
|
513
|
+
if (controller.getSnapshot().isDirty || hasNavigatedRef.current) return;
|
|
514
|
+
// @cast-boundary form-draft blob: `values` round-trips through an opaque
|
|
515
|
+
// jsonb column and comes back untyped.
|
|
516
|
+
controller.setValues(draft.values as Partial<TValues>);
|
|
517
|
+
setRawStep(Math.max(draft.stepIndex, 0));
|
|
518
|
+
} catch (err: unknown) {
|
|
519
|
+
// biome-ignore lint/suspicious/noConsole: draft restore must not red-screen the form
|
|
520
|
+
console.warn("render-edit: draft restore query failed", err);
|
|
521
|
+
}
|
|
509
522
|
})();
|
|
510
523
|
return () => {
|
|
511
524
|
cancelled = true;
|
|
@@ -533,24 +546,29 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
533
546
|
dispatcher === undefined
|
|
534
547
|
)
|
|
535
548
|
return;
|
|
536
|
-
didListRef.current = true;
|
|
537
549
|
let cancelled = false;
|
|
538
550
|
void (async () => {
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
551
|
+
try {
|
|
552
|
+
const result = await dispatcher.query<{ readonly drafts: readonly DraftCandidate[] }>(
|
|
553
|
+
FORM_DRAFT_LIST,
|
|
554
|
+
{ screenId: screen.id },
|
|
555
|
+
);
|
|
556
|
+
// skip: superseded by a newer mount, or the lookup failed — an
|
|
557
|
+
// unreachable draft store must not break a fresh create.
|
|
558
|
+
if (cancelled || !result.isSuccess) return;
|
|
559
|
+
didListRef.current = true;
|
|
560
|
+
const prefix = newDraftPrefix(screen.id);
|
|
561
|
+
// list's LIKE-prefix scan also matches edit-mode drafts
|
|
562
|
+
// (`${screenId}:${entityId}`) — keep only create-mode ones.
|
|
563
|
+
const candidates = (result.data?.drafts ?? []).filter((d) => d.draftKey.startsWith(prefix));
|
|
564
|
+
// skip: no open drafts for this screen — stay null, saveDraft mints a
|
|
565
|
+
// fresh draftId on the first step change same as any other create.
|
|
566
|
+
if (candidates.length === 0) return;
|
|
567
|
+
setDraftCandidates(candidates);
|
|
568
|
+
} catch (err: unknown) {
|
|
569
|
+
// biome-ignore lint/suspicious/noConsole: draft lookup must not red-screen the form
|
|
570
|
+
console.warn("render-edit: draft list query failed", err);
|
|
571
|
+
}
|
|
554
572
|
})();
|
|
555
573
|
return () => {
|
|
556
574
|
cancelled = true;
|
|
@@ -671,11 +689,16 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
671
689
|
);
|
|
672
690
|
return;
|
|
673
691
|
}
|
|
674
|
-
void dispatcher
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
692
|
+
void dispatcher
|
|
693
|
+
.write(FORM_DRAFT_SAVE, {
|
|
694
|
+
draftKey: key,
|
|
695
|
+
values: controller.getSnapshot().values,
|
|
696
|
+
stepIndex,
|
|
697
|
+
})
|
|
698
|
+
.catch((err: unknown) => {
|
|
699
|
+
// biome-ignore lint/suspicious/noConsole: fire-and-forget draft save must not red-screen
|
|
700
|
+
console.warn("render-edit: draft save failed", err);
|
|
701
|
+
});
|
|
679
702
|
}
|
|
680
703
|
|
|
681
704
|
// Next: scoped validate() on the current step's fields — errors stay
|
|
@@ -684,6 +707,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
684
707
|
// controls.validate() API), so no call, no clearing of unrelated errors.
|
|
685
708
|
function handleWizardNext(): void {
|
|
686
709
|
const next = Math.min(currentStep + 1, lastStepIndex);
|
|
710
|
+
hasNavigatedRef.current = true;
|
|
687
711
|
// Locked state (#1896): `disabled` means "no input/no write", not "no
|
|
688
712
|
// navigation" — a disabled wizard must still be steppable so Back/Next
|
|
689
713
|
// reach every step. But neither validate() (would block on an empty
|
|
@@ -703,6 +727,11 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
703
727
|
|
|
704
728
|
function handleWizardBack(): void {
|
|
705
729
|
const previous = Math.max(currentStep - 1, 0);
|
|
730
|
+
hasNavigatedRef.current = true;
|
|
731
|
+
if (disabled) {
|
|
732
|
+
setRawStep(previous);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
706
735
|
setRawStep(previous);
|
|
707
736
|
saveDraft(previous);
|
|
708
737
|
}
|
|
@@ -770,6 +799,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
770
799
|
// native form submit (Enter key) reaches this handler regardless of the
|
|
771
800
|
// button's disabled attribute — block it here too, not just in the UI.
|
|
772
801
|
if (disabled) return;
|
|
802
|
+
// Sync guard — React state `isSubmitting` is too late for double-clicks
|
|
803
|
+
// in the same tick (customSubmit has no submitInFlight of its own).
|
|
804
|
+
if (isSubmittingRef.current) return;
|
|
805
|
+
isSubmittingRef.current = true;
|
|
773
806
|
setIsSubmitting(true);
|
|
774
807
|
setExtensionErrorKey(null);
|
|
775
808
|
try {
|
|
@@ -779,7 +812,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
779
812
|
if (snapshot.isUnchanged && extensionDirty) {
|
|
780
813
|
// Same discard as the main path: an extension-only save is still a
|
|
781
814
|
// successful submit, so the draft must not survive it.
|
|
782
|
-
if (await persistExtensions())
|
|
815
|
+
if (await persistExtensions()) {
|
|
816
|
+
submittedRef.current = true;
|
|
817
|
+
await discardDraft();
|
|
818
|
+
}
|
|
783
819
|
return;
|
|
784
820
|
}
|
|
785
821
|
let result: SubmitResult<unknown>;
|
|
@@ -829,7 +865,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
829
865
|
// `onSubmit` typically navigates away and unmounts this form, which
|
|
830
866
|
// would abort an in-flight discard and leave the draft behind after
|
|
831
867
|
// a successful submit.
|
|
832
|
-
if (result.isNoOp !== true)
|
|
868
|
+
if (result.isNoOp !== true) {
|
|
869
|
+
submittedRef.current = true;
|
|
870
|
+
await discardDraft();
|
|
871
|
+
}
|
|
833
872
|
extensionsPersisted = await persistExtensions();
|
|
834
873
|
} else if (result.validationBlocked) {
|
|
835
874
|
// Root-level `.refine()`/cross-field issues from controller.validate()
|
|
@@ -867,6 +906,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
867
906
|
// and would unmount it before the user sees the failure).
|
|
868
907
|
if (shouldNotifyCaller(result, extensionsPersisted)) onSubmit?.(result);
|
|
869
908
|
} finally {
|
|
909
|
+
isSubmittingRef.current = false;
|
|
870
910
|
setIsSubmitting(false);
|
|
871
911
|
}
|
|
872
912
|
}
|
|
@@ -936,7 +976,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
936
976
|
{translate("kumiko.actions.next")}
|
|
937
977
|
</Button>
|
|
938
978
|
)}
|
|
939
|
-
{isFormEditable && (!isWizard || isLastWizardStep) && (
|
|
979
|
+
{(isFormEditable || hasExtensionRegistrations) && (!isWizard || isLastWizardStep) && (
|
|
940
980
|
<Button
|
|
941
981
|
type="submit"
|
|
942
982
|
disabled={(snapshot.isUnchanged && !extensionDirty) || isSubmitting || disabled}
|
|
@@ -1068,6 +1108,19 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1068
1108
|
// navigating past its step — otherwise Finish only ran the last
|
|
1069
1109
|
// mounted step's handler and silently dropped earlier steps' writes.
|
|
1070
1110
|
const stepHidden = isWizard && sectionIndex !== currentStep;
|
|
1111
|
+
const wrapWizardStep = (key: string, el: ReactNode): ReactNode => {
|
|
1112
|
+
if (!isWizard) return el;
|
|
1113
|
+
if (WizardStepGroup === undefined) {
|
|
1114
|
+
throw new Error(
|
|
1115
|
+
"RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
return (
|
|
1119
|
+
<WizardStepGroup key={key} hidden={stepHidden}>
|
|
1120
|
+
{el}
|
|
1121
|
+
</WizardStepGroup>
|
|
1122
|
+
);
|
|
1123
|
+
};
|
|
1071
1124
|
// Off-screen wizard steps stay mounted (see comment above) but must
|
|
1072
1125
|
// not participate in native constraint validation, or the Next
|
|
1073
1126
|
// button's `type="submit"` triggers the browser's full-form check
|
|
@@ -1094,18 +1147,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1094
1147
|
validate={scopedValidate}
|
|
1095
1148
|
/>
|
|
1096
1149
|
);
|
|
1097
|
-
|
|
1098
|
-
if (WizardStepGroup === undefined) {
|
|
1099
|
-
// Both silent fallbacks are unsafe here — render-visible-all-steps or unmount-drops-registry.
|
|
1100
|
-
throw new Error(
|
|
1101
|
-
"RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
|
|
1102
|
-
);
|
|
1103
|
-
}
|
|
1104
|
-
return (
|
|
1105
|
-
<WizardStepGroup key={section.title} hidden={stepHidden}>
|
|
1106
|
-
{mount}
|
|
1107
|
-
</WizardStepGroup>
|
|
1108
|
-
);
|
|
1150
|
+
return wrapWizardStep(section.title, mount);
|
|
1109
1151
|
}
|
|
1110
1152
|
if (section.kind === "relatedList") {
|
|
1111
1153
|
// parentId is the displayed record's id — without it there's no
|
|
@@ -1163,17 +1205,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1163
1205
|
</Grid>
|
|
1164
1206
|
</Section>
|
|
1165
1207
|
);
|
|
1166
|
-
|
|
1167
|
-
if (WizardStepGroup === undefined) {
|
|
1168
|
-
throw new Error(
|
|
1169
|
-
"RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
|
|
1170
|
-
);
|
|
1171
|
-
}
|
|
1172
|
-
return (
|
|
1173
|
-
<WizardStepGroup key={sectionKey} hidden={stepHidden}>
|
|
1174
|
-
{sectionEl}
|
|
1175
|
-
</WizardStepGroup>
|
|
1176
|
-
);
|
|
1208
|
+
return wrapWizardStep(sectionKey, sectionEl);
|
|
1177
1209
|
})}
|
|
1178
1210
|
{formError !== null && (
|
|
1179
1211
|
<Banner
|
|
@@ -359,12 +359,18 @@ function FieldRendererOutput({
|
|
|
359
359
|
// App locale as default when the FormatSpec declares none of its own —
|
|
360
360
|
// otherwise locale-sensitive formats (timestamp/date/number/decimal/
|
|
361
361
|
// bigInt/unit) fell back to Intl's runtime default instead of the app
|
|
362
|
-
// language chosen via LocaleProvider (fw#2187).
|
|
363
|
-
//
|
|
364
|
-
// further below in readOnlyDisplayText.
|
|
362
|
+
// language chosen via LocaleProvider (fw#2187). Prefer renderer.locale
|
|
363
|
+
// when set; coalesce undefined (spread override) back to appLocale (#2332).
|
|
365
364
|
return (
|
|
366
365
|
<Text testId={`field-value-${field.field}`}>
|
|
367
|
-
{applyFormatSpec(
|
|
366
|
+
{applyFormatSpec(
|
|
367
|
+
{
|
|
368
|
+
...renderer,
|
|
369
|
+
locale: (renderer as { locale?: string }).locale ?? appLocale,
|
|
370
|
+
},
|
|
371
|
+
field.value,
|
|
372
|
+
t,
|
|
373
|
+
)}
|
|
368
374
|
</Text>
|
|
369
375
|
);
|
|
370
376
|
}
|
|
@@ -520,7 +526,7 @@ function renderInput({
|
|
|
520
526
|
);
|
|
521
527
|
}
|
|
522
528
|
case "money": {
|
|
523
|
-
const currency = field.currency
|
|
529
|
+
const currency = resolveMoneyCurrency(field.value, field.currency);
|
|
524
530
|
return (
|
|
525
531
|
<Input
|
|
526
532
|
kind="money"
|
|
@@ -692,6 +698,17 @@ function numberValue(v: unknown): number | "" {
|
|
|
692
698
|
// stored-config coercion) is MAJOR units too — every producer in this repo
|
|
693
699
|
// hands rehydrateMoney's `{amount,…}` shape or a raw major-unit number, never
|
|
694
700
|
// pre-scaled minor units.
|
|
701
|
+
function resolveMoneyCurrency(value: unknown, fieldCurrency: string | undefined): string {
|
|
702
|
+
if (
|
|
703
|
+
typeof value === "object" &&
|
|
704
|
+
value !== null &&
|
|
705
|
+
typeof (value as { currency?: unknown }).currency === "string"
|
|
706
|
+
) {
|
|
707
|
+
return (value as { currency: string }).currency;
|
|
708
|
+
}
|
|
709
|
+
return fieldCurrency ?? "EUR";
|
|
710
|
+
}
|
|
711
|
+
|
|
695
712
|
function moneyMinorValue(v: unknown, currency: string): number | "" {
|
|
696
713
|
if (v === undefined || v === null || v === "") return "";
|
|
697
714
|
if (typeof v === "number") return Math.round(v * 10 ** currencyDecimals(currency));
|
|
@@ -756,7 +773,7 @@ function readOnlyDisplayText(field: EditFieldViewModel, appLocale: string): stri
|
|
|
756
773
|
return n === "" ? "—" : new Intl.NumberFormat(appLocale).format(n);
|
|
757
774
|
}
|
|
758
775
|
case "money": {
|
|
759
|
-
const currency = field.currency
|
|
776
|
+
const currency = resolveMoneyCurrency(value, field.currency);
|
|
760
777
|
const minor = moneyMinorValue(value, currency);
|
|
761
778
|
if (minor === "") return "—";
|
|
762
779
|
const major = minor / 10 ** currencyDecimals(currency);
|
|
@@ -33,14 +33,18 @@ describe("useForm — mounted without a DispatcherProvider", () => {
|
|
|
33
33
|
expect(result.current.snapshot.values.title).toBe("");
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
test("submit() without an explicit dispatcher
|
|
36
|
+
test("submit() without an explicit dispatcher returns isSuccess:false instead of crashing the hook", async () => {
|
|
37
37
|
const { result } = renderHook(() =>
|
|
38
38
|
useForm({ initial: { title: "hello" }, submit: { type: "app:write:task:create" } }),
|
|
39
39
|
);
|
|
40
40
|
|
|
41
|
-
await
|
|
42
|
-
|
|
43
|
-
);
|
|
41
|
+
const submitResult = await result.current.controller.submit();
|
|
42
|
+
expect(submitResult.validationBlocked).toBe(false);
|
|
43
|
+
expect(submitResult.isSuccess).toBe(false);
|
|
44
|
+
if (submitResult.isSuccess || submitResult.validationBlocked) {
|
|
45
|
+
throw new Error("expected write failure");
|
|
46
|
+
}
|
|
47
|
+
expect(submitResult.error.message).toMatch(/submit\(\) called without a dispatcher/);
|
|
44
48
|
});
|
|
45
49
|
|
|
46
50
|
test("submit() with an explicit dispatcher still works without a provider", async () => {
|
package/src/i18n.tsx
CHANGED
|
@@ -193,6 +193,48 @@ function interpolate(template: string, params?: Readonly<Record<string, unknown>
|
|
|
193
193
|
});
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/** Non-throwing LocaleContext read — undefined outside LocaleProvider.
|
|
197
|
+
* Used by DataTable FormatCell so plain tables without a provider do not crash. */
|
|
198
|
+
export function useOptionalLocale(): string | undefined {
|
|
199
|
+
const ctx = useContext(LocaleContext);
|
|
200
|
+
useSyncExternalStore(
|
|
201
|
+
(onStoreChange) => (ctx ? ctx.resolver.subscribe(onStoreChange) : () => {}),
|
|
202
|
+
() => (ctx ? ctx.resolver.locale() : "en"),
|
|
203
|
+
() => "en",
|
|
204
|
+
);
|
|
205
|
+
return ctx === undefined ? undefined : ctx.resolver.locale();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Non-throwing translate — undefined outside LocaleProvider. */
|
|
209
|
+
export function useOptionalTranslation():
|
|
210
|
+
| ((key: string, params?: Readonly<Record<string, unknown>>) => string)
|
|
211
|
+
| undefined {
|
|
212
|
+
const ctx = useContext(LocaleContext);
|
|
213
|
+
const locale = useSyncExternalStore(
|
|
214
|
+
(onStoreChange) => (ctx ? ctx.resolver.subscribe(onStoreChange) : () => {}),
|
|
215
|
+
() => (ctx ? ctx.resolver.locale() : "en"),
|
|
216
|
+
() => "en",
|
|
217
|
+
);
|
|
218
|
+
const t = useCallback(
|
|
219
|
+
(key: string, params?: Readonly<Record<string, unknown>>): string => {
|
|
220
|
+
if (ctx === undefined) return key;
|
|
221
|
+
const resolved = ctx.resolver.translate(key, params);
|
|
222
|
+
if (resolved !== key) return resolved;
|
|
223
|
+
const languageRoot = locale.split("-")[0] ?? locale;
|
|
224
|
+
const localesToTry = [locale, languageRoot, ctx.fallbackLocale];
|
|
225
|
+
for (const bundle of ctx.fallbackBundles) {
|
|
226
|
+
for (const localeToTry of localesToTry) {
|
|
227
|
+
const value = bundle[localeToTry]?.[key];
|
|
228
|
+
if (value !== undefined) return interpolate(value, params);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return key;
|
|
232
|
+
},
|
|
233
|
+
[ctx, locale],
|
|
234
|
+
);
|
|
235
|
+
return ctx === undefined ? undefined : t;
|
|
236
|
+
}
|
|
237
|
+
|
|
196
238
|
/** Default-Resolver für Apps ohne eigene i18n-Schicht. Gibt jeden Key
|
|
197
239
|
* unverändert zurück — die Plugin-Fallback-Bundles erledigen dann die
|
|
198
240
|
* echte Übersetzung. Nützlich auch als Basis für Tests. */
|
package/src/index.ts
CHANGED
package/src/primitives.tsx
CHANGED
|
@@ -35,7 +35,7 @@ import type {
|
|
|
35
35
|
ConfigScope,
|
|
36
36
|
ConfigValueSource,
|
|
37
37
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
38
|
-
import type { FormWidth } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
38
|
+
import type { FieldIconKey, FormWidth } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
39
39
|
import type {
|
|
40
40
|
FieldIssue,
|
|
41
41
|
ListColumnViewModel,
|
|
@@ -179,9 +179,8 @@ export type InputProps =
|
|
|
179
179
|
/** Read-only Input (z.B. gewürfelter Free-Tier-Slug). Nicht `disabled`
|
|
180
180
|
* — bleibt fokussier-/kopierbar. */
|
|
181
181
|
readonly readOnly?: boolean;
|
|
182
|
-
/**
|
|
183
|
-
|
|
184
|
-
readonly icon?: string;
|
|
182
|
+
/** Closed FieldIconKey vocabulary (FIELD_ICONS registry, renderer-web). */
|
|
183
|
+
readonly icon?: FieldIconKey;
|
|
185
184
|
}
|
|
186
185
|
| {
|
|
187
186
|
readonly kind: "email";
|
|
@@ -223,9 +222,8 @@ export type InputProps =
|
|
|
223
222
|
readonly required?: boolean;
|
|
224
223
|
readonly hasError?: boolean;
|
|
225
224
|
readonly testId?: string;
|
|
226
|
-
/**
|
|
227
|
-
|
|
228
|
-
readonly icon?: string;
|
|
225
|
+
/** Closed FieldIconKey vocabulary (FIELD_ICONS registry, renderer-web). */
|
|
226
|
+
readonly icon?: FieldIconKey;
|
|
229
227
|
/** `<input step>`. "any" disables the native stepMismatch constraint
|
|
230
228
|
* (needed for decimal fields — integer fields leave this unset). */
|
|
231
229
|
readonly step?: number | "any";
|