@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.
Files changed (68) hide show
  1. package/dist/app/action-form-body.js +10 -2
  2. package/dist/app/action-form-support.d.ts +1 -0
  3. package/dist/app/action-form-support.js +1 -1
  4. package/dist/app/config-edit-body.js +6 -2
  5. package/dist/app/entity-edit-prefill.d.ts +11 -0
  6. package/dist/app/entity-edit-prefill.js +9 -4
  7. package/dist/app/extension-sections.d.ts +5 -3
  8. package/dist/app/form-schema.js +1 -1
  9. package/dist/app/header-actions-bar.js +1 -1
  10. package/dist/components/edit-mounts.d.ts +2 -2
  11. package/dist/components/embedded-list-field.js +1 -1
  12. package/dist/components/related-list-section.js +14 -1
  13. package/dist/components/render-edit-action-button.d.ts +1 -3
  14. package/dist/components/render-edit-action-button.js +2 -2
  15. package/dist/components/render-edit.js +11 -8
  16. package/dist/components/render-field.js +5 -2
  17. package/dist/components/render-list.js +31 -18
  18. package/dist/flow-diagram/render.js +9 -1
  19. package/dist/headless/form/form-controller.js +8 -0
  20. package/dist/headless/form/types.d.ts +1 -0
  21. package/dist/headless/format/index.js +89 -64
  22. package/dist/headless/view-model/edit.js +2 -0
  23. package/dist/headless/view-model/list.js +184 -149
  24. package/dist/headless/view-model/types.d.ts +3 -0
  25. package/dist/hooks/live-refetch-scheduler.js +4 -0
  26. package/dist/hooks/use-reference-lookup.d.ts +9 -1
  27. package/dist/hooks/use-reference-lookup.js +17 -3
  28. package/dist/i18n-defaults.js +6 -2
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.js +1 -0
  31. package/dist/primitives.d.ts +19 -0
  32. package/dist/web/app/create-app.js +8 -3
  33. package/dist/web/app/dashboard-body.js +17 -1
  34. package/dist/web/app/dashboard-panels.js +12 -2
  35. package/dist/web/index.d.ts +1 -1
  36. package/dist/web/index.js +1 -1
  37. package/dist/web/layout/shell-header.js +2 -1
  38. package/dist/web/layout/uniform-bar-controls.d.ts +1 -0
  39. package/dist/web/layout/uniform-bar-controls.js +4 -0
  40. package/dist/web/lib/file-ref-download.js +4 -1
  41. package/dist/web/primitives/action-menu.js +1 -1
  42. package/dist/web/primitives/code-input.d.ts +6 -0
  43. package/dist/web/primitives/code-input.js +31 -0
  44. package/dist/web/primitives/data-table.js +4 -3
  45. package/dist/web/primitives/facet-filter.js +1 -1
  46. package/dist/web/primitives/file-thumbnail.d.ts +3 -0
  47. package/dist/web/primitives/file-thumbnail.js +5 -0
  48. package/dist/web/primitives/input.js +6 -1
  49. package/dist/web/primitives/layout-elements.js +6 -4
  50. package/dist/web/primitives/page-header.js +3 -1
  51. package/dist/web/primitives/row-actions.js +3 -3
  52. package/dist/web/primitives/use-focus-on-mount.d.ts +2 -0
  53. package/dist/web/primitives/use-focus-on-mount.js +9 -0
  54. package/dist/web/widgets/bounded-load-cache.d.ts +1 -1
  55. package/dist/web/widgets/bounded-load-cache.js +10 -4
  56. package/dist/web/widgets/choice-cards.d.ts +17 -0
  57. package/dist/web/widgets/choice-cards.js +12 -0
  58. package/dist/web/widgets/collapsible-section.js +1 -1
  59. package/dist/web/widgets/detail-list.d.ts +5 -1
  60. package/dist/web/widgets/detail-list.js +5 -1
  61. package/dist/web/widgets/index.d.ts +1 -0
  62. package/dist/web/widgets/index.js +1 -0
  63. package/dist/web/widgets/pdf-preview.js +1 -1
  64. package/dist/web/widgets/plan-card.d.ts +2 -0
  65. package/dist/web/widgets/plan-card.js +1 -1
  66. package/dist/web/widgets/upload-zone.js +13 -5
  67. package/package.json +5 -5
  68. 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
- setFormEpoch((epoch) => epoch + 1);
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
- const cascadeQuery = useQuery("config:query:cascade", {});
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(fieldNames, query, fallback) {
75
- if (fieldNames.length === 0)
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 query = useQuery("config:query:values", { keys: [TENANT_CURRENCY_CONFIG_KEY] }, { enabled: tenantFieldNames.length > 0 });
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 the current host form values as saved after the section wrote
47
- * them itself, so a following navigation does not ask to discard.
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;
@@ -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, compactLabel: true, onError: onError }, compactPrimary.action.id)), overflowActions.length > 0 && (_jsx(PageHeader, { overflowItems: overflowActions.map(toMenuItem) })), pendingActionDialog] }));
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 "sortable" in field;
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, compactLabel, Button, Dialog, onError, }: {
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, compactLabel = false, Button, Dialog, onError, }) {
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 }), ...(compactLabel && !showIconOnly && { size: "sm" }), ...(showIconOnly && {
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
- const markSaved = useCallback(() => {
219
- controller.rebase();
220
- extensionDirtyRef.current = false;
221
- setExtensionDirty(false);
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`) }, group.title))) }, `${sectionKey}-groups`));
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`) }, sectionKey));
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`)] }, sectionKey));
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 && !queryResult.loading && queryResult.error === null
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 (with a "plus" fallback at the call site).
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
- const echoIndex = pendingEmitsRef.current.indexOf(searchValue ?? "");
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, map) => {
90
+ const handleLookupMap = useCallback((field, lookup) => {
83
91
  setReferenceLookups((prev) => {
84
- // Skip the update when the map is identical. Otherwise the bridge
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
- if (prev[field] === map)
95
+ const current = prev[field];
96
+ if (current?.map === lookup.map && current.complete === lookup.complete)
88
97
  return prev;
89
- return { ...prev, [field]: map };
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 map = referenceLookups[col.field];
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 it found nothing.
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
- : map === undefined
120
+ : published === undefined
110
121
  ? EMPTY_VALUE_PLACEHOLDER
111
- : translate(unknownKey);
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" : "sm", ...(showIconOnly && { ariaLabel: action.label, title: action.label }), onClick: () => {
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(spec.nodes.map((n) => n.id));
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
  };