@cosmicdrift/kumiko-renderer 0.250.0 → 0.252.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 +4 -4
- package/src/app/__tests__/entity-list-screen-padding.test.tsx +111 -0
- package/src/app/kumiko-screen.tsx +8 -86
- package/src/app/list-facets.ts +90 -0
- package/src/components/__tests__/related-list-section.test.tsx +301 -1
- package/src/components/related-list-section.tsx +61 -6
- package/src/components/render-edit.tsx +5 -4
- package/src/components/render-list.tsx +7 -0
- package/src/primitives.tsx +12 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.252.0",
|
|
4
4
|
"description": "Platform-agnostic React renderer for Kumiko screens. Contains the shared logic — primitives-contract, hooks, KumikoScreen, navigation & SSE abstractions — that any platform-specific renderer (web, native) composes. No DOM, no EventSource, no react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
18
|
+
"@cosmicdrift/kumiko-framework": "0.252.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.252.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"@types/react-dom": "^19.2.3",
|
|
28
28
|
"jsdom": "^29.1.1",
|
|
29
29
|
"react-dom": "^19.2.6",
|
|
30
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
30
|
+
"@cosmicdrift/kumiko-locale-de": "0.252.0"
|
|
31
31
|
},
|
|
32
32
|
"repository": {
|
|
33
33
|
"type": "git",
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// A list screen's chrome is the DataTable's own outer wrapper — no
|
|
2
|
+
// FormScreenShell/PageSection sits around it. `screenPadding` is how that
|
|
3
|
+
// wrapper reaches the shared screen-padding token, so it has to be set on the
|
|
4
|
+
// real list path (KumikoScreen → EntityListScreen → EntityListBody →
|
|
5
|
+
// RenderList), not just be available on the primitive (fw#2640).
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import type {
|
|
8
|
+
EntityDefinition,
|
|
9
|
+
EntityListScreenDefinition,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
11
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
12
|
+
import { render, waitFor } from "@testing-library/react";
|
|
13
|
+
import type { ComponentType, ReactNode } from "react";
|
|
14
|
+
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
15
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
16
|
+
import { type CorePrimitives, type DataTableProps, PrimitivesProvider } from "../../primitives";
|
|
17
|
+
import type { FeatureSchema } from "../feature-schema";
|
|
18
|
+
import { KumikoScreen } from "../kumiko-screen";
|
|
19
|
+
import { NavProvider } from "../nav";
|
|
20
|
+
|
|
21
|
+
const noop = (): ReactNode => null;
|
|
22
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
23
|
+
|
|
24
|
+
function stubDispatcher(): Dispatcher {
|
|
25
|
+
return {
|
|
26
|
+
write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
|
|
27
|
+
query: (async () => ({
|
|
28
|
+
isSuccess: true,
|
|
29
|
+
data: { rows: [], total: 0 },
|
|
30
|
+
})) as unknown as Dispatcher["query"],
|
|
31
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
32
|
+
statusStore: {
|
|
33
|
+
getState: () => "online",
|
|
34
|
+
subscribe: () => () => {},
|
|
35
|
+
} as unknown as Dispatcher["statusStore"],
|
|
36
|
+
async *stream() {},
|
|
37
|
+
pendingWrites: () => [],
|
|
38
|
+
pendingFiles: () => [],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildSchema(): FeatureSchema {
|
|
43
|
+
const entity: EntityDefinition = {
|
|
44
|
+
fields: {
|
|
45
|
+
name: { type: "text", maxLength: 200, required: false, searchable: false, sortable: false },
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
const listScreen: EntityListScreenDefinition = {
|
|
49
|
+
id: "org-list",
|
|
50
|
+
type: "entityList",
|
|
51
|
+
entity: "org",
|
|
52
|
+
columns: ["name"],
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
featureName: "orgs",
|
|
56
|
+
entities: { org: entity },
|
|
57
|
+
screens: [listScreen],
|
|
58
|
+
} as FeatureSchema;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("entityList screen padding (fw#2640)", () => {
|
|
62
|
+
test("the list screen marks its table as the screen body", async () => {
|
|
63
|
+
let capturedScreenPadding: boolean | undefined;
|
|
64
|
+
const capturingDataTable: ComponentType<DataTableProps> = (props) => {
|
|
65
|
+
capturedScreenPadding = props.screenPadding;
|
|
66
|
+
return null;
|
|
67
|
+
};
|
|
68
|
+
const primitives: CorePrimitives = {
|
|
69
|
+
Button: noop,
|
|
70
|
+
Banner: passChildren,
|
|
71
|
+
Field: passChildren,
|
|
72
|
+
Input: noop,
|
|
73
|
+
DataTable: capturingDataTable,
|
|
74
|
+
Form: passChildren,
|
|
75
|
+
Section: passChildren,
|
|
76
|
+
Card: passChildren,
|
|
77
|
+
Grid: passChildren,
|
|
78
|
+
GridCell: passChildren,
|
|
79
|
+
Text: passChildren,
|
|
80
|
+
Heading: noop,
|
|
81
|
+
Dialog: noop,
|
|
82
|
+
Modal: noop,
|
|
83
|
+
Lightbox: noop,
|
|
84
|
+
ConfigSourceBadge: noop,
|
|
85
|
+
ConfigCascadeView: noop,
|
|
86
|
+
Link: noop,
|
|
87
|
+
};
|
|
88
|
+
render(
|
|
89
|
+
<LocaleProvider resolver={createStaticLocaleResolver({ locale: "de-DE" })}>
|
|
90
|
+
<DispatcherProvider dispatcher={stubDispatcher()}>
|
|
91
|
+
<NavProvider
|
|
92
|
+
value={{
|
|
93
|
+
route: { screenId: "orgs:org-list" },
|
|
94
|
+
navigate: () => {},
|
|
95
|
+
replace: () => {},
|
|
96
|
+
hrefFor: () => "",
|
|
97
|
+
searchParams: {},
|
|
98
|
+
setSearchParams: () => {},
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
<PrimitivesProvider value={primitives}>
|
|
102
|
+
<KumikoScreen schema={buildSchema()} qn="orgs:screen:org-list" />
|
|
103
|
+
</PrimitivesProvider>
|
|
104
|
+
</NavProvider>
|
|
105
|
+
</DispatcherProvider>
|
|
106
|
+
</LocaleProvider>,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
await waitFor(() => expect(capturedScreenPadding).toBe(true));
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -6,7 +6,6 @@ import type {
|
|
|
6
6
|
EntityDefinition,
|
|
7
7
|
EntityEditScreenDefinition,
|
|
8
8
|
EntityListScreenDefinition,
|
|
9
|
-
ListFacetSpec,
|
|
10
9
|
ProjectionDetailScreenDefinition,
|
|
11
10
|
ProjectionListScreenDefinition,
|
|
12
11
|
RowAction,
|
|
@@ -50,6 +49,12 @@ import { useDashboardBody } from "./dashboard-body";
|
|
|
50
49
|
import type { FeatureSchema } from "./feature-schema";
|
|
51
50
|
import { buildFormSchema } from "./form-schema";
|
|
52
51
|
import { layoutFieldNames } from "./layout-fields";
|
|
52
|
+
import {
|
|
53
|
+
buildFilterFacets,
|
|
54
|
+
buildFilterPayload,
|
|
55
|
+
type ResolvedFacetSpec,
|
|
56
|
+
resolveProjectionFacetSpecs,
|
|
57
|
+
} from "./list-facets";
|
|
53
58
|
import { useNav } from "./nav";
|
|
54
59
|
import {
|
|
55
60
|
synthesizeProjectionDetailEntity,
|
|
@@ -1066,58 +1071,6 @@ export function buildListQueryPayload(state: {
|
|
|
1066
1071
|
return payload;
|
|
1067
1072
|
}
|
|
1068
1073
|
|
|
1069
|
-
// One resolved facet, independent of where the type info came from — an
|
|
1070
|
-
// entity field (entityList) or an explicit ListFacetSpec (projectionList,
|
|
1071
|
-
// fw#2224). Shared by buildFilterFacets/buildFilterPayload below so both
|
|
1072
|
-
// screen types build their query-payload filters and DataTable facet-UI
|
|
1073
|
-
// through the same code, instead of two copies that can drift.
|
|
1074
|
-
type ResolvedFacetSpec = {
|
|
1075
|
-
readonly field: string;
|
|
1076
|
-
readonly type: "select" | "boolean";
|
|
1077
|
-
readonly label: string;
|
|
1078
|
-
readonly options: readonly { readonly value: string; readonly label: string }[];
|
|
1079
|
-
};
|
|
1080
|
-
|
|
1081
|
-
function buildFilterFacets(specs: readonly ResolvedFacetSpec[]): DataTableFacet[] {
|
|
1082
|
-
return specs.map((spec) => ({ field: spec.field, label: spec.label, options: spec.options }));
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
// User-selected faceted filters from URL-state → payload.filters. Boolean
|
|
1086
|
-
// fields coerce "true"/"false" strings to real booleans (DB column is
|
|
1087
|
-
// boolean); everything else stays string[] under op:"in" (multi-select
|
|
1088
|
-
// semantics). `typeOf` resolves a field to its known type string —
|
|
1089
|
-
// undefined means "unknown field", so it's dropped (typo-safe: a stale/
|
|
1090
|
-
// hand-crafted URL param for an undeclared field never reaches the
|
|
1091
|
-
// server). Deliberately NOT gated on the field being a *facet* — entityList
|
|
1092
|
-
// passes through any field present in entity.fields, matching its
|
|
1093
|
-
// pre-fw#2224 behavior; only the boolean-coercion branch cares about type.
|
|
1094
|
-
|
|
1095
|
-
function isFacetI18nKey(label: string): boolean {
|
|
1096
|
-
return !/\s/.test(label) && (label.includes(".") || label.includes(":"));
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
function buildFilterPayload(
|
|
1100
|
-
urlFilters: Readonly<Record<string, readonly string[]>>,
|
|
1101
|
-
typeOf: (field: string) => string | undefined,
|
|
1102
|
-
): { field: string; op: "in"; value: unknown }[] {
|
|
1103
|
-
const out: { field: string; op: "in"; value: unknown }[] = [];
|
|
1104
|
-
for (const [field, values] of Object.entries(urlFilters)) {
|
|
1105
|
-
if (values.length === 0) continue;
|
|
1106
|
-
// `id` is a base column (not a declared facet), allowed as an id-set
|
|
1107
|
-
// filter so a header-slot control — e.g. the tags TagFilter — can narrow
|
|
1108
|
-
// ANY list to a resolved set of row ids without the host declaring a facet.
|
|
1109
|
-
if (field === "id") {
|
|
1110
|
-
out.push({ field, op: "in", value: values });
|
|
1111
|
-
continue;
|
|
1112
|
-
}
|
|
1113
|
-
const type = typeOf(field);
|
|
1114
|
-
if (type === undefined) continue;
|
|
1115
|
-
const value = type === "boolean" ? values.map((v) => v === "true") : values;
|
|
1116
|
-
out.push({ field, op: "in", value });
|
|
1117
|
-
}
|
|
1118
|
-
return out;
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
1074
|
// entityList adapter — one DataTableFacet per filterable select/boolean
|
|
1122
1075
|
// entity field, labels via the standard field/option i18n convention.
|
|
1123
1076
|
function resolveEntityFacetSpecs(
|
|
@@ -1164,39 +1117,6 @@ function resolveEntityFacetSpecs(
|
|
|
1164
1117
|
return out;
|
|
1165
1118
|
}
|
|
1166
1119
|
|
|
1167
|
-
// projectionList adapter — a projectionList has no entity/i18n convention to
|
|
1168
|
-
// derive labels from, so ListFacetSpec carries every label explicitly
|
|
1169
|
-
// (fw#2224). Labels may be raw display strings or i18n keys; run them
|
|
1170
|
-
// through translate like entity facets (passthrough when the key is missing).
|
|
1171
|
-
function resolveProjectionFacetSpecs(
|
|
1172
|
-
facets: readonly ListFacetSpec[] | undefined,
|
|
1173
|
-
translate: Translate,
|
|
1174
|
-
): ResolvedFacetSpec[] {
|
|
1175
|
-
if (facets === undefined) return [];
|
|
1176
|
-
const tr = (label: string): string => (isFacetI18nKey(label) ? translate(label) : label);
|
|
1177
|
-
return facets.map((facet) =>
|
|
1178
|
-
facet.type === "select"
|
|
1179
|
-
? {
|
|
1180
|
-
field: facet.field,
|
|
1181
|
-
type: "select",
|
|
1182
|
-
label: tr(facet.label),
|
|
1183
|
-
options: facet.options.map((opt) => ({
|
|
1184
|
-
value: opt.value,
|
|
1185
|
-
label: tr(opt.label),
|
|
1186
|
-
})),
|
|
1187
|
-
}
|
|
1188
|
-
: {
|
|
1189
|
-
field: facet.field,
|
|
1190
|
-
type: "boolean",
|
|
1191
|
-
label: tr(facet.label),
|
|
1192
|
-
options: [
|
|
1193
|
-
{ value: "true", label: tr(facet.trueLabel) },
|
|
1194
|
-
{ value: "false", label: tr(facet.falseLabel) },
|
|
1195
|
-
],
|
|
1196
|
-
},
|
|
1197
|
-
);
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
1120
|
// ---- drawer-kind actions: ToolbarAction (fw#2225) + RowAction (fw#2710) ----
|
|
1201
1121
|
//
|
|
1202
1122
|
// Shared across EntityListBody, ProjectionListBody, ProjectionDetailBody and
|
|
@@ -1841,6 +1761,7 @@ function EntityListBody({
|
|
|
1841
1761
|
onSearchChange={urlState.setQ}
|
|
1842
1762
|
sort={effectiveSort}
|
|
1843
1763
|
onSortChange={urlState.setSort}
|
|
1764
|
+
screenPadding
|
|
1844
1765
|
{...(pager !== undefined && { pager })}
|
|
1845
1766
|
{...(rowActions !== undefined && { rowActions })}
|
|
1846
1767
|
{...(rowActionMode !== undefined && { rowActionMode })}
|
|
@@ -2131,6 +2052,7 @@ function ProjectionListBody({
|
|
|
2131
2052
|
onSearchChange={urlState.setQ}
|
|
2132
2053
|
sort={activeSort}
|
|
2133
2054
|
onSortChange={urlState.setSort}
|
|
2055
|
+
screenPadding
|
|
2134
2056
|
{...(pager !== undefined && { pager })}
|
|
2135
2057
|
{...(rowActions !== undefined && { rowActions })}
|
|
2136
2058
|
{...(rowActionMode !== undefined && { rowActionMode })}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Split from kumiko-screen.tsx: related-list-section.tsx renders through
|
|
2
|
+
// kumiko-screen.tsx, so importing this back from there would be a require cycle.
|
|
3
|
+
import type { ListFacetSpec } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
4
|
+
import type { Translate } from "@cosmicdrift/kumiko-headless";
|
|
5
|
+
import type { DataTableFacet } from "../primitives";
|
|
6
|
+
|
|
7
|
+
// One resolved facet, independent of where the type info came from — an
|
|
8
|
+
// entity field (entityList) or an explicit ListFacetSpec (projectionList,
|
|
9
|
+
// fw#2224). Shared by buildFilterFacets/buildFilterPayload below so both
|
|
10
|
+
// screen types build their query-payload filters and DataTable facet-UI
|
|
11
|
+
// through the same code, instead of two copies that can drift.
|
|
12
|
+
export type ResolvedFacetSpec = {
|
|
13
|
+
readonly field: string;
|
|
14
|
+
readonly type: "select" | "boolean";
|
|
15
|
+
readonly label: string;
|
|
16
|
+
readonly options: readonly { readonly value: string; readonly label: string }[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function buildFilterFacets(specs: readonly ResolvedFacetSpec[]): DataTableFacet[] {
|
|
20
|
+
return specs.map((spec) => ({ field: spec.field, label: spec.label, options: spec.options }));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// User-selected faceted filters from URL-state → payload.filters. Boolean
|
|
24
|
+
// fields coerce "true"/"false" strings to real booleans (DB column is
|
|
25
|
+
// boolean); everything else stays string[] under op:"in" (multi-select
|
|
26
|
+
// semantics). `typeOf` resolves a field to its known type string —
|
|
27
|
+
// undefined means "unknown field", so it's dropped (typo-safe: a stale/
|
|
28
|
+
// hand-crafted URL param for an undeclared field never reaches the
|
|
29
|
+
// server). Deliberately NOT gated on the field being a *facet* — entityList
|
|
30
|
+
// passes through any field present in entity.fields, matching its
|
|
31
|
+
// pre-fw#2224 behavior; only the boolean-coercion branch cares about type.
|
|
32
|
+
|
|
33
|
+
function isFacetI18nKey(label: string): boolean {
|
|
34
|
+
return !/\s/.test(label) && (label.includes(".") || label.includes(":"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildFilterPayload(
|
|
38
|
+
urlFilters: Readonly<Record<string, readonly string[]>>,
|
|
39
|
+
typeOf: (field: string) => string | undefined,
|
|
40
|
+
): { field: string; op: "in"; value: unknown }[] {
|
|
41
|
+
const out: { field: string; op: "in"; value: unknown }[] = [];
|
|
42
|
+
for (const [field, values] of Object.entries(urlFilters)) {
|
|
43
|
+
if (values.length === 0) continue;
|
|
44
|
+
// `id` is a base column (not a declared facet), allowed as an id-set
|
|
45
|
+
// filter so a header-slot control — e.g. the tags TagFilter — can narrow
|
|
46
|
+
// ANY list to a resolved set of row ids without the host declaring a facet.
|
|
47
|
+
if (field === "id") {
|
|
48
|
+
out.push({ field, op: "in", value: values });
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const type = typeOf(field);
|
|
52
|
+
if (type === undefined) continue;
|
|
53
|
+
const value = type === "boolean" ? values.map((v) => v === "true") : values;
|
|
54
|
+
out.push({ field, op: "in", value });
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// projectionList adapter — a projectionList has no entity/i18n convention to
|
|
60
|
+
// derive labels from, so ListFacetSpec carries every label explicitly
|
|
61
|
+
// (fw#2224). Labels may be raw display strings or i18n keys; run them
|
|
62
|
+
// through translate like entity facets (passthrough when the key is missing).
|
|
63
|
+
export function resolveProjectionFacetSpecs(
|
|
64
|
+
facets: readonly ListFacetSpec[] | undefined,
|
|
65
|
+
translate: Translate,
|
|
66
|
+
): ResolvedFacetSpec[] {
|
|
67
|
+
if (facets === undefined) return [];
|
|
68
|
+
const tr = (label: string): string => (isFacetI18nKey(label) ? translate(label) : label);
|
|
69
|
+
return facets.map((facet) =>
|
|
70
|
+
facet.type === "select"
|
|
71
|
+
? {
|
|
72
|
+
field: facet.field,
|
|
73
|
+
type: "select",
|
|
74
|
+
label: tr(facet.label),
|
|
75
|
+
options: facet.options.map((opt) => ({
|
|
76
|
+
value: opt.value,
|
|
77
|
+
label: tr(opt.label),
|
|
78
|
+
})),
|
|
79
|
+
}
|
|
80
|
+
: {
|
|
81
|
+
field: facet.field,
|
|
82
|
+
type: "boolean",
|
|
83
|
+
label: tr(facet.label),
|
|
84
|
+
options: [
|
|
85
|
+
{ value: "true", label: tr(facet.trueLabel) },
|
|
86
|
+
{ value: "false", label: tr(facet.falseLabel) },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -10,6 +10,7 @@ import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
|
10
10
|
import {
|
|
11
11
|
type CorePrimitives,
|
|
12
12
|
type DataTableProps,
|
|
13
|
+
type InputProps,
|
|
13
14
|
PrimitivesProvider,
|
|
14
15
|
type SectionProps,
|
|
15
16
|
} from "../../primitives";
|
|
@@ -54,6 +55,15 @@ const testDataTable: ComponentType<DataTableProps> = ({ rows, rowActions }) => (
|
|
|
54
55
|
</table>
|
|
55
56
|
);
|
|
56
57
|
|
|
58
|
+
const testInput = (props: InputProps) =>
|
|
59
|
+
props.kind === "text" ? (
|
|
60
|
+
<input
|
|
61
|
+
data-testid={props.id}
|
|
62
|
+
value={props.value}
|
|
63
|
+
onChange={(e) => props.onChange(e.target.value)}
|
|
64
|
+
/>
|
|
65
|
+
) : null;
|
|
66
|
+
|
|
57
67
|
function testPrimitives(): CorePrimitives {
|
|
58
68
|
return {
|
|
59
69
|
Button: noop,
|
|
@@ -61,7 +71,7 @@ function testPrimitives(): CorePrimitives {
|
|
|
61
71
|
<div data-testid={testId}>{children}</div>
|
|
62
72
|
),
|
|
63
73
|
Field: passChildren,
|
|
64
|
-
Input:
|
|
74
|
+
Input: testInput,
|
|
65
75
|
DataTable: testDataTable,
|
|
66
76
|
Form: noop,
|
|
67
77
|
Section: testSection,
|
|
@@ -638,3 +648,293 @@ describe("RelatedListSection — truncation banner (fw#2722 review)", () => {
|
|
|
638
648
|
expect(rtlScreen.getByTestId("related-list-truncated")).toBeTruthy();
|
|
639
649
|
});
|
|
640
650
|
});
|
|
651
|
+
|
|
652
|
+
// A DataTable stub that renders the toolbarStart slot (carries the search
|
|
653
|
+
// Input) and filterFacets as one button per option, wired straight to
|
|
654
|
+
// onFilterChange/onFilterReset — enough to prove RelatedListSection resolves
|
|
655
|
+
// facets and wires search through RenderList, without the production
|
|
656
|
+
// DataTable's own facet-dropdown chrome.
|
|
657
|
+
const searchFacetDataTable: ComponentType<DataTableProps> = ({
|
|
658
|
+
rows,
|
|
659
|
+
toolbarStart,
|
|
660
|
+
filterFacets,
|
|
661
|
+
onFilterChange,
|
|
662
|
+
onFilterReset,
|
|
663
|
+
}) => (
|
|
664
|
+
<div>
|
|
665
|
+
{toolbarStart}
|
|
666
|
+
{(filterFacets ?? []).map((facet) => (
|
|
667
|
+
<div key={facet.field}>
|
|
668
|
+
{facet.options.map((opt) => (
|
|
669
|
+
<button
|
|
670
|
+
key={opt.value}
|
|
671
|
+
type="button"
|
|
672
|
+
data-testid={`facet-${facet.field}-${opt.value}`}
|
|
673
|
+
onClick={() => onFilterChange?.(facet.field, [opt.value])}
|
|
674
|
+
>
|
|
675
|
+
{opt.label}
|
|
676
|
+
</button>
|
|
677
|
+
))}
|
|
678
|
+
</div>
|
|
679
|
+
))}
|
|
680
|
+
{onFilterReset !== undefined && (
|
|
681
|
+
<button type="button" data-testid="filter-reset" onClick={onFilterReset}>
|
|
682
|
+
Reset
|
|
683
|
+
</button>
|
|
684
|
+
)}
|
|
685
|
+
<table>
|
|
686
|
+
<tbody>
|
|
687
|
+
{rows.map((row) => (
|
|
688
|
+
<tr key={row.id} data-testid={`row-${row.id}`} />
|
|
689
|
+
))}
|
|
690
|
+
</tbody>
|
|
691
|
+
</table>
|
|
692
|
+
</div>
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
// A dispatcher stub that genuinely filters a fixed row set by the received
|
|
696
|
+
// payload — `search` as a case-insensitive substring on `name`, `filters`
|
|
697
|
+
// entries (`{ field, op: "in", value }`) as membership on that field —
|
|
698
|
+
// instead of returning canned rows, so a test can tell a real payload from a
|
|
699
|
+
// stale one. Every payload it sees is recorded for the "last payload"
|
|
700
|
+
// assertions below.
|
|
701
|
+
function filteringDispatcher(rows: readonly Record<string, unknown>[]): {
|
|
702
|
+
dispatcher: Dispatcher;
|
|
703
|
+
payloads: Record<string, unknown>[];
|
|
704
|
+
} {
|
|
705
|
+
const payloads: Record<string, unknown>[] = [];
|
|
706
|
+
const dispatcher: Dispatcher = {
|
|
707
|
+
write: (async () => ({ isSuccess: true, data: null })) as Dispatcher["write"],
|
|
708
|
+
query: (async (_type: string, payload: unknown) => {
|
|
709
|
+
const p = payload as {
|
|
710
|
+
search?: string;
|
|
711
|
+
filters?: readonly { field: string; op: "in"; value: readonly unknown[] }[];
|
|
712
|
+
};
|
|
713
|
+
payloads.push(p);
|
|
714
|
+
let result = rows;
|
|
715
|
+
if (p.search !== undefined && p.search !== "") {
|
|
716
|
+
const term = p.search.toLowerCase();
|
|
717
|
+
result = result.filter((r) =>
|
|
718
|
+
String(r["name"] ?? "")
|
|
719
|
+
.toLowerCase()
|
|
720
|
+
.includes(term),
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
for (const f of p.filters ?? []) {
|
|
724
|
+
result = result.filter((r) => f.value.includes(r[f.field]));
|
|
725
|
+
}
|
|
726
|
+
return { isSuccess: true, data: { rows: result, nextCursor: null } };
|
|
727
|
+
}) as Dispatcher["query"],
|
|
728
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as Dispatcher["batch"],
|
|
729
|
+
statusStore: {
|
|
730
|
+
getState: () => "online",
|
|
731
|
+
subscribe: () => () => {},
|
|
732
|
+
} as unknown as Dispatcher["statusStore"],
|
|
733
|
+
async *stream() {},
|
|
734
|
+
pendingWrites: () => [],
|
|
735
|
+
pendingFiles: () => [],
|
|
736
|
+
};
|
|
737
|
+
return { dispatcher, payloads };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function renderWithDataTable(
|
|
741
|
+
dispatcher: Dispatcher,
|
|
742
|
+
section: EditRelatedListSectionViewModel,
|
|
743
|
+
DataTable: ComponentType<DataTableProps>,
|
|
744
|
+
hideTitle?: boolean,
|
|
745
|
+
) {
|
|
746
|
+
return render(
|
|
747
|
+
<LocaleProvider
|
|
748
|
+
resolver={createStaticLocaleResolver({ locale: "en-US" })}
|
|
749
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
750
|
+
>
|
|
751
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
752
|
+
<PrimitivesProvider value={{ ...testPrimitives(), DataTable }}>
|
|
753
|
+
<NavProvider value={stubNav().nav}>
|
|
754
|
+
<RelatedListSection
|
|
755
|
+
section={section}
|
|
756
|
+
parentId="order-1"
|
|
757
|
+
featureName="orders"
|
|
758
|
+
{...(hideTitle === true && { hideTitle: true })}
|
|
759
|
+
/>
|
|
760
|
+
</NavProvider>
|
|
761
|
+
</PrimitivesProvider>
|
|
762
|
+
</DispatcherProvider>
|
|
763
|
+
</LocaleProvider>,
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
describe("RelatedListSection — search + facets (fw#2740)", () => {
|
|
768
|
+
const nameRows = [
|
|
769
|
+
{ id: "r1", name: "Alice" },
|
|
770
|
+
{ id: "r2", name: "Bob" },
|
|
771
|
+
];
|
|
772
|
+
|
|
773
|
+
test("searchable: true — typing (after the 300ms debounce) sends payload.search and narrows the rendered rows", async () => {
|
|
774
|
+
const { dispatcher, payloads } = filteringDispatcher(nameRows);
|
|
775
|
+
renderWithDataTable(
|
|
776
|
+
dispatcher,
|
|
777
|
+
{
|
|
778
|
+
kind: "relatedList",
|
|
779
|
+
title: "Contacts",
|
|
780
|
+
query: "lease:query:contacts:list",
|
|
781
|
+
columns: [{ field: "name" }],
|
|
782
|
+
searchable: true,
|
|
783
|
+
},
|
|
784
|
+
searchFacetDataTable,
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
788
|
+
expect(rtlScreen.getByTestId("row-r2")).toBeTruthy();
|
|
789
|
+
|
|
790
|
+
fireEvent.change(rtlScreen.getByTestId("render-list-search"), {
|
|
791
|
+
target: { value: "ali" },
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
await waitFor(
|
|
795
|
+
() => {
|
|
796
|
+
const last = payloads[payloads.length - 1];
|
|
797
|
+
expect(last?.["search"]).toBe("ali");
|
|
798
|
+
},
|
|
799
|
+
{ timeout: 2000 },
|
|
800
|
+
);
|
|
801
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
802
|
+
expect(rtlScreen.queryByTestId("row-r2")).toBeNull();
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test("without searchable, RenderList shows no search input and no payload ever carries a search key", async () => {
|
|
806
|
+
const { dispatcher, payloads } = filteringDispatcher(nameRows);
|
|
807
|
+
renderWithDataTable(
|
|
808
|
+
dispatcher,
|
|
809
|
+
{
|
|
810
|
+
kind: "relatedList",
|
|
811
|
+
title: "Contacts",
|
|
812
|
+
query: "lease:query:contacts:list",
|
|
813
|
+
columns: [{ field: "name" }],
|
|
814
|
+
},
|
|
815
|
+
searchFacetDataTable,
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
819
|
+
expect(rtlScreen.queryByTestId("render-list-search")).toBeNull();
|
|
820
|
+
for (const p of payloads) {
|
|
821
|
+
expect("search" in p).toBe(false);
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
test("tabs mode (hideTitle): the search box still renders and narrows the rows", async () => {
|
|
826
|
+
const { dispatcher, payloads } = filteringDispatcher(nameRows);
|
|
827
|
+
renderWithDataTable(
|
|
828
|
+
dispatcher,
|
|
829
|
+
{
|
|
830
|
+
kind: "relatedList",
|
|
831
|
+
title: "Contacts",
|
|
832
|
+
query: "lease:query:contacts:list",
|
|
833
|
+
columns: [{ field: "name" }],
|
|
834
|
+
searchable: true,
|
|
835
|
+
},
|
|
836
|
+
searchFacetDataTable,
|
|
837
|
+
true,
|
|
838
|
+
);
|
|
839
|
+
|
|
840
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
841
|
+
expect(rtlScreen.getByTestId("row-r2")).toBeTruthy();
|
|
842
|
+
expect(rtlScreen.getByTestId("render-list-search")).toBeTruthy();
|
|
843
|
+
|
|
844
|
+
fireEvent.change(rtlScreen.getByTestId("render-list-search"), {
|
|
845
|
+
target: { value: "ali" },
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
await waitFor(
|
|
849
|
+
() => {
|
|
850
|
+
const last = payloads[payloads.length - 1];
|
|
851
|
+
expect(last?.["search"]).toBe("ali");
|
|
852
|
+
},
|
|
853
|
+
{ timeout: 2000 },
|
|
854
|
+
);
|
|
855
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
856
|
+
expect(rtlScreen.queryByTestId("row-r2")).toBeNull();
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
const statusSection: EditRelatedListSectionViewModel = {
|
|
860
|
+
kind: "relatedList",
|
|
861
|
+
title: "Positions",
|
|
862
|
+
query: "lease:query:items:list",
|
|
863
|
+
columns: [{ field: "name" }, { field: "status" }],
|
|
864
|
+
facets: [
|
|
865
|
+
{
|
|
866
|
+
field: "status",
|
|
867
|
+
type: "select",
|
|
868
|
+
label: "Status",
|
|
869
|
+
options: [
|
|
870
|
+
{ value: "active", label: "Active" },
|
|
871
|
+
{ value: "ended", label: "Ended" },
|
|
872
|
+
],
|
|
873
|
+
},
|
|
874
|
+
],
|
|
875
|
+
};
|
|
876
|
+
const statusRows = [
|
|
877
|
+
{ id: "active-1", name: "Running", status: "active" },
|
|
878
|
+
{ id: "ended-1", name: "Closed", status: "ended" },
|
|
879
|
+
];
|
|
880
|
+
|
|
881
|
+
test("select facet: clicking an option filters payload.filters and rows, resetting clears both", async () => {
|
|
882
|
+
const { dispatcher, payloads } = filteringDispatcher(statusRows);
|
|
883
|
+
renderWithDataTable(dispatcher, statusSection, searchFacetDataTable);
|
|
884
|
+
|
|
885
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-active-1")).toBeTruthy());
|
|
886
|
+
expect(rtlScreen.getByTestId("row-ended-1")).toBeTruthy();
|
|
887
|
+
|
|
888
|
+
fireEvent.click(rtlScreen.getByTestId("facet-status-active"));
|
|
889
|
+
|
|
890
|
+
await waitFor(() => expect(rtlScreen.queryByTestId("row-ended-1")).toBeNull());
|
|
891
|
+
expect(rtlScreen.getByTestId("row-active-1")).toBeTruthy();
|
|
892
|
+
expect(payloads[payloads.length - 1]?.["filters"]).toEqual([
|
|
893
|
+
{ field: "status", op: "in", value: ["active"] },
|
|
894
|
+
]);
|
|
895
|
+
|
|
896
|
+
fireEvent.click(rtlScreen.getByTestId("filter-reset"));
|
|
897
|
+
|
|
898
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-ended-1")).toBeTruthy());
|
|
899
|
+
expect(rtlScreen.getByTestId("row-active-1")).toBeTruthy();
|
|
900
|
+
expect(payloads[payloads.length - 1]?.["filters"]).toBeUndefined();
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
test("boolean facet: selecting a value sends a real boolean in payload.filters and narrows rows accordingly", async () => {
|
|
904
|
+
const paidRows = [
|
|
905
|
+
{ id: "paid-1", name: "Invoice A", paid: true },
|
|
906
|
+
{ id: "unpaid-1", name: "Invoice B", paid: false },
|
|
907
|
+
];
|
|
908
|
+
const { dispatcher, payloads } = filteringDispatcher(paidRows);
|
|
909
|
+
renderWithDataTable(
|
|
910
|
+
dispatcher,
|
|
911
|
+
{
|
|
912
|
+
kind: "relatedList",
|
|
913
|
+
title: "Invoices",
|
|
914
|
+
query: "lease:query:invoices:list",
|
|
915
|
+
columns: [{ field: "name" }, { field: "paid" }],
|
|
916
|
+
facets: [
|
|
917
|
+
{
|
|
918
|
+
field: "paid",
|
|
919
|
+
type: "boolean",
|
|
920
|
+
label: "Paid",
|
|
921
|
+
trueLabel: "Yes",
|
|
922
|
+
falseLabel: "No",
|
|
923
|
+
},
|
|
924
|
+
],
|
|
925
|
+
},
|
|
926
|
+
searchFacetDataTable,
|
|
927
|
+
);
|
|
928
|
+
|
|
929
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-paid-1")).toBeTruthy());
|
|
930
|
+
expect(rtlScreen.getByTestId("row-unpaid-1")).toBeTruthy();
|
|
931
|
+
|
|
932
|
+
fireEvent.click(rtlScreen.getByTestId("facet-paid-true"));
|
|
933
|
+
|
|
934
|
+
await waitFor(() => expect(rtlScreen.queryByTestId("row-unpaid-1")).toBeNull());
|
|
935
|
+
expect(rtlScreen.getByTestId("row-paid-1")).toBeTruthy();
|
|
936
|
+
expect(payloads[payloads.length - 1]?.["filters"]).toEqual([
|
|
937
|
+
{ field: "paid", op: "in", value: [true] },
|
|
938
|
+
]);
|
|
939
|
+
});
|
|
940
|
+
});
|
|
@@ -10,7 +10,12 @@ import type {
|
|
|
10
10
|
ListRowViewModel,
|
|
11
11
|
Translate,
|
|
12
12
|
} from "@cosmicdrift/kumiko-headless";
|
|
13
|
-
import { type ReactNode, useMemo, useState } from "react";
|
|
13
|
+
import { type ReactNode, useCallback, useMemo, useState } from "react";
|
|
14
|
+
import {
|
|
15
|
+
buildFilterFacets,
|
|
16
|
+
buildFilterPayload,
|
|
17
|
+
resolveProjectionFacetSpecs,
|
|
18
|
+
} from "../app/list-facets";
|
|
14
19
|
import { useNav } from "../app/nav";
|
|
15
20
|
import {
|
|
16
21
|
buildProjectionRowActions,
|
|
@@ -22,7 +27,7 @@ import { useOptionalDispatcher } from "../context/dispatcher-context";
|
|
|
22
27
|
import type { ListSort } from "../hooks/use-list-url-state";
|
|
23
28
|
import { useQuery } from "../hooks/use-query";
|
|
24
29
|
import { useTranslation } from "../i18n";
|
|
25
|
-
import { usePrimitives } from "../primitives";
|
|
30
|
+
import { type DataTableFacet, usePrimitives } from "../primitives";
|
|
26
31
|
import { sortByAccessor } from "../sort-by-accessor";
|
|
27
32
|
import { RenderList } from "./render-list";
|
|
28
33
|
|
|
@@ -94,16 +99,53 @@ export function RelatedListSection({
|
|
|
94
99
|
[section.columns],
|
|
95
100
|
);
|
|
96
101
|
|
|
102
|
+
// Local state, not URL state: a section `id` is optional, so there is no
|
|
103
|
+
// stable URL key to namespace against — the section's sort is local for the
|
|
104
|
+
// same reason. Consequence: search/filters reset on reload.
|
|
105
|
+
const [search, setSearch] = useState("");
|
|
106
|
+
const [filters, setFilters] = useState<Readonly<Record<string, readonly string[]>>>({});
|
|
107
|
+
|
|
108
|
+
const facetSpecs = useMemo(
|
|
109
|
+
() => resolveProjectionFacetSpecs(section.facets, effectiveTranslate),
|
|
110
|
+
[section.facets, effectiveTranslate],
|
|
111
|
+
);
|
|
112
|
+
const filterPayload = useMemo(
|
|
113
|
+
() =>
|
|
114
|
+
buildFilterPayload(filters, (field) => facetSpecs.find((spec) => spec.field === field)?.type),
|
|
115
|
+
[filters, facetSpecs],
|
|
116
|
+
);
|
|
117
|
+
const filterFacets = useMemo<DataTableFacet[]>(() => buildFilterFacets(facetSpecs), [facetSpecs]);
|
|
118
|
+
|
|
97
119
|
const payload = useMemo(
|
|
98
120
|
() => ({
|
|
99
121
|
[section.parentParam ?? "id"]: parentId,
|
|
100
122
|
...(section.pageSize !== undefined && { limit: section.pageSize }),
|
|
123
|
+
// Gated on the declared capability, not just on state carrying a value —
|
|
124
|
+
// same rule as ProjectionListBody: a param the bound query's Zod schema
|
|
125
|
+
// doesn't accept would 422 the whole section.
|
|
126
|
+
...(section.searchable === true && search !== "" && { search }),
|
|
127
|
+
...(section.facets !== undefined && filterPayload.length > 0 && { filters: filterPayload }),
|
|
101
128
|
}),
|
|
102
|
-
[
|
|
129
|
+
[
|
|
130
|
+
section.parentParam,
|
|
131
|
+
section.pageSize,
|
|
132
|
+
section.searchable,
|
|
133
|
+
section.facets,
|
|
134
|
+
parentId,
|
|
135
|
+
search,
|
|
136
|
+
filterPayload,
|
|
137
|
+
],
|
|
103
138
|
);
|
|
104
139
|
|
|
105
140
|
const rowsQuery = useQuery<PagedRows>(section.query, payload);
|
|
106
141
|
|
|
142
|
+
const onFilterChange = useCallback(
|
|
143
|
+
(field: string, values: readonly string[]) =>
|
|
144
|
+
setFilters((prev) => ({ ...prev, [field]: values })),
|
|
145
|
+
[],
|
|
146
|
+
);
|
|
147
|
+
const onFilterReset = useCallback(() => setFilters({}), []);
|
|
148
|
+
|
|
107
149
|
// Sorted client-side over the already-loaded rows — this section has no
|
|
108
150
|
// pager (see `payload` above: a one-shot fetch, no cursor/offset), so the
|
|
109
151
|
// loaded set already IS the full display set and there is no "other page"
|
|
@@ -196,6 +238,17 @@ export function RelatedListSection({
|
|
|
196
238
|
translate={effectiveTranslate}
|
|
197
239
|
sort={sort}
|
|
198
240
|
onSortChange={setSort}
|
|
241
|
+
{...(section.searchable === true && {
|
|
242
|
+
searchable: true,
|
|
243
|
+
searchValue: search,
|
|
244
|
+
onSearchChange: setSearch,
|
|
245
|
+
})}
|
|
246
|
+
{...(filterFacets.length > 0 && {
|
|
247
|
+
filterFacets,
|
|
248
|
+
filterValues: filters,
|
|
249
|
+
onFilterChange,
|
|
250
|
+
onFilterReset,
|
|
251
|
+
})}
|
|
199
252
|
{...(onRowClick !== undefined && { onRowClick })}
|
|
200
253
|
{...(rowActions !== undefined && { rowActions })}
|
|
201
254
|
{...(rowActionMode !== undefined && { rowActionMode })}
|
|
@@ -206,9 +259,11 @@ export function RelatedListSection({
|
|
|
206
259
|
|
|
207
260
|
// hideTitle (tabs mode) → the tab panel is already the boundary: no
|
|
208
261
|
// Section card wrapper here, `chromeless` above drops the table's own
|
|
209
|
-
// card frame too, and `scrollBody`
|
|
210
|
-
// Akte tab scrolls internally instead of
|
|
211
|
-
// the
|
|
262
|
+
// card frame too, and `scrollBody` caps this wrapper at the panel's
|
|
263
|
+
// available height so a long Akte tab scrolls internally instead of
|
|
264
|
+
// stretching the page, while a short one still sizes to its content
|
|
265
|
+
// instead of stretching to the bottom (fw#2722, fw#2778) — the list sits
|
|
266
|
+
// directly in the tab. `FillContainer` is this section's
|
|
212
267
|
// link in RenderEdit's `fillHeight` chain (see render-edit.tsx): it is
|
|
213
268
|
// always this section's own root whenever hideTitle is set, since tabs
|
|
214
269
|
// mode narrows RenderEdit to exactly this one active section. A platform
|
|
@@ -631,11 +631,12 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
631
631
|
|
|
632
632
|
// A lone relatedList tab (hideSectionTitles is only ever set by the tabs
|
|
633
633
|
// layout, which also narrows filteredSections to that one active section)
|
|
634
|
-
// needs its own tab panel
|
|
634
|
+
// needs its own tab panel capped at the available height so its table
|
|
635
635
|
// scrolls inside the panel instead of the whole page stretching to the
|
|
636
|
-
// row count (fw#2722)
|
|
637
|
-
//
|
|
638
|
-
//
|
|
636
|
+
// row count (fw#2722) — a short table still sizes to its content instead
|
|
637
|
+
// of stretching the panel to the bottom (fw#2778). Any other layout —
|
|
638
|
+
// multiple sections, a non-relatedList tab, stacked (non-tabs) forms —
|
|
639
|
+
// keeps normal document-flow height untouched.
|
|
639
640
|
const fillHeight = hideSectionTitles === true && filteredSections[0]?.kind === "relatedList";
|
|
640
641
|
|
|
641
642
|
// Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
|
|
@@ -118,6 +118,11 @@ export type RenderListProps = {
|
|
|
118
118
|
* scrolls rows internally instead of growing the page (relatedList in a
|
|
119
119
|
* tabs-mode section, fw#2722). Default false. */
|
|
120
120
|
readonly scrollBody?: boolean;
|
|
121
|
+
/** Forwarded to `DataTableProps.screenPadding` — the table carries the
|
|
122
|
+
* shared screen padding because it is the screen body (entityList/
|
|
123
|
+
* projectionList, fw#2640). Not set for an embedded relatedList.
|
|
124
|
+
* Default false. */
|
|
125
|
+
readonly screenPadding?: boolean;
|
|
121
126
|
};
|
|
122
127
|
|
|
123
128
|
// Resolved-Form einer Toolbar-Action: KumikoScreen baut das aus dem
|
|
@@ -172,6 +177,7 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
172
177
|
onFilterReset,
|
|
173
178
|
chromeless,
|
|
174
179
|
scrollBody,
|
|
180
|
+
screenPadding,
|
|
175
181
|
} = props;
|
|
176
182
|
// Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
|
|
177
183
|
// wären Column-Header raw i18n-Keys.
|
|
@@ -388,6 +394,7 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
388
394
|
{...(onFilterReset !== undefined && { onFilterReset })}
|
|
389
395
|
{...(chromeless !== undefined && { chromeless })}
|
|
390
396
|
{...(scrollBody !== undefined && { scrollBody })}
|
|
397
|
+
{...(screenPadding !== undefined && { screenPadding })}
|
|
391
398
|
testId="render-list-table"
|
|
392
399
|
/>
|
|
393
400
|
</>
|
package/src/primitives.tsx
CHANGED
|
@@ -637,6 +637,14 @@ export type DataTableProps = {
|
|
|
637
637
|
* has no effect outside a sized flex-col ancestor). Default false:
|
|
638
638
|
* unchanged document-flow table that grows with its content. */
|
|
639
639
|
readonly scrollBody?: boolean;
|
|
640
|
+
/** Uses the shared screen padding (wider bottom inset) instead of the
|
|
641
|
+
* table's symmetric embedded inset — for a table that IS the screen body
|
|
642
|
+
* (entityList/projectionList), so a list screen ends at the same footer
|
|
643
|
+
* distance as a form or custom screen (fw#2640). Hosts set this or
|
|
644
|
+
* `scrollBody`, not both: the wider bottom inset competes with
|
|
645
|
+
* `scrollBody`'s flex-fill height budget.
|
|
646
|
+
* Default false: unchanged symmetric inset for an embedded table. */
|
|
647
|
+
readonly screenPadding?: boolean;
|
|
640
648
|
};
|
|
641
649
|
|
|
642
650
|
// ---- EmbeddedListInput (createEmbeddedListField widget) ----
|
|
@@ -807,9 +815,10 @@ export type SectionProps = {
|
|
|
807
815
|
};
|
|
808
816
|
|
|
809
817
|
/** Chromeless flex-fill layout host — no title, no card frame, no padding,
|
|
810
|
-
* just a container that sizes to
|
|
811
|
-
*
|
|
812
|
-
*
|
|
818
|
+
* just a container that sizes to its content and, once the ancestor chain
|
|
819
|
+
* is height-constrained, shrinks so one scrolling child can scroll
|
|
820
|
+
* internally instead of the page growing (fw#2722, height fw#2778). Web:
|
|
821
|
+
* `<div className="flex min-h-0 flex-col">`. Native: Views are
|
|
813
822
|
* already flex-column and the parent is already a bounded viewport there,
|
|
814
823
|
* so a native impl may render this as a bare Fragment. Used only as the
|
|
815
824
|
* terminal link in `RenderEdit`'s `fillHeight` chain (`RelatedListSection`'s
|