@cosmicdrift/kumiko-renderer 0.193.1 → 0.195.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 +3 -3
- package/src/__tests__/error-i18n-defaults.test.ts +9 -0
- package/src/app/__tests__/content-editors.test.tsx +24 -5
- package/src/app/__tests__/content-preview.test.tsx +23 -1
- package/src/app/__tests__/form-schema.test.ts +8 -12
- package/src/app/content-editors.tsx +13 -6
- package/src/app/content-preview.tsx +3 -1
- package/src/app/extension-sections.tsx +5 -2
- package/src/app/form-schema.ts +13 -3
- package/src/app/kumiko-screen.tsx +8 -2
- package/src/components/__tests__/embedded-list-field.test.tsx +68 -1
- package/src/components/__tests__/render-field-money-embedded-list-consistency.test.tsx +212 -0
- package/src/components/__tests__/render-field-money-roundtrip.test.tsx +14 -0
- package/src/components/__tests__/render-field-unsupported-types.test.tsx +22 -1
- package/src/components/embedded-list-field.tsx +59 -7
- package/src/components/reference-create-dialog.tsx +1 -1
- package/src/components/render-edit-logic.ts +3 -8
- package/src/components/render-edit.tsx +152 -75
- package/src/components/render-field.tsx +15 -3
- package/src/i18n-defaults.ts +23 -5
- package/src/index.ts +1 -0
- package/src/primitives.tsx +28 -7
|
@@ -25,7 +25,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro
|
|
|
25
25
|
import type { z } from "zod";
|
|
26
26
|
import { ExtensionFormRegistryProvider, useExtensionFormHost } from "../app/extension-form-submit";
|
|
27
27
|
import { extensionSectionName, useExtensionSectionComponent } from "../app/extension-sections";
|
|
28
|
-
import {
|
|
28
|
+
import { useOptionalDispatcher } from "../context/dispatcher-context";
|
|
29
29
|
import { useDraftStorage } from "../context/draft-storage-context";
|
|
30
30
|
import { formatWhen } from "../format-when";
|
|
31
31
|
import { useForm } from "../hooks/use-form";
|
|
@@ -47,6 +47,9 @@ const FORM_DRAFT_GET = "form-draft:query:get";
|
|
|
47
47
|
const FORM_DRAFT_SAVE = "form-draft:write:save";
|
|
48
48
|
const FORM_DRAFT_DISCARD = "form-draft:write:discard";
|
|
49
49
|
const FORM_DRAFT_LIST = "form-draft:query:list";
|
|
50
|
+
// Mirrors bundled-features/src/form-draft/constants.ts's FORM_DRAFT_KEY_MAX_LENGTH
|
|
51
|
+
// — same hardcoding rationale as the QNs above (no dependency on that package).
|
|
52
|
+
const FORM_DRAFT_KEY_MAX_LENGTH = 256;
|
|
50
53
|
|
|
51
54
|
// Trailing-edge debounce for patch()-triggered draft saves (#1914). A single
|
|
52
55
|
// patch() call (VIN-decode, an extension section) should not save immediately
|
|
@@ -77,6 +80,14 @@ function newDraftPrefix(screenId: string): string {
|
|
|
77
80
|
return `${screenId}:new:`;
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
// `crypto.randomUUID` is missing in non-secure contexts and React Native/Hermes
|
|
84
|
+
// without a polyfill — guard like dispatcher-live.ts's generateRequestId.
|
|
85
|
+
function mintDraftId(): string {
|
|
86
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
87
|
+
if (typeof c?.randomUUID === "function") return c.randomUUID();
|
|
88
|
+
return `draft-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
80
91
|
// End-to-end renderer für einen entityEdit screen. Rendert aus-
|
|
81
92
|
// schließlich über Primitives — kein raw HTML. Ein Native-Renderer
|
|
82
93
|
// der dieselbe Primitives-Registry füllt kriegt das Form ohne weitere
|
|
@@ -149,7 +160,10 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
149
160
|
* reference on every call must not do so unconditionally: `setValues` is
|
|
150
161
|
* a no-op when the merged value is reference-equal to the current one,
|
|
151
162
|
* so only a converging patch settles instead of looping. Without this
|
|
152
|
-
* prop, existing behavior is unchanged.
|
|
163
|
+
* prop, existing behavior is unchanged. `onControlsReady` is guaranteed
|
|
164
|
+
* to have already fired by the time the mount-time `onChange` call
|
|
165
|
+
* happens, so a caller patching dependent fields from inside `onChange`
|
|
166
|
+
* never has to guard against `controls` being undefined. */
|
|
153
167
|
readonly onChange?: (state: RenderEditChangeState<TValues>) => void;
|
|
154
168
|
/** Controlled mode (issue #1887): called once after mount, hands the
|
|
155
169
|
* caller `patch`/`validate`/`getValues` bound to this RenderEdit
|
|
@@ -175,7 +189,13 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
175
189
|
* moot — e.g. Solon's editor pointing at an existing record instead of
|
|
176
190
|
* creating a new one. Extension sections are out of scope: RenderEdit has
|
|
177
191
|
* no way to force-disable an arbitrary registered component. Omitting
|
|
178
|
-
* this prop keeps unchanged behavior.
|
|
192
|
+
* this prop keeps unchanged behavior.
|
|
193
|
+
*
|
|
194
|
+
* ponytail: direct-consumer only — kumiko-screen.tsx's RenderEdit call
|
|
195
|
+
* sites pass explicit prop lists without a spread and never forward
|
|
196
|
+
* `disabled`, so a screen-driven app can't set the locked state today.
|
|
197
|
+
* Upgrade path if that's needed: thread a screen-spec flag through to
|
|
198
|
+
* `EntityEditCreateBody`/`EntityEditEditBody`. */
|
|
179
199
|
readonly disabled?: boolean;
|
|
180
200
|
/** Renders the fields without RenderEdit's own action bar (save, cancel,
|
|
181
201
|
* delete, copy-link). For hosts that put those controls into their own
|
|
@@ -250,7 +270,6 @@ function ExtensionSectionMount({
|
|
|
250
270
|
values,
|
|
251
271
|
patch,
|
|
252
272
|
validate,
|
|
253
|
-
hidden,
|
|
254
273
|
}: {
|
|
255
274
|
readonly section: EditExtensionSectionViewModel;
|
|
256
275
|
readonly entityName: string;
|
|
@@ -259,7 +278,6 @@ function ExtensionSectionMount({
|
|
|
259
278
|
readonly values?: Readonly<Record<string, unknown>>;
|
|
260
279
|
readonly patch?: (partial: Readonly<Record<string, unknown>>) => void;
|
|
261
280
|
readonly validate?: () => boolean;
|
|
262
|
-
readonly hidden?: boolean;
|
|
263
281
|
}): ReactNode {
|
|
264
282
|
const { Banner, Section, Text } = usePrimitives();
|
|
265
283
|
const name = extensionSectionName(section.component);
|
|
@@ -270,7 +288,6 @@ function ExtensionSectionMount({
|
|
|
270
288
|
key={section.title}
|
|
271
289
|
title={section.title}
|
|
272
290
|
testId={`section-extension-${section.title}`}
|
|
273
|
-
hidden={hidden}
|
|
274
291
|
>
|
|
275
292
|
<Banner variant="info" testId={`section-extension-placeholder-${section.title}`}>
|
|
276
293
|
<Text>
|
|
@@ -283,7 +300,7 @@ function ExtensionSectionMount({
|
|
|
283
300
|
);
|
|
284
301
|
}
|
|
285
302
|
return (
|
|
286
|
-
<Section title={section.title} testId={`section-extension-${section.title}`}
|
|
303
|
+
<Section title={section.title} testId={`section-extension-${section.title}`}>
|
|
287
304
|
<Component
|
|
288
305
|
entityName={entityName}
|
|
289
306
|
entityId={entityId}
|
|
@@ -334,10 +351,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
334
351
|
// ohnehin nur in einem mounted Kumiko-App-Tree läuft.
|
|
335
352
|
const t = useTranslation();
|
|
336
353
|
const translate = translateProp ?? t;
|
|
337
|
-
const dispatcher =
|
|
354
|
+
const dispatcher = useOptionalDispatcher();
|
|
338
355
|
|
|
339
356
|
const isWizard = screen.layout.mode === "wizard";
|
|
340
|
-
const draftEnabled = isWizard && screen.layout.draft === true;
|
|
357
|
+
const draftEnabled = isWizard && screen.layout.draft === true && dispatcher !== undefined;
|
|
341
358
|
const isCreateMode = entityIdProp === undefined || entityIdProp === null || entityIdProp === "";
|
|
342
359
|
const draftStorage = useDraftStorage();
|
|
343
360
|
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
|
@@ -378,23 +395,26 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
378
395
|
const [extensionErrorKey, setExtensionErrorKey] = useState<string | null>(null);
|
|
379
396
|
const { registry: extensionFormRegistry, runAll: runExtensionSubmits } =
|
|
380
397
|
useExtensionFormHost(setExtensionDirty);
|
|
381
|
-
const {
|
|
382
|
-
|
|
398
|
+
const {
|
|
399
|
+
Button,
|
|
400
|
+
Banner,
|
|
401
|
+
Dialog,
|
|
402
|
+
Form,
|
|
403
|
+
Section,
|
|
404
|
+
Grid,
|
|
405
|
+
GridCell,
|
|
406
|
+
Text,
|
|
407
|
+
Progress,
|
|
408
|
+
StepBar,
|
|
409
|
+
WizardStepGroup,
|
|
410
|
+
} = usePrimitives();
|
|
383
411
|
|
|
384
412
|
const fields = useMemo(() => deriveFormFields<TValues, TCtx>(screen), [screen]);
|
|
385
413
|
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
// kann daher nicht von `vm`/`filteredSections` abgeleitet werden, die erst
|
|
391
|
-
// nach dem Controller (aus snapshot.values) existieren — die Feldnamen-Menge
|
|
392
|
-
// pro Section ist aber wertunabhängig (nur visible/readOnly/value hängen von
|
|
393
|
-
// `values` ab), also liefert diese Ableitung dieselbe Menge wie
|
|
394
|
-
// filterEditSections(vm.sections, fieldsFilter) es täte. undefined (= kein
|
|
395
|
-
// Filter aktiv) heißt unscoped validate/submit — auf "alle gerenderten
|
|
396
|
-
// Felder" scopen würde sonst root-level .refine()-Issues aus der
|
|
397
|
-
// unscoped-Validierung stillschweigend wegfiltern.
|
|
414
|
+
// Must be computed before submitConfig/useForm bakes it in (the controller
|
|
415
|
+
// freezes it on first render) — safe because a section's field-name set is
|
|
416
|
+
// value-independent, so it matches filterEditSections(vm.sections, fieldsFilter).
|
|
417
|
+
// undefined = unscoped, since scoping would silently drop root-level .refine() issues.
|
|
398
418
|
const scopeFieldNames = useMemo(
|
|
399
419
|
() =>
|
|
400
420
|
fieldsFilter === undefined ? undefined : fieldsFilter.filter((f) => Object.hasOwn(fields, f)),
|
|
@@ -453,23 +473,6 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
453
473
|
// on every parent render, not just on a snapshot change, risking a loop if
|
|
454
474
|
// the caller's onChange triggers a parent re-render. Held in a ref like
|
|
455
475
|
// onChangeRef so only a real snapshot mutation retriggers this effect.
|
|
456
|
-
const schemaRef = useRef(schema);
|
|
457
|
-
schemaRef.current = schema;
|
|
458
|
-
useEffect(() => {
|
|
459
|
-
const cb = onChangeRef.current;
|
|
460
|
-
if (cb === undefined) return;
|
|
461
|
-
// Dry-run parse against `schema` — NOT controller.validate(). Calling
|
|
462
|
-
// validate() here would write field-level errors into snapshot.errors
|
|
463
|
-
// on every keystroke, painting error messages while the user is still
|
|
464
|
-
// typing. `valid` can therefore legitimately diverge from what's
|
|
465
|
-
// currently rendered under the fields (the last *mutating* validate()
|
|
466
|
-
// call, e.g. from controls.validate() or submit()).
|
|
467
|
-
const currentSchema = schemaRef.current;
|
|
468
|
-
const valid =
|
|
469
|
-
currentSchema === undefined ? true : currentSchema.safeParse(snapshot.values).success;
|
|
470
|
-
cb({ values: snapshot.values, changes: snapshot.changes, dirty: snapshot.isDirty, valid });
|
|
471
|
-
}, [snapshot]);
|
|
472
|
-
|
|
473
476
|
const onControlsReadyRef = useRef(onControlsReady);
|
|
474
477
|
onControlsReadyRef.current = onControlsReady;
|
|
475
478
|
const scopeFieldNamesRef = useRef(scopeFieldNames);
|
|
@@ -507,16 +510,24 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
507
510
|
const patchAndScheduleDraftSave = useCallback(
|
|
508
511
|
(partial: Partial<TValues>) => {
|
|
509
512
|
controller.setValues(partial);
|
|
510
|
-
if (!draftEnabled) return;
|
|
513
|
+
if (!draftEnabled || dispatcher === undefined) return;
|
|
511
514
|
if (draftSaveTimerRef.current !== null) clearTimeout(draftSaveTimerRef.current);
|
|
512
515
|
draftSaveTimerRef.current = setTimeout(() => {
|
|
513
516
|
draftSaveTimerRef.current = null;
|
|
514
517
|
saveDraftRef.current(currentStepRef.current);
|
|
515
518
|
}, PATCH_DRAFT_SAVE_DEBOUNCE_MS);
|
|
516
519
|
},
|
|
517
|
-
[controller, draftEnabled],
|
|
520
|
+
[controller, draftEnabled, dispatcher],
|
|
518
521
|
);
|
|
519
522
|
|
|
523
|
+
// Runs before the onChange effect below (declaration order = React
|
|
524
|
+
// execution order) so that an onChange fired on mount always sees
|
|
525
|
+
// `controls !== undefined` — both effects depend only on mount-stable
|
|
526
|
+
// values (controller/scopedValidate/patchAndScheduleDraftSave), so the
|
|
527
|
+
// swap is otherwise a no-op. See the #1888 VIN-decode test: an initial
|
|
528
|
+
// value that should derive dependent fields on mount needs controls.patch
|
|
529
|
+
// available on the very first onChange call, not just from the second
|
|
530
|
+
// keystroke onward.
|
|
520
531
|
useEffect(() => {
|
|
521
532
|
const cb = onControlsReadyRef.current;
|
|
522
533
|
if (cb === undefined) return;
|
|
@@ -532,9 +543,28 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
532
543
|
// RenderEdit mount in practice.
|
|
533
544
|
}, [controller, scopedValidate, patchAndScheduleDraftSave]);
|
|
534
545
|
|
|
546
|
+
const schemaRef = useRef(schema);
|
|
547
|
+
schemaRef.current = schema;
|
|
548
|
+
// Controls are guaranteed ready by the time this fires (see the
|
|
549
|
+
// onControlsReady effect above, which is declared first on purpose).
|
|
550
|
+
useEffect(() => {
|
|
551
|
+
const cb = onChangeRef.current;
|
|
552
|
+
if (cb === undefined) return;
|
|
553
|
+
// Dry-run parse against `schema` — NOT controller.validate(). Calling
|
|
554
|
+
// validate() here would write field-level errors into snapshot.errors
|
|
555
|
+
// on every keystroke, painting error messages while the user is still
|
|
556
|
+
// typing. `valid` can therefore legitimately diverge from what's
|
|
557
|
+
// currently rendered under the fields (the last *mutating* validate()
|
|
558
|
+
// call, e.g. from controls.validate() or submit()).
|
|
559
|
+
const currentSchema = schemaRef.current;
|
|
560
|
+
const valid =
|
|
561
|
+
currentSchema === undefined ? true : currentSchema.safeParse(snapshot.values).success;
|
|
562
|
+
cb({ values: snapshot.values, changes: snapshot.changes, dirty: snapshot.isDirty, valid });
|
|
563
|
+
}, [snapshot]);
|
|
564
|
+
|
|
535
565
|
useEffect(() => {
|
|
536
566
|
// skip: this screen does not persist a draft.
|
|
537
|
-
if (!draftEnabled) return;
|
|
567
|
+
if (!draftEnabled || dispatcher === undefined) return;
|
|
538
568
|
// skip: create-mode with no draftId yet — nothing to restore, either
|
|
539
569
|
// the list-fallback effect below finds one or the first step change
|
|
540
570
|
// mints a fresh one.
|
|
@@ -581,7 +611,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
581
611
|
// skip: this screen does not persist a draft, this is edit-mode, a
|
|
582
612
|
// draftId is already known (from storage or an earlier adoption), or
|
|
583
613
|
// this mount already ran the list lookup once (didListRef).
|
|
584
|
-
if (
|
|
614
|
+
if (
|
|
615
|
+
!draftEnabled ||
|
|
616
|
+
!isCreateMode ||
|
|
617
|
+
draftId !== null ||
|
|
618
|
+
didListRef.current ||
|
|
619
|
+
dispatcher === undefined
|
|
620
|
+
)
|
|
621
|
+
return;
|
|
585
622
|
didListRef.current = true;
|
|
586
623
|
let cancelled = false;
|
|
587
624
|
void (async () => {
|
|
@@ -665,17 +702,19 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
665
702
|
// Step transitions only — never per keystroke. Deliberately not awaited: a
|
|
666
703
|
// failed draft save must not block the step change.
|
|
667
704
|
//
|
|
668
|
-
// Create-mode, first step change
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
//
|
|
705
|
+
// Create-mode, first draft save (step change OR a debounced patch() from
|
|
706
|
+
// controlled mode / extension sections, see patchAndScheduleDraftSave
|
|
707
|
+
// above — both call this on step 0 too): mints the draftId here (issue
|
|
708
|
+
// #1913) rather than at mount, so a form nobody ever interacted with
|
|
709
|
+
// never claims a draftId or writes a row. `draftKey`/`draftId` state
|
|
710
|
+
// won't reflect the mint until the next render, so the just-minted key is
|
|
672
711
|
// computed inline instead of read from the memoized `draftKey`.
|
|
673
712
|
function saveDraft(stepIndex: number): void {
|
|
674
713
|
// skip: this screen does not persist a draft.
|
|
675
|
-
if (!draftEnabled) return;
|
|
714
|
+
if (!draftEnabled || dispatcher === undefined) return;
|
|
676
715
|
let key = draftKey;
|
|
677
716
|
if (isCreateMode && draftId === null) {
|
|
678
|
-
const mintedId =
|
|
717
|
+
const mintedId = mintDraftId();
|
|
679
718
|
mintedDraftIdRef.current = mintedId;
|
|
680
719
|
draftStorage.setDraftId(screen.id, mintedId);
|
|
681
720
|
setDraftId(mintedId);
|
|
@@ -689,6 +728,16 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
689
728
|
// in practice (mint above always produces one), kept as a type-level
|
|
690
729
|
// guard against a stale `undefined` key ever reaching the write.
|
|
691
730
|
if (key === undefined) return;
|
|
731
|
+
// draftKeySchema.max(FORM_DRAFT_KEY_MAX_LENGTH) rejects this server-side —
|
|
732
|
+
// catch it here instead of losing the save silently to the fire-and-forget
|
|
733
|
+
// write below (the user would only notice on resume, with nothing to resume).
|
|
734
|
+
if (key.length > FORM_DRAFT_KEY_MAX_LENGTH) {
|
|
735
|
+
// biome-ignore lint/suspicious/noConsole: no error-surfacing path exists for a fire-and-forget draft save.
|
|
736
|
+
console.warn(
|
|
737
|
+
`RenderEdit: draftKey "${key}" is ${key.length} chars, over the server's ${FORM_DRAFT_KEY_MAX_LENGTH}-char limit — skipping this draft save. Shorten screen.id or entity id.`,
|
|
738
|
+
);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
692
741
|
void dispatcher.write(FORM_DRAFT_SAVE, {
|
|
693
742
|
draftKey: key,
|
|
694
743
|
values: controller.getSnapshot().values,
|
|
@@ -727,7 +776,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
727
776
|
|
|
728
777
|
async function discardDraft(): Promise<void> {
|
|
729
778
|
// skip: this screen does not persist a draft.
|
|
730
|
-
if (!draftEnabled) return;
|
|
779
|
+
if (!draftEnabled || dispatcher === undefined) return;
|
|
731
780
|
// A pending debounced patch-save must not fire after discard — it would
|
|
732
781
|
// resurrect the draft it just deleted.
|
|
733
782
|
if (draftSaveTimerRef.current !== null) {
|
|
@@ -791,8 +840,8 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
791
840
|
setIsSubmitting(true);
|
|
792
841
|
setExtensionErrorKey(null);
|
|
793
842
|
try {
|
|
794
|
-
// Extension-only:
|
|
795
|
-
//
|
|
843
|
+
// Extension-only: only a section is dirty, the main form is unchanged.
|
|
844
|
+
// No entity write (would send an empty changes payload) — only
|
|
796
845
|
// die Section-Handler laufen lassen.
|
|
797
846
|
if (snapshot.isUnchanged && extensionDirty) {
|
|
798
847
|
// Same discard as the main path: an extension-only save is still a
|
|
@@ -802,27 +851,27 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
802
851
|
}
|
|
803
852
|
let result: SubmitResult<unknown>;
|
|
804
853
|
if (customSubmit !== undefined) {
|
|
805
|
-
// customSubmit
|
|
806
|
-
//
|
|
854
|
+
// customSubmit path (e.g. configEdit, which fires a separate write
|
|
855
|
+
// per field). Client-side validation first, then
|
|
807
856
|
// an den Caller; on-success rebased der Form-State explizit
|
|
808
|
-
//
|
|
809
|
-
//
|
|
857
|
+
// because controller.submit() normally does this itself and
|
|
858
|
+
// without customSubmit's help the controller knows nothing about
|
|
810
859
|
// erfolgreichen Submit (isUnchanged blieb sonst false).
|
|
811
860
|
//
|
|
812
861
|
// WICHTIG: snapshot direkt vom Controller holen statt aus
|
|
813
|
-
// React
|
|
814
|
-
//
|
|
815
|
-
//
|
|
862
|
+
// React state. On rapid fill→click, React batching may not have
|
|
863
|
+
// committed input state updates yet when the submit click fires.
|
|
864
|
+
// handleSubmit's closure would then run with
|
|
816
865
|
// stale snapshot.changes={} laufen, customSubmit fired keine
|
|
817
866
|
// Writes, returnt success, Form rebase → User glaubt "saved"
|
|
818
867
|
// aber gar nichts ist passiert. controller.getSnapshot() ist
|
|
819
868
|
// immer aktuell — der Controller ist die Source-of-Truth, die
|
|
820
|
-
// React
|
|
869
|
+
// React state is only a mirror for rendering.
|
|
821
870
|
const valid = controller.validate(scopeFieldNames);
|
|
822
871
|
if (!valid) {
|
|
823
|
-
// Field
|
|
824
|
-
//
|
|
825
|
-
//
|
|
872
|
+
// Field order matters: validationBlocked:true is its own
|
|
873
|
+
// variant in the SubmitResult union (NOT mixed with data/error);
|
|
874
|
+
// TS only narrows cleanly without a discriminator fight.
|
|
826
875
|
const blocked: SubmitResult<unknown> = {
|
|
827
876
|
validationBlocked: true,
|
|
828
877
|
isSuccess: false,
|
|
@@ -835,8 +884,8 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
835
884
|
} else {
|
|
836
885
|
result = await controller.submit();
|
|
837
886
|
}
|
|
838
|
-
// Form-level
|
|
839
|
-
// Field
|
|
887
|
+
// Form-level errors (without field-level details) go to the banner.
|
|
888
|
+
// Field errors flow via snapshot.errors into the individual fields.
|
|
840
889
|
let extensionsPersisted = true;
|
|
841
890
|
if (result.isSuccess) {
|
|
842
891
|
setFormError(null);
|
|
@@ -888,7 +937,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
888
937
|
|
|
889
938
|
// Sticky-top Action-Bar: Delete (links, destructive) + Cancel +
|
|
890
939
|
// Save. Delete sitzt links abgesetzt damit die Click-Distanz zu
|
|
891
|
-
// Save
|
|
940
|
+
// Save is large; red styling + confirm dialog are enough protection
|
|
892
941
|
// gegen Fehlklicks. Save bleibt rechts (primary affordance).
|
|
893
942
|
const formActions = (
|
|
894
943
|
<>
|
|
@@ -957,7 +1006,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
957
1006
|
// Title + Subtitle, create/edit-bewusst. i18n-Keys (mode = "create"|"edit"):
|
|
958
1007
|
// screen:<id>.<mode>.title / .<mode>.subtitle
|
|
959
1008
|
// Fallback-Kette: mode-spezifisch → generisch (screen:<id>.title/.subtitle).
|
|
960
|
-
// title
|
|
1009
|
+
// title falls back to screenId; subtitle to undefined (no subtitle).
|
|
961
1010
|
const isCreate = (() => {
|
|
962
1011
|
const id = resolveExtensionEntityId(entityIdProp, vm.id);
|
|
963
1012
|
return id == null || id === "";
|
|
@@ -1049,9 +1098,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1049
1098
|
}
|
|
1050
1099
|
return (
|
|
1051
1100
|
<StepBar
|
|
1052
|
-
steps={filteredSections.map(
|
|
1053
|
-
(section, sectionIndex) => section.title ?? String(sectionIndex + 1),
|
|
1054
|
-
)}
|
|
1101
|
+
steps={filteredSections.map((section) => section.title ?? "")}
|
|
1055
1102
|
currentIndex={currentStep}
|
|
1056
1103
|
compactLabel={compactLabel}
|
|
1057
1104
|
testId="render-edit-wizard-steps"
|
|
@@ -1068,8 +1115,17 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1068
1115
|
// navigating past its step — otherwise Finish only ran the last
|
|
1069
1116
|
// mounted step's handler and silently dropped earlier steps' writes.
|
|
1070
1117
|
const stepHidden = isWizard && sectionIndex !== currentStep;
|
|
1118
|
+
// Off-screen wizard steps stay mounted (see comment above) but must
|
|
1119
|
+
// not participate in native constraint validation, or the Next
|
|
1120
|
+
// button's `type="submit"` triggers the browser's full-form check
|
|
1121
|
+
// — including required fields on unvisited steps, which are not
|
|
1122
|
+
// focusable while hidden, so the wizard silently stops navigating
|
|
1123
|
+
// (only a console warning, no visible error). A plain `hidden`
|
|
1124
|
+
// attribute does NOT bar descendants from constraint validation on
|
|
1125
|
+
// web — see WizardStepGroupProps for what implementations must
|
|
1126
|
+
// guarantee.
|
|
1071
1127
|
if (section.kind === "extension") {
|
|
1072
|
-
|
|
1128
|
+
const mount = (
|
|
1073
1129
|
<ExtensionSectionMount
|
|
1074
1130
|
key={section.title}
|
|
1075
1131
|
section={section}
|
|
@@ -1083,9 +1139,20 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1083
1139
|
patchAndScheduleDraftSave as (partial: Readonly<Record<string, unknown>>) => void
|
|
1084
1140
|
}
|
|
1085
1141
|
validate={scopedValidate}
|
|
1086
|
-
hidden={stepHidden}
|
|
1087
1142
|
/>
|
|
1088
1143
|
);
|
|
1144
|
+
if (!isWizard) return mount;
|
|
1145
|
+
if (WizardStepGroup === undefined) {
|
|
1146
|
+
// Both silent fallbacks are unsafe here — render-visible-all-steps or unmount-drops-registry.
|
|
1147
|
+
throw new Error(
|
|
1148
|
+
"RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
|
|
1149
|
+
);
|
|
1150
|
+
}
|
|
1151
|
+
return (
|
|
1152
|
+
<WizardStepGroup key={section.title} hidden={stepHidden}>
|
|
1153
|
+
{mount}
|
|
1154
|
+
</WizardStepGroup>
|
|
1155
|
+
);
|
|
1089
1156
|
}
|
|
1090
1157
|
if (!section.visible) return null;
|
|
1091
1158
|
// Section-Header unterdrücken wenn er den Form-Titel der
|
|
@@ -1094,13 +1161,12 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1094
1161
|
const sectionTitle = section.title === formTitle ? undefined : section.title;
|
|
1095
1162
|
// Titellose Sections kollidieren sonst auf key/testId — Index-Fallback.
|
|
1096
1163
|
const sectionKey = section.title ?? `section-${sectionIndex}`;
|
|
1097
|
-
|
|
1164
|
+
const sectionEl = (
|
|
1098
1165
|
<Section
|
|
1099
1166
|
key={sectionKey}
|
|
1100
1167
|
{...(sectionTitle !== undefined && { title: sectionTitle })}
|
|
1101
1168
|
{...(section.description !== undefined && { subtitle: section.description })}
|
|
1102
1169
|
testId={`section-${sectionKey}`}
|
|
1103
|
-
hidden={stepHidden}
|
|
1104
1170
|
>
|
|
1105
1171
|
<Grid columns={section.columns}>
|
|
1106
1172
|
{section.fields.map((field: EditFieldViewModel) => (
|
|
@@ -1126,6 +1192,17 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1126
1192
|
</Grid>
|
|
1127
1193
|
</Section>
|
|
1128
1194
|
);
|
|
1195
|
+
if (!isWizard) return sectionEl;
|
|
1196
|
+
if (WizardStepGroup === undefined) {
|
|
1197
|
+
throw new Error(
|
|
1198
|
+
"RenderEdit: wizard layout requires primitives.WizardStepGroup, but none is registered.",
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
1201
|
+
return (
|
|
1202
|
+
<WizardStepGroup key={sectionKey} hidden={stepHidden}>
|
|
1203
|
+
{sectionEl}
|
|
1204
|
+
</WizardStepGroup>
|
|
1205
|
+
);
|
|
1129
1206
|
})}
|
|
1130
1207
|
{formError !== null && (
|
|
1131
1208
|
<Banner
|
|
@@ -23,6 +23,12 @@ import { ReferenceCreateDialog } from "./reference-create-dialog";
|
|
|
23
23
|
// Der field.type → Input-kind Mapping bleibt hier, weil es
|
|
24
24
|
// Domain-Logik ist (EntityDefinition-Feldtyp) und nicht Darstellung.
|
|
25
25
|
|
|
26
|
+
// No `hideLabel` prop here (fw#1870/#1871#3, deliberately out of scope):
|
|
27
|
+
// RenderField is driven entirely by EditFieldViewModel/EntityEditScreenDefinition,
|
|
28
|
+
// which have no per-field hideLabel slot — wiring it through would need a
|
|
29
|
+
// schema change, not just a prop. Declarative grid/table screens stay on
|
|
30
|
+
// visible labels until that schema work happens; imperative *Field widgets
|
|
31
|
+
// (form-fields.tsx, AiTextField) already support it.
|
|
26
32
|
export type RenderFieldProps = {
|
|
27
33
|
readonly field: EditFieldViewModel;
|
|
28
34
|
readonly issues?: readonly FieldIssue[];
|
|
@@ -53,7 +59,7 @@ export function RenderField({
|
|
|
53
59
|
fieldAppendix,
|
|
54
60
|
allIssues,
|
|
55
61
|
}: RenderFieldProps): ReactNode {
|
|
56
|
-
const { Field, Input, Banner } = usePrimitives();
|
|
62
|
+
const { Field, Input, Banner, Text } = usePrimitives();
|
|
57
63
|
// App-Locale (i18n) für money/date-Inputs — sonst fielen sie auf
|
|
58
64
|
// navigator.language (Browser-Sprache) zurück statt der gewählten
|
|
59
65
|
// App-Sprache. BEWUSSTE API-Verschärfung (seit 0.38): RenderField ist
|
|
@@ -88,7 +94,7 @@ export function RenderField({
|
|
|
88
94
|
featureName={featureName ?? ""}
|
|
89
95
|
/>
|
|
90
96
|
) : (
|
|
91
|
-
renderInput({ field, id, hasError, onChange, Input, appLocale, Banner, t })
|
|
97
|
+
renderInput({ field, id, hasError, onChange, Input, appLocale, Banner, Text, t })
|
|
92
98
|
);
|
|
93
99
|
|
|
94
100
|
return (
|
|
@@ -303,6 +309,7 @@ function renderInput({
|
|
|
303
309
|
Input,
|
|
304
310
|
appLocale,
|
|
305
311
|
Banner,
|
|
312
|
+
Text,
|
|
306
313
|
t,
|
|
307
314
|
}: {
|
|
308
315
|
readonly field: EditFieldViewModel;
|
|
@@ -312,6 +319,7 @@ function renderInput({
|
|
|
312
319
|
readonly Input: ReturnType<typeof usePrimitives>["Input"];
|
|
313
320
|
readonly appLocale: string;
|
|
314
321
|
readonly Banner: ReturnType<typeof usePrimitives>["Banner"];
|
|
322
|
+
readonly Text: ReturnType<typeof usePrimitives>["Text"];
|
|
315
323
|
readonly t: ReturnType<typeof useTranslation>;
|
|
316
324
|
}): ReactNode {
|
|
317
325
|
const common = {
|
|
@@ -476,6 +484,7 @@ function renderInput({
|
|
|
476
484
|
{...(field.entityType !== undefined && { entityType: field.entityType })}
|
|
477
485
|
{...(field.fieldName !== undefined && { fieldName: field.fieldName })}
|
|
478
486
|
{...(field.imageVariant !== undefined && { imageVariant: field.imageVariant })}
|
|
487
|
+
{...(field.capture !== undefined && { capture: field.capture })}
|
|
479
488
|
/>
|
|
480
489
|
);
|
|
481
490
|
}
|
|
@@ -492,12 +501,15 @@ function renderInput({
|
|
|
492
501
|
case "embedded":
|
|
493
502
|
case "jsonb":
|
|
494
503
|
case "files":
|
|
495
|
-
case "images":
|
|
504
|
+
case "images": {
|
|
505
|
+
const hasValue = field.value !== undefined && field.value !== null && field.value !== "";
|
|
496
506
|
return (
|
|
497
507
|
<Banner id={id} variant="info">
|
|
498
508
|
{t("kumiko.field.unsupported")}
|
|
509
|
+
{hasValue && <Text variant="code">{JSON.stringify(field.value)}</Text>}
|
|
499
510
|
</Banner>
|
|
500
511
|
);
|
|
512
|
+
}
|
|
501
513
|
default: {
|
|
502
514
|
// text + unknown scalar type → text input. If TextFieldDef.multiline
|
|
503
515
|
// is set (the view-model carries it), the renderer switches to
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -30,9 +30,6 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
30
30
|
"kumiko.wizard.step": "Schritt {current} von {total}",
|
|
31
31
|
"kumiko.wizard.step-with-title": "Schritt {current} von {total} · {title}",
|
|
32
32
|
|
|
33
|
-
// StepBar widget (renderer-web) — screen-reader text for completed steps whose number is visually replaced by a checkmark.
|
|
34
|
-
"kumiko.widget.stepBar.done": "Erledigt",
|
|
35
|
-
|
|
36
33
|
// Version — Update-Awareness-Banner (UpdateChecker).
|
|
37
34
|
"kumiko.version.update-available": "Eine neue Version ist verfügbar.",
|
|
38
35
|
|
|
@@ -57,6 +54,10 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
57
54
|
"kumiko.field.embedded-list.move-down": "Nach unten verschieben",
|
|
58
55
|
"kumiko.field.embedded-list.empty": "Noch keine Zeilen.",
|
|
59
56
|
"kumiko.field.embedded-list.empty-cta": "Erste Zeile hinzufügen",
|
|
57
|
+
"kumiko.field.embedded-list.paste-rows-truncated":
|
|
58
|
+
"{count} eingefügte Zeile(n) wurden verworfen (maximale Zeilenzahl erreicht).",
|
|
59
|
+
"kumiko.field.embedded-list.paste-cells-unmatched":
|
|
60
|
+
"{count} Zelle(n) hatten keine passende Option und wurden nicht geändert.",
|
|
60
61
|
|
|
61
62
|
// List — DataTable Toolbar, Empty-State, Search.
|
|
62
63
|
"kumiko.list.search-placeholder": "Suchen…",
|
|
@@ -83,12 +84,20 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
83
84
|
"kumiko.widget.upload.uploading": "Wird hochgeladen…",
|
|
84
85
|
"kumiko.widget.upload.done": "Hochgeladen",
|
|
85
86
|
"kumiko.widget.upload.error": "Fehlgeschlagen",
|
|
87
|
+
"kumiko.widget.upload.rejected-type": "Dateityp nicht erlaubt",
|
|
88
|
+
|
|
89
|
+
// Widgets — StepBar screen-reader text for completed steps whose number is visually replaced by a checkmark.
|
|
90
|
+
"kumiko.widget.step-bar.done": "Erledigt",
|
|
86
91
|
|
|
87
92
|
// Nav — Sidebar Tree (Toggle-aria-Labels).
|
|
88
93
|
"kumiko.nav.expand": "Aufklappen",
|
|
89
94
|
"kumiko.nav.collapse": "Zuklappen",
|
|
90
95
|
"kumiko.nav.search": "Navigation durchsuchen…",
|
|
91
96
|
|
|
97
|
+
// Workspace — Switcher-Trigger aria-Label (renderer-web).
|
|
98
|
+
"kumiko.workspace.switch": "Workspace wechseln",
|
|
99
|
+
"kumiko.workspace.select": "Workspace wählen",
|
|
100
|
+
|
|
92
101
|
// Dialog — Confirm-Buttons + Close-aria-Label.
|
|
93
102
|
"kumiko.dialog.confirm": "Bestätigen",
|
|
94
103
|
"kumiko.dialog.cancel": "Abbrechen",
|
|
@@ -211,8 +220,6 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
211
220
|
"kumiko.wizard.step": "Step {current} of {total}",
|
|
212
221
|
"kumiko.wizard.step-with-title": "Step {current} of {total} · {title}",
|
|
213
222
|
|
|
214
|
-
"kumiko.widget.stepBar.done": "Done",
|
|
215
|
-
|
|
216
223
|
"kumiko.version.update-available": "A new version is available.",
|
|
217
224
|
|
|
218
225
|
"kumiko.toast.learn-more": "Learn more",
|
|
@@ -234,6 +241,10 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
234
241
|
"kumiko.field.embedded-list.move-down": "Move down",
|
|
235
242
|
"kumiko.field.embedded-list.empty": "No rows yet.",
|
|
236
243
|
"kumiko.field.embedded-list.empty-cta": "Add first row",
|
|
244
|
+
"kumiko.field.embedded-list.paste-rows-truncated":
|
|
245
|
+
"{count} pasted row(s) were dropped (max row count reached).",
|
|
246
|
+
"kumiko.field.embedded-list.paste-cells-unmatched":
|
|
247
|
+
"{count} cell(s) had no matching option and were left unchanged.",
|
|
237
248
|
|
|
238
249
|
"kumiko.list.search-placeholder": "Search…",
|
|
239
250
|
"kumiko.list.empty.title": "No entries yet.",
|
|
@@ -254,11 +265,18 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
254
265
|
"kumiko.widget.upload.uploading": "Uploading…",
|
|
255
266
|
"kumiko.widget.upload.done": "Uploaded",
|
|
256
267
|
"kumiko.widget.upload.error": "Failed",
|
|
268
|
+
"kumiko.widget.upload.rejected-type": "File type not allowed",
|
|
269
|
+
|
|
270
|
+
"kumiko.widget.step-bar.done": "Done",
|
|
257
271
|
|
|
258
272
|
"kumiko.nav.expand": "Expand",
|
|
259
273
|
"kumiko.nav.collapse": "Collapse",
|
|
260
274
|
"kumiko.nav.search": "Search navigation…",
|
|
261
275
|
|
|
276
|
+
"kumiko.workspace.switch": "Switch workspace",
|
|
277
|
+
// Workspace-Switcher fallback label when activeId points at no visible workspace.
|
|
278
|
+
"kumiko.workspace.select": "Select workspace",
|
|
279
|
+
|
|
262
280
|
"kumiko.dialog.confirm": "Confirm",
|
|
263
281
|
"kumiko.dialog.cancel": "Cancel",
|
|
264
282
|
"kumiko.dialog.close": "Close",
|
package/src/index.ts
CHANGED