@cosmicdrift/kumiko-renderer 0.398.0 → 0.400.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/dist/app/entity-edit-prefill.js +7 -1
- package/dist/app/entity-list-facet-specs.js +2 -0
- package/dist/app/entity-list-screen.js +3 -3
- package/dist/app/form-schema.js +42 -17
- package/dist/app/list-facets.d.ts +7 -4
- package/dist/app/list-facets.js +23 -6
- package/dist/app/projection-detail-body.js +7 -0
- package/dist/app/projection-detail-header-model.d.ts +1 -1
- package/dist/app/projection-detail-header-model.js +2 -2
- package/dist/app/projection-detail-header-region.d.ts +1 -0
- package/dist/app/projection-detail-header-region.js +1 -1
- package/dist/app/projection-detail-header.js +4 -2
- package/dist/app/projection-detail-navigation.d.ts +1 -0
- package/dist/app/projection-detail-navigation.js +9 -1
- package/dist/app/projection-list-body.js +1 -1
- package/dist/app/reference-facet-bridge.d.ts +14 -2
- package/dist/app/reference-facet-bridge.js +37 -18
- package/dist/app/use-projection-list-query.d.ts +2 -2
- package/dist/app/use-projection-list-query.js +2 -1
- package/dist/app/use-related-list-tab-counts.d.ts +5 -0
- package/dist/app/use-related-list-tab-counts.js +68 -0
- package/dist/components/embedded-list-field.js +69 -91
- package/dist/components/embedded-list-paste.d.ts +2 -0
- package/dist/components/embedded-list-paste.js +73 -0
- package/dist/components/related-list-section.js +7 -11
- package/dist/components/render-edit-fields-section.js +1 -1
- package/dist/components/render-edit-helpers.js +2 -2
- package/dist/components/render-field-choice-inputs.js +1 -1
- package/dist/data-table-facet-types.d.ts +5 -0
- package/dist/embedded-list-types.d.ts +117 -0
- package/dist/embedded-list-types.js +1 -0
- package/dist/headless/form/form-controller.js +9 -1
- package/dist/headless/index.d.ts +1 -1
- package/dist/headless/index.js +1 -1
- package/dist/headless/view-model/edit.js +40 -50
- package/dist/headless/view-model/embedded-list-cells.d.ts +12 -0
- package/dist/headless/view-model/embedded-list-cells.js +49 -0
- package/dist/headless/view-model/embedded-list.d.ts +1 -1
- package/dist/headless/view-model/embedded-list.js +1 -1
- package/dist/headless/view-model/index.d.ts +1 -1
- package/dist/headless/view-model/index.js +1 -1
- package/dist/headless/view-model/list.js +2 -3
- package/dist/headless/view-model/types.d.ts +23 -6
- package/dist/hooks/related-list-parent-payload.d.ts +2 -0
- package/dist/hooks/related-list-parent-payload.js +7 -0
- package/dist/i18n-defaults.js +11 -0
- package/dist/primitives.d.ts +4 -88
- package/dist/web/layout/notification-bell.d.ts +5 -0
- package/dist/web/layout/notification-bell.js +39 -0
- package/dist/web/layout/shell-header.js +4 -1
- package/dist/web/layout/use-in-app-inbox.d.ts +15 -0
- package/dist/web/layout/use-in-app-inbox.js +61 -0
- package/dist/web/lib/file-upload-form-data.d.ts +2 -0
- package/dist/web/lib/file-upload-form-data.js +2 -0
- package/dist/web/lib/upload-file-ref.d.ts +1 -1
- package/dist/web/primitives/data-table-cell.d.ts +2 -0
- package/dist/web/primitives/data-table-cell.js +1 -1
- package/dist/web/primitives/embedded-file-cell.d.ts +11 -0
- package/dist/web/primitives/embedded-file-cell.js +50 -0
- package/dist/web/primitives/embedded-list-cards.d.ts +38 -0
- package/dist/web/primitives/embedded-list-cards.js +71 -0
- package/dist/web/primitives/embedded-list-cell.d.ts +41 -0
- package/dist/web/primitives/embedded-list-cell.js +132 -0
- package/dist/web/primitives/embedded-list-input.d.ts +1 -1
- package/dist/web/primitives/embedded-list-input.js +29 -142
- package/dist/web/primitives/facet-filter.js +4 -0
- package/dist/web/primitives/facet-search-filter.d.ts +9 -0
- package/dist/web/primitives/facet-search-filter.js +31 -0
- package/dist/web/primitives/file-upload.d.ts +2 -1
- package/dist/web/primitives/file-upload.js +2 -1
- package/dist/web/primitives/input-choice-kinds.js +1 -1
- package/dist/web/primitives/input-optional-props.d.ts +2 -1
- package/dist/web/primitives/input-optional-props.js +2 -1
- package/dist/web/primitives/locale-number.d.ts +3 -0
- package/dist/web/primitives/locale-number.js +8 -0
- package/dist/web/primitives/number-input.d.ts +2 -1
- package/dist/web/primitives/number-input.js +4 -11
- package/dist/web/primitives/segmented-select.d.ts +6 -1
- package/dist/web/primitives/segmented-select.js +8 -4
- package/dist/web/sse/live-events.js +14 -3
- package/dist/web/widgets/status-badge.d.ts +1 -0
- package/dist/web/widgets/status-badge.js +1 -1
- package/package.json +5 -5
- package/src/changes.json +18 -0
|
@@ -1,12 +1,65 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { evalFieldCondition, isFieldRequired } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
4
|
import { useState } from "react";
|
|
3
|
-
import {
|
|
5
|
+
import { groupEmbeddedListIssues, sumEmbeddedListColumn, withDerivedCells, } from "../headless/index.js";
|
|
4
6
|
import { pluralKey } from "../headless/plural-key.js";
|
|
5
7
|
import { REFERENCE_COMBOBOX_LIMIT } from "../hooks/reference-limits.js";
|
|
6
8
|
import { useQuery } from "../hooks/use-query.js";
|
|
7
9
|
import { referenceOptionSource, storedReferenceRowsPayload, withUnlistedSelectedRows, } from "../hooks/use-reference-lookup.js";
|
|
8
10
|
import { useTranslation } from "../i18n.js";
|
|
9
11
|
import { usePrimitives } from "../primitives.js";
|
|
12
|
+
import { coerceCellValue } from "./embedded-list-paste.js";
|
|
13
|
+
// The primitive never imports condition logic, so the per-row state is evaluated here.
|
|
14
|
+
function rowStateCallbacks(cell, derivedDef) {
|
|
15
|
+
const { visibleWhen, requiredWhen } = cell;
|
|
16
|
+
const derivedWhen = derivedDef?.when;
|
|
17
|
+
return {
|
|
18
|
+
...(visibleWhen !== undefined && {
|
|
19
|
+
isHiddenInRow: (row) => !evalFieldCondition(visibleWhen, row),
|
|
20
|
+
}),
|
|
21
|
+
...((visibleWhen !== undefined || requiredWhen !== undefined) && {
|
|
22
|
+
isRequiredInRow: (row) => isFieldRequired({ required: cell.required, visibleWhen, requiredWhen }, row),
|
|
23
|
+
}),
|
|
24
|
+
...(derivedDef !== undefined &&
|
|
25
|
+
derivedWhen !== undefined && {
|
|
26
|
+
isDerivedInRow: (row) => evalFieldCondition(derivedWhen, row),
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
// An inRange derived cell (value, min, max → result) reads as one measurement on a phone card:
|
|
31
|
+
// the limits become a hint under the value and the result sits beside it. Editable limits stay
|
|
32
|
+
// their own rows: hiding them would leave phone users no way to fill them.
|
|
33
|
+
function cardPlacementByField(derived, cells) {
|
|
34
|
+
const placement = new Map();
|
|
35
|
+
const isNotEditable = (field) => {
|
|
36
|
+
const cell = cells.find((c) => c.field === field);
|
|
37
|
+
return cell !== undefined && (cell.readOnly === true || derived?.[field] !== undefined);
|
|
38
|
+
};
|
|
39
|
+
for (const [target, def] of Object.entries(derived ?? {})) {
|
|
40
|
+
if (def.op !== "inRange")
|
|
41
|
+
continue;
|
|
42
|
+
const [value, min, max] = def.from;
|
|
43
|
+
if (value === undefined || min === undefined || max === undefined)
|
|
44
|
+
continue;
|
|
45
|
+
if (isNotEditable(min) && isNotEditable(max)) {
|
|
46
|
+
placement.set(value, { cardLimits: { min, max } });
|
|
47
|
+
placement.set(min, { hiddenInCard: true });
|
|
48
|
+
placement.set(max, { hiddenInCard: true });
|
|
49
|
+
}
|
|
50
|
+
placement.set(target, { cardAttachedTo: value });
|
|
51
|
+
}
|
|
52
|
+
return placement;
|
|
53
|
+
}
|
|
54
|
+
function cardColumnProps(cell, placement) {
|
|
55
|
+
const fromDerived = placement.get(cell.field);
|
|
56
|
+
return {
|
|
57
|
+
...fromDerived,
|
|
58
|
+
...((cell.hiddenInCard === true || fromDerived?.hiddenInCard === true) && {
|
|
59
|
+
hiddenInCard: true,
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
10
63
|
function storedCellIds(rows, cellField) {
|
|
11
64
|
const ids = new Set();
|
|
12
65
|
for (const row of rows) {
|
|
@@ -19,95 +72,9 @@ function storedCellIds(rows, cellField) {
|
|
|
19
72
|
function withRecomputedDerived(row, derived, cells) {
|
|
20
73
|
if (derived === undefined)
|
|
21
74
|
return row;
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
// client preview and server depending on Object.entries(derived) order.
|
|
26
|
-
const source = { ...row };
|
|
27
|
-
const result = { ...row };
|
|
28
|
-
for (const [derivedField, def] of Object.entries(derived)) {
|
|
29
|
-
const values = def.from.map((src) => {
|
|
30
|
-
const v = source[src];
|
|
31
|
-
return typeof v === "number" ? v : undefined;
|
|
32
|
-
});
|
|
33
|
-
const computed = computeDerivedCellValue(def.op, values);
|
|
34
|
-
const target = cells.find((c) => c.field === derivedField);
|
|
35
|
-
result[derivedField] =
|
|
36
|
-
computed !== undefined && target !== undefined
|
|
37
|
-
? roundDerivedCellValue(computed, target)
|
|
38
|
-
: computed;
|
|
39
|
-
}
|
|
40
|
-
return result;
|
|
41
|
-
}
|
|
42
|
-
// Locale-aware decimal parse for pasted text: strips space/NBSP thousands
|
|
43
|
-
// separators outright, then treats whichever of "," or "." appears LAST in
|
|
44
|
-
// the string as the decimal separator and strips the other one — handles
|
|
45
|
-
// both "1.234,56" (de) and "1,234.56" (en-US) pastes. Returns undefined for
|
|
46
|
-
// anything that still doesn't parse as a finite number.
|
|
47
|
-
function parseLocaleAwareDecimal(raw) {
|
|
48
|
-
const stripped = raw.replace(/[\s ]/g, "");
|
|
49
|
-
if (stripped === "")
|
|
50
|
-
return undefined;
|
|
51
|
-
const lastComma = stripped.lastIndexOf(",");
|
|
52
|
-
const lastDot = stripped.lastIndexOf(".");
|
|
53
|
-
const decimalIndex = Math.max(lastComma, lastDot);
|
|
54
|
-
if (decimalIndex === -1) {
|
|
55
|
-
const n = Number(stripped);
|
|
56
|
-
return Number.isFinite(n) ? n : undefined;
|
|
57
|
-
}
|
|
58
|
-
const integerPart = stripped.slice(0, decimalIndex).replace(/[.,]/g, "");
|
|
59
|
-
const fractionPart = stripped.slice(decimalIndex + 1);
|
|
60
|
-
if (!/^\d*$/.test(integerPart) || !/^\d*$/.test(fractionPart))
|
|
61
|
-
return undefined;
|
|
62
|
-
const n = Number(`${integerPart === "" ? "0" : integerPart}.${fractionPart === "" ? "0" : fractionPart}`);
|
|
63
|
-
return Number.isFinite(n) ? n : undefined;
|
|
64
|
-
}
|
|
65
|
-
function coerceCellValue(column, text, currency) {
|
|
66
|
-
switch (column.type) {
|
|
67
|
-
case "text":
|
|
68
|
-
return text;
|
|
69
|
-
case "number":
|
|
70
|
-
case "decimal": {
|
|
71
|
-
if (text.trim() === "")
|
|
72
|
-
return undefined;
|
|
73
|
-
const n = Number(text);
|
|
74
|
-
return Number.isFinite(n) ? n : undefined;
|
|
75
|
-
}
|
|
76
|
-
case "money": {
|
|
77
|
-
// Money cells are minor-unit integers (cents) in storage — paste
|
|
78
|
-
// arrives as a major-unit decimal string ("12,99"/"12.99"), so scale
|
|
79
|
-
// by the currency's decimal places. Must agree with the typed-in path
|
|
80
|
-
// (renderCellControl's MoneyInput, which scales by the same
|
|
81
|
-
// currencyDecimals) — a hardcoded ×100 here diverged for zero-/three-
|
|
82
|
-
// decimal currencies (JPY, BHD, ...), landing a pasted value 100x off
|
|
83
|
-
// from the same value typed by hand.
|
|
84
|
-
// parseLocaleAwareDecimal handles both "1.234,56" (de) and
|
|
85
|
-
// "1,234.56" (en-US) thousands/decimal conventions — a naive
|
|
86
|
-
// `replace(",", ".")` mangled German-locale pastes with thousands
|
|
87
|
-
// separators into NaN.
|
|
88
|
-
if (text.trim() === "")
|
|
89
|
-
return undefined;
|
|
90
|
-
const n = parseLocaleAwareDecimal(text);
|
|
91
|
-
return n === undefined ? undefined : Math.round(n * 10 ** currencyDecimals(currency));
|
|
92
|
-
}
|
|
93
|
-
case "boolean":
|
|
94
|
-
return ["true", "1", "yes", "y", "ja"].includes(text.trim().toLowerCase());
|
|
95
|
-
case "date":
|
|
96
|
-
case "timestamp":
|
|
97
|
-
return text.trim();
|
|
98
|
-
case "select": {
|
|
99
|
-
const match = (column.options ?? []).find((opt) => opt === text || column.optionLabels?.[opt] === text);
|
|
100
|
-
return match;
|
|
101
|
-
}
|
|
102
|
-
case "reference": {
|
|
103
|
-
const match = (column.referenceOptions ?? []).find((o) => o.label === text || o.value === text);
|
|
104
|
-
return match?.value;
|
|
105
|
-
}
|
|
106
|
-
default: {
|
|
107
|
-
const exhaustiveCheck = column.type;
|
|
108
|
-
return exhaustiveCheck;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
75
|
+
const targets = Object.fromEntries(cells.map((cell) => [cell.field, cell]));
|
|
76
|
+
const recomputed = withDerivedCells(row, derived, targets);
|
|
77
|
+
return isPlainObject(recomputed) ? recomputed : row;
|
|
111
78
|
}
|
|
112
79
|
// The reference-column queries are hooks called in a loop, so their count must
|
|
113
80
|
// stay fixed per mount. Keying on the reference cells remounts when that set
|
|
@@ -141,6 +108,7 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
|
|
|
141
108
|
});
|
|
142
109
|
if (EmbeddedListInput === undefined)
|
|
143
110
|
return null;
|
|
111
|
+
const cardPlacement = cardPlacementByField(derived, cells);
|
|
144
112
|
const columns = cells.map((cell) => {
|
|
145
113
|
const isDerived = derived?.[cell.field] !== undefined;
|
|
146
114
|
if (cell.type === "reference") {
|
|
@@ -157,7 +125,9 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
|
|
|
157
125
|
type: cell.type,
|
|
158
126
|
required: cell.required,
|
|
159
127
|
derived: isDerived,
|
|
128
|
+
...rowStateCallbacks(cell, derived?.[cell.field]),
|
|
160
129
|
...(cell.readOnly === true && { readOnly: true }),
|
|
130
|
+
...cardColumnProps(cell, cardPlacement),
|
|
161
131
|
referenceOptions,
|
|
162
132
|
referenceLoading: query?.loading ?? false,
|
|
163
133
|
};
|
|
@@ -168,10 +138,16 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
|
|
|
168
138
|
type: cell.type,
|
|
169
139
|
required: cell.required,
|
|
170
140
|
derived: isDerived,
|
|
141
|
+
...rowStateCallbacks(cell, derived?.[cell.field]),
|
|
171
142
|
...(cell.readOnly === true && { readOnly: true }),
|
|
143
|
+
...cardColumnProps(cell, cardPlacement),
|
|
172
144
|
...(cell.options !== undefined && { options: cell.options }),
|
|
173
145
|
...(cell.optionLabels !== undefined && { optionLabels: cell.optionLabels }),
|
|
174
146
|
...(cell.display !== undefined && { display: cell.display }),
|
|
147
|
+
...(cell.optionTones !== undefined && { optionTones: cell.optionTones }),
|
|
148
|
+
...(cell.capture !== undefined && { capture: cell.capture }),
|
|
149
|
+
...(cell.maxCount !== undefined && { maxCount: cell.maxCount }),
|
|
150
|
+
...(cell.accept !== undefined && { accept: cell.accept }),
|
|
175
151
|
};
|
|
176
152
|
});
|
|
177
153
|
const totals = (field.embeddedListTotals ?? []).map((entry) => {
|
|
@@ -271,6 +247,8 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
|
|
|
271
247
|
const column = columns[columnIndex + gridColOffset];
|
|
272
248
|
if (column === undefined || column.readOnly === true)
|
|
273
249
|
return;
|
|
250
|
+
if (column.type === "images" || column.type === "files")
|
|
251
|
+
return;
|
|
274
252
|
const coerced = coerceCellValue(column, text, field.embeddedListCurrency ?? "EUR");
|
|
275
253
|
// Also covers an unparseable money paste (e.g. mangled thousands
|
|
276
254
|
// separators) — the cell must stay unchanged instead of silently
|
|
@@ -291,5 +269,5 @@ function EmbeddedListFieldInner({ field, id, onChange, allIssues, featureName, }
|
|
|
291
269
|
const recomputed = nextRows.map((row, i) => touchedIndices.has(i) ? withRecomputedDerived(row, derived, cells) : row);
|
|
292
270
|
onChange(recomputed);
|
|
293
271
|
}
|
|
294
|
-
return (_jsx(EmbeddedListInput, { id: id, columns: columns, rows: rows, totals: totals, currency: field.embeddedListCurrency, disabled: field.readOnly, fixedRows: field.embeddedListFixedRows, minItems: field.embeddedListMinItems, maxItems: field.embeddedListMaxItems, listIssues: combinedListIssues, rowIssues: rowIssues, cellIssues: cellIssues, onCellChange: handleCellChange, onAddRow: handleAddRow, onRemoveRow: handleRemoveRow, onDuplicateRow: handleDuplicateRow, onMoveRow: handleMoveRow, onPasteCells: handlePasteCells, addLabel: t("kumiko.field.embedded-list.add-row"), actionsLabel: t("kumiko.field.embedded-list.actions"), removeLabel: t("kumiko.field.embedded-list.remove-row"), duplicateLabel: t("kumiko.field.embedded-list.duplicate-row"), moveUpLabel: t("kumiko.field.embedded-list.move-up"), moveDownLabel: t("kumiko.field.embedded-list.move-down"), emptyLabel: t("kumiko.field.embedded-list.empty"), emptyCtaLabel: t("kumiko.field.embedded-list.empty-cta"), testId: id }));
|
|
272
|
+
return (_jsx(EmbeddedListInput, { id: id, columns: columns, rows: rows, totals: totals, currency: field.embeddedListCurrency, disabled: field.readOnly, fixedRows: field.embeddedListFixedRows, minItems: field.embeddedListMinItems, maxItems: field.embeddedListMaxItems, uploadTarget: field.embeddedListUploadTarget, listIssues: combinedListIssues, rowIssues: rowIssues, cellIssues: cellIssues, onCellChange: handleCellChange, onAddRow: handleAddRow, onRemoveRow: handleRemoveRow, onDuplicateRow: handleDuplicateRow, onMoveRow: handleMoveRow, onPasteCells: handlePasteCells, addLabel: t("kumiko.field.embedded-list.add-row"), actionsLabel: t("kumiko.field.embedded-list.actions"), removeLabel: t("kumiko.field.embedded-list.remove-row"), duplicateLabel: t("kumiko.field.embedded-list.duplicate-row"), moveUpLabel: t("kumiko.field.embedded-list.move-up"), moveDownLabel: t("kumiko.field.embedded-list.move-down"), emptyLabel: t("kumiko.field.embedded-list.empty"), emptyCtaLabel: t("kumiko.field.embedded-list.empty-cta"), testId: id }));
|
|
295
273
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { currencyDecimals } from "../headless/index.js";
|
|
2
|
+
// Locale-aware decimal parse for pasted text: strips space/NBSP thousands
|
|
3
|
+
// separators outright, then treats whichever of "," or "." appears LAST in
|
|
4
|
+
// the string as the decimal separator and strips the other one — handles
|
|
5
|
+
// both "1.234,56" (de) and "1,234.56" (en-US) pastes. Returns undefined for
|
|
6
|
+
// anything that still doesn't parse as a finite number.
|
|
7
|
+
function parseLocaleAwareDecimal(raw) {
|
|
8
|
+
const stripped = raw.replace(/[\s\u00a0]/g, "");
|
|
9
|
+
if (stripped === "")
|
|
10
|
+
return undefined;
|
|
11
|
+
const lastComma = stripped.lastIndexOf(",");
|
|
12
|
+
const lastDot = stripped.lastIndexOf(".");
|
|
13
|
+
const decimalIndex = Math.max(lastComma, lastDot);
|
|
14
|
+
if (decimalIndex === -1) {
|
|
15
|
+
const n = Number(stripped);
|
|
16
|
+
return Number.isFinite(n) ? n : undefined;
|
|
17
|
+
}
|
|
18
|
+
const integerPart = stripped.slice(0, decimalIndex).replace(/[.,]/g, "");
|
|
19
|
+
const fractionPart = stripped.slice(decimalIndex + 1);
|
|
20
|
+
if (!/^\d*$/.test(integerPart) || !/^\d*$/.test(fractionPart))
|
|
21
|
+
return undefined;
|
|
22
|
+
const n = Number(`${integerPart === "" ? "0" : integerPart}.${fractionPart === "" ? "0" : fractionPart}`);
|
|
23
|
+
return Number.isFinite(n) ? n : undefined;
|
|
24
|
+
}
|
|
25
|
+
function coerceNumber(text) {
|
|
26
|
+
if (text.trim() === "")
|
|
27
|
+
return undefined;
|
|
28
|
+
const n = Number(text);
|
|
29
|
+
return Number.isFinite(n) ? n : undefined;
|
|
30
|
+
}
|
|
31
|
+
// Money cells are minor-unit integers (cents) in storage — paste arrives as a major-unit decimal
|
|
32
|
+
// string ("12,99"/"12.99"), so scale by the currency's decimal places. Must agree with the
|
|
33
|
+
// typed-in path (MoneyInput, which scales by the same currencyDecimals) — a hardcoded ×100
|
|
34
|
+
// diverged for zero-/three-decimal currencies (JPY, BHD, ...).
|
|
35
|
+
function coerceMoney(text, currency) {
|
|
36
|
+
if (text.trim() === "")
|
|
37
|
+
return undefined;
|
|
38
|
+
const n = parseLocaleAwareDecimal(text);
|
|
39
|
+
return n === undefined ? undefined : Math.round(n * 10 ** currencyDecimals(currency));
|
|
40
|
+
}
|
|
41
|
+
function coerceSelect(column, text) {
|
|
42
|
+
return (column.options ?? []).find((opt) => opt === text || column.optionLabels?.[opt] === text);
|
|
43
|
+
}
|
|
44
|
+
function coerceReference(column, text) {
|
|
45
|
+
return (column.referenceOptions ?? []).find((o) => o.label === text || o.value === text)?.value;
|
|
46
|
+
}
|
|
47
|
+
export function coerceCellValue(column, text, currency) {
|
|
48
|
+
switch (column.type) {
|
|
49
|
+
case "text":
|
|
50
|
+
return text;
|
|
51
|
+
case "number":
|
|
52
|
+
case "decimal":
|
|
53
|
+
return coerceNumber(text);
|
|
54
|
+
case "money":
|
|
55
|
+
return coerceMoney(text, currency);
|
|
56
|
+
case "boolean":
|
|
57
|
+
return ["true", "1", "yes", "y", "ja"].includes(text.trim().toLowerCase());
|
|
58
|
+
case "date":
|
|
59
|
+
case "timestamp":
|
|
60
|
+
return text.trim();
|
|
61
|
+
case "select":
|
|
62
|
+
return coerceSelect(column, text);
|
|
63
|
+
case "reference":
|
|
64
|
+
return coerceReference(column, text);
|
|
65
|
+
case "images":
|
|
66
|
+
case "files":
|
|
67
|
+
return undefined;
|
|
68
|
+
default: {
|
|
69
|
+
const exhaustiveCheck = column.type;
|
|
70
|
+
return exhaustiveCheck;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -8,7 +8,7 @@ import { useListBulk } from "../app/list-bulk-actions.js";
|
|
|
8
8
|
import { buildFilterFacets, buildFilterPayload, mergeReferenceFacetOptions, resolveProjectionFacetSpecs, } from "../app/list-facets.js";
|
|
9
9
|
import { useNav } from "../app/nav.js";
|
|
10
10
|
import { SYNTHESIZED_FIELD_PII_REASON } from "../app/projection-list-shim.js";
|
|
11
|
-
import { ReferenceFacetBridges, } from "../app/reference-facet-bridge.js";
|
|
11
|
+
import { ReferenceFacetBridges, withReferenceFacetState, } from "../app/reference-facet-bridge.js";
|
|
12
12
|
import { navigateWithReturnTo, useReturnHost } from "../app/return-to.js";
|
|
13
13
|
import { buildDefaultEditRowAction, buildProjectionRowActions, buildProjectionToolbarActions, buildRecordActions, findRowClickAction, runDrawerRowAction, runProjectionRowNavigate, } from "../app/row-actions.js";
|
|
14
14
|
import { findEditScreenFor } from "../app/screen-access.js";
|
|
@@ -16,6 +16,7 @@ import { dispatcherErrorText } from "../app/write-failed-error.js";
|
|
|
16
16
|
import { useOptionalDispatcher } from "../context/dispatcher-context.js";
|
|
17
17
|
import { useUserRoles } from "../context/user-roles-context.js";
|
|
18
18
|
import { rendererWarn } from "../dev-console.js";
|
|
19
|
+
import { buildRelatedListParentPayload } from "../hooks/related-list-parent-payload.js";
|
|
19
20
|
import { useRelatedListRows } from "../hooks/use-related-list-rows.js";
|
|
20
21
|
import { useRelatedListState } from "../hooks/use-related-list-state.js";
|
|
21
22
|
import { useOptionalLocale, useTranslation } from "../i18n.js";
|
|
@@ -142,19 +143,14 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
|
|
|
142
143
|
const { search, filters, sort, setSearch, setFilters, setSort, listTable, groupField } = useRelatedListState(section, nav.route?.screenId);
|
|
143
144
|
const facetSpecs = useMemo(() => resolveProjectionFacetSpecs(section.facets, effectiveTranslate, featureName), [section.facets, effectiveTranslate, featureName]);
|
|
144
145
|
const [referenceFacetOptions, setReferenceFacetOptions] = useState({});
|
|
145
|
-
const handleFacetOptions = useCallback((field,
|
|
146
|
+
const handleFacetOptions = useCallback((field, state) => setReferenceFacetOptions((prev) => withReferenceFacetState(prev, field, state)), []);
|
|
146
147
|
const resolvedFacetSpecs = useMemo(() => mergeReferenceFacetOptions(facetSpecs, referenceFacetOptions), [facetSpecs, referenceFacetOptions]);
|
|
147
148
|
const filterPayload = useMemo(() => buildFilterPayload(filters, (field) => resolvedFacetSpecs.find((spec) => spec.field === field)?.type), [filters, resolvedFacetSpecs]);
|
|
148
149
|
const filterFacets = useMemo(() => buildFilterFacets(resolvedFacetSpecs), [resolvedFacetSpecs]);
|
|
149
150
|
const payload = useMemo(() => ({
|
|
150
|
-
// `
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
// separate from `filterPayload` (user facets) below so a facet
|
|
154
|
-
// selection can never clear or overwrite it.
|
|
155
|
-
...(section.parentFilter !== undefined
|
|
156
|
-
? { filter: { field: section.parentFilter.field, op: "eq", value: parentId } }
|
|
157
|
-
: { [section.parentParam ?? "id"]: parentId }),
|
|
151
|
+
// Kept separate from `filterPayload` (user facets) below so a facet selection can never
|
|
152
|
+
// clear or overwrite the parent pin.
|
|
153
|
+
...buildRelatedListParentPayload({ parentFilter: section.parentFilter, parentParam: section.parentParam }, parentId),
|
|
158
154
|
...(section.pageSize !== undefined && { limit: section.pageSize }),
|
|
159
155
|
// Gated on the declared capability, not just on state carrying a value —
|
|
160
156
|
// same rule as ProjectionListBody: a param the bound query's Zod schema
|
|
@@ -332,7 +328,7 @@ export function RelatedListSection({ section, parentId, record, featureName, tra
|
|
|
332
328
|
// `renderer` stays DOM-free. Stacked (non-tabs) sections keep their own
|
|
333
329
|
// Section card and document-flow height since they render a visible title
|
|
334
330
|
// and aren't confined to a tab panel.
|
|
335
|
-
const bridges = _jsx(ReferenceFacetBridges, { specs: facetSpecs,
|
|
331
|
+
const bridges = (_jsx(ReferenceFacetBridges, { specs: facetSpecs, selected: filters, onState: handleFacetOptions }));
|
|
336
332
|
if (embedded === true) {
|
|
337
333
|
const countSuffix = listRows.hasData
|
|
338
334
|
? ` · ${aggregates.serverGroups?.totalCount ?? listRows.rows.length}`
|
|
@@ -10,7 +10,7 @@ function renderTabbedGroupsSection(rc, section, sectionKey) {
|
|
|
10
10
|
// group cards (Card-in-Card) — the boot-validator rejects that
|
|
11
11
|
// combination in tabs mode, so `sectionActionsEl` is always
|
|
12
12
|
// undefined here at runtime.
|
|
13
|
-
return rc.wrapWizardStep(sectionKey, _jsx(Card, { options: { framed: false }, children: groupsGrid }, sectionKey));
|
|
13
|
+
return rc.wrapWizardStep(sectionKey, _jsx(Card, { options: { framed: false }, ...(section.description !== undefined && { slots: { subtitle: section.description } }), children: groupsGrid }, sectionKey));
|
|
14
14
|
}
|
|
15
15
|
function renderTabbedFieldsSection(rc, section, sectionKey) {
|
|
16
16
|
if (section.groups !== undefined)
|
|
@@ -73,7 +73,7 @@ export function wizardStepKey(section, index) {
|
|
|
73
73
|
return declaredId ?? section.title ?? `step-${index}`;
|
|
74
74
|
}
|
|
75
75
|
function isMoneyValue(value) {
|
|
76
|
-
return typeof value === "object" && value !== null && "amount" in value;
|
|
76
|
+
return typeof value === "object" && value !== null && ("amount" in value || "currency" in value);
|
|
77
77
|
}
|
|
78
78
|
// The seeds buildInitialValues writes for NULL columns of an existing record.
|
|
79
79
|
function isEmptyFormValue(value) {
|
|
@@ -82,7 +82,7 @@ function isEmptyFormValue(value) {
|
|
|
82
82
|
}
|
|
83
83
|
if (Array.isArray(value))
|
|
84
84
|
return value.length === 0;
|
|
85
|
-
return isMoneyValue(value) && value.amount === 0;
|
|
85
|
+
return isMoneyValue(value) && (value.amount === 0 || value.amount === undefined);
|
|
86
86
|
}
|
|
87
87
|
// select/boolean are excluded from the "holds user data" check: they are
|
|
88
88
|
// often pre-filled (defaults, server-set values), so they say nothing about
|
|
@@ -25,7 +25,7 @@ export function renderSelectInput({ field, Input, onChange }, { common }) {
|
|
|
25
25
|
// `display` is shared with multiSelect's "checkboxes", which is not a
|
|
26
26
|
// select presentation — narrow to the two a select field can request.
|
|
27
27
|
const display = field.display === "radio" || field.display === "dropdown" ? field.display : undefined;
|
|
28
|
-
return (_jsx(Input, { kind: "select", ...common, value: stringValue(field.value), onChange: (v) => onChange(v), options: selectOptions, ...(display !== undefined && { display }) }));
|
|
28
|
+
return (_jsx(Input, { kind: "select", ...common, value: stringValue(field.value), onChange: (v) => onChange(v), options: selectOptions, ...(display !== undefined && { display }), ...(field.optionTones !== undefined && { optionTones: field.optionTones }) }));
|
|
29
29
|
}
|
|
30
30
|
export function renderFileInput({ field, Input, onChange }, { common }) {
|
|
31
31
|
const kind = field.type === "image" ? "image" : "file";
|
|
@@ -16,6 +16,11 @@ export type DataTableFacet = {
|
|
|
16
16
|
readonly showCounts?: boolean;
|
|
17
17
|
/** Chips only: hide chips whose `count` is 0 (unless pressed). */
|
|
18
18
|
readonly hideEmpty?: boolean;
|
|
19
|
+
/** Present: the options come from a server-side search (reference facets), so the dropdown
|
|
20
|
+
* shows a search field and reports the typed term instead of filtering `options` itself. */
|
|
21
|
+
readonly onSearchChange?: (query: string) => void;
|
|
22
|
+
/** The search is loading; the option list may still show the previous term's rows. */
|
|
23
|
+
readonly searching?: boolean;
|
|
19
24
|
};
|
|
20
25
|
export type DataTableFacetChip = {
|
|
21
26
|
readonly id: string;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { SelectOptionTone } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
2
|
+
import type { FieldIssue } from "./headless/index.ts";
|
|
3
|
+
/** Cell type for one column of an embedded-list field. Mirrors
|
|
4
|
+
* EmbeddedListCellViewModel["type"] from `@cosmicdrift/kumiko-renderer/headless`. */
|
|
5
|
+
export type EmbeddedListCellType = "text" | "number" | "boolean" | "date" | "money" | "decimal" | "select" | "reference" | "timestamp" | "images" | "files";
|
|
6
|
+
/** One column of an embedded-list table (one entry per key of the source
|
|
7
|
+
* EmbeddedFieldDef's `schema`). */
|
|
8
|
+
export type EmbeddedListColumn = {
|
|
9
|
+
readonly field: string;
|
|
10
|
+
readonly label: string;
|
|
11
|
+
readonly type: EmbeddedListCellType;
|
|
12
|
+
readonly required: boolean;
|
|
13
|
+
/** Read-only, value supplied by the caller (already computed from
|
|
14
|
+
* sibling cells) — not directly user-editable. */
|
|
15
|
+
readonly derived: boolean;
|
|
16
|
+
/** Shown but not editable by the user; unlike `derived` it is not computed. */
|
|
17
|
+
readonly readOnly?: boolean;
|
|
18
|
+
/** Phone cards skip this column; the table still shows it. */
|
|
19
|
+
readonly hiddenInCard?: boolean;
|
|
20
|
+
/** Phone cards show the row's min/max (sub-field names) as a hint under this column's control. */
|
|
21
|
+
readonly cardLimits?: {
|
|
22
|
+
readonly min: string;
|
|
23
|
+
readonly max: string;
|
|
24
|
+
};
|
|
25
|
+
/** Phone cards render this column unlabeled beside the named column's control. */
|
|
26
|
+
readonly cardAttachedTo?: string;
|
|
27
|
+
/** Only for `type: "select"`. */
|
|
28
|
+
readonly options?: readonly string[];
|
|
29
|
+
readonly optionLabels?: Readonly<Record<string, string>>;
|
|
30
|
+
/** Only for `type: "select"`; `"radio"` renders segmented buttons, the
|
|
31
|
+
* default is a dropdown. */
|
|
32
|
+
readonly display?: "radio" | "dropdown";
|
|
33
|
+
/** Only for `type: "reference"` — pre-fetched by the caller, ONE query
|
|
34
|
+
* per column shared across every row (not one query per cell). */
|
|
35
|
+
readonly referenceOptions?: readonly {
|
|
36
|
+
readonly value: string;
|
|
37
|
+
readonly label: string;
|
|
38
|
+
}[];
|
|
39
|
+
readonly referenceLoading?: boolean;
|
|
40
|
+
/** Only for `type: "select"`; tone per option (selected segment, derived badge). */
|
|
41
|
+
readonly optionTones?: Readonly<Partial<Record<string, SelectOptionTone>>>;
|
|
42
|
+
/** Only for `type: "images" | "files"`. */
|
|
43
|
+
readonly capture?: "user" | "environment";
|
|
44
|
+
readonly maxCount?: number;
|
|
45
|
+
readonly accept?: readonly string[];
|
|
46
|
+
/** Per-row state, evaluated by the caller (conditions are engine logic the primitive must not import).
|
|
47
|
+
* A hidden cell keeps its value but shows no control. */
|
|
48
|
+
readonly isHiddenInRow?: (row: Readonly<Record<string, unknown>>) => boolean;
|
|
49
|
+
readonly isRequiredInRow?: (row: Readonly<Record<string, unknown>>) => boolean;
|
|
50
|
+
/** Overrides `derived` per row: a conditional derived cell is editable on rows where it does not apply. */
|
|
51
|
+
readonly isDerivedInRow?: (row: Readonly<Record<string, unknown>>) => boolean;
|
|
52
|
+
};
|
|
53
|
+
/** One entry of an embedded-list totals row (e.g. an invoice's total
|
|
54
|
+
* amount, summed across all rows by the caller). */
|
|
55
|
+
export type EmbeddedListTotal = {
|
|
56
|
+
readonly field: string;
|
|
57
|
+
readonly label: string;
|
|
58
|
+
readonly value: number;
|
|
59
|
+
/** Set for labelled multi-column totals, which have no single column to
|
|
60
|
+
* read the money type from. */
|
|
61
|
+
readonly money?: boolean;
|
|
62
|
+
};
|
|
63
|
+
/** Row-array + totals table for a `createEmbeddedListField` (invoice-
|
|
64
|
+
* positions-style) field — structurally too different from `Input`'s
|
|
65
|
+
* value/onChange union to fit there, so it's its own primitive, same as
|
|
66
|
+
* `DataTable`. Controlled: the caller (the eventual form-field wrapper)
|
|
67
|
+
* computes rows/derived values/issue groups and wires every callback;
|
|
68
|
+
* this primitive only renders and reports interaction. */
|
|
69
|
+
export type EmbeddedListInputProps = {
|
|
70
|
+
readonly id: string;
|
|
71
|
+
readonly columns: readonly EmbeddedListColumn[];
|
|
72
|
+
readonly rows: readonly Readonly<Record<string, unknown>>[];
|
|
73
|
+
readonly totals?: readonly EmbeddedListTotal[];
|
|
74
|
+
/** Currency for money cells and the totals row — one value for the whole
|
|
75
|
+
* list (currency lives on the head aggregate, not the row). Web impl
|
|
76
|
+
* falls back to "EUR" when absent. */
|
|
77
|
+
readonly currency?: string;
|
|
78
|
+
/** Whole-list read-only (e.g. a released invoice) — hides row-mutation
|
|
79
|
+
* affordances and disables every cell. */
|
|
80
|
+
readonly disabled?: boolean;
|
|
81
|
+
/** Rows are fixed: no add, remove, duplicate or move; cells stay editable. */
|
|
82
|
+
readonly fixedRows?: boolean;
|
|
83
|
+
readonly minItems?: number;
|
|
84
|
+
readonly maxItems?: number;
|
|
85
|
+
/** Upload target of file cells: `entityType`/`entityId`/`fieldName` of the list, the column name is the `subField`. */
|
|
86
|
+
readonly uploadTarget?: {
|
|
87
|
+
readonly entityType: string;
|
|
88
|
+
readonly fieldName: string;
|
|
89
|
+
readonly entityId?: string;
|
|
90
|
+
};
|
|
91
|
+
/** Shown under the whole list (e.g. under the totals row). */
|
|
92
|
+
readonly listIssues?: readonly FieldIssue[];
|
|
93
|
+
/** Keyed by row index. */
|
|
94
|
+
readonly rowIssues?: Readonly<Record<number, readonly FieldIssue[]>>;
|
|
95
|
+
/** Keyed `${rowIndex}.${field}`. */
|
|
96
|
+
readonly cellIssues?: Readonly<Record<string, readonly FieldIssue[]>>;
|
|
97
|
+
readonly onCellChange: (rowIndex: number, field: string, value: unknown) => void;
|
|
98
|
+
readonly onAddRow: () => void;
|
|
99
|
+
readonly onRemoveRow: (rowIndex: number) => void;
|
|
100
|
+
readonly onDuplicateRow: (rowIndex: number) => void;
|
|
101
|
+
readonly onMoveRow: (fromIndex: number, toIndex: number) => void;
|
|
102
|
+
/** Tab/newline-delimited clipboard paste starting at (rowIndex,
|
|
103
|
+
* columnIndex) — parsed to a 2D string grid by the Web impl (paste
|
|
104
|
+
* events are a browser-only concept); undefined caller = no paste
|
|
105
|
+
* handling wired up. */
|
|
106
|
+
readonly onPasteCells?: (rowIndex: number, columnIndex: number, grid: readonly (readonly string[])[]) => void;
|
|
107
|
+
readonly addLabel: string;
|
|
108
|
+
/** Accessible name of the (otherwise headerless) row-actions column. */
|
|
109
|
+
readonly actionsLabel: string;
|
|
110
|
+
readonly removeLabel: string;
|
|
111
|
+
readonly duplicateLabel: string;
|
|
112
|
+
readonly moveUpLabel: string;
|
|
113
|
+
readonly moveDownLabel: string;
|
|
114
|
+
readonly emptyLabel: string;
|
|
115
|
+
readonly emptyCtaLabel: string;
|
|
116
|
+
readonly testId?: string;
|
|
117
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -62,6 +62,13 @@ function valuesDiff(current, initial) {
|
|
|
62
62
|
// of being omitted — no validation error surfaces it, unlike the "" case.
|
|
63
63
|
// Fixing that needs real per-field seed-tracking (which value was actually
|
|
64
64
|
// user-set vs. defaulted), not just a wider strip predicate here.
|
|
65
|
+
// The seed of an untouched optional money field (buildInitialValues); never a valid payload value.
|
|
66
|
+
function isCurrencyOnlyMoney(value) {
|
|
67
|
+
return (typeof value === "object" &&
|
|
68
|
+
value !== null &&
|
|
69
|
+
"currency" in value &&
|
|
70
|
+
!("amount" in value && value.amount !== undefined));
|
|
71
|
+
}
|
|
65
72
|
function stripUntouchedEmptyStrings(values, initial, forceIncludeEmpty) {
|
|
66
73
|
const v = values; // @cast-boundary form-values
|
|
67
74
|
const ini = initial; // @cast-boundary form-values
|
|
@@ -69,7 +76,8 @@ function stripUntouchedEmptyStrings(values, initial, forceIncludeEmpty) {
|
|
|
69
76
|
for (const key of Object.keys(v)) {
|
|
70
77
|
// Value-based seed strip, except keys previously submitted as "" after a
|
|
71
78
|
// real clear — those must survive post-rebase submits.
|
|
72
|
-
|
|
79
|
+
const untouchedEmptyString = !forceIncludeEmpty.has(key) && v[key] === "" && ini[key] === "";
|
|
80
|
+
if (untouchedEmptyString || isCurrencyOnlyMoney(v[key])) {
|
|
73
81
|
out ??= { ...v };
|
|
74
82
|
delete out[key];
|
|
75
83
|
}
|
package/dist/headless/index.d.ts
CHANGED
|
@@ -12,4 +12,4 @@ export { pluralKey } from "./plural-key.ts";
|
|
|
12
12
|
export type { Store, WritableStore } from "./store/index.ts";
|
|
13
13
|
export { createStore, shallowEqual } from "./store/index.ts";
|
|
14
14
|
export type { CellLink, CellLinkItem, ComputeEditViewModelInput, ComputeListViewModelInput, DerivedCellRoundingTarget, EditExtensionSectionViewModel, EditFieldSpec, EditFieldsSectionViewModel, EditFieldViewModel, EditFormMode, EditRelatedListSectionViewModel, EditSectionSpec, EditSectionViewModel, EditViewModel, EditWriteFormSectionViewModel, EmbeddedDerivedOp, EmbeddedListCellViewModel, EmbeddedListIssueGroups, FieldConditionCtx, FieldRenderer, ListColumnFileDisplay, ListColumnSpec, ListColumnViewModel, ListRowViewModel, ListViewModel, RuntimeCellLink, RuntimeCellLinkItems, RuntimeRenderer, ScreenSlots, Translate, } from "./view-model/index.ts";
|
|
15
|
-
export { buildOptionLabels, computeDerivedCellValue, computeEditViewModel, computeListViewModel, computeRelatedListSectionViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./view-model/index.ts";
|
|
15
|
+
export { buildOptionLabels, computeDerivedCellValue, computeEditViewModel, computeListViewModel, computeRelatedListSectionViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, withDerivedCells, } from "./view-model/index.ts";
|
package/dist/headless/index.js
CHANGED
|
@@ -7,4 +7,4 @@ export { applyFormatSpec, currencyDecimals, DOCUMENT_FALLBACK_CURRENCY, EMPTY_VA
|
|
|
7
7
|
export { resolveNavigation } from "./nav/index.js";
|
|
8
8
|
export { pluralKey } from "./plural-key.js";
|
|
9
9
|
export { createStore, shallowEqual } from "./store/index.js";
|
|
10
|
-
export { buildOptionLabels, computeDerivedCellValue, computeEditViewModel, computeListViewModel, computeRelatedListSectionViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, } from "./view-model/index.js";
|
|
10
|
+
export { buildOptionLabels, computeDerivedCellValue, computeEditViewModel, computeListViewModel, computeRelatedListSectionViewModel, embeddedCellLabelKey, embeddedCellOptionLabelKey, fieldLabelKey, fieldOptionLabelKey, fieldOptionLabelKeyPrefix, groupEmbeddedListIssues, roundDerivedCellValue, sumEmbeddedListColumn, withDerivedCells, } from "./view-model/index.js";
|