@cosmicdrift/kumiko-renderer 0.208.2 → 0.209.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__/list-filter-facets.test.tsx +241 -0
- package/src/app/kumiko-screen.tsx +409 -129
- package/src/components/render-edit.tsx +18 -0
- package/src/components/render-field.tsx +184 -10
- package/src/index.ts +1 -0
- package/src/primitives.tsx +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.209.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.209.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.209.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2",
|
|
22
22
|
"zod": "^4.4.3"
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@testing-library/react": "^16.3.2",
|
|
26
26
|
"@types/react": "^19.2.14",
|
|
27
27
|
"jsdom": "^29.1.1",
|
|
28
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
28
|
+
"@cosmicdrift/kumiko-locale-de": "0.209.0"
|
|
29
29
|
},
|
|
30
30
|
"repository": {
|
|
31
31
|
"type": "git",
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// fw#2224: ProjectionListBody had neither `filter` nor faceted filters —
|
|
2
|
+
// only EntityListBody did (see kumiko-screen.tsx's buildFilterPayload/
|
|
3
|
+
// buildFilterFacets doc). This proves both directions of the fix: entityList
|
|
4
|
+
// keeps its exact pre-refactor payload/facet behavior after the shared
|
|
5
|
+
// function extraction, and projectionList gains the same capability —
|
|
6
|
+
// screen.filter and screen.facets reach payload.filter/payload.filters,
|
|
7
|
+
// with boolean facets coerced from URL-state strings to real booleans.
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import type {
|
|
11
|
+
EntityDefinition,
|
|
12
|
+
EntityListScreenDefinition,
|
|
13
|
+
ProjectionListScreenDefinition,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
15
|
+
import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
|
|
16
|
+
import { act, render, waitFor } from "@testing-library/react";
|
|
17
|
+
import { type ComponentType, type ReactNode, useState } from "react";
|
|
18
|
+
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
19
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
20
|
+
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
21
|
+
import { type CorePrimitives, type DataTableProps, PrimitivesProvider } from "../../primitives";
|
|
22
|
+
import type { FeatureSchema } from "../feature-schema";
|
|
23
|
+
import { KumikoScreen } from "../kumiko-screen";
|
|
24
|
+
import type { NavApi } from "../nav";
|
|
25
|
+
import { NavProvider } from "../nav";
|
|
26
|
+
|
|
27
|
+
let capturedProps: DataTableProps | undefined;
|
|
28
|
+
const captureDataTable: ComponentType<DataTableProps> = (props) => {
|
|
29
|
+
capturedProps = props;
|
|
30
|
+
return null;
|
|
31
|
+
};
|
|
32
|
+
// Indirection defeats TS narrowing `capturedProps` to `undefined` at read
|
|
33
|
+
// sites — the compiler can't see that `captureDataTable` (a React render
|
|
34
|
+
// callback) reassigns it between the reset and the read.
|
|
35
|
+
const getCapturedProps = (): DataTableProps | undefined => capturedProps;
|
|
36
|
+
const noop = (): ReactNode => null;
|
|
37
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
38
|
+
|
|
39
|
+
const testPrimitives: CorePrimitives = {
|
|
40
|
+
Button: noop,
|
|
41
|
+
Banner: passChildren,
|
|
42
|
+
Field: passChildren,
|
|
43
|
+
Input: noop,
|
|
44
|
+
DataTable: captureDataTable,
|
|
45
|
+
Form: passChildren,
|
|
46
|
+
Section: passChildren,
|
|
47
|
+
Card: passChildren,
|
|
48
|
+
Grid: passChildren,
|
|
49
|
+
GridCell: passChildren,
|
|
50
|
+
Text: passChildren,
|
|
51
|
+
Heading: noop,
|
|
52
|
+
Dialog: noop,
|
|
53
|
+
Modal: noop,
|
|
54
|
+
Lightbox: noop,
|
|
55
|
+
ConfigSourceBadge: noop,
|
|
56
|
+
ConfigCascadeView: noop,
|
|
57
|
+
Link: noop,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
let queryCalls: Array<{ readonly type: string; readonly payload: unknown }> = [];
|
|
61
|
+
|
|
62
|
+
function stubDispatcher(): Dispatcher {
|
|
63
|
+
return {
|
|
64
|
+
write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
|
|
65
|
+
query: (async (type: string, payload: unknown) => {
|
|
66
|
+
queryCalls.push({ type, payload });
|
|
67
|
+
return { isSuccess: true, data: { rows: [], nextCursor: null } };
|
|
68
|
+
}) as unknown as Dispatcher["query"],
|
|
69
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
70
|
+
statusStore: {
|
|
71
|
+
getState: () => "online",
|
|
72
|
+
subscribe: () => () => {},
|
|
73
|
+
} as unknown as Dispatcher["statusStore"],
|
|
74
|
+
async *stream() {},
|
|
75
|
+
pendingWrites: () => [],
|
|
76
|
+
pendingFiles: () => [],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Stateful nav so setSearchParams (called by useListUrlState.setFilter)
|
|
81
|
+
// actually re-renders the tree — a plain mock object wouldn't trigger React,
|
|
82
|
+
// and toggling a facet would never reach a second query() call.
|
|
83
|
+
function StatefulNav({ children }: { readonly children: ReactNode }): ReactNode {
|
|
84
|
+
const [params, setParams] = useState<Record<string, string>>({});
|
|
85
|
+
const value: NavApi = {
|
|
86
|
+
route: { screenId: "unused" },
|
|
87
|
+
navigate: () => {},
|
|
88
|
+
replace: () => {},
|
|
89
|
+
hrefFor: () => "",
|
|
90
|
+
searchParams: params,
|
|
91
|
+
setSearchParams: (updates) => {
|
|
92
|
+
setParams((prev) => {
|
|
93
|
+
const next = { ...prev };
|
|
94
|
+
for (const [k, v] of Object.entries(updates)) {
|
|
95
|
+
if (v === null) delete next[k];
|
|
96
|
+
else next[k] = v;
|
|
97
|
+
}
|
|
98
|
+
return next;
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
return <NavProvider value={value}>{children}</NavProvider>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function renderScreen(schema: FeatureSchema, qn: string): void {
|
|
106
|
+
render(
|
|
107
|
+
<LocaleProvider
|
|
108
|
+
resolver={createStaticLocaleResolver({ locale: "de-DE" })}
|
|
109
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
110
|
+
>
|
|
111
|
+
<DispatcherProvider dispatcher={stubDispatcher()}>
|
|
112
|
+
<StatefulNav>
|
|
113
|
+
<PrimitivesProvider value={testPrimitives}>
|
|
114
|
+
<KumikoScreen schema={schema} qn={qn} />
|
|
115
|
+
</PrimitivesProvider>
|
|
116
|
+
</StatefulNav>
|
|
117
|
+
</DispatcherProvider>
|
|
118
|
+
</LocaleProvider>,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe("entityList facets — unchanged after the shared buildFilterFacets/buildFilterPayload extraction (fw#2224)", () => {
|
|
123
|
+
test("a filterable boolean field renders as a facet, and toggling it sends a coerced boolean in payload.filters", async () => {
|
|
124
|
+
queryCalls = [];
|
|
125
|
+
capturedProps = undefined;
|
|
126
|
+
const entity: EntityDefinition = {
|
|
127
|
+
fields: {
|
|
128
|
+
active: { type: "boolean", filterable: true, required: false },
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
const screen: EntityListScreenDefinition = {
|
|
132
|
+
id: "unit-list",
|
|
133
|
+
type: "entityList",
|
|
134
|
+
entity: "unit",
|
|
135
|
+
columns: ["active"],
|
|
136
|
+
};
|
|
137
|
+
const schema: FeatureSchema = {
|
|
138
|
+
featureName: "units",
|
|
139
|
+
entities: { unit: entity },
|
|
140
|
+
screens: [screen],
|
|
141
|
+
} as FeatureSchema;
|
|
142
|
+
|
|
143
|
+
renderScreen(schema, "units:screen:unit-list");
|
|
144
|
+
|
|
145
|
+
await waitFor(() => expect(capturedProps).toBeDefined());
|
|
146
|
+
const props = getCapturedProps();
|
|
147
|
+
if (props === undefined) throw new Error("DataTable was not rendered");
|
|
148
|
+
expect(props.filterFacets).toHaveLength(1);
|
|
149
|
+
expect(props.filterFacets?.[0]?.field).toBe("active");
|
|
150
|
+
expect(props.filterFacets?.[0]?.options).toEqual([
|
|
151
|
+
{ value: "true", label: expect.any(String) },
|
|
152
|
+
{ value: "false", label: expect.any(String) },
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
const countBeforeToggle = queryCalls.length;
|
|
156
|
+
await act(async () => {
|
|
157
|
+
props.onFilterChange?.("active", ["true"]);
|
|
158
|
+
});
|
|
159
|
+
await waitFor(() => expect(queryCalls.length).toBeGreaterThan(countBeforeToggle));
|
|
160
|
+
const lastPayload = queryCalls[queryCalls.length - 1]?.payload;
|
|
161
|
+
expect(lastPayload).toMatchObject({
|
|
162
|
+
filters: [{ field: "active", op: "in", value: [true] }],
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("projectionList filter + facets (fw#2224)", () => {
|
|
168
|
+
test("screen.filter is sent verbatim in the query payload", async () => {
|
|
169
|
+
queryCalls = [];
|
|
170
|
+
const screen: ProjectionListScreenDefinition = {
|
|
171
|
+
id: "member-list",
|
|
172
|
+
type: "projectionList",
|
|
173
|
+
query: "ledger:query:member:list",
|
|
174
|
+
columns: ["status"],
|
|
175
|
+
filter: { field: "tier", op: "eq", value: "gold" },
|
|
176
|
+
};
|
|
177
|
+
const schema: FeatureSchema = {
|
|
178
|
+
featureName: "ledger",
|
|
179
|
+
entities: {},
|
|
180
|
+
screens: [screen],
|
|
181
|
+
} as FeatureSchema;
|
|
182
|
+
|
|
183
|
+
renderScreen(schema, "ledger:screen:member-list");
|
|
184
|
+
|
|
185
|
+
await waitFor(() => expect(queryCalls.length).toBeGreaterThan(0));
|
|
186
|
+
expect(queryCalls[0]?.payload).toMatchObject({
|
|
187
|
+
filter: { field: "tier", op: "eq", value: "gold" },
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("a boolean facet renders with the screen's explicit labels, and toggling it sends a coerced boolean in payload.filters", async () => {
|
|
192
|
+
queryCalls = [];
|
|
193
|
+
capturedProps = undefined;
|
|
194
|
+
const screen: ProjectionListScreenDefinition = {
|
|
195
|
+
id: "member-list",
|
|
196
|
+
type: "projectionList",
|
|
197
|
+
query: "ledger:query:member:list",
|
|
198
|
+
columns: ["active"],
|
|
199
|
+
facets: [
|
|
200
|
+
{
|
|
201
|
+
field: "active",
|
|
202
|
+
type: "boolean",
|
|
203
|
+
label: "Active",
|
|
204
|
+
trueLabel: "Active",
|
|
205
|
+
falseLabel: "Inactive",
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
const schema: FeatureSchema = {
|
|
210
|
+
featureName: "ledger",
|
|
211
|
+
entities: {},
|
|
212
|
+
screens: [screen],
|
|
213
|
+
} as FeatureSchema;
|
|
214
|
+
|
|
215
|
+
renderScreen(schema, "ledger:screen:member-list");
|
|
216
|
+
|
|
217
|
+
await waitFor(() => expect(capturedProps).toBeDefined());
|
|
218
|
+
const props = getCapturedProps();
|
|
219
|
+
if (props === undefined) throw new Error("DataTable was not rendered");
|
|
220
|
+
expect(props.filterFacets).toEqual([
|
|
221
|
+
{
|
|
222
|
+
field: "active",
|
|
223
|
+
label: "Active",
|
|
224
|
+
options: [
|
|
225
|
+
{ value: "true", label: "Active" },
|
|
226
|
+
{ value: "false", label: "Inactive" },
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
const countBeforeToggle = queryCalls.length;
|
|
232
|
+
await act(async () => {
|
|
233
|
+
props.onFilterChange?.("active", ["true"]);
|
|
234
|
+
});
|
|
235
|
+
await waitFor(() => expect(queryCalls.length).toBeGreaterThan(countBeforeToggle));
|
|
236
|
+
const lastPayload = queryCalls[queryCalls.length - 1]?.payload;
|
|
237
|
+
expect(lastPayload).toMatchObject({
|
|
238
|
+
filters: [{ field: "active", op: "in", value: [true] }],
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
EntityDefinition,
|
|
7
7
|
EntityEditScreenDefinition,
|
|
8
8
|
EntityListScreenDefinition,
|
|
9
|
+
ListFacetSpec,
|
|
9
10
|
ProjectionDetailScreenDefinition,
|
|
10
11
|
ProjectionListScreenDefinition,
|
|
11
12
|
RowAction,
|
|
@@ -24,7 +25,7 @@ import type {
|
|
|
24
25
|
SubmitResult,
|
|
25
26
|
Translate,
|
|
26
27
|
} from "@cosmicdrift/kumiko-headless";
|
|
27
|
-
import { fieldLabelKey } from "@cosmicdrift/kumiko-headless";
|
|
28
|
+
import { fieldLabelKey, fieldOptionLabelKey } from "@cosmicdrift/kumiko-headless";
|
|
28
29
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
29
30
|
import { extractCreatedId } from "../components/reference-create-dialog";
|
|
30
31
|
import { RenderEdit, type RenderEditAction } from "../components/render-edit";
|
|
@@ -858,6 +859,226 @@ export function buildListQueryPayload(state: {
|
|
|
858
859
|
return payload;
|
|
859
860
|
}
|
|
860
861
|
|
|
862
|
+
// One resolved facet, independent of where the type info came from — an
|
|
863
|
+
// entity field (entityList) or an explicit ListFacetSpec (projectionList,
|
|
864
|
+
// fw#2224). Shared by buildFilterFacets/buildFilterPayload below so both
|
|
865
|
+
// screen types build their query-payload filters and DataTable facet-UI
|
|
866
|
+
// through the same code, instead of two copies that can drift.
|
|
867
|
+
type ResolvedFacetSpec = {
|
|
868
|
+
readonly field: string;
|
|
869
|
+
readonly type: "select" | "boolean";
|
|
870
|
+
readonly label: string;
|
|
871
|
+
readonly options: readonly { readonly value: string; readonly label: string }[];
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
function buildFilterFacets(specs: readonly ResolvedFacetSpec[]): DataTableFacet[] {
|
|
875
|
+
return specs.map((spec) => ({ field: spec.field, label: spec.label, options: spec.options }));
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// User-selected faceted filters from URL-state → payload.filters. Boolean
|
|
879
|
+
// fields coerce "true"/"false" strings to real booleans (DB column is
|
|
880
|
+
// boolean); everything else stays string[] under op:"in" (multi-select
|
|
881
|
+
// semantics). `typeOf` resolves a field to its known type string —
|
|
882
|
+
// undefined means "unknown field", so it's dropped (typo-safe: a stale/
|
|
883
|
+
// hand-crafted URL param for an undeclared field never reaches the
|
|
884
|
+
// server). Deliberately NOT gated on the field being a *facet* — entityList
|
|
885
|
+
// passes through any field present in entity.fields, matching its
|
|
886
|
+
// pre-fw#2224 behavior; only the boolean-coercion branch cares about type.
|
|
887
|
+
function buildFilterPayload(
|
|
888
|
+
urlFilters: Readonly<Record<string, readonly string[]>>,
|
|
889
|
+
typeOf: (field: string) => string | undefined,
|
|
890
|
+
): { field: string; op: "in"; value: unknown }[] {
|
|
891
|
+
const out: { field: string; op: "in"; value: unknown }[] = [];
|
|
892
|
+
for (const [field, values] of Object.entries(urlFilters)) {
|
|
893
|
+
if (values.length === 0) continue;
|
|
894
|
+
// `id` is a base column (not a declared facet), allowed as an id-set
|
|
895
|
+
// filter so a header-slot control — e.g. the tags TagFilter — can narrow
|
|
896
|
+
// ANY list to a resolved set of row ids without the host declaring a facet.
|
|
897
|
+
if (field === "id") {
|
|
898
|
+
out.push({ field, op: "in", value: values });
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
const type = typeOf(field);
|
|
902
|
+
if (type === undefined) continue;
|
|
903
|
+
const value = type === "boolean" ? values.map((v) => v === "true") : values;
|
|
904
|
+
out.push({ field, op: "in", value });
|
|
905
|
+
}
|
|
906
|
+
return out;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// entityList adapter — one DataTableFacet per filterable select/boolean
|
|
910
|
+
// entity field, labels via the standard field/option i18n convention.
|
|
911
|
+
function resolveEntityFacetSpecs(
|
|
912
|
+
fields: Readonly<Record<string, unknown>>,
|
|
913
|
+
featureName: string,
|
|
914
|
+
entityName: string,
|
|
915
|
+
translate: Translate,
|
|
916
|
+
): ResolvedFacetSpec[] {
|
|
917
|
+
const out: ResolvedFacetSpec[] = [];
|
|
918
|
+
for (const [field, rawDef] of Object.entries(fields)) {
|
|
919
|
+
// entity.fields ist am Renderer-Layer schwach getypt (Record<string,
|
|
920
|
+
// unknown>, vom Schema deserialisiert) — Boundary-Cast wie buildInitialValues.
|
|
921
|
+
const def = rawDef as { type?: string; filterable?: boolean; options?: readonly string[] };
|
|
922
|
+
if (def.filterable !== true) continue;
|
|
923
|
+
const label = translate(fieldLabelKey(featureName, entityName, field));
|
|
924
|
+
if (def.type === "select" && Array.isArray(def.options)) {
|
|
925
|
+
out.push({
|
|
926
|
+
field,
|
|
927
|
+
type: "select",
|
|
928
|
+
label,
|
|
929
|
+
options: def.options.map((value) => ({
|
|
930
|
+
value,
|
|
931
|
+
label: translate(fieldOptionLabelKey(featureName, entityName, field, value)),
|
|
932
|
+
})),
|
|
933
|
+
});
|
|
934
|
+
} else if (def.type === "boolean") {
|
|
935
|
+
out.push({
|
|
936
|
+
field,
|
|
937
|
+
type: "boolean",
|
|
938
|
+
label,
|
|
939
|
+
options: [
|
|
940
|
+
{
|
|
941
|
+
value: "true",
|
|
942
|
+
label: translate(fieldOptionLabelKey(featureName, entityName, field, "true")),
|
|
943
|
+
},
|
|
944
|
+
{
|
|
945
|
+
value: "false",
|
|
946
|
+
label: translate(fieldOptionLabelKey(featureName, entityName, field, "false")),
|
|
947
|
+
},
|
|
948
|
+
],
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
return out;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// projectionList adapter — a projectionList has no entity/i18n convention to
|
|
956
|
+
// derive labels from, so ListFacetSpec carries every label explicitly
|
|
957
|
+
// (fw#2224); this just reshapes it into the same ResolvedFacetSpec the
|
|
958
|
+
// entityList adapter produces.
|
|
959
|
+
function resolveProjectionFacetSpecs(
|
|
960
|
+
facets: readonly ListFacetSpec[] | undefined,
|
|
961
|
+
): ResolvedFacetSpec[] {
|
|
962
|
+
if (facets === undefined) return [];
|
|
963
|
+
return facets.map((facet) =>
|
|
964
|
+
facet.type === "select"
|
|
965
|
+
? { field: facet.field, type: "select", label: facet.label, options: facet.options }
|
|
966
|
+
: {
|
|
967
|
+
field: facet.field,
|
|
968
|
+
type: "boolean",
|
|
969
|
+
label: facet.label,
|
|
970
|
+
options: [
|
|
971
|
+
{ value: "true", label: facet.trueLabel },
|
|
972
|
+
{ value: "false", label: facet.falseLabel },
|
|
973
|
+
],
|
|
974
|
+
},
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// ---- toolbarAction kind:"drawer" (fw#2225) ----
|
|
979
|
+
//
|
|
980
|
+
// Shared between EntityListBody and ProjectionListBody: state for "which
|
|
981
|
+
// drawer-kind toolbar action is currently open" plus a host component that
|
|
982
|
+
// mounts the referenced actionForm inside the Drawer primitive. Reuses
|
|
983
|
+
// ActionFormBody (no second, parallel form renderer) with onSuccess/
|
|
984
|
+
// onCancelOverride so submit-success closes the drawer + refetches the
|
|
985
|
+
// list instead of navigating, mirroring what a full-page actionForm would
|
|
986
|
+
// do via `redirect`.
|
|
987
|
+
type ToolbarDrawerAction = Extract<ToolbarAction, { kind: "drawer" }>;
|
|
988
|
+
|
|
989
|
+
function useToolbarDrawerAction(schema: FeatureSchema): {
|
|
990
|
+
readonly drawerAction: ToolbarDrawerAction | null;
|
|
991
|
+
readonly drawerScreen: ActionFormScreenDefinition | undefined;
|
|
992
|
+
readonly openDrawer: (action: ToolbarDrawerAction) => void;
|
|
993
|
+
readonly closeDrawer: () => void;
|
|
994
|
+
} {
|
|
995
|
+
const [drawerAction, setDrawerAction] = useState<ToolbarDrawerAction | null>(null);
|
|
996
|
+
const drawerScreen = useMemo(() => {
|
|
997
|
+
if (drawerAction === null) return undefined;
|
|
998
|
+
// Same same-feature, short-id resolution as runNavigate — the drawer's
|
|
999
|
+
// `screen` reference is scoped to schema.screens, not cross-feature.
|
|
1000
|
+
return schema.screens.find(
|
|
1001
|
+
(s): s is ActionFormScreenDefinition =>
|
|
1002
|
+
s.type === "actionForm" && lastSegment(s.id) === drawerAction.screen,
|
|
1003
|
+
);
|
|
1004
|
+
}, [drawerAction, schema.screens]);
|
|
1005
|
+
const openDrawer = useCallback((action: ToolbarDrawerAction) => setDrawerAction(action), []);
|
|
1006
|
+
const closeDrawer = useCallback(() => setDrawerAction(null), []);
|
|
1007
|
+
return { drawerAction, drawerScreen, openDrawer, closeDrawer };
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function ToolbarDrawerHost({
|
|
1011
|
+
schema,
|
|
1012
|
+
drawerAction,
|
|
1013
|
+
drawerScreen,
|
|
1014
|
+
userRoles,
|
|
1015
|
+
translate,
|
|
1016
|
+
onClose,
|
|
1017
|
+
onSuccess,
|
|
1018
|
+
}: {
|
|
1019
|
+
readonly schema: FeatureSchema;
|
|
1020
|
+
readonly drawerAction: ToolbarDrawerAction | null;
|
|
1021
|
+
readonly drawerScreen: ActionFormScreenDefinition | undefined;
|
|
1022
|
+
readonly userRoles: readonly string[] | undefined;
|
|
1023
|
+
readonly translate?: Translate;
|
|
1024
|
+
readonly onClose: () => void;
|
|
1025
|
+
readonly onSuccess: () => void;
|
|
1026
|
+
}): ReactNode {
|
|
1027
|
+
const { Drawer, Banner, Text } = usePrimitives();
|
|
1028
|
+
const t = useTranslation();
|
|
1029
|
+
const effectiveTranslate = translate ?? t;
|
|
1030
|
+
// Drawer is an optional Core-Primitive (additive rollout) — same "skip +
|
|
1031
|
+
// warn once" precedent as rowActions without a mounted DispatcherProvider
|
|
1032
|
+
// above, instead of crashing when a web app hasn't upgraded its
|
|
1033
|
+
// createKumikoApp wiring yet.
|
|
1034
|
+
useEffect(() => {
|
|
1035
|
+
if (drawerAction !== null && Drawer === undefined) {
|
|
1036
|
+
// biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
|
|
1037
|
+
console.warn(
|
|
1038
|
+
`[kumiko] toolbarAction "${drawerAction.id}" is kind:"drawer", but no <Drawer> primitive is registered — it will not open. createKumikoApp() from kumiko-renderer-web wires it automatically.`,
|
|
1039
|
+
);
|
|
1040
|
+
}
|
|
1041
|
+
}, [drawerAction, Drawer]);
|
|
1042
|
+
|
|
1043
|
+
if (drawerAction === null || Drawer === undefined) return null;
|
|
1044
|
+
|
|
1045
|
+
// Access mirrors kind:"navigate" exactly: the toolbar button itself stays
|
|
1046
|
+
// visible either way (same as navigate — access is enforced at the
|
|
1047
|
+
// target, not by hiding the trigger), but the drawer shows the same
|
|
1048
|
+
// "Access denied" state KumikoScreen's top-level gate would show for a
|
|
1049
|
+
// direct hit on that screen, never the form.
|
|
1050
|
+
const allowed = screenAccessAllows(drawerScreen?.access, userRoles);
|
|
1051
|
+
|
|
1052
|
+
return (
|
|
1053
|
+
<Drawer
|
|
1054
|
+
open={true}
|
|
1055
|
+
onOpenChange={(open) => {
|
|
1056
|
+
if (!open) onClose();
|
|
1057
|
+
}}
|
|
1058
|
+
title={effectiveTranslate(drawerAction.label)}
|
|
1059
|
+
testId={`toolbar-drawer-${drawerAction.id}`}
|
|
1060
|
+
>
|
|
1061
|
+
{drawerScreen === undefined ? (
|
|
1062
|
+
<Banner padded variant="error" testId="kumiko-toolbar-drawer-not-found">
|
|
1063
|
+
Screen not found: <Text variant="code">{drawerAction.screen}</Text>
|
|
1064
|
+
</Banner>
|
|
1065
|
+
) : !allowed ? (
|
|
1066
|
+
<Banner padded variant="error" testId="kumiko-toolbar-drawer-access-denied">
|
|
1067
|
+
Access denied: <Text variant="code">{drawerAction.screen}</Text>
|
|
1068
|
+
</Banner>
|
|
1069
|
+
) : (
|
|
1070
|
+
<ActionFormBody
|
|
1071
|
+
schema={schema}
|
|
1072
|
+
screen={drawerScreen}
|
|
1073
|
+
{...(translate !== undefined && { translate })}
|
|
1074
|
+
onSuccess={onSuccess}
|
|
1075
|
+
onCancelOverride={onClose}
|
|
1076
|
+
/>
|
|
1077
|
+
)}
|
|
1078
|
+
</Drawer>
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
861
1082
|
function EntityListScreen({
|
|
862
1083
|
schema,
|
|
863
1084
|
screen,
|
|
@@ -911,6 +1132,8 @@ function EntityListBody({
|
|
|
911
1132
|
const { Banner } = usePrimitives();
|
|
912
1133
|
const queryType = entityQueryCommand(featureName, screen.entity, "list");
|
|
913
1134
|
const nav = useNav();
|
|
1135
|
+
const userRoles = useUserRoles();
|
|
1136
|
+
const { drawerAction, drawerScreen, openDrawer, closeDrawer } = useToolbarDrawerAction(schema);
|
|
914
1137
|
|
|
915
1138
|
// URL-State: sort/dir/q/page leben unter dem screen.id-Namespace
|
|
916
1139
|
// (`/orders?orders.sort=createdAt&orders.dir=desc&orders.q=acme`),
|
|
@@ -947,28 +1170,18 @@ function EntityListBody({
|
|
|
947
1170
|
}, [useInfinite, sortQKey]);
|
|
948
1171
|
|
|
949
1172
|
// User-gewählte Faceted-Filter aus dem URL-State → payload.filters.
|
|
950
|
-
//
|
|
951
|
-
//
|
|
952
|
-
const filterPayload = useMemo(
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
}
|
|
963
|
-
// entity.fields ist am Renderer-Layer schwach getypt (vom Schema
|
|
964
|
-
// deserialisiert) — Boundary-Cast wie buildInitialValues.
|
|
965
|
-
const def = entity.fields[field] as { type?: string } | undefined;
|
|
966
|
-
if (def === undefined) continue;
|
|
967
|
-
const value = def.type === "boolean" ? values.map((v) => v === "true") : values;
|
|
968
|
-
out.push({ field, op: "in", value });
|
|
969
|
-
}
|
|
970
|
-
return out;
|
|
971
|
-
}, [urlState.filters, entity.fields]);
|
|
1173
|
+
// typeOf liest den Field-Type direkt aus entity.fields (nicht gated auf
|
|
1174
|
+
// filterable — bewusst permissiv, siehe buildFilterPayload-Doc).
|
|
1175
|
+
const filterPayload = useMemo(
|
|
1176
|
+
() =>
|
|
1177
|
+
buildFilterPayload(urlState.filters, (field) => {
|
|
1178
|
+
// entity.fields ist am Renderer-Layer schwach getypt (vom Schema
|
|
1179
|
+
// deserialisiert) — Boundary-Cast wie buildInitialValues.
|
|
1180
|
+
const def = entity.fields[field] as { type?: string } | undefined;
|
|
1181
|
+
return def?.type;
|
|
1182
|
+
}),
|
|
1183
|
+
[urlState.filters, entity.fields],
|
|
1184
|
+
);
|
|
972
1185
|
|
|
973
1186
|
// Entity-only additions (screen.filter, faceted filters) layer on top of
|
|
974
1187
|
// the shared buildListQueryPayload — projectionList has neither.
|
|
@@ -1052,52 +1265,13 @@ function EntityListBody({
|
|
|
1052
1265
|
// Faceted-Filter: ein Dropdown pro filterable select/boolean-Feld.
|
|
1053
1266
|
// Labels + select-Option-Labels über dieselbe i18n-Konvention wie die
|
|
1054
1267
|
// Spalten-Header (fieldLabelKey / :option:<value>).
|
|
1055
|
-
const filterFacets = useMemo<DataTableFacet[]>(
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
filterable?: boolean;
|
|
1063
|
-
options?: readonly string[];
|
|
1064
|
-
};
|
|
1065
|
-
if (def.filterable !== true) continue;
|
|
1066
|
-
const label = effectiveTranslate(fieldLabelKey(featureName, screen.entity, field));
|
|
1067
|
-
if (def.type === "select" && Array.isArray(def.options)) {
|
|
1068
|
-
out.push({
|
|
1069
|
-
field,
|
|
1070
|
-
label,
|
|
1071
|
-
options: def.options.map((value) => ({
|
|
1072
|
-
value,
|
|
1073
|
-
label: effectiveTranslate(
|
|
1074
|
-
`${featureName}:entity:${screen.entity}:field:${field}:option:${value}`,
|
|
1075
|
-
),
|
|
1076
|
-
})),
|
|
1077
|
-
});
|
|
1078
|
-
} else if (def.type === "boolean") {
|
|
1079
|
-
out.push({
|
|
1080
|
-
field,
|
|
1081
|
-
label,
|
|
1082
|
-
options: [
|
|
1083
|
-
{
|
|
1084
|
-
value: "true",
|
|
1085
|
-
label: effectiveTranslate(
|
|
1086
|
-
`${featureName}:entity:${screen.entity}:field:${field}:option:true`,
|
|
1087
|
-
),
|
|
1088
|
-
},
|
|
1089
|
-
{
|
|
1090
|
-
value: "false",
|
|
1091
|
-
label: effectiveTranslate(
|
|
1092
|
-
`${featureName}:entity:${screen.entity}:field:${field}:option:false`,
|
|
1093
|
-
),
|
|
1094
|
-
},
|
|
1095
|
-
],
|
|
1096
|
-
});
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
|
-
return out;
|
|
1100
|
-
}, [entity.fields, featureName, screen.entity, effectiveTranslate]);
|
|
1268
|
+
const filterFacets = useMemo<DataTableFacet[]>(
|
|
1269
|
+
() =>
|
|
1270
|
+
buildFilterFacets(
|
|
1271
|
+
resolveEntityFacetSpecs(entity.fields, featureName, screen.entity, effectiveTranslate),
|
|
1272
|
+
),
|
|
1273
|
+
[entity.fields, featureName, screen.entity, effectiveTranslate],
|
|
1274
|
+
);
|
|
1101
1275
|
|
|
1102
1276
|
// Soft-Dispatcher: in Tests die ohne DispatcherProvider mounten,
|
|
1103
1277
|
// bleibt rowActions undefined statt zu crashen. Echte Apps haben
|
|
@@ -1235,6 +1409,14 @@ function EntityListBody({
|
|
|
1235
1409
|
onTrigger: () => nav.navigate({ screenId: action.screen }),
|
|
1236
1410
|
};
|
|
1237
1411
|
}
|
|
1412
|
+
if (action.kind === "drawer") {
|
|
1413
|
+
return {
|
|
1414
|
+
id: action.id,
|
|
1415
|
+
label: effectiveTranslate(action.label),
|
|
1416
|
+
...(action.style !== undefined && { style: action.style }),
|
|
1417
|
+
onTrigger: () => openDrawer(action),
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1238
1420
|
// writeHandler — braucht Dispatcher. Wenn keiner mounted ist,
|
|
1239
1421
|
// skippen wir die Action statt zu crashen (gleiche Logik wie
|
|
1240
1422
|
// bei rowActions; einmaliger Warn-Log dort reicht).
|
|
@@ -1265,7 +1447,7 @@ function EntityListBody({
|
|
|
1265
1447
|
};
|
|
1266
1448
|
})
|
|
1267
1449
|
.filter((a: ToolbarActionButton | null): a is ToolbarActionButton => a !== null);
|
|
1268
|
-
}, [screen.toolbarActions, effectiveTranslate, nav, dispatcher, rowsQuery.refetch]);
|
|
1450
|
+
}, [screen.toolbarActions, effectiveTranslate, nav, dispatcher, rowsQuery.refetch, openDrawer]);
|
|
1269
1451
|
|
|
1270
1452
|
if (rowsQuery.loading && rowsQuery.data === null) {
|
|
1271
1453
|
return (
|
|
@@ -1329,34 +1511,48 @@ function EntityListBody({
|
|
|
1329
1511
|
const renderRows = useInfinite ? accumulated : (rowsQuery.data?.rows ?? []);
|
|
1330
1512
|
|
|
1331
1513
|
return (
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
filterFacets
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1514
|
+
<>
|
|
1515
|
+
<RenderList
|
|
1516
|
+
screen={screen}
|
|
1517
|
+
entity={entity}
|
|
1518
|
+
rows={renderRows}
|
|
1519
|
+
featureName={featureName}
|
|
1520
|
+
searchable={searchable}
|
|
1521
|
+
searchValue={urlState.q}
|
|
1522
|
+
onSearchChange={urlState.setQ}
|
|
1523
|
+
sort={effectiveSort}
|
|
1524
|
+
onSortChange={urlState.setSort}
|
|
1525
|
+
{...(pager !== undefined && { pager })}
|
|
1526
|
+
{...(rowActions !== undefined && { rowActions })}
|
|
1527
|
+
{...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions })}
|
|
1528
|
+
{...(useInfinite && {
|
|
1529
|
+
onReachEnd: loadMore,
|
|
1530
|
+
loadingMore: rowsQuery.loading,
|
|
1531
|
+
hasMore,
|
|
1532
|
+
})}
|
|
1533
|
+
{...(onCreate !== undefined && { onCreate })}
|
|
1534
|
+
{...(translate !== undefined && { translate })}
|
|
1535
|
+
{...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
|
|
1536
|
+
{...(filterFacets.length > 0 && {
|
|
1537
|
+
filterFacets,
|
|
1538
|
+
filterValues: urlState.filters,
|
|
1539
|
+
onFilterChange: urlState.setFilter,
|
|
1540
|
+
onFilterReset: urlState.clearFilters,
|
|
1541
|
+
})}
|
|
1542
|
+
/>
|
|
1543
|
+
<ToolbarDrawerHost
|
|
1544
|
+
schema={schema}
|
|
1545
|
+
drawerAction={drawerAction}
|
|
1546
|
+
drawerScreen={drawerScreen}
|
|
1547
|
+
userRoles={userRoles}
|
|
1548
|
+
{...(translate !== undefined && { translate })}
|
|
1549
|
+
onClose={closeDrawer}
|
|
1550
|
+
onSuccess={() => {
|
|
1551
|
+
closeDrawer();
|
|
1552
|
+
void rowsQuery.refetch();
|
|
1553
|
+
}}
|
|
1554
|
+
/>
|
|
1555
|
+
</>
|
|
1360
1556
|
);
|
|
1361
1557
|
}
|
|
1362
1558
|
|
|
@@ -1385,6 +1581,8 @@ function ProjectionListBody({
|
|
|
1385
1581
|
const nav = useNav();
|
|
1386
1582
|
const dispatcher = useOptionalDispatcher();
|
|
1387
1583
|
const effectiveTranslate = translate ?? t;
|
|
1584
|
+
const userRoles = useUserRoles();
|
|
1585
|
+
const { drawerAction, drawerScreen, openDrawer, closeDrawer } = useToolbarDrawerAction(schema);
|
|
1388
1586
|
|
|
1389
1587
|
// searchable/sortable/paginated are derived at buildAppSchema time from the
|
|
1390
1588
|
// query handler's Zod schema (fw#2165) — not authored on the screen.
|
|
@@ -1408,22 +1606,56 @@ function ProjectionListBody({
|
|
|
1408
1606
|
// "infinite" on a paginated projectionList is silently a no-op today.
|
|
1409
1607
|
const usePager = paginated && (screen.pagination ?? "pages") === "pages";
|
|
1410
1608
|
|
|
1411
|
-
|
|
1609
|
+
// Facets (fw#2224) — a projectionList has no entity, so screen.facets is
|
|
1610
|
+
// the only field inventory; resolveProjectionFacetSpecs reshapes its
|
|
1611
|
+
// explicit labels into the same ResolvedFacetSpec the entityList adapter
|
|
1612
|
+
// produces, so both feed the same buildFilterFacets/buildFilterPayload.
|
|
1613
|
+
const facetSpecs = useMemo(() => resolveProjectionFacetSpecs(screen.facets), [screen.facets]);
|
|
1614
|
+
const filterPayload = useMemo(
|
|
1412
1615
|
() =>
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
page: urlState.page,
|
|
1419
|
-
useInfinite: false,
|
|
1420
|
-
cursor: undefined,
|
|
1421
|
-
}),
|
|
1422
|
-
[limit, activeSearch, activeSort, usePager, urlState.page],
|
|
1616
|
+
buildFilterPayload(
|
|
1617
|
+
urlState.filters,
|
|
1618
|
+
(field) => facetSpecs.find((spec) => spec.field === field)?.type,
|
|
1619
|
+
),
|
|
1620
|
+
[urlState.filters, facetSpecs],
|
|
1423
1621
|
);
|
|
1424
1622
|
|
|
1623
|
+
const queryPayload = useMemo(() => {
|
|
1624
|
+
const payload = buildListQueryPayload({
|
|
1625
|
+
limit,
|
|
1626
|
+
search: activeSearch,
|
|
1627
|
+
sort: activeSort,
|
|
1628
|
+
usePager,
|
|
1629
|
+
page: urlState.page,
|
|
1630
|
+
useInfinite: false,
|
|
1631
|
+
cursor: undefined,
|
|
1632
|
+
});
|
|
1633
|
+
// Gated on screen.filter/screen.facets being declared (not just on
|
|
1634
|
+
// urlState/filterPayload happening to carry a value) — same rule as
|
|
1635
|
+
// activeSearch/activeSort above: a stale URL must not smuggle a param
|
|
1636
|
+
// the bound query's Zod schema doesn't accept (fw#2165 pattern).
|
|
1637
|
+
if (screen.filter !== undefined) {
|
|
1638
|
+
payload["filter"] = screen.filter;
|
|
1639
|
+
}
|
|
1640
|
+
if (screen.facets !== undefined && filterPayload.length > 0) {
|
|
1641
|
+
payload["filters"] = filterPayload;
|
|
1642
|
+
}
|
|
1643
|
+
return payload;
|
|
1644
|
+
}, [
|
|
1645
|
+
limit,
|
|
1646
|
+
activeSearch,
|
|
1647
|
+
activeSort,
|
|
1648
|
+
usePager,
|
|
1649
|
+
urlState.page,
|
|
1650
|
+
screen.filter,
|
|
1651
|
+
screen.facets,
|
|
1652
|
+
filterPayload,
|
|
1653
|
+
]);
|
|
1654
|
+
|
|
1425
1655
|
const rowsQuery = useQuery<PagedRows>(screen.query, queryPayload, { live: true });
|
|
1426
1656
|
|
|
1657
|
+
const filterFacets = useMemo<DataTableFacet[]>(() => buildFilterFacets(facetSpecs), [facetSpecs]);
|
|
1658
|
+
|
|
1427
1659
|
const runNavigate = useCallback(
|
|
1428
1660
|
(action: RowActionNavigate, row: ListRowViewModel) => {
|
|
1429
1661
|
const entityId =
|
|
@@ -1516,6 +1748,15 @@ function ProjectionListBody({
|
|
|
1516
1748
|
});
|
|
1517
1749
|
continue;
|
|
1518
1750
|
}
|
|
1751
|
+
if (action.kind === "drawer") {
|
|
1752
|
+
out.push({
|
|
1753
|
+
id: action.id,
|
|
1754
|
+
label: effectiveTranslate(action.label),
|
|
1755
|
+
...(action.style !== undefined && { style: action.style }),
|
|
1756
|
+
onTrigger: () => openDrawer(action),
|
|
1757
|
+
});
|
|
1758
|
+
continue;
|
|
1759
|
+
}
|
|
1519
1760
|
// writeHandler — analog entityList; ohne Dispatcher skippen statt crashen.
|
|
1520
1761
|
if (dispatcher === undefined) continue;
|
|
1521
1762
|
out.push({
|
|
@@ -1543,7 +1784,7 @@ function ProjectionListBody({
|
|
|
1543
1784
|
});
|
|
1544
1785
|
}
|
|
1545
1786
|
return out.length > 0 ? out : undefined;
|
|
1546
|
-
}, [screen.toolbarActions, effectiveTranslate, nav, dispatcher, rowsQuery.refetch]);
|
|
1787
|
+
}, [screen.toolbarActions, effectiveTranslate, nav, dispatcher, rowsQuery.refetch, openDrawer]);
|
|
1547
1788
|
|
|
1548
1789
|
if (rowsQuery.loading && rowsQuery.data === null) {
|
|
1549
1790
|
return (
|
|
@@ -1594,22 +1835,42 @@ function ProjectionListBody({
|
|
|
1594
1835
|
: undefined;
|
|
1595
1836
|
|
|
1596
1837
|
return (
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1838
|
+
<>
|
|
1839
|
+
<RenderList
|
|
1840
|
+
screen={listScreen}
|
|
1841
|
+
entity={entity}
|
|
1842
|
+
rows={rowsQuery.data?.rows ?? []}
|
|
1843
|
+
featureName={schema.featureName}
|
|
1844
|
+
searchable={searchable}
|
|
1845
|
+
searchValue={urlState.q}
|
|
1846
|
+
onSearchChange={urlState.setQ}
|
|
1847
|
+
sort={activeSort}
|
|
1848
|
+
onSortChange={urlState.setSort}
|
|
1849
|
+
{...(pager !== undefined && { pager })}
|
|
1850
|
+
{...(rowActions !== undefined && { rowActions })}
|
|
1851
|
+
{...(toolbarActions !== undefined && { toolbarActions })}
|
|
1852
|
+
{...(translate !== undefined && { translate })}
|
|
1853
|
+
{...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
|
|
1854
|
+
{...(filterFacets.length > 0 && {
|
|
1855
|
+
filterFacets,
|
|
1856
|
+
filterValues: urlState.filters,
|
|
1857
|
+
onFilterChange: urlState.setFilter,
|
|
1858
|
+
onFilterReset: urlState.clearFilters,
|
|
1859
|
+
})}
|
|
1860
|
+
/>
|
|
1861
|
+
<ToolbarDrawerHost
|
|
1862
|
+
schema={schema}
|
|
1863
|
+
drawerAction={drawerAction}
|
|
1864
|
+
drawerScreen={drawerScreen}
|
|
1865
|
+
userRoles={userRoles}
|
|
1866
|
+
{...(translate !== undefined && { translate })}
|
|
1867
|
+
onClose={closeDrawer}
|
|
1868
|
+
onSuccess={() => {
|
|
1869
|
+
closeDrawer();
|
|
1870
|
+
void rowsQuery.refetch();
|
|
1871
|
+
}}
|
|
1872
|
+
/>
|
|
1873
|
+
</>
|
|
1613
1874
|
);
|
|
1614
1875
|
}
|
|
1615
1876
|
|
|
@@ -1833,6 +2094,8 @@ function ProjectionDetailBody({
|
|
|
1833
2094
|
onCancel={screen.listScreenId !== undefined ? navigateToList : undefined}
|
|
1834
2095
|
{...(headerActions !== undefined && { actions: headerActions })}
|
|
1835
2096
|
{...(translate !== undefined && { translate })}
|
|
2097
|
+
valueDisplay={screen.valueDisplay ?? "text"}
|
|
2098
|
+
hideActions={screen.hideActions === true}
|
|
1836
2099
|
/>
|
|
1837
2100
|
);
|
|
1838
2101
|
}
|
|
@@ -1849,10 +2112,21 @@ function ActionFormBody({
|
|
|
1849
2112
|
schema,
|
|
1850
2113
|
screen,
|
|
1851
2114
|
translate,
|
|
2115
|
+
onSuccess,
|
|
2116
|
+
onCancelOverride,
|
|
1852
2117
|
}: {
|
|
1853
2118
|
readonly schema: FeatureSchema;
|
|
1854
2119
|
readonly screen: ActionFormScreenDefinition;
|
|
1855
2120
|
readonly translate?: Translate;
|
|
2121
|
+
/** Drawer-hosted usage (toolbarAction kind:"drawer", fw#2225): called
|
|
2122
|
+
* instead of the redirect-based navigation on successful submit, so the
|
|
2123
|
+
* host closes the drawer + refetches its list regardless of whether
|
|
2124
|
+
* `screen.redirect` is set — a full-page redirect would navigate away
|
|
2125
|
+
* from the list the drawer sits on top of. */
|
|
2126
|
+
readonly onSuccess?: () => void;
|
|
2127
|
+
/** Drawer-hosted usage: replaces the cancelTarget/redirect-based Cancel
|
|
2128
|
+
* handler so Cancel closes the drawer instead of navigating. */
|
|
2129
|
+
readonly onCancelOverride?: () => void;
|
|
1856
2130
|
}): ReactNode {
|
|
1857
2131
|
const nav = useNav();
|
|
1858
2132
|
const synthEntity = useMemo(() => synthesizeActionFormEntity(screen.fields), [screen.fields]);
|
|
@@ -1868,24 +2142,30 @@ function ActionFormBody({
|
|
|
1868
2142
|
);
|
|
1869
2143
|
const handleSubmitted = useCallback(
|
|
1870
2144
|
(result: SubmitResult<unknown>) => {
|
|
2145
|
+
if (!result.isSuccess) return;
|
|
2146
|
+
if (onSuccess !== undefined) {
|
|
2147
|
+
onSuccess();
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
1871
2150
|
// Redirect ist optional. Bei isSuccess + redirect → nav.navigate.
|
|
1872
2151
|
// Author entscheidet bewusst ob "stay on form" (default) oder
|
|
1873
2152
|
// "back to list" (typisch bei Create-style Aktionen).
|
|
1874
|
-
if (
|
|
2153
|
+
if (screen.redirect !== undefined) {
|
|
1875
2154
|
nav.navigate({ screenId: lastSegment(screen.redirect) });
|
|
1876
2155
|
}
|
|
1877
2156
|
},
|
|
1878
|
-
[nav, screen.redirect],
|
|
2157
|
+
[nav, screen.redirect, onSuccess],
|
|
1879
2158
|
);
|
|
1880
2159
|
// Cancel ist nur sinnvoll wenn ein Navigations-Ziel existiert —
|
|
1881
2160
|
// sonst hätte der Button nirgendwo hin zu navigieren. cancelTarget
|
|
1882
2161
|
// gewinnt über redirect; `false` schaltet den Button explizit ab
|
|
1883
2162
|
// (Single-Action-Screens, wo Cancel nur Submit-ohne-Senden wäre).
|
|
1884
2163
|
const handleCancel = useMemo<(() => void) | undefined>(() => {
|
|
2164
|
+
if (onCancelOverride !== undefined) return onCancelOverride;
|
|
1885
2165
|
const target = screen.cancelTarget ?? screen.redirect;
|
|
1886
2166
|
if (target === undefined || target === false) return undefined;
|
|
1887
2167
|
return () => nav.navigate({ screenId: lastSegment(target) });
|
|
1888
|
-
}, [nav, screen.redirect, screen.cancelTarget]);
|
|
2168
|
+
}, [nav, screen.redirect, screen.cancelTarget, onCancelOverride]);
|
|
1889
2169
|
return (
|
|
1890
2170
|
<RenderEdit
|
|
1891
2171
|
screen={synthScreen}
|
|
@@ -210,6 +210,13 @@ export type RenderEditProps<TValues extends FormValues, TCtx = unknown> = {
|
|
|
210
210
|
* `onControlsReady`'s `submit`. Omitting this prop keeps unchanged
|
|
211
211
|
* behavior. */
|
|
212
212
|
readonly hideActions?: boolean;
|
|
213
|
+
/** "form" (default) — every field renders as its Input widget, disabled
|
|
214
|
+
* when `field.readOnly`, unchanged behavior. "text" renders a
|
|
215
|
+
* `field.readOnly` field as plain text instead of a disabled Input
|
|
216
|
+
* (ProjectionDetailBody's read view, fw#2245) — editable fields are
|
|
217
|
+
* unaffected either way, so this only changes forms that already have
|
|
218
|
+
* readOnly fields. */
|
|
219
|
+
readonly valueDisplay?: "form" | "text";
|
|
213
220
|
};
|
|
214
221
|
|
|
215
222
|
export type RenderEditAction = {
|
|
@@ -427,6 +434,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
427
434
|
fields: fieldsFilter,
|
|
428
435
|
disabled = false,
|
|
429
436
|
hideActions,
|
|
437
|
+
valueDisplay = "form",
|
|
430
438
|
} = props;
|
|
431
439
|
const { customSubmit } = props;
|
|
432
440
|
// Translate-Fallback: wenn der Caller keine Translate-Fn übergibt,
|
|
@@ -1358,6 +1366,8 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
1358
1366
|
fieldAppendix: fieldAppendix(field.field),
|
|
1359
1367
|
})}
|
|
1360
1368
|
allIssues={snapshot.errors}
|
|
1369
|
+
valueDisplay={valueDisplay}
|
|
1370
|
+
row={snapshot.values}
|
|
1361
1371
|
/>
|
|
1362
1372
|
))}
|
|
1363
1373
|
</Grid>
|
|
@@ -1442,6 +1452,10 @@ type GridCellForFieldProps = {
|
|
|
1442
1452
|
/** Full issues-by-path map (FormSnapshot.errors) — passed through for
|
|
1443
1453
|
* embedded-list fields, which bucket row-/cell-level issues themselves. */
|
|
1444
1454
|
readonly allIssues: Readonly<Record<string, readonly FieldIssue[]>>;
|
|
1455
|
+
/** Passed through to RenderField unchanged — see RenderEditProps.valueDisplay. */
|
|
1456
|
+
readonly valueDisplay: "form" | "text";
|
|
1457
|
+
/** Passed through to RenderField as `row` — see RenderFieldProps.row. */
|
|
1458
|
+
readonly row: Readonly<Record<string, unknown>>;
|
|
1445
1459
|
};
|
|
1446
1460
|
|
|
1447
1461
|
function GridCellForField({
|
|
@@ -1454,6 +1468,8 @@ function GridCellForField({
|
|
|
1454
1468
|
labelAppendix,
|
|
1455
1469
|
fieldAppendix,
|
|
1456
1470
|
allIssues,
|
|
1471
|
+
valueDisplay,
|
|
1472
|
+
row,
|
|
1457
1473
|
}: GridCellForFieldProps): ReactNode {
|
|
1458
1474
|
// RenderField renders nothing for a hidden field, but the GridCell around it still claims the row.
|
|
1459
1475
|
if (!field.visible) return null;
|
|
@@ -1469,6 +1485,8 @@ function GridCellForField({
|
|
|
1469
1485
|
{...(labelAppendix !== undefined && { labelAppendix })}
|
|
1470
1486
|
{...(fieldAppendix !== undefined && { fieldAppendix })}
|
|
1471
1487
|
allIssues={allIssues}
|
|
1488
|
+
valueDisplay={valueDisplay}
|
|
1489
|
+
row={row}
|
|
1472
1490
|
/>
|
|
1473
1491
|
</GridCell>
|
|
1474
1492
|
);
|
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
import type { EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
1
|
import {
|
|
2
|
+
type EntityEditScreenDefinition,
|
|
3
|
+
type FieldRenderer,
|
|
4
|
+
isFormatSpec,
|
|
5
|
+
} from "@cosmicdrift/kumiko-framework/ui-types";
|
|
6
|
+
import {
|
|
7
|
+
applyFormatSpec,
|
|
3
8
|
currencyDecimals,
|
|
4
9
|
type EditFieldViewModel,
|
|
5
10
|
type FieldIssue,
|
|
6
11
|
} from "@cosmicdrift/kumiko-headless";
|
|
7
12
|
import { type ReactNode, useCallback, useMemo, useState } from "react";
|
|
8
13
|
import { useAppFeatures } from "../app/app-features-context";
|
|
14
|
+
import { useColumnRenderer } from "../app/column-renderers";
|
|
15
|
+
import { extensionSectionName } from "../app/extension-sections";
|
|
9
16
|
import { toKebab } from "../app/qn";
|
|
10
17
|
import { screenAccessAllows } from "../app/screen-access";
|
|
11
18
|
import { useUserRoles } from "../context/user-roles-context";
|
|
@@ -48,6 +55,17 @@ export type RenderFieldProps = {
|
|
|
48
55
|
* (`${field}.${rowIndex}` / `${field}.${rowIndex}.${cellField}`).
|
|
49
56
|
* Other field types ignore this prop. */
|
|
50
57
|
readonly allIssues?: Readonly<Record<string, readonly FieldIssue[]>>;
|
|
58
|
+
/** "form" (default) renders every field as its editable Input, disabled
|
|
59
|
+
* when `field.readOnly` — unchanged behavior. "text" renders a
|
|
60
|
+
* `field.readOnly` field as plain text instead (projectionDetail's read
|
|
61
|
+
* view, fw#2245); editable fields are untouched by this prop either way. */
|
|
62
|
+
readonly valueDisplay?: "form" | "text";
|
|
63
|
+
/** Current form values, keyed by field name — only consulted when
|
|
64
|
+
* `field.renderer` resolves to a `{ react: { __component } }` registry
|
|
65
|
+
* component, passed through as `ColumnRendererProps.row` (same contract
|
|
66
|
+
* as list-column renderers, fw#2245). Omitted → falls back to a
|
|
67
|
+
* single-key `{ [field.field]: field.value }` row. */
|
|
68
|
+
readonly row?: Readonly<Record<string, unknown>>;
|
|
51
69
|
};
|
|
52
70
|
|
|
53
71
|
export function RenderField({
|
|
@@ -58,6 +76,8 @@ export function RenderField({
|
|
|
58
76
|
labelAppendix,
|
|
59
77
|
fieldAppendix,
|
|
60
78
|
allIssues,
|
|
79
|
+
valueDisplay = "form",
|
|
80
|
+
row,
|
|
61
81
|
}: RenderFieldProps): ReactNode {
|
|
62
82
|
const { Field, Input, Banner, Text } = usePrimitives();
|
|
63
83
|
// App-Locale (i18n) für money/date-Inputs — sonst fielen sie auf
|
|
@@ -71,12 +91,24 @@ export function RenderField({
|
|
|
71
91
|
|
|
72
92
|
const id = inputId(field);
|
|
73
93
|
const hasError = issues !== undefined && issues.length > 0;
|
|
94
|
+
// An author-declared renderer always wins over the input widget, same as
|
|
95
|
+
// the list-column path (render-list.tsx). Only takes effect once the field
|
|
96
|
+
// is actually readOnly — a FormatSpec/PlatformComponent renderer has no
|
|
97
|
+
// editable widget of its own, so applying it to an editable field would
|
|
98
|
+
// silently make that field un-editable (fw#2245).
|
|
99
|
+
const readOnlyText = valueDisplay === "text" && field.readOnly && field.renderer === undefined;
|
|
74
100
|
|
|
75
101
|
// Reference-Field rendert eine eigene Component — sie nutzt
|
|
76
102
|
// useQuery() für den Live-Lookup, also muss sie als React-
|
|
77
103
|
// Komponente gemountet werden (nicht als pure render-Call).
|
|
78
104
|
const control =
|
|
79
|
-
field.
|
|
105
|
+
field.readOnly && field.renderer !== undefined ? (
|
|
106
|
+
<FieldRendererOutput
|
|
107
|
+
field={field}
|
|
108
|
+
renderer={field.renderer}
|
|
109
|
+
{...(row !== undefined && { row })}
|
|
110
|
+
/>
|
|
111
|
+
) : field.type === "embedded" && field.embeddedListCells !== undefined ? (
|
|
80
112
|
<EmbeddedListField
|
|
81
113
|
field={field}
|
|
82
114
|
id={id}
|
|
@@ -85,14 +117,20 @@ export function RenderField({
|
|
|
85
117
|
featureName={featureName ?? ""}
|
|
86
118
|
/>
|
|
87
119
|
) : field.type === "reference" ? (
|
|
88
|
-
|
|
89
|
-
field={field}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
120
|
+
readOnlyText ? (
|
|
121
|
+
<ReadOnlyReferenceValue field={field} featureName={featureName ?? ""} />
|
|
122
|
+
) : (
|
|
123
|
+
<ReferenceInput
|
|
124
|
+
field={field}
|
|
125
|
+
id={id}
|
|
126
|
+
hasError={hasError}
|
|
127
|
+
onChange={onChange}
|
|
128
|
+
Input={Input}
|
|
129
|
+
featureName={featureName ?? ""}
|
|
130
|
+
/>
|
|
131
|
+
)
|
|
132
|
+
) : readOnlyText && !isComplexFieldType(field.type) ? (
|
|
133
|
+
<Text testId={`field-value-${field.field}`}>{readOnlyDisplayText(field, appLocale)}</Text>
|
|
96
134
|
) : (
|
|
97
135
|
renderInput({ field, id, hasError, onChange, Input, appLocale, Banner, Text, t })
|
|
98
136
|
);
|
|
@@ -296,6 +334,87 @@ function inputId(field: EditFieldViewModel): string {
|
|
|
296
334
|
return `kumiko-edit-${field.field}`;
|
|
297
335
|
}
|
|
298
336
|
|
|
337
|
+
// Doesn't resolve the `string` (cross-feature QN) FieldRenderer variant — the
|
|
338
|
+
// list-column path (DataTableCell) doesn't either, so this stays in parity.
|
|
339
|
+
function FieldRendererOutput({
|
|
340
|
+
field,
|
|
341
|
+
renderer,
|
|
342
|
+
row,
|
|
343
|
+
}: {
|
|
344
|
+
readonly field: EditFieldViewModel;
|
|
345
|
+
readonly renderer: FieldRenderer;
|
|
346
|
+
readonly row?: Readonly<Record<string, unknown>>;
|
|
347
|
+
}): ReactNode {
|
|
348
|
+
const { Text } = usePrimitives();
|
|
349
|
+
const componentName =
|
|
350
|
+
!isFormatSpec(renderer) && typeof renderer === "object" && renderer !== null
|
|
351
|
+
? extensionSectionName(renderer)
|
|
352
|
+
: undefined;
|
|
353
|
+
const Component = useColumnRenderer(componentName);
|
|
354
|
+
if (isFormatSpec(renderer)) {
|
|
355
|
+
return (
|
|
356
|
+
<Text testId={`field-value-${field.field}`}>{applyFormatSpec(renderer, field.value)}</Text>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
if (componentName !== undefined) {
|
|
360
|
+
if (Component !== undefined) {
|
|
361
|
+
return (
|
|
362
|
+
<Component
|
|
363
|
+
value={field.value}
|
|
364
|
+
row={row ?? { [field.field]: field.value }}
|
|
365
|
+
column={{ field: field.field }}
|
|
366
|
+
/>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
// biome-ignore lint/suspicious/noConsole: dev-warning for a registry mismatch, mirrors DataTableCell's columnRenderer warning.
|
|
370
|
+
console.warn(`[kumiko] fieldRenderer "${componentName}" not registered`);
|
|
371
|
+
}
|
|
372
|
+
return <Text testId={`field-value-${field.field}`}>{stringValue(field.value)}</Text>;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Read-only text for `type: "reference"` — resolves the referenced row's
|
|
376
|
+
// label via the same lookup query as ReferenceInput's combobox, but shows
|
|
377
|
+
// plain text instead of mounting an (unusable, disabled) combobox.
|
|
378
|
+
function ReadOnlyReferenceValue({
|
|
379
|
+
field,
|
|
380
|
+
featureName,
|
|
381
|
+
}: {
|
|
382
|
+
readonly field: EditFieldViewModel;
|
|
383
|
+
readonly featureName: string;
|
|
384
|
+
}): ReactNode {
|
|
385
|
+
const { Text } = usePrimitives();
|
|
386
|
+
const refEntity = field.refEntity ?? "";
|
|
387
|
+
const refFeature = field.refFeature ?? featureName;
|
|
388
|
+
const labelField = field.refLabelField ?? "id";
|
|
389
|
+
const isMultiple = field.refMultiple === true;
|
|
390
|
+
const queryQn = `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`;
|
|
391
|
+
const queryResult = useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(queryQn, {
|
|
392
|
+
limit: REFERENCE_COMBOBOX_LIMIT,
|
|
393
|
+
});
|
|
394
|
+
const ids: readonly string[] = isMultiple
|
|
395
|
+
? Array.isArray(field.value)
|
|
396
|
+
? (field.value as readonly string[])
|
|
397
|
+
: []
|
|
398
|
+
: typeof field.value === "string" && field.value !== ""
|
|
399
|
+
? [field.value]
|
|
400
|
+
: [];
|
|
401
|
+
if (ids.length === 0) return <Text testId={`field-value-${field.field}`}>—</Text>;
|
|
402
|
+
const rows = queryResult.data?.rows ?? [];
|
|
403
|
+
const labels = ids.map((id) => {
|
|
404
|
+
const row = rows.find((r) => String(r["id"] ?? "") === id);
|
|
405
|
+
return row !== undefined ? String(row[labelField] ?? id) : id;
|
|
406
|
+
});
|
|
407
|
+
return <Text testId={`field-value-${field.field}`}>{labels.join(", ")}</Text>;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Field types whose read display already isn't a boxed "disabled input" look
|
|
411
|
+
// (embedded/jsonb render as an info Banner, files/images as an unsupported-
|
|
412
|
+
// type Banner) — `readOnlyDisplayText` doesn't cover these, they keep going
|
|
413
|
+
// through `renderInput`'s existing Banner branch in text-display mode too.
|
|
414
|
+
function isComplexFieldType(type: string): boolean {
|
|
415
|
+
return type === "embedded" || type === "jsonb" || type === "files" || type === "images";
|
|
416
|
+
}
|
|
417
|
+
|
|
299
418
|
// Dispatches field.type → Input-kind. Select threads options through
|
|
300
419
|
// from the EditFieldViewModel (computeEditViewModel pulls them from
|
|
301
420
|
// SelectFieldDef.options). Structural types without a widget (embedded,
|
|
@@ -592,3 +711,58 @@ function locatedValue(v: unknown): { at: string; tz: string; utc?: string } | ""
|
|
|
592
711
|
}
|
|
593
712
|
return "";
|
|
594
713
|
}
|
|
714
|
+
|
|
715
|
+
// Read-only text for a `field.readOnly` field without its own `renderer` —
|
|
716
|
+
// per-type formatting that reuses the same value-shaping helpers as the
|
|
717
|
+
// editable widgets above, so a text value and its would-be Input widget stay
|
|
718
|
+
// derived from the identical parse (fw#2245). `type: "reference"` isn't
|
|
719
|
+
// covered here — it needs a live label lookup, see ReadOnlyReferenceValue.
|
|
720
|
+
// `isComplexFieldType` types aren't covered either — callers keep those on
|
|
721
|
+
// `renderInput`'s existing Banner fallback.
|
|
722
|
+
function readOnlyDisplayText(field: EditFieldViewModel, appLocale: string): string {
|
|
723
|
+
const { type, value } = field;
|
|
724
|
+
if (value === undefined || value === null || value === "") return "—";
|
|
725
|
+
switch (type) {
|
|
726
|
+
case "boolean":
|
|
727
|
+
return applyFormatSpec({ format: "boolean" }, value);
|
|
728
|
+
case "date":
|
|
729
|
+
return applyFormatSpec({ format: "date", locale: field.dateLocale ?? appLocale }, value);
|
|
730
|
+
case "timestamp":
|
|
731
|
+
return applyFormatSpec({ format: "timestamp", locale: field.dateLocale ?? appLocale }, value);
|
|
732
|
+
case "locatedTimestamp": {
|
|
733
|
+
const located = locatedValue(value);
|
|
734
|
+
if (located === "" || located.at === "") return "—";
|
|
735
|
+
return applyFormatSpec(
|
|
736
|
+
{ format: "timestamp", locale: field.dateLocale ?? appLocale },
|
|
737
|
+
located.utc ?? located.at,
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
case "number":
|
|
741
|
+
case "bigInt":
|
|
742
|
+
case "decimal": {
|
|
743
|
+
const n = numberValue(value);
|
|
744
|
+
return n === "" ? "—" : new Intl.NumberFormat(appLocale).format(n);
|
|
745
|
+
}
|
|
746
|
+
case "money": {
|
|
747
|
+
const currency = field.currency ?? "EUR";
|
|
748
|
+
const minor = moneyMinorValue(value, currency);
|
|
749
|
+
if (minor === "") return "—";
|
|
750
|
+
const major = minor / 10 ** currencyDecimals(currency);
|
|
751
|
+
return new Intl.NumberFormat(appLocale, { style: "currency", currency }).format(major);
|
|
752
|
+
}
|
|
753
|
+
case "select":
|
|
754
|
+
case "multiSelect": {
|
|
755
|
+
const labels = field.optionLabels;
|
|
756
|
+
const values = Array.isArray(value) ? value : [value];
|
|
757
|
+
if (values.length === 0) return "—";
|
|
758
|
+
return values
|
|
759
|
+
.map((v) => (typeof v === "string" ? (labels?.[v] ?? v) : stringValue(v)))
|
|
760
|
+
.join(", ");
|
|
761
|
+
}
|
|
762
|
+
case "file":
|
|
763
|
+
case "image":
|
|
764
|
+
return typeof value === "string" ? value : "—";
|
|
765
|
+
default:
|
|
766
|
+
return stringValue(value);
|
|
767
|
+
}
|
|
768
|
+
}
|
package/src/index.ts
CHANGED
package/src/primitives.tsx
CHANGED
|
@@ -817,6 +817,20 @@ export type ModalProps = {
|
|
|
817
817
|
readonly testId?: string;
|
|
818
818
|
};
|
|
819
819
|
|
|
820
|
+
/** Side panel for hosting an existing self-contained form/widget (own
|
|
821
|
+
* submit/cancel buttons) without leaving the underlying screen — e.g. a
|
|
822
|
+
* toolbar action's `kind: "drawer"` mounts an actionForm here instead of
|
|
823
|
+
* navigating to a full page (fw#2225). Same "bare content shell, no
|
|
824
|
+
* footer buttons of its own" contract as `Modal`, slide-in instead of
|
|
825
|
+
* centered overlay. */
|
|
826
|
+
export type DrawerProps = {
|
|
827
|
+
readonly open: boolean;
|
|
828
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
829
|
+
readonly title?: string;
|
|
830
|
+
readonly children: ReactNode;
|
|
831
|
+
readonly testId?: string;
|
|
832
|
+
};
|
|
833
|
+
|
|
820
834
|
/** Image lightbox — full-size preview on click. Web renders Radix overlay;
|
|
821
835
|
* trigger (thumbnail) and open state live in the app. */
|
|
822
836
|
export type LightboxProps = {
|
|
@@ -947,6 +961,10 @@ export type CorePrimitives = {
|
|
|
947
961
|
readonly Heading: ComponentType<HeadingProps>;
|
|
948
962
|
readonly Dialog: ComponentType<DialogProps>;
|
|
949
963
|
readonly Modal: ComponentType<ModalProps>;
|
|
964
|
+
/** Optional (unlike the other Core-Primitives) so existing partial
|
|
965
|
+
* CorePrimitives mocks in tests keep compiling — additive rollout of
|
|
966
|
+
* a new primitive shouldn't force every test double to grow a stub. */
|
|
967
|
+
readonly Drawer?: ComponentType<DrawerProps>;
|
|
950
968
|
readonly Lightbox: ComponentType<LightboxProps>;
|
|
951
969
|
readonly ConfigSourceBadge: ComponentType<ConfigSourceBadgeProps>;
|
|
952
970
|
readonly ConfigCascadeView: ComponentType<ConfigCascadeViewProps>;
|