@cosmicdrift/kumiko-renderer 0.327.0 → 0.328.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.
@@ -96,6 +96,7 @@ function testPrimitives(): CorePrimitives {
96
96
  function stubDispatcher(
97
97
  rows: readonly Record<string, unknown>[] = [{ id: "r1", name: "Alice" }],
98
98
  nextCursor: string | null = null,
99
+ envelope: { readonly total?: number; readonly omitCursor?: boolean } = {},
99
100
  ): {
100
101
  dispatcher: Dispatcher;
101
102
  writes: Array<{ type: string; payload: unknown }>;
@@ -110,7 +111,14 @@ function stubDispatcher(
110
111
  }) as Dispatcher["write"],
111
112
  query: (async () => {
112
113
  queryCalls += 1;
113
- return { isSuccess: true, data: { rows, nextCursor } };
114
+ return {
115
+ isSuccess: true,
116
+ data: {
117
+ rows,
118
+ ...(envelope.omitCursor !== true && { nextCursor }),
119
+ ...(envelope.total !== undefined && { total: envelope.total }),
120
+ },
121
+ };
114
122
  }) as Dispatcher["query"],
115
123
  batch: (async () => ({ isSuccess: true, results: [] })) as Dispatcher["batch"],
116
124
  statusStore: {
@@ -189,6 +197,42 @@ function renderRelatedList(
189
197
  );
190
198
  }
191
199
 
200
+ describe("RelatedListSection column value type", () => {
201
+ test("a column declaring valueType money reaches the table as a money column", async () => {
202
+ const { dispatcher } = stubDispatcher();
203
+ let capturedTypes: Record<string, string | undefined> = {};
204
+ const capturingDataTable: ComponentType<DataTableProps> = (props) => {
205
+ capturedTypes = Object.fromEntries(props.columns.map((c) => [c.field, c.type]));
206
+ return testDataTable(props);
207
+ };
208
+ render(
209
+ <LocaleProvider
210
+ resolver={createStaticLocaleResolver({ locale: "en-US" })}
211
+ fallbackBundles={[kumikoDefaultTranslations]}
212
+ >
213
+ <DispatcherProvider dispatcher={dispatcher}>
214
+ <PrimitivesProvider value={{ ...testPrimitives(), DataTable: capturingDataTable }}>
215
+ <NavProvider value={stubNav().nav}>
216
+ <RelatedListSection
217
+ section={{
218
+ ...historySection,
219
+ columns: [{ field: "name" }, { field: "betrag", valueType: "money" }],
220
+ }}
221
+ parentId="order-1"
222
+ record={{ id: "order-1" }}
223
+ featureName="orders"
224
+ />
225
+ </NavProvider>
226
+ </PrimitivesProvider>
227
+ </DispatcherProvider>
228
+ </LocaleProvider>,
229
+ );
230
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
231
+ expect(capturedTypes["betrag"]).toBe("money");
232
+ expect(capturedTypes["name"]).toBe("text");
233
+ });
234
+ });
235
+
192
236
  describe("RelatedListSection — tabs-mode card chrome (fw#2722)", () => {
193
237
  test("hideTitle (tabs mode) renders the list without a Section wrapper, drops the table frame (the tab card frames it), and marks scrollBody", async () => {
194
238
  const { dispatcher } = stubDispatcher();
@@ -1046,6 +1090,26 @@ describe("RelatedListSection — truncation banner (fw#2722 review)", () => {
1046
1090
  expect(rtlScreen.queryByTestId("related-list-truncated")).toBeNull();
1047
1091
  });
1048
1092
 
1093
+ test("a handler that omits nextCursor renders no truncation banner, even at exactly one row", async () => {
1094
+ const { dispatcher } = stubDispatcher([{ id: "r1", name: "Alice" }], null, {
1095
+ omitCursor: true,
1096
+ });
1097
+ renderRelatedList(dispatcher);
1098
+
1099
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1100
+ expect(rtlScreen.queryByTestId("related-list-truncated")).toBeNull();
1101
+ });
1102
+
1103
+ test("a cursor with a total the rows already cover renders no truncation banner", async () => {
1104
+ const { dispatcher } = stubDispatcher([{ id: "r1", name: "Alice" }], "cursor-abc", {
1105
+ total: 1,
1106
+ });
1107
+ renderRelatedList(dispatcher);
1108
+
1109
+ await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
1110
+ expect(rtlScreen.queryByTestId("related-list-truncated")).toBeNull();
1111
+ });
1112
+
1049
1113
  test("the banner shows regardless of an active sort — an unsorted truncated list is equally misleading", async () => {
1050
1114
  const { dispatcher } = stubDispatcher(
1051
1115
  [
@@ -128,7 +128,7 @@ describe("RenderList toolbar actions collapse to icon-only", () => {
128
128
  renderToolbar([toolbarAction("sync", "Sync", true), toolbarAction("export", "Export", true)]);
129
129
 
130
130
  const button = rtlScreen.getByTestId("render-list-toolbar-action-sync");
131
- expect(button.getAttribute("data-size")).toBe("md");
131
+ expect(button.getAttribute("data-size")).toBe("sm");
132
132
  expect(button.textContent).toBe("Sync");
133
133
  });
134
134
 
@@ -140,7 +140,7 @@ describe("RenderList toolbar actions collapse to icon-only", () => {
140
140
  ]);
141
141
 
142
142
  const button = rtlScreen.getByTestId("render-list-toolbar-action-sync");
143
- expect(button.getAttribute("data-size")).toBe("md");
143
+ expect(button.getAttribute("data-size")).toBe("sm");
144
144
  expect(button.textContent).toBe("Sync");
145
145
  });
146
146
  });
@@ -188,7 +188,7 @@ describe("WriteFormSection", () => {
188
188
 
189
189
  expect(actions.contains(button)).toBe(true);
190
190
  expect(body.contains(button)).toBe(false);
191
- expect(button.dataset["icon"]).toBe("check");
191
+ expect(button.dataset["icon"]).toBeUndefined();
192
192
  });
193
193
 
194
194
  test("submit dispatches through the section's configured write handler with the entered values", async () => {
@@ -1,6 +1,6 @@
1
1
  import type { EditFieldViewModel, FieldIssue } from "@cosmicdrift/kumiko-headless";
2
2
  import type { ReactNode } from "react";
3
- import type { usePrimitives } from "../primitives";
3
+ import type { FieldCellWidth, usePrimitives } from "../primitives";
4
4
  import { RenderField } from "./render-field";
5
5
 
6
6
  // Extracted out of render-edit.tsx so write-form-section.tsx can reuse it
@@ -23,8 +23,33 @@ export type GridCellForFieldProps = {
23
23
  readonly valueDisplay: "form" | "text";
24
24
  /** Passed through to RenderField as `row` — see RenderFieldProps.row. */
25
25
  readonly row: Readonly<Record<string, unknown>>;
26
+ readonly changed?: boolean;
27
+ readonly flow?: boolean;
26
28
  };
27
29
 
30
+ const FIELD_CELL_WIDTH_BY_TYPE: Readonly<Record<string, FieldCellWidth>> = {
31
+ number: "number",
32
+ decimal: "number",
33
+ bigInt: "number",
34
+ money: "money",
35
+ date: "date",
36
+ select: "select",
37
+ multiSelect: "select",
38
+ boolean: "auto",
39
+ longText: "full",
40
+ embedded: "full",
41
+ jsonb: "full",
42
+ file: "full",
43
+ files: "full",
44
+ image: "full",
45
+ images: "full",
46
+ };
47
+
48
+ function fieldCellWidth(field: EditFieldViewModel): FieldCellWidth {
49
+ if (field.span !== undefined || (field.type === "text" && field.multiline)) return "full";
50
+ return FIELD_CELL_WIDTH_BY_TYPE[field.type] ?? "text";
51
+ }
52
+
28
53
  export function GridCellForField({
29
54
  field,
30
55
  columns,
@@ -37,13 +62,15 @@ export function GridCellForField({
37
62
  allIssues,
38
63
  valueDisplay,
39
64
  row,
65
+ changed,
66
+ flow,
40
67
  }: GridCellForFieldProps): ReactNode {
41
68
  // RenderField renders nothing for a hidden field, but the GridCell around it still claims the row.
42
69
  if (!field.visible) return null;
43
70
 
44
71
  const effectiveSpan = field.span !== undefined ? Math.min(field.span, columns) : 1;
45
72
  return (
46
- <GridCell span={effectiveSpan}>
73
+ <GridCell span={effectiveSpan} {...(flow === true && { width: fieldCellWidth(field) })}>
47
74
  <RenderField
48
75
  field={field}
49
76
  {...(issues !== undefined && { issues })}
@@ -54,6 +81,7 @@ export function GridCellForField({
54
81
  allIssues={allIssues}
55
82
  valueDisplay={valueDisplay}
56
83
  row={row}
84
+ {...(changed === true && { changed })}
57
85
  />
58
86
  </GridCell>
59
87
  );
@@ -35,6 +35,7 @@ import { useUserRoles } from "../context/user-roles-context";
35
35
  import type { ListSort } from "../hooks/use-list-url-state";
36
36
  import { useQuery } from "../hooks/use-query";
37
37
  import { useTranslation } from "../i18n";
38
+ import { PageHeaderSlotAvailableProvider } from "../page-header-slot";
38
39
  import { type DataTableFacet, usePrimitives } from "../primitives";
39
40
  import { sortByAccessor } from "../sort-by-accessor";
40
41
  import { RenderEditActionButton } from "./render-edit-action-button";
@@ -58,10 +59,13 @@ type PagedRows = {
58
59
  function synthesizeRelatedListEntity(
59
60
  columns: EditRelatedListSectionViewModel["columns"],
60
61
  ): EntityDefinition {
61
- const fields: Record<string, { type: "text"; sortable: boolean }> = {};
62
+ const fields: Record<string, { type: string; sortable: boolean }> = {};
62
63
  for (const col of columns) {
63
64
  const normalized = normalizeListColumn(col);
64
- fields[normalized.field] = { type: "text", sortable: normalized.sortable === true };
65
+ fields[normalized.field] = {
66
+ type: normalized.valueType ?? "text",
67
+ sortable: normalized.sortable === true,
68
+ };
65
69
  }
66
70
  return { fields } as unknown as EntityDefinition;
67
71
  }
@@ -84,6 +88,7 @@ export function RelatedListSection({
84
88
  featureName,
85
89
  translate,
86
90
  hideTitle,
91
+ grow,
87
92
  onOpenDrawer,
88
93
  actions,
89
94
  }: {
@@ -96,6 +101,8 @@ export function RelatedListSection({
96
101
  readonly featureName: string;
97
102
  readonly translate?: Translate;
98
103
  readonly hideTitle?: boolean;
104
+ /** Under a fixed-height screen the tab panel chain stretches so the count footer sits at the bottom. */
105
+ readonly grow?: boolean;
99
106
  /** Opens a drawer-kind rowAction (fw#2710). Supplied by the parent
100
107
  * (ProjectionDetailBody), which owns schema + the actual Drawer render —
101
108
  * this component only ever invokes the callback. */
@@ -379,7 +386,12 @@ export function RelatedListSection({
379
386
  // are really just "the first N in the server's own order" (fw#2722 review).
380
387
  // `nextCursor` is exactly how the paged envelope marks that: non-null means
381
388
  // more rows exist server-side beyond what was fetched.
382
- const truncated = rowsQuery.data !== null && rowsQuery.data.nextCursor !== null;
389
+ // A handler that omits `nextCursor` (or reports a `total` the fetched rows
390
+ // already cover) has no further rows — only a real cursor marks truncation.
391
+ const truncated =
392
+ rowsQuery.data !== null &&
393
+ typeof rowsQuery.data.nextCursor === "string" &&
394
+ (rowsQuery.data.total === undefined || rowsQuery.data.total > rowsQuery.data.rows.length);
383
395
 
384
396
  const content =
385
397
  rowsQuery.loading && rowsQuery.data === null ? (
@@ -404,31 +416,39 @@ export function RelatedListSection({
404
416
  {emptyStateActionError}
405
417
  </Banner>
406
418
  )}
407
- <RenderList
408
- screen={listScreen}
409
- entity={entity}
410
- rows={sortedRows}
411
- featureName={featureName}
412
- translate={effectiveTranslate}
413
- sort={sort}
414
- onSortChange={setSort}
415
- {...(section.searchable === true && {
416
- searchable: true,
417
- searchValue: search,
418
- onSearchChange: setSearch,
419
- })}
420
- {...(filterFacets.length > 0 && {
421
- filterFacets,
422
- filterValues: filters,
423
- onFilterChange,
424
- onFilterReset,
425
- })}
426
- {...(onRowClick !== undefined && { onRowClick })}
427
- {...(rowActions !== undefined && { rowActions })}
428
- {...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons })}
429
- {...(emptyStateContent !== undefined && { emptyState: emptyStateContent })}
430
- {...(hideTitle === true && { scrollBody: true, screenPadding: false, chromeless: true })}
431
- />
419
+ <PageHeaderSlotAvailableProvider value={false}>
420
+ <RenderList
421
+ screen={listScreen}
422
+ entity={entity}
423
+ rows={sortedRows}
424
+ featureName={featureName}
425
+ translate={effectiveTranslate}
426
+ {...(section.description !== undefined && { description: section.description })}
427
+ {...(section.itemNoun !== undefined && { itemNounKey: section.itemNoun })}
428
+ sort={sort}
429
+ onSortChange={setSort}
430
+ {...(section.searchable === true && {
431
+ searchable: true,
432
+ searchValue: search,
433
+ onSearchChange: setSearch,
434
+ })}
435
+ {...(filterFacets.length > 0 && {
436
+ filterFacets,
437
+ filterValues: filters,
438
+ onFilterChange,
439
+ onFilterReset,
440
+ })}
441
+ {...(onRowClick !== undefined && { onRowClick })}
442
+ {...(rowActions !== undefined && { rowActions })}
443
+ {...(toolbarActionButtons !== undefined && { toolbarActions: toolbarActionButtons })}
444
+ {...(emptyStateContent !== undefined && { emptyState: emptyStateContent })}
445
+ {...(hideTitle === true && {
446
+ scrollBody: true,
447
+ screenPadding: false,
448
+ chromeless: true,
449
+ })}
450
+ />
451
+ </PageHeaderSlotAvailableProvider>
432
452
  </>
433
453
  );
434
454
 
@@ -447,7 +467,11 @@ export function RelatedListSection({
447
467
  return (
448
468
  <>
449
469
  {bridges}
450
- {FillContainer !== undefined ? <FillContainer>{content}</FillContainer> : content}
470
+ {FillContainer !== undefined ? (
471
+ <FillContainer {...(grow === true && { grow: true })}>{content}</FillContainer>
472
+ ) : (
473
+ content
474
+ )}
451
475
  </>
452
476
  );
453
477
  }
@@ -45,6 +45,7 @@ export function RenderEditActionConfirmDialog({
45
45
  export function RenderEditActionButton({
46
46
  action,
47
47
  iconOnly = false,
48
+ hideIcon = false,
48
49
  Button,
49
50
  Dialog,
50
51
  onError,
@@ -53,6 +54,8 @@ export function RenderEditActionButton({
53
54
  /** Group-level collapse (see `shouldRenderActionsIconOnly`) — only takes
54
55
  * effect when this action actually resolved an icon. */
55
56
  readonly iconOnly?: boolean;
57
+ /** Record-header actions are text-only; the icon still shows when collapsed to icon-only. */
58
+ readonly hideIcon?: boolean;
56
59
  readonly Button: ReturnType<typeof usePrimitives>["Button"];
57
60
  readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
58
61
  readonly onError: (text: string | null) => void;
@@ -81,7 +84,7 @@ export function RenderEditActionButton({
81
84
  type="button"
82
85
  variant={variant}
83
86
  loading={busy}
84
- {...(action.icon !== undefined && { icon: action.icon })}
87
+ {...(action.icon !== undefined && (!hideIcon || showIconOnly) && { icon: action.icon })}
85
88
  {...(showIconOnly && { size: "icon" as const, ariaLabel: action.label })}
86
89
  onClick={() => {
87
90
  if (needsActionConfirm(action)) {
@@ -53,6 +53,9 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
53
53
  readonly onDelete?: () => Promise<void> | void;
54
54
  readonly onCancel?: () => void;
55
55
  readonly onReload?: () => void;
56
+ /** Fires when the form gains or loses unsaved input (field changes or a
57
+ * dirty extension section). Lets a host such as a drawer guard closing. */
58
+ readonly onDirtyChange?: (dirty: boolean) => void;
56
59
  /** Copy-link action (issue #912) — only set in update mode (create mode
57
60
  * has no entity id yet, hence no permalink). The callback is already fully
58
61
  * bound (URL building + clipboard happen outside, in
@@ -83,6 +86,8 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
83
86
  readonly submitLabel?: string;
84
87
  /** Visual style of the submit button (actionForm `submitStyle`). Default "primary". */
85
88
  readonly submitVariant?: "primary" | "danger";
89
+ /** Context box above the drawer form (title + optional subtitle, already resolved). */
90
+ readonly summary?: { readonly title: string; readonly subtitle?: string };
86
91
  /** Per-field extra content inline after the label (e.g.
87
92
  * ConfigSourceBadge). Called with the field name, returns a ReactNode or
88
93
  * undefined. */
@@ -161,6 +166,11 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
161
166
  * redundant title above its content. Omitting this prop keeps unchanged
162
167
  * behavior. */
163
168
  readonly hideSectionTitles?: boolean;
169
+ /** Screen body fills the shell height: the form gets `fillHeight` and
170
+ * `stickyActions` (sections scroll, footer pinned). Set by KumikoScreen for
171
+ * screens with `fillHeight !== false`; dialogs and other embedded hosts
172
+ * omit it and keep document-flow height. */
173
+ readonly fillScreenHeight?: boolean;
164
174
  /** Extra content rendered above the card, sharing its left padding and
165
175
  * width — for a host with its own header region (title/metrics/tabs)
166
176
  * that would otherwise render as unpadded siblings before RenderEdit.