@cosmicdrift/kumiko-renderer 0.239.0 → 0.241.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.
@@ -0,0 +1,225 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Dispatcher, EditWriteFormSectionViewModel } from "@cosmicdrift/kumiko-headless";
3
+ import { fireEvent, render, screen as rtlScreen, waitFor } from "@testing-library/react";
4
+ import type { ComponentType, ReactNode } from "react";
5
+ import { DispatcherProvider } from "../../context/dispatcher-context";
6
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
7
+ import { kumikoDefaultTranslations } from "../../i18n-defaults";
8
+ import {
9
+ type BannerProps,
10
+ type ButtonProps,
11
+ type CorePrimitives,
12
+ PrimitivesProvider,
13
+ type SectionProps,
14
+ } from "../../primitives";
15
+ import { WriteFormSection } from "../write-form-section";
16
+
17
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
18
+ const noop = () => {};
19
+
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
+ {children}
23
+ </button>
24
+ );
25
+
26
+ const testInput: ComponentType<{
27
+ name?: string;
28
+ value?: unknown;
29
+ onChange?: (v: unknown) => void;
30
+ }> = ({ name = "field", value, onChange }) => (
31
+ <input
32
+ aria-label={name}
33
+ data-testid={`input-${name}`}
34
+ value={typeof value === "string" ? value : ""}
35
+ onChange={(e) => onChange?.(e.target.value)}
36
+ />
37
+ );
38
+
39
+ const testBanner: ComponentType<BannerProps> = ({ children, testId }) => (
40
+ <div data-testid={testId}>{children}</div>
41
+ );
42
+
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>
52
+ );
53
+
54
+ function testPrimitives(): CorePrimitives {
55
+ return {
56
+ Button: testButton,
57
+ Banner: testBanner,
58
+ Field: passChildren,
59
+ Input: testInput,
60
+ DataTable: noop,
61
+ Form: noop,
62
+ Section: testSection,
63
+ Card: passChildren,
64
+ Grid: passChildren,
65
+ GridCell: passChildren,
66
+ Text: noop,
67
+ Heading: noop,
68
+ Dialog: noop,
69
+ Modal: noop,
70
+ Lightbox: noop,
71
+ ConfigSourceBadge: noop,
72
+ ConfigCascadeView: noop,
73
+ Link: noop,
74
+ } as unknown as CorePrimitives;
75
+ }
76
+
77
+ function stubDispatcher(writeImpl?: Dispatcher["write"]): {
78
+ dispatcher: Dispatcher;
79
+ writes: Array<{ type: string; payload: unknown }>;
80
+ } {
81
+ const writes: Array<{ type: string; payload: unknown }> = [];
82
+ const dispatcher: Dispatcher = {
83
+ write: (async (type, payload) => {
84
+ writes.push({ type, payload });
85
+ if (writeImpl) return writeImpl(type, payload);
86
+ return { isSuccess: true, data: { id: "n1" } };
87
+ }) as Dispatcher["write"],
88
+ query: (async () => ({ isSuccess: true, data: {} })) as Dispatcher["query"],
89
+ batch: (async () => ({ isSuccess: true, results: [] })) as Dispatcher["batch"],
90
+ statusStore: {
91
+ getState: () => "online",
92
+ subscribe: () => () => {},
93
+ } as unknown as Dispatcher["statusStore"],
94
+ async *stream() {},
95
+ pendingWrites: () => [],
96
+ pendingFiles: () => [],
97
+ };
98
+ return { dispatcher, writes };
99
+ }
100
+
101
+ const noteSection: EditWriteFormSectionViewModel = {
102
+ kind: "writeForm",
103
+ title: "Add note",
104
+ columns: 1,
105
+ handler: "orders:write:add-note",
106
+ fields: [
107
+ {
108
+ field: "note",
109
+ label: "Note",
110
+ type: "text",
111
+ value: "",
112
+ visible: true,
113
+ readOnly: false,
114
+ required: true,
115
+ },
116
+ ],
117
+ };
118
+
119
+ function renderWriteForm(
120
+ section: EditWriteFormSectionViewModel,
121
+ dispatcher: Dispatcher,
122
+ onSubmitted: () => void,
123
+ ) {
124
+ return render(
125
+ <LocaleProvider
126
+ resolver={createStaticLocaleResolver({ locale: "en-US" })}
127
+ fallbackBundles={[kumikoDefaultTranslations]}
128
+ >
129
+ <DispatcherProvider dispatcher={dispatcher}>
130
+ <PrimitivesProvider value={testPrimitives()}>
131
+ <WriteFormSection section={section} featureName="orders" onSubmitted={onSubmitted} />
132
+ </PrimitivesProvider>
133
+ </DispatcherProvider>
134
+ </LocaleProvider>,
135
+ );
136
+ }
137
+
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
+
152
+ test("submit dispatches through the section's configured write handler with the entered values", async () => {
153
+ const { dispatcher, writes } = stubDispatcher();
154
+ let submittedCount = 0;
155
+ renderWriteForm(noteSection, dispatcher, () => {
156
+ submittedCount += 1;
157
+ });
158
+
159
+ fireEvent.change(rtlScreen.getByLabelText("note"), { target: { value: "Called back" } });
160
+ fireEvent.click(rtlScreen.getByTestId("write-form-section-submit"));
161
+
162
+ await waitFor(() => expect(submittedCount).toBe(1));
163
+ expect(writes).toEqual([{ type: "orders:write:add-note", payload: { note: "Called back" } }]);
164
+ });
165
+
166
+ test("a failed write surfaces the error banner and does not call onSubmitted", async () => {
167
+ const { dispatcher } = stubDispatcher(async () => ({
168
+ isSuccess: false,
169
+ error: { code: "conflict", httpStatus: 409, i18nKey: "errors.conflict", message: "conflict" },
170
+ }));
171
+ let submittedCount = 0;
172
+ renderWriteForm(noteSection, dispatcher, () => {
173
+ submittedCount += 1;
174
+ });
175
+
176
+ fireEvent.change(rtlScreen.getByLabelText("note"), { target: { value: "x" } });
177
+ fireEvent.click(rtlScreen.getByTestId("write-form-section-submit"));
178
+
179
+ await waitFor(() => expect(rtlScreen.getByTestId("write-form-section-error")).toBeTruthy());
180
+ expect(submittedCount).toBe(0);
181
+ });
182
+
183
+ test("submit is blocked by schema validation when a required field is left empty", async () => {
184
+ const { dispatcher, writes } = stubDispatcher();
185
+ renderWriteForm(noteSection, dispatcher, () => {
186
+ throw new Error("must not submit while required field is empty");
187
+ });
188
+
189
+ fireEvent.click(rtlScreen.getByTestId("write-form-section-submit"));
190
+
191
+ await waitFor(() => expect(writes).toHaveLength(0));
192
+ });
193
+
194
+ // Pins the only route a writeForm section has to thread the host record's
195
+ // id into its payload (see EditWriteFormSection.handler's doc): a
196
+ // visible:false field never renders an input, but its resolved value still
197
+ // seeds `initial` and so rides along in the submit payload untouched.
198
+ test("a visible:false field's prefilled value rides along in the submit payload", async () => {
199
+ const sectionWithHiddenId: EditWriteFormSectionViewModel = {
200
+ ...noteSection,
201
+ fields: [
202
+ {
203
+ field: "orderId",
204
+ label: "Order",
205
+ type: "text",
206
+ value: "order-42",
207
+ visible: false,
208
+ readOnly: false,
209
+ required: false,
210
+ },
211
+ ...noteSection.fields,
212
+ ],
213
+ };
214
+ const { dispatcher, writes } = stubDispatcher();
215
+ renderWriteForm(sectionWithHiddenId, dispatcher, noop);
216
+
217
+ expect(rtlScreen.queryByTestId("input-orderId")).toBeNull();
218
+
219
+ fireEvent.change(rtlScreen.getByLabelText("note"), { target: { value: "hi" } });
220
+ fireEvent.click(rtlScreen.getByTestId("write-form-section-submit"));
221
+
222
+ await waitFor(() => expect(writes).toHaveLength(1));
223
+ expect(writes[0]?.payload).toEqual({ orderId: "order-42", note: "hi" });
224
+ });
225
+ });
@@ -0,0 +1,60 @@
1
+ import type { EditFieldViewModel, FieldIssue } from "@cosmicdrift/kumiko-headless";
2
+ import type { ReactNode } from "react";
3
+ import type { usePrimitives } from "../primitives";
4
+ import { RenderField } from "./render-field";
5
+
6
+ // Extracted out of render-edit.tsx so write-form-section.tsx can reuse it
7
+ // without a circular import between the two components.
8
+ export type GridCellForFieldProps = {
9
+ readonly field: EditFieldViewModel;
10
+ readonly columns: number;
11
+ readonly issues: readonly FieldIssue[] | undefined;
12
+ readonly onChange: (value: unknown) => void;
13
+ readonly GridCell: ReturnType<typeof usePrimitives>["GridCell"];
14
+ /** Tier 2.7e-3: passed through so Reference fields can build the correct
15
+ * lookup query QN (`<feature>:query:<refEntity>:list`). */
16
+ readonly featureName: string;
17
+ readonly labelAppendix?: ReactNode;
18
+ readonly fieldAppendix?: ReactNode;
19
+ /** Full issues-by-path map (FormSnapshot.errors) — passed through for
20
+ * embedded-list fields, which bucket row-/cell-level issues themselves. */
21
+ readonly allIssues: Readonly<Record<string, readonly FieldIssue[]>>;
22
+ /** Passed through to RenderField unchanged — see RenderEditProps.valueDisplay. */
23
+ readonly valueDisplay: "form" | "text";
24
+ /** Passed through to RenderField as `row` — see RenderFieldProps.row. */
25
+ readonly row: Readonly<Record<string, unknown>>;
26
+ };
27
+
28
+ export function GridCellForField({
29
+ field,
30
+ columns,
31
+ issues,
32
+ onChange,
33
+ GridCell,
34
+ featureName,
35
+ labelAppendix,
36
+ fieldAppendix,
37
+ allIssues,
38
+ valueDisplay,
39
+ row,
40
+ }: GridCellForFieldProps): ReactNode {
41
+ // RenderField renders nothing for a hidden field, but the GridCell around it still claims the row.
42
+ if (!field.visible) return null;
43
+
44
+ const effectiveSpan = field.span !== undefined ? Math.min(field.span, columns) : 1;
45
+ return (
46
+ <GridCell span={effectiveSpan}>
47
+ <RenderField
48
+ field={field}
49
+ {...(issues !== undefined && { issues })}
50
+ onChange={onChange}
51
+ featureName={featureName}
52
+ {...(labelAppendix !== undefined && { labelAppendix })}
53
+ {...(fieldAppendix !== undefined && { fieldAppendix })}
54
+ allIssues={allIssues}
55
+ valueDisplay={valueDisplay}
56
+ row={row}
57
+ />
58
+ </GridCell>
59
+ );
60
+ }
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  EntityDefinition,
3
3
  EntityListScreenDefinition,
4
+ RowActionNavigate,
4
5
  } from "@cosmicdrift/kumiko-framework/ui-types";
5
6
  import { normalizeListColumn } from "@cosmicdrift/kumiko-framework/ui-types";
6
7
  import type {
@@ -10,7 +11,13 @@ import type {
10
11
  } from "@cosmicdrift/kumiko-headless";
11
12
  import { type ReactNode, useMemo } from "react";
12
13
  import { useNav } from "../app/nav";
14
+ import {
15
+ buildProjectionRowActions,
16
+ rowActionModeFor,
17
+ runProjectionRowNavigate,
18
+ } from "../app/row-actions";
13
19
  import { dispatcherErrorText } from "../app/write-failed-error";
20
+ import { useOptionalDispatcher } from "../context/dispatcher-context";
14
21
  import { useQuery } from "../hooks/use-query";
15
22
  import { useTranslation } from "../i18n";
16
23
  import { usePrimitives } from "../primitives";
@@ -57,6 +64,7 @@ export function RelatedListSection({
57
64
  const t = useTranslation();
58
65
  const effectiveTranslate = translate ?? t;
59
66
  const nav = useNav();
67
+ const dispatcher = useOptionalDispatcher();
60
68
 
61
69
  const entity = useMemo(() => synthesizeRelatedListEntity(section.columns), [section.columns]);
62
70
  const listScreen = useMemo(
@@ -84,6 +92,12 @@ export function RelatedListSection({
84
92
  const rowsQuery = useQuery<PagedRows>(section.query, payload);
85
93
 
86
94
  const rowClick = section.rowClick;
95
+ // A row-body click target comes from EITHER the legacy `rowClick` field OR
96
+ // a `rowActions` entry marked rowClick:true — the boot-validator rejects
97
+ // both being set, so this order is just a fallback, not a precedence rule.
98
+ const rowClickAction = section.rowActions?.find(
99
+ (a): a is RowActionNavigate => a.kind === "navigate" && a.rowClick === true,
100
+ );
87
101
  const onRowClick =
88
102
  rowClick !== undefined
89
103
  ? (row: ListRowViewModel) => {
@@ -91,7 +105,26 @@ export function RelatedListSection({
91
105
  if (id === "") return;
92
106
  nav.navigate({ entity: rowClick.entity, id });
93
107
  }
94
- : undefined;
108
+ : rowClickAction !== undefined
109
+ ? (row: ListRowViewModel) => runProjectionRowNavigate(nav, rowClickAction, row)
110
+ : undefined;
111
+
112
+ // Same execution path as projectionList's rowActions (kumiko-screen.tsx) —
113
+ // a relatedList row has the identical "no guaranteed id field" shape a
114
+ // query-projection row has, so navigate/writeHandler dispatch is shared
115
+ // rather than a second implementation (fw editable-detail-screens).
116
+ const rowActions = useMemo(
117
+ () =>
118
+ buildProjectionRowActions({
119
+ rowActions: section.rowActions,
120
+ translate: effectiveTranslate,
121
+ dispatcher,
122
+ nav,
123
+ refetch: rowsQuery.refetch,
124
+ }),
125
+ [section.rowActions, effectiveTranslate, dispatcher, nav, rowsQuery.refetch],
126
+ );
127
+ const rowActionMode = rowActionModeFor(rowActions);
95
128
 
96
129
  const content =
97
130
  rowsQuery.loading && rowsQuery.data === null ? (
@@ -110,6 +143,8 @@ export function RelatedListSection({
110
143
  featureName={featureName}
111
144
  translate={effectiveTranslate}
112
145
  {...(onRowClick !== undefined && { onRowClick })}
146
+ {...(rowActions !== undefined && { rowActions })}
147
+ {...(rowActionMode !== undefined && { rowActionMode })}
113
148
  />
114
149
  );
115
150
 
@@ -41,9 +41,10 @@ export function shouldNotifyCaller(
41
41
  return !(result.isSuccess && !extensionsPersisted);
42
42
  }
43
43
 
44
- // Extension and relatedList sections skip the `fields` filter (neither has a
45
- // `field`-name set that filtering applies to); a `fields` section left with
46
- // zero fields after filtering is dropped, not rendered empty.
44
+ // Extension, relatedList and writeForm sections skip the `fields` filter
45
+ // (writeForm's fields belong to its own independent form, not the host's);
46
+ // a `fields` section left with zero fields after filtering is dropped, not
47
+ // rendered empty.
47
48
  export function filterEditSections(
48
49
  sections: readonly EditSectionViewModel[],
49
50
  fieldsFilter: readonly string[] | undefined,
@@ -52,7 +53,11 @@ export function filterEditSections(
52
53
  const filterSet = new Set(fieldsFilter);
53
54
  const result: EditSectionViewModel[] = [];
54
55
  for (const section of sections) {
55
- if (section.kind === "extension" || section.kind === "relatedList") {
56
+ if (
57
+ section.kind === "extension" ||
58
+ section.kind === "relatedList" ||
59
+ section.kind === "writeForm"
60
+ ) {
56
61
  result.push(section);
57
62
  continue;
58
63
  }
@@ -13,7 +13,6 @@ import type {
13
13
  EditFieldViewModel,
14
14
  EditSectionViewModel,
15
15
  FieldConditions,
16
- FieldIssue,
17
16
  FormValues,
18
17
  SubmitResult,
19
18
  } from "@cosmicdrift/kumiko-headless";
@@ -37,6 +36,7 @@ import { formatWhen } from "../format-when";
37
36
  import { useForm } from "../hooks/use-form";
38
37
  import { useTranslation } from "../i18n";
39
38
  import { shouldRenderActionsIconOnly, usePrimitives } from "../primitives";
39
+ import { GridCellForField } from "./grid-cell-for-field";
40
40
  import { RelatedListSection } from "./related-list-section";
41
41
  import {
42
42
  filterEditSections,
@@ -44,7 +44,7 @@ import {
44
44
  resolveExtensionEntityId,
45
45
  shouldNotifyCaller,
46
46
  } from "./render-edit-logic";
47
- import { RenderField } from "./render-field";
47
+ import { WriteFormSection } from "./write-form-section";
48
48
 
49
49
  // Qualified names of the bundled `form-draft` feature. Hardcoded because the
50
50
  // renderer must not depend on @cosmicdrift/kumiko-bundled-features; a screen
@@ -606,17 +606,18 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
606
606
  const filteredSections = useMemo(
607
607
  // A fully-hidden "fields" section (every field in it currently
608
608
  // condition-hidden) must not occupy a wizard step; it would render
609
- // empty and block Back/Next on nothing. Extension and relatedList
610
- // sections carry no `visible` (they own their own lifecycle / run
611
- // their own query), so they always pass through. A section with no
612
- // fields at all (e.g. a review-only step) has `visible: fields.some(...)`
613
- // = false vacuously; that's "no fields to hide", not "hidden", so it
614
- // stays too (fw#1901).
609
+ // empty and block Back/Next on nothing. Extension, relatedList and
610
+ // writeForm sections carry no `visible` (they own their own lifecycle /
611
+ // run their own query / own submit), so they always pass through. A
612
+ // section with no fields at all (e.g. a review-only step) has
613
+ // `visible: fields.some(...)` = false vacuously; that's "no fields to
614
+ // hide", not "hidden", so it stays too (fw#1901).
615
615
  () =>
616
616
  filterEditSections(vm.sections, fieldsFilter).filter(
617
617
  (section) =>
618
618
  section.kind === "extension" ||
619
619
  section.kind === "relatedList" ||
620
+ section.kind === "writeForm" ||
620
621
  section.fields.length === 0 ||
621
622
  section.visible,
622
623
  ),
@@ -1211,6 +1212,21 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1211
1212
  />
1212
1213
  );
1213
1214
  }
1215
+ if (section.kind === "writeForm") {
1216
+ // Own submit button + dispatcher call, entirely independent of
1217
+ // this screen's (nonexistent, on projectionDetail) form submit —
1218
+ // rejected at boot in wizard layouts, so no WizardStepGroup here.
1219
+ return (
1220
+ <WriteFormSection
1221
+ key={section.title ?? `write-form-${sectionIndex}`}
1222
+ section={section}
1223
+ featureName={featureName}
1224
+ translate={translate}
1225
+ hideTitle={hideSectionTitles}
1226
+ onSubmitted={() => onReload?.()}
1227
+ />
1228
+ );
1229
+ }
1214
1230
  if (!section.visible) return null;
1215
1231
  // Section-Header unterdrücken wenn er den Form-Titel der
1216
1232
  // Action-Bar 1:1 wiederholen würde (typisch bei Single-Section-
@@ -1307,60 +1323,3 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1307
1323
  </ExtensionFormRegistryProvider>
1308
1324
  );
1309
1325
  }
1310
-
1311
- // Winziger Wrapper der die span-Logik kapselt und die Field-Cell in
1312
- // die Grid platziert. Eigene Component damit die map-Callback oben
1313
- // schlank bleibt.
1314
- type GridCellForFieldProps = {
1315
- readonly field: EditFieldViewModel;
1316
- readonly columns: number;
1317
- readonly issues: readonly FieldIssue[] | undefined;
1318
- readonly onChange: (value: unknown) => void;
1319
- readonly GridCell: ReturnType<typeof usePrimitives>["GridCell"];
1320
- /** Tier 2.7e-3: durchgereicht damit Reference-Felder die richtige
1321
- * Lookup-Query-QN bauen können (`<feature>:query:<refEntity>:list`). */
1322
- readonly featureName: string;
1323
- readonly labelAppendix?: ReactNode;
1324
- readonly fieldAppendix?: ReactNode;
1325
- /** Full issues-by-path map (FormSnapshot.errors) — passed through for
1326
- * embedded-list fields, which bucket row-/cell-level issues themselves. */
1327
- readonly allIssues: Readonly<Record<string, readonly FieldIssue[]>>;
1328
- /** Passed through to RenderField unchanged — see RenderEditProps.valueDisplay. */
1329
- readonly valueDisplay: "form" | "text";
1330
- /** Passed through to RenderField as `row` — see RenderFieldProps.row. */
1331
- readonly row: Readonly<Record<string, unknown>>;
1332
- };
1333
-
1334
- function GridCellForField({
1335
- field,
1336
- columns,
1337
- issues,
1338
- onChange,
1339
- GridCell,
1340
- featureName,
1341
- labelAppendix,
1342
- fieldAppendix,
1343
- allIssues,
1344
- valueDisplay,
1345
- row,
1346
- }: GridCellForFieldProps): ReactNode {
1347
- // RenderField renders nothing for a hidden field, but the GridCell around it still claims the row.
1348
- if (!field.visible) return null;
1349
-
1350
- const effectiveSpan = field.span !== undefined ? Math.min(field.span, columns) : 1;
1351
- return (
1352
- <GridCell span={effectiveSpan}>
1353
- <RenderField
1354
- field={field}
1355
- {...(issues !== undefined && { issues })}
1356
- onChange={onChange}
1357
- featureName={featureName}
1358
- {...(labelAppendix !== undefined && { labelAppendix })}
1359
- {...(fieldAppendix !== undefined && { fieldAppendix })}
1360
- allIssues={allIssues}
1361
- valueDisplay={valueDisplay}
1362
- row={row}
1363
- />
1364
- </GridCell>
1365
- );
1366
- }
@@ -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
  }