@cosmicdrift/kumiko-renderer 0.240.0 → 0.242.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.240.0",
3
+ "version": "0.242.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.240.0",
19
- "@cosmicdrift/kumiko-headless": "0.240.0",
18
+ "@cosmicdrift/kumiko-framework": "0.242.0",
19
+ "@cosmicdrift/kumiko-headless": "0.242.0",
20
20
  "react": "^19.2.6",
21
21
  "temporal-polyfill": "^0.3.2",
22
22
  "zod": "^4.4.3"
@@ -27,7 +27,7 @@
27
27
  "@types/react-dom": "^19.2.3",
28
28
  "jsdom": "^29.1.1",
29
29
  "react-dom": "^19.2.6",
30
- "@cosmicdrift/kumiko-locale-de": "0.240.0"
30
+ "@cosmicdrift/kumiko-locale-de": "0.242.0"
31
31
  },
32
32
  "repository": {
33
33
  "type": "git",
@@ -0,0 +1,191 @@
1
+ // fw#2312: `singleton: true` marks a projectionDetail screen whose query
2
+ // determines the shown row from the caller's session/context instead of a
3
+ // row id in the path (a self-service "my profile"/"my data" screen has no
4
+ // row to link to). Before this flag, ProjectionDetailBody always rendered
5
+ // the "needs a row id in the path" error banner when `entityId` was
6
+ // undefined (kumiko-screen.tsx) — both user-profile and user-data-rights'
7
+ // privacy-center screen hit exactly that, unrenderable, until the flag
8
+ // existed (fw#2312 bug report). This proves the fix, the no-flag regression
9
+ // case stays covered, and that a stray/spoofed path id never reaches the
10
+ // query under singleton (renderer-side half of the security requirement —
11
+ // the boot-validator half lives in framework's projection-detail-
12
+ // singleton.test.ts).
13
+
14
+ import { describe, expect, test } from "bun:test";
15
+ import type { ProjectionDetailScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
16
+ import type { Dispatcher } from "@cosmicdrift/kumiko-headless";
17
+ import { render, waitFor } from "@testing-library/react";
18
+ import type { ComponentType, ReactNode } from "react";
19
+ import { DispatcherProvider } from "../../context/dispatcher-context";
20
+ import { UserRolesProvider } from "../../context/user-roles-context";
21
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
22
+ import { kumikoDefaultTranslations } from "../../i18n-defaults";
23
+ import {
24
+ type BannerProps,
25
+ type CorePrimitives,
26
+ type FormProps,
27
+ PrimitivesProvider,
28
+ } from "../../primitives";
29
+ import { AppFeaturesProvider } from "../app-features-context";
30
+ import type { FeatureSchema } from "../feature-schema";
31
+ import { KumikoScreen } from "../kumiko-screen";
32
+ import type { NavApi } from "../nav";
33
+ import { NavProvider } from "../nav";
34
+
35
+ const noop = (): ReactNode => null;
36
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
37
+
38
+ const FormWithActions: ComponentType<FormProps> = ({ children }) => (
39
+ <div data-testid="form-body">{children}</div>
40
+ );
41
+
42
+ const TestBanner: ComponentType<BannerProps> = ({ children, testId }) => (
43
+ <div data-testid={testId}>{children}</div>
44
+ );
45
+
46
+ const testPrimitives: CorePrimitives = {
47
+ Button: noop,
48
+ Banner: TestBanner,
49
+ Field: passChildren,
50
+ Input: noop,
51
+ DataTable: noop,
52
+ Form: FormWithActions,
53
+ Section: passChildren,
54
+ Card: passChildren,
55
+ Grid: passChildren,
56
+ GridCell: passChildren,
57
+ Text: passChildren,
58
+ Heading: noop,
59
+ Dialog: noop,
60
+ Modal: noop,
61
+ Lightbox: noop,
62
+ ConfigSourceBadge: noop,
63
+ ConfigCascadeView: noop,
64
+ Link: noop,
65
+ };
66
+
67
+ function stubDispatcher(
68
+ record: Readonly<Record<string, unknown>> | null,
69
+ queries: Array<{ type: string; payload: unknown }>,
70
+ ): Dispatcher {
71
+ return {
72
+ write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
73
+ query: (async (type: string, payload: unknown) => {
74
+ queries.push({ type, payload });
75
+ return { isSuccess: true, data: record };
76
+ }) as unknown as Dispatcher["query"],
77
+ batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
78
+ statusStore: {
79
+ getState: () => "online",
80
+ subscribe: () => () => {},
81
+ } as unknown as Dispatcher["statusStore"],
82
+ async *stream() {},
83
+ pendingWrites: () => [],
84
+ pendingFiles: () => [],
85
+ };
86
+ }
87
+
88
+ function meScreen(
89
+ overrides?: Partial<ProjectionDetailScreenDefinition>,
90
+ ): FeatureSchema["screens"][number] {
91
+ return {
92
+ id: "profile",
93
+ type: "projectionDetail",
94
+ query: "app:query:me",
95
+ layout: { sections: [{ title: "s", fields: ["name"] }] },
96
+ ...overrides,
97
+ } as FeatureSchema["screens"][number];
98
+ }
99
+
100
+ function renderScreen(opts: {
101
+ readonly screen: FeatureSchema["screens"][number];
102
+ readonly entityId?: string;
103
+ readonly queries: Array<{ type: string; payload: unknown }>;
104
+ readonly record?: Readonly<Record<string, unknown>> | null;
105
+ }): ReturnType<typeof render> {
106
+ const schema: FeatureSchema = { featureName: "app", entities: {}, screens: [opts.screen] };
107
+ const navApi: NavApi = {
108
+ route: { screenId: "app:screen:profile" },
109
+ navigate: () => {},
110
+ replace: () => {},
111
+ hrefFor: () => "",
112
+ searchParams: {},
113
+ setSearchParams: () => {},
114
+ };
115
+ return render(
116
+ <LocaleProvider
117
+ resolver={createStaticLocaleResolver({ locale: "de-DE" })}
118
+ fallbackBundles={[kumikoDefaultTranslations]}
119
+ >
120
+ <DispatcherProvider
121
+ dispatcher={stubDispatcher(
122
+ opts.record !== undefined ? opts.record : { id: "u1", name: "Ada" },
123
+ opts.queries,
124
+ )}
125
+ >
126
+ <AppFeaturesProvider features={[schema]}>
127
+ <UserRolesProvider roles={[]}>
128
+ <NavProvider value={navApi}>
129
+ <PrimitivesProvider value={testPrimitives}>
130
+ <KumikoScreen
131
+ schema={schema}
132
+ qn="app:screen:profile"
133
+ {...(opts.entityId !== undefined && { entityId: opts.entityId })}
134
+ />
135
+ </PrimitivesProvider>
136
+ </NavProvider>
137
+ </UserRolesProvider>
138
+ </AppFeaturesProvider>
139
+ </DispatcherProvider>
140
+ </LocaleProvider>,
141
+ );
142
+ }
143
+
144
+ describe("projectionDetail singleton (fw#2312)", () => {
145
+ test("singleton + no path id renders the record instead of the missing-id banner", async () => {
146
+ const queries: Array<{ type: string; payload: unknown }> = [];
147
+ const { queryByTestId, getByTestId } = renderScreen({
148
+ screen: meScreen({ singleton: true }),
149
+ queries,
150
+ });
151
+
152
+ await waitFor(() => expect(getByTestId("form-body")).toBeTruthy());
153
+ expect(queryByTestId("kumiko-screen-projection-detail-missing-id")).toBeNull();
154
+ expect(queries).toEqual([{ type: "app:query:me", payload: {} }]);
155
+ });
156
+
157
+ test("no singleton flag + no path id still shows the missing-id banner (no regression)", async () => {
158
+ const queries: Array<{ type: string; payload: unknown }> = [];
159
+ const { getByTestId } = renderScreen({ screen: meScreen(), queries });
160
+
161
+ await waitFor(() =>
162
+ expect(getByTestId("kumiko-screen-projection-detail-missing-id")).toBeTruthy(),
163
+ );
164
+ });
165
+
166
+ test("singleton ignores a stray path id — the query never receives it", async () => {
167
+ const queries: Array<{ type: string; payload: unknown }> = [];
168
+ const { getByTestId } = renderScreen({
169
+ screen: meScreen({ singleton: true }),
170
+ entityId: "attacker-controlled-id",
171
+ queries,
172
+ });
173
+
174
+ await waitFor(() => expect(getByTestId("form-body")).toBeTruthy());
175
+ expect(queries).toEqual([{ type: "app:query:me", payload: {} }]);
176
+ });
177
+
178
+ test("singleton + record-not-found banner never shows the (ignored) path id", async () => {
179
+ const queries: Array<{ type: string; payload: unknown }> = [];
180
+ const { getByTestId } = renderScreen({
181
+ screen: meScreen({ singleton: true }),
182
+ entityId: "99",
183
+ record: null,
184
+ queries,
185
+ });
186
+
187
+ const banner = await waitFor(() => getByTestId("kumiko-screen-record-missing"));
188
+ expect(banner.textContent).toBe("Record not found.");
189
+ expect(banner.textContent).not.toContain("99");
190
+ });
191
+ });
@@ -2076,6 +2076,12 @@ function ProjectionDetailBody({
2076
2076
  const effectiveTranslate = translate ?? t;
2077
2077
  const nav = useNav();
2078
2078
  const idParam = screen.idParam ?? "id";
2079
+ // Singleton screens ignore whatever id the path happens to carry — the
2080
+ // server picks the row from session/context, so forwarding a path id
2081
+ // (even a stray/spoofed one) into the query or into extension sections'
2082
+ // entityId fallback (resolveExtensionEntityId in render-edit.tsx) would
2083
+ // let a client-controlled value leak where none is meant to reach.
2084
+ const effectiveEntityId = screen.singleton === true ? undefined : entityId;
2079
2085
  const isTabsMode = screen.layout.mode === "tabs";
2080
2086
  const activeSection = useMemo(() => {
2081
2087
  if (!isTabsMode || Tabs === undefined) return undefined;
@@ -2104,7 +2110,7 @@ function ProjectionDetailBody({
2104
2110
  }, [screen, activeSection]);
2105
2111
  const detailQuery = useQuery<Readonly<Record<string, unknown>>>(
2106
2112
  screen.query,
2107
- entityId !== undefined ? { [idParam]: entityId } : {},
2113
+ effectiveEntityId !== undefined ? { [idParam]: effectiveEntityId } : {},
2108
2114
  );
2109
2115
 
2110
2116
  // A writeForm section's handler creates a new record (see EditWriteFormSection's
@@ -2157,9 +2163,12 @@ function ProjectionDetailBody({
2157
2163
  label: effectiveTranslate("kumiko.actions.edit"),
2158
2164
  icon: resolveActionIcon("edit"),
2159
2165
  onPress: () =>
2160
- nav.navigate({ screenId: targetScreenId, ...(entityId !== undefined && { entityId }) }),
2166
+ nav.navigate({
2167
+ screenId: targetScreenId,
2168
+ ...(effectiveEntityId !== undefined && { entityId: effectiveEntityId }),
2169
+ }),
2161
2170
  };
2162
- }, [editScreen, effectiveTranslate, nav, entityId]);
2171
+ }, [editScreen, effectiveTranslate, nav, effectiveEntityId]);
2163
2172
 
2164
2173
  const headerActions = useMemo((): readonly RenderEditAction[] | undefined => {
2165
2174
  const record = detailQuery.data ?? {};
@@ -2282,7 +2291,7 @@ function ProjectionDetailBody({
2282
2291
  detailQuery.refetch,
2283
2292
  ]);
2284
2293
 
2285
- if (entityId === undefined) {
2294
+ if (effectiveEntityId === undefined && screen.singleton !== true) {
2286
2295
  return (
2287
2296
  <Banner padded variant="error" testId="kumiko-screen-projection-detail-missing-id">
2288
2297
  Screen <Text variant="code">{screen.id}</Text> (projectionDetail) needs a row id in the path
@@ -2308,7 +2317,13 @@ function ProjectionDetailBody({
2308
2317
  if (!record) {
2309
2318
  return (
2310
2319
  <Banner padded variant="error" testId="kumiko-screen-record-missing">
2311
- Record <Text variant="code">{entityId}</Text> not found.
2320
+ {screen.singleton === true ? (
2321
+ "Record not found."
2322
+ ) : (
2323
+ <>
2324
+ Record <Text variant="code">{entityId}</Text> not found.
2325
+ </>
2326
+ )}
2312
2327
  </Banner>
2313
2328
  );
2314
2329
  }
@@ -2397,7 +2412,7 @@ function ProjectionDetailBody({
2397
2412
  entity={entity}
2398
2413
  featureName={schema.featureName}
2399
2414
  initial={record as FormValues}
2400
- entityId={entityId}
2415
+ entityId={effectiveEntityId}
2401
2416
  customSubmit={async () => ({ isSuccess: true, validationBlocked: false, data: undefined })}
2402
2417
  onReload={reloadDetail}
2403
2418
  {...(headerActions !== undefined && { actions: headerActions })}
@@ -3,7 +3,7 @@ import type { RowAction } from "@cosmicdrift/kumiko-framework/ui-types";
3
3
  import type { Dispatcher, EditRelatedListSectionViewModel } from "@cosmicdrift/kumiko-headless";
4
4
  import { render, screen as rtlScreen, waitFor } from "@testing-library/react";
5
5
  import type { ComponentType, ReactNode } from "react";
6
- import { NavProvider } from "../../app/nav";
6
+ import { type NavApi, NavProvider } from "../../app/nav";
7
7
  import { DispatcherProvider } from "../../context/dispatcher-context";
8
8
  import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
9
9
  import { kumikoDefaultTranslations } from "../../i18n-defaults";
@@ -26,22 +26,27 @@ const testSection: ComponentType<SectionProps> = ({ testId, children }) => (
26
26
  // wired straight to the action's onTrigger — enough to prove
27
27
  // RelatedListSection wires rowActions through to a real dispatch, without
28
28
  // needing the production DataTable's sorting/paging/kebab-menu chrome.
29
+ // The isVisible filter mirrors the production DataTable's own row-action
30
+ // filter (renderer-web primitives/index.tsx) so per-row gating is exercised
31
+ // here rather than assumed.
29
32
  const testDataTable: ComponentType<DataTableProps> = ({ rows, rowActions }) => (
30
33
  <table>
31
34
  <tbody>
32
35
  {rows.map((row) => (
33
36
  <tr key={row.id} data-testid={`row-${row.id}`}>
34
37
  <td>
35
- {(rowActions ?? []).map((action) => (
36
- <button
37
- key={action.id}
38
- type="button"
39
- data-testid={`action-${action.id}-${row.id}`}
40
- onClick={() => void action.onTrigger(row)}
41
- >
42
- {action.label}
43
- </button>
44
- ))}
38
+ {(rowActions ?? [])
39
+ .filter((action) => action.isVisible === undefined || action.isVisible(row))
40
+ .map((action) => (
41
+ <button
42
+ key={action.id}
43
+ type="button"
44
+ data-testid={`action-${action.id}-${row.id}`}
45
+ onClick={() => void action.onTrigger(row)}
46
+ >
47
+ {action.label}
48
+ </button>
49
+ ))}
45
50
  </td>
46
51
  </tr>
47
52
  ))}
@@ -74,7 +79,7 @@ function testPrimitives(): CorePrimitives {
74
79
  } as unknown as CorePrimitives;
75
80
  }
76
81
 
77
- function stubDispatcher(): {
82
+ function stubDispatcher(rows: readonly Record<string, unknown>[] = [{ id: "r1", name: "Alice" }]): {
78
83
  dispatcher: Dispatcher;
79
84
  writes: Array<{ type: string; payload: unknown }>;
80
85
  queryCount: () => number;
@@ -88,10 +93,7 @@ function stubDispatcher(): {
88
93
  }) as Dispatcher["write"],
89
94
  query: (async () => {
90
95
  queryCalls += 1;
91
- return {
92
- isSuccess: true,
93
- data: { rows: [{ id: "r1", name: "Alice" }], nextCursor: null },
94
- };
96
+ return { isSuccess: true, data: { rows, nextCursor: null } };
95
97
  }) as Dispatcher["query"],
96
98
  batch: (async () => ({ isSuccess: true, results: [] })) as Dispatcher["batch"],
97
99
  statusStore: {
@@ -105,6 +107,28 @@ function stubDispatcher(): {
105
107
  return { dispatcher, writes, queryCount: () => queryCalls };
106
108
  }
107
109
 
110
+ function stubNav(): {
111
+ nav: NavApi;
112
+ navigations: unknown[];
113
+ searchParams: Array<Record<string, string | null>>;
114
+ } {
115
+ const navigations: unknown[] = [];
116
+ const searchParams: Array<Record<string, string | null>> = [];
117
+ const nav: NavApi = {
118
+ route: undefined,
119
+ navigate: (target: unknown) => {
120
+ navigations.push(target);
121
+ },
122
+ replace: noop,
123
+ hrefFor: () => "#",
124
+ searchParams: {},
125
+ setSearchParams: (params: Record<string, string | null>) => {
126
+ searchParams.push(params);
127
+ },
128
+ } as unknown as NavApi;
129
+ return { nav, navigations, searchParams };
130
+ }
131
+
108
132
  const rowActions: readonly RowAction[] = [
109
133
  {
110
134
  id: "resend",
@@ -122,7 +146,11 @@ const historySection: EditRelatedListSectionViewModel = {
122
146
  rowActions,
123
147
  };
124
148
 
125
- function renderRelatedList(dispatcher: Dispatcher) {
149
+ function renderRelatedList(
150
+ dispatcher: Dispatcher,
151
+ section: EditRelatedListSectionViewModel = historySection,
152
+ nav: NavApi = stubNav().nav,
153
+ ) {
126
154
  return render(
127
155
  <LocaleProvider
128
156
  resolver={createStaticLocaleResolver({ locale: "en-US" })}
@@ -130,17 +158,8 @@ function renderRelatedList(dispatcher: Dispatcher) {
130
158
  >
131
159
  <DispatcherProvider dispatcher={dispatcher}>
132
160
  <PrimitivesProvider value={testPrimitives()}>
133
- <NavProvider
134
- value={{
135
- route: undefined,
136
- navigate: noop,
137
- replace: noop,
138
- hrefFor: () => "#",
139
- searchParams: {},
140
- setSearchParams: noop,
141
- }}
142
- >
143
- <RelatedListSection section={historySection} parentId="order-1" featureName="orders" />
161
+ <NavProvider value={nav}>
162
+ <RelatedListSection section={section} parentId="order-1" featureName="orders" />
144
163
  </NavProvider>
145
164
  </PrimitivesProvider>
146
165
  </DispatcherProvider>
@@ -160,4 +179,61 @@ describe("RelatedListSection — rowActions", () => {
160
179
  expect(writes[0]).toEqual({ type: "orders:write:resend", payload: { id: "r1" } });
161
180
  await waitFor(() => expect(queryCount()).toBe(2));
162
181
  });
182
+
183
+ test("a navigate row action targets its screen and carries the clicked row's own values as search params", async () => {
184
+ const { dispatcher } = stubDispatcher([{ id: "item-7", name: "Rent 2024", amount: 1200 }]);
185
+ const { nav, navigations, searchParams } = stubNav();
186
+ renderRelatedList(
187
+ dispatcher,
188
+ {
189
+ kind: "relatedList",
190
+ title: "Positions",
191
+ query: "lease:query:items:list",
192
+ columns: [{ field: "name" }],
193
+ rowActions: [
194
+ {
195
+ kind: "navigate",
196
+ id: "adjust-rent",
197
+ label: "actions.adjustRent",
198
+ screen: "adjust-rent-form",
199
+ params: { map: { itemId: "id", currentAmount: "amount" } },
200
+ },
201
+ ],
202
+ },
203
+ nav,
204
+ );
205
+
206
+ await waitFor(() => expect(rtlScreen.getByTestId("row-item-7")).toBeTruthy());
207
+ rtlScreen.getByTestId("action-adjust-rent-item-7").click();
208
+
209
+ await waitFor(() => expect(navigations).toHaveLength(1));
210
+ expect(navigations[0]).toEqual({ screenId: "adjust-rent-form" });
211
+ expect(searchParams).toEqual([{ itemId: "item-7", currentAmount: "1200" }]);
212
+ });
213
+
214
+ test("a row action with a visible condition renders only on the rows that satisfy it", async () => {
215
+ const { dispatcher } = stubDispatcher([
216
+ { id: "active-1", name: "Running", status: "active" },
217
+ { id: "ended-1", name: "Closed", status: "ended" },
218
+ ]);
219
+ renderRelatedList(dispatcher, {
220
+ kind: "relatedList",
221
+ title: "Positions",
222
+ query: "lease:query:items:list",
223
+ columns: [{ field: "name" }],
224
+ rowActions: [
225
+ {
226
+ id: "end-item",
227
+ label: "actions.endItem",
228
+ handler: "lease:write:end-item",
229
+ payload: { pick: ["id"] },
230
+ visible: { field: "status", eq: "active" },
231
+ },
232
+ ],
233
+ });
234
+
235
+ await waitFor(() => expect(rtlScreen.getByTestId("row-ended-1")).toBeTruthy());
236
+ expect(rtlScreen.getByTestId("action-end-item-active-1")).toBeTruthy();
237
+ expect(rtlScreen.queryByTestId("action-end-item-ended-1")).toBeNull();
238
+ });
163
239
  });
@@ -0,0 +1,164 @@
1
+ // fw#2312: readOnly fields with `field.renderer: { format: "json" }` (audit
2
+ // payload/metadata, job logs) must hand the RAW value to a registered
3
+ // JsonView primitive — applyFormatSpec's already-indented string is only
4
+ // the fallback for a primitives-provider without JsonView (e.g. an app
5
+ // predating this rollout). "children contains \n" is not a discriminating
6
+ // assertion here (applyFormatSpec already returns a real newline string
7
+ // today) — the test instead checks that JsonView is actually invoked and
8
+ // receives the structured value, not a flattened string.
9
+ //
10
+ // Capture-primitives instead of real ones, same pattern as
11
+ // render-field-unit-format.test.tsx.
12
+
13
+ import { describe, expect, test } from "bun:test";
14
+ import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
15
+ import { render } from "@testing-library/react";
16
+ import type { ComponentType, ReactNode } from "react";
17
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
18
+ import {
19
+ type CorePrimitives,
20
+ type JsonViewProps,
21
+ PrimitivesProvider,
22
+ type TextProps,
23
+ } from "../../primitives";
24
+ import { RenderField } from "../render-field";
25
+
26
+ let capturedJsonView: JsonViewProps | undefined;
27
+ const captureJsonView: ComponentType<JsonViewProps> = (props) => {
28
+ capturedJsonView = props;
29
+ return null;
30
+ };
31
+
32
+ let capturedText: TextProps | undefined;
33
+ const captureText: ComponentType<TextProps> = (props) => {
34
+ capturedText = props;
35
+ return null;
36
+ };
37
+
38
+ const noop = (): ReactNode => null;
39
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
40
+
41
+ const basePrimitives: Omit<CorePrimitives, "Text" | "JsonView"> = {
42
+ Button: noop,
43
+ // Passes children through (not noop): the "unsupported jsonb" fallback
44
+ // path wraps its Text/JsonView content in <Banner>, so a Banner that
45
+ // swallows children would silently never mount them, making those
46
+ // captures a no-op regardless of which branch RenderField actually took.
47
+ Banner: passChildren,
48
+ Field: passChildren,
49
+ Input: noop,
50
+ DataTable: noop,
51
+ Form: noop,
52
+ Section: noop,
53
+ Card: noop,
54
+ Grid: noop,
55
+ GridCell: noop,
56
+ Heading: noop,
57
+ Dialog: noop,
58
+ Modal: noop,
59
+ Lightbox: noop,
60
+ ConfigSourceBadge: noop,
61
+ ConfigCascadeView: noop,
62
+ Link: noop,
63
+ };
64
+
65
+ const primitivesWithJsonView: CorePrimitives = {
66
+ ...basePrimitives,
67
+ Text: captureText,
68
+ JsonView: captureJsonView,
69
+ };
70
+
71
+ const primitivesWithoutJsonView: CorePrimitives = {
72
+ ...basePrimitives,
73
+ Text: captureText,
74
+ };
75
+
76
+ function payloadField(
77
+ value: unknown,
78
+ renderer: EditFieldViewModel["renderer"] = { format: "json" },
79
+ ): EditFieldViewModel {
80
+ return {
81
+ field: "payload",
82
+ label: "Payload",
83
+ type: "jsonb",
84
+ value,
85
+ visible: true,
86
+ readOnly: true,
87
+ required: false,
88
+ renderer,
89
+ };
90
+ }
91
+
92
+ // No `renderer` key at all (not even `renderer: undefined`, which a default
93
+ // parameter would still override) — this is the "no author-declared
94
+ // FieldRenderer" case that falls through to renderInput's jsonb/embedded/
95
+ // files/images fallback banner instead of FieldRendererOutput.
96
+ function unrenderedJsonbField(value: unknown): EditFieldViewModel {
97
+ return {
98
+ field: "payload",
99
+ label: "Payload",
100
+ type: "jsonb",
101
+ value,
102
+ visible: true,
103
+ readOnly: true,
104
+ required: false,
105
+ };
106
+ }
107
+
108
+ function renderPayload(primitives: CorePrimitives, field: EditFieldViewModel): void {
109
+ capturedJsonView = undefined;
110
+ capturedText = undefined;
111
+ render(
112
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale: "en-US" })}>
113
+ <PrimitivesProvider value={primitives}>
114
+ <RenderField field={field} onChange={() => {}} />
115
+ </PrimitivesProvider>
116
+ </LocaleProvider>,
117
+ );
118
+ }
119
+
120
+ describe("RenderField — format: json (fw#2312)", () => {
121
+ test("mit registriertem JsonView bekommt es den rohen, strukturierten Wert — keinen flachen String", () => {
122
+ const value = { user: { id: 1, roles: ["admin", "billing"] } };
123
+ renderPayload(primitivesWithJsonView, payloadField(value));
124
+ expect(capturedJsonView?.value).toBe(value);
125
+ expect(typeof capturedJsonView?.value).toBe("object");
126
+ expect(capturedText).toBeUndefined();
127
+ });
128
+
129
+ test("renderer.indent wird an JsonView durchgereicht", () => {
130
+ renderPayload(primitivesWithJsonView, payloadField({ a: 1 }, { format: "json", indent: 4 }));
131
+ expect(capturedJsonView?.indent).toBe(4);
132
+ });
133
+
134
+ test("ohne registriertes JsonView fällt es auf Text mit dem von applyFormatSpec formatierten String zurück", () => {
135
+ const value = { a: 1 };
136
+ renderPayload(primitivesWithoutJsonView, payloadField(value));
137
+ expect(capturedJsonView).toBeUndefined();
138
+ expect(capturedText?.children).toBe(JSON.stringify(value, null, 2));
139
+ });
140
+
141
+ test("ein zirkulärer Wert erreicht JsonView unverändert — die Aufbereitung ist Sache der Primitive-Implementierung, nicht von RenderField", () => {
142
+ const circular: Record<string, unknown> = { name: "job-1" };
143
+ circular["self"] = circular;
144
+ expect(() => renderPayload(primitivesWithJsonView, payloadField(circular))).not.toThrow();
145
+ expect(capturedJsonView?.value).toBe(circular);
146
+ });
147
+ });
148
+
149
+ describe("RenderField — unsupported jsonb/embedded/files/images Fallback-Banner (fw#2312)", () => {
150
+ test("mit registriertem JsonView zeigt der Banner den Wert strukturiert statt als JSON.stringify-Einzeiler", () => {
151
+ const value = { assignee: "user-1", tags: ["a", "b"] };
152
+ renderPayload(primitivesWithJsonView, unrenderedJsonbField(value));
153
+ expect(capturedJsonView?.value).toBe(value);
154
+ expect(capturedText).toBeUndefined();
155
+ });
156
+
157
+ test("ohne registriertes JsonView fällt der Banner auf Text variant=code mit dem alten Einzeiler zurück", () => {
158
+ const value = { assignee: "user-1" };
159
+ renderPayload(primitivesWithoutJsonView, unrenderedJsonbField(value));
160
+ expect(capturedJsonView).toBeUndefined();
161
+ expect(capturedText?.children).toBe(JSON.stringify(value));
162
+ expect(capturedText?.variant).toBe("code");
163
+ });
164
+ });
@@ -17,8 +17,8 @@ import { WriteFormSection } from "../write-form-section";
17
17
  const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
18
18
  const noop = () => {};
19
19
 
20
- const testButton: ComponentType<ButtonProps> = ({ children, onClick, testId, disabled }) => (
21
- <button type="button" data-testid={testId} onClick={onClick} disabled={disabled}>
20
+ const testButton: ComponentType<ButtonProps> = ({ children, onClick, testId, disabled, icon }) => (
21
+ <button type="button" data-testid={testId} data-icon={icon} onClick={onClick} disabled={disabled}>
22
22
  {children}
23
23
  </button>
24
24
  );
@@ -40,8 +40,15 @@ const testBanner: ComponentType<BannerProps> = ({ children, testId }) => (
40
40
  <div data-testid={testId}>{children}</div>
41
41
  );
42
42
 
43
- const testSection: ComponentType<SectionProps> = ({ testId, children }) => (
44
- <div data-testid={testId}>{children}</div>
43
+ // Mirrors DefaultSection's real actions slot closely enough to let tests
44
+ // assert the submit button lands in the footer, not the body (fw#2675).
45
+ const testSection: ComponentType<SectionProps> = ({ testId, children, actions }) => (
46
+ <div data-testid={testId}>
47
+ <div data-testid={testId !== undefined ? `${testId}-body` : undefined}>{children}</div>
48
+ {actions !== undefined && (
49
+ <div data-testid={testId !== undefined ? `${testId}-actions` : undefined}>{actions}</div>
50
+ )}
51
+ </div>
45
52
  );
46
53
 
47
54
  function testPrimitives(): CorePrimitives {
@@ -129,6 +136,19 @@ function renderWriteForm(
129
136
  }
130
137
 
131
138
  describe("WriteFormSection", () => {
139
+ test("submit button renders in the section's actions footer, not the body (fw#2675)", () => {
140
+ const { dispatcher } = stubDispatcher();
141
+ renderWriteForm(noteSection, dispatcher, noop);
142
+
143
+ const actions = rtlScreen.getByTestId("write-form-Add note-actions");
144
+ const body = rtlScreen.getByTestId("write-form-Add note-body");
145
+ const button = rtlScreen.getByTestId("write-form-section-submit");
146
+
147
+ expect(actions.contains(button)).toBe(true);
148
+ expect(body.contains(button)).toBe(false);
149
+ expect(button.dataset["icon"]).toBe("check");
150
+ });
151
+
132
152
  test("submit dispatches through the section's configured write handler with the entered values", async () => {
133
153
  const { dispatcher, writes } = stubDispatcher();
134
154
  let submittedCount = 0;
@@ -83,7 +83,7 @@ export function RenderField({
83
83
  valueDisplay = "form",
84
84
  row,
85
85
  }: RenderFieldProps): ReactNode {
86
- const { Field, Input, Banner, Text } = usePrimitives();
86
+ const { Field, Input, Banner, Text, JsonView } = usePrimitives();
87
87
  // App-Locale (i18n) für money/date-Inputs — sonst fielen sie auf
88
88
  // navigator.language (Browser-Sprache) zurück statt der gewählten
89
89
  // App-Sprache. BEWUSSTE API-Verschärfung (seit 0.38): RenderField ist
@@ -137,7 +137,19 @@ export function RenderField({
137
137
  ) : readOnlyText && !isComplexFieldType(field.type) ? (
138
138
  <Text testId={`field-value-${field.field}`}>{readOnlyDisplayText(field, appLocale)}</Text>
139
139
  ) : (
140
- renderInput({ field, id, hasError, onChange, Input, appLocale, Banner, Text, t, row })
140
+ renderInput({
141
+ field,
142
+ id,
143
+ hasError,
144
+ onChange,
145
+ Input,
146
+ appLocale,
147
+ Banner,
148
+ Text,
149
+ JsonView,
150
+ t,
151
+ row,
152
+ })
141
153
  );
142
154
 
143
155
  return (
@@ -351,7 +363,7 @@ function FieldRendererOutput({
351
363
  readonly row?: Readonly<Record<string, unknown>>;
352
364
  readonly appLocale: string;
353
365
  }): ReactNode {
354
- const { Text } = usePrimitives();
366
+ const { Text, JsonView } = usePrimitives();
355
367
  const t = useTranslation();
356
368
  const componentName =
357
369
  !isFormatSpec(renderer) && typeof renderer === "object" && renderer !== null
@@ -359,6 +371,18 @@ function FieldRendererOutput({
359
371
  : undefined;
360
372
  const Component = useColumnRenderer(componentName);
361
373
  if (isFormatSpec(renderer)) {
374
+ // format:"json" wants the raw value (JsonView stringifies + highlights
375
+ // itself) — applyFormatSpec's already-indented string is only the
376
+ // fallback for primitives-providers without JsonView (fw#2312).
377
+ if (renderer.format === "json" && JsonView !== undefined) {
378
+ return (
379
+ <JsonView
380
+ value={field.value}
381
+ indent={renderer.indent}
382
+ testId={`field-value-${field.field}`}
383
+ />
384
+ );
385
+ }
362
386
  // App locale as default when the FormatSpec declares none of its own —
363
387
  // otherwise locale-sensitive formats (timestamp/date/number/decimal/
364
388
  // bigInt/unit) fell back to Intl's runtime default instead of the app
@@ -495,6 +519,7 @@ function renderInput({
495
519
  appLocale,
496
520
  Banner,
497
521
  Text,
522
+ JsonView,
498
523
  t,
499
524
  row,
500
525
  }: {
@@ -506,6 +531,7 @@ function renderInput({
506
531
  readonly appLocale: string;
507
532
  readonly Banner: ReturnType<typeof usePrimitives>["Banner"];
508
533
  readonly Text: ReturnType<typeof usePrimitives>["Text"];
534
+ readonly JsonView: ReturnType<typeof usePrimitives>["JsonView"];
509
535
  readonly t: ReturnType<typeof useTranslation>;
510
536
  readonly row?: Readonly<Record<string, unknown>>;
511
537
  }): ReactNode {
@@ -710,7 +736,12 @@ function renderInput({
710
736
  return (
711
737
  <Banner id={id} variant="info">
712
738
  {t("kumiko.field.unsupported")}
713
- {hasValue && <Text variant="code">{JSON.stringify(field.value)}</Text>}
739
+ {hasValue &&
740
+ (JsonView !== undefined ? (
741
+ <JsonView value={field.value} testId={`field-value-${field.field}`} />
742
+ ) : (
743
+ <Text variant="code">{JSON.stringify(field.value)}</Text>
744
+ ))}
714
745
  </Banner>
715
746
  );
716
747
  }
@@ -129,25 +129,35 @@ export function WriteFormSection({
129
129
  {error}
130
130
  </Banner>
131
131
  )}
132
- <Button
133
- type="button"
134
- variant="primary"
135
- disabled={isSubmitting}
136
- loading={isSubmitting}
137
- onClick={() => void handleSubmit()}
138
- testId="write-form-section-submit"
139
- >
140
- {section.submitLabel ?? effectiveTranslate("kumiko.actions.save")}
141
- </Button>
142
132
  </>
143
133
  );
144
134
 
145
- if (hideTitle || section.title === undefined) return content;
135
+ // type="button" (not "submit") is load-bearing: this section is deliberately
136
+ // NOT a nested <form> (see the component doc above), so a "submit" type
137
+ // would instead trigger the host RenderEdit's own form submit.
138
+ const submitButton = (
139
+ <Button
140
+ type="button"
141
+ variant="primary"
142
+ icon="check"
143
+ disabled={isSubmitting}
144
+ loading={isSubmitting}
145
+ onClick={() => void handleSubmit()}
146
+ testId="write-form-section-submit"
147
+ >
148
+ {section.submitLabel ?? effectiveTranslate("kumiko.actions.save")}
149
+ </Button>
150
+ );
151
+
152
+ // Routed through Section's `actions` slot (same mechanism render-edit.tsx
153
+ // uses via Form's `actions`) so the button gets the established right-
154
+ // aligned footer treatment instead of stretching full-width inline.
146
155
  return (
147
156
  <Section
148
- title={section.title}
157
+ {...(!hideTitle && section.title !== undefined && { title: section.title })}
149
158
  {...(section.icon !== undefined && { icon: section.icon })}
150
- testId={`write-form-${section.title}`}
159
+ actions={submitButton}
160
+ testId={`write-form-${section.title ?? "section"}`}
151
161
  >
152
162
  {content}
153
163
  </Section>
package/src/index.ts CHANGED
@@ -190,6 +190,7 @@ export type {
190
190
  GridProps,
191
191
  HeadingProps,
192
192
  InputProps,
193
+ JsonViewProps,
193
194
  LightboxProps,
194
195
  LinkProps,
195
196
  MetricProps,
@@ -448,8 +448,9 @@ export type InputProps =
448
448
  readonly name: string;
449
449
  readonly value: string;
450
450
  readonly onChange: (v: string) => void;
451
- /** Anzahl sichtbarer Zeilen. Default 4 in der Default-Primitive
452
- * hinreichend für Notes, vertikal-scrollbar drüber. */
451
+ /** Visible rows. Acts as a minimum height — the field starts this tall
452
+ * and still grows with its content. Omitted, the default primitive
453
+ * falls back to its own floor (#2677). */
453
454
  readonly rows?: number;
454
455
  readonly disabled?: boolean;
455
456
  readonly required?: boolean;
@@ -1059,6 +1060,17 @@ export type MetricProps = {
1059
1060
  readonly testId?: string;
1060
1061
  };
1061
1062
 
1063
+ /** Structured JSON display (audit payload/metadata, job logs, unsupported
1064
+ * jsonb/embedded field fallback). Takes the raw value, not a pre-stringified
1065
+ * one — the implementation owns `JSON.stringify` (and must not throw on
1066
+ * circular refs / BigInt / other non-serializable input). `indent` mirrors
1067
+ * `JSON.stringify`'s space-count param, default 2. */
1068
+ export type JsonViewProps = {
1069
+ readonly value: unknown;
1070
+ readonly indent?: number;
1071
+ readonly testId?: string;
1072
+ };
1073
+
1062
1074
  // ---- Core-Registry (Kumiko-eigene Primitives) ----
1063
1075
 
1064
1076
  export type CorePrimitives = {
@@ -1112,6 +1124,10 @@ export type CorePrimitives = {
1112
1124
  * CorePrimitives mocks in tests keep compiling — additive rollout of
1113
1125
  * a new primitive shouldn't force every test double to grow a stub. */
1114
1126
  readonly Metric?: ComponentType<MetricProps>;
1127
+ /** Optional (unlike the other Core-Primitives) so existing partial
1128
+ * CorePrimitives mocks in tests keep compiling — additive rollout of
1129
+ * a new primitive shouldn't force every test double to grow a stub. */
1130
+ readonly JsonView?: ComponentType<JsonViewProps>;
1115
1131
  };
1116
1132
 
1117
1133
  /** Offene Extension-Zone für App-eigene Primitives. Devs erweitern