@cosmicdrift/kumiko-renderer 0.384.0 → 0.385.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/action-form-body.d.ts +50 -0
- package/dist/app/action-form-body.js +275 -0
- package/dist/app/client-actions.js +2 -2
- package/dist/app/config-edit-body.d.ts +9 -0
- package/dist/app/config-edit-body.js +340 -0
- package/dist/app/entity-edit-prefill.d.ts +27 -0
- package/dist/app/entity-edit-prefill.js +377 -0
- package/dist/app/entity-edit-screen.d.ts +11 -0
- package/dist/app/entity-edit-screen.js +395 -0
- package/dist/app/entity-list-screen.d.ts +10 -0
- package/dist/app/entity-list-screen.js +547 -0
- package/dist/app/kumiko-screen.d.ts +1 -36
- package/dist/app/kumiko-screen.js +16 -2855
- package/dist/app/projection-detail-body.d.ts +12 -0
- package/dist/app/projection-detail-body.js +464 -0
- package/dist/app/projection-detail-shim.d.ts +1 -1
- package/dist/app/projection-detail-shim.js +5 -1
- package/dist/app/projection-list-body.d.ts +10 -0
- package/dist/app/projection-list-body.js +245 -0
- package/dist/app/projection-list-shim.d.ts +2 -1
- package/dist/app/projection-list-shim.js +4 -0
- package/dist/app/reference-facet-bridge.js +2 -2
- package/dist/app/row-actions.js +3 -4
- package/dist/app/screen-shared.d.ts +72 -0
- package/dist/app/screen-shared.js +325 -0
- package/dist/app/secret-mint-body.js +3 -3
- package/dist/components/edit-mounts.d.ts +33 -0
- package/dist/components/edit-mounts.js +53 -0
- package/dist/components/error-boundary.js +2 -2
- package/dist/components/reference-create-dialog.js +1 -1
- package/dist/components/related-list-section.js +2 -2
- package/dist/components/render-edit-helpers.d.ts +29 -0
- package/dist/components/render-edit-helpers.js +121 -0
- package/dist/components/render-edit.d.ts +1 -4
- package/dist/components/render-edit.js +41 -443
- package/dist/components/render-field.js +3 -4
- package/dist/components/render-list.js +2 -2
- package/dist/components/use-wizard-form-draft.d.ts +51 -0
- package/dist/components/use-wizard-form-draft.js +291 -0
- package/dist/dev-console.d.ts +3 -0
- package/dist/dev-console.js +15 -0
- package/dist/headless/contracts/primitives.js +14 -17
- package/dist/headless/format/index.js +3 -3
- package/dist/headless/view-model/edit.d.ts +1 -1
- package/dist/headless/view-model/edit.js +9 -16
- package/dist/headless/view-model/list.d.ts +1 -1
- package/dist/headless/view-model/list.js +3 -8
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -5
- package/dist/live/error-mapping.js +3 -4
- package/dist/web/app/create-app.js +5 -8
- package/dist/web/app/dashboard-body.js +4 -599
- package/dist/web/app/dashboard-panels.d.ts +53 -0
- package/dist/web/app/dashboard-panels.js +373 -0
- package/dist/web/app/dashboard-shared.d.ts +90 -0
- package/dist/web/app/dashboard-shared.js +171 -0
- package/dist/web/app/dashboard-text.d.ts +6 -0
- package/dist/web/app/dashboard-text.js +65 -0
- package/dist/web/layout/nav-tree.js +3 -4
- package/dist/web/layout/target-resolver-stub.js +2 -2
- package/dist/web/primitives/banner.d.ts +4 -0
- package/dist/web/primitives/banner.js +28 -0
- package/dist/web/primitives/button.d.ts +7 -0
- package/dist/web/primitives/button.js +48 -0
- package/dist/web/primitives/card-styles.d.ts +8 -0
- package/dist/web/primitives/card-styles.js +18 -0
- package/dist/web/primitives/card.d.ts +4 -0
- package/dist/web/primitives/card.js +41 -0
- package/dist/web/primitives/contexts.d.ts +22 -0
- package/dist/web/primitives/contexts.js +50 -0
- package/dist/web/primitives/data-table-cell.d.ts +76 -0
- package/dist/web/primitives/data-table-cell.js +403 -0
- package/dist/web/primitives/data-table.d.ts +4 -0
- package/dist/web/primitives/data-table.js +279 -0
- package/dist/web/primitives/default-form.d.ts +4 -0
- package/dist/web/primitives/default-form.js +79 -0
- package/dist/web/primitives/facet-filter.d.ts +14 -0
- package/dist/web/primitives/facet-filter.js +39 -0
- package/dist/web/primitives/field.d.ts +63 -0
- package/dist/web/primitives/field.js +181 -0
- package/dist/web/primitives/form.d.ts +53 -0
- package/dist/web/primitives/form.js +252 -0
- package/dist/web/primitives/index.d.ts +7 -30
- package/dist/web/primitives/index.js +27 -2006
- package/dist/web/primitives/input.d.ts +4 -0
- package/dist/web/primitives/input.js +166 -0
- package/dist/web/primitives/layout-elements.d.ts +14 -0
- package/dist/web/primitives/layout-elements.js +194 -0
- package/dist/web/primitives/modal.js +1 -1
- package/dist/web/primitives/pager.d.ts +22 -0
- package/dist/web/primitives/pager.js +89 -0
- package/dist/web/primitives/row-actions.d.ts +13 -0
- package/dist/web/primitives/row-actions.js +178 -0
- package/dist/web/primitives/secret-reveal.d.ts +4 -0
- package/dist/web/primitives/secret-reveal.js +67 -0
- package/package.json +5 -5
- package/src/changes.json +6 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-types/kms-adapter-types";
|
|
3
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
4
|
+
import { ArrowDown, ArrowUp, ArrowUpDown, Check } from "lucide-react";
|
|
5
|
+
import { Fragment } from "react";
|
|
6
|
+
import { rendererWarn } from "../../dev-console.js";
|
|
7
|
+
import { applyFormatSpec, EMPTY_VALUE_PLACEHOLDER, isSafeHref } from "../../headless/index.js";
|
|
8
|
+
import { statusToneForOptionTone, statusToneForValue, useColumnRenderer, useOptionalPrimitives, } from "../../index.js";
|
|
9
|
+
import { cn } from "../lib/cn.js";
|
|
10
|
+
import { Badge } from "../ui/badge.js";
|
|
11
|
+
import { TableHead } from "../ui/table.js";
|
|
12
|
+
import { StatusBadge } from "../widgets/status-badge.js";
|
|
13
|
+
import { FileDisplayCell } from "./file-thumbnail.js";
|
|
14
|
+
import { currencyDecimals, formatMoney } from "./money-input.js";
|
|
15
|
+
import "../react-css-custom-properties.js";
|
|
16
|
+
import { isPlainPrimaryClick } from "./layout-elements.js";
|
|
17
|
+
export function isEmptyCellValue(value) {
|
|
18
|
+
return (value === null || value === undefined || (typeof value === "string" && value.trim() === ""));
|
|
19
|
+
}
|
|
20
|
+
// A boolean false renders as an empty string unless the column's format spec names a falseLabel.
|
|
21
|
+
// Projection lists synthesize every column as type "text", so a boolean format spec
|
|
22
|
+
// marks a boolean column on its own.
|
|
23
|
+
function isBooleanColumn(type, renderer) {
|
|
24
|
+
if (type === "boolean")
|
|
25
|
+
return true;
|
|
26
|
+
return (typeof renderer === "object" &&
|
|
27
|
+
renderer !== null &&
|
|
28
|
+
"format" in renderer &&
|
|
29
|
+
renderer.format === "boolean");
|
|
30
|
+
}
|
|
31
|
+
export function isUnlabeledFalse(type, value, renderer) {
|
|
32
|
+
if (value !== false || !isBooleanColumn(type, renderer))
|
|
33
|
+
return false;
|
|
34
|
+
if (renderer === undefined)
|
|
35
|
+
return true;
|
|
36
|
+
return (typeof renderer === "object" &&
|
|
37
|
+
renderer !== null &&
|
|
38
|
+
"format" in renderer &&
|
|
39
|
+
renderer.format === "boolean" &&
|
|
40
|
+
!("falseLabel" in renderer));
|
|
41
|
+
}
|
|
42
|
+
function isUnlabeledBooleanFormat(renderer) {
|
|
43
|
+
return (typeof renderer === "object" &&
|
|
44
|
+
renderer !== null &&
|
|
45
|
+
"format" in renderer &&
|
|
46
|
+
renderer.format === "boolean" &&
|
|
47
|
+
!("trueLabel" in renderer) &&
|
|
48
|
+
!("falseLabel" in renderer));
|
|
49
|
+
}
|
|
50
|
+
function BooleanCell({ value, translate, }) {
|
|
51
|
+
const label = value
|
|
52
|
+
? (translate?.("kumiko.boolean.yes") ?? "Yes")
|
|
53
|
+
: (translate?.("kumiko.boolean.no") ?? "No");
|
|
54
|
+
return (_jsxs("span", { "data-boolean-cell": value ? "true" : "false", children: [value ? (_jsx(Check, { "aria-hidden": "true", className: "h-4 w-4" })) : (_jsx("span", { "aria-hidden": "true", className: "text-muted-foreground", children: EMPTY_VALUE_PLACEHOLDER })), _jsx("span", { className: "sr-only", children: label })] }));
|
|
55
|
+
}
|
|
56
|
+
// True when the cell would render the default "✓": no renderer, or a boolean
|
|
57
|
+
// format spec that names no trueLabel of its own.
|
|
58
|
+
export function showsBareCheckMark(type, value, renderer) {
|
|
59
|
+
if (value !== true || !isBooleanColumn(type, renderer))
|
|
60
|
+
return false;
|
|
61
|
+
if (renderer === undefined)
|
|
62
|
+
return true;
|
|
63
|
+
return (typeof renderer === "object" &&
|
|
64
|
+
renderer !== null &&
|
|
65
|
+
"format" in renderer &&
|
|
66
|
+
!("trueLabel" in renderer));
|
|
67
|
+
}
|
|
68
|
+
// Badges (select pills, component renderers) must never be clipped by the card's meta truncation.
|
|
69
|
+
export function isBadgeColumn(col) {
|
|
70
|
+
return (col.type === "select" ||
|
|
71
|
+
col.type === "multiSelect" ||
|
|
72
|
+
isComponentRendererRef(col.renderer) !== undefined);
|
|
73
|
+
}
|
|
74
|
+
// The first cell is the keyboard-reachable entry point of a clickable row; the
|
|
75
|
+
// row's own onClick keeps serving the mouse. Enter is handled explicitly so
|
|
76
|
+
// it also opens the row where a button does not synthesize a click.
|
|
77
|
+
export function FirstCellLink({ enabled, empty, emptyLabel, onOpen, children, }) {
|
|
78
|
+
if (!enabled || (empty && emptyLabel === undefined))
|
|
79
|
+
return children;
|
|
80
|
+
return (_jsx("button", { type: "button", className: "max-w-full cursor-pointer truncate bg-transparent p-0 text-left font-medium text-foreground hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", onClick: (e) => {
|
|
81
|
+
e.stopPropagation();
|
|
82
|
+
onOpen();
|
|
83
|
+
}, onKeyDown: (e) => {
|
|
84
|
+
if (e.key !== "Enter")
|
|
85
|
+
return;
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
e.stopPropagation();
|
|
88
|
+
onOpen();
|
|
89
|
+
}, children: empty ? _jsx("span", { className: "sr-only", children: emptyLabel }) : children }));
|
|
90
|
+
}
|
|
91
|
+
// SortableHeader: renders the th header per column, with or without
|
|
92
|
+
// click-sort. Three paths:
|
|
93
|
+
// (a) sortable=false OR no onSortChange -> plain label, no cursor
|
|
94
|
+
// interaction (DataTable purely as a view without sort wiring).
|
|
95
|
+
// (b) sortable=true + onSortChange -> header is a button, click
|
|
96
|
+
// cycles asc -> desc -> null. aria-sort mirrors the state.
|
|
97
|
+
// (c) sortable=true in the schema but no onSortChange prop -> still
|
|
98
|
+
// label-only, but data-sortable=true stays so tests that know the
|
|
99
|
+
// schema view keep working.
|
|
100
|
+
// w-px on an auto-layout table cell shrinks the column to its content (the
|
|
101
|
+
// actions column relies on the same behaviour).
|
|
102
|
+
export const FIT_CONTENT_CELL_CLASS = "w-px";
|
|
103
|
+
export function SortableHeader({ field, label, sortable, highlighted, numeric, fitContent, sort, onSortChange, }) {
|
|
104
|
+
const active = sort?.field === field ? sort : undefined;
|
|
105
|
+
const ariaSort = active?.dir === "asc" ? "ascending" : active?.dir === "desc" ? "descending" : "none";
|
|
106
|
+
if (!sortable || onSortChange === undefined) {
|
|
107
|
+
return (_jsx(TableHead, { "data-testid": `column-${field}`, "data-sortable": sortable === true ? true : undefined, "data-highlighted": highlighted === true ? "true" : undefined, className: cn("font-medium whitespace-normal text-muted-foreground", numeric === true && "text-right", fitContent === true && FIT_CONTENT_CELL_CLASS, highlighted === true && "bg-accent/40"), children: label }));
|
|
108
|
+
}
|
|
109
|
+
const Icon = active?.dir === "asc" ? ArrowUp : active?.dir === "desc" ? ArrowDown : ArrowUpDown;
|
|
110
|
+
const next = nextSortState(active?.dir, field);
|
|
111
|
+
return (_jsx(TableHead, { "data-testid": `column-${field}`, "data-sortable": "true", "data-highlighted": highlighted === true ? "true" : undefined, "aria-sort": ariaSort, className: cn(active !== undefined
|
|
112
|
+
? "font-semibold text-foreground"
|
|
113
|
+
: "font-medium text-muted-foreground", "whitespace-normal", numeric === true && "text-right", fitContent === true && FIT_CONTENT_CELL_CLASS, highlighted === true && "bg-accent/40"), children: _jsxs("button", { type: "button", onClick: () => onSortChange(next), className: cn("group/sort inline-flex h-8 items-center gap-1.5 rounded-sm px-2 -mx-2 text-sm [font-weight:inherit]", "hover:bg-accent hover:text-accent-foreground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", active !== undefined && "text-foreground"), children: [_jsx("span", { children: label }), _jsx(Icon, { "data-sort-icon": active === undefined ? "idle" : "active", className: cn("size-3.5", active === undefined &&
|
|
114
|
+
"opacity-0 group-hover/sort:opacity-40 group-focus-visible/sort:opacity-40"), "aria-hidden": "true" })] }) }));
|
|
115
|
+
}
|
|
116
|
+
// 3-state toggle: no sort -> asc -> desc -> no sort. Idiomatic for
|
|
117
|
+
// power-user lists where "I want the server default order back" is a
|
|
118
|
+
// real action (instead of toggling asc<->desc forever).
|
|
119
|
+
function nextSortState(current, field) {
|
|
120
|
+
if (current === undefined)
|
|
121
|
+
return { field, dir: "asc" };
|
|
122
|
+
if (current === "asc")
|
|
123
|
+
return { field, dir: "desc" };
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
// Type guard for the `{ react: { __component: "Name" } }` form in which
|
|
127
|
+
// PlatformComponent renderers arrive serialized in the schema. Schemas
|
|
128
|
+
// travel over the wire (server -> client), real component refs would break
|
|
129
|
+
// that; the string key is the single source of truth.
|
|
130
|
+
export function isComponentRendererRef(renderer) {
|
|
131
|
+
if (renderer === null || typeof renderer !== "object")
|
|
132
|
+
return undefined;
|
|
133
|
+
const reactBranch = propertyOf(renderer, "react");
|
|
134
|
+
if (reactBranch === null || typeof reactBranch !== "object")
|
|
135
|
+
return undefined;
|
|
136
|
+
const component = propertyOf(reactBranch, "__component");
|
|
137
|
+
if (typeof component !== "string" || component.length === 0)
|
|
138
|
+
return undefined;
|
|
139
|
+
return { name: component };
|
|
140
|
+
}
|
|
141
|
+
function isMoneyValue(value) {
|
|
142
|
+
const currency = propertyOf(value, "currency");
|
|
143
|
+
return (typeof value === "object" &&
|
|
144
|
+
value !== null &&
|
|
145
|
+
typeof propertyOf(value, "amount") === "number" &&
|
|
146
|
+
typeof currency === "string" &&
|
|
147
|
+
/^[A-Za-z]{3}$/.test(currency));
|
|
148
|
+
}
|
|
149
|
+
// Type-specific default cell renderers. The author can set an explicit
|
|
150
|
+
// renderer per column (FormatSpec or PlatformComponent); without an
|
|
151
|
+
// explicit renderer DataTableCell falls through to here.
|
|
152
|
+
//
|
|
153
|
+
// - boolean -> check mark / empty
|
|
154
|
+
// - timestamp/date -> locale-formatted (no raw ISO string)
|
|
155
|
+
// - number/decimal/bigInt → locale-formatted via Intl.NumberFormat (fw#2160)
|
|
156
|
+
// - select/multiSelect → human-readable (kebab-case → Title Case), multiSelect values joined with ", "
|
|
157
|
+
// - money → { amount, currency } formatted via Intl (not "[object Object]")
|
|
158
|
+
// - text/else -> toString
|
|
159
|
+
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/;
|
|
160
|
+
// Deduplicated per column key only (not per screen): a once-per-field-name
|
|
161
|
+
// hint, so a second screen reusing the same field name stays silent.
|
|
162
|
+
const warnedTimestampColumns = new Set();
|
|
163
|
+
// A raw ISO string in a "text" column means the author forgot `renderer:
|
|
164
|
+
// { format: "timestamp" }` — guessing a format here could be wrong, so we
|
|
165
|
+
// warn and leave the value as-is instead of auto-formatting.
|
|
166
|
+
function warnMissingTimestampFormat(columnKey) {
|
|
167
|
+
if (typeof process === "undefined" || process.env.NODE_ENV === "production")
|
|
168
|
+
return;
|
|
169
|
+
const key = columnKey ?? "<unknown column>";
|
|
170
|
+
if (warnedTimestampColumns.has(key))
|
|
171
|
+
return;
|
|
172
|
+
warnedTimestampColumns.add(key);
|
|
173
|
+
rendererWarn(`[kumiko] column "${key}" renders a raw ISO timestamp as text — add renderer: { format: "timestamp" } to its column definition.`);
|
|
174
|
+
}
|
|
175
|
+
export function defaultCellRender(value, type, optionLabels, locale, columnKey, grouping = true) {
|
|
176
|
+
if (value === null || value === undefined || value === "")
|
|
177
|
+
return "";
|
|
178
|
+
if (type === "boolean")
|
|
179
|
+
return value === true ? "✓" : "";
|
|
180
|
+
if (type === "timestamp" || type === "date") {
|
|
181
|
+
return applyFormatSpec({ format: type, locale }, value);
|
|
182
|
+
}
|
|
183
|
+
if (type === "number" || type === "decimal" || type === "bigInt") {
|
|
184
|
+
return applyFormatSpec({ format: type, locale, grouping }, value);
|
|
185
|
+
}
|
|
186
|
+
if (type === "money") {
|
|
187
|
+
if (!isMoneyValue(value))
|
|
188
|
+
return String(value);
|
|
189
|
+
// formatMoney expects minor units scaled by currencyDecimals(currency).
|
|
190
|
+
// rehydrateMoney's `amountMinor` is scaled by a flat MINOR_UNIT_SCALE=100
|
|
191
|
+
// instead, which disagrees with currencyDecimals for non-2-decimal
|
|
192
|
+
// currencies (JPY: 0 decimals → 100x too high; BHD: 3 decimals → 10x too
|
|
193
|
+
// low). Deriving minor units from `amount` keeps this consistent with
|
|
194
|
+
// render-field.tsx's moneyMinorValue.
|
|
195
|
+
const minor = Math.round(value.amount * 10 ** currencyDecimals(value.currency));
|
|
196
|
+
return formatMoney(minor, value.currency, locale);
|
|
197
|
+
}
|
|
198
|
+
if (type === "select" || type === "multiSelect") {
|
|
199
|
+
const values = Array.isArray(value) ? value : [value];
|
|
200
|
+
return values
|
|
201
|
+
.map((v) => {
|
|
202
|
+
const raw = String(v);
|
|
203
|
+
// Translated label from the view-model builder (convention key
|
|
204
|
+
// `<feature>:entity:<entity>:field:<field>:option:<value>`).
|
|
205
|
+
// Fallback to humanizeSlug when no label is registered — same
|
|
206
|
+
// behavior as before the optionLabels patch.
|
|
207
|
+
const translated = optionLabels?.[raw];
|
|
208
|
+
return translated !== undefined && translated !== raw ? translated : humanizeSlug(raw);
|
|
209
|
+
})
|
|
210
|
+
.join(", ");
|
|
211
|
+
}
|
|
212
|
+
if (type === "text" && typeof value === "string" && ISO_DATETIME_RE.test(value)) {
|
|
213
|
+
warnMissingTimestampFormat(columnKey);
|
|
214
|
+
}
|
|
215
|
+
return typeof value === "string" ? value : String(value);
|
|
216
|
+
}
|
|
217
|
+
function humanizeSlug(slug) {
|
|
218
|
+
// "degraded-performance" → "Degraded performance". A dotted value such as
|
|
219
|
+
// "mobile.de" is a domain, not a slug, and must stay as stored.
|
|
220
|
+
if (slug.includes("."))
|
|
221
|
+
return slug;
|
|
222
|
+
const spaced = slug.replace(/[-_]/g, " ");
|
|
223
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
224
|
+
}
|
|
225
|
+
// Tooltip text for truncated cells: on hover the browser shows the
|
|
226
|
+
// full text. Skipped for object/array (not user-readable);
|
|
227
|
+
// the browser stringifies numbers correctly anyway; booleans get no raw true/false tooltip.
|
|
228
|
+
export function cellTitle(value) {
|
|
229
|
+
if (value === null || value === undefined)
|
|
230
|
+
return undefined;
|
|
231
|
+
if (typeof value === "string")
|
|
232
|
+
return value.length > 0 ? value : undefined;
|
|
233
|
+
if (typeof value === "number")
|
|
234
|
+
return String(value);
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
function selectPill(value, optionLabels, optionTones, locale, RegisteredStatusBadge) {
|
|
238
|
+
const label = defaultCellRender(value, "select", optionLabels, locale);
|
|
239
|
+
const declaredTone = typeof value === "string" && optionTones && Object.hasOwn(optionTones, value)
|
|
240
|
+
? optionTones[value]
|
|
241
|
+
: undefined;
|
|
242
|
+
const tone = (declaredTone !== undefined ? statusToneForOptionTone(declaredTone) : undefined) ??
|
|
243
|
+
(typeof value === "string" ? statusToneForValue(value) : undefined);
|
|
244
|
+
if (tone !== undefined) {
|
|
245
|
+
// Same registered primitive as the projectionDetail header, so an app-provided badge applies to list cells too.
|
|
246
|
+
if (RegisteredStatusBadge !== undefined) {
|
|
247
|
+
return _jsx(RegisteredStatusBadge, { value: label, tone: tone });
|
|
248
|
+
}
|
|
249
|
+
return _jsx(StatusBadge, { tone: tone, children: label });
|
|
250
|
+
}
|
|
251
|
+
// dashboard-01 pattern: outline badge + muted instead of a filled secondary.
|
|
252
|
+
return (_jsx(Badge, { variant: "outline", className: "px-1.5 text-muted-foreground", children: label }));
|
|
253
|
+
}
|
|
254
|
+
// A multi reference may list the same id twice; the occurrence count keeps React keys unique.
|
|
255
|
+
function uniqueLinkItemKeys(items) {
|
|
256
|
+
const seen = new Map();
|
|
257
|
+
return items.map((item) => {
|
|
258
|
+
const occurrence = seen.get(item.id) ?? 0;
|
|
259
|
+
seen.set(item.id, occurrence + 1);
|
|
260
|
+
return `${item.id}#${occurrence}`;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
// A link nested in the clickable first-cell button or card button would be an
|
|
264
|
+
// <a> inside a <button>, so those cells stay plain text.
|
|
265
|
+
export function cellLinkProp(col, row, allowed) {
|
|
266
|
+
if (!allowed)
|
|
267
|
+
return {};
|
|
268
|
+
const link = col.linkFor?.(row.values[col.field], row.values);
|
|
269
|
+
const linkItems = col.linkItemsFor?.(row.values[col.field], row.values);
|
|
270
|
+
return {
|
|
271
|
+
...(link !== undefined && { link }),
|
|
272
|
+
...(linkItems !== undefined && { linkItems }),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
export function buildTableItems(rows, grouping, toggledGroups) {
|
|
276
|
+
if (grouping === undefined)
|
|
277
|
+
return rows.map((row) => ({ kind: "row", row }));
|
|
278
|
+
const groups = new Map();
|
|
279
|
+
for (const row of rows) {
|
|
280
|
+
const key = grouping.keyOf(row);
|
|
281
|
+
const members = groups.get(key);
|
|
282
|
+
if (members === undefined)
|
|
283
|
+
groups.set(key, [row]);
|
|
284
|
+
else
|
|
285
|
+
members.push(row);
|
|
286
|
+
}
|
|
287
|
+
return [...groups].flatMap(([key, members]) => {
|
|
288
|
+
const label = grouping.headerLabel(key, members);
|
|
289
|
+
if (label === undefined)
|
|
290
|
+
return members.map((row) => ({ kind: "row", row }));
|
|
291
|
+
const collapsed = grouping.startsCollapsed(key) !== toggledGroups.has(key);
|
|
292
|
+
return [
|
|
293
|
+
{ kind: "group", key, label, collapsed },
|
|
294
|
+
...(collapsed ? [] : members.map((row) => ({ kind: "row", row }))),
|
|
295
|
+
];
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
const ROW_TONE_CLASS = {
|
|
299
|
+
bad: "bg-status-bad/10 hover:bg-status-bad/15",
|
|
300
|
+
warn: "bg-status-warn/10 hover:bg-status-warn/15",
|
|
301
|
+
ok: "bg-status-ok/10 hover:bg-status-ok/15",
|
|
302
|
+
neutral: "",
|
|
303
|
+
};
|
|
304
|
+
export function rowToneClass(tone) {
|
|
305
|
+
return tone === undefined ? "" : ROW_TONE_CLASS[tone];
|
|
306
|
+
}
|
|
307
|
+
export const NUMERIC_COLUMN_TYPES = new Set([
|
|
308
|
+
"number",
|
|
309
|
+
"decimal",
|
|
310
|
+
"bigInt",
|
|
311
|
+
"money",
|
|
312
|
+
]);
|
|
313
|
+
// Digits stand in columns here, so they need equal-width figures; the body no longer sets tabular-nums.
|
|
314
|
+
export const TABULAR_COLUMN_TYPES = new Set([
|
|
315
|
+
...NUMERIC_COLUMN_TYPES,
|
|
316
|
+
"date",
|
|
317
|
+
"timestamp",
|
|
318
|
+
"locatedTimestamp",
|
|
319
|
+
]);
|
|
320
|
+
// A plain <a>, not DefaultLink: the click must not reach the row's own click
|
|
321
|
+
// handler, which opens the same record's row action.
|
|
322
|
+
function CellTextLink({ link, children, }) {
|
|
323
|
+
return (_jsx("a", { href: isSafeHref(link.href) ? link.href : "#", className: "text-primary underline-offset-4 hover:underline", onClick: (event) => {
|
|
324
|
+
event.stopPropagation();
|
|
325
|
+
if (!isPlainPrimaryClick(event))
|
|
326
|
+
return;
|
|
327
|
+
event.preventDefault();
|
|
328
|
+
link.onPress();
|
|
329
|
+
}, children: children }));
|
|
330
|
+
}
|
|
331
|
+
// Cell renderer as a component (instead of a plain function) so the
|
|
332
|
+
// useColumnRenderer hook can read from the provider. The four paths:
|
|
333
|
+
// 1. FormatSpec (`{ format: "timestamp" }` etc.) -> applyFormatSpec.
|
|
334
|
+
// 2. RuntimeRenderer (function) -> direct call. Only for render-list-
|
|
335
|
+
// internal reference-lookup closures, never from the serialized schema.
|
|
336
|
+
// 3. PlatformComponent (`{ react: { __component: "X" } }`) -> looks up
|
|
337
|
+
// "X" via useColumnRenderer and renders `<X value row column/>`.
|
|
338
|
+
// Not registered -> one-time warning + default fallback.
|
|
339
|
+
// 4. Otherwise -> defaultCellRender (type-based string renderer).
|
|
340
|
+
export function DataTableCell({ value, row, field, type, renderer, optionLabels, optionTones, onChange, translate, locale, grouping = true, fileDisplay, compactFile = false, link, linkItems, }) {
|
|
341
|
+
const componentRef = isComponentRendererRef(renderer);
|
|
342
|
+
const ResolvedComponent = useColumnRenderer(componentRef?.name);
|
|
343
|
+
const RegisteredStatusBadge = useOptionalPrimitives()?.StatusBadge;
|
|
344
|
+
if (isEmptyCellValue(value) && typeof renderer !== "function" && componentRef === undefined) {
|
|
345
|
+
return (_jsx("span", { "data-empty-cell": "true", className: "text-muted-foreground", children: EMPTY_VALUE_PLACEHOLDER }));
|
|
346
|
+
}
|
|
347
|
+
if (value === PII_ERASED_SENTINEL && translate !== undefined) {
|
|
348
|
+
return translate("kumiko.pii.erased");
|
|
349
|
+
}
|
|
350
|
+
if (typeof value === "boolean" &&
|
|
351
|
+
isBooleanColumn(type, renderer) &&
|
|
352
|
+
(renderer === undefined || isUnlabeledBooleanFormat(renderer))) {
|
|
353
|
+
return _jsx(BooleanCell, { value: value, translate: translate });
|
|
354
|
+
}
|
|
355
|
+
if (isUnlabeledFalse(type, value, renderer)) {
|
|
356
|
+
return (_jsx("span", { "data-empty-cell": "true", className: "text-muted-foreground", children: EMPTY_VALUE_PLACEHOLDER }));
|
|
357
|
+
}
|
|
358
|
+
if (fileDisplay !== undefined) {
|
|
359
|
+
return (_jsx(FileDisplayCell, { value: value, display: fileDisplay, compact: compactFile, fallbackLabel: translate?.("kumiko.field.file-upload.attached") ?? "File attached" }));
|
|
360
|
+
}
|
|
361
|
+
if (typeof renderer === "object" && renderer !== null && "format" in renderer) {
|
|
362
|
+
const spec = renderer;
|
|
363
|
+
return applyFormatSpec({ ...spec, locale: spec.locale ?? locale }, value, translate);
|
|
364
|
+
}
|
|
365
|
+
if (typeof renderer === "function") {
|
|
366
|
+
if (linkItems !== undefined) {
|
|
367
|
+
const linkItemKeys = uniqueLinkItemKeys(linkItems);
|
|
368
|
+
return (_jsx(_Fragment, { children: linkItems.map((item, index) => (_jsxs(Fragment, { children: [index > 0 && ", ", item.link === undefined ? (item.label) : (_jsx(CellTextLink, { link: item.link, children: item.label }))] }, linkItemKeys[index]))) }));
|
|
369
|
+
}
|
|
370
|
+
const fn = renderer;
|
|
371
|
+
const text = fn(value, row);
|
|
372
|
+
return link === undefined ? text : _jsx(CellTextLink, { link: link, children: text });
|
|
373
|
+
}
|
|
374
|
+
if (componentRef !== undefined) {
|
|
375
|
+
if (ResolvedComponent !== undefined) {
|
|
376
|
+
const node = (_jsx(ResolvedComponent, { value: value, row: row, column: { field }, ...(onChange !== undefined && { onChange }) }));
|
|
377
|
+
// stopPropagation only on the editable branch — plain cells must still
|
|
378
|
+
// fire row onClick when onCellChange is set for sibling editable cells.
|
|
379
|
+
if (onChange !== undefined) {
|
|
380
|
+
return (_jsx("span", { className: "contents", onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), children: node }));
|
|
381
|
+
}
|
|
382
|
+
return node;
|
|
383
|
+
}
|
|
384
|
+
// Renderer referenced in the schema but no map entry client-side —
|
|
385
|
+
// typical case: clientFeatures.columnRenderers forgotten or a typo in the
|
|
386
|
+
// __component key. Warn instead of crashing so a schema boot still works
|
|
387
|
+
// (the default type renderer takes over).
|
|
388
|
+
rendererWarn(`[kumiko] columnRenderer "${componentRef.name}" not registered`);
|
|
389
|
+
}
|
|
390
|
+
// A select value renders as a pill instead of plain text. When the raw
|
|
391
|
+
// value is a known status word, it carries the same tone as the
|
|
392
|
+
// projectionDetail header badge — a status column stayed grey while the
|
|
393
|
+
// detail view of the same value was coloured (fw#2579). Unknown values
|
|
394
|
+
// keep the neutral outline pill.
|
|
395
|
+
if (type === "select" && value !== null && value !== undefined && value !== "") {
|
|
396
|
+
return selectPill(value, optionLabels, optionTones, locale, RegisteredStatusBadge);
|
|
397
|
+
}
|
|
398
|
+
// One pill per picked value; a single pill would hold the whole joined list.
|
|
399
|
+
if (type === "multiSelect" && Array.isArray(value) && value.length > 0) {
|
|
400
|
+
return (_jsx("span", { className: "flex flex-wrap gap-1", children: value.map((entry) => (_jsx(Fragment, { children: selectPill(entry, optionLabels, optionTones, locale, RegisteredStatusBadge) }, String(entry)))) }));
|
|
401
|
+
}
|
|
402
|
+
return defaultCellRender(value, type, optionLabels, locale, field, grouping);
|
|
403
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type DataTableProps } from "../../index.ts";
|
|
3
|
+
import "../react-css-custom-properties.ts";
|
|
4
|
+
export declare function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange, emptyState, toolbarStart, toolbarDescription, toolbarEnd, pager, onReachEnd, loadingMore, hasMore, itemNoun, rowActions, rowActionMode, rowGrouping, rowTone, filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, testId, onCellChange, getRowTestId, getCellTestId, chromeless, scrollBody, screenPadding, expandedRowIds, onToggleRowExpanded, renderExpandedRow, }: DataTableProps): ReactNode;
|