@cosmicdrift/kumiko-renderer 0.332.0 → 0.334.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 (35) 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/facet-count-bridge.d.ts +10 -0
  4. package/dist/app/facet-count-bridge.js +20 -0
  5. package/dist/app/kumiko-screen.js +103 -42
  6. package/dist/app/list-facets.d.ts +14 -2
  7. package/dist/app/list-facets.js +74 -0
  8. package/dist/app/query-state-banners.d.ts +8 -0
  9. package/dist/app/query-state-banners.js +14 -0
  10. package/dist/app/row-actions.js +50 -40
  11. package/dist/app/secret-mint-body.js +3 -3
  12. package/dist/app/secrets-edit-body.js +46 -17
  13. package/dist/components/grid-cell-for-field.d.ts +7 -2
  14. package/dist/components/grid-cell-for-field.js +2 -2
  15. package/dist/components/query-options-select.d.ts +12 -3
  16. package/dist/components/query-options-select.js +45 -2
  17. package/dist/components/related-list-section.js +41 -8
  18. package/dist/components/render-edit-types.d.ts +24 -1
  19. package/dist/components/render-edit.js +100 -28
  20. package/dist/components/render-field.d.ts +7 -1
  21. package/dist/components/render-field.js +32 -4
  22. package/dist/components/render-list.d.ts +4 -1
  23. package/dist/components/render-list.js +2 -2
  24. package/dist/components/write-form-section.d.ts +5 -1
  25. package/dist/components/write-form-section.js +17 -5
  26. package/dist/context/embedded-screen-context.d.ts +5 -0
  27. package/dist/context/embedded-screen-context.js +12 -0
  28. package/dist/hooks/use-list-url-state.d.ts +2 -1
  29. package/dist/hooks/use-list-url-state.js +23 -9
  30. package/dist/i18n-defaults.js +31 -5
  31. package/dist/index.d.ts +2 -1
  32. package/dist/index.js +1 -0
  33. package/dist/primitives.d.ts +95 -13
  34. package/package.json +5 -5
  35. package/src/changes.json +7 -0
@@ -93,6 +93,7 @@ function buildNavigateRowAction(action, translate, nav, host) {
93
93
  id: action.id,
94
94
  label: translate(action.label),
95
95
  ...(action.style !== undefined && { style: action.style }),
96
+ ...(action.display !== undefined && { display: action.display }),
96
97
  confirmRequired: false,
97
98
  ...(action.rowClick === true && { rowClick: true }),
98
99
  ...(actionIcon !== undefined && { icon: actionIcon }),
@@ -125,6 +126,7 @@ function buildDrawerRowAction(action, translate, openDrawer) {
125
126
  id: action.id,
126
127
  label: translate(action.label),
127
128
  ...(action.style !== undefined && { style: action.style }),
129
+ ...(action.display !== undefined && { display: action.display }),
128
130
  confirmRequired: false,
129
131
  ...(actionIcon !== undefined && { icon: actionIcon }),
130
132
  onTrigger: (row) => {
@@ -149,6 +151,7 @@ function buildWriteHandlerRowAction(action, translate, refetch, dispatcher) {
149
151
  id: action.id,
150
152
  label: translate(action.label),
151
153
  ...(action.style !== undefined && { style: action.style }),
154
+ ...(action.display !== undefined && { display: action.display }),
152
155
  icon: resolveActionIcon(action.id, action.icon),
153
156
  ...(action.confirm !== undefined && { confirm: translate(action.confirm) }),
154
157
  ...(action.confirmLabel !== undefined && {
@@ -290,47 +293,52 @@ export function buildProjectionToolbarActions(options) {
290
293
  }
291
294
  return out.length > 0 ? out : undefined;
292
295
  }
293
- function buildNavigateRecordAction(action, options) {
294
- const { record, translate, nav, host, actionIcon, defaultScreenTargetEntityId, sameEntityScreenId, } = options;
295
- if (action.entity !== undefined) {
296
- const targetEntity = action.entity;
297
- const id = action.entityId !== undefined ? String(record[action.entityId] ?? "") : "";
298
- return {
299
- id: action.id,
300
- label: translate(action.label),
301
- ...(action.style !== undefined && { style: action.style }),
302
- confirmRequired: false,
303
- ...(actionIcon !== undefined && { icon: actionIcon }),
304
- onPress: () => {
305
- // No entityId on record (id === "") → nothing to navigate to.
306
- if (id !== "") {
307
- const params = navigateActionSearchParams(action, record);
308
- navigateWithReturnTo(nav, { entity: targetEntity, id }, host, params);
309
- }
310
- },
311
- };
312
- }
313
- if (action.screen !== undefined) {
314
- const explicit = action.entityId !== undefined ? String(record[action.entityId] ?? "") : undefined;
315
- const fallback = sameEntityScreenId?.(action.screen) ?? defaultScreenTargetEntityId;
316
- const navEntityId = explicit ?? fallback;
317
- const targetScreen = action.screen;
318
- return {
319
- id: action.id,
320
- label: translate(action.label),
321
- ...(action.style !== undefined && { style: action.style }),
322
- confirmRequired: false,
323
- ...(actionIcon !== undefined && { icon: actionIcon }),
324
- onPress: () => {
325
- const target = {
326
- screenId: targetScreen,
327
- ...(navEntityId !== undefined && navEntityId !== "" && { entityId: navEntityId }),
328
- };
296
+ function navigateActionBase(action, translate, actionIcon) {
297
+ return {
298
+ id: action.id,
299
+ label: translate(action.label),
300
+ ...(action.style !== undefined && { style: action.style }),
301
+ ...(action.display !== undefined && { display: action.display }),
302
+ confirmRequired: false,
303
+ ...(actionIcon !== undefined && { icon: actionIcon }),
304
+ };
305
+ }
306
+ function buildNavigateEntityAction(action, targetEntity, options) {
307
+ const { record, translate, nav, host, actionIcon } = options;
308
+ const id = action.entityId !== undefined ? String(record[action.entityId] ?? "") : "";
309
+ return {
310
+ ...navigateActionBase(action, translate, actionIcon),
311
+ onPress: () => {
312
+ // No entityId on record (id === "") → nothing to navigate to.
313
+ if (id !== "") {
329
314
  const params = navigateActionSearchParams(action, record);
330
- navigateWithReturnTo(nav, target, host, params);
331
- },
332
- };
333
- }
315
+ navigateWithReturnTo(nav, { entity: targetEntity, id }, host, params);
316
+ }
317
+ },
318
+ };
319
+ }
320
+ function buildNavigateScreenAction(action, targetScreen, options) {
321
+ const { record, translate, nav, host, actionIcon, defaultScreenTargetEntityId, sameEntityScreenId, } = options;
322
+ const explicit = action.entityId !== undefined ? String(record[action.entityId] ?? "") : undefined;
323
+ const fallback = sameEntityScreenId?.(targetScreen) ?? defaultScreenTargetEntityId;
324
+ const navEntityId = explicit ?? fallback;
325
+ return {
326
+ ...navigateActionBase(action, translate, actionIcon),
327
+ onPress: () => {
328
+ const target = {
329
+ screenId: targetScreen,
330
+ ...(navEntityId !== undefined && navEntityId !== "" && { entityId: navEntityId }),
331
+ };
332
+ const params = navigateActionSearchParams(action, record);
333
+ navigateWithReturnTo(nav, target, host, params);
334
+ },
335
+ };
336
+ }
337
+ function buildNavigateRecordAction(action, options) {
338
+ if (action.entity !== undefined)
339
+ return buildNavigateEntityAction(action, action.entity, options);
340
+ if (action.screen !== undefined)
341
+ return buildNavigateScreenAction(action, action.screen, options);
334
342
  return undefined;
335
343
  }
336
344
  function buildDrawerRecordAction(action, options) {
@@ -339,6 +347,7 @@ function buildDrawerRecordAction(action, options) {
339
347
  id: action.id,
340
348
  label: translate(action.label),
341
349
  ...(action.style !== undefined && { style: action.style }),
350
+ ...(action.display !== undefined && { display: action.display }),
342
351
  confirmRequired: false,
343
352
  ...(actionIcon !== undefined && { icon: actionIcon }),
344
353
  onPress: () => {
@@ -353,6 +362,7 @@ function buildWriteHandlerRecordAction(action, options) {
353
362
  id: action.id,
354
363
  label: translate(action.label),
355
364
  ...(action.style !== undefined && { style: action.style }),
365
+ ...(action.display !== undefined && { display: action.display }),
356
366
  ...(actionIcon !== undefined && { icon: actionIcon }),
357
367
  ...(action.confirm !== undefined && { confirm: translate(action.confirm) }),
358
368
  ...(action.confirmLabel !== undefined && {
@@ -113,7 +113,7 @@ export function SecretMintBody({ schema, screen, translate }) {
113
113
  finishMint();
114
114
  }, [finishMint]);
115
115
  if (done) {
116
- return (_jsx(Card, { children: _jsx(Banner, { variant: "info", testId: "kumiko-screen-secret-mint-done", children: effectiveTranslate(confirm?.doneMessage ?? "kumiko.secretMint.done") }) }));
116
+ 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
117
  }
118
118
  if (revealed !== null) {
119
119
  const values = screen.reveal.fields.flatMap((revealField) => {
@@ -134,7 +134,7 @@ export function SecretMintBody({ schema, screen, translate }) {
134
134
  },
135
135
  ];
136
136
  });
137
- return (_jsxs(Card, { testId: "kumiko-screen-secret-mint-card", 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))) })), confirm !== undefined && confirmEntity !== undefined && confirmScreen !== undefined ? (_jsx(RenderEdit, { screen: confirmScreen, entity: confirmEntity, featureName: schema.featureName, initial: confirmInitial ?? {}, writeCommand: confirm.handler, payloadMode: "values", buildPayload: (snapshot) => ({ ...snapshot.values, ...carriedRef.current }), onSubmit: handleConfirmSubmitted, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(translate !== undefined && { translate }), ...(confirm.submitLabel !== undefined && { submitLabel: confirm.submitLabel }) })) : (_jsx(Button, { type: "button", variant: "primary", onClick: finishMint, testId: "kumiko-screen-secret-mint-confirm", children: effectiveTranslate(screen.reveal.confirmLabel ?? "kumiko.secretMint.confirm") }))] }));
137
+ return (_jsxs(Card, { options: { screenBody: true }, testId: "kumiko-screen-secret-mint-card", 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))) })), confirm !== undefined && confirmEntity !== undefined && confirmScreen !== undefined ? (_jsx(RenderEdit, { screen: confirmScreen, entity: confirmEntity, featureName: schema.featureName, initial: confirmInitial ?? {}, writeCommand: confirm.handler, payloadMode: "values", buildPayload: (snapshot) => ({ ...snapshot.values, ...carriedRef.current }), onSubmit: handleConfirmSubmitted, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(translate !== undefined && { translate }), ...(confirm.submitLabel !== undefined && { submitLabel: confirm.submitLabel }) })) : (_jsx(Button, { type: "button", variant: "primary", onClick: finishMint, testId: "kumiko-screen-secret-mint-confirm", children: effectiveTranslate(screen.reveal.confirmLabel ?? "kumiko.secretMint.confirm") }))] }));
138
138
  }
139
- return (_jsx(RenderEdit, { screen: synthScreen, entity: synthEntity, featureName: schema.featureName, initial: initial, extensionInitialValues: initial, writeCommand: screen.handler, payloadMode: "values", onSubmit: handleSubmitted, ...(handleCancel !== undefined && { onCancel: handleCancel }), ...(translate !== undefined && { translate }), ...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel }) }));
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 }) }));
140
140
  }
@@ -3,13 +3,23 @@ import { useCallback, useMemo, useState } from "react";
3
3
  import { useDispatcher } from "../context/dispatcher-context.js";
4
4
  import { useQuery } from "../hooks/use-query.js";
5
5
  import { useTranslation } from "../i18n.js";
6
- import { usePrimitives } from "../primitives.js";
6
+ import { STICKY_PRIMARY_ACTION_PROP, usePrimitives } from "../primitives.js";
7
+ import { QueryErrorBanner, QueryLoadingBanner } from "./query-state-banners.js";
7
8
  import { dispatcherErrorText } from "./write-failed-error.js";
9
+ function groupSectionsByTitle(sections) {
10
+ const byTitle = new Map();
11
+ for (const section of sections) {
12
+ const title = section.title ?? "config.secrets.section";
13
+ // kumiko-lint-ignore section-fields-raw secretsEdit section fields are field-id strings, not EditFieldSpec
14
+ byTitle.set(title, [...(byTitle.get(title) ?? []), ...section.fields]);
15
+ }
16
+ return [...byTitle].map(([title, fields]) => ({ title, fields }));
17
+ }
8
18
  // secrets:query:list never returns plaintext (only a redacted preview), so
9
19
  // unlike ConfigEditBody there is no server value to pre-fill a draft with —
10
20
  // every input starts at "" and stays that way unless the user types into it.
11
21
  export function SecretsEditBody({ screen, translate }) {
12
- const { Banner, Form, Section, Field, Input, Button, Text } = usePrimitives();
22
+ const { Banner, Form, Section, Field, Input, Button, Text, Grid } = usePrimitives();
13
23
  const t = useTranslation();
14
24
  const effectiveTranslate = translate ?? t;
15
25
  const dispatcher = useDispatcher();
@@ -52,25 +62,44 @@ export function SecretsEditBody({ screen, translate }) {
52
62
  }
53
63
  await listQuery.refetch();
54
64
  }, [dispatcher, listQuery.refetch, effectiveTranslate]);
55
- if (listQuery.loading && listQuery.data === null) {
56
- return (_jsx(Banner, { padded: true, variant: "loading", testId: "kumiko-screen-loading", children: "Loading\u2026" }));
57
- }
65
+ if (listQuery.loading && listQuery.data === null)
66
+ return _jsx(QueryLoadingBanner, {});
58
67
  if (listQuery.error) {
59
- return (_jsx(Banner, { padded: true, variant: "error", testId: "kumiko-screen-error", children: dispatcherErrorText(listQuery.error, effectiveTranslate) }));
68
+ return (_jsx(QueryErrorBanner, { error: listQuery.error, translate: effectiveTranslate, onRetry: listQuery.refetch }));
60
69
  }
70
+ // The generator emits one section per feature; features without a declared
71
+ // title all fall under the generic "Secrets" heading and share one band.
72
+ const sectionGroups = groupSectionsByTitle(screen.sections);
73
+ const unsavedCount = Object.values(drafts).filter((value) => value.trim() !== "").length;
61
74
  return (_jsxs(Form, { onSubmit: () => {
62
75
  void handleSubmit();
63
- }, testId: "secrets-edit-form", ...(screen.description !== undefined && {
76
+ }, testId: "secrets-edit-form", width: "full", fillHeight: true, stickyActions: true, screenForm: true, unsavedCount: unsavedCount, ...(screen.description !== undefined && {
64
77
  subtitle: effectiveTranslate(screen.description),
65
- }), actions: _jsx(Button, { type: "submit", variant: "primary", loading: submitting, disabled: submitting, testId: "secrets-edit-submit", children: effectiveTranslate("kumiko.actions.save") }), children: [submitError !== null && (_jsx(Banner, { variant: "error", testId: "secrets-edit-error", children: submitError })), screen.sections.map((section, index) => (_jsx(Section, { ...(section.title !== undefined && { title: effectiveTranslate(section.title) }), children: section.fields.map((fieldId) => {
66
- const qualified = screen.secretKeys[fieldId];
67
- if (qualified === undefined)
68
- return null;
69
- const row = rowsByQualifiedKey.get(qualified);
70
- const hintKey = screen.fieldHints?.[fieldId];
71
- const isRequired = screen.requiredFields?.includes(fieldId) ?? false;
72
- return (_jsxs(Field, { id: fieldId, label: effectiveTranslate(screen.fieldLabels[fieldId] ?? fieldId), required: isRequired, testId: `field-${fieldId}`, fieldAppendix: _jsxs(_Fragment, { children: [hintKey !== undefined && (_jsx(Text, { variant: "small", children: effectiveTranslate(hintKey) })), row !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Text, { variant: "small", testId: `secret-preview-${fieldId}`, children: row.redactedPreview ?? effectiveTranslate("config.secrets.set") }), _jsx(Button, { type: "button", variant: "danger-ghost", size: "sm", onClick: () => handleDelete(qualified), testId: `secret-delete-${fieldId}`, children: effectiveTranslate("config.secrets.delete") })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { variant: "small", testId: `secret-not-set-${fieldId}`, children: effectiveTranslate("config.secrets.notSet") }), isRequired && (_jsx(Text, { variant: "small", testId: `required-marker-${fieldId}`, children: effectiveTranslate("config.secrets.required") }))] }))] }), children: [_jsx(Text, { variant: "small", children: effectiveTranslate(row !== undefined
78
+ }), 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) => {
79
+ const qualified = screen.secretKeys[fieldId];
80
+ if (qualified === undefined)
81
+ return null;
82
+ const row = rowsByQualifiedKey.get(qualified);
83
+ const hintKey = screen.fieldHints?.[fieldId];
84
+ const isRequired = screen.requiredFields?.includes(fieldId) ?? false;
85
+ return (_jsx(Field, { id: fieldId, layout: "row", label: effectiveTranslate(screen.fieldLabels[fieldId] ?? fieldId), required: isRequired, testId: `field-${fieldId}`, status: row !== undefined
86
+ ? {
87
+ tone: "ok",
88
+ label: effectiveTranslate("config.secrets.saved"),
89
+ testId: `secret-saved-${fieldId}`,
90
+ }
91
+ : {
92
+ tone: isRequired ? "bad" : "muted",
93
+ label: effectiveTranslate("config.secrets.notSet"),
94
+ testId: `secret-not-set-${fieldId}`,
95
+ }, ...(hintKey !== undefined && { description: effectiveTranslate(hintKey) }), ...(row !== undefined && {
96
+ fieldAppendix: (_jsxs(Grid, { columns: "auto", children: [_jsx(Text, { variant: "muted", testId: `secret-preview-${fieldId}`, children: row.redactedPreview !== null
97
+ ? effectiveTranslate("config.secrets.stored", {
98
+ preview: row.redactedPreview,
99
+ })
100
+ : effectiveTranslate("config.secrets.set") }), _jsx(Button, { type: "button", variant: "danger-ghost", size: "sm", onClick: () => handleDelete(qualified), testId: `secret-delete-${fieldId}`, children: effectiveTranslate("config.secrets.delete") })] })),
101
+ }), children: _jsx(Input, { kind: "password", id: fieldId, name: fieldId, value: drafts[fieldId] ?? "", onChange: (v) => setDraft(fieldId, v), placeholder: effectiveTranslate(row !== undefined
73
102
  ? "config.secrets.replacePlaceholder"
74
- : "config.secrets.placeholder") }), _jsx(Input, { kind: "password", id: fieldId, name: fieldId, value: drafts[fieldId] ?? "", onChange: (v) => setDraft(fieldId, v), autoComplete: "new-password", disabled: submitting, testId: `secret-input-${fieldId}` })] }, fieldId));
75
- }) }, section.title ?? `section-${index}`)))] }));
103
+ : "config.secrets.placeholder"), autoComplete: "new-password", disabled: submitting, testId: `secret-input-${fieldId}` }) }, fieldId));
104
+ }) }) }, section.title)))] }));
76
105
  }
@@ -1,6 +1,6 @@
1
1
  import type { EditFieldViewModel, FieldIssue } from "@cosmicdrift/kumiko-headless";
2
2
  import type { ReactNode } from "react";
3
- import type { FieldCellWidth, usePrimitives } from "../primitives.js";
3
+ import type { FieldCellWidth, FieldProps, usePrimitives } from "../primitives.js";
4
4
  export type GridCellForFieldProps = {
5
5
  readonly field: EditFieldViewModel;
6
6
  readonly columns: number;
@@ -12,6 +12,9 @@ export type GridCellForFieldProps = {
12
12
  readonly featureName: string;
13
13
  readonly labelAppendix?: ReactNode;
14
14
  readonly fieldAppendix?: ReactNode;
15
+ readonly appendixPlacement?: FieldProps["appendixPlacement"];
16
+ readonly description?: ReactNode;
17
+ readonly status?: FieldProps["status"];
15
18
  /** Full issues-by-path map (FormSnapshot.errors) — passed through for
16
19
  * embedded-list fields, which bucket row-/cell-level issues themselves. */
17
20
  readonly allIssues: Readonly<Record<string, readonly FieldIssue[]>>;
@@ -21,6 +24,8 @@ export type GridCellForFieldProps = {
21
24
  readonly row: Readonly<Record<string, unknown>>;
22
25
  readonly changed?: boolean;
23
26
  readonly flow?: boolean;
27
+ readonly layout?: FieldProps["layout"];
28
+ readonly accent?: boolean;
24
29
  };
25
30
  export declare function fieldCellWidth(field: EditFieldViewModel): FieldCellWidth;
26
- export declare function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, allIssues, valueDisplay, row, changed, flow, }: GridCellForFieldProps): ReactNode;
31
+ export declare function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, }: GridCellForFieldProps): ReactNode;
@@ -29,10 +29,10 @@ export function fieldCellWidth(field) {
29
29
  return "full";
30
30
  return FIELD_CELL_WIDTH_BY_TYPE[field.type] ?? "text";
31
31
  }
32
- export function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, allIssues, valueDisplay, row, changed, flow, }) {
32
+ export function GridCellForField({ field, columns, issues, onChange, GridCell, featureName, labelAppendix, fieldAppendix, appendixPlacement, description, status, allIssues, valueDisplay, row, changed, flow, layout, accent, }) {
33
33
  // RenderField renders nothing for a hidden field, but the GridCell around it still claims the row.
34
34
  if (!field.visible)
35
35
  return null;
36
36
  const effectiveSpan = field.span !== undefined ? Math.min(field.span, columns) : 1;
37
- return (_jsx(GridCell, { span: effectiveSpan, ...(flow === true && { width: fieldCellWidth(field) }), children: _jsx(RenderField, { field: field, ...(issues !== undefined && { issues }), onChange: onChange, featureName: featureName, ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), allIssues: allIssues, valueDisplay: valueDisplay, row: row, ...(changed === true && { changed }) }) }));
37
+ return (_jsx(GridCell, { span: effectiveSpan, ...(flow === true && { width: fieldCellWidth(field) }), children: _jsx(RenderField, { field: field, ...(issues !== undefined && { issues }), onChange: onChange, featureName: featureName, ...(labelAppendix !== undefined && { labelAppendix }), ...(fieldAppendix !== undefined && { fieldAppendix }), ...(appendixPlacement !== undefined && { appendixPlacement }), ...(description !== undefined && { description }), ...(status !== undefined && { status }), allIssues: allIssues, valueDisplay: valueDisplay, row: row, ...(changed === true && { changed }), ...(layout !== undefined && { layout }), ...(accent === true && { accent }) }) }));
38
38
  }
@@ -1,12 +1,21 @@
1
1
  import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
2
- import type { ReactNode } from "react";
2
+ import { type OptionsQueryPayload } from "@cosmicdrift/kumiko-types/fields";
3
+ import { type ReactNode } from "react";
4
+ type ResolvedOptionsQueryPayload = Readonly<Record<string, string | number | boolean>>;
5
+ export declare function resolveOptionsQueryPayload(payload: OptionsQueryPayload, row: Readonly<Record<string, unknown>>): ResolvedOptionsQueryPayload;
3
6
  type QueryOptionsSelectProps = {
4
7
  readonly field: EditFieldViewModel;
5
8
  readonly query: string;
6
- readonly payload: Readonly<Record<string, string | number | boolean>>;
9
+ readonly payload: OptionsQueryPayload;
10
+ readonly row: Readonly<Record<string, unknown>>;
7
11
  readonly id: string;
8
12
  readonly hasError: boolean;
9
13
  readonly onChange: (value: unknown) => void;
10
14
  };
11
- export declare function QueryOptionsSelect({ field, query, payload, id, hasError, onChange, }: QueryOptionsSelectProps): ReactNode;
15
+ export declare function QueryOptionsSelect({ field, query, payload, row, id, hasError, onChange, }: QueryOptionsSelectProps): ReactNode;
16
+ export declare function QueryOptionLabel({ query, payload, value, }: {
17
+ readonly query: string;
18
+ readonly payload: ResolvedOptionsQueryPayload;
19
+ readonly value: string | number | boolean;
20
+ }): ReactNode;
12
21
  export {};
@@ -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,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { normalizeListColumn, parseRefTarget } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import { evalFieldCondition, normalizeListColumn, parseRefTarget, } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import { useCallback, useMemo, useState } from "react";
4
4
  import { useAppFeatures } from "../app/app-features-context.js";
5
5
  import { buildFilterFacets, buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "../app/list-facets.js";
@@ -12,9 +12,9 @@ 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 { useTranslation } from "../i18n.js";
15
+ import { useLocale, useTranslation } from "../i18n.js";
16
16
  import { PageHeaderSlotAvailableProvider } from "../page-header-slot.js";
17
- import { usePrimitives } from "../primitives.js";
17
+ import { usePrimitives, } from "../primitives.js";
18
18
  import { sortByAccessor } from "../sort-by-accessor.js";
19
19
  import { RenderEditActionButton } from "./render-edit-action-button.js";
20
20
  import { RenderList } from "./render-list.js";
@@ -47,11 +47,48 @@ function warnRelatedListEmptyStateDrawerDropped(actionId) {
47
47
  // biome-ignore lint/suspicious/noConsole: dev-time warning, same pattern as row-actions.ts
48
48
  console.warn(`[kumiko] relatedList emptyState.action "${actionId}" is kind: "drawer" but no onOpenDrawer was supplied — dropped.`);
49
49
  }
50
+ function buildRowGrouping(groupBy, translate, locale) {
51
+ return {
52
+ keyOf: (row) => String(row.values[groupBy.field] ?? ""),
53
+ startsCollapsed: (key) => groupBy.collapsedWhen !== undefined && key === String(groupBy.collapsedWhen),
54
+ headerLabel: (key, rows) => {
55
+ const dates = groupBy.dateField === undefined
56
+ ? []
57
+ : rows
58
+ .map((row) => String(row.values[groupBy.dateField ?? ""] ?? ""))
59
+ .filter((value) => value !== "")
60
+ .sort();
61
+ const latest = dates[dates.length - 1];
62
+ return translate(groupBy.labels?.[key] ?? groupBy.label, {
63
+ count: rows.length,
64
+ value: key,
65
+ lastDate: latest === undefined
66
+ ? ""
67
+ : new Date(latest).toLocaleDateString(locale, {
68
+ year: "numeric",
69
+ month: "2-digit",
70
+ day: "2-digit",
71
+ timeZone: "UTC",
72
+ }),
73
+ });
74
+ },
75
+ };
76
+ }
77
+ function buildRowTone(rule) {
78
+ if (rule === undefined)
79
+ return undefined;
80
+ return (row) => (evalFieldCondition(rule, { ...row.values }) ? rule.tone : undefined);
81
+ }
50
82
  export function RelatedListSection({ section, parentId, record, featureName, translate, hideTitle, grow, onOpenDrawer, actions, embedded, onAfterWrite, }) {
51
83
  const { Banner, Section, Card, FillContainer, Text, Button, Dialog } = usePrimitives();
52
84
  const [emptyStateActionError, setEmptyStateActionError] = useState(null);
53
85
  const t = useTranslation();
54
86
  const effectiveTranslate = translate ?? t;
87
+ const locale = useLocale().locale();
88
+ const rowGrouping = useMemo(() => section.groupBy === undefined
89
+ ? undefined
90
+ : buildRowGrouping(section.groupBy, effectiveTranslate, locale), [section.groupBy, effectiveTranslate, locale]);
91
+ const rowTone = useMemo(() => buildRowTone(section.rowTone), [section.rowTone]);
55
92
  const nav = useNav();
56
93
  const host = useReturnHost();
57
94
  const dispatcher = useOptionalDispatcher();
@@ -269,11 +306,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
269
306
  filterValues: filters,
270
307
  onFilterChange,
271
308
  onFilterReset,
272
- }), ...(onRowClick !== undefined && { onRowClick }), ...(rowActions !== undefined && { rowActions }), ...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons }), ...(emptyStateContent !== undefined && { emptyState: emptyStateContent }), ...(hideTitle === true && {
273
- scrollBody: true,
274
- screenPadding: false,
275
- chromeless: true,
276
- }), ...(embedded === true && { screenPadding: false, chromeless: true }) }) })] }));
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 }), screenPadding: false, ...(hideTitle === true && { scrollBody: true, chromeless: true }), ...(embedded === true && { chromeless: true }) }) })] }));
277
310
  // hideTitle (tabs mode) → the caller (render-edit.tsx) already wraps this
278
311
  // component's own return value in the same Card frame every other tab
279
312
  // kind gets — no card here, or the two would nest. `scrollBody` caps the
@@ -1,4 +1,4 @@
1
- import type { EntityDefinition, EntityEditScreenDefinition, IconKey, RowAction, RowActionDrawer } from "@cosmicdrift/kumiko-framework/ui-types";
1
+ import type { ActionFormFooterAction, EntityDefinition, EntityEditScreenDefinition, IconKey, RowAction, RowActionDrawer } from "@cosmicdrift/kumiko-framework/ui-types";
2
2
  import type { DispatcherError, FormSnapshot, FormValues, SubmitResult, Translate } from "@cosmicdrift/kumiko-headless";
3
3
  import type { ReactNode } from "react";
4
4
  import type * as z from "zod";
@@ -73,6 +73,9 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
73
73
  readonly submitLabel?: string;
74
74
  /** Visual style of the submit button (actionForm `submitStyle`). Default "primary". */
75
75
  readonly submitVariant?: "primary" | "danger";
76
+ /** Extra footer buttons (actionForm `footerActions`): each sets its `patch`
77
+ * on the form values, then submits through the normal validate + write path. */
78
+ readonly footerActions?: readonly ActionFormFooterAction[];
76
79
  /** Context box above the drawer form (title + optional subtitle, already resolved). */
77
80
  readonly summary?: {
78
81
  readonly title: string;
@@ -85,6 +88,17 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
85
88
  /** Per-field extra content below the input (e.g. ConfigCascadeView).
86
89
  * Called with the field name, returns a ReactNode or undefined. */
87
90
  readonly fieldAppendix?: (fieldName: string) => ReactNode | undefined;
91
+ /** Placement of every `fieldAppendix` relative to its control. Default
92
+ * above-control; below-control keeps the inputs of neighbouring fields
93
+ * on one line when an appendix expands. */
94
+ readonly fieldAppendixPlacement?: "above-control" | "below-control";
95
+ /** Per-field help text rendered under the label (translated by the caller). */
96
+ readonly fieldDescription?: (fieldName: string) => ReactNode | undefined;
97
+ /** Marks fields whose value is set at this level; drawn by `settings-list` rows. */
98
+ readonly fieldAccent?: (fieldName: string) => boolean;
99
+ /** Edit-mode "changed" markers also for screens without an entity id
100
+ * (configEdit loads its values from a query, not a record). */
101
+ readonly markChangedFields?: boolean;
88
102
  /** Controlled mode (issue #1887): fires on every values-snapshot change
89
103
  * (typing, `patch(...)` from outside) with the current values. `changes`
90
104
  * is the delta against the initial values — same semantics as
@@ -161,6 +175,15 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
161
175
  * screens with `fillHeight !== false`; dialogs and other embedded hosts
162
176
  * omit it and keep document-flow height. */
163
177
  readonly fillScreenHeight?: boolean;
178
+ /** Show the unsaved-changes footer (count, Discard, "Save changes") even
179
+ * without an entity id or a screen-height form — for hosts that edit an
180
+ * existing server-side record through `customSubmit` (configEdit, also when
181
+ * embedded as a dashboard panel). */
182
+ readonly dirtyFooter?: boolean;
183
+ /** Validate the edited field right after each change instead of only on
184
+ * submit, so a bounds violation shows at the field while the Save button
185
+ * looks enabled. Set where Save never blocks on a pre-check (configEdit). */
186
+ readonly validateOnChange?: boolean;
164
187
  /** Extra content rendered above the card, sharing its left padding and
165
188
  * width — for a host with its own header region (title/metrics/tabs)
166
189
  * that would otherwise render as unpadded siblings before RenderEdit.