@cosmicdrift/kumiko-renderer 0.387.0 → 0.388.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/action-form-body.js +10 -2
- package/dist/app/action-form-support.d.ts +1 -0
- package/dist/app/action-form-support.js +1 -1
- package/dist/app/config-edit-body.js +6 -2
- package/dist/app/entity-edit-prefill.d.ts +11 -0
- package/dist/app/entity-edit-prefill.js +9 -4
- package/dist/app/extension-sections.d.ts +5 -3
- package/dist/app/form-schema.js +1 -1
- package/dist/app/header-actions-bar.js +1 -1
- package/dist/components/edit-mounts.d.ts +2 -2
- package/dist/components/embedded-list-field.js +1 -1
- package/dist/components/related-list-section.js +14 -1
- package/dist/components/render-edit-action-button.d.ts +1 -3
- package/dist/components/render-edit-action-button.js +2 -2
- package/dist/components/render-edit.js +11 -8
- package/dist/components/render-field.js +5 -2
- package/dist/components/render-list.js +31 -18
- package/dist/flow-diagram/render.js +9 -1
- package/dist/headless/form/form-controller.js +8 -0
- package/dist/headless/form/types.d.ts +1 -0
- package/dist/headless/format/index.js +89 -64
- package/dist/headless/view-model/edit.js +2 -0
- package/dist/headless/view-model/list.js +184 -149
- package/dist/headless/view-model/types.d.ts +3 -0
- package/dist/hooks/live-refetch-scheduler.js +4 -0
- package/dist/hooks/use-reference-lookup.d.ts +9 -1
- package/dist/hooks/use-reference-lookup.js +17 -3
- package/dist/i18n-defaults.js +6 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/primitives.d.ts +19 -0
- package/dist/web/app/create-app.js +8 -3
- package/dist/web/app/dashboard-body.js +17 -1
- package/dist/web/app/dashboard-panels.js +12 -2
- package/dist/web/index.d.ts +1 -1
- package/dist/web/index.js +1 -1
- package/dist/web/layout/shell-header.js +2 -1
- package/dist/web/layout/uniform-bar-controls.d.ts +1 -0
- package/dist/web/layout/uniform-bar-controls.js +4 -0
- package/dist/web/lib/file-ref-download.js +4 -1
- package/dist/web/primitives/action-menu.js +1 -1
- package/dist/web/primitives/code-input.d.ts +6 -0
- package/dist/web/primitives/code-input.js +31 -0
- package/dist/web/primitives/data-table.js +4 -3
- package/dist/web/primitives/facet-filter.js +1 -1
- package/dist/web/primitives/file-thumbnail.d.ts +3 -0
- package/dist/web/primitives/file-thumbnail.js +5 -0
- package/dist/web/primitives/input.js +6 -1
- package/dist/web/primitives/layout-elements.js +6 -4
- package/dist/web/primitives/page-header.js +3 -1
- package/dist/web/primitives/row-actions.js +3 -3
- package/dist/web/primitives/use-focus-on-mount.d.ts +2 -0
- package/dist/web/primitives/use-focus-on-mount.js +9 -0
- package/dist/web/widgets/bounded-load-cache.d.ts +1 -1
- package/dist/web/widgets/bounded-load-cache.js +10 -4
- package/dist/web/widgets/choice-cards.d.ts +17 -0
- package/dist/web/widgets/choice-cards.js +12 -0
- package/dist/web/widgets/collapsible-section.js +1 -1
- package/dist/web/widgets/detail-list.d.ts +5 -1
- package/dist/web/widgets/detail-list.js +5 -1
- package/dist/web/widgets/index.d.ts +1 -0
- package/dist/web/widgets/index.js +1 -0
- package/dist/web/widgets/pdf-preview.js +1 -1
- package/dist/web/widgets/plan-card.d.ts +2 -0
- package/dist/web/widgets/plan-card.js +1 -1
- package/dist/web/widgets/upload-zone.js +13 -5
- package/package.json +5 -5
- package/src/changes.json +49 -0
|
@@ -129,6 +129,7 @@ export function ActionFormBody({ schema, screen, translate, entityId, initialOve
|
|
|
129
129
|
})
|
|
130
130
|
: undefined, loadableFieldNames);
|
|
131
131
|
const loadedValues = loadState?.status === "ready" ? loadState.values : undefined;
|
|
132
|
+
const refetchLoadedValues = loadState?.status === "ready" ? loadState.refetch : undefined;
|
|
132
133
|
const initial = useMemo(() => {
|
|
133
134
|
// With `load` the loaded record is the only source of values: a crafted link
|
|
134
135
|
// or a row's params must not seed fields the record would otherwise own.
|
|
@@ -204,8 +205,14 @@ export function ActionFormBody({ schema, screen, translate, entityId, initialOve
|
|
|
204
205
|
else if (hasSuccessLinks || screen.resetOnSuccess === true) {
|
|
205
206
|
publish(effectiveTranslate("kumiko.actionForm.success"));
|
|
206
207
|
}
|
|
207
|
-
if (screen.resetOnSuccess === true)
|
|
208
|
-
|
|
208
|
+
if (screen.resetOnSuccess === true) {
|
|
209
|
+
// A loaded record is stale after the write: reseed the form from the fresh one.
|
|
210
|
+
const remountForm = () => setFormEpoch((epoch) => epoch + 1);
|
|
211
|
+
if (refetchLoadedValues === undefined)
|
|
212
|
+
remountForm();
|
|
213
|
+
else
|
|
214
|
+
void refetchLoadedValues().finally(remountForm);
|
|
215
|
+
}
|
|
209
216
|
}
|
|
210
217
|
// Without a redirect the form stays put; returnTo only replaces an existing
|
|
211
218
|
// navigation, and never one to a record screen (see redirectTargetsRecord).
|
|
@@ -227,6 +234,7 @@ export function ActionFormBody({ schema, screen, translate, entityId, initialOve
|
|
|
227
234
|
dispatcher,
|
|
228
235
|
locale,
|
|
229
236
|
effectiveTranslate,
|
|
237
|
+
refetchLoadedValues,
|
|
230
238
|
]);
|
|
231
239
|
// The success box disappears with the next edit; a submit clears it itself.
|
|
232
240
|
const handleDirtyChange = useCallback((dirty) => {
|
|
@@ -21,6 +21,7 @@ export type ActionFormLoadState = {
|
|
|
21
21
|
} | {
|
|
22
22
|
readonly status: "ready";
|
|
23
23
|
readonly values: Readonly<Record<string, unknown>>;
|
|
24
|
+
readonly refetch: () => Promise<void>;
|
|
24
25
|
};
|
|
25
26
|
/** A sensitive, writeOnly or password field never takes a value from a loaded
|
|
26
27
|
* record: the form must not echo a secret back into an input. */
|
|
@@ -53,7 +53,7 @@ export function useActionFormLoad(load, recordId, fieldNames) {
|
|
|
53
53
|
return { status: "loading" };
|
|
54
54
|
if (query.data === null)
|
|
55
55
|
return { status: "missing-record" };
|
|
56
|
-
return { status: "ready", values };
|
|
56
|
+
return { status: "ready", values, refetch: query.refetch };
|
|
57
57
|
}
|
|
58
58
|
/** Banner for every `load` state that is not "ready". */
|
|
59
59
|
export function ActionFormLoadStatus({ state, screenId, translate, }) {
|
|
@@ -97,8 +97,12 @@ export function ConfigEditBody({ schema, screen, translate, }) {
|
|
|
97
97
|
// Detail load: config:query:values returns ALL keys of the tenant.
|
|
98
98
|
// We map them via screen.configKeys from short to qualified name onto our
|
|
99
99
|
// form field values.
|
|
100
|
-
const valuesQuery = useQuery("config:query:values", {
|
|
101
|
-
|
|
100
|
+
const valuesQuery = useQuery("config:query:values", {
|
|
101
|
+
scope: screen.scope,
|
|
102
|
+
});
|
|
103
|
+
const cascadeQuery = useQuery("config:query:cascade", {
|
|
104
|
+
scope: screen.scope,
|
|
105
|
+
});
|
|
102
106
|
const secretKeys = screen.secretKeys;
|
|
103
107
|
const secretsQuery = useQuery("secrets:query:list", {}, { enabled: secretKeys !== undefined });
|
|
104
108
|
const synthEntity = useMemo(() => synthesizeConfigEditEntity(screen.fields), [screen.fields]);
|
|
@@ -2,6 +2,17 @@ import type { FormValues } from "../headless/index.ts";
|
|
|
2
2
|
export declare function versionOf(record: Readonly<Record<string, unknown>>): number;
|
|
3
3
|
export declare function buildInitialValues(fields: Readonly<Record<string, unknown>>, defaultCurrency?: string, moneyCurrencyOverrides?: Readonly<Record<string, string>>): FormValues;
|
|
4
4
|
export declare function tenantCurrencyMoneyFieldNames(fields: Readonly<Record<string, unknown>>): readonly string[];
|
|
5
|
+
export type TenantCurrencyResolution = {
|
|
6
|
+
readonly status: "not-needed";
|
|
7
|
+
} | {
|
|
8
|
+
readonly status: "loading";
|
|
9
|
+
} | {
|
|
10
|
+
readonly status: "unresolved";
|
|
11
|
+
} | {
|
|
12
|
+
readonly status: "ready";
|
|
13
|
+
readonly currency: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function useTenantCurrency(needed: boolean, fallback: string | undefined): TenantCurrencyResolution;
|
|
5
16
|
export declare function literalCurrencyOverrides(fields: Readonly<Record<string, unknown>>): Readonly<Record<string, string>>;
|
|
6
17
|
export declare function useMoneyCurrencyOverrides(fields: Readonly<Record<string, unknown>>, tenantFieldNames: readonly string[], fallback: string | undefined): {
|
|
7
18
|
readonly overrides: Readonly<Record<string, string>> | undefined;
|
|
@@ -71,8 +71,8 @@ export function tenantCurrencyMoneyFieldNames(fields) {
|
|
|
71
71
|
// back to "EUR" while the query is genuinely still in flight — a
|
|
72
72
|
// screen with no tenant-declared money field, or a query that errored or
|
|
73
73
|
// returned no value for the key, resolves immediately instead of blocking.
|
|
74
|
-
function resolveTenantCurrency(
|
|
75
|
-
if (
|
|
74
|
+
function resolveTenantCurrency(needed, query, fallback) {
|
|
75
|
+
if (!needed)
|
|
76
76
|
return { status: "not-needed" };
|
|
77
77
|
if (query.error) {
|
|
78
78
|
return fallback === undefined
|
|
@@ -88,6 +88,12 @@ function resolveTenantCurrency(fieldNames, query, fallback) {
|
|
|
88
88
|
? { status: "unresolved" }
|
|
89
89
|
: { status: "ready", currency: fallback };
|
|
90
90
|
}
|
|
91
|
+
// `needed = false` skips the config query entirely; an errored query or a missing value
|
|
92
|
+
// resolves to `fallback` (or `unresolved` without one).
|
|
93
|
+
export function useTenantCurrency(needed, fallback) {
|
|
94
|
+
const query = useQuery("config:query:values", { keys: [TENANT_CURRENCY_CONFIG_KEY] }, { enabled: needed });
|
|
95
|
+
return resolveTenantCurrency(needed, query, fallback);
|
|
96
|
+
}
|
|
91
97
|
// A money field naming a fixed ISO code (`currency: { kind: "literal", code }`)
|
|
92
98
|
// resolves with no query at all. Read structurally, same idiom as
|
|
93
99
|
// tenantCurrencyMoneyFieldNames above.
|
|
@@ -115,8 +121,7 @@ export function useMoneyCurrencyOverrides(fields, tenantFieldNames,
|
|
|
115
121
|
// `undefined` = no safe last resort (entity-less forms): an unresolvable
|
|
116
122
|
// tenant currency is reported via `unresolved` instead of guessing a code.
|
|
117
123
|
fallback) {
|
|
118
|
-
const
|
|
119
|
-
const resolution = resolveTenantCurrency(tenantFieldNames, query, fallback);
|
|
124
|
+
const resolution = useTenantCurrency(tenantFieldNames.length > 0, fallback);
|
|
120
125
|
const resolvedTenantCurrency = resolution.status === "ready" ? resolution.currency : undefined;
|
|
121
126
|
const overrides = useMemo(() => {
|
|
122
127
|
const out = { ...literalCurrencyOverrides(fields) };
|
|
@@ -43,10 +43,12 @@ export type ExtensionSectionProps = {
|
|
|
43
43
|
* `snapshot.errors` on the field instead of a collective message.
|
|
44
44
|
* Undefined outside entityEdit sections. */
|
|
45
45
|
readonly validate?: () => boolean;
|
|
46
|
-
/** Marks
|
|
47
|
-
*
|
|
46
|
+
/** Marks host form values as saved after the section wrote them itself, so
|
|
47
|
+
* a following navigation does not ask to discard. Pass the field names the
|
|
48
|
+
* section persisted: other host fields keep their unsaved state. Without
|
|
49
|
+
* `fields`, every current host value counts as saved.
|
|
48
50
|
* Undefined outside entityEdit sections and the entityEdit footer slot. */
|
|
49
|
-
readonly markSaved?: () => void;
|
|
51
|
+
readonly markSaved?: (fields?: readonly string[]) => void;
|
|
50
52
|
/** Whether the host form has unsaved changes. Only set in the entityEdit
|
|
51
53
|
* footer slot (`screen.slots.footer`); undefined in all other mounts. */
|
|
52
54
|
readonly hasUnsavedChanges?: boolean;
|
package/dist/app/form-schema.js
CHANGED
|
@@ -29,7 +29,7 @@ function isRequiredValueMissing(spec, field, record, unchangedFrom) {
|
|
|
29
29
|
// — they're fillable, so NO_WIDGET_FIELD_TYPES's "embedded"
|
|
30
30
|
// entry must not exempt them. A statically-`required: true` field
|
|
31
31
|
// with no bound widget is also caught at boot
|
|
32
|
-
// (validateNoWidgetRequiredField in boot-validator/screens.ts).
|
|
32
|
+
// (validateNoWidgetRequiredField in boot-validator/screens-forms.ts).
|
|
33
33
|
if (NO_WIDGET_FIELD_TYPES.includes(field.type) && !isEmbeddedListField(field))
|
|
34
34
|
return false;
|
|
35
35
|
if (isPresent(record[spec.field]))
|
|
@@ -18,7 +18,7 @@ export function HeaderActionsBar({ actions, Button, Dialog, ActionOverflowMenu,
|
|
|
18
18
|
if (compact && PageHeader !== undefined) {
|
|
19
19
|
const compactPrimary = compactHeaderPrimary(actions, preferredPrimaryId);
|
|
20
20
|
const overflowActions = actions.filter((a) => a !== compactPrimary?.action);
|
|
21
|
-
return (_jsxs(_Fragment, { children: [compactPrimary !== undefined && (_jsx(RenderEditActionButton, { action: compactPrimary.action, Button: Button, Dialog: Dialog, iconOnly: compactPrimary.iconOnly,
|
|
21
|
+
return (_jsxs(_Fragment, { children: [compactPrimary !== undefined && (_jsx(RenderEditActionButton, { action: compactPrimary.action, Button: Button, Dialog: Dialog, iconOnly: compactPrimary.iconOnly, onError: onError }, compactPrimary.action.id)), overflowActions.length > 0 && (_jsx(PageHeader, { overflowItems: overflowActions.map(toMenuItem) })), pendingActionDialog] }));
|
|
22
22
|
}
|
|
23
23
|
const maxPlainButtons = collapseAfterPrimary ? 1 : 2;
|
|
24
24
|
if (actions.length <= maxPlainButtons || ActionOverflowMenu === undefined) {
|
|
@@ -10,7 +10,7 @@ export declare function ExtensionSectionMount({ section, entityName: hostEntityN
|
|
|
10
10
|
readonly values?: Readonly<Record<string, unknown>>;
|
|
11
11
|
readonly patch?: (partial: Readonly<Record<string, unknown>>) => void;
|
|
12
12
|
readonly validate?: () => boolean;
|
|
13
|
-
readonly markSaved?: () => void;
|
|
13
|
+
readonly markSaved?: (fields?: readonly string[]) => void;
|
|
14
14
|
readonly hideTitle?: boolean;
|
|
15
15
|
/** section.actions, already resolved into buttons by the caller. */
|
|
16
16
|
readonly actions?: ReactNode;
|
|
@@ -24,7 +24,7 @@ export declare function EditSlotMount({ slot, slotName, screenId, entityName, en
|
|
|
24
24
|
readonly entityName: string;
|
|
25
25
|
readonly entityId: string | null;
|
|
26
26
|
readonly values: Readonly<Record<string, unknown>>;
|
|
27
|
-
readonly markSaved?: () => void;
|
|
27
|
+
readonly markSaved?: (fields?: readonly string[]) => void;
|
|
28
28
|
readonly hasUnsavedChanges?: boolean;
|
|
29
29
|
readonly wizardStep?: {
|
|
30
30
|
readonly index: number;
|
|
@@ -169,7 +169,7 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
|
|
|
169
169
|
};
|
|
170
170
|
}
|
|
171
171
|
return {
|
|
172
|
-
field: entry.from.join("+")
|
|
172
|
+
field: `${entry.from.join("+")}:${entry.label}`,
|
|
173
173
|
label: t(entry.label),
|
|
174
174
|
value: entry.from.reduce((sum, name) => sum + sumEmbeddedListColumn(rows, name), 0),
|
|
175
175
|
money: entry.from.every((name) => cells.find((c) => c.field === name)?.type === "money"),
|
|
@@ -22,8 +22,21 @@ import { usePrimitives, } from "../primitives.js";
|
|
|
22
22
|
import { sortByAccessor } from "../sort-by-accessor.js";
|
|
23
23
|
import { RenderEditActionButton } from "./render-edit-action-button.js";
|
|
24
24
|
import { RenderList } from "./render-list.js";
|
|
25
|
+
// Keyed on the type, not on key presence: most factories omit `sortable`
|
|
26
|
+
// from the definition, so `"sortable" in field` would drop the column flag.
|
|
27
|
+
const NON_SORTABLE_FIELD_TYPES = new Set([
|
|
28
|
+
"longText",
|
|
29
|
+
"multiSelect",
|
|
30
|
+
"embedded",
|
|
31
|
+
"jsonb",
|
|
32
|
+
"tz",
|
|
33
|
+
"file",
|
|
34
|
+
"image",
|
|
35
|
+
"files",
|
|
36
|
+
"images",
|
|
37
|
+
]);
|
|
25
38
|
function isSortableCapable(field) {
|
|
26
|
-
return
|
|
39
|
+
return !NON_SORTABLE_FIELD_TYPES.has(field.type);
|
|
27
40
|
}
|
|
28
41
|
const RELATED_LIST_PSEUDO_ENTITY = "__related-list__";
|
|
29
42
|
// Minimal EntityDefinition from the section's own columns — same shape as
|
|
@@ -10,15 +10,13 @@ export declare function RenderEditActionConfirmDialog({ action, open, onOpenChan
|
|
|
10
10
|
readonly onConfirm: () => void | Promise<void>;
|
|
11
11
|
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
12
12
|
}): ReactNode;
|
|
13
|
-
export declare function RenderEditActionButton({ action, iconOnly, hideIcon,
|
|
13
|
+
export declare function RenderEditActionButton({ action, iconOnly, hideIcon, Button, Dialog, onError, }: {
|
|
14
14
|
readonly action: RenderEditAction;
|
|
15
15
|
/** Group-level collapse (see `shouldRenderActionsIconOnly`) — only takes
|
|
16
16
|
* effect when this action actually resolved an icon. */
|
|
17
17
|
readonly iconOnly?: boolean;
|
|
18
18
|
/** Record-header actions are text-only; the icon still shows when collapsed to icon-only. */
|
|
19
19
|
readonly hideIcon?: boolean;
|
|
20
|
-
/** Short labelled button for a phone header, where a bare icon would hide what the action does. */
|
|
21
|
-
readonly compactLabel?: boolean;
|
|
22
20
|
readonly Button: ReturnType<typeof usePrimitives>["Button"];
|
|
23
21
|
readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
|
|
24
22
|
readonly onError: (notice: ErrorNotice | null) => void;
|
|
@@ -15,7 +15,7 @@ export function RenderEditActionConfirmDialog({ action, open, onOpenChange, onCo
|
|
|
15
15
|
// render-list.tsx's ToolbarActionView (each RenderEditAction is
|
|
16
16
|
// independently bound by the caller, there is no shared trigger pipeline
|
|
17
17
|
// to hook into like the built-in onDelete/onSubmit paths have).
|
|
18
|
-
export function RenderEditActionButton({ action, iconOnly = false, hideIcon = false,
|
|
18
|
+
export function RenderEditActionButton({ action, iconOnly = false, hideIcon = false, Button, Dialog, onError, }) {
|
|
19
19
|
const [busy, setBusy] = useState(false);
|
|
20
20
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
21
21
|
const trigger = async () => {
|
|
@@ -33,7 +33,7 @@ export function RenderEditActionButton({ action, iconOnly = false, hideIcon = fa
|
|
|
33
33
|
};
|
|
34
34
|
const variant = action.style ?? "secondary";
|
|
35
35
|
const showIconOnly = iconOnly && action.icon !== undefined;
|
|
36
|
-
return (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: variant, loading: busy, ...(action.icon !== undefined && (!hideIcon || showIconOnly) && { icon: action.icon }), ...(
|
|
36
|
+
return (_jsxs(_Fragment, { children: [_jsx(Button, { type: "button", variant: variant, loading: busy, ...(action.icon !== undefined && (!hideIcon || showIconOnly) && { icon: action.icon }), ...(showIconOnly && {
|
|
37
37
|
size: "icon",
|
|
38
38
|
ariaLabel: action.label,
|
|
39
39
|
title: action.label,
|
|
@@ -215,10 +215,13 @@ export function RenderEdit(props) {
|
|
|
215
215
|
const scopeFieldNamesRef = useRef(scopeFieldNames);
|
|
216
216
|
scopeFieldNamesRef.current = scopeFieldNames;
|
|
217
217
|
const scopedValidate = useCallback(() => controller.validate(scopeFieldNamesRef.current), [controller]);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
218
|
+
// The extension dirty flag aggregates every section; each section clears its
|
|
219
|
+
// own part, so a host-side markSaved must not reset it.
|
|
220
|
+
const markSaved = useCallback((fields) => {
|
|
221
|
+
if (fields === undefined)
|
|
222
|
+
controller.rebase();
|
|
223
|
+
else
|
|
224
|
+
controller.rebaseFields(fields);
|
|
222
225
|
}, [controller]);
|
|
223
226
|
// `saveDraft` (defined below) is a fresh closure every render, over that
|
|
224
227
|
// render's `draftKey`/`draftId` — the ref lets the debounce timer below
|
|
@@ -1048,7 +1051,7 @@ export function RenderEdit(props) {
|
|
|
1048
1051
|
// Title-less sections would otherwise collide on key/testId, so use the index
|
|
1049
1052
|
// as a fallback.
|
|
1050
1053
|
const sectionKey = section.title ?? `section-${sectionIndex}`;
|
|
1051
|
-
const renderFieldGrid = (fields, columns, gridKey) => (_jsx(Grid, { columns: columns, ...(isSettingsList ? { list: true } : usesFieldFlow && { flow: true }), children: fields.map((field) => (_jsx(GridCellForField, { field: disabled ? { ...field, readOnly: true } : field, columns: columns, ...(!isSettingsList && usesFieldFlow && { flow: true }), ...(isSettingsList && {
|
|
1054
|
+
const renderFieldGrid = (fields, columns, gridKey, columnsDeclared) => (_jsx(Grid, { columns: usesFieldFlow && columnsDeclared !== true ? 1 : columns, ...(isSettingsList ? { list: true } : usesFieldFlow && { flow: true }), children: fields.map((field) => (_jsx(GridCellForField, { field: disabled ? { ...field, readOnly: true } : field, columns: columns, ...(!isSettingsList && usesFieldFlow && { flow: true }), ...(isSettingsList && {
|
|
1052
1055
|
layout: "row",
|
|
1053
1056
|
accent: fieldAccent?.(field.field) === true,
|
|
1054
1057
|
}), issues: snapshot.errors[field.field], onChange: (v) => {
|
|
@@ -1075,7 +1078,7 @@ export function RenderEdit(props) {
|
|
|
1075
1078
|
// cannot stand in for the tabs-mode card here.
|
|
1076
1079
|
if (hideSectionTitles === true) {
|
|
1077
1080
|
if (section.groups !== undefined) {
|
|
1078
|
-
const groupsGrid = (_jsx(Grid, { columns: 1, children: section.groups.map((group, groupIndex) => (_jsx(Card, { slots: { title: group.title }, testId: `section-${sectionKey}-group-${groupIndex}`, children: renderFieldGrid(group.fields, group.columns, `${sectionKey}-group-${groupIndex}-grid
|
|
1081
|
+
const groupsGrid = (_jsx(Grid, { columns: 1, children: section.groups.map((group, groupIndex) => (_jsx(Card, { slots: { title: group.title }, testId: `section-${sectionKey}-group-${groupIndex}`, children: renderFieldGrid(group.fields, group.columns, `${sectionKey}-group-${groupIndex}-grid`, group.columnsDeclared) }, group.title))) }, `${sectionKey}-groups`));
|
|
1079
1082
|
// `actions` on a `groups` section has no group of its own to
|
|
1080
1083
|
// sit in and would need an extra title-less Card around the
|
|
1081
1084
|
// group cards (Card-in-Card) — the boot-validator rejects that
|
|
@@ -1092,7 +1095,7 @@ export function RenderEdit(props) {
|
|
|
1092
1095
|
...(section.description !== undefined && { subtitle: section.description }),
|
|
1093
1096
|
...(sectionActionsEl !== undefined && { headerActions: sectionActionsEl }),
|
|
1094
1097
|
},
|
|
1095
|
-
}), testId: `section-${sectionKey}`, children: renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid
|
|
1098
|
+
}), testId: `section-${sectionKey}`, children: renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`, section.columnsDeclared) }, sectionKey));
|
|
1096
1099
|
return wrapWizardStep(sectionKey, cardEl);
|
|
1097
1100
|
}
|
|
1098
1101
|
// Suppress the section header when it would just repeat the form
|
|
@@ -1109,7 +1112,7 @@ export function RenderEdit(props) {
|
|
|
1109
1112
|
const sectionSubtitle = section.description ?? (headerFallback ? formSubtitle : undefined);
|
|
1110
1113
|
const sectionEl = (_jsxs(Section, { ...(sectionNavItems.some((item) => item.id === formSectionDomId(sectionIndex)) && {
|
|
1111
1114
|
id: formSectionDomId(sectionIndex),
|
|
1112
|
-
}), ...(sectionTitle !== undefined && { title: sectionTitle }), ...(sectionSubtitle !== undefined && { subtitle: sectionSubtitle }), ...(section.icon !== undefined && { icon: section.icon }), ...(sectionActionsEl !== undefined && { actions: sectionActionsEl }), ...(isSettingsList && { layout: "settings-list" }), testId: `section-${sectionKey}`, children: [showsAllRequiredHint && insideDrawer && sectionIndex === 0 && allRequiredHint, renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid
|
|
1115
|
+
}), ...(sectionTitle !== undefined && { title: sectionTitle }), ...(sectionSubtitle !== undefined && { subtitle: sectionSubtitle }), ...(section.icon !== undefined && { icon: section.icon }), ...(sectionActionsEl !== undefined && { actions: sectionActionsEl }), ...(isSettingsList && { layout: "settings-list" }), testId: `section-${sectionKey}`, children: [showsAllRequiredHint && insideDrawer && sectionIndex === 0 && allRequiredHint, renderFieldGrid(section.fields, section.columns, `${sectionKey}-grid`, section.columnsDeclared)] }, sectionKey));
|
|
1113
1116
|
return wrapWizardStep(sectionKey, sectionEl);
|
|
1114
1117
|
}), formError !== null && (_jsxs(Banner, { variant: "error", testId: "render-edit-form-error", actions: formError.code === "version_conflict" && onReload !== undefined ? (_jsx(Button, { variant: "secondary", onClick: () => {
|
|
1115
1118
|
onReload();
|
|
@@ -16,7 +16,7 @@ import { fileIdsOf } from "../file-ids-of.js";
|
|
|
16
16
|
import { applyFormatSpec, currencyDecimals, EMPTY_VALUE_PLACEHOLDER, } from "../headless/index.js";
|
|
17
17
|
import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
|
|
18
18
|
import { useQuery } from "../hooks/use-query.js";
|
|
19
|
-
import { referenceOptionSource } from "../hooks/use-reference-lookup.js";
|
|
19
|
+
import { isCompleteRowPage, referenceOptionSource } from "../hooks/use-reference-lookup.js";
|
|
20
20
|
import { useLocale, useTranslation } from "../i18n.js";
|
|
21
21
|
import { usePrimitives } from "../primitives.js";
|
|
22
22
|
import { AvailabilityOptionsMultiSelect, AvailabilityOptionsSelect, staticSelectOptions, } from "./availability-options-select.js";
|
|
@@ -354,7 +354,10 @@ function ReadOnlyReferenceValue({ field, featureName, }) {
|
|
|
354
354
|
const row = rows.find((r) => String(r["id"] ?? "") === id);
|
|
355
355
|
if (row === undefined) {
|
|
356
356
|
const unknownKey = UNRESOLVED_REFERENCE_LABEL_KEYS[`${refFeature}:${refEntity}`];
|
|
357
|
-
const label = unknownKey !== undefined &&
|
|
357
|
+
const label = unknownKey !== undefined &&
|
|
358
|
+
!queryResult.loading &&
|
|
359
|
+
queryResult.error === null &&
|
|
360
|
+
isCompleteRowPage(queryResult.data)
|
|
358
361
|
? t(unknownKey)
|
|
359
362
|
: id;
|
|
360
363
|
return { id, label, link: undefined };
|
|
@@ -12,7 +12,8 @@ import { shouldRenderActionsIconOnly, usePrimitives, } from "../primitives.js";
|
|
|
12
12
|
import { ListEmptyStateBody } from "./list-empty-state.js";
|
|
13
13
|
import { needsActionConfirm } from "./render-edit-action-button.js";
|
|
14
14
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
15
|
-
// Icon-less actions are promoted too
|
|
15
|
+
// Icon-less actions are promoted too; on phones the call site falls back to a
|
|
16
|
+
// "plus" icon, on desktop the label carries the action.
|
|
16
17
|
function isHeaderPromotableToolbarAction(action) {
|
|
17
18
|
return action.style === "primary" && !needsActionConfirm(action);
|
|
18
19
|
}
|
|
@@ -37,24 +38,31 @@ export function RenderList(props) {
|
|
|
37
38
|
// The parent may echo our own debounced emits back late (router/URL round
|
|
38
39
|
// trip), possibly after a newer emit already went out; adopting such a stale
|
|
39
40
|
// echo would erase keys typed in between. All unechoed emits are kept, and a
|
|
40
|
-
// matching echo drops itself and every older one.
|
|
41
|
+
// matching echo drops itself and every older one. The render phase only reads
|
|
42
|
+
// the list (a discarded or repeated render must find the echo again); the
|
|
43
|
+
// matched entries are dropped in an effect.
|
|
41
44
|
const pendingEmitsRef = useRef([]);
|
|
42
45
|
if (searchValue !== syncedSearchValue) {
|
|
43
46
|
setSyncedSearchValue(searchValue);
|
|
44
|
-
|
|
45
|
-
if (echoIndex >= 0) {
|
|
46
|
-
pendingEmitsRef.current.splice(0, echoIndex + 1);
|
|
47
|
-
}
|
|
48
|
-
else {
|
|
47
|
+
if (!pendingEmitsRef.current.includes(searchValue ?? "")) {
|
|
49
48
|
setLocalQ(searchValue ?? "");
|
|
50
49
|
pendingEmitsRef.current = [];
|
|
51
50
|
}
|
|
52
51
|
}
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const echoIndex = pendingEmitsRef.current.indexOf(syncedSearchValue ?? "");
|
|
54
|
+
if (echoIndex >= 0)
|
|
55
|
+
pendingEmitsRef.current.splice(0, echoIndex + 1);
|
|
56
|
+
}, [syncedSearchValue]);
|
|
53
57
|
useEffect(() => {
|
|
54
58
|
if (onSearchChange === undefined)
|
|
55
59
|
return;
|
|
56
60
|
if (localQ === (searchValue ?? ""))
|
|
57
61
|
return;
|
|
62
|
+
// A late echo of an older emit changes searchValue but must not re-send
|
|
63
|
+
// the value that is already in flight.
|
|
64
|
+
if (pendingEmitsRef.current.at(-1) === localQ)
|
|
65
|
+
return;
|
|
58
66
|
const timer = setTimeout(() => {
|
|
59
67
|
pendingEmitsRef.current.push(localQ);
|
|
60
68
|
onSearchChange(localQ);
|
|
@@ -79,14 +87,15 @@ export function RenderList(props) {
|
|
|
79
87
|
labelField: c.refLabelField ?? "id",
|
|
80
88
|
})), [vm.columns, featureName]);
|
|
81
89
|
const [referenceLookups, setReferenceLookups] = useState({});
|
|
82
|
-
const handleLookupMap = useCallback((field,
|
|
90
|
+
const handleLookupMap = useCallback((field, lookup) => {
|
|
83
91
|
setReferenceLookups((prev) => {
|
|
84
|
-
// Skip the update when the
|
|
92
|
+
// Skip the update when the lookup is identical. Otherwise the bridge
|
|
85
93
|
// would re-render and send the same map back until React entered
|
|
86
94
|
// an infinite loop.
|
|
87
|
-
|
|
95
|
+
const current = prev[field];
|
|
96
|
+
if (current?.map === lookup.map && current.complete === lookup.complete)
|
|
88
97
|
return prev;
|
|
89
|
-
return { ...prev, [field]:
|
|
98
|
+
return { ...prev, [field]: lookup };
|
|
90
99
|
});
|
|
91
100
|
}, []);
|
|
92
101
|
const resolveReferenceLink = useReferenceLinkResolver();
|
|
@@ -100,15 +109,19 @@ export function RenderList(props) {
|
|
|
100
109
|
// the default is used only when none is set.
|
|
101
110
|
if (col.renderer !== undefined)
|
|
102
111
|
return col;
|
|
103
|
-
const
|
|
112
|
+
const published = referenceLookups[col.field];
|
|
113
|
+
const map = published?.map;
|
|
104
114
|
const unknownKey = UNRESOLVED_REFERENCE_LABEL_KEYS[`${col.refFeature ?? featureName}:${col.refEntity ?? ""}`];
|
|
105
115
|
// No raw id for entities that opt in: a placeholder while the lookup
|
|
106
|
-
// loads, the "unknown" label once
|
|
116
|
+
// loads, the "unknown" label once the complete lookup found nothing. A
|
|
117
|
+
// capped lookup page cannot prove absence, so it keeps the raw id.
|
|
107
118
|
const unresolvedLabel = (id) => unknownKey === undefined
|
|
108
119
|
? id
|
|
109
|
-
:
|
|
120
|
+
: published === undefined
|
|
110
121
|
? EMPTY_VALUE_PLACEHOLDER
|
|
111
|
-
:
|
|
122
|
+
: published.complete
|
|
123
|
+
? translate(unknownKey)
|
|
124
|
+
: id;
|
|
112
125
|
const labelField = col.refLabelField ?? "id";
|
|
113
126
|
const refEntity = col.refEntity ?? "";
|
|
114
127
|
// Only a row the user can see is linkable: resolved by the server
|
|
@@ -273,8 +286,8 @@ function ReferenceLookupBridge({ field, refEntity, labelField, featureName, onMa
|
|
|
273
286
|
const resolved = !lookup.loading && !lookup.failed;
|
|
274
287
|
useEffect(() => {
|
|
275
288
|
if (resolved)
|
|
276
|
-
onMap(field, lookup.map);
|
|
277
|
-
}, [field, lookup.map, resolved, onMap]);
|
|
289
|
+
onMap(field, { map: lookup.map, complete: lookup.complete });
|
|
290
|
+
}, [field, lookup.map, lookup.complete, resolved, onMap]);
|
|
278
291
|
return null;
|
|
279
292
|
}
|
|
280
293
|
// ToolbarActionView renders one button per toolbar action, with a confirm
|
|
@@ -305,7 +318,7 @@ function ToolbarActionView({ action, iconOnly = false, Button, Dialog, Banner, }
|
|
|
305
318
|
const variant = action.style ?? "secondary";
|
|
306
319
|
const needsConfirm = needsActionConfirm(action);
|
|
307
320
|
const showIconOnly = iconOnly && action.icon !== undefined;
|
|
308
|
-
return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: variant, loading: busy, ...(action.icon !== undefined && { icon: action.icon }), size: showIconOnly ? "icon" : "
|
|
321
|
+
return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: variant, loading: busy, ...(action.icon !== undefined && { icon: action.icon }), size: showIconOnly ? "icon" : "md", ...(showIconOnly && { ariaLabel: action.label, title: action.label }), onClick: () => {
|
|
309
322
|
if (needsConfirm) {
|
|
310
323
|
setConfirmOpen(true);
|
|
311
324
|
}
|
|
@@ -23,7 +23,15 @@ function cardSize(node) {
|
|
|
23
23
|
return { width, height };
|
|
24
24
|
}
|
|
25
25
|
function assertEdgesReferenceKnownNodes(spec) {
|
|
26
|
-
const ids = new Set(
|
|
26
|
+
const ids = new Set();
|
|
27
|
+
for (const node of spec.nodes) {
|
|
28
|
+
if (ids.has(node.id))
|
|
29
|
+
throw new Error(`FlowSpec: duplicate node id "${node.id}"`);
|
|
30
|
+
ids.add(node.id);
|
|
31
|
+
if (node.icon !== undefined && node.icon !== "ai" && !Object.hasOwn(ICONS, node.icon)) {
|
|
32
|
+
throw new Error(`FlowSpec: node "${node.id}" has unknown icon "${node.icon}"`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
27
35
|
for (const edge of spec.edges) {
|
|
28
36
|
if (!ids.has(edge.from))
|
|
29
37
|
throw new Error(`FlowSpec: edge references unknown node "${edge.from}"`);
|
|
@@ -217,6 +217,13 @@ export function createFormController(options) {
|
|
|
217
217
|
errors = Object.freeze({});
|
|
218
218
|
invalidate();
|
|
219
219
|
}
|
|
220
|
+
function runRebaseFields(fields) {
|
|
221
|
+
initial = {
|
|
222
|
+
...initial,
|
|
223
|
+
...Object.fromEntries(fields.map((field) => [field, values[field]])),
|
|
224
|
+
};
|
|
225
|
+
invalidate();
|
|
226
|
+
}
|
|
220
227
|
// Stale-submit-safe rebase. Difference to runRebase: the baseline is
|
|
221
228
|
// taken from the values that were actually SENT to the server, not
|
|
222
229
|
// from `values` at the moment the server replied. If the user typed
|
|
@@ -296,6 +303,7 @@ export function createFormController(options) {
|
|
|
296
303
|
invalidate();
|
|
297
304
|
},
|
|
298
305
|
rebase: runRebase,
|
|
306
|
+
rebaseFields: runRebaseFields,
|
|
299
307
|
setCtx(nextCtx) {
|
|
300
308
|
// Cast back to TCtx: setCtx's public signature takes unknown so
|
|
301
309
|
// callers don't need to plumb generics. If conditions depend on ctx
|
|
@@ -33,6 +33,7 @@ export type FormController<TValues extends FormValues> = {
|
|
|
33
33
|
validate(scope?: readonly FormScopeKey<TValues>[]): boolean;
|
|
34
34
|
reset(): void;
|
|
35
35
|
rebase(): void;
|
|
36
|
+
rebaseFields(fields: readonly string[]): void;
|
|
36
37
|
setCtx(ctx: unknown): void;
|
|
37
38
|
submit<TData = unknown>(): Promise<SubmitResult<TData>>;
|
|
38
39
|
};
|