@cosmicdrift/kumiko-renderer 0.183.2 → 0.185.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 +3 -3
- package/src/components/__tests__/embedded-list-field.test.tsx +319 -0
- package/src/components/embedded-list-field.tsx +247 -0
- package/src/components/render-edit.tsx +6 -0
- package/src/components/render-field.tsx +16 -1
- package/src/hooks/use-query.ts +1 -1
- package/src/i18n-defaults.ts +23 -0
- package/src/index.ts +5 -1
- package/src/primitives.tsx +105 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.185.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.185.0",
|
|
19
|
+
"@cosmicdrift/kumiko-headless": "0.185.0",
|
|
20
20
|
"react": "^19.2.6",
|
|
21
21
|
"temporal-polyfill": "^0.3.2"
|
|
22
22
|
},
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// EmbeddedListField Tests — RTL + happy-dom, style follows
|
|
2
|
+
// render-field-app-locale.test.tsx: mount under real Locale/Dispatcher/
|
|
3
|
+
// Primitives providers, capture the props the (mocked) EmbeddedListInput
|
|
4
|
+
// primitive receives, then invoke its callbacks the way the real
|
|
5
|
+
// primitive would (a user clicking/typing) and assert the resulting
|
|
6
|
+
// onChange call.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import type { Dispatcher, EditFieldViewModel, FieldIssue } from "@cosmicdrift/kumiko-headless";
|
|
10
|
+
import { render, waitFor } from "@testing-library/react";
|
|
11
|
+
import type { ComponentType, ReactNode } from "react";
|
|
12
|
+
import { DispatcherProvider } from "../../context/dispatcher-context";
|
|
13
|
+
import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
|
|
14
|
+
import { kumikoDefaultTranslations } from "../../i18n-defaults";
|
|
15
|
+
import {
|
|
16
|
+
type CorePrimitives,
|
|
17
|
+
type EmbeddedListInputProps,
|
|
18
|
+
PrimitivesProvider,
|
|
19
|
+
} from "../../primitives";
|
|
20
|
+
import { EmbeddedListField } from "../embedded-list-field";
|
|
21
|
+
import { RenderField, type RenderFieldProps } from "../render-field";
|
|
22
|
+
|
|
23
|
+
let captured: EmbeddedListInputProps | undefined;
|
|
24
|
+
const captureEmbeddedListInput: ComponentType<EmbeddedListInputProps> = (props) => {
|
|
25
|
+
captured = props;
|
|
26
|
+
return null;
|
|
27
|
+
};
|
|
28
|
+
const noop = (): ReactNode => null;
|
|
29
|
+
const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
|
|
30
|
+
|
|
31
|
+
function testPrimitives(): CorePrimitives {
|
|
32
|
+
return {
|
|
33
|
+
Button: noop,
|
|
34
|
+
Banner: noop,
|
|
35
|
+
Field: passChildren,
|
|
36
|
+
Input: noop,
|
|
37
|
+
EmbeddedListInput: captureEmbeddedListInput,
|
|
38
|
+
DataTable: noop,
|
|
39
|
+
Form: passChildren,
|
|
40
|
+
Section: passChildren,
|
|
41
|
+
Card: passChildren,
|
|
42
|
+
Grid: passChildren,
|
|
43
|
+
GridCell: passChildren,
|
|
44
|
+
Text: passChildren,
|
|
45
|
+
Heading: noop,
|
|
46
|
+
Dialog: noop,
|
|
47
|
+
Modal: noop,
|
|
48
|
+
Lightbox: noop,
|
|
49
|
+
ConfigSourceBadge: noop,
|
|
50
|
+
ConfigCascadeView: noop,
|
|
51
|
+
Link: noop,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type ProductRow = { readonly id: string; readonly name: string };
|
|
56
|
+
|
|
57
|
+
function stubDispatcher(productRows: readonly ProductRow[] = []): Dispatcher {
|
|
58
|
+
return {
|
|
59
|
+
write: (async () => ({ isSuccess: true, data: {} })) as unknown as Dispatcher["write"],
|
|
60
|
+
query: (async (type: string) => {
|
|
61
|
+
if (type === "invoices:query:product:list") {
|
|
62
|
+
return { isSuccess: true, data: { rows: productRows } };
|
|
63
|
+
}
|
|
64
|
+
return { isSuccess: true, data: { rows: [] } };
|
|
65
|
+
}) as unknown as Dispatcher["query"],
|
|
66
|
+
batch: (async () => ({ isSuccess: true, results: [] })) as unknown as Dispatcher["batch"],
|
|
67
|
+
statusStore: {
|
|
68
|
+
getState: () => "online",
|
|
69
|
+
subscribe: () => () => {},
|
|
70
|
+
} as unknown as Dispatcher["statusStore"],
|
|
71
|
+
async *stream() {},
|
|
72
|
+
pendingWrites: () => [],
|
|
73
|
+
pendingFiles: () => [],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function invoiceLinesField(overrides: Partial<EditFieldViewModel> = {}): EditFieldViewModel {
|
|
78
|
+
return {
|
|
79
|
+
field: "lines",
|
|
80
|
+
label: "Lines",
|
|
81
|
+
type: "embedded",
|
|
82
|
+
value: [],
|
|
83
|
+
visible: true,
|
|
84
|
+
readOnly: false,
|
|
85
|
+
required: true,
|
|
86
|
+
embeddedListCells: [
|
|
87
|
+
{
|
|
88
|
+
field: "product",
|
|
89
|
+
label: "Product",
|
|
90
|
+
type: "reference",
|
|
91
|
+
required: true,
|
|
92
|
+
refEntity: "product",
|
|
93
|
+
refFeature: "invoices",
|
|
94
|
+
refLabelField: "name",
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
field: "unit",
|
|
98
|
+
label: "Unit",
|
|
99
|
+
type: "select",
|
|
100
|
+
required: true,
|
|
101
|
+
options: ["pcs", "hours", "kg"],
|
|
102
|
+
},
|
|
103
|
+
{ field: "quantity", label: "Qty", type: "number", required: true },
|
|
104
|
+
{ field: "unitPrice", label: "Unit Price", type: "money", required: true },
|
|
105
|
+
{ field: "amount", label: "Amount", type: "money", required: false },
|
|
106
|
+
],
|
|
107
|
+
embeddedListDerived: { amount: { op: "multiply", from: ["quantity", "unitPrice"] } },
|
|
108
|
+
embeddedListTotals: ["amount"],
|
|
109
|
+
embeddedListMinItems: 1,
|
|
110
|
+
embeddedListMaxItems: 5,
|
|
111
|
+
...overrides,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function renderEmbeddedListField(
|
|
116
|
+
field: EditFieldViewModel,
|
|
117
|
+
onChange: (v: unknown) => void,
|
|
118
|
+
allIssues: Readonly<Record<string, readonly FieldIssue[]>> = {},
|
|
119
|
+
productRows: readonly ProductRow[] = [],
|
|
120
|
+
): void {
|
|
121
|
+
captured = undefined;
|
|
122
|
+
render(
|
|
123
|
+
<LocaleProvider
|
|
124
|
+
resolver={createStaticLocaleResolver()}
|
|
125
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
126
|
+
>
|
|
127
|
+
<DispatcherProvider dispatcher={stubDispatcher(productRows)}>
|
|
128
|
+
<PrimitivesProvider value={testPrimitives()}>
|
|
129
|
+
<EmbeddedListField
|
|
130
|
+
field={field}
|
|
131
|
+
id="kumiko-edit-lines"
|
|
132
|
+
onChange={onChange}
|
|
133
|
+
allIssues={allIssues}
|
|
134
|
+
featureName="invoices"
|
|
135
|
+
/>
|
|
136
|
+
</PrimitivesProvider>
|
|
137
|
+
</DispatcherProvider>
|
|
138
|
+
</LocaleProvider>,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe("EmbeddedListField — cell change recomputes derived", () => {
|
|
143
|
+
test("changing quantity updates the cell and recomputes amount in the same row", () => {
|
|
144
|
+
const rows = [{ product: "p1", unit: "pcs", quantity: 2, unitPrice: 500, amount: 1000 }];
|
|
145
|
+
let lastValue: unknown;
|
|
146
|
+
renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
147
|
+
lastValue = v;
|
|
148
|
+
});
|
|
149
|
+
captured?.onCellChange(0, "quantity", 4);
|
|
150
|
+
expect(lastValue).toEqual([
|
|
151
|
+
{ product: "p1", unit: "pcs", quantity: 4, unitPrice: 500, amount: 2000 },
|
|
152
|
+
]);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("EmbeddedListField — row operations", () => {
|
|
157
|
+
const rows = [
|
|
158
|
+
{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 },
|
|
159
|
+
{ product: "p2", unit: "hours", quantity: 2, unitPrice: 200, amount: 400 },
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
test("onAddRow appends an empty row with derived recomputed", () => {
|
|
163
|
+
let lastValue: unknown;
|
|
164
|
+
renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
165
|
+
lastValue = v;
|
|
166
|
+
});
|
|
167
|
+
captured?.onAddRow();
|
|
168
|
+
expect(lastValue).toEqual([...rows, { amount: undefined }]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("onRemoveRow removes exactly the targeted row", () => {
|
|
172
|
+
let lastValue: unknown;
|
|
173
|
+
renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
174
|
+
lastValue = v;
|
|
175
|
+
});
|
|
176
|
+
captured?.onRemoveRow(0);
|
|
177
|
+
expect(lastValue).toEqual([rows[1]]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("onDuplicateRow inserts a copy right after the source row", () => {
|
|
181
|
+
let lastValue: unknown;
|
|
182
|
+
renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
183
|
+
lastValue = v;
|
|
184
|
+
});
|
|
185
|
+
captured?.onDuplicateRow(0);
|
|
186
|
+
expect(lastValue).toEqual([rows[0], rows[0], rows[1]]);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("onDuplicateRow is a no-op for an out-of-range index", () => {
|
|
190
|
+
let called = false;
|
|
191
|
+
renderEmbeddedListField(invoiceLinesField({ value: rows }), () => {
|
|
192
|
+
called = true;
|
|
193
|
+
});
|
|
194
|
+
captured?.onDuplicateRow(99);
|
|
195
|
+
expect(called).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("onMoveRow moves an element from one index to another, immutably", () => {
|
|
199
|
+
let lastValue: unknown;
|
|
200
|
+
renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
201
|
+
lastValue = v;
|
|
202
|
+
});
|
|
203
|
+
captured?.onMoveRow(1, 0);
|
|
204
|
+
expect(lastValue).toEqual([rows[1], rows[0]]);
|
|
205
|
+
// original array must stay untouched
|
|
206
|
+
expect(rows).toEqual([
|
|
207
|
+
{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 },
|
|
208
|
+
{ product: "p2", unit: "hours", quantity: 2, unitPrice: 200, amount: 400 },
|
|
209
|
+
]);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("onMoveRow is a no-op for an out-of-range target index", () => {
|
|
213
|
+
let called = false;
|
|
214
|
+
renderEmbeddedListField(invoiceLinesField({ value: rows }), () => {
|
|
215
|
+
called = true;
|
|
216
|
+
});
|
|
217
|
+
captured?.onMoveRow(0, 5);
|
|
218
|
+
expect(called).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("EmbeddedListField — paste coercion", () => {
|
|
223
|
+
test("pastes number/money/select cells with correct coercion per column type", () => {
|
|
224
|
+
const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
|
|
225
|
+
let lastValue: unknown;
|
|
226
|
+
renderEmbeddedListField(invoiceLinesField({ value: rows }), (v) => {
|
|
227
|
+
lastValue = v;
|
|
228
|
+
});
|
|
229
|
+
// Columns in order: product, unit, quantity, unitPrice, amount.
|
|
230
|
+
// Paste starting at column index 1 ("unit") for row 0: unit, quantity, unitPrice.
|
|
231
|
+
captured?.onPasteCells?.(0, 1, [["hours", "3", "12,50"]]);
|
|
232
|
+
expect(lastValue).toEqual([
|
|
233
|
+
{ product: "p1", unit: "hours", quantity: 3, unitPrice: 1250, amount: 3750 },
|
|
234
|
+
]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("paste beyond the current rows appends new rows but never past maxItems", () => {
|
|
238
|
+
const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
|
|
239
|
+
let lastValue: unknown;
|
|
240
|
+
renderEmbeddedListField(invoiceLinesField({ value: rows, embeddedListMaxItems: 2 }), (v) => {
|
|
241
|
+
lastValue = v;
|
|
242
|
+
});
|
|
243
|
+
captured?.onPasteCells?.(0, 2, [
|
|
244
|
+
["1", "100"],
|
|
245
|
+
["2", "200"],
|
|
246
|
+
["3", "300"],
|
|
247
|
+
]);
|
|
248
|
+
const result = lastValue as readonly Record<string, unknown>[];
|
|
249
|
+
expect(result.length).toBe(2);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("EmbeddedListField — reference column populated via useQuery", () => {
|
|
254
|
+
test("referenceOptions come from the product list query", async () => {
|
|
255
|
+
renderEmbeddedListField(invoiceLinesField({ value: [] }), () => {}, {}, [
|
|
256
|
+
{ id: "p1", name: "Widget A" },
|
|
257
|
+
]);
|
|
258
|
+
await waitFor(() => {
|
|
259
|
+
const productColumn = captured?.columns.find((c) => c.field === "product");
|
|
260
|
+
expect(productColumn?.referenceOptions).toEqual([{ value: "p1", label: "Widget A" }]);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("EmbeddedListField — issue grouping wiring", () => {
|
|
266
|
+
test("a lines.0.amount issue is routed as a cellIssue at 0.amount", () => {
|
|
267
|
+
const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
|
|
268
|
+
const issue: FieldIssue = { path: "lines.0.amount", code: "custom", i18nKey: "Bad amount" };
|
|
269
|
+
renderEmbeddedListField(invoiceLinesField({ value: rows }), () => {}, {
|
|
270
|
+
"lines.0.amount": [issue],
|
|
271
|
+
});
|
|
272
|
+
expect(captured?.cellIssues?.["0.amount"]).toEqual([issue]);
|
|
273
|
+
expect(captured?.rowIssues ?? {}).toEqual({});
|
|
274
|
+
expect(captured?.listIssues ?? []).toEqual([]);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("a lines-level issue is routed as a listIssue", () => {
|
|
278
|
+
const issue: FieldIssue = { path: "lines", code: "custom", i18nKey: "Too few lines" };
|
|
279
|
+
renderEmbeddedListField(invoiceLinesField({ value: [] }), () => {}, {
|
|
280
|
+
lines: [issue],
|
|
281
|
+
});
|
|
282
|
+
expect(captured?.listIssues).toEqual([issue]);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
function renderFieldWithEmbeddedList(
|
|
287
|
+
field: EditFieldViewModel,
|
|
288
|
+
allIssues: RenderFieldProps["allIssues"],
|
|
289
|
+
): void {
|
|
290
|
+
captured = undefined;
|
|
291
|
+
render(
|
|
292
|
+
<LocaleProvider
|
|
293
|
+
resolver={createStaticLocaleResolver()}
|
|
294
|
+
fallbackBundles={[kumikoDefaultTranslations]}
|
|
295
|
+
>
|
|
296
|
+
<DispatcherProvider dispatcher={stubDispatcher()}>
|
|
297
|
+
<PrimitivesProvider value={testPrimitives()}>
|
|
298
|
+
<RenderField
|
|
299
|
+
field={field}
|
|
300
|
+
onChange={() => {}}
|
|
301
|
+
allIssues={allIssues}
|
|
302
|
+
featureName="invoices"
|
|
303
|
+
/>
|
|
304
|
+
</PrimitivesProvider>
|
|
305
|
+
</DispatcherProvider>
|
|
306
|
+
</LocaleProvider>,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
describe("RenderField — routes embedded-list fields to EmbeddedListField (plumbing)", () => {
|
|
311
|
+
test("field.embeddedListCells set → RenderField mounts EmbeddedListField, allIssues propagate", () => {
|
|
312
|
+
const rows = [{ product: "p1", unit: "pcs", quantity: 1, unitPrice: 100, amount: 100 }];
|
|
313
|
+
const issue: FieldIssue = { path: "lines.0.amount", code: "custom", i18nKey: "Bad amount" };
|
|
314
|
+
renderFieldWithEmbeddedList(invoiceLinesField({ value: rows }), {
|
|
315
|
+
"lines.0.amount": [issue],
|
|
316
|
+
});
|
|
317
|
+
expect(captured?.cellIssues?.["0.amount"]).toEqual([issue]);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type { EditFieldViewModel, FieldIssue } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
import {
|
|
3
|
+
computeDerivedCellValue,
|
|
4
|
+
groupEmbeddedListIssues,
|
|
5
|
+
sumEmbeddedListColumn,
|
|
6
|
+
} from "@cosmicdrift/kumiko-headless";
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
import { toKebab } from "../app/qn";
|
|
9
|
+
import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits";
|
|
10
|
+
import { useQuery } from "../hooks/use-query";
|
|
11
|
+
import { useTranslation } from "../i18n";
|
|
12
|
+
import type { EmbeddedListColumn, EmbeddedListTotal } from "../primitives";
|
|
13
|
+
import { usePrimitives } from "../primitives";
|
|
14
|
+
|
|
15
|
+
export type EmbeddedListFieldProps = {
|
|
16
|
+
readonly field: EditFieldViewModel;
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly onChange: (value: unknown) => void;
|
|
19
|
+
readonly allIssues: Readonly<Record<string, readonly FieldIssue[]>>;
|
|
20
|
+
readonly featureName: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type EmbeddedRow = Readonly<Record<string, unknown>>;
|
|
24
|
+
|
|
25
|
+
function withRecomputedDerived(
|
|
26
|
+
row: EmbeddedRow,
|
|
27
|
+
derived: EditFieldViewModel["embeddedListDerived"],
|
|
28
|
+
): EmbeddedRow {
|
|
29
|
+
if (derived === undefined) return row;
|
|
30
|
+
const result: Record<string, unknown> = { ...row };
|
|
31
|
+
for (const [derivedField, def] of Object.entries(derived)) {
|
|
32
|
+
const values = def.from.map((src) => {
|
|
33
|
+
const v = result[src];
|
|
34
|
+
return typeof v === "number" ? v : undefined;
|
|
35
|
+
});
|
|
36
|
+
result[derivedField] = computeDerivedCellValue(def.op, values);
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function coerceCellValue(column: EmbeddedListColumn, text: string): unknown {
|
|
42
|
+
switch (column.type) {
|
|
43
|
+
case "text":
|
|
44
|
+
return text;
|
|
45
|
+
case "number":
|
|
46
|
+
case "decimal": {
|
|
47
|
+
if (text.trim() === "") return undefined;
|
|
48
|
+
const n = Number(text);
|
|
49
|
+
return Number.isFinite(n) ? n : undefined;
|
|
50
|
+
}
|
|
51
|
+
case "money": {
|
|
52
|
+
// Money cells are minor-unit integers (cents) in storage — paste
|
|
53
|
+
// arrives as a major-unit decimal string ("12,99"/"12.99"), so ×100.
|
|
54
|
+
if (text.trim() === "") return undefined;
|
|
55
|
+
const n = Number(text.replace(",", "."));
|
|
56
|
+
return Number.isFinite(n) ? Math.round(n * 100) : undefined;
|
|
57
|
+
}
|
|
58
|
+
case "boolean":
|
|
59
|
+
return ["true", "1", "yes", "y", "ja"].includes(text.trim().toLowerCase());
|
|
60
|
+
case "date":
|
|
61
|
+
case "timestamp":
|
|
62
|
+
return text.trim();
|
|
63
|
+
case "select": {
|
|
64
|
+
const match = (column.options ?? []).find(
|
|
65
|
+
(opt) => opt === text || column.optionLabels?.[opt] === text,
|
|
66
|
+
);
|
|
67
|
+
return match;
|
|
68
|
+
}
|
|
69
|
+
case "reference": {
|
|
70
|
+
const match = (column.referenceOptions ?? []).find(
|
|
71
|
+
(o) => o.label === text || o.value === text,
|
|
72
|
+
);
|
|
73
|
+
return match?.value;
|
|
74
|
+
}
|
|
75
|
+
default: {
|
|
76
|
+
const exhaustiveCheck: never = column.type;
|
|
77
|
+
return exhaustiveCheck;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function EmbeddedListField({
|
|
83
|
+
field,
|
|
84
|
+
id,
|
|
85
|
+
onChange,
|
|
86
|
+
allIssues,
|
|
87
|
+
featureName,
|
|
88
|
+
}: EmbeddedListFieldProps): ReactNode {
|
|
89
|
+
const { EmbeddedListInput } = usePrimitives();
|
|
90
|
+
const t = useTranslation();
|
|
91
|
+
|
|
92
|
+
const cells = field.embeddedListCells ?? [];
|
|
93
|
+
const rows = Array.isArray(field.value) ? (field.value as readonly EmbeddedRow[]) : [];
|
|
94
|
+
const derived = field.embeddedListDerived;
|
|
95
|
+
|
|
96
|
+
const referenceCells = cells.filter((c) => c.type === "reference");
|
|
97
|
+
const referenceQueries = referenceCells.map((cell) => {
|
|
98
|
+
const refFeature = cell.refFeature ?? featureName;
|
|
99
|
+
const refEntity = cell.refEntity ?? "";
|
|
100
|
+
const qn = `${toKebab(refFeature)}:query:${toKebab(refEntity)}:list`;
|
|
101
|
+
// biome-ignore lint/correctness/useHookAtTopLevel: referenceCells comes from the entity-schema definition — fixed for the screen's lifetime, not a real conditional-hook risk.
|
|
102
|
+
return useQuery<{ rows: ReadonlyArray<Record<string, unknown>> }>(qn, {
|
|
103
|
+
limit: REFERENCE_COMBOBOX_LIMIT,
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (EmbeddedListInput === undefined) return null;
|
|
108
|
+
|
|
109
|
+
const columns: EmbeddedListColumn[] = cells.map((cell) => {
|
|
110
|
+
const isDerived = derived?.[cell.field] !== undefined;
|
|
111
|
+
if (cell.type === "reference") {
|
|
112
|
+
const idx = referenceCells.indexOf(cell);
|
|
113
|
+
const query = referenceQueries[idx];
|
|
114
|
+
const labelField = cell.refLabelField ?? "id";
|
|
115
|
+
const referenceOptions = (query?.data?.rows ?? []).map((row) => ({
|
|
116
|
+
value: String(row["id"] ?? ""),
|
|
117
|
+
label: String(row[labelField] ?? row["id"] ?? ""),
|
|
118
|
+
}));
|
|
119
|
+
return {
|
|
120
|
+
field: cell.field,
|
|
121
|
+
label: cell.label,
|
|
122
|
+
type: cell.type,
|
|
123
|
+
required: cell.required,
|
|
124
|
+
derived: isDerived,
|
|
125
|
+
referenceOptions,
|
|
126
|
+
referenceLoading: query?.loading ?? false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
field: cell.field,
|
|
131
|
+
label: cell.label,
|
|
132
|
+
type: cell.type,
|
|
133
|
+
required: cell.required,
|
|
134
|
+
derived: isDerived,
|
|
135
|
+
...(cell.options !== undefined && { options: cell.options }),
|
|
136
|
+
...(cell.optionLabels !== undefined && { optionLabels: cell.optionLabels }),
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const totals: EmbeddedListTotal[] = (field.embeddedListTotals ?? []).map((subFieldName) => {
|
|
141
|
+
const cell = cells.find((c) => c.field === subFieldName);
|
|
142
|
+
return {
|
|
143
|
+
field: subFieldName,
|
|
144
|
+
label: cell?.label ?? subFieldName,
|
|
145
|
+
value: sumEmbeddedListColumn(rows, subFieldName),
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const { listIssues, rowIssues, cellIssues } = groupEmbeddedListIssues(allIssues, field.field);
|
|
150
|
+
|
|
151
|
+
function replaceRow(rowIndex: number, updater: (row: EmbeddedRow) => EmbeddedRow): void {
|
|
152
|
+
const nextRows = rows.map((row, i) => (i === rowIndex ? updater(row) : row));
|
|
153
|
+
onChange(nextRows);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function handleCellChange(rowIndex: number, cellField: string, value: unknown): void {
|
|
157
|
+
replaceRow(rowIndex, (row) => withRecomputedDerived({ ...row, [cellField]: value }, derived));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function handleAddRow(): void {
|
|
161
|
+
onChange([...rows, withRecomputedDerived({}, derived)]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function handleRemoveRow(rowIndex: number): void {
|
|
165
|
+
onChange(rows.filter((_, i) => i !== rowIndex));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function handleDuplicateRow(rowIndex: number): void {
|
|
169
|
+
const source = rows[rowIndex];
|
|
170
|
+
if (source === undefined) return;
|
|
171
|
+
const next = [...rows];
|
|
172
|
+
next.splice(rowIndex + 1, 0, { ...source });
|
|
173
|
+
onChange(next);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function handleMoveRow(fromIndex: number, toIndex: number): void {
|
|
177
|
+
if (toIndex < 0 || toIndex >= rows.length) return;
|
|
178
|
+
const next = [...rows];
|
|
179
|
+
const [moved] = next.splice(fromIndex, 1);
|
|
180
|
+
if (moved === undefined) return;
|
|
181
|
+
next.splice(toIndex, 0, moved);
|
|
182
|
+
onChange(next);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function handlePasteCells(
|
|
186
|
+
rowIndex: number,
|
|
187
|
+
columnIndex: number,
|
|
188
|
+
grid: readonly (readonly string[])[],
|
|
189
|
+
): void {
|
|
190
|
+
const maxItems = field.embeddedListMaxItems;
|
|
191
|
+
const nextRows = [...rows];
|
|
192
|
+
const touchedIndices = new Set<number>();
|
|
193
|
+
|
|
194
|
+
grid.forEach((gridRow, gridRowOffset) => {
|
|
195
|
+
const targetRowIndex = rowIndex + gridRowOffset;
|
|
196
|
+
if (targetRowIndex >= nextRows.length) {
|
|
197
|
+
if (maxItems !== undefined && nextRows.length >= maxItems) return;
|
|
198
|
+
nextRows.push({});
|
|
199
|
+
}
|
|
200
|
+
const targetRow = nextRows[targetRowIndex];
|
|
201
|
+
if (targetRow === undefined) return;
|
|
202
|
+
let updatedRow: Record<string, unknown> = { ...targetRow };
|
|
203
|
+
gridRow.forEach((text, gridColOffset) => {
|
|
204
|
+
const column = columns[columnIndex + gridColOffset];
|
|
205
|
+
if (column === undefined) return;
|
|
206
|
+
updatedRow = { ...updatedRow, [column.field]: coerceCellValue(column, text) };
|
|
207
|
+
});
|
|
208
|
+
nextRows[targetRowIndex] = updatedRow;
|
|
209
|
+
touchedIndices.add(targetRowIndex);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const recomputed = nextRows.map((row, i) =>
|
|
213
|
+
touchedIndices.has(i) ? withRecomputedDerived(row, derived) : row,
|
|
214
|
+
);
|
|
215
|
+
onChange(recomputed);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<EmbeddedListInput
|
|
220
|
+
id={id}
|
|
221
|
+
columns={columns}
|
|
222
|
+
rows={rows}
|
|
223
|
+
totals={totals}
|
|
224
|
+
currency={field.embeddedListCurrency}
|
|
225
|
+
disabled={field.readOnly}
|
|
226
|
+
minItems={field.embeddedListMinItems}
|
|
227
|
+
maxItems={field.embeddedListMaxItems}
|
|
228
|
+
listIssues={listIssues}
|
|
229
|
+
rowIssues={rowIssues}
|
|
230
|
+
cellIssues={cellIssues}
|
|
231
|
+
onCellChange={handleCellChange}
|
|
232
|
+
onAddRow={handleAddRow}
|
|
233
|
+
onRemoveRow={handleRemoveRow}
|
|
234
|
+
onDuplicateRow={handleDuplicateRow}
|
|
235
|
+
onMoveRow={handleMoveRow}
|
|
236
|
+
onPasteCells={handlePasteCells}
|
|
237
|
+
addLabel={t("kumiko.field.embedded-list.add-row")}
|
|
238
|
+
removeLabel={t("kumiko.field.embedded-list.remove-row")}
|
|
239
|
+
duplicateLabel={t("kumiko.field.embedded-list.duplicate-row")}
|
|
240
|
+
moveUpLabel={t("kumiko.field.embedded-list.move-up")}
|
|
241
|
+
moveDownLabel={t("kumiko.field.embedded-list.move-down")}
|
|
242
|
+
emptyLabel={t("kumiko.field.embedded-list.empty")}
|
|
243
|
+
emptyCtaLabel={t("kumiko.field.embedded-list.empty-cta")}
|
|
244
|
+
testId={id}
|
|
245
|
+
/>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
@@ -468,6 +468,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
|
|
|
468
468
|
{...(fieldAppendix !== undefined && {
|
|
469
469
|
fieldAppendix: fieldAppendix(field.field),
|
|
470
470
|
})}
|
|
471
|
+
allIssues={snapshot.errors}
|
|
471
472
|
/>
|
|
472
473
|
))}
|
|
473
474
|
</Grid>
|
|
@@ -533,6 +534,9 @@ type GridCellForFieldProps = {
|
|
|
533
534
|
readonly featureName: string;
|
|
534
535
|
readonly labelAppendix?: ReactNode;
|
|
535
536
|
readonly fieldAppendix?: ReactNode;
|
|
537
|
+
/** Full issues-by-path map (FormSnapshot.errors) — passed through for
|
|
538
|
+
* embedded-list fields, which bucket row-/cell-level issues themselves. */
|
|
539
|
+
readonly allIssues: Readonly<Record<string, readonly FieldIssue[]>>;
|
|
536
540
|
};
|
|
537
541
|
|
|
538
542
|
function GridCellForField({
|
|
@@ -544,6 +548,7 @@ function GridCellForField({
|
|
|
544
548
|
featureName,
|
|
545
549
|
labelAppendix,
|
|
546
550
|
fieldAppendix,
|
|
551
|
+
allIssues,
|
|
547
552
|
}: GridCellForFieldProps): ReactNode {
|
|
548
553
|
const effectiveSpan = field.span !== undefined ? Math.min(field.span, columns) : 1;
|
|
549
554
|
return (
|
|
@@ -555,6 +560,7 @@ function GridCellForField({
|
|
|
555
560
|
featureName={featureName}
|
|
556
561
|
{...(labelAppendix !== undefined && { labelAppendix })}
|
|
557
562
|
{...(fieldAppendix !== undefined && { fieldAppendix })}
|
|
563
|
+
allIssues={allIssues}
|
|
558
564
|
/>
|
|
559
565
|
</GridCell>
|
|
560
566
|
);
|
|
@@ -9,6 +9,7 @@ import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits";
|
|
|
9
9
|
import { useQuery } from "../hooks/use-query";
|
|
10
10
|
import { useLocale, useTranslation } from "../i18n";
|
|
11
11
|
import { usePrimitives } from "../primitives";
|
|
12
|
+
import { EmbeddedListField } from "./embedded-list-field";
|
|
12
13
|
import { ReferenceCreateDialog } from "./reference-create-dialog";
|
|
13
14
|
|
|
14
15
|
// RenderField übersetzt ein EditFieldViewModel → Primitives-Baum.
|
|
@@ -32,6 +33,11 @@ export type RenderFieldProps = {
|
|
|
32
33
|
/** Optionaler Zusatz-Inhalt der nach dem Input gerendert wird (z.B.
|
|
33
34
|
* ConfigCascade). */
|
|
34
35
|
readonly fieldAppendix?: ReactNode;
|
|
36
|
+
/** Flat issues-by-path map (FormSnapshot.errors) — only relevant for
|
|
37
|
+
* type:"embedded" with embeddedListCells, to bucket row-/cell-issues
|
|
38
|
+
* (`${field}.${rowIndex}` / `${field}.${rowIndex}.${cellField}`).
|
|
39
|
+
* Other field types ignore this prop. */
|
|
40
|
+
readonly allIssues?: Readonly<Record<string, readonly FieldIssue[]>>;
|
|
35
41
|
};
|
|
36
42
|
|
|
37
43
|
export function RenderField({
|
|
@@ -41,6 +47,7 @@ export function RenderField({
|
|
|
41
47
|
featureName,
|
|
42
48
|
labelAppendix,
|
|
43
49
|
fieldAppendix,
|
|
50
|
+
allIssues,
|
|
44
51
|
}: RenderFieldProps): ReactNode {
|
|
45
52
|
const { Field, Input } = usePrimitives();
|
|
46
53
|
// App-Locale (i18n) für money/date-Inputs — sonst fielen sie auf
|
|
@@ -58,7 +65,15 @@ export function RenderField({
|
|
|
58
65
|
// useQuery() für den Live-Lookup, also muss sie als React-
|
|
59
66
|
// Komponente gemountet werden (nicht als pure render-Call).
|
|
60
67
|
const control =
|
|
61
|
-
field.type === "
|
|
68
|
+
field.type === "embedded" && field.embeddedListCells !== undefined ? (
|
|
69
|
+
<EmbeddedListField
|
|
70
|
+
field={field}
|
|
71
|
+
id={id}
|
|
72
|
+
onChange={onChange}
|
|
73
|
+
allIssues={allIssues ?? {}}
|
|
74
|
+
featureName={featureName ?? ""}
|
|
75
|
+
/>
|
|
76
|
+
) : field.type === "reference" ? (
|
|
62
77
|
<ReferenceInput
|
|
63
78
|
field={field}
|
|
64
79
|
id={id}
|
package/src/hooks/use-query.ts
CHANGED
|
@@ -43,7 +43,7 @@ export type UseQueryOptions = {
|
|
|
43
43
|
// Extract the entity-name from a standard Kumiko query type. Returns
|
|
44
44
|
// undefined for non-conforming types so the live-mode silently skips
|
|
45
45
|
// them instead of subscribing to a channel no event will ever match.
|
|
46
|
-
function entityFromQueryType(type: string): string | undefined {
|
|
46
|
+
export function entityFromQueryType(type: string): string | undefined {
|
|
47
47
|
// Expected shape: "<feature>:query:<entity>:<verb>"
|
|
48
48
|
const parts = type.split(":");
|
|
49
49
|
if (parts.length !== 4) return undefined;
|
package/src/i18n-defaults.ts
CHANGED
|
@@ -36,6 +36,13 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
36
36
|
"kumiko.field.locatedTzHint": "Zeit lokal am angegebenen Ort",
|
|
37
37
|
"kumiko.field.reference-created-no-id":
|
|
38
38
|
"Datensatz wurde angelegt, konnte aber nicht automatisch ausgewählt werden. Bitte manuell auswählen.",
|
|
39
|
+
"kumiko.field.embedded-list.add-row": "Zeile hinzufügen",
|
|
40
|
+
"kumiko.field.embedded-list.remove-row": "Zeile entfernen",
|
|
41
|
+
"kumiko.field.embedded-list.duplicate-row": "Zeile duplizieren",
|
|
42
|
+
"kumiko.field.embedded-list.move-up": "Nach oben verschieben",
|
|
43
|
+
"kumiko.field.embedded-list.move-down": "Nach unten verschieben",
|
|
44
|
+
"kumiko.field.embedded-list.empty": "Noch keine Zeilen.",
|
|
45
|
+
"kumiko.field.embedded-list.empty-cta": "Erste Zeile hinzufügen",
|
|
39
46
|
|
|
40
47
|
// List — DataTable Toolbar, Empty-State, Search.
|
|
41
48
|
"kumiko.list.search-placeholder": "Suchen…",
|
|
@@ -58,6 +65,11 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
58
65
|
"kumiko.widget.loading": "Lade…",
|
|
59
66
|
"kumiko.widget.error.title": "Konnte nicht geladen werden.",
|
|
60
67
|
|
|
68
|
+
// Widgets — UploadZone Status-Zeile pro Datei.
|
|
69
|
+
"kumiko.widget.upload.uploading": "Wird hochgeladen…",
|
|
70
|
+
"kumiko.widget.upload.done": "Hochgeladen",
|
|
71
|
+
"kumiko.widget.upload.error": "Fehlgeschlagen",
|
|
72
|
+
|
|
61
73
|
// Nav — Sidebar Tree (Toggle-aria-Labels).
|
|
62
74
|
"kumiko.nav.expand": "Aufklappen",
|
|
63
75
|
"kumiko.nav.collapse": "Zuklappen",
|
|
@@ -187,6 +199,13 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
187
199
|
"kumiko.field.locatedTzHint": "Time local to the given location",
|
|
188
200
|
"kumiko.field.reference-created-no-id":
|
|
189
201
|
"Record was created but could not be selected automatically. Please select it manually.",
|
|
202
|
+
"kumiko.field.embedded-list.add-row": "Add row",
|
|
203
|
+
"kumiko.field.embedded-list.remove-row": "Remove row",
|
|
204
|
+
"kumiko.field.embedded-list.duplicate-row": "Duplicate row",
|
|
205
|
+
"kumiko.field.embedded-list.move-up": "Move up",
|
|
206
|
+
"kumiko.field.embedded-list.move-down": "Move down",
|
|
207
|
+
"kumiko.field.embedded-list.empty": "No rows yet.",
|
|
208
|
+
"kumiko.field.embedded-list.empty-cta": "Add first row",
|
|
190
209
|
|
|
191
210
|
"kumiko.list.search-placeholder": "Search…",
|
|
192
211
|
"kumiko.list.empty.title": "No entries yet.",
|
|
@@ -204,6 +223,10 @@ export const kumikoDefaultTranslations: TranslationsByLocale = {
|
|
|
204
223
|
"kumiko.widget.loading": "Loading…",
|
|
205
224
|
"kumiko.widget.error.title": "Couldn't load.",
|
|
206
225
|
|
|
226
|
+
"kumiko.widget.upload.uploading": "Uploading…",
|
|
227
|
+
"kumiko.widget.upload.done": "Uploaded",
|
|
228
|
+
"kumiko.widget.upload.error": "Failed",
|
|
229
|
+
|
|
207
230
|
"kumiko.nav.expand": "Expand",
|
|
208
231
|
"kumiko.nav.collapse": "Collapse",
|
|
209
232
|
"kumiko.nav.search": "Search navigation…",
|
package/src/index.ts
CHANGED
|
@@ -115,7 +115,7 @@ export { useListUrlState } from "./hooks/use-list-url-state";
|
|
|
115
115
|
export type { UseMutationResult } from "./hooks/use-mutation";
|
|
116
116
|
export { useMutation } from "./hooks/use-mutation";
|
|
117
117
|
export type { UseQueryOptions, UseQueryResult } from "./hooks/use-query";
|
|
118
|
-
export { useQuery } from "./hooks/use-query";
|
|
118
|
+
export { entityFromQueryType, useQuery } from "./hooks/use-query";
|
|
119
119
|
export { useStore, useStoreSelector } from "./hooks/use-store";
|
|
120
120
|
export type {
|
|
121
121
|
StreamStatus,
|
|
@@ -153,6 +153,10 @@ export type {
|
|
|
153
153
|
DataTableSort,
|
|
154
154
|
DataTableSortDir,
|
|
155
155
|
DialogProps,
|
|
156
|
+
EmbeddedListCellType,
|
|
157
|
+
EmbeddedListColumn,
|
|
158
|
+
EmbeddedListInputProps,
|
|
159
|
+
EmbeddedListTotal,
|
|
156
160
|
FieldProps,
|
|
157
161
|
FormProps,
|
|
158
162
|
FormWidth,
|
package/src/primitives.tsx
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// Field — label + issues um ein Input-Control
|
|
16
16
|
// Input — discriminated union über text/number/boolean/date
|
|
17
17
|
// DataTable — Spalten + Zeilen + onRowClick, Empty-State intern
|
|
18
|
+
// EmbeddedListInput — row array + totals table for createEmbeddedListField
|
|
18
19
|
// Form — submit-Wrapper (Web: <form>, Native: View + onSubmit)
|
|
19
20
|
// Section — titled Gruppe von Feldern (Web: <fieldset>+<legend>)
|
|
20
21
|
// Grid — columns-basiertes Layout innerhalb einer Section
|
|
@@ -46,6 +47,7 @@ import {
|
|
|
46
47
|
createContext,
|
|
47
48
|
type FormEvent,
|
|
48
49
|
type ReactNode,
|
|
50
|
+
type Ref,
|
|
49
51
|
useContext,
|
|
50
52
|
} from "react";
|
|
51
53
|
|
|
@@ -82,6 +84,13 @@ export type ButtonProps = {
|
|
|
82
84
|
readonly width?: "full" | "auto";
|
|
83
85
|
readonly children: ReactNode;
|
|
84
86
|
readonly testId?: string;
|
|
87
|
+
/** Layout extras — Web merges via cn(), native impls ignore it
|
|
88
|
+
* (precedent: LinkProps.className). */
|
|
89
|
+
readonly className?: string;
|
|
90
|
+
/** DOM ref on the button — escape hatch for cases that need more than
|
|
91
|
+
* a click (e.g. binding a drop-target handler). Web forwards it, native
|
|
92
|
+
* impls ignore it (no native equivalent). */
|
|
93
|
+
readonly ref?: Ref<HTMLButtonElement>;
|
|
85
94
|
};
|
|
86
95
|
|
|
87
96
|
/** Navigations-Link. `variant="button"` rendert die Button-Optik auf einem
|
|
@@ -533,6 +542,98 @@ export type DataTableProps = {
|
|
|
533
542
|
readonly testId?: string;
|
|
534
543
|
};
|
|
535
544
|
|
|
545
|
+
// ---- EmbeddedListInput (createEmbeddedListField widget) ----
|
|
546
|
+
|
|
547
|
+
/** Cell type for one column of an embedded-list field. Mirrors
|
|
548
|
+
* EmbeddedListCellViewModel["type"] from `@cosmicdrift/kumiko-headless`. */
|
|
549
|
+
export type EmbeddedListCellType =
|
|
550
|
+
| "text"
|
|
551
|
+
| "number"
|
|
552
|
+
| "boolean"
|
|
553
|
+
| "date"
|
|
554
|
+
| "money"
|
|
555
|
+
| "decimal"
|
|
556
|
+
| "select"
|
|
557
|
+
| "reference"
|
|
558
|
+
| "timestamp";
|
|
559
|
+
|
|
560
|
+
/** One column of an embedded-list table (one entry per key of the source
|
|
561
|
+
* EmbeddedFieldDef's `schema`). */
|
|
562
|
+
export type EmbeddedListColumn = {
|
|
563
|
+
readonly field: string;
|
|
564
|
+
readonly label: string;
|
|
565
|
+
readonly type: EmbeddedListCellType;
|
|
566
|
+
readonly required: boolean;
|
|
567
|
+
/** Read-only, value supplied by the caller (already computed from
|
|
568
|
+
* sibling cells) — not directly user-editable. */
|
|
569
|
+
readonly derived: boolean;
|
|
570
|
+
/** Only for `type: "select"`. */
|
|
571
|
+
readonly options?: readonly string[];
|
|
572
|
+
readonly optionLabels?: Readonly<Record<string, string>>;
|
|
573
|
+
/** Only for `type: "reference"` — pre-fetched by the caller, ONE query
|
|
574
|
+
* per column shared across every row (not one query per cell). */
|
|
575
|
+
readonly referenceOptions?: readonly { readonly value: string; readonly label: string }[];
|
|
576
|
+
readonly referenceLoading?: boolean;
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
/** One entry of an embedded-list totals row (e.g. an invoice's total
|
|
580
|
+
* amount, summed across all rows by the caller). */
|
|
581
|
+
export type EmbeddedListTotal = {
|
|
582
|
+
readonly field: string;
|
|
583
|
+
readonly label: string;
|
|
584
|
+
readonly value: number;
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
/** Row-array + totals table for a `createEmbeddedListField` (invoice-
|
|
588
|
+
* positions-style) field — structurally too different from `Input`'s
|
|
589
|
+
* value/onChange union to fit there, so it's its own primitive, same as
|
|
590
|
+
* `DataTable`. Controlled: the caller (the eventual form-field wrapper)
|
|
591
|
+
* computes rows/derived values/issue groups and wires every callback;
|
|
592
|
+
* this primitive only renders and reports interaction. */
|
|
593
|
+
export type EmbeddedListInputProps = {
|
|
594
|
+
readonly id: string;
|
|
595
|
+
readonly columns: readonly EmbeddedListColumn[];
|
|
596
|
+
readonly rows: readonly Readonly<Record<string, unknown>>[];
|
|
597
|
+
readonly totals?: readonly EmbeddedListTotal[];
|
|
598
|
+
/** Currency for money cells and the totals row — one value for the whole
|
|
599
|
+
* list (currency lives on the head aggregate, not the row). Web impl
|
|
600
|
+
* falls back to "EUR" when absent. */
|
|
601
|
+
readonly currency?: string;
|
|
602
|
+
/** Whole-list read-only (e.g. a released invoice) — hides row-mutation
|
|
603
|
+
* affordances and disables every cell. */
|
|
604
|
+
readonly disabled?: boolean;
|
|
605
|
+
readonly minItems?: number;
|
|
606
|
+
readonly maxItems?: number;
|
|
607
|
+
/** Shown under the whole list (e.g. under the totals row). */
|
|
608
|
+
readonly listIssues?: readonly FieldIssue[];
|
|
609
|
+
/** Keyed by row index. */
|
|
610
|
+
readonly rowIssues?: Readonly<Record<number, readonly FieldIssue[]>>;
|
|
611
|
+
/** Keyed `${rowIndex}.${field}`. */
|
|
612
|
+
readonly cellIssues?: Readonly<Record<string, readonly FieldIssue[]>>;
|
|
613
|
+
readonly onCellChange: (rowIndex: number, field: string, value: unknown) => void;
|
|
614
|
+
readonly onAddRow: () => void;
|
|
615
|
+
readonly onRemoveRow: (rowIndex: number) => void;
|
|
616
|
+
readonly onDuplicateRow: (rowIndex: number) => void;
|
|
617
|
+
readonly onMoveRow: (fromIndex: number, toIndex: number) => void;
|
|
618
|
+
/** Tab/newline-delimited clipboard paste starting at (rowIndex,
|
|
619
|
+
* columnIndex) — parsed to a 2D string grid by the Web impl (paste
|
|
620
|
+
* events are a browser-only concept); undefined caller = no paste
|
|
621
|
+
* handling wired up. */
|
|
622
|
+
readonly onPasteCells?: (
|
|
623
|
+
rowIndex: number,
|
|
624
|
+
columnIndex: number,
|
|
625
|
+
grid: readonly (readonly string[])[],
|
|
626
|
+
) => void;
|
|
627
|
+
readonly addLabel: string;
|
|
628
|
+
readonly removeLabel: string;
|
|
629
|
+
readonly duplicateLabel: string;
|
|
630
|
+
readonly moveUpLabel: string;
|
|
631
|
+
readonly moveDownLabel: string;
|
|
632
|
+
readonly emptyLabel: string;
|
|
633
|
+
readonly emptyCtaLabel: string;
|
|
634
|
+
readonly testId?: string;
|
|
635
|
+
};
|
|
636
|
+
|
|
536
637
|
export type { FormWidth };
|
|
537
638
|
|
|
538
639
|
/** Submit-Wrapper. Web: `<form onSubmit>`, Native: View das einen
|
|
@@ -750,6 +851,10 @@ export type CorePrimitives = {
|
|
|
750
851
|
readonly Field: ComponentType<FieldProps>;
|
|
751
852
|
readonly Input: ComponentType<InputProps>;
|
|
752
853
|
readonly DataTable: ComponentType<DataTableProps>;
|
|
854
|
+
/** Optional (unlike the other Core-Primitives) so existing partial
|
|
855
|
+
* CorePrimitives mocks in tests keep compiling — additive rollout of
|
|
856
|
+
* a new primitive shouldn't force every test double to grow a stub. */
|
|
857
|
+
readonly EmbeddedListInput?: ComponentType<EmbeddedListInputProps>;
|
|
753
858
|
readonly Form: ComponentType<FormProps>;
|
|
754
859
|
readonly Section: ComponentType<SectionProps>;
|
|
755
860
|
readonly Card: ComponentType<CardProps>;
|