@cosmicdrift/kumiko-renderer 0.334.0 → 0.336.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/dist/app/content-preview.js +7 -9
- package/dist/app/extension-sections.d.ts +6 -0
- package/dist/app/form-schema.d.ts +2 -1
- package/dist/app/form-schema.js +11 -2
- package/dist/app/kumiko-screen.d.ts +5 -0
- package/dist/app/kumiko-screen.js +196 -65
- package/dist/app/nav.d.ts +5 -0
- package/dist/app/nav.js +17 -4
- package/dist/app/reference-facet-bridge.js +8 -1
- package/dist/app/row-actions.d.ts +12 -0
- package/dist/app/row-actions.js +24 -7
- package/dist/app/screen-access.js +13 -9
- package/dist/app/secret-mint-body.js +40 -6
- package/dist/app/secrets-edit-body.js +71 -44
- package/dist/components/embedded-list-field.js +2 -4
- package/dist/components/multi-select-checkboxes.js +4 -1
- package/dist/components/reference-create-dialog.js +9 -4
- package/dist/components/related-list-section.js +7 -5
- package/dist/components/render-edit-action-button.d.ts +1 -1
- package/dist/components/render-edit-action-button.js +5 -1
- package/dist/components/render-edit-logic.d.ts +2 -1
- package/dist/components/render-edit-logic.js +8 -5
- package/dist/components/render-edit-types.d.ts +10 -1
- package/dist/components/render-edit.js +189 -54
- package/dist/components/render-field.js +10 -13
- package/dist/components/render-list.js +6 -6
- package/dist/components/write-form-section.js +12 -20
- package/dist/hooks/use-reference-lookup.d.ts +7 -0
- package/dist/hooks/use-reference-lookup.js +10 -0
- package/dist/hooks/use-report-step-complete.d.ts +2 -0
- package/dist/hooks/use-report-step-complete.js +13 -0
- package/dist/i18n.js +8 -4
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -1
- package/dist/primitives.d.ts +30 -8
- package/dist/primitives.js +5 -0
- package/dist/session/session-ended.d.ts +16 -0
- package/dist/session/session-ended.js +73 -0
- package/dist/sort-by-accessor.d.ts +6 -2
- package/dist/sort-by-accessor.js +13 -3
- package/dist/sse/live-events.d.ts +1 -1
- package/package.json +5 -5
- package/src/changes.json +54 -0
|
@@ -15,6 +15,7 @@ export declare function runProjectionRowNavigate(nav: NavApi, action: RowActionN
|
|
|
15
15
|
type OpenDrawer = (action: RowActionDrawer, initialValues: Readonly<Record<string, unknown>> | undefined) => void;
|
|
16
16
|
/** @internal test-only */
|
|
17
17
|
export declare function resetDrawerActionWarningsForTests(): void;
|
|
18
|
+
export declare function buildDrawerRowAction(action: RowActionDrawer, translate: Translate, openDrawer: OpenDrawer): DataTableRowAction;
|
|
18
19
|
export declare function buildProjectionRowActions(options: {
|
|
19
20
|
readonly rowActions: readonly RowAction[] | undefined;
|
|
20
21
|
readonly translate: Translate;
|
|
@@ -52,6 +53,8 @@ export declare function buildProjectionToolbarActions(options: {
|
|
|
52
53
|
readonly record?: Readonly<Record<string, unknown>>;
|
|
53
54
|
readonly host: ReturnHost | undefined;
|
|
54
55
|
}): readonly ToolbarActionButton[] | undefined;
|
|
56
|
+
export declare function deletesShownRecord(action: RowActionWriteHandler, payload: Readonly<Record<string, unknown>>, shownRecordId: unknown): boolean;
|
|
57
|
+
export type RecordLeftHandler = (action: RowActionWriteHandler, resultData: unknown, deletedShownRecord: boolean) => void;
|
|
55
58
|
export declare function buildRecordActions(options: {
|
|
56
59
|
readonly actions: readonly RowAction[];
|
|
57
60
|
readonly record: Readonly<Record<string, unknown>>;
|
|
@@ -62,6 +65,11 @@ export declare function buildRecordActions(options: {
|
|
|
62
65
|
readonly openDrawer: (action: RowActionDrawer, initialValues: Readonly<Record<string, unknown>> | undefined) => void;
|
|
63
66
|
/** Called after a writeHandler action succeeds (refetch/reload). */
|
|
64
67
|
readonly onWriteSuccess: () => void | Promise<void>;
|
|
68
|
+
/** Called INSTEAD of `onWriteSuccess` when a writeHandler action succeeds
|
|
69
|
+
* and leaves the shown record (`redirect` set, or it deletes the record).
|
|
70
|
+
* Callers whose actions run on the shown record pass it; callers whose
|
|
71
|
+
* records are list rows omit it. */
|
|
72
|
+
readonly onRecordLeft?: RecordLeftHandler;
|
|
65
73
|
/** Screen-target navigate default entityId when the action declares no
|
|
66
74
|
* explicit `entityId` field-source. A caller editing a fixed entity can
|
|
67
75
|
* pass its own `entityId` here directly (the record it already is). A
|
|
@@ -73,5 +81,9 @@ export declare function buildRecordActions(options: {
|
|
|
73
81
|
* belongs to (cross-feature lookup) — undefined otherwise. Omitted by
|
|
74
82
|
* callers with no such cross-screen lookup. */
|
|
75
83
|
readonly sameEntityScreenId?: (targetScreen: string) => string | undefined;
|
|
84
|
+
/** Default `{ id }` for a writeHandler action declaring no `payload`. A
|
|
85
|
+
* caller editing a fixed entity passes its route id so the payload does
|
|
86
|
+
* not depend on the loaded record carrying an `id` field. */
|
|
87
|
+
readonly defaultWritePayloadId?: string;
|
|
76
88
|
}): readonly RenderEditAction[] | undefined;
|
|
77
89
|
export {};
|
package/dist/app/row-actions.js
CHANGED
|
@@ -119,7 +119,7 @@ function warnDrawerActionDropped(actionKind, actionId) {
|
|
|
119
119
|
// biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
|
|
120
120
|
console.warn(`[kumiko] ${actionKind} "${actionId}" is kind:"drawer", but the host did not wire openDrawer (RelatedListSection: pass onOpenDrawer) — it will not render.`);
|
|
121
121
|
}
|
|
122
|
-
function buildDrawerRowAction(action, translate, openDrawer) {
|
|
122
|
+
export function buildDrawerRowAction(action, translate, openDrawer) {
|
|
123
123
|
const { visible, params } = action;
|
|
124
124
|
const actionIcon = resolveActionIcon(action.id, action.icon);
|
|
125
125
|
return {
|
|
@@ -269,9 +269,9 @@ export function buildProjectionToolbarActions(options) {
|
|
|
269
269
|
return undefined;
|
|
270
270
|
const out = [];
|
|
271
271
|
for (const action of toolbarActions) {
|
|
272
|
+
// Fail closed: a conditional action without a record to evaluate against stays hidden.
|
|
272
273
|
if (action.visible !== undefined &&
|
|
273
|
-
record
|
|
274
|
-
!evalFieldCondition(action.visible, record)) {
|
|
274
|
+
(record === undefined || !evalFieldCondition(action.visible, record))) {
|
|
275
275
|
continue;
|
|
276
276
|
}
|
|
277
277
|
if (action.kind === "navigate") {
|
|
@@ -356,8 +356,17 @@ function buildDrawerRecordAction(action, options) {
|
|
|
356
356
|
},
|
|
357
357
|
};
|
|
358
358
|
}
|
|
359
|
+
// Entity delete handler QN: `<feature>:write:<entity>:delete` (see
|
|
360
|
+
// entityWriteCommand in kumiko-screen.tsx).
|
|
361
|
+
const ENTITY_DELETE_HANDLER_PATTERN = /:write:[^:]+:delete$/;
|
|
362
|
+
export function deletesShownRecord(action, payload, shownRecordId) {
|
|
363
|
+
return (ENTITY_DELETE_HANDLER_PATTERN.test(action.handler) &&
|
|
364
|
+
payload["id"] !== undefined &&
|
|
365
|
+
payload["id"] === shownRecordId);
|
|
366
|
+
}
|
|
359
367
|
function buildWriteHandlerRecordAction(action, options) {
|
|
360
|
-
const { record, translate, actionIcon, dispatcher, onWriteSuccess } = options;
|
|
368
|
+
const { record, translate, actionIcon, dispatcher, onWriteSuccess, defaultWritePayloadId, onRecordLeft, } = options;
|
|
369
|
+
const shownRecordId = defaultWritePayloadId ?? record["id"];
|
|
361
370
|
return {
|
|
362
371
|
id: action.id,
|
|
363
372
|
label: translate(action.label),
|
|
@@ -371,12 +380,18 @@ function buildWriteHandlerRecordAction(action, options) {
|
|
|
371
380
|
onPress: async () => {
|
|
372
381
|
const payload = action.payload !== undefined
|
|
373
382
|
? evalRowExtractor(action.payload, record)
|
|
374
|
-
: { id:
|
|
383
|
+
: { id: shownRecordId };
|
|
375
384
|
const result = await dispatcher.write(action.handler, payload);
|
|
376
385
|
if (!result.isSuccess) {
|
|
377
386
|
throw new WriteFailedError(result.error, dispatcherErrorText(result.error, translate));
|
|
378
387
|
}
|
|
379
|
-
|
|
388
|
+
const deletedShownRecord = deletesShownRecord(action, payload, shownRecordId);
|
|
389
|
+
if (onRecordLeft !== undefined && (action.redirect !== undefined || deletedShownRecord)) {
|
|
390
|
+
onRecordLeft(action, result.data, deletedShownRecord);
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
await onWriteSuccess();
|
|
394
|
+
}
|
|
380
395
|
},
|
|
381
396
|
};
|
|
382
397
|
}
|
|
@@ -387,7 +402,7 @@ function buildWriteHandlerRecordAction(action, options) {
|
|
|
387
402
|
// resolved against the record currently being viewed/edited, independent of
|
|
388
403
|
// any table row.
|
|
389
404
|
export function buildRecordActions(options) {
|
|
390
|
-
const { actions, record, translate, nav, host, dispatcher, openDrawer, onWriteSuccess, defaultScreenTargetEntityId, sameEntityScreenId, } = options;
|
|
405
|
+
const { actions, record, translate, nav, host, dispatcher, openDrawer, onWriteSuccess, onRecordLeft, defaultScreenTargetEntityId, sameEntityScreenId, defaultWritePayloadId, } = options;
|
|
391
406
|
const out = [];
|
|
392
407
|
for (const action of actions) {
|
|
393
408
|
if (action.visible !== undefined && !evalFieldCondition(action.visible, record))
|
|
@@ -420,6 +435,8 @@ export function buildRecordActions(options) {
|
|
|
420
435
|
actionIcon,
|
|
421
436
|
dispatcher,
|
|
422
437
|
onWriteSuccess,
|
|
438
|
+
defaultWritePayloadId,
|
|
439
|
+
onRecordLeft,
|
|
423
440
|
}));
|
|
424
441
|
}
|
|
425
442
|
return out.length > 0 ? out : undefined;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isUiAccessGranted } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import { findDetailForScreen } from "./nav.js";
|
|
2
3
|
import { lastSegment } from "./qn.js";
|
|
3
4
|
// Shared UI-visibility predicate for the screen-render path (#1203 — nav
|
|
4
5
|
// filtering via filterByAccess in workspace-shell.tsx hid role-gated
|
|
@@ -20,15 +21,18 @@ export function navigateTargetAllows(navigate, appFeatures, userRoles) {
|
|
|
20
21
|
// forbidden ones.
|
|
21
22
|
if (appFeatures.length === 0)
|
|
22
23
|
return true;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
// Entity targets share nav's lookup so the gate and resolveTarget can't
|
|
25
|
+
// pick different screens. Short ids are globally unique
|
|
26
|
+
// (validateScreenShortIdCollisions), so the first screen hit is the one.
|
|
27
|
+
if (entity !== undefined) {
|
|
28
|
+
const found = findDetailForScreen(appFeatures, entity);
|
|
29
|
+
if (found !== undefined)
|
|
30
|
+
return isUiAccessGranted(found.screen.access, userRoles);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
for (const feature of appFeatures) {
|
|
34
|
+
const candidate = feature.screens.find((s) => lastSegment(s.id) === screen);
|
|
35
|
+
if (candidate !== undefined)
|
|
32
36
|
return isUiAccessGranted(candidate.access, userRoles);
|
|
33
37
|
}
|
|
34
38
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { RenderEdit } from "../components/render-edit.js";
|
|
4
4
|
import { useTranslation } from "../i18n.js";
|
|
@@ -44,7 +44,7 @@ function isBlank(value) {
|
|
|
44
44
|
export function SecretMintBody({ schema, screen, translate }) {
|
|
45
45
|
const nav = useNav();
|
|
46
46
|
const returnTarget = useReturnTarget(screen.id);
|
|
47
|
-
const { Card, Heading, Banner, Button, Text, Grid, GridCell, SecretReveal } = usePrimitives();
|
|
47
|
+
const { Card, Heading, Banner, Button, Text, Grid, GridCell, Section, SecretReveal } = usePrimitives();
|
|
48
48
|
const t = useTranslation();
|
|
49
49
|
const effectiveTranslate = translate ?? t;
|
|
50
50
|
const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
|
|
@@ -63,6 +63,9 @@ export function SecretMintBody({ schema, screen, translate }) {
|
|
|
63
63
|
}), [screen.fields, screen.urlPrefillFields, nav.searchParams, synthScreen, handoffValues]);
|
|
64
64
|
const [revealed, setRevealed] = useState(null);
|
|
65
65
|
const [done, setDone] = useState(false);
|
|
66
|
+
// The secret was minted server-side but none of the declared reveal fields
|
|
67
|
+
// came back; the mint form must not return (a re-mint would invalidate it).
|
|
68
|
+
const [revealMissing, setRevealMissing] = useState(false);
|
|
66
69
|
// Never rendered, never merged into `revealed` or the confirm form's
|
|
67
70
|
// initial values — read only from `buildPayload` at confirm-submit time.
|
|
68
71
|
const carriedRef = useRef({});
|
|
@@ -82,16 +85,36 @@ export function SecretMintBody({ schema, screen, translate }) {
|
|
|
82
85
|
continue;
|
|
83
86
|
values[revealField.field] = value;
|
|
84
87
|
}
|
|
88
|
+
if (Object.values(values).every(isBlank)) {
|
|
89
|
+
// biome-ignore lint/suspicious/noConsole: the user-facing banner carries no detail; this names the screen and fields for the developer
|
|
90
|
+
console.error(`secretMint "${screen.id}": write result carried no value for reveal fields [${screen.reveal.fields
|
|
91
|
+
.map((revealField) => revealField.field)
|
|
92
|
+
.join(", ")}]`);
|
|
93
|
+
setRevealMissing(true);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
85
96
|
setRevealed(values);
|
|
86
97
|
carriedRef.current =
|
|
87
98
|
confirm?.carry !== undefined ? extractCarriedValues(result.data, confirm.carry) : {};
|
|
88
|
-
}, [screen.reveal.fields, confirm?.carry]);
|
|
99
|
+
}, [screen.id, screen.reveal.fields, confirm?.carry]);
|
|
89
100
|
const handleCancel = useMemo(() => {
|
|
90
101
|
const target = screen.cancelTarget ?? screen.redirect;
|
|
91
102
|
if (target === undefined || target === false)
|
|
92
103
|
return undefined;
|
|
93
|
-
return () =>
|
|
104
|
+
return () => {
|
|
105
|
+
// A native stack keeps this screen mounted after navigate; drop the secret first.
|
|
106
|
+
setRevealed(null);
|
|
107
|
+
carriedRef.current = {};
|
|
108
|
+
navigateToReturnOr(nav, returnTarget, () => nav.navigate({ screenId: lastSegment(target) }));
|
|
109
|
+
};
|
|
94
110
|
}, [nav, screen.redirect, screen.cancelTarget, returnTarget]);
|
|
111
|
+
// The confirm step must always offer a way out: a failed confirm (e.g. an
|
|
112
|
+
// expired setup token) otherwise leaves a form that can only keep failing.
|
|
113
|
+
// Restarting re-mints, which issues a fresh secret and invalidates this one.
|
|
114
|
+
const restartMint = useCallback(() => {
|
|
115
|
+
setRevealed(null);
|
|
116
|
+
carriedRef.current = {};
|
|
117
|
+
}, []);
|
|
95
118
|
// Ends the reveal phase for both paths (the bare acknowledge button, and a
|
|
96
119
|
// successful confirm submit): clears the secret and the carried values, then
|
|
97
120
|
// either navigates (screen.redirect) or shows a done-state — never falls
|
|
@@ -115,6 +138,9 @@ export function SecretMintBody({ schema, screen, translate }) {
|
|
|
115
138
|
if (done) {
|
|
116
139
|
return (_jsx(Card, { options: { screenBody: true }, children: _jsx(Banner, { variant: "info", testId: "kumiko-screen-secret-mint-done", children: effectiveTranslate(confirm?.doneMessage ?? "kumiko.secretMint.done") }) }));
|
|
117
140
|
}
|
|
141
|
+
if (revealMissing) {
|
|
142
|
+
return (_jsx(Card, { options: { screenBody: true }, children: _jsx(Banner, { variant: "error", testId: "kumiko-screen-secret-mint-missing", children: effectiveTranslate("kumiko.form.error.generic") }) }));
|
|
143
|
+
}
|
|
118
144
|
if (revealed !== null) {
|
|
119
145
|
const values = screen.reveal.fields.flatMap((revealField) => {
|
|
120
146
|
const raw = revealed[revealField.field];
|
|
@@ -134,7 +160,15 @@ export function SecretMintBody({ schema, screen, translate }) {
|
|
|
134
160
|
},
|
|
135
161
|
];
|
|
136
162
|
});
|
|
137
|
-
|
|
163
|
+
const revealContent = (_jsxs(_Fragment, { children: [_jsx(Heading, { variant: "page", children: effectiveTranslate(screen.reveal.title ?? "kumiko.secretMint.title") }), _jsx(Banner, { variant: "warning", testId: "kumiko-screen-secret-mint-warning", children: effectiveTranslate(screen.reveal.warning ?? "kumiko.secretMint.warning") }), SecretReveal !== undefined ? (_jsx(SecretReveal, { values: values, copyLabel: effectiveTranslate("kumiko.secretMint.copy"), copiedLabel: effectiveTranslate("kumiko.secretMint.copied"), testId: "kumiko-screen-secret-mint-reveal" })) : (_jsx(Grid, { columns: 1, testId: "kumiko-screen-secret-mint-reveal", children: values.map((v) => (_jsxs(GridCell, { children: [_jsx(Text, { children: v.label }), _jsx(Text, { variant: "code", children: v.value })] }, v.label))) }))] }));
|
|
164
|
+
// One screen form like the mint phase: the reveal leads the form column
|
|
165
|
+
// instead of the confirm form nesting a second card inside a card. The
|
|
166
|
+
// keys stop the mint and confirm forms sharing one instance (and its
|
|
167
|
+
// submit state) across phases.
|
|
168
|
+
if (confirm !== undefined && confirmEntity !== undefined && confirmScreen !== undefined) {
|
|
169
|
+
return (_jsx(RenderEdit, { screen: confirmScreen, i18nScreenId: screen.id, entity: confirmEntity, featureName: schema.featureName, initial: confirmInitial ?? {}, writeCommand: confirm.handler, payloadMode: "values", buildPayload: (snapshot) => ({ ...snapshot.values, ...carriedRef.current }), onSubmit: handleConfirmSubmitted, onCancel: handleCancel ?? restartMint, fillScreenHeight: true, leadContent: _jsx(Section, { testId: "kumiko-screen-secret-mint-card", children: revealContent }), ...(translate !== undefined && { translate }), ...(confirm.submitLabel !== undefined && { submitLabel: confirm.submitLabel }) }, "confirm"));
|
|
170
|
+
}
|
|
171
|
+
return (_jsxs(Card, { options: { screenBody: true }, testId: "kumiko-screen-secret-mint-card", children: [revealContent, _jsx(Button, { type: "button", variant: "primary", onClick: finishMint, testId: "kumiko-screen-secret-mint-confirm", children: effectiveTranslate(screen.reveal.confirmLabel ?? "kumiko.secretMint.confirm") })] }));
|
|
138
172
|
}
|
|
139
|
-
return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, extensionInitialValues: initial, writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, fillScreenHeight: true, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(translate !== undefined && { translate }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }) }));
|
|
173
|
+
return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, extensionInitialValues: initial, writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, fillScreenHeight: true, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(translate !== undefined && { translate }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }) }, "mint"));
|
|
140
174
|
}
|
|
@@ -19,7 +19,7 @@ function groupSectionsByTitle(sections) {
|
|
|
19
19
|
// unlike ConfigEditBody there is no server value to pre-fill a draft with —
|
|
20
20
|
// every input starts at "" and stays that way unless the user types into it.
|
|
21
21
|
export function SecretsEditBody({ screen, translate }) {
|
|
22
|
-
const { Banner, Form, Section, Field, Input, Button, Text, Grid } = usePrimitives();
|
|
22
|
+
const { Banner, Dialog, Form, Section, Field, Input, Button, Text, Grid } = usePrimitives();
|
|
23
23
|
const t = useTranslation();
|
|
24
24
|
const effectiveTranslate = translate ?? t;
|
|
25
25
|
const dispatcher = useDispatcher();
|
|
@@ -27,6 +27,8 @@ export function SecretsEditBody({ screen, translate }) {
|
|
|
27
27
|
const [drafts, setDrafts] = useState({});
|
|
28
28
|
const [submitting, setSubmitting] = useState(false);
|
|
29
29
|
const [submitError, setSubmitError] = useState(null);
|
|
30
|
+
const [pendingDeleteKey, setPendingDeleteKey] = useState(null);
|
|
31
|
+
const [deleting, setDeleting] = useState(false);
|
|
30
32
|
const rowsByQualifiedKey = useMemo(() => {
|
|
31
33
|
const out = new Map();
|
|
32
34
|
for (const row of listQuery.data ?? [])
|
|
@@ -45,22 +47,41 @@ export function SecretsEditBody({ screen, translate }) {
|
|
|
45
47
|
return;
|
|
46
48
|
setSubmitting(true);
|
|
47
49
|
setSubmitError(null);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
try {
|
|
51
|
+
const result = await dispatcher.batch(commands);
|
|
52
|
+
if (!result.isSuccess) {
|
|
53
|
+
setSubmitError(dispatcherErrorText(result.error, effectiveTranslate));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
setDrafts({});
|
|
57
|
+
await listQuery.refetch();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Transport failures reject instead of returning a result; drafts stay
|
|
61
|
+
// so the user can retry without retyping the secrets.
|
|
62
|
+
setSubmitError(effectiveTranslate("kumiko.form.error.generic"));
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
setSubmitting(false);
|
|
53
66
|
}
|
|
54
|
-
setDrafts({});
|
|
55
|
-
await listQuery.refetch();
|
|
56
67
|
}, [dispatcher, drafts, screen.secretKeys, listQuery.refetch, effectiveTranslate]);
|
|
57
68
|
const handleDelete = useCallback(async (qualified) => {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
69
|
+
setDeleting(true);
|
|
70
|
+
try {
|
|
71
|
+
const result = await dispatcher.write("secrets:write:delete", { key: qualified });
|
|
72
|
+
if (!result.isSuccess) {
|
|
73
|
+
setSubmitError(dispatcherErrorText(result.error, effectiveTranslate));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
setSubmitError(null);
|
|
77
|
+
await listQuery.refetch();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
setSubmitError(effectiveTranslate("kumiko.form.error.generic"));
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
setDeleting(false);
|
|
62
84
|
}
|
|
63
|
-
await listQuery.refetch();
|
|
64
85
|
}, [dispatcher, listQuery.refetch, effectiveTranslate]);
|
|
65
86
|
if (listQuery.loading && listQuery.data === null)
|
|
66
87
|
return _jsx(QueryLoadingBanner, {});
|
|
@@ -71,35 +92,41 @@ export function SecretsEditBody({ screen, translate }) {
|
|
|
71
92
|
// title all fall under the generic "Secrets" heading and share one band.
|
|
72
93
|
const sectionGroups = groupSectionsByTitle(screen.sections);
|
|
73
94
|
const unsavedCount = Object.values(drafts).filter((value) => value.trim() !== "").length;
|
|
74
|
-
return (_jsxs(Form, { onSubmit: () => {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
95
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Form, { onSubmit: () => {
|
|
96
|
+
void handleSubmit();
|
|
97
|
+
}, testId: "secrets-edit-form", width: "full", fillHeight: true, stickyActions: true, screenForm: true, unsavedCount: unsavedCount, ...(screen.description !== undefined && {
|
|
98
|
+
subtitle: effectiveTranslate(screen.description),
|
|
99
|
+
}), actions: _jsxs(_Fragment, { children: [unsavedCount > 0 && (_jsx(Button, { type: "button", variant: "ghost", disabled: submitting, onClick: () => setDrafts({}), testId: "secrets-edit-discard", [STICKY_PRIMARY_ACTION_PROP]: true, children: effectiveTranslate("kumiko.form.discard") })), _jsx(Button, { type: "submit", variant: "primary", loading: submitting, disabled: submitting || unsavedCount === 0, testId: "secrets-edit-submit", children: effectiveTranslate("kumiko.form.saveChanges") })] }), children: [submitError !== null && (_jsx(Banner, { variant: "error", testId: "secrets-edit-error", children: submitError })), sectionGroups.map((section) => (_jsx(Section, { layout: "settings-list", title: effectiveTranslate(section.title), subtitle: effectiveTranslate("config.secrets.description"), children: _jsx(Grid, { columns: 1, list: true, children: section.fields.map((fieldId) => {
|
|
100
|
+
const qualified = screen.secretKeys[fieldId];
|
|
101
|
+
if (qualified === undefined)
|
|
102
|
+
return null;
|
|
103
|
+
const row = rowsByQualifiedKey.get(qualified);
|
|
104
|
+
const hintKey = screen.fieldHints?.[fieldId];
|
|
105
|
+
const isRequired = screen.requiredFields?.includes(fieldId) ?? false;
|
|
106
|
+
return (_jsx(Field, { id: fieldId, layout: "row", label: effectiveTranslate(screen.fieldLabels[fieldId] ?? fieldId), required: isRequired, testId: `field-${fieldId}`, status: row !== undefined
|
|
107
|
+
? {
|
|
108
|
+
tone: "ok",
|
|
109
|
+
label: effectiveTranslate("config.secrets.saved"),
|
|
110
|
+
testId: `secret-saved-${fieldId}`,
|
|
111
|
+
}
|
|
112
|
+
: {
|
|
113
|
+
tone: isRequired ? "bad" : "muted",
|
|
114
|
+
label: effectiveTranslate("config.secrets.notSet"),
|
|
115
|
+
testId: `secret-not-set-${fieldId}`,
|
|
116
|
+
}, ...(hintKey !== undefined && { description: effectiveTranslate(hintKey) }), ...(row !== undefined && {
|
|
117
|
+
fieldAppendix: (_jsxs(Grid, { columns: "auto", children: [_jsx(Text, { variant: "muted", testId: `secret-preview-${fieldId}`, children: row.redactedPreview !== null
|
|
118
|
+
? effectiveTranslate("config.secrets.stored", {
|
|
119
|
+
preview: row.redactedPreview,
|
|
120
|
+
})
|
|
121
|
+
: effectiveTranslate("config.secrets.set") }), _jsx(Button, { type: "button", variant: "danger-ghost", size: "sm", disabled: submitting || deleting, onClick: () => setPendingDeleteKey(qualified), testId: `secret-delete-${fieldId}`, children: effectiveTranslate("config.secrets.delete") })] })),
|
|
122
|
+
}), children: _jsx(Input, { kind: "password", id: fieldId, name: fieldId, value: drafts[fieldId] ?? "", onChange: (v) => setDraft(fieldId, v), placeholder: effectiveTranslate(row !== undefined
|
|
123
|
+
? "config.secrets.replacePlaceholder"
|
|
124
|
+
: "config.secrets.placeholder"), autoComplete: "new-password", disabled: submitting, testId: `secret-input-${fieldId}` }) }, fieldId));
|
|
125
|
+
}) }) }, section.title)))] }), _jsx(Dialog, { open: pendingDeleteKey !== null, onOpenChange: (open) => {
|
|
126
|
+
if (!open)
|
|
127
|
+
setPendingDeleteKey(null);
|
|
128
|
+
}, title: effectiveTranslate("config.secrets.delete"), description: effectiveTranslate("config.secrets.deleteConfirm"), confirmLabel: effectiveTranslate("config.secrets.delete"), variant: "danger", initialFocus: "cancel", onConfirm: async () => {
|
|
129
|
+
if (pendingDeleteKey !== null)
|
|
130
|
+
await handleDelete(pendingDeleteKey);
|
|
131
|
+
}, testId: "secrets-delete-dialog" })] }));
|
|
105
132
|
}
|
|
@@ -3,7 +3,7 @@ import { computeDerivedCellValue, currencyDecimals, groupEmbeddedListIssues, rou
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
|
|
5
5
|
import { useQuery } from "../hooks/use-query.js";
|
|
6
|
-
import {
|
|
6
|
+
import { referenceOptionSource } from "../hooks/use-reference-lookup.js";
|
|
7
7
|
import { useTranslation } from "../i18n.js";
|
|
8
8
|
import { usePrimitives } from "../primitives.js";
|
|
9
9
|
function withRecomputedDerived(row, derived, cells) {
|
|
@@ -107,9 +107,7 @@ export function EmbeddedListField({ field, id, onChange, allIssues, featureName,
|
|
|
107
107
|
const rows = Array.isArray(field.value) ? field.value : [];
|
|
108
108
|
const derived = field.embeddedListDerived;
|
|
109
109
|
const referenceCells = cells.filter((c) => c.type === "reference");
|
|
110
|
-
const referenceSources = referenceCells.map((cell) => cell.
|
|
111
|
-
? { queryQn: cell.refOptionsQuery, labelKey: "label" }
|
|
112
|
-
: referenceLookupSource(cell.refFeature ?? featureName, cell.refEntity ?? "", cell.refLabelField ?? "id"));
|
|
110
|
+
const referenceSources = referenceCells.map((cell) => referenceOptionSource(cell, cell.refFeature ?? featureName, cell.refEntity ?? ""));
|
|
113
111
|
const referenceQueries = referenceSources.map((source) =>
|
|
114
112
|
// biome-ignore lint/correctness/useHookAtTopLevel: referenceCells comes from the entity-schema definition — fixed for the screen's lifetime, not a real conditional-hook risk.
|
|
115
113
|
useQuery(source.queryQn, {
|
|
@@ -32,5 +32,8 @@ export function MultiSelectCheckboxes({ field, id, options, value, onChange, })
|
|
|
32
32
|
};
|
|
33
33
|
return (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: "secondary", size: "sm", disabled: disabled, onClick: toggleAll, testId: `${id}-select-all`, children: allSelected
|
|
34
34
|
? t("kumiko.field.multiSelect.deselect-all")
|
|
35
|
-
: t("kumiko.field.multiSelect.select-all") }), _jsx(Grid, { columns: field.columns ?? 2, testId: `${id}-checkboxes`,
|
|
35
|
+
: t("kumiko.field.multiSelect.select-all") }), _jsx(Grid, { columns: field.columns ?? 2, testId: `${id}-checkboxes`,
|
|
36
|
+
// Field's label targets `id`, which no checkbox carries here; point the
|
|
37
|
+
// group at Field's `${id}-label` element instead.
|
|
38
|
+
ariaLabelledBy: `${id}-label`, ...(field.maxRows !== undefined && { maxRows: field.maxRows }), children: options.map((opt) => (_jsx(GridCell, { children: _jsx(Field, { id: `${id}-${opt.value}`, label: opt.label, layout: "inline", children: _jsx(Input, { kind: "boolean", id: `${id}-${opt.value}`, name: `${id}-${opt.value}`, value: selected.has(opt.value), onChange: (checked) => toggleOption(opt.value, checked), disabled: disabled }) }) }, opt.value))) })] }));
|
|
36
39
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useMemo } from "react";
|
|
3
|
-
import { buildInitialValues } from "../app/kumiko-screen.js";
|
|
3
|
+
import { buildInitialValues, tenantCurrencyMoneyFieldNames, useMoneyCurrencyOverrides, } from "../app/kumiko-screen.js";
|
|
4
4
|
import { toKebab } from "../app/qn.js";
|
|
5
5
|
import { useTranslation } from "../i18n.js";
|
|
6
6
|
import { usePrimitives } from "../primitives.js";
|
|
@@ -31,9 +31,14 @@ export function extractCreatedId(data) {
|
|
|
31
31
|
return extractIdField(data, "id");
|
|
32
32
|
}
|
|
33
33
|
export function ReferenceCreateDialog({ open, onClose, onCreated, featureName, screen, entity, }) {
|
|
34
|
-
const { Modal } = usePrimitives();
|
|
34
|
+
const { Banner, Modal } = usePrimitives();
|
|
35
35
|
const t = useTranslation();
|
|
36
|
-
const
|
|
36
|
+
const entityDefaultCurrency = entity.defaultCurrency ?? "EUR";
|
|
37
|
+
const tenantCurrencyFieldNames = useMemo(() => (open ? tenantCurrencyMoneyFieldNames(entity.fields) : []), [open, entity.fields]);
|
|
38
|
+
// Same tenant-currency resolution as the entityEdit create screen (fw#2933),
|
|
39
|
+
// so a `currency: { kind: "tenant" }` field seeds identically via this dialog.
|
|
40
|
+
const { overrides: moneyCurrencyOverrides, loading: currencyLoading } = useMoneyCurrencyOverrides(entity.fields, tenantCurrencyFieldNames, entityDefaultCurrency);
|
|
41
|
+
const initial = useMemo(() => buildInitialValues(entity.fields, entityDefaultCurrency, moneyCurrencyOverrides), [entity.fields, entityDefaultCurrency, moneyCurrencyOverrides]);
|
|
37
42
|
const writeCommand = entityWriteCommand(featureName, screen.entity);
|
|
38
43
|
const handleSubmitted = (result) => {
|
|
39
44
|
if (result.validationBlocked || !result.isSuccess)
|
|
@@ -42,5 +47,5 @@ export function ReferenceCreateDialog({ open, onClose, onCreated, featureName, s
|
|
|
42
47
|
};
|
|
43
48
|
if (!open)
|
|
44
49
|
return null;
|
|
45
|
-
return (_jsx(Modal, { open: open, onOpenChange: (next) => !next && onClose(), title: t("kumiko.actions.create"), children: _jsx(RenderEdit, { screen: screen, entity: entity, featureName: featureName, initial: initial, writeCommand: writeCommand, onSubmit: handleSubmitted, onCancel: onClose, ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }) }) }));
|
|
50
|
+
return (_jsx(Modal, { open: open, onOpenChange: (next) => !next && onClose(), title: t("kumiko.actions.create"), children: currencyLoading ? (_jsx(Banner, { padded: true, variant: "loading", testId: "reference-create-dialog-loading", children: "Loading\u2026" })) : (_jsx(RenderEdit, { screen: screen, entity: entity, featureName: featureName, initial: initial, writeCommand: writeCommand, onSubmit: handleSubmitted, onCancel: onClose, ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }) })) }));
|
|
46
51
|
}
|
|
@@ -12,7 +12,7 @@ import { dispatcherErrorText } from "../app/write-failed-error.js";
|
|
|
12
12
|
import { useOptionalDispatcher } from "../context/dispatcher-context.js";
|
|
13
13
|
import { useUserRoles } from "../context/user-roles-context.js";
|
|
14
14
|
import { useQuery } from "../hooks/use-query.js";
|
|
15
|
-
import {
|
|
15
|
+
import { useOptionalLocale, useTranslation } from "../i18n.js";
|
|
16
16
|
import { PageHeaderSlotAvailableProvider } from "../page-header-slot.js";
|
|
17
17
|
import { usePrimitives, } from "../primitives.js";
|
|
18
18
|
import { sortByAccessor } from "../sort-by-accessor.js";
|
|
@@ -84,10 +84,10 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
|
|
|
84
84
|
const [emptyStateActionError, setEmptyStateActionError] = useState(null);
|
|
85
85
|
const t = useTranslation();
|
|
86
86
|
const effectiveTranslate = translate ?? t;
|
|
87
|
-
const locale =
|
|
87
|
+
const locale = useOptionalLocale();
|
|
88
88
|
const rowGrouping = useMemo(() => section.groupBy === undefined
|
|
89
89
|
? undefined
|
|
90
|
-
: buildRowGrouping(section.groupBy, effectiveTranslate, locale), [section.groupBy, effectiveTranslate, locale]);
|
|
90
|
+
: buildRowGrouping(section.groupBy, effectiveTranslate, locale ?? "en"), [section.groupBy, effectiveTranslate, locale]);
|
|
91
91
|
const rowTone = useMemo(() => buildRowTone(section.rowTone), [section.rowTone]);
|
|
92
92
|
const nav = useNav();
|
|
93
93
|
const host = useReturnHost();
|
|
@@ -175,12 +175,14 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
|
|
|
175
175
|
const field = normalizeListColumn(col).field;
|
|
176
176
|
accessors[field] = (row) => {
|
|
177
177
|
const value = row[field];
|
|
178
|
-
|
|
178
|
+
if (value === null || value === undefined)
|
|
179
|
+
return null;
|
|
180
|
+
return typeof value === "number" ? value : String(value);
|
|
179
181
|
};
|
|
180
182
|
}
|
|
181
183
|
return accessors;
|
|
182
184
|
}, [section.columns]);
|
|
183
|
-
const sortedRows = useMemo(() => sortByAccessor(rowsQuery.data?.rows ?? [], sort, sortAccessors), [rowsQuery.data, sort, sortAccessors]);
|
|
185
|
+
const sortedRows = useMemo(() => sortByAccessor(rowsQuery.data?.rows ?? [], sort, sortAccessors, locale), [rowsQuery.data, sort, sortAccessors, locale]);
|
|
184
186
|
const rowClick = section.rowClick;
|
|
185
187
|
// A row-body click target comes from EITHER the legacy `rowClick` field OR
|
|
186
188
|
// a `rowActions` entry marked rowClick:true — the boot-validator rejects
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import type { usePrimitives } from "../primitives.js";
|
|
3
3
|
import type { RenderEditAction } from "./render-edit-types.js";
|
|
4
|
-
export declare function needsActionConfirm(action: RenderEditAction): boolean;
|
|
4
|
+
export declare function needsActionConfirm(action: Pick<RenderEditAction, "confirm" | "confirmRequired" | "style">): boolean;
|
|
5
5
|
export declare function RenderEditActionConfirmDialog({ action, open, onOpenChange, onConfirm, Dialog, }: {
|
|
6
6
|
readonly action: RenderEditAction;
|
|
7
7
|
readonly open: boolean;
|
|
@@ -32,7 +32,11 @@ export function RenderEditActionButton({ action, iconOnly = false, hideIcon = fa
|
|
|
32
32
|
};
|
|
33
33
|
const variant = action.style ?? "secondary";
|
|
34
34
|
const showIconOnly = iconOnly && action.icon !== undefined;
|
|
35
|
-
return (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: variant, loading: busy, ...(action.icon !== undefined && (!hideIcon || showIconOnly) && { icon: action.icon }), ...(showIconOnly && {
|
|
35
|
+
return (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: variant, loading: busy, ...(action.icon !== undefined && (!hideIcon || showIconOnly) && { icon: action.icon }), ...(showIconOnly && {
|
|
36
|
+
size: "icon",
|
|
37
|
+
ariaLabel: action.label,
|
|
38
|
+
title: action.label,
|
|
39
|
+
}), onClick: () => {
|
|
36
40
|
if (needsActionConfirm(action)) {
|
|
37
41
|
setConfirmOpen(true);
|
|
38
42
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { EditSectionViewModel, SubmitResult } from "@cosmicdrift/kumiko-headless";
|
|
1
|
+
import type { EditFieldViewModel, EditSectionViewModel, SubmitResult } from "@cosmicdrift/kumiko-headless";
|
|
2
2
|
export declare function hasEditableSection(sections: readonly EditSectionViewModel[]): boolean;
|
|
3
3
|
export declare function resolveExtensionEntityId(entityIdProp: string | null | undefined, vmId: string | null): string | null;
|
|
4
4
|
export declare function shouldNotifyCaller(result: SubmitResult<unknown>, extensionsPersisted: boolean): boolean;
|
|
5
5
|
export declare function hasIssueWithoutRenderedField(issuePaths: readonly string[], sections: readonly EditSectionViewModel[]): boolean;
|
|
6
|
+
export declare function hasIssueWithoutVisibleField(issuePaths: readonly string[], fields: readonly EditFieldViewModel[]): boolean;
|
|
6
7
|
export declare function filterEditSections(sections: readonly EditSectionViewModel[], fieldsFilter: readonly string[] | undefined): readonly EditSectionViewModel[];
|
|
@@ -30,13 +30,16 @@ export function shouldNotifyCaller(result, extensionsPersisted) {
|
|
|
30
30
|
// Matched on the first path segment (embedded-list rows are dotted, e.g. `tasks.2.title`).
|
|
31
31
|
// No issues at all counts as unmatched, so the caller keeps the banner.
|
|
32
32
|
export function hasIssueWithoutRenderedField(issuePaths, sections) {
|
|
33
|
+
const renderedFields = sections
|
|
34
|
+
.filter((s) => s.kind === "fields" && (s.visible || s.fields.length === 0))
|
|
35
|
+
.flatMap((s) => (s.kind === "fields" ? s.fields : []));
|
|
36
|
+
return hasIssueWithoutVisibleField(issuePaths, renderedFields);
|
|
37
|
+
}
|
|
38
|
+
export function hasIssueWithoutVisibleField(issuePaths, fields) {
|
|
33
39
|
if (issuePaths.length === 0)
|
|
34
40
|
return true;
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
.flatMap((s) => (s.kind === "fields" ? s.fields.filter((f) => f.visible) : []))
|
|
38
|
-
.map((f) => f.field));
|
|
39
|
-
return issuePaths.some((path) => !renderedFields.has(path.split(".")[0] ?? path));
|
|
41
|
+
const visibleFields = new Set(fields.filter((f) => f.visible).map((f) => f.field));
|
|
42
|
+
return issuePaths.some((path) => !visibleFields.has(path.split(".")[0] ?? path));
|
|
40
43
|
}
|
|
41
44
|
// Extension, relatedList and writeForm sections skip the `fields` filter
|
|
42
45
|
// (writeForm's fields belong to its own independent form, not the host's);
|
|
@@ -4,6 +4,10 @@ import type { ReactNode } from "react";
|
|
|
4
4
|
import type * as z from "zod";
|
|
5
5
|
export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
6
6
|
readonly screen: EntityEditScreenDefinition;
|
|
7
|
+
/** Screen id used for the `screen:<id>.title`/`.subtitle` lookups and as the
|
|
8
|
+
* title fallback, when `screen.id` is synthetic (e.g. a secretMint confirm
|
|
9
|
+
* form keeps its own id for the draft key). Defaults to `screen.id`. */
|
|
10
|
+
readonly i18nScreenId?: string;
|
|
7
11
|
readonly entity: EntityDefinition;
|
|
8
12
|
readonly featureName: string;
|
|
9
13
|
readonly initial: TValues;
|
|
@@ -104,7 +108,8 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
104
108
|
* is the delta against the initial values — same semantics as
|
|
105
109
|
* `payloadMode: "changes"` — so a caller never overwrites unseen fields.
|
|
106
110
|
* `valid` is a pure dry-run parse against `schema` (not a
|
|
107
|
-
* `controller.validate()` call)
|
|
111
|
+
* `controller.validate()` call) that ignores issues on hidden fields and
|
|
112
|
+
* outside the `fields` scope, like submit() does, so it does not paint field errors into
|
|
108
113
|
* the UI and can diverge from the currently rendered `snapshot.errors` —
|
|
109
114
|
* always `true` without `schema`. A caller that patches a fresh object
|
|
110
115
|
* reference on every call must not do so unconditionally: `setValues` is
|
|
@@ -175,6 +180,10 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
175
180
|
* screens with `fillHeight !== false`; dialogs and other embedded hosts
|
|
176
181
|
* omit it and keep document-flow height. */
|
|
177
182
|
readonly fillScreenHeight?: boolean;
|
|
183
|
+
/** Rendered first inside the form, above the sections, in the same column and
|
|
184
|
+
* rhythm — for content that belongs to the form but is no field (e.g. the
|
|
185
|
+
* secret a confirm step refers to). */
|
|
186
|
+
readonly leadContent?: ReactNode;
|
|
178
187
|
/** Show the unsaved-changes footer (count, Discard, "Save changes") even
|
|
179
188
|
* without an entity id or a screen-height form — for hosts that edit an
|
|
180
189
|
* existing server-side record through `customSubmit` (configEdit, also when
|