@cosmicdrift/kumiko-renderer 0.238.0 → 0.240.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/kumiko-screen.tsx +45 -179
- 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__/write-form-section.test.tsx +205 -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/write-form-section.tsx +155 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { RowAction } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
+
import type { Dispatcher, EditRelatedListSectionViewModel } from "@cosmicdrift/kumiko-headless";
|
|
4
|
+
import { render, screen as rtlScreen, waitFor } from "@testing-library/react";
|
|
5
|
+
import type { ComponentType, ReactNode } from "react";
|
|
6
|
+
import { NavProvider } from "../../app/nav";
|
|
7
|
+
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
8
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
9
|
+
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
10
|
+
import {
|
|
11
|
+
type CorePrimitives,
|
|
12
|
+
type DataTableProps,
|
|
13
|
+
PrimitivesProvider,
|
|
14
|
+
type SectionProps,
|
|
15
|
+
} from "../../primitives";
|
|
16
|
+
import { RelatedListSection } from "../related-list-section";
|
|
17
|
+
|
|
18
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
19
|
+
const noop = () => {};
|
|
20
|
+
|
|
21
|
+
const testSection: ComponentType<SectionProps> = ({ testId, children }) => (
|
|
22
|
+
<div data-testid={testId}>{children}</div>
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
// A minimal DataTable stub: renders one button per row per row-action,
|
|
26
|
+
// wired straight to the action's onTrigger — enough to prove
|
|
27
|
+
// RelatedListSection wires rowActions through to a real dispatch, without
|
|
28
|
+
// needing the production DataTable's sorting/paging/kebab-menu chrome.
|
|
29
|
+
const testDataTable: ComponentType<DataTableProps> = ({ rows, rowActions }) => (
|
|
30
|
+
<table>
|
|
31
|
+
<tbody>
|
|
32
|
+
{rows.map((row) => (
|
|
33
|
+
<tr key={row.id} data-testid={`row-${row.id}`}>
|
|
34
|
+
<td>
|
|
35
|
+
{(rowActions ?? []).map((action) => (
|
|
36
|
+
<button
|
|
37
|
+
key={action.id}
|
|
38
|
+
type="button"
|
|
39
|
+
data-testid={`action-${action.id}-${row.id}`}
|
|
40
|
+
onClick={() => void action.onTrigger(row)}
|
|
41
|
+
>
|
|
42
|
+
{action.label}
|
|
43
|
+
</button>
|
|
44
|
+
))}
|
|
45
|
+
</td>
|
|
46
|
+
</tr>
|
|
47
|
+
))}
|
|
48
|
+
</tbody>
|
|
49
|
+
</table>
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
function testPrimitives(): CorePrimitives {
|
|
53
|
+
return {
|
|
54
|
+
Button: noop,
|
|
55
|
+
Banner: ({ children, testId }: { children?: ReactNode; testId?: string }) => (
|
|
56
|
+
<div data-testid={testId}>{children}</div>
|
|
57
|
+
),
|
|
58
|
+
Field: passChildren,
|
|
59
|
+
Input: noop,
|
|
60
|
+
DataTable: testDataTable,
|
|
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(): {
|
|
78
|
+
dispatcher: Dispatcher;
|
|
79
|
+
writes: Array<{ type: string; payload: unknown }>;
|
|
80
|
+
queryCount: () => number;
|
|
81
|
+
} {
|
|
82
|
+
const writes: Array<{ type: string; payload: unknown }> = [];
|
|
83
|
+
let queryCalls = 0;
|
|
84
|
+
const dispatcher: Dispatcher = {
|
|
85
|
+
write: (async (type, payload) => {
|
|
86
|
+
writes.push({ type, payload });
|
|
87
|
+
return { isSuccess: true, data: null };
|
|
88
|
+
}) as Dispatcher["write"],
|
|
89
|
+
query: (async () => {
|
|
90
|
+
queryCalls += 1;
|
|
91
|
+
return {
|
|
92
|
+
isSuccess: true,
|
|
93
|
+
data: { rows: [{ id: "r1", name: "Alice" }], nextCursor: null },
|
|
94
|
+
};
|
|
95
|
+
}) as Dispatcher["query"],
|
|
96
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as Dispatcher["batch"],
|
|
97
|
+
statusStore: {
|
|
98
|
+
getState: () => "online",
|
|
99
|
+
subscribe: () => () => {},
|
|
100
|
+
} as unknown as Dispatcher["statusStore"],
|
|
101
|
+
async *stream() {},
|
|
102
|
+
pendingWrites: () => [],
|
|
103
|
+
pendingFiles: () => [],
|
|
104
|
+
};
|
|
105
|
+
return { dispatcher, writes, queryCount: () => queryCalls };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const rowActions: readonly RowAction[] = [
|
|
109
|
+
{
|
|
110
|
+
id: "resend",
|
|
111
|
+
label: "actions.resend",
|
|
112
|
+
handler: "orders:write:resend",
|
|
113
|
+
payload: { pick: ["id"] },
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
const historySection: EditRelatedListSectionViewModel = {
|
|
118
|
+
kind: "relatedList",
|
|
119
|
+
title: "History",
|
|
120
|
+
query: "orders:query:notifications:list",
|
|
121
|
+
columns: [{ field: "name" }],
|
|
122
|
+
rowActions,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function renderRelatedList(dispatcher: Dispatcher) {
|
|
126
|
+
return render(
|
|
127
|
+
<LocaleProvider
|
|
128
|
+
resolver={createStaticLocaleResolver({ locale: "en-US" })}
|
|
129
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
130
|
+
>
|
|
131
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
132
|
+
<PrimitivesProvider value={testPrimitives()}>
|
|
133
|
+
<NavProvider
|
|
134
|
+
value={{
|
|
135
|
+
route: undefined,
|
|
136
|
+
navigate: noop,
|
|
137
|
+
replace: noop,
|
|
138
|
+
hrefFor: () => "#",
|
|
139
|
+
searchParams: {},
|
|
140
|
+
setSearchParams: noop,
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
<RelatedListSection section={historySection} parentId="order-1" featureName="orders" />
|
|
144
|
+
</NavProvider>
|
|
145
|
+
</PrimitivesProvider>
|
|
146
|
+
</DispatcherProvider>
|
|
147
|
+
</LocaleProvider>,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
describe("RelatedListSection — rowActions", () => {
|
|
152
|
+
test("clicking a row action dispatches through the configured write-handler with the extracted payload, then refetches", async () => {
|
|
153
|
+
const { dispatcher, writes, queryCount } = stubDispatcher();
|
|
154
|
+
renderRelatedList(dispatcher);
|
|
155
|
+
|
|
156
|
+
await waitFor(() => expect(rtlScreen.getByTestId("row-r1")).toBeTruthy());
|
|
157
|
+
rtlScreen.getByTestId("action-resend-r1").click();
|
|
158
|
+
|
|
159
|
+
await waitFor(() => expect(writes).toHaveLength(1));
|
|
160
|
+
expect(writes[0]).toEqual({ type: "orders:write:resend", payload: { id: "r1" } });
|
|
161
|
+
await waitFor(() => expect(queryCount()).toBe(2));
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -123,6 +123,20 @@ describe("hasEditableSection", () => {
|
|
|
123
123
|
expect(hasEditableSection([])).toBe(false);
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
// writeForm owns its own submit button (write-form-section-submit) —
|
|
127
|
+
// counting it here would additionally render the screen-level Save button
|
|
128
|
+
// (render-edit-submit) on a projectionDetail, which has no form submit to
|
|
129
|
+
// wire it to.
|
|
130
|
+
test("writeForm section → false (has its own submit, not the screen's)", () => {
|
|
131
|
+
const writeFormSection: EditSectionViewModel = {
|
|
132
|
+
kind: "writeForm",
|
|
133
|
+
columns: 1,
|
|
134
|
+
handler: "orders:write:add-note",
|
|
135
|
+
fields: [field(false)],
|
|
136
|
+
};
|
|
137
|
+
expect(hasEditableSection([writeFormSection])).toBe(false);
|
|
138
|
+
});
|
|
139
|
+
|
|
126
140
|
test("hidden section with editable field → false (user cannot see the field)", () => {
|
|
127
141
|
const hiddenSectionWithEditableField: EditSectionViewModel = {
|
|
128
142
|
kind: "fields",
|
|
@@ -0,0 +1,205 @@
|
|
|
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 }) => (
|
|
21
|
+
<button type="button" data-testid={testId} 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
|
+
const testSection: ComponentType<SectionProps> = ({ testId, children }) => (
|
|
44
|
+
<div data-testid={testId}>{children}</div>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
function testPrimitives(): CorePrimitives {
|
|
48
|
+
return {
|
|
49
|
+
Button: testButton,
|
|
50
|
+
Banner: testBanner,
|
|
51
|
+
Field: passChildren,
|
|
52
|
+
Input: testInput,
|
|
53
|
+
DataTable: noop,
|
|
54
|
+
Form: noop,
|
|
55
|
+
Section: testSection,
|
|
56
|
+
Card: passChildren,
|
|
57
|
+
Grid: passChildren,
|
|
58
|
+
GridCell: passChildren,
|
|
59
|
+
Text: noop,
|
|
60
|
+
Heading: noop,
|
|
61
|
+
Dialog: noop,
|
|
62
|
+
Modal: noop,
|
|
63
|
+
Lightbox: noop,
|
|
64
|
+
ConfigSourceBadge: noop,
|
|
65
|
+
ConfigCascadeView: noop,
|
|
66
|
+
Link: noop,
|
|
67
|
+
} as unknown as CorePrimitives;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function stubDispatcher(writeImpl?: Dispatcher["write"]): {
|
|
71
|
+
dispatcher: Dispatcher;
|
|
72
|
+
writes: Array<{ type: string; payload: unknown }>;
|
|
73
|
+
} {
|
|
74
|
+
const writes: Array<{ type: string; payload: unknown }> = [];
|
|
75
|
+
const dispatcher: Dispatcher = {
|
|
76
|
+
write: (async (type, payload) => {
|
|
77
|
+
writes.push({ type, payload });
|
|
78
|
+
if (writeImpl) return writeImpl(type, payload);
|
|
79
|
+
return { isSuccess: true, data: { id: "n1" } };
|
|
80
|
+
}) as Dispatcher["write"],
|
|
81
|
+
query: (async () => ({ isSuccess: true, data: {} })) as Dispatcher["query"],
|
|
82
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as Dispatcher["batch"],
|
|
83
|
+
statusStore: {
|
|
84
|
+
getState: () => "online",
|
|
85
|
+
subscribe: () => () => {},
|
|
86
|
+
} as unknown as Dispatcher["statusStore"],
|
|
87
|
+
async *stream() {},
|
|
88
|
+
pendingWrites: () => [],
|
|
89
|
+
pendingFiles: () => [],
|
|
90
|
+
};
|
|
91
|
+
return { dispatcher, writes };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const noteSection: EditWriteFormSectionViewModel = {
|
|
95
|
+
kind: "writeForm",
|
|
96
|
+
title: "Add note",
|
|
97
|
+
columns: 1,
|
|
98
|
+
handler: "orders:write:add-note",
|
|
99
|
+
fields: [
|
|
100
|
+
{
|
|
101
|
+
field: "note",
|
|
102
|
+
label: "Note",
|
|
103
|
+
type: "text",
|
|
104
|
+
value: "",
|
|
105
|
+
visible: true,
|
|
106
|
+
readOnly: false,
|
|
107
|
+
required: true,
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function renderWriteForm(
|
|
113
|
+
section: EditWriteFormSectionViewModel,
|
|
114
|
+
dispatcher: Dispatcher,
|
|
115
|
+
onSubmitted: () => void,
|
|
116
|
+
) {
|
|
117
|
+
return render(
|
|
118
|
+
<LocaleProvider
|
|
119
|
+
resolver={createStaticLocaleResolver({ locale: "en-US" })}
|
|
120
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
121
|
+
>
|
|
122
|
+
<DispatcherProvider dispatcher={dispatcher}>
|
|
123
|
+
<PrimitivesProvider value={testPrimitives()}>
|
|
124
|
+
<WriteFormSection section={section} featureName="orders" onSubmitted={onSubmitted} />
|
|
125
|
+
</PrimitivesProvider>
|
|
126
|
+
</DispatcherProvider>
|
|
127
|
+
</LocaleProvider>,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
describe("WriteFormSection", () => {
|
|
132
|
+
test("submit dispatches through the section's configured write handler with the entered values", async () => {
|
|
133
|
+
const { dispatcher, writes } = stubDispatcher();
|
|
134
|
+
let submittedCount = 0;
|
|
135
|
+
renderWriteForm(noteSection, dispatcher, () => {
|
|
136
|
+
submittedCount += 1;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
fireEvent.change(rtlScreen.getByLabelText("note"), { target: { value: "Called back" } });
|
|
140
|
+
fireEvent.click(rtlScreen.getByTestId("write-form-section-submit"));
|
|
141
|
+
|
|
142
|
+
await waitFor(() => expect(submittedCount).toBe(1));
|
|
143
|
+
expect(writes).toEqual([{ type: "orders:write:add-note", payload: { note: "Called back" } }]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("a failed write surfaces the error banner and does not call onSubmitted", async () => {
|
|
147
|
+
const { dispatcher } = stubDispatcher(async () => ({
|
|
148
|
+
isSuccess: false,
|
|
149
|
+
error: { code: "conflict", httpStatus: 409, i18nKey: "errors.conflict", message: "conflict" },
|
|
150
|
+
}));
|
|
151
|
+
let submittedCount = 0;
|
|
152
|
+
renderWriteForm(noteSection, dispatcher, () => {
|
|
153
|
+
submittedCount += 1;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
fireEvent.change(rtlScreen.getByLabelText("note"), { target: { value: "x" } });
|
|
157
|
+
fireEvent.click(rtlScreen.getByTestId("write-form-section-submit"));
|
|
158
|
+
|
|
159
|
+
await waitFor(() => expect(rtlScreen.getByTestId("write-form-section-error")).toBeTruthy());
|
|
160
|
+
expect(submittedCount).toBe(0);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("submit is blocked by schema validation when a required field is left empty", async () => {
|
|
164
|
+
const { dispatcher, writes } = stubDispatcher();
|
|
165
|
+
renderWriteForm(noteSection, dispatcher, () => {
|
|
166
|
+
throw new Error("must not submit while required field is empty");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
fireEvent.click(rtlScreen.getByTestId("write-form-section-submit"));
|
|
170
|
+
|
|
171
|
+
await waitFor(() => expect(writes).toHaveLength(0));
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Pins the only route a writeForm section has to thread the host record's
|
|
175
|
+
// id into its payload (see EditWriteFormSection.handler's doc): a
|
|
176
|
+
// visible:false field never renders an input, but its resolved value still
|
|
177
|
+
// seeds `initial` and so rides along in the submit payload untouched.
|
|
178
|
+
test("a visible:false field's prefilled value rides along in the submit payload", async () => {
|
|
179
|
+
const sectionWithHiddenId: EditWriteFormSectionViewModel = {
|
|
180
|
+
...noteSection,
|
|
181
|
+
fields: [
|
|
182
|
+
{
|
|
183
|
+
field: "orderId",
|
|
184
|
+
label: "Order",
|
|
185
|
+
type: "text",
|
|
186
|
+
value: "order-42",
|
|
187
|
+
visible: false,
|
|
188
|
+
readOnly: false,
|
|
189
|
+
required: false,
|
|
190
|
+
},
|
|
191
|
+
...noteSection.fields,
|
|
192
|
+
],
|
|
193
|
+
};
|
|
194
|
+
const { dispatcher, writes } = stubDispatcher();
|
|
195
|
+
renderWriteForm(sectionWithHiddenId, dispatcher, noop);
|
|
196
|
+
|
|
197
|
+
expect(rtlScreen.queryByTestId("input-orderId")).toBeNull();
|
|
198
|
+
|
|
199
|
+
fireEvent.change(rtlScreen.getByLabelText("note"), { target: { value: "hi" } });
|
|
200
|
+
fireEvent.click(rtlScreen.getByTestId("write-form-section-submit"));
|
|
201
|
+
|
|
202
|
+
await waitFor(() => expect(writes).toHaveLength(1));
|
|
203
|
+
expect(writes[0]?.payload).toEqual({ orderId: "order-42", note: "hi" });
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -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
|
-
}
|