@cosmicdrift/kumiko-renderer 0.333.0 → 0.335.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 (52) hide show
  1. package/dist/app/config-edit-shim.d.ts +7 -0
  2. package/dist/app/config-edit-shim.js +39 -0
  3. package/dist/app/content-preview.js +7 -9
  4. package/dist/app/extension-sections.d.ts +6 -0
  5. package/dist/app/form-schema.d.ts +2 -1
  6. package/dist/app/form-schema.js +11 -2
  7. package/dist/app/kumiko-screen.d.ts +5 -0
  8. package/dist/app/kumiko-screen.js +259 -101
  9. package/dist/app/nav.d.ts +5 -0
  10. package/dist/app/nav.js +17 -4
  11. package/dist/app/query-state-banners.d.ts +8 -0
  12. package/dist/app/query-state-banners.js +14 -0
  13. package/dist/app/reference-facet-bridge.js +8 -1
  14. package/dist/app/row-actions.d.ts +12 -0
  15. package/dist/app/row-actions.js +24 -7
  16. package/dist/app/screen-access.js +13 -9
  17. package/dist/app/secret-mint-body.js +41 -7
  18. package/dist/app/secrets-edit-body.js +89 -33
  19. package/dist/components/embedded-list-field.js +2 -4
  20. package/dist/components/grid-cell-for-field.d.ts +7 -2
  21. package/dist/components/grid-cell-for-field.js +2 -2
  22. package/dist/components/multi-select-checkboxes.js +4 -1
  23. package/dist/components/query-options-select.d.ts +12 -3
  24. package/dist/components/query-options-select.js +45 -2
  25. package/dist/components/reference-create-dialog.js +9 -4
  26. package/dist/components/related-list-section.js +8 -10
  27. package/dist/components/render-edit-action-button.d.ts +1 -1
  28. package/dist/components/render-edit-action-button.js +5 -1
  29. package/dist/components/render-edit-logic.d.ts +2 -1
  30. package/dist/components/render-edit-logic.js +8 -5
  31. package/dist/components/render-edit-types.d.ts +30 -1
  32. package/dist/components/render-edit.js +248 -76
  33. package/dist/components/render-field.d.ts +7 -1
  34. package/dist/components/render-field.js +40 -15
  35. package/dist/components/render-list.js +6 -6
  36. package/dist/components/write-form-section.d.ts +5 -1
  37. package/dist/components/write-form-section.js +29 -25
  38. package/dist/context/embedded-screen-context.d.ts +5 -0
  39. package/dist/context/embedded-screen-context.js +12 -0
  40. package/dist/hooks/use-reference-lookup.d.ts +7 -0
  41. package/dist/hooks/use-reference-lookup.js +10 -0
  42. package/dist/i18n-defaults.js +30 -5
  43. package/dist/i18n.js +8 -4
  44. package/dist/index.d.ts +5 -1
  45. package/dist/index.js +3 -0
  46. package/dist/primitives.d.ts +70 -19
  47. package/dist/session/session-ended.d.ts +16 -0
  48. package/dist/session/session-ended.js +73 -0
  49. package/dist/sort-by-accessor.d.ts +6 -2
  50. package/dist/sort-by-accessor.js +13 -3
  51. package/package.json +5 -5
  52. package/src/changes.json +40 -0
@@ -1,14 +1,50 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { isOptionsQueryFieldRef } from "@cosmicdrift/kumiko-types/fields";
3
+ import { useEffect, useRef } from "react";
2
4
  import { useQuery } from "../hooks/use-query.js";
3
5
  import { usePrimitives } from "../primitives.js";
6
+ function isPayloadScalar(value) {
7
+ return ((typeof value === "string" && value !== "") ||
8
+ typeof value === "number" ||
9
+ typeof value === "boolean");
10
+ }
11
+ // An empty sibling value drops its key, so "nothing chosen yet" reaches the query
12
+ // handler as an absent property, not as "" / null.
13
+ export function resolveOptionsQueryPayload(payload, row) {
14
+ const resolved = {};
15
+ for (const [name, entry] of Object.entries(payload)) {
16
+ const value = isOptionsQueryFieldRef(entry) ? row[entry.field] : entry;
17
+ if (isPayloadScalar(value))
18
+ resolved[name] = value;
19
+ }
20
+ return resolved;
21
+ }
4
22
  // Select whose options come from a query (SelectFieldDef.optionsQuery), so it
5
23
  // has to be a mounted component for useQuery. useQuery re-fetches on the
6
24
  // serialized payload, hence no payload memoization here.
7
- export function QueryOptionsSelect({ field, query, payload, id, hasError, onChange, }) {
25
+ export function QueryOptionsSelect({ field, query, payload, row, id, hasError, onChange, }) {
8
26
  const { Input } = usePrimitives();
9
- const { data, loading } = useQuery(query, payload);
27
+ const resolvedPayload = resolveOptionsQueryPayload(payload, row);
28
+ const payloadKey = JSON.stringify(resolvedPayload);
29
+ const { data, loading } = useQuery(query, resolvedPayload);
10
30
  const current = typeof field.value === "string" ? field.value : "";
11
31
  const rows = data?.rows ?? [];
32
+ // useQuery keeps the previous data while a changed payload reloads, and a
33
+ // superseded response is aborted — so a fresh `data` object always belongs to
34
+ // the payload of the render it arrives in. The first load never clears (a stored
35
+ // value missing there stays visible); only a load after a payload change does.
36
+ const settledPayloadKey = useRef(null);
37
+ // biome-ignore lint/correctness/useExhaustiveDependencies: runs once per loaded result, not on every keystroke
38
+ useEffect(() => {
39
+ if (data === null)
40
+ return;
41
+ const previousKey = settledPayloadKey.current;
42
+ settledPayloadKey.current = payloadKey;
43
+ const payloadChanged = previousKey !== null && previousKey !== payloadKey;
44
+ if (payloadChanged && current !== "" && !data.rows.some((row) => row.value === current)) {
45
+ onChange("");
46
+ }
47
+ }, [data]);
12
48
  // A stored value missing from the result (still loading, or dropped from the
13
49
  // catalog) must stay visible instead of silently turning into "nothing selected".
14
50
  const options = current !== "" && !rows.some((row) => row.value === current)
@@ -19,3 +55,10 @@ export function QueryOptionsSelect({ field, query, payload, id, hasError, onChan
19
55
  const display = field.display === "radio" || field.display === "dropdown" ? field.display : "dropdown";
20
56
  return (_jsx(Input, { kind: "select", id: id, name: field.field, disabled: field.readOnly || loading, required: field.required, hasError: hasError, value: current, onChange: (v) => onChange(v), options: options, display: display }));
21
57
  }
58
+ // Badge text for a stored value of an optionsQuery select: the row's label once
59
+ // loaded, the raw value while loading or when the value is not in the result.
60
+ export function QueryOptionLabel({ query, payload, value, }) {
61
+ const { data } = useQuery(query, payload);
62
+ const label = data?.rows.find((row) => row.value === value)?.label;
63
+ return label ?? String(value);
64
+ }
@@ -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 initial = useMemo(() => buildInitialValues(entity.fields, entity.defaultCurrency ?? "EUR"), [entity.fields, entity.defaultCurrency]);
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 { useLocale, useTranslation } from "../i18n.js";
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 = useLocale().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
- return typeof value === "number" ? value : String(value ?? "");
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
@@ -306,11 +308,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
306
308
  filterValues: filters,
307
309
  onFilterChange,
308
310
  onFilterReset,
309
- }), ...(onRowClick !== undefined && { onRowClick }), ...(rowActions !== undefined && { rowActions }), ...(section.rowActionMode !== undefined && { rowActionMode: section.rowActionMode }), ...(rowGrouping !== undefined && { rowGrouping }), ...(rowTone !== undefined && { rowTone }), ...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons }), ...(emptyStateContent !== undefined && { emptyState: emptyStateContent }), ...(hideTitle === true && {
310
- scrollBody: true,
311
- screenPadding: false,
312
- chromeless: true,
313
- }), ...(embedded === true && { screenPadding: false, chromeless: true }) }) })] }));
311
+ }), ...(onRowClick !== undefined && { onRowClick }), ...(rowActions !== undefined && { rowActions }), ...(section.rowActionMode !== undefined && { rowActionMode: section.rowActionMode }), ...(rowGrouping !== undefined && { rowGrouping }), ...(rowTone !== undefined && { rowTone }), ...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons }), ...(emptyStateContent !== undefined && { emptyState: emptyStateContent }), screenPadding: false, ...(hideTitle === true && { scrollBody: true, chromeless: true }), ...(embedded === true && { chromeless: true }) }) })] }));
314
312
  // hideTitle (tabs mode) → the caller (render-edit.tsx) already wraps this
315
313
  // component's own return value in the same Card frame every other tab
316
314
  // kind gets — no card here, or the two would nest. `scrollBody` caps the
@@ -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 && { size: "icon", ariaLabel: action.label }), onClick: () => {
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 renderedFields = new Set(sections
36
- .filter((s) => s.kind === "fields" && (s.visible || s.fields.length === 0))
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;
@@ -88,12 +92,24 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
88
92
  /** Per-field extra content below the input (e.g. ConfigCascadeView).
89
93
  * Called with the field name, returns a ReactNode or undefined. */
90
94
  readonly fieldAppendix?: (fieldName: string) => ReactNode | undefined;
95
+ /** Placement of every `fieldAppendix` relative to its control. Default
96
+ * above-control; below-control keeps the inputs of neighbouring fields
97
+ * on one line when an appendix expands. */
98
+ readonly fieldAppendixPlacement?: "above-control" | "below-control";
99
+ /** Per-field help text rendered under the label (translated by the caller). */
100
+ readonly fieldDescription?: (fieldName: string) => ReactNode | undefined;
101
+ /** Marks fields whose value is set at this level; drawn by `settings-list` rows. */
102
+ readonly fieldAccent?: (fieldName: string) => boolean;
103
+ /** Edit-mode "changed" markers also for screens without an entity id
104
+ * (configEdit loads its values from a query, not a record). */
105
+ readonly markChangedFields?: boolean;
91
106
  /** Controlled mode (issue #1887): fires on every values-snapshot change
92
107
  * (typing, `patch(...)` from outside) with the current values. `changes`
93
108
  * is the delta against the initial values — same semantics as
94
109
  * `payloadMode: "changes"` — so a caller never overwrites unseen fields.
95
110
  * `valid` is a pure dry-run parse against `schema` (not a
96
- * `controller.validate()` call), so it does not paint field errors into
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
97
113
  * the UI and can diverge from the currently rendered `snapshot.errors` —
98
114
  * always `true` without `schema`. A caller that patches a fresh object
99
115
  * reference on every call must not do so unconditionally: `setValues` is
@@ -164,6 +180,19 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
164
180
  * screens with `fillHeight !== false`; dialogs and other embedded hosts
165
181
  * omit it and keep document-flow height. */
166
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;
187
+ /** Show the unsaved-changes footer (count, Discard, "Save changes") even
188
+ * without an entity id or a screen-height form — for hosts that edit an
189
+ * existing server-side record through `customSubmit` (configEdit, also when
190
+ * embedded as a dashboard panel). */
191
+ readonly dirtyFooter?: boolean;
192
+ /** Validate the edited field right after each change instead of only on
193
+ * submit, so a bounds violation shows at the field while the Save button
194
+ * looks enabled. Set where Save never blocks on a pre-check (configEdit). */
195
+ readonly validateOnChange?: boolean;
167
196
  /** Extra content rendered above the card, sharing its left padding and
168
197
  * width — for a host with its own header region (title/metrics/tabs)
169
198
  * that would otherwise render as unpadded siblings before RenderEdit.