@cosmicdrift/kumiko-renderer 0.249.0 → 0.251.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 +21 -86
- package/src/app/list-facets.ts +90 -0
- package/src/app/row-actions.ts +2 -0
- package/src/components/__tests__/related-list-section.test.tsx +301 -1
- package/src/components/__tests__/render-edit-action-button.test.tsx +20 -0
- package/src/components/related-list-section.tsx +61 -6
- package/src/components/render-edit-action-button.tsx +5 -2
- package/src/components/render-edit-types.ts +6 -0
- package/src/components/render-edit.tsx +7 -5
- package/src/components/render-list.tsx +13 -1
- package/src/primitives.tsx +19 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.251.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.251.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.251.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.251.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,
|
|
@@ -763,6 +768,7 @@ function EntityEditUpdateForm({
|
|
|
763
768
|
id: action.id,
|
|
764
769
|
label: effectiveTranslate(action.label),
|
|
765
770
|
...(action.style !== undefined && { style: action.style }),
|
|
771
|
+
confirmRequired: false,
|
|
766
772
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
767
773
|
onPress: () => {
|
|
768
774
|
if (id === "") return;
|
|
@@ -783,6 +789,7 @@ function EntityEditUpdateForm({
|
|
|
783
789
|
id: action.id,
|
|
784
790
|
label: effectiveTranslate(action.label),
|
|
785
791
|
...(action.style !== undefined && { style: action.style }),
|
|
792
|
+
confirmRequired: false,
|
|
786
793
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
787
794
|
onPress: () => {
|
|
788
795
|
nav.navigate({
|
|
@@ -802,6 +809,7 @@ function EntityEditUpdateForm({
|
|
|
802
809
|
id: action.id,
|
|
803
810
|
label: effectiveTranslate(action.label),
|
|
804
811
|
...(action.style !== undefined && { style: action.style }),
|
|
812
|
+
confirmRequired: false,
|
|
805
813
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
806
814
|
onPress: () => {
|
|
807
815
|
const initialValues =
|
|
@@ -1063,58 +1071,6 @@ export function buildListQueryPayload(state: {
|
|
|
1063
1071
|
return payload;
|
|
1064
1072
|
}
|
|
1065
1073
|
|
|
1066
|
-
// One resolved facet, independent of where the type info came from — an
|
|
1067
|
-
// entity field (entityList) or an explicit ListFacetSpec (projectionList,
|
|
1068
|
-
// fw#2224). Shared by buildFilterFacets/buildFilterPayload below so both
|
|
1069
|
-
// screen types build their query-payload filters and DataTable facet-UI
|
|
1070
|
-
// through the same code, instead of two copies that can drift.
|
|
1071
|
-
type ResolvedFacetSpec = {
|
|
1072
|
-
readonly field: string;
|
|
1073
|
-
readonly type: "select" | "boolean";
|
|
1074
|
-
readonly label: string;
|
|
1075
|
-
readonly options: readonly { readonly value: string; readonly label: string }[];
|
|
1076
|
-
};
|
|
1077
|
-
|
|
1078
|
-
function buildFilterFacets(specs: readonly ResolvedFacetSpec[]): DataTableFacet[] {
|
|
1079
|
-
return specs.map((spec) => ({ field: spec.field, label: spec.label, options: spec.options }));
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
// User-selected faceted filters from URL-state → payload.filters. Boolean
|
|
1083
|
-
// fields coerce "true"/"false" strings to real booleans (DB column is
|
|
1084
|
-
// boolean); everything else stays string[] under op:"in" (multi-select
|
|
1085
|
-
// semantics). `typeOf` resolves a field to its known type string —
|
|
1086
|
-
// undefined means "unknown field", so it's dropped (typo-safe: a stale/
|
|
1087
|
-
// hand-crafted URL param for an undeclared field never reaches the
|
|
1088
|
-
// server). Deliberately NOT gated on the field being a *facet* — entityList
|
|
1089
|
-
// passes through any field present in entity.fields, matching its
|
|
1090
|
-
// pre-fw#2224 behavior; only the boolean-coercion branch cares about type.
|
|
1091
|
-
|
|
1092
|
-
function isFacetI18nKey(label: string): boolean {
|
|
1093
|
-
return !/\s/.test(label) && (label.includes(".") || label.includes(":"));
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
function buildFilterPayload(
|
|
1097
|
-
urlFilters: Readonly<Record<string, readonly string[]>>,
|
|
1098
|
-
typeOf: (field: string) => string | undefined,
|
|
1099
|
-
): { field: string; op: "in"; value: unknown }[] {
|
|
1100
|
-
const out: { field: string; op: "in"; value: unknown }[] = [];
|
|
1101
|
-
for (const [field, values] of Object.entries(urlFilters)) {
|
|
1102
|
-
if (values.length === 0) continue;
|
|
1103
|
-
// `id` is a base column (not a declared facet), allowed as an id-set
|
|
1104
|
-
// filter so a header-slot control — e.g. the tags TagFilter — can narrow
|
|
1105
|
-
// ANY list to a resolved set of row ids without the host declaring a facet.
|
|
1106
|
-
if (field === "id") {
|
|
1107
|
-
out.push({ field, op: "in", value: values });
|
|
1108
|
-
continue;
|
|
1109
|
-
}
|
|
1110
|
-
const type = typeOf(field);
|
|
1111
|
-
if (type === undefined) continue;
|
|
1112
|
-
const value = type === "boolean" ? values.map((v) => v === "true") : values;
|
|
1113
|
-
out.push({ field, op: "in", value });
|
|
1114
|
-
}
|
|
1115
|
-
return out;
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
1074
|
// entityList adapter — one DataTableFacet per filterable select/boolean
|
|
1119
1075
|
// entity field, labels via the standard field/option i18n convention.
|
|
1120
1076
|
function resolveEntityFacetSpecs(
|
|
@@ -1161,39 +1117,6 @@ function resolveEntityFacetSpecs(
|
|
|
1161
1117
|
return out;
|
|
1162
1118
|
}
|
|
1163
1119
|
|
|
1164
|
-
// projectionList adapter — a projectionList has no entity/i18n convention to
|
|
1165
|
-
// derive labels from, so ListFacetSpec carries every label explicitly
|
|
1166
|
-
// (fw#2224). Labels may be raw display strings or i18n keys; run them
|
|
1167
|
-
// through translate like entity facets (passthrough when the key is missing).
|
|
1168
|
-
function resolveProjectionFacetSpecs(
|
|
1169
|
-
facets: readonly ListFacetSpec[] | undefined,
|
|
1170
|
-
translate: Translate,
|
|
1171
|
-
): ResolvedFacetSpec[] {
|
|
1172
|
-
if (facets === undefined) return [];
|
|
1173
|
-
const tr = (label: string): string => (isFacetI18nKey(label) ? translate(label) : label);
|
|
1174
|
-
return facets.map((facet) =>
|
|
1175
|
-
facet.type === "select"
|
|
1176
|
-
? {
|
|
1177
|
-
field: facet.field,
|
|
1178
|
-
type: "select",
|
|
1179
|
-
label: tr(facet.label),
|
|
1180
|
-
options: facet.options.map((opt) => ({
|
|
1181
|
-
value: opt.value,
|
|
1182
|
-
label: tr(opt.label),
|
|
1183
|
-
})),
|
|
1184
|
-
}
|
|
1185
|
-
: {
|
|
1186
|
-
field: facet.field,
|
|
1187
|
-
type: "boolean",
|
|
1188
|
-
label: tr(facet.label),
|
|
1189
|
-
options: [
|
|
1190
|
-
{ value: "true", label: tr(facet.trueLabel) },
|
|
1191
|
-
{ value: "false", label: tr(facet.falseLabel) },
|
|
1192
|
-
],
|
|
1193
|
-
},
|
|
1194
|
-
);
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1197
1120
|
// ---- drawer-kind actions: ToolbarAction (fw#2225) + RowAction (fw#2710) ----
|
|
1198
1121
|
//
|
|
1199
1122
|
// Shared across EntityListBody, ProjectionListBody, ProjectionDetailBody and
|
|
@@ -1612,6 +1535,7 @@ function EntityListBody({
|
|
|
1612
1535
|
id: action.id,
|
|
1613
1536
|
label: effectiveTranslate(action.label),
|
|
1614
1537
|
...(action.style !== undefined && { style: action.style }),
|
|
1538
|
+
confirmRequired: false,
|
|
1615
1539
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1616
1540
|
onTrigger: (row: ListRowViewModel) => runNavigate(navigateAction, row),
|
|
1617
1541
|
...(actionVisible !== undefined && {
|
|
@@ -1627,6 +1551,7 @@ function EntityListBody({
|
|
|
1627
1551
|
id: action.id,
|
|
1628
1552
|
label: effectiveTranslate(action.label),
|
|
1629
1553
|
...(action.style !== undefined && { style: action.style }),
|
|
1554
|
+
confirmRequired: false,
|
|
1630
1555
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1631
1556
|
onTrigger: (row: ListRowViewModel) => {
|
|
1632
1557
|
const initialValues =
|
|
@@ -1709,6 +1634,7 @@ function EntityListBody({
|
|
|
1709
1634
|
id: action.id,
|
|
1710
1635
|
label: effectiveTranslate(action.label),
|
|
1711
1636
|
...(action.style !== undefined && { style: action.style }),
|
|
1637
|
+
confirmRequired: false,
|
|
1712
1638
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1713
1639
|
onTrigger: () => nav.navigate({ screenId: action.screen }),
|
|
1714
1640
|
};
|
|
@@ -1718,6 +1644,7 @@ function EntityListBody({
|
|
|
1718
1644
|
id: action.id,
|
|
1719
1645
|
label: effectiveTranslate(action.label),
|
|
1720
1646
|
...(action.style !== undefined && { style: action.style }),
|
|
1647
|
+
confirmRequired: false,
|
|
1721
1648
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
1722
1649
|
onTrigger: () => openDrawer(action),
|
|
1723
1650
|
};
|
|
@@ -1834,6 +1761,7 @@ function EntityListBody({
|
|
|
1834
1761
|
onSearchChange={urlState.setQ}
|
|
1835
1762
|
sort={effectiveSort}
|
|
1836
1763
|
onSortChange={urlState.setSort}
|
|
1764
|
+
screenPadding
|
|
1837
1765
|
{...(pager !== undefined && { pager })}
|
|
1838
1766
|
{...(rowActions !== undefined && { rowActions })}
|
|
1839
1767
|
{...(rowActionMode !== undefined && { rowActionMode })}
|
|
@@ -2017,6 +1945,7 @@ function ProjectionListBody({
|
|
|
2017
1945
|
id: action.id,
|
|
2018
1946
|
label: effectiveTranslate(action.label),
|
|
2019
1947
|
...(action.style !== undefined && { style: action.style }),
|
|
1948
|
+
confirmRequired: false,
|
|
2020
1949
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
2021
1950
|
onTrigger: () => nav.navigate({ screenId: target }),
|
|
2022
1951
|
});
|
|
@@ -2027,6 +1956,7 @@ function ProjectionListBody({
|
|
|
2027
1956
|
id: action.id,
|
|
2028
1957
|
label: effectiveTranslate(action.label),
|
|
2029
1958
|
...(action.style !== undefined && { style: action.style }),
|
|
1959
|
+
confirmRequired: false,
|
|
2030
1960
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
2031
1961
|
onTrigger: () => openDrawer(action),
|
|
2032
1962
|
});
|
|
@@ -2122,6 +2052,7 @@ function ProjectionListBody({
|
|
|
2122
2052
|
onSearchChange={urlState.setQ}
|
|
2123
2053
|
sort={activeSort}
|
|
2124
2054
|
onSortChange={urlState.setSort}
|
|
2055
|
+
screenPadding
|
|
2125
2056
|
{...(pager !== undefined && { pager })}
|
|
2126
2057
|
{...(rowActions !== undefined && { rowActions })}
|
|
2127
2058
|
{...(rowActionMode !== undefined && { rowActionMode })}
|
|
@@ -2315,6 +2246,7 @@ function ProjectionDetailBody({
|
|
|
2315
2246
|
id: action.id,
|
|
2316
2247
|
label: effectiveTranslate(action.label),
|
|
2317
2248
|
...(action.style !== undefined && { style: action.style }),
|
|
2249
|
+
confirmRequired: false,
|
|
2318
2250
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
2319
2251
|
onPress: () => {
|
|
2320
2252
|
if (id === "") return;
|
|
@@ -2348,6 +2280,7 @@ function ProjectionDetailBody({
|
|
|
2348
2280
|
id: action.id,
|
|
2349
2281
|
label: effectiveTranslate(action.label),
|
|
2350
2282
|
...(action.style !== undefined && { style: action.style }),
|
|
2283
|
+
confirmRequired: false,
|
|
2351
2284
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
2352
2285
|
onPress: () => {
|
|
2353
2286
|
nav.navigate({
|
|
@@ -2367,6 +2300,7 @@ function ProjectionDetailBody({
|
|
|
2367
2300
|
id: action.id,
|
|
2368
2301
|
label: effectiveTranslate(action.label),
|
|
2369
2302
|
...(action.style !== undefined && { style: action.style }),
|
|
2303
|
+
confirmRequired: false,
|
|
2370
2304
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
2371
2305
|
onPress: () => {
|
|
2372
2306
|
const initialValues =
|
|
@@ -2728,6 +2662,7 @@ function ActionFormBody({
|
|
|
2728
2662
|
onSubmit={handleSubmitted}
|
|
2729
2663
|
{...(handleCancel !== undefined && { onCancel: handleCancel })}
|
|
2730
2664
|
{...(screen.submitLabel !== undefined && { submitLabel: screen.submitLabel })}
|
|
2665
|
+
{...(screen.submitStyle !== undefined && { submitVariant: screen.submitStyle })}
|
|
2731
2666
|
{...(translate !== undefined && { translate })}
|
|
2732
2667
|
/>
|
|
2733
2668
|
);
|
|
@@ -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
|
+
}
|
package/src/app/row-actions.ts
CHANGED
|
@@ -156,6 +156,7 @@ function buildNavigateRowAction(
|
|
|
156
156
|
id: action.id,
|
|
157
157
|
label: translate(action.label),
|
|
158
158
|
...(action.style !== undefined && { style: action.style }),
|
|
159
|
+
confirmRequired: false,
|
|
159
160
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
160
161
|
onTrigger: (row: ListRowViewModel) => runProjectionRowNavigate(nav, action, row),
|
|
161
162
|
...(visible !== undefined && {
|
|
@@ -195,6 +196,7 @@ function buildDrawerRowAction(
|
|
|
195
196
|
id: action.id,
|
|
196
197
|
label: translate(action.label),
|
|
197
198
|
...(action.style !== undefined && { style: action.style }),
|
|
199
|
+
confirmRequired: false,
|
|
198
200
|
...(actionIcon !== undefined && { icon: actionIcon }),
|
|
199
201
|
onTrigger: (row: ListRowViewModel) => {
|
|
200
202
|
openDrawer(action, params !== undefined ? evalRowExtractor(params, row.values) : undefined);
|
|
@@ -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
|
+
});
|
|
@@ -137,6 +137,26 @@ describe("RenderEditActionButton", () => {
|
|
|
137
137
|
await waitFor(() => expect(pressed).toBe(1));
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
+
// fw#2752: schema-driven navigate/drawer actions set confirmRequired: false
|
|
141
|
+
// to opt a danger-styled action out of the forced dialog — the colour still
|
|
142
|
+
// marks it destructive, but the target form is itself the confirmation.
|
|
143
|
+
test("danger style with confirmRequired=false fires onPress directly, no dialog", async () => {
|
|
144
|
+
let pressed = 0;
|
|
145
|
+
renderAction({
|
|
146
|
+
id: "open-terminate-form",
|
|
147
|
+
label: "Terminate",
|
|
148
|
+
style: "danger",
|
|
149
|
+
confirmRequired: false,
|
|
150
|
+
onPress: async () => {
|
|
151
|
+
pressed += 1;
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
fireEvent.click(rtlScreen.getByTestId("render-edit-action-open-terminate-form"));
|
|
156
|
+
expect(rtlScreen.queryByTestId("render-edit-action-open-terminate-form-dialog")).toBeNull();
|
|
157
|
+
await waitFor(() => expect(pressed).toBe(1));
|
|
158
|
+
});
|
|
159
|
+
|
|
140
160
|
test("onPress failure reports via onError", async () => {
|
|
141
161
|
const errors: Array<string | null> = [];
|
|
142
162
|
renderAction(
|
|
@@ -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
|
|
@@ -39,8 +39,11 @@ export function RenderEditActionButton({
|
|
|
39
39
|
|
|
40
40
|
const variant = action.style ?? "secondary";
|
|
41
41
|
// Same rule as RowActionWriteHandler: "danger" forces a confirm even
|
|
42
|
-
// without an explicit confirm key
|
|
43
|
-
|
|
42
|
+
// without an explicit confirm key — unless `confirmRequired` overrides it
|
|
43
|
+
// (schema-driven navigate/drawer actions, where the target form is itself
|
|
44
|
+
// the confirmation).
|
|
45
|
+
const needsConfirm =
|
|
46
|
+
action.confirm !== undefined || (action.confirmRequired ?? action.style === "danger");
|
|
44
47
|
const showIconOnly = iconOnly && action.icon !== undefined;
|
|
45
48
|
|
|
46
49
|
return (
|
|
@@ -76,6 +76,8 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
76
76
|
* "Save" can be replaced by domain-specific strings ("Approve" /
|
|
77
77
|
* "Dispatch" / etc.). */
|
|
78
78
|
readonly submitLabel?: string;
|
|
79
|
+
/** Visual style of the submit button (actionForm `submitStyle`). Default "primary". */
|
|
80
|
+
readonly submitVariant?: "primary" | "danger";
|
|
79
81
|
/** Per-field extra content inline after the label (e.g.
|
|
80
82
|
* ConfigSourceBadge). Called with the field name, returns a ReactNode or
|
|
81
83
|
* undefined. */
|
|
@@ -168,6 +170,10 @@ export type RenderEditAction = {
|
|
|
168
170
|
readonly style?: "primary" | "secondary" | "danger";
|
|
169
171
|
readonly confirm?: string;
|
|
170
172
|
readonly confirmLabel?: string;
|
|
173
|
+
/** Overrides the default "danger implies a confirm dialog" rule. Schema-driven
|
|
174
|
+
* navigate/drawer actions set it to false: the colour marks the action as
|
|
175
|
+
* destructive, but the target form is the confirmation. */
|
|
176
|
+
readonly confirmRequired?: boolean;
|
|
171
177
|
/** Resolved icon (author `RowAction.icon` or the id-derived default) —
|
|
172
178
|
* drives both the icon-left-of-text render and the icon-only collapse
|
|
173
179
|
* rule (see `shouldRenderActionsIconOnly`). */
|
|
@@ -212,6 +212,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
212
212
|
actions,
|
|
213
213
|
onRelatedListDrawerAction,
|
|
214
214
|
submitLabel,
|
|
215
|
+
submitVariant,
|
|
215
216
|
labelAppendix,
|
|
216
217
|
fieldAppendix,
|
|
217
218
|
entityId: entityIdProp,
|
|
@@ -630,11 +631,12 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
630
631
|
|
|
631
632
|
// A lone relatedList tab (hideSectionTitles is only ever set by the tabs
|
|
632
633
|
// layout, which also narrows filteredSections to that one active section)
|
|
633
|
-
// needs its own tab panel
|
|
634
|
+
// needs its own tab panel capped at the available height so its table
|
|
634
635
|
// scrolls inside the panel instead of the whole page stretching to the
|
|
635
|
-
// row count (fw#2722)
|
|
636
|
-
//
|
|
637
|
-
//
|
|
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.
|
|
638
640
|
const fillHeight = hideSectionTitles === true && filteredSections[0]?.kind === "relatedList";
|
|
639
641
|
|
|
640
642
|
// Persistiert alle composed Extension-Sections mit der aufgelösten entityId.
|
|
@@ -1032,7 +1034,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1032
1034
|
type="submit"
|
|
1033
1035
|
disabled={(snapshot.isUnchanged && !extensionDirty) || isSubmitting || disabled}
|
|
1034
1036
|
loading={isSubmitting}
|
|
1035
|
-
variant="primary"
|
|
1037
|
+
variant={submitVariant ?? "primary"}
|
|
1036
1038
|
icon="check"
|
|
1037
1039
|
testId="render-edit-submit"
|
|
1038
1040
|
>
|
|
@@ -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
|
|
@@ -130,6 +135,10 @@ export type ToolbarActionButton = {
|
|
|
130
135
|
readonly style?: "primary" | "secondary" | "danger";
|
|
131
136
|
readonly confirm?: string;
|
|
132
137
|
readonly confirmLabel?: string;
|
|
138
|
+
/** Overrides the default "danger implies a confirm dialog" rule. Schema-driven
|
|
139
|
+
* navigate/drawer actions set it to false: the colour marks the action as
|
|
140
|
+
* destructive, but the target form is the confirmation. */
|
|
141
|
+
readonly confirmRequired?: boolean;
|
|
133
142
|
readonly onTrigger: () => Promise<void> | void;
|
|
134
143
|
/** Id-derived default icon (ACTION_ICON_BY_ID in kumiko-screen.tsx) —
|
|
135
144
|
* ToolbarAction has no author-declared icon field, unlike RowAction. */
|
|
@@ -168,6 +177,7 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
168
177
|
onFilterReset,
|
|
169
178
|
chromeless,
|
|
170
179
|
scrollBody,
|
|
180
|
+
screenPadding,
|
|
171
181
|
} = props;
|
|
172
182
|
// Wie RenderEdit: Translate-Fallback aus dem i18next-Context, sonst
|
|
173
183
|
// wären Column-Header raw i18n-Keys.
|
|
@@ -384,6 +394,7 @@ export function RenderList(props: RenderListProps): ReactNode {
|
|
|
384
394
|
{...(onFilterReset !== undefined && { onFilterReset })}
|
|
385
395
|
{...(chromeless !== undefined && { chromeless })}
|
|
386
396
|
{...(scrollBody !== undefined && { scrollBody })}
|
|
397
|
+
{...(screenPadding !== undefined && { screenPadding })}
|
|
387
398
|
testId="render-list-table"
|
|
388
399
|
/>
|
|
389
400
|
</>
|
|
@@ -492,7 +503,8 @@ function ToolbarActionView({
|
|
|
492
503
|
};
|
|
493
504
|
|
|
494
505
|
const variant: "primary" | "secondary" | "danger" = action.style ?? "secondary";
|
|
495
|
-
const needsConfirm =
|
|
506
|
+
const needsConfirm =
|
|
507
|
+
action.confirm !== undefined || (action.confirmRequired ?? action.style === "danger");
|
|
496
508
|
const showIconOnly = iconOnly && action.icon !== undefined;
|
|
497
509
|
|
|
498
510
|
return (
|
package/src/primitives.tsx
CHANGED
|
@@ -488,8 +488,9 @@ export type DataTableRowAction = {
|
|
|
488
488
|
readonly id: string;
|
|
489
489
|
/** Translated Label. */
|
|
490
490
|
readonly label: string;
|
|
491
|
-
/** Visual
|
|
492
|
-
*
|
|
491
|
+
/** Visual style — "danger" renders the red variant in the default
|
|
492
|
+
* primitive. Whether it also forces a confirm dialog is governed by
|
|
493
|
+
* `confirmRequired` (falls back to `style === "danger"` when unset). */
|
|
493
494
|
readonly style?: "primary" | "secondary" | "danger";
|
|
494
495
|
/** Translated Confirm-Prompt (Description im Dialog) — wenn gesetzt,
|
|
495
496
|
* öffnet ein Modal vor der Ausführung. Bei style=danger ohne expliziten
|
|
@@ -498,6 +499,10 @@ export type DataTableRowAction = {
|
|
|
498
499
|
/** Translated Confirm-Button-Label im Dialog. Default = `label`
|
|
499
500
|
* (Action-Label wird wiederverwendet). */
|
|
500
501
|
readonly confirmLabel?: string;
|
|
502
|
+
/** Overrides the default "danger implies a confirm dialog" rule. Schema-driven
|
|
503
|
+
* navigate/drawer actions set it to false: the colour marks the action as
|
|
504
|
+
* destructive, but the target form is the confirmation. */
|
|
505
|
+
readonly confirmRequired?: boolean;
|
|
501
506
|
/** Wird mit der ListRowViewModel der geklickten Row aufgerufen. Async
|
|
502
507
|
* erlaubt — der Renderer kann während der Promise-Resolution einen
|
|
503
508
|
* Loading-State auf dem Button zeigen. */
|
|
@@ -632,6 +637,14 @@ export type DataTableProps = {
|
|
|
632
637
|
* has no effect outside a sized flex-col ancestor). Default false:
|
|
633
638
|
* unchanged document-flow table that grows with its content. */
|
|
634
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;
|
|
635
648
|
};
|
|
636
649
|
|
|
637
650
|
// ---- EmbeddedListInput (createEmbeddedListField widget) ----
|
|
@@ -802,9 +815,10 @@ export type SectionProps = {
|
|
|
802
815
|
};
|
|
803
816
|
|
|
804
817
|
/** Chromeless flex-fill layout host — no title, no card frame, no padding,
|
|
805
|
-
* just a container that sizes to
|
|
806
|
-
*
|
|
807
|
-
*
|
|
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
|
|
808
822
|
* already flex-column and the parent is already a bounded viewport there,
|
|
809
823
|
* so a native impl may render this as a bare Fragment. Used only as the
|
|
810
824
|
* terminal link in `RenderEdit`'s `fillHeight` chain (`RelatedListSection`'s
|