@cosmicdrift/kumiko-renderer 0.326.1 → 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 +6 -0
- package/src/changes.json +52 -0
- package/src/components/__tests__/related-list-section.test.tsx +71 -2
- 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 +27 -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
package/src/changes.json
CHANGED
|
@@ -1,4 +1,56 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.328.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Screens fill the shell height by default",
|
|
6
|
+
"detail": "entityList, projectionList, projectionDetail, entityEdit, actionForm and wizard screens now fill the height of the shell content. The table or the form body scrolls inside, the toolbar stays on top and the pager or action bar stays pinned at the bottom (`scrollBody`, `stickyActions`). The shell header is 56px. Custom screens and direct DataTable or Form usage are unchanged.",
|
|
7
|
+
"migration": "Set `fillHeight: false` on a screen to restore the old page scrolling (table grows with its rows, pager below the last row, form footer in the flow). Apps with e2e or screenshot tests that scroll the page to reach the pager or the form footer need to adjust them or opt out per screen."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.328.0",
|
|
11
|
+
"type": "breaking",
|
|
12
|
+
"title": "Pager shows a status line, page size select and Page X of Y instead of page numbers",
|
|
13
|
+
"detail": "The numbered page list is gone. The list footer shows \"1-19 of 19 <entities>\" (with the entity plural label when known), \"Page X of Y\", previous and next buttons and a page size select (25, 50, 100). Lists without a pager show a plain entry count. The chosen size is kept in the URL under `<screen>.size`.",
|
|
14
|
+
"migration": "Remove code and tests that click numbered page buttons (`-page-N` test ids); use the next and previous buttons. The screen's declared `pageSize` stays the default, `<screen>.size` in the URL overrides it. The new keys `kumiko.pager.pageOf`, `kumiko.pager.status.noun`, `kumiko.pager.pageSize`, `kumiko.pager.pageSizeLabel`, `kumiko.list.count.*` exist in en, de and es; apps with their own locale need them too."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.328.0",
|
|
18
|
+
"type": "breaking",
|
|
19
|
+
"title": "rowActionMode adaptive has new semantics and mobile rows lose the kebab for plain navigation",
|
|
20
|
+
"detail": "With `rowActionMode: \"adaptive\"` (the default), a table with `onRowClick` renders the first cell as a keyboard-operable link and puts all row actions into the kebab. Without `onRowClick` the primary row action is a link-button and the remaining actions go into the kebab (a single action gets no kebab). Below the md breakpoint a row that only navigates shows a chevron and no kebab.",
|
|
21
|
+
"migration": "Code or tests that expect up to two inline row action buttons must open the kebab or use `rowActionMode: \"inline\"`, which is unchanged. To keep a visible action next to the row link, drop `onRowClick` and declare the action as the primary row action."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"version": "0.328.0",
|
|
25
|
+
"type": "breaking",
|
|
26
|
+
"title": "Form, wizard and detail screens use the board layout",
|
|
27
|
+
"detail": "Screen forms have no card, sections are separated by lines, fields flow in rows with widths by type (text 240px, number 96px, money 160px, date and select 200px, textarea full row) and the page title lives in the shell header. Delete and Copy link move into the header kebab, the footer reads status, Cancel, Save, and the wizard footer has no Cancel. Wizard steps sit in a vertical rail from the lg breakpoint. The first detail header action is a button, further ones sit in the kebab. Detail screens have no header card and use the full width: metrics render as a dl band, tabs are underlined only when active, related lists are full-bleed with a count footer. Required-field asterisks are omitted when every field is required and \"All fields are required.\" is shown instead.",
|
|
28
|
+
"migration": "Adjust screenshots and DOM tests that pin the old card layout. `fillHeight: false` restores page scrolling only; the footer order, the moved actions and the new field layout apply in both modes. Empty list cells render a dash."
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"version": "0.328.0",
|
|
32
|
+
"type": "improvement",
|
|
33
|
+
"title": "PageHeader slot, MetricBand, wizard Save and close, drawer row actions with unsaved-input guard",
|
|
34
|
+
"detail": "Core primitives `PageHeader` (status and actions portalled into the shell header when a shell provides the slot, otherwise the caller keeps its placement) and `MetricBand` (borderless dl of metrics) are new and optional. Wizards on an existing record show \"Save and close\". Row actions and related list row actions with `kind: \"drawer\"` open the action form in a flush drawer. Closing it with unsaved input (X, Escape, overlay, Cancel) asks for confirmation first; nothing is asked after a successful submit. Row action drawers are prefilled from the row through `params`. The action form `summary` renders a context box above the fields."
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"version": "0.328.0",
|
|
38
|
+
"type": "fix",
|
|
39
|
+
"title": "Related list no longer claims \"showing the first N entries\" for a complete list",
|
|
40
|
+
"detail": "The hint \"Zeigt die ersten 1 Einträge. Es gibt weitere, die hier nicht angezeigt werden.\" appeared for a single row even when nothing else existed. It now appears only when more rows exist than were loaded."
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"version": "0.328.0",
|
|
44
|
+
"type": "improvement",
|
|
45
|
+
"title": "writeForm section Save button without icon, parity with entityEdit",
|
|
46
|
+
"detail": "The Save button of a writeForm section no longer shows a check icon; it matches the entityEdit submit button. Tests that look for the icon inside the section Save button need updating."
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"version": "0.328.0",
|
|
50
|
+
"type": "improvement",
|
|
51
|
+
"title": "useQuery gains a refetchIntervalMs option for polling",
|
|
52
|
+
"detail": "With `refetchIntervalMs` set, an enabled query re-runs on a timer. Background ticks keep `loading` false, are skipped while a fetch is still in flight, and stop on unmount or when enabled, the interval, type or payload change. Only timers are used, so it works on web and React Native."
|
|
53
|
+
},
|
|
2
54
|
{
|
|
3
55
|
"version": "0.325.2",
|
|
4
56
|
"type": "fix",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, spyOn, test } from "bun:test";
|
|
1
|
+
import { beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import type { EntityEditScreenDefinition, RowAction } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
3
|
import type { Dispatcher, EditRelatedListSectionViewModel } from "@cosmicdrift/kumiko-headless";
|
|
4
4
|
import { act, fireEvent, render, screen as rtlScreen, waitFor } from "@testing-library/react";
|
|
@@ -6,6 +6,7 @@ import type { ComponentType, ReactNode } from "react";
|
|
|
6
6
|
import { AppFeaturesProvider } from "../../app/app-features-context";
|
|
7
7
|
import type { FeatureSchema } from "../../app/feature-schema";
|
|
8
8
|
import { type NavApi, NavProvider } from "../../app/nav";
|
|
9
|
+
import { resetDrawerActionWarningsForTests } from "../../app/row-actions";
|
|
9
10
|
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
10
11
|
import { UserRolesProvider } from "../../context/user-roles-context";
|
|
11
12
|
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
@@ -95,6 +96,7 @@ function testPrimitives(): CorePrimitives {
|
|
|
95
96
|
function stubDispatcher(
|
|
96
97
|
rows: readonly Record<string, unknown>[] = [{ id: "r1", name: "Alice" }],
|
|
97
98
|
nextCursor: string | null = null,
|
|
99
|
+
envelope: { readonly total?: number; readonly omitCursor?: boolean } = {},
|
|
98
100
|
): {
|
|
99
101
|
dispatcher: Dispatcher;
|
|
100
102
|
writes: Array<{ type: string; payload: unknown }>;
|
|
@@ -109,7 +111,14 @@ function stubDispatcher(
|
|
|
109
111
|
}) as Dispatcher["write"],
|
|
110
112
|
query: (async () => {
|
|
111
113
|
queryCalls += 1;
|
|
112
|
-
return {
|
|
114
|
+
return {
|
|
115
|
+
isSuccess: true,
|
|
116
|
+
data: {
|
|
117
|
+
rows,
|
|
118
|
+
...(envelope.omitCursor !== true && { nextCursor }),
|
|
119
|
+
...(envelope.total !== undefined && { total: envelope.total }),
|
|
120
|
+
},
|
|
121
|
+
};
|
|
113
122
|
}) as Dispatcher["query"],
|
|
114
123
|
batch: (async () => ({ isSuccess: true, results: [] })) as Dispatcher["batch"],
|
|
115
124
|
statusStore: {
|
|
@@ -188,6 +197,42 @@ function renderRelatedList(
|
|
|
188
197
|
);
|
|
189
198
|
}
|
|
190
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
|
+
|
|
191
236
|
describe("RelatedListSection — tabs-mode card chrome (fw#2722)", () => {
|
|
192
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 () => {
|
|
193
238
|
const { dispatcher } = stubDispatcher();
|
|
@@ -736,6 +781,10 @@ describe("RelatedListSection — rowActions", () => {
|
|
|
736
781
|
// pins that hand-off, not the Drawer UI itself (covered by kumiko-screen's
|
|
737
782
|
// own tests).
|
|
738
783
|
describe("RelatedListSection — rowActions drawer-kind (fw#2710)", () => {
|
|
784
|
+
beforeEach(() => {
|
|
785
|
+
resetDrawerActionWarningsForTests();
|
|
786
|
+
});
|
|
787
|
+
|
|
739
788
|
test("clicking a drawer rowAction calls onOpenDrawer with the action and the row's extracted values", async () => {
|
|
740
789
|
const { dispatcher } = stubDispatcher([{ id: "item-7", name: "Rent 2024", amount: 1200 }]);
|
|
741
790
|
const openDrawerCalls: unknown[][] = [];
|
|
@@ -1041,6 +1090,26 @@ describe("RelatedListSection — truncation banner (fw#2722 review)", () => {
|
|
|
1041
1090
|
expect(rtlScreen.queryByTestId("related-list-truncated")).toBeNull();
|
|
1042
1091
|
});
|
|
1043
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
|
+
|
|
1044
1113
|
test("the banner shows regardless of an active sort — an unsorted truncated list is equally misleading", async () => {
|
|
1045
1114
|
const { dispatcher } = stubDispatcher(
|
|
1046
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.
|