@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer",
3
- "version": "0.183.2",
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.183.2",
19
- "@cosmicdrift/kumiko-headless": "0.183.2",
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 === "reference" ? (
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}
@@ -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;
@@ -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,
@@ -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>;