@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 +4 -4
- package/src/app/__tests__/projection-detail-singleton.test.tsx +191 -0
- package/src/app/kumiko-screen.tsx +21 -6
- package/src/components/__tests__/related-list-section.test.tsx +104 -28
- package/src/components/__tests__/render-field-json-format.test.tsx +164 -0
- package/src/components/__tests__/write-form-section.test.tsx +24 -4
- package/src/components/render-field.tsx +35 -4
- package/src/components/write-form-section.tsx +23 -13
- package/src/index.ts +1 -0
- package/src/primitives.tsx +18 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "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.
|
|
19
|
-
"@cosmicdrift/kumiko-headless": "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.
|
|
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
|
-
|
|
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({
|
|
2166
|
+
nav.navigate({
|
|
2167
|
+
screenId: targetScreenId,
|
|
2168
|
+
...(effectiveEntityId !== undefined && { entityId: effectiveEntityId }),
|
|
2169
|
+
}),
|
|
2161
2170
|
};
|
|
2162
|
-
}, [editScreen, effectiveTranslate, nav,
|
|
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 (
|
|
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
|
-
|
|
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={
|
|
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 ?? [])
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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({
|
|
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 &&
|
|
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
|
-
|
|
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
|
|
157
|
+
{...(!hideTitle && section.title !== undefined && { title: section.title })}
|
|
149
158
|
{...(section.icon !== undefined && { icon: section.icon })}
|
|
150
|
-
|
|
159
|
+
actions={submitButton}
|
|
160
|
+
testId={`write-form-${section.title ?? "section"}`}
|
|
151
161
|
>
|
|
152
162
|
{content}
|
|
153
163
|
</Section>
|
package/src/index.ts
CHANGED
package/src/primitives.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
452
|
-
*
|
|
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
|