@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.
- package/package.json +4 -4
- package/src/app/__tests__/projection-detail-shim.test.ts +43 -0
- package/src/app/__tests__/projection-detail-singleton.test.tsx +191 -0
- package/src/app/kumiko-screen.tsx +66 -185
- package/src/app/row-actions.ts +201 -0
- package/src/components/__tests__/related-list-section.test.tsx +163 -0
- package/src/components/__tests__/render-edit-logic.test.ts +14 -0
- package/src/components/__tests__/render-field-json-format.test.tsx +164 -0
- package/src/components/__tests__/write-form-section.test.tsx +225 -0
- package/src/components/grid-cell-for-field.tsx +60 -0
- package/src/components/related-list-section.tsx +36 -1
- package/src/components/render-edit-logic.ts +9 -4
- package/src/components/render-edit.tsx +24 -65
- package/src/components/render-field.tsx +35 -4
- package/src/components/write-form-section.tsx +165 -0
- package/src/index.ts +1 -0
- package/src/primitives.tsx +15 -0
|
@@ -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
|
|
45
|
-
//
|
|
46
|
-
// zero fields after filtering is dropped, not
|
|
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 (
|
|
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 {
|
|
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
|
|
610
|
-
// sections carry no `visible` (they own their own lifecycle /
|
|
611
|
-
// their own query), so they always pass through. A
|
|
612
|
-
// fields at all (e.g. a review-only step) has
|
|
613
|
-
// = false vacuously; that's "no fields to
|
|
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({
|
|
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
|
}
|