@cosmicdrift/kumiko-renderer 0.208.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.208.3",
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.208.3",
19
- "@cosmicdrift/kumiko-headless": "0.208.3",
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.208.3"
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
- // Boolean-Felder: "true"/"false"-Strings zu echten Booleans coercen
951
- // (DB-Spalte ist boolean). Alles als op:"in" (Multi-Select-Semantik).
952
- const filterPayload = useMemo(() => {
953
- const out: { field: string; op: "in"; value: unknown }[] = [];
954
- for (const [field, values] of Object.entries(urlState.filters)) {
955
- if (values.length === 0) continue;
956
- // `id` is a base column (not in entity.fields), allowed as an id-set
957
- // filter so a header-slot control e.g. the tags TagFilter — can narrow
958
- // ANY list to a resolved set of row ids without the host declaring a facet.
959
- if (field === "id") {
960
- out.push({ field, op: "in", value: values });
961
- continue;
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
- const out: DataTableFacet[] = [];
1057
- for (const [field, rawDef] of Object.entries(entity.fields)) {
1058
- // entity.fields ist am Renderer-Layer schwach getypt (Record<string,
1059
- // unknown>, vom Schema deserialisiert) — Boundary-Cast wie buildInitialValues.
1060
- const def = rawDef as {
1061
- type?: string;
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
- <RenderList
1333
- screen={screen}
1334
- entity={entity}
1335
- rows={renderRows}
1336
- featureName={featureName}
1337
- searchable={searchable}
1338
- searchValue={urlState.q}
1339
- onSearchChange={urlState.setQ}
1340
- sort={effectiveSort}
1341
- onSortChange={urlState.setSort}
1342
- {...(pager !== undefined && { pager })}
1343
- {...(rowActions !== undefined && { rowActions })}
1344
- {...(toolbarActions !== undefined && toolbarActions.length > 0 && { toolbarActions })}
1345
- {...(useInfinite && {
1346
- onReachEnd: loadMore,
1347
- loadingMore: rowsQuery.loading,
1348
- hasMore,
1349
- })}
1350
- {...(onCreate !== undefined && { onCreate })}
1351
- {...(translate !== undefined && { translate })}
1352
- {...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
1353
- {...(filterFacets.length > 0 && {
1354
- filterFacets,
1355
- filterValues: urlState.filters,
1356
- onFilterChange: urlState.setFilter,
1357
- onFilterReset: urlState.clearFilters,
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
- const queryPayload = useMemo(
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
- buildListQueryPayload({
1414
- limit,
1415
- search: activeSearch,
1416
- sort: activeSort,
1417
- usePager,
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
- <RenderList
1598
- screen={listScreen}
1599
- entity={entity}
1600
- rows={rowsQuery.data?.rows ?? []}
1601
- featureName={schema.featureName}
1602
- searchable={searchable}
1603
- searchValue={urlState.q}
1604
- onSearchChange={urlState.setQ}
1605
- sort={activeSort}
1606
- onSortChange={urlState.setSort}
1607
- {...(pager !== undefined && { pager })}
1608
- {...(rowActions !== undefined && { rowActions })}
1609
- {...(toolbarActions !== undefined && { toolbarActions })}
1610
- {...(translate !== undefined && { translate })}
1611
- {...(wrappedOnRowClick !== undefined && { onRowClick: wrappedOnRowClick })}
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 (result.isSuccess && screen.redirect !== undefined) {
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.type === "embedded" && field.embeddedListCells !== undefined ? (
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
- <ReferenceInput
89
- field={field}
90
- id={id}
91
- hasError={hasError}
92
- onChange={onChange}
93
- Input={Input}
94
- featureName={featureName ?? ""}
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
@@ -176,6 +176,7 @@ export type {
176
176
  DataTableSort,
177
177
  DataTableSortDir,
178
178
  DialogProps,
179
+ DrawerProps,
179
180
  EmbeddedListCellType,
180
181
  EmbeddedListColumn,
181
182
  EmbeddedListInputProps,
@@ -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>;