@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.
- package/package.json +5 -5
- package/src/app/__tests__/entity-edit-fill-height.test.tsx +118 -0
- package/src/app/__tests__/entity-list-screen-padding.test.tsx +64 -47
- package/src/app/action-form-shim.ts +2 -0
- package/src/app/kumiko-screen.tsx +274 -87
- package/src/app/projection-detail-shim.ts +1 -0
- package/src/app/projection-list-shim.ts +3 -0
- package/src/app/row-actions.ts +1 -0
- package/src/changes.json +52 -0
- package/src/components/__tests__/related-list-section.test.tsx +65 -1
- package/src/components/__tests__/render-list-toolbar-icon-only.test.tsx +2 -2
- package/src/components/__tests__/write-form-section.test.tsx +1 -1
- package/src/components/grid-cell-for-field.tsx +30 -2
- package/src/components/related-list-section.tsx +53 -29
- package/src/components/render-edit-action-button.tsx +4 -1
- package/src/components/render-edit-types.ts +10 -0
- package/src/components/render-edit.tsx +564 -393
- package/src/components/render-field.tsx +11 -2
- package/src/components/render-list.tsx +45 -13
- package/src/components/write-form-section.tsx +0 -1
- package/src/hooks/__tests__/use-query.test.tsx +149 -0
- package/src/hooks/use-list-url-state.ts +37 -1
- package/src/hooks/use-query.ts +57 -23
- package/src/i18n-defaults.ts +21 -1
- package/src/index.ts +7 -0
- package/src/inside-drawer.ts +12 -0
- package/src/page-header-slot.ts +12 -0
- package/src/primitives.tsx +137 -16
- package/src/screen-fills-height.ts +3 -0
|
@@ -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 {
|
|
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("
|
|
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("
|
|
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"]).
|
|
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:
|
|
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] = {
|
|
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
|
-
|
|
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
|
-
<
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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 ?
|
|
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.
|