@raybase/datasheet-grid 0.0.2 → 0.1.2
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/LICENSE +21 -0
- package/LICENSE-react-datasheet-grid +21 -0
- package/README.md +32 -1
- package/dist/adapter.d.ts +34 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +2 -0
- package/dist/adapter.js.map +1 -0
- package/dist/cells.d.ts +111 -0
- package/dist/cells.d.ts.map +1 -0
- package/dist/cells.js +800 -0
- package/dist/cells.js.map +1 -0
- package/dist/colors.d.ts +10 -0
- package/dist/colors.d.ts.map +1 -0
- package/dist/colors.js +42 -0
- package/dist/colors.js.map +1 -0
- package/dist/engine.d.ts +3 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +2 -0
- package/dist/engine.js.map +1 -0
- package/dist/filter-bar.d.ts +33 -0
- package/dist/filter-bar.d.ts.map +1 -0
- package/dist/filter-bar.js +304 -0
- package/dist/filter-bar.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/popover.d.ts +22 -0
- package/dist/popover.d.ts.map +1 -0
- package/dist/popover.js +67 -0
- package/dist/popover.js.map +1 -0
- package/dist/record-panel.d.ts +20 -0
- package/dist/record-panel.d.ts.map +1 -0
- package/dist/record-panel.js +193 -0
- package/dist/record-panel.js.map +1 -0
- package/dist/styles.css +2 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/ui.d.ts +28 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +108 -0
- package/dist/ui.js.map +1 -0
- package/dist/utils.d.ts +16 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +79 -0
- package/dist/utils.js.map +1 -0
- package/dist/view.d.ts +5 -0
- package/dist/view.d.ts.map +1 -0
- package/dist/view.js +85 -0
- package/dist/view.js.map +1 -0
- package/package.json +55 -10
package/dist/cells.js
ADDED
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { createTextColumn, keyColumn } from "./engine.js";
|
|
3
|
+
import { formatDate, formatDateTime, isNullValue, isPrimitiveArray, parseStoredDate, parseTypedDate, toIsoDate } from "./utils.js";
|
|
4
|
+
import { cx, icons, KindGlyph } from "./ui.js";
|
|
5
|
+
import { pillBase, pillColor, referencePill } from "./colors.js";
|
|
6
|
+
import { Popover } from "./popover.js";
|
|
7
|
+
const cellWrap = "flex h-full w-full items-center overflow-hidden px-[10px]";
|
|
8
|
+
// The library's stock text/number columns commit on every keystroke
|
|
9
|
+
// (continuousUpdates), which makes Escape unable to discard edits. These
|
|
10
|
+
// variants only commit on blur/Enter so Escape reverts the cell.
|
|
11
|
+
function nullDisplayText(value) {
|
|
12
|
+
return isNullValue(value) ? "" : String(value);
|
|
13
|
+
}
|
|
14
|
+
export function NullToken() {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
function columnAllowsClear(column) {
|
|
18
|
+
return !column.required && !column.primaryKey && !column.auto;
|
|
19
|
+
}
|
|
20
|
+
function clearValue(rowData, allowClear) {
|
|
21
|
+
return allowClear ? null : rowData;
|
|
22
|
+
}
|
|
23
|
+
function textCellColumnFor(column) {
|
|
24
|
+
return {
|
|
25
|
+
...createTextColumn({
|
|
26
|
+
continuousUpdates: false,
|
|
27
|
+
formatBlurredInput: (value) => nullDisplayText(value),
|
|
28
|
+
parseUserInput: (value) => value,
|
|
29
|
+
parsePastedValue: (value) => {
|
|
30
|
+
const text = value.replace(/[\n\r]+/g, " ").trim();
|
|
31
|
+
return text;
|
|
32
|
+
}
|
|
33
|
+
}),
|
|
34
|
+
deleteValue: () => "",
|
|
35
|
+
isCellEmpty: ({ rowData }) => isEmptyValue(rowData)
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Core's `raybaseSecretRedacted`: what a set `secret()` column reads as, and the write that leaves it alone. */
|
|
39
|
+
export const secretRedactedMarker = "[REDACTED]";
|
|
40
|
+
const secretMask = "••••••••";
|
|
41
|
+
// A secret cell never shows a value: a set secret reads as a mask, editing
|
|
42
|
+
// starts blank, an edit left blank keeps the stored secret, and Delete clears it.
|
|
43
|
+
function secretCellColumnFor() {
|
|
44
|
+
return {
|
|
45
|
+
...createTextColumn({
|
|
46
|
+
continuousUpdates: false,
|
|
47
|
+
placeholder: "Not set",
|
|
48
|
+
formatBlurredInput: (value) => (isEmptyValue(value) ? "" : secretMask),
|
|
49
|
+
formatInputOnFocus: () => "",
|
|
50
|
+
formatForCopy: () => "",
|
|
51
|
+
parseUserInput: (value) => (value === "" ? secretRedactedMarker : value),
|
|
52
|
+
parsePastedValue: (value) => value.replace(/[\n\r]+/g, "").trim() || secretRedactedMarker
|
|
53
|
+
}),
|
|
54
|
+
deleteValue: () => null,
|
|
55
|
+
copyValue: () => "",
|
|
56
|
+
isCellEmpty: ({ rowData }) => isEmptyValue(rowData)
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function intCellColumnFor(column) {
|
|
60
|
+
const allowClear = columnAllowsClear(column);
|
|
61
|
+
return {
|
|
62
|
+
...createTextColumn({
|
|
63
|
+
continuousUpdates: false,
|
|
64
|
+
alignRight: true,
|
|
65
|
+
formatBlurredInput: (value) => (typeof value === "number" ? new Intl.NumberFormat().format(value) : nullDisplayText(value)),
|
|
66
|
+
parseUserInput: (value) => {
|
|
67
|
+
const number = parseFloat(value);
|
|
68
|
+
return Number.isNaN(number) ? null : Math.round(number);
|
|
69
|
+
},
|
|
70
|
+
parsePastedValue: (value) => {
|
|
71
|
+
const number = parseFloat(value);
|
|
72
|
+
return Number.isNaN(number) ? null : Math.round(number);
|
|
73
|
+
}
|
|
74
|
+
}),
|
|
75
|
+
deleteValue: ({ rowData }) => clearValue(rowData, allowClear),
|
|
76
|
+
pasteValue: ({ rowData, value }) => {
|
|
77
|
+
const number = parseFloat(value);
|
|
78
|
+
if (Number.isNaN(number))
|
|
79
|
+
return allowClear ? null : rowData;
|
|
80
|
+
return Math.round(number);
|
|
81
|
+
},
|
|
82
|
+
isCellEmpty: ({ rowData }) => isEmptyValue(rowData)
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function floatCellColumnFor(column) {
|
|
86
|
+
const allowClear = columnAllowsClear(column);
|
|
87
|
+
return {
|
|
88
|
+
...createTextColumn({
|
|
89
|
+
continuousUpdates: false,
|
|
90
|
+
alignRight: true,
|
|
91
|
+
formatBlurredInput: (value) => (typeof value === "number" ? new Intl.NumberFormat().format(value) : nullDisplayText(value)),
|
|
92
|
+
parseUserInput: (value) => {
|
|
93
|
+
const number = parseFloat(value);
|
|
94
|
+
return Number.isNaN(number) ? null : number;
|
|
95
|
+
},
|
|
96
|
+
parsePastedValue: (value) => {
|
|
97
|
+
const number = parseFloat(value);
|
|
98
|
+
return Number.isNaN(number) ? null : number;
|
|
99
|
+
}
|
|
100
|
+
}),
|
|
101
|
+
deleteValue: ({ rowData }) => clearValue(rowData, allowClear),
|
|
102
|
+
pasteValue: ({ rowData, value }) => {
|
|
103
|
+
const number = parseFloat(value);
|
|
104
|
+
if (Number.isNaN(number))
|
|
105
|
+
return allowClear ? null : rowData;
|
|
106
|
+
return number;
|
|
107
|
+
},
|
|
108
|
+
isCellEmpty: ({ rowData }) => isEmptyValue(rowData)
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const popoverSearchInput = "h-8 w-full rounded-md border border-zinc-200 bg-white px-2.5 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-400 focus:outline-none";
|
|
112
|
+
const popoverFooterButton = "inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900";
|
|
113
|
+
/**
|
|
114
|
+
* Detect a plain click (not a drag) so cell selection drags still work.
|
|
115
|
+
* Tracks mouseup/mousemove on the document because the grid re-renders cells
|
|
116
|
+
* mid-click, which can swallow the matching mouseup on the element itself.
|
|
117
|
+
*/
|
|
118
|
+
function useClickToOpen(onOpen, enabled) {
|
|
119
|
+
const startRef = useRef(null);
|
|
120
|
+
const cleanupRef = useRef(null);
|
|
121
|
+
useEffect(() => () => cleanupRef.current?.(), []);
|
|
122
|
+
return {
|
|
123
|
+
onMouseDown: (event) => {
|
|
124
|
+
if (!enabled)
|
|
125
|
+
return;
|
|
126
|
+
startRef.current = { x: event.clientX, y: event.clientY };
|
|
127
|
+
cleanupRef.current?.();
|
|
128
|
+
const onMouseUp = (upEvent) => {
|
|
129
|
+
cleanupRef.current?.();
|
|
130
|
+
if (upEvent.button !== 0)
|
|
131
|
+
return;
|
|
132
|
+
const start = startRef.current;
|
|
133
|
+
startRef.current = null;
|
|
134
|
+
if (!start || Math.abs(upEvent.clientX - start.x) > 4 || Math.abs(upEvent.clientY - start.y) > 4)
|
|
135
|
+
return;
|
|
136
|
+
onOpen();
|
|
137
|
+
};
|
|
138
|
+
const onMouseMove = (moveEvent) => {
|
|
139
|
+
const start = startRef.current;
|
|
140
|
+
if (start && (Math.abs(moveEvent.clientX - start.x) > 4 || Math.abs(moveEvent.clientY - start.y) > 4)) {
|
|
141
|
+
startRef.current = null;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
document.addEventListener("mouseup", onMouseUp, true);
|
|
145
|
+
document.addEventListener("mousemove", onMouseMove, true);
|
|
146
|
+
cleanupRef.current = () => {
|
|
147
|
+
document.removeEventListener("mouseup", onMouseUp, true);
|
|
148
|
+
document.removeEventListener("mousemove", onMouseMove, true);
|
|
149
|
+
cleanupRef.current = null;
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
export function PickerValue({ value, columnData }) {
|
|
155
|
+
if (value === null || value === undefined)
|
|
156
|
+
return React.createElement(NullToken, null);
|
|
157
|
+
if (value === "")
|
|
158
|
+
return null;
|
|
159
|
+
if (columnData.variant === "enum") {
|
|
160
|
+
return React.createElement("span", { className: cx(pillBase, pillColor(value, columnData.enumValues)) },
|
|
161
|
+
React.createElement("span", { className: "truncate" }, value));
|
|
162
|
+
}
|
|
163
|
+
const option = columnData.options.find((candidate) => candidate.id === value);
|
|
164
|
+
if (!option)
|
|
165
|
+
return React.createElement("span", { className: "truncate font-mono text-[12px] text-zinc-400" }, value);
|
|
166
|
+
return React.createElement("span", { className: cx(pillBase, referencePill) },
|
|
167
|
+
React.createElement("span", { className: "truncate" }, option.label));
|
|
168
|
+
}
|
|
169
|
+
export function PickerPopover({ anchor, columnData, value, onPick, onClose }) {
|
|
170
|
+
const [query, setQuery] = useState("");
|
|
171
|
+
const [highlight, setHighlight] = useState(0);
|
|
172
|
+
const listRef = useRef(null);
|
|
173
|
+
const inputRef = useRef(null);
|
|
174
|
+
const normalized = query.trim().toLowerCase();
|
|
175
|
+
const filtered = normalized
|
|
176
|
+
? columnData.options.filter((option) => option.label.toLowerCase().includes(normalized) || option.id.toLowerCase().includes(normalized))
|
|
177
|
+
: columnData.options;
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
inputRef.current?.focus();
|
|
180
|
+
}, []);
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
setHighlight(0);
|
|
183
|
+
}, [normalized]);
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
listRef.current?.querySelector('[data-highlight="true"]')?.scrollIntoView({ block: "nearest" });
|
|
186
|
+
}, [highlight]);
|
|
187
|
+
const pick = (next) => {
|
|
188
|
+
onPick(next);
|
|
189
|
+
onClose();
|
|
190
|
+
};
|
|
191
|
+
return (React.createElement(Popover, { anchor: anchor, onClose: onClose, minWidth: 240, maxWidth: 360, matchAnchorWidth: true },
|
|
192
|
+
React.createElement("div", { className: "border-b border-zinc-100 p-2" },
|
|
193
|
+
React.createElement("input", { ref: inputRef, className: popoverSearchInput, value: query, placeholder: columnData.variant === "reference" ? "Find a record…" : "Find an option…", onChange: (event) => setQuery(event.target.value), onKeyDown: (event) => {
|
|
194
|
+
// Keep grid-level key handling out of the editor (it would re-enter edit mode).
|
|
195
|
+
event.stopPropagation();
|
|
196
|
+
if (event.key === "ArrowDown") {
|
|
197
|
+
event.preventDefault();
|
|
198
|
+
setHighlight((current) => Math.min(current + 1, filtered.length - 1));
|
|
199
|
+
}
|
|
200
|
+
else if (event.key === "ArrowUp") {
|
|
201
|
+
event.preventDefault();
|
|
202
|
+
setHighlight((current) => Math.max(current - 1, 0));
|
|
203
|
+
}
|
|
204
|
+
else if (event.key === "Enter") {
|
|
205
|
+
event.preventDefault();
|
|
206
|
+
if (filtered[highlight])
|
|
207
|
+
pick(filtered[highlight].id);
|
|
208
|
+
}
|
|
209
|
+
} })),
|
|
210
|
+
React.createElement("div", { ref: listRef, className: "max-h-60 overflow-auto py-1", role: "listbox" },
|
|
211
|
+
value && columnData.allowClear ? (React.createElement("button", { type: "button", className: "flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-zinc-400 hover:bg-zinc-50 hover:text-zinc-700", onMouseDown: (event) => {
|
|
212
|
+
event.preventDefault();
|
|
213
|
+
pick(null);
|
|
214
|
+
} },
|
|
215
|
+
icons.closeSmall,
|
|
216
|
+
"Clear value")) : null,
|
|
217
|
+
filtered.map((option, index) => {
|
|
218
|
+
const isSelected = option.id === value;
|
|
219
|
+
const isHighlighted = index === highlight;
|
|
220
|
+
return (React.createElement("button", { type: "button", key: option.id, role: "option", "aria-selected": isSelected, "data-highlight": isHighlighted || undefined, className: cx("flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm", isHighlighted ? "bg-zinc-100" : "hover:bg-zinc-50"), onMouseEnter: () => setHighlight(index), onMouseDown: (event) => {
|
|
221
|
+
event.preventDefault();
|
|
222
|
+
pick(option.id);
|
|
223
|
+
} },
|
|
224
|
+
columnData.variant === "enum" ? (React.createElement("span", { className: cx(pillBase, pillColor(option.label, columnData.enumValues)) }, option.label)) : (React.createElement("span", { className: "min-w-0 flex-1 truncate text-zinc-700" }, option.label)),
|
|
225
|
+
isSelected ? React.createElement("span", { className: "ml-auto shrink-0 text-zinc-500" }, icons.checkmark) : null));
|
|
226
|
+
}),
|
|
227
|
+
!filtered.length ? React.createElement("div", { className: "px-3 py-6 text-center text-sm text-zinc-400" }, "No matches") : null)));
|
|
228
|
+
}
|
|
229
|
+
export function PickerCell({ rowData, setRowData, active, focus, disabled, columnData, stopEditing }) {
|
|
230
|
+
const wrapRef = useRef(null);
|
|
231
|
+
const [clickedOpen, setClickedOpen] = useState(false);
|
|
232
|
+
const open = (focus || clickedOpen) && !disabled;
|
|
233
|
+
// Like text and date cells: the first click only selects the cell; clicking
|
|
234
|
+
// an already-active cell (or Enter / double-click) opens the dropdown.
|
|
235
|
+
const clickHandlers = useClickToOpen(() => setClickedOpen(true), !disabled && !open && active);
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
if (!focus)
|
|
238
|
+
setClickedOpen(false);
|
|
239
|
+
}, [focus]);
|
|
240
|
+
const close = () => {
|
|
241
|
+
setClickedOpen(false);
|
|
242
|
+
if (focus)
|
|
243
|
+
stopEditing({ nextRow: false });
|
|
244
|
+
};
|
|
245
|
+
return (React.createElement("div", { ref: wrapRef, className: cx(cellWrap, active && !disabled && "cursor-pointer"), ...clickHandlers },
|
|
246
|
+
React.createElement(PickerValue, { value: rowData ?? null, columnData: columnData }),
|
|
247
|
+
open ? (React.createElement(PickerPopover, { anchor: wrapRef.current, columnData: columnData, value: rowData ?? null, onPick: (next) => setRowData(next), onClose: close })) : null));
|
|
248
|
+
}
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Boolean
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
const booleanFalsyText = new Set(["", "false", "no", "off", "disabled", "0", "n", "f", "unchecked", "undefined", "null"]);
|
|
253
|
+
export function BooleanCell({ rowData, setRowData, focus, active, disabled, stopEditing }) {
|
|
254
|
+
useLayoutEffect(() => {
|
|
255
|
+
if (!focus || disabled)
|
|
256
|
+
return;
|
|
257
|
+
setRowData(!rowData);
|
|
258
|
+
stopEditing({ nextRow: false });
|
|
259
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
260
|
+
}, [focus, disabled]);
|
|
261
|
+
if (isNullValue(rowData)) {
|
|
262
|
+
return React.createElement("div", { className: cellWrap },
|
|
263
|
+
React.createElement(NullToken, null));
|
|
264
|
+
}
|
|
265
|
+
return (React.createElement("input", { className: "dsg-checkbox", tabIndex: -1, type: "checkbox", disabled: disabled, checked: Boolean(rowData), onMouseDown: () => {
|
|
266
|
+
if (!active)
|
|
267
|
+
setRowData(!rowData);
|
|
268
|
+
}, onChange: () => null }));
|
|
269
|
+
}
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Date / datetime
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
function startOfMonth(date) {
|
|
274
|
+
return new Date(date.getFullYear(), date.getMonth(), 1);
|
|
275
|
+
}
|
|
276
|
+
function sameDay(a, b) {
|
|
277
|
+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
278
|
+
}
|
|
279
|
+
export function Calendar({ selected, onPick }) {
|
|
280
|
+
const today = new Date();
|
|
281
|
+
const [view, setView] = useState(() => startOfMonth(selected ?? today));
|
|
282
|
+
const selectedTime = selected?.getTime();
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
if (selected)
|
|
285
|
+
setView(startOfMonth(selected));
|
|
286
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
287
|
+
}, [selectedTime]);
|
|
288
|
+
const days = [];
|
|
289
|
+
const gridStart = new Date(view.getFullYear(), view.getMonth(), 1 - view.getDay());
|
|
290
|
+
for (let i = 0; i < 42; i++) {
|
|
291
|
+
days.push(new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + i));
|
|
292
|
+
}
|
|
293
|
+
return (React.createElement("div", { className: "px-3 pb-2 pt-1.5" },
|
|
294
|
+
React.createElement("div", { className: "mb-1 flex items-center justify-between" },
|
|
295
|
+
React.createElement("button", { type: "button", className: popoverFooterButton, onMouseDown: (event) => event.preventDefault(), onClick: () => setView(new Date(view.getFullYear(), view.getMonth() - 1, 1)), "aria-label": "Previous month" }, icons.chevronLeft),
|
|
296
|
+
React.createElement("div", { className: "text-[13px] font-semibold text-zinc-900" }, view.toLocaleDateString(undefined, { month: "long", year: "numeric" })),
|
|
297
|
+
React.createElement("button", { type: "button", className: popoverFooterButton, onMouseDown: (event) => event.preventDefault(), onClick: () => setView(new Date(view.getFullYear(), view.getMonth() + 1, 1)), "aria-label": "Next month" }, icons.chevronRight)),
|
|
298
|
+
React.createElement("div", { className: "grid grid-cols-7 text-center" },
|
|
299
|
+
["S", "M", "T", "W", "T", "F", "S"].map((label, index) => (React.createElement("div", { key: index, className: "py-1 text-[10px] font-semibold uppercase text-zinc-400" }, label))),
|
|
300
|
+
days.map((day) => {
|
|
301
|
+
const inMonth = day.getMonth() === view.getMonth();
|
|
302
|
+
const isSelected = selected ? sameDay(day, selected) : false;
|
|
303
|
+
const isToday = sameDay(day, today);
|
|
304
|
+
return (React.createElement("button", { type: "button", key: day.toISOString(), className: cx("mx-auto flex h-7 w-7 items-center justify-center rounded-md text-xs tabular-nums transition-colors", isSelected ? "bg-zinc-950 font-semibold text-white" : "hover:bg-zinc-100", !isSelected && (inMonth ? "text-zinc-700" : "text-zinc-300"), !isSelected && isToday && "font-semibold text-zinc-950 ring-1 ring-inset ring-zinc-300"), onMouseDown: (event) => event.preventDefault(), onClick: () => onPick(day) }, day.getDate()));
|
|
305
|
+
}))));
|
|
306
|
+
}
|
|
307
|
+
function toLocalTimeInput(date) {
|
|
308
|
+
return `${`${date.getHours()}`.padStart(2, "0")}:${`${date.getMinutes()}`.padStart(2, "0")}`;
|
|
309
|
+
}
|
|
310
|
+
export function DatePopover({ anchor, value, withTime, allowClear, onCommit, onClose }) {
|
|
311
|
+
// Edits buffer locally; picking a day commits dates immediately, while
|
|
312
|
+
// datetime edits commit on click-away/Enter and Escape discards them.
|
|
313
|
+
const [draft, setDraft] = useState(value);
|
|
314
|
+
const selected = parseStoredDate(draft);
|
|
315
|
+
const [text, setText] = useState(() => draftInputText(parseStoredDate(value), value, withTime));
|
|
316
|
+
const inputRef = useRef(null);
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
inputRef.current?.focus();
|
|
319
|
+
inputRef.current?.select();
|
|
320
|
+
}, []);
|
|
321
|
+
const commitAndClose = (next) => {
|
|
322
|
+
if (next !== value)
|
|
323
|
+
onCommit(next);
|
|
324
|
+
onClose();
|
|
325
|
+
};
|
|
326
|
+
const updateDraft = (next) => {
|
|
327
|
+
setDraft(next);
|
|
328
|
+
setText(draftInputText(parseStoredDate(next), next, withTime));
|
|
329
|
+
};
|
|
330
|
+
const pickDay = (date) => {
|
|
331
|
+
if (!withTime) {
|
|
332
|
+
commitAndClose(toIsoDate(date));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const next = new Date(date.getFullYear(), date.getMonth(), date.getDate(), selected?.getHours() ?? 12, selected?.getMinutes() ?? 0);
|
|
336
|
+
updateDraft(next.toISOString());
|
|
337
|
+
};
|
|
338
|
+
const pickTime = (time) => {
|
|
339
|
+
const [hours, minutes] = time.split(":").map(Number);
|
|
340
|
+
if (Number.isNaN(hours))
|
|
341
|
+
return;
|
|
342
|
+
const base = selected ?? new Date();
|
|
343
|
+
updateDraft(new Date(base.getFullYear(), base.getMonth(), base.getDate(), hours, minutes || 0).toISOString());
|
|
344
|
+
};
|
|
345
|
+
const submitText = () => {
|
|
346
|
+
const trimmed = text.trim();
|
|
347
|
+
if (!trimmed) {
|
|
348
|
+
if (!allowClear)
|
|
349
|
+
return;
|
|
350
|
+
commitAndClose(null);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const parsed = parseTypedDate(trimmed);
|
|
354
|
+
if (!parsed)
|
|
355
|
+
return;
|
|
356
|
+
commitAndClose(withTime ? parsed.toISOString() : toIsoDate(parsed));
|
|
357
|
+
};
|
|
358
|
+
return (React.createElement(Popover, { anchor: anchor, onClose: (reason) => (reason === "escape" ? onClose() : commitAndClose(draft)), minWidth: 264, maxWidth: 300 },
|
|
359
|
+
React.createElement("div", { className: "flex items-center gap-1.5 border-b border-zinc-100 p-2" },
|
|
360
|
+
React.createElement("input", { ref: inputRef, className: popoverSearchInput, value: text, placeholder: withTime ? "YYYY-MM-DD HH:MM" : "YYYY-MM-DD", onChange: (event) => setText(event.target.value), onKeyDown: (event) => {
|
|
361
|
+
event.stopPropagation();
|
|
362
|
+
if (event.key !== "Enter")
|
|
363
|
+
return;
|
|
364
|
+
event.preventDefault();
|
|
365
|
+
submitText();
|
|
366
|
+
} }),
|
|
367
|
+
withTime ? (React.createElement("input", { type: "time", className: "h-8 shrink-0 rounded-md border border-zinc-200 bg-white px-1.5 text-sm text-zinc-900 focus:border-zinc-400 focus:outline-none", value: selected ? toLocalTimeInput(selected) : "", onChange: (event) => pickTime(event.target.value), onKeyDown: (event) => event.stopPropagation() })) : null),
|
|
368
|
+
React.createElement(Calendar, { selected: selected, onPick: pickDay }),
|
|
369
|
+
React.createElement("div", { className: "flex items-center justify-between border-t border-zinc-100 px-2 py-1.5" },
|
|
370
|
+
React.createElement("button", { type: "button", className: popoverFooterButton, onMouseDown: (event) => event.preventDefault(), onClick: () => (withTime ? updateDraft(new Date().toISOString()) : commitAndClose(toIsoDate(new Date()))) },
|
|
371
|
+
icons.calendar,
|
|
372
|
+
"Today"),
|
|
373
|
+
draft && allowClear ? (React.createElement("button", { type: "button", className: popoverFooterButton, onMouseDown: (event) => event.preventDefault(), onClick: () => commitAndClose(null) },
|
|
374
|
+
icons.closeSmall,
|
|
375
|
+
"Clear")) : null)));
|
|
376
|
+
}
|
|
377
|
+
function draftInputText(parsed, raw, withTime) {
|
|
378
|
+
if (withTime)
|
|
379
|
+
return parsed ? `${toIsoDate(parsed)} ${toLocalTimeInput(parsed)}` : "";
|
|
380
|
+
return parsed ? toIsoDate(parsed) : raw ?? "";
|
|
381
|
+
}
|
|
382
|
+
export function DateCell({ rowData, setRowData, focus, disabled, columnData, stopEditing }) {
|
|
383
|
+
const wrapRef = useRef(null);
|
|
384
|
+
const display = columnData.withTime ? formatDateTime(rowData) : formatDate(rowData);
|
|
385
|
+
return (React.createElement("div", { ref: wrapRef, className: cellWrap },
|
|
386
|
+
isNullValue(rowData) ? React.createElement(NullToken, null) : React.createElement("span", { className: cx("truncate text-sm", disabled ? "text-zinc-500" : "text-zinc-900") }, display),
|
|
387
|
+
focus && !disabled ? (React.createElement(DatePopover, { anchor: wrapRef.current, value: typeof rowData === "string" ? rowData : null, withTime: columnData.withTime, allowClear: columnData.allowClear, onCommit: (next) => setRowData(next), onClose: () => stopEditing({ nextRow: false }) })) : null));
|
|
388
|
+
}
|
|
389
|
+
export function LongTextCell({ rowData, setRowData, focus, disabled, stopEditing }) {
|
|
390
|
+
const wrapRef = useRef(null);
|
|
391
|
+
const textareaRef = useRef(null);
|
|
392
|
+
const [draft, setDraft] = useState(null);
|
|
393
|
+
useLayoutEffect(() => {
|
|
394
|
+
if (focus && !disabled) {
|
|
395
|
+
setDraft(typeof rowData === "string" ? rowData : "");
|
|
396
|
+
}
|
|
397
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
398
|
+
}, [focus, disabled]);
|
|
399
|
+
useEffect(() => {
|
|
400
|
+
if (draft === null)
|
|
401
|
+
return;
|
|
402
|
+
const textarea = textareaRef.current;
|
|
403
|
+
if (textarea) {
|
|
404
|
+
textarea.focus();
|
|
405
|
+
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
406
|
+
}
|
|
407
|
+
}, [draft !== null]);
|
|
408
|
+
const commitAndClose = () => {
|
|
409
|
+
if (draft !== null && draft !== (rowData ?? ""))
|
|
410
|
+
setRowData(draft);
|
|
411
|
+
setDraft(null);
|
|
412
|
+
stopEditing({ nextRow: false });
|
|
413
|
+
};
|
|
414
|
+
const discardAndClose = () => {
|
|
415
|
+
setDraft(null);
|
|
416
|
+
stopEditing({ nextRow: false });
|
|
417
|
+
};
|
|
418
|
+
return (React.createElement("div", { ref: wrapRef, className: cellWrap },
|
|
419
|
+
isNullValue(rowData) ? React.createElement(NullToken, null) : React.createElement("span", { className: "truncate text-sm text-zinc-900" }, typeof rowData === "string" ? rowData.replace(/\n/g, " ") : ""),
|
|
420
|
+
focus && !disabled && draft !== null ? (React.createElement(Popover, { anchor: wrapRef.current, onClose: (reason) => (reason === "escape" ? discardAndClose() : commitAndClose()), minWidth: 320, maxWidth: 520, matchAnchorWidth: true },
|
|
421
|
+
React.createElement("textarea", { ref: textareaRef, className: "block max-h-[320px] min-h-[140px] w-full resize-y rounded-t-lg border-0 bg-transparent p-3 text-sm leading-relaxed text-zinc-900 placeholder:text-zinc-400 focus:outline-none", value: draft, placeholder: "Write something\u2026", onChange: (event) => setDraft(event.target.value), onKeyDown: (event) => {
|
|
422
|
+
event.stopPropagation();
|
|
423
|
+
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
|
424
|
+
event.preventDefault();
|
|
425
|
+
commitAndClose();
|
|
426
|
+
}
|
|
427
|
+
} }),
|
|
428
|
+
React.createElement("div", { className: "rounded-b-lg border-t border-zinc-100 bg-zinc-50/60 px-3 py-1.5 text-[11px] text-zinc-400" }, "Click away to save \u00B7 Esc to cancel"))) : null));
|
|
429
|
+
}
|
|
430
|
+
function asTags(value) {
|
|
431
|
+
return isPrimitiveArray(value) ? value.map(String) : [];
|
|
432
|
+
}
|
|
433
|
+
export function TagPills({ tags }) {
|
|
434
|
+
return (React.createElement(React.Fragment, null, tags.map((tag, index) => (React.createElement("span", { key: `${tag}-${index}`, className: cx(pillBase, "shrink-0", pillColor(tag)) },
|
|
435
|
+
React.createElement("span", { className: "max-w-[160px] truncate" }, tag))))));
|
|
436
|
+
}
|
|
437
|
+
export function TagsEditorPopover({ anchor, tags, suggestions, onCommit, onCancel }) {
|
|
438
|
+
// Edits are buffered locally so Escape can discard the whole session.
|
|
439
|
+
const [draft, setDraftTags] = useState(tags);
|
|
440
|
+
const [query, setQuery] = useState("");
|
|
441
|
+
const [highlight, setHighlight] = useState(0);
|
|
442
|
+
const inputRef = useRef(null);
|
|
443
|
+
const listRef = useRef(null);
|
|
444
|
+
const normalized = query.trim().toLowerCase();
|
|
445
|
+
const available = suggestions.filter((tag) => !draft.includes(tag) && (!normalized || tag.toLowerCase().includes(normalized)));
|
|
446
|
+
const exactExists = suggestions.some((tag) => tag.toLowerCase() === normalized) || draft.some((tag) => tag.toLowerCase() === normalized);
|
|
447
|
+
const canCreate = Boolean(normalized) && !exactExists;
|
|
448
|
+
const optionCount = available.length + (canCreate ? 1 : 0);
|
|
449
|
+
useEffect(() => {
|
|
450
|
+
inputRef.current?.focus();
|
|
451
|
+
}, []);
|
|
452
|
+
useEffect(() => {
|
|
453
|
+
setHighlight(0);
|
|
454
|
+
}, [normalized]);
|
|
455
|
+
useEffect(() => {
|
|
456
|
+
listRef.current?.querySelector('[data-highlight="true"]')?.scrollIntoView({ block: "nearest" });
|
|
457
|
+
}, [highlight]);
|
|
458
|
+
const addTag = (tag) => {
|
|
459
|
+
const trimmed = tag.trim();
|
|
460
|
+
if (!trimmed || draft.includes(trimmed))
|
|
461
|
+
return;
|
|
462
|
+
setDraftTags((current) => [...current, trimmed]);
|
|
463
|
+
setQuery("");
|
|
464
|
+
};
|
|
465
|
+
const removeTag = (index) => {
|
|
466
|
+
setDraftTags((current) => current.filter((_, i) => i !== index));
|
|
467
|
+
};
|
|
468
|
+
const confirm = () => onCommit(draft);
|
|
469
|
+
return (React.createElement(Popover, { anchor: anchor, onClose: (reason) => (reason === "escape" ? onCancel() : confirm()), minWidth: 280, maxWidth: 380, matchAnchorWidth: true },
|
|
470
|
+
React.createElement("div", { className: "flex flex-wrap items-center gap-1 border-b border-zinc-100 p-2" },
|
|
471
|
+
draft.map((tag, index) => (React.createElement("span", { key: `${tag}-${index}`, className: cx(pillBase, pillColor(tag), "gap-1 pr-1") },
|
|
472
|
+
React.createElement("span", { className: "max-w-[140px] truncate" }, tag),
|
|
473
|
+
React.createElement("button", { type: "button", className: "flex h-4 w-4 items-center justify-center rounded-full text-current opacity-50 transition-opacity hover:opacity-100", "aria-label": `Remove ${tag}`, onMouseDown: (event) => {
|
|
474
|
+
event.preventDefault();
|
|
475
|
+
removeTag(index);
|
|
476
|
+
} }, icons.closeSmall)))),
|
|
477
|
+
React.createElement("input", { ref: inputRef, className: "h-7 min-w-[90px] flex-1 bg-transparent px-1 text-sm text-zinc-900 placeholder:text-zinc-400 focus:outline-none", value: query, placeholder: draft.length ? "Add another…" : "Add a tag…", onChange: (event) => setQuery(event.target.value), onKeyDown: (event) => {
|
|
478
|
+
event.stopPropagation();
|
|
479
|
+
if (event.key === "ArrowDown") {
|
|
480
|
+
event.preventDefault();
|
|
481
|
+
setHighlight((current) => Math.min(current + 1, optionCount - 1));
|
|
482
|
+
}
|
|
483
|
+
else if (event.key === "ArrowUp") {
|
|
484
|
+
event.preventDefault();
|
|
485
|
+
setHighlight((current) => Math.max(current - 1, 0));
|
|
486
|
+
}
|
|
487
|
+
else if (event.key === "Enter") {
|
|
488
|
+
event.preventDefault();
|
|
489
|
+
if (highlight < available.length && available[highlight])
|
|
490
|
+
addTag(available[highlight]);
|
|
491
|
+
else if (canCreate)
|
|
492
|
+
addTag(query);
|
|
493
|
+
else if (!normalized)
|
|
494
|
+
confirm();
|
|
495
|
+
}
|
|
496
|
+
else if (event.key === "Backspace" && !query && draft.length) {
|
|
497
|
+
removeTag(draft.length - 1);
|
|
498
|
+
}
|
|
499
|
+
} })),
|
|
500
|
+
React.createElement("div", { ref: listRef, className: "max-h-48 overflow-auto py-1" },
|
|
501
|
+
available.map((tag, index) => (React.createElement("button", { type: "button", key: tag, "data-highlight": index === highlight || undefined, className: cx("flex w-full items-center px-3 py-1.5 text-left text-sm", index === highlight ? "bg-zinc-100" : "hover:bg-zinc-50"), onMouseEnter: () => setHighlight(index), onMouseDown: (event) => {
|
|
502
|
+
event.preventDefault();
|
|
503
|
+
addTag(tag);
|
|
504
|
+
} },
|
|
505
|
+
React.createElement("span", { className: cx(pillBase, pillColor(tag)) }, tag)))),
|
|
506
|
+
canCreate ? (React.createElement("button", { type: "button", "data-highlight": highlight === available.length || undefined, className: cx("flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-zinc-600", highlight === available.length ? "bg-zinc-100" : "hover:bg-zinc-50"), onMouseEnter: () => setHighlight(available.length), onMouseDown: (event) => {
|
|
507
|
+
event.preventDefault();
|
|
508
|
+
addTag(query);
|
|
509
|
+
} },
|
|
510
|
+
icons.plus,
|
|
511
|
+
"Add ",
|
|
512
|
+
React.createElement("span", { className: cx(pillBase, pillColor(query.trim())) }, query.trim()))) : null,
|
|
513
|
+
!available.length && !canCreate ? (React.createElement("div", { className: "px-3 py-4 text-center text-sm text-zinc-400" }, suggestions.length ? "No matches" : "Type to add tags")) : null)));
|
|
514
|
+
}
|
|
515
|
+
export function TagsCell({ rowData, setRowData, focus, disabled, columnData, stopEditing }) {
|
|
516
|
+
const wrapRef = useRef(null);
|
|
517
|
+
const tags = asTags(rowData);
|
|
518
|
+
return (React.createElement("div", { ref: wrapRef, className: cx(cellWrap, "gap-1") },
|
|
519
|
+
isNullValue(rowData) ? React.createElement(NullToken, null) : React.createElement(TagPills, { tags: tags }),
|
|
520
|
+
focus && !disabled ? (React.createElement(TagsEditorPopover, { anchor: wrapRef.current, tags: tags, suggestions: columnData.suggestions, onCommit: (next) => {
|
|
521
|
+
setRowData(next);
|
|
522
|
+
stopEditing({ nextRow: false });
|
|
523
|
+
}, onCancel: () => stopEditing({ nextRow: false }) })) : null));
|
|
524
|
+
}
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
// JSON (objects and anything that isn't a primitive array)
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
export function JsonEditorPopover({ anchor, value, allowClear, onCommit, onClose }) {
|
|
529
|
+
const [draft, setDraft] = useState(() => (value === null || value === undefined ? "" : JSON.stringify(value, null, 2)));
|
|
530
|
+
const textareaRef = useRef(null);
|
|
531
|
+
useEffect(() => {
|
|
532
|
+
const textarea = textareaRef.current;
|
|
533
|
+
if (textarea) {
|
|
534
|
+
textarea.focus();
|
|
535
|
+
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
536
|
+
}
|
|
537
|
+
}, []);
|
|
538
|
+
let parsed = null;
|
|
539
|
+
let parseError = "";
|
|
540
|
+
const trimmed = draft.trim();
|
|
541
|
+
if (trimmed) {
|
|
542
|
+
try {
|
|
543
|
+
parsed = JSON.parse(trimmed);
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
parseError = error instanceof Error ? error.message.replace(/^JSON\.parse: /, "") : "Invalid JSON";
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const valid = !parseError && (allowClear || Boolean(trimmed));
|
|
550
|
+
const save = () => {
|
|
551
|
+
if (!valid)
|
|
552
|
+
return;
|
|
553
|
+
onCommit(trimmed ? parsed : null);
|
|
554
|
+
onClose();
|
|
555
|
+
};
|
|
556
|
+
return (React.createElement(Popover, { anchor: anchor, onClose: onClose, minWidth: 340, maxWidth: 520, matchAnchorWidth: true },
|
|
557
|
+
React.createElement("textarea", { ref: textareaRef, className: "block max-h-[320px] min-h-[160px] w-full resize-y rounded-t-lg border-0 bg-transparent p-3 font-mono text-[12px] leading-relaxed text-zinc-900 placeholder:text-zinc-400 focus:outline-none", value: draft, placeholder: "null", spellCheck: false, onChange: (event) => setDraft(event.target.value), onKeyDown: (event) => {
|
|
558
|
+
event.stopPropagation();
|
|
559
|
+
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
|
560
|
+
event.preventDefault();
|
|
561
|
+
save();
|
|
562
|
+
}
|
|
563
|
+
} }),
|
|
564
|
+
React.createElement("div", { className: "flex items-center justify-between gap-2 rounded-b-lg border-t border-zinc-100 bg-zinc-50/60 px-2 py-1.5" },
|
|
565
|
+
React.createElement("span", { className: cx("truncate px-1 text-[11px]", valid ? "text-zinc-400" : "text-red-600") }, valid ? "JSON" : parseError || "Required"),
|
|
566
|
+
React.createElement("div", { className: "flex shrink-0 items-center gap-1" },
|
|
567
|
+
React.createElement("button", { type: "button", className: popoverFooterButton, onMouseDown: (event) => event.preventDefault(), onClick: onClose }, "Cancel"),
|
|
568
|
+
React.createElement("button", { type: "button", className: "inline-flex h-7 items-center rounded-md bg-zinc-950 px-2.5 text-xs font-medium text-white transition-colors hover:bg-zinc-800 disabled:opacity-40", disabled: !valid, onMouseDown: (event) => event.preventDefault(), onClick: save }, "Save")))));
|
|
569
|
+
}
|
|
570
|
+
export function JsonCell({ rowData, setRowData, focus, disabled, columnData, stopEditing }) {
|
|
571
|
+
const wrapRef = useRef(null);
|
|
572
|
+
const display = rowData === null || rowData === undefined || rowData === "" ? "" : JSON.stringify(rowData);
|
|
573
|
+
return (React.createElement("div", { ref: wrapRef, className: cellWrap },
|
|
574
|
+
isNullValue(rowData) ? React.createElement(NullToken, null) : React.createElement("span", { className: "truncate font-mono text-[12px] text-zinc-600" }, display),
|
|
575
|
+
focus && !disabled ? (React.createElement(JsonEditorPopover, { anchor: wrapRef.current, value: rowData, allowClear: columnData.allowClear, onCommit: (next) => setRowData(next), onClose: () => stopEditing({ nextRow: false }) })) : null));
|
|
576
|
+
}
|
|
577
|
+
export function HeaderTitle({ columnKey, column, sort }) {
|
|
578
|
+
const isSorted = sort?.activeKey === columnKey;
|
|
579
|
+
return (React.createElement("span", { className: cx("flex min-w-0 items-center gap-1.5", sort && "cursor-context-menu"), title: "Right-click for column menu" },
|
|
580
|
+
React.createElement("span", { className: "shrink-0 text-zinc-400" },
|
|
581
|
+
React.createElement(KindGlyph, { kind: column.kind, hasEnum: Boolean(column.enumValues?.length) })),
|
|
582
|
+
React.createElement("span", { className: "truncate" }, column.label),
|
|
583
|
+
column.required && !column.primaryKey && !column.auto ? React.createElement("span", { className: "text-zinc-300" }, "*") : null,
|
|
584
|
+
isSorted ? React.createElement("span", { className: "text-[10px] text-zinc-500" }, sort.direction === "asc" ? "↑" : "↓") : null));
|
|
585
|
+
}
|
|
586
|
+
const minResizeWidth = 70;
|
|
587
|
+
function HeaderResizeHandle({ columnKey, handlers }) {
|
|
588
|
+
return (React.createElement("span", { className: "rb-col-resize", title: "Drag to resize \u00B7 double-click to reset", onMouseDown: (event) => {
|
|
589
|
+
if (event.button !== 0)
|
|
590
|
+
return;
|
|
591
|
+
event.preventDefault();
|
|
592
|
+
event.stopPropagation();
|
|
593
|
+
const headerCell = event.target.closest(".dsg-cell");
|
|
594
|
+
const startWidth = headerCell?.getBoundingClientRect().width ?? 150;
|
|
595
|
+
const startX = event.clientX;
|
|
596
|
+
document.body.classList.add("rb-col-resizing");
|
|
597
|
+
const onMove = (moveEvent) => {
|
|
598
|
+
handlers.onResize(columnKey, Math.max(minResizeWidth, Math.round(startWidth + moveEvent.clientX - startX)));
|
|
599
|
+
};
|
|
600
|
+
const onUp = () => {
|
|
601
|
+
document.removeEventListener("mousemove", onMove);
|
|
602
|
+
document.removeEventListener("mouseup", onUp);
|
|
603
|
+
document.body.classList.remove("rb-col-resizing");
|
|
604
|
+
handlers.onResizeEnd();
|
|
605
|
+
};
|
|
606
|
+
document.addEventListener("mousemove", onMove);
|
|
607
|
+
document.addEventListener("mouseup", onUp);
|
|
608
|
+
}, onDoubleClick: (event) => {
|
|
609
|
+
event.stopPropagation();
|
|
610
|
+
handlers.onResizeReset(columnKey);
|
|
611
|
+
} }));
|
|
612
|
+
}
|
|
613
|
+
function isEmptyValue(value) {
|
|
614
|
+
return value === null || value === undefined || value === "" || (Array.isArray(value) && !value.length);
|
|
615
|
+
}
|
|
616
|
+
// The grid freezes grow:0 columns at their basis, so basis carries the
|
|
617
|
+
// preferred pixel width; minWidth only guards shrinking columns.
|
|
618
|
+
const layoutByKind = {
|
|
619
|
+
id: { basis: 200, minWidth: 140, grow: 0 },
|
|
620
|
+
text: { basis: 160, minWidth: 120, grow: 1 },
|
|
621
|
+
longText: { basis: 240, minWidth: 160, grow: 2 },
|
|
622
|
+
integer: { basis: 100, minWidth: 80, grow: 0 },
|
|
623
|
+
real: { basis: 110, minWidth: 80, grow: 0 },
|
|
624
|
+
boolean: { basis: 100, minWidth: 80, grow: 0 },
|
|
625
|
+
date: { basis: 140, minWidth: 120, grow: 0 },
|
|
626
|
+
datetime: { basis: 190, minWidth: 150, grow: 0 },
|
|
627
|
+
json: { basis: 220, minWidth: 160, grow: 1 },
|
|
628
|
+
ref: { basis: 200, minWidth: 150, grow: 1 }
|
|
629
|
+
};
|
|
630
|
+
const enumLayout = { basis: 160, minWidth: 120, grow: 0 };
|
|
631
|
+
function baseColumnFor(key, column, input) {
|
|
632
|
+
const allowClear = columnAllowsClear(column);
|
|
633
|
+
if (column.enumValues?.length) {
|
|
634
|
+
const enumValues = column.enumValues;
|
|
635
|
+
return {
|
|
636
|
+
component: PickerCell,
|
|
637
|
+
columnData: { variant: "enum", options: enumValues.map((value) => ({ id: value, label: value })), allowClear, enumValues },
|
|
638
|
+
disableKeys: true,
|
|
639
|
+
keepFocus: true,
|
|
640
|
+
deleteValue: ({ rowData }) => clearValue(rowData, allowClear),
|
|
641
|
+
copyValue: ({ rowData }) => rowData ?? "",
|
|
642
|
+
pasteValue: ({ rowData, value }) => {
|
|
643
|
+
const text = value.trim();
|
|
644
|
+
if (!text)
|
|
645
|
+
return clearValue(rowData, allowClear);
|
|
646
|
+
return enumValues.find((candidate) => candidate.toLowerCase() === text.toLowerCase()) ?? rowData ?? null;
|
|
647
|
+
},
|
|
648
|
+
isCellEmpty: ({ rowData }) => isEmptyValue(rowData)
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
if (column.references) {
|
|
652
|
+
const options = input.references[key] ?? [];
|
|
653
|
+
return {
|
|
654
|
+
component: PickerCell,
|
|
655
|
+
columnData: { variant: "reference", options, allowClear },
|
|
656
|
+
disableKeys: true,
|
|
657
|
+
keepFocus: true,
|
|
658
|
+
deleteValue: ({ rowData }) => clearValue(rowData, allowClear),
|
|
659
|
+
copyValue: ({ rowData }) => rowData ?? "",
|
|
660
|
+
pasteValue: ({ rowData, value }) => {
|
|
661
|
+
const text = value.trim();
|
|
662
|
+
if (!text)
|
|
663
|
+
return clearValue(rowData, allowClear);
|
|
664
|
+
const byId = options.find((option) => option.id === text);
|
|
665
|
+
if (byId)
|
|
666
|
+
return byId.id;
|
|
667
|
+
const byLabel = options.find((option) => option.label.toLowerCase() === text.toLowerCase());
|
|
668
|
+
if (byLabel)
|
|
669
|
+
return byLabel.id;
|
|
670
|
+
return text;
|
|
671
|
+
},
|
|
672
|
+
isCellEmpty: ({ rowData }) => isEmptyValue(rowData)
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
switch (column.kind) {
|
|
676
|
+
case "boolean":
|
|
677
|
+
return {
|
|
678
|
+
component: BooleanCell,
|
|
679
|
+
columnData: { allowClear },
|
|
680
|
+
disableKeys: true,
|
|
681
|
+
keepFocus: true,
|
|
682
|
+
deleteValue: ({ rowData }) => clearValue(rowData, allowClear),
|
|
683
|
+
copyValue: ({ rowData }) => (isNullValue(rowData) ? "" : rowData ? "YES" : "NO"),
|
|
684
|
+
pasteValue: ({ rowData, value }) => {
|
|
685
|
+
const text = value.trim().toLowerCase();
|
|
686
|
+
if (!text)
|
|
687
|
+
return clearValue(rowData, allowClear);
|
|
688
|
+
return !booleanFalsyText.has(text);
|
|
689
|
+
},
|
|
690
|
+
isCellEmpty: ({ rowData }) => isEmptyValue(rowData)
|
|
691
|
+
};
|
|
692
|
+
case "integer":
|
|
693
|
+
return intCellColumnFor(column);
|
|
694
|
+
case "real":
|
|
695
|
+
return floatCellColumnFor(column);
|
|
696
|
+
case "date":
|
|
697
|
+
case "datetime":
|
|
698
|
+
return {
|
|
699
|
+
component: DateCell,
|
|
700
|
+
columnData: { withTime: column.kind === "datetime", allowClear },
|
|
701
|
+
disableKeys: true,
|
|
702
|
+
keepFocus: true,
|
|
703
|
+
deleteValue: ({ rowData }) => clearValue(rowData, allowClear),
|
|
704
|
+
copyValue: ({ rowData }) => rowData ?? "",
|
|
705
|
+
pasteValue: ({ rowData, value }) => {
|
|
706
|
+
const text = value.trim();
|
|
707
|
+
if (!text)
|
|
708
|
+
return clearValue(rowData, allowClear);
|
|
709
|
+
const parsed = parseTypedDate(text);
|
|
710
|
+
if (!parsed)
|
|
711
|
+
return rowData ?? null;
|
|
712
|
+
return column.kind === "datetime" ? parsed.toISOString() : toIsoDate(parsed);
|
|
713
|
+
},
|
|
714
|
+
isCellEmpty: ({ rowData }) => isEmptyValue(rowData)
|
|
715
|
+
};
|
|
716
|
+
case "secret":
|
|
717
|
+
return secretCellColumnFor();
|
|
718
|
+
case "longText":
|
|
719
|
+
return {
|
|
720
|
+
component: LongTextCell,
|
|
721
|
+
columnData: { allowClear },
|
|
722
|
+
disableKeys: true,
|
|
723
|
+
keepFocus: true,
|
|
724
|
+
deleteValue: () => "",
|
|
725
|
+
copyValue: ({ rowData }) => rowData ?? "",
|
|
726
|
+
pasteValue: ({ value }) => value,
|
|
727
|
+
isCellEmpty: ({ rowData }) => isEmptyValue(rowData)
|
|
728
|
+
};
|
|
729
|
+
case "json": {
|
|
730
|
+
if (input.jsonModes[key] === "tags") {
|
|
731
|
+
return {
|
|
732
|
+
component: TagsCell,
|
|
733
|
+
columnData: { suggestions: input.tagSuggestions[key] ?? [], allowClear },
|
|
734
|
+
disableKeys: true,
|
|
735
|
+
keepFocus: true,
|
|
736
|
+
deleteValue: () => [],
|
|
737
|
+
copyValue: ({ rowData }) => (isPrimitiveArray(rowData) ? rowData.join(", ") : ""),
|
|
738
|
+
pasteValue: ({ rowData, value }) => {
|
|
739
|
+
const text = value.trim();
|
|
740
|
+
if (!text)
|
|
741
|
+
return [];
|
|
742
|
+
try {
|
|
743
|
+
const parsed = JSON.parse(text);
|
|
744
|
+
if (Array.isArray(parsed))
|
|
745
|
+
return parsed;
|
|
746
|
+
}
|
|
747
|
+
catch {
|
|
748
|
+
// fall through to comma parsing
|
|
749
|
+
}
|
|
750
|
+
return text.split(",").map((part) => part.trim()).filter(Boolean);
|
|
751
|
+
},
|
|
752
|
+
isCellEmpty: ({ rowData }) => isEmptyValue(rowData)
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
return {
|
|
756
|
+
component: JsonCell,
|
|
757
|
+
columnData: { allowClear },
|
|
758
|
+
disableKeys: true,
|
|
759
|
+
keepFocus: true,
|
|
760
|
+
deleteValue: ({ rowData }) => clearValue(rowData, allowClear),
|
|
761
|
+
copyValue: ({ rowData }) => (rowData === null || rowData === undefined ? "" : JSON.stringify(rowData)),
|
|
762
|
+
pasteValue: ({ rowData, value }) => {
|
|
763
|
+
const text = value.trim();
|
|
764
|
+
if (!text)
|
|
765
|
+
return clearValue(rowData, allowClear);
|
|
766
|
+
try {
|
|
767
|
+
return JSON.parse(text);
|
|
768
|
+
}
|
|
769
|
+
catch {
|
|
770
|
+
return rowData ?? null;
|
|
771
|
+
}
|
|
772
|
+
},
|
|
773
|
+
isCellEmpty: ({ rowData }) => isEmptyValue(rowData)
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
default:
|
|
777
|
+
return textCellColumnFor(column);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
export function buildGridColumns(input) {
|
|
781
|
+
return Object.entries(input.table.columns).map(([key, column]) => {
|
|
782
|
+
const layout = column.enumValues?.length ? enumLayout : layoutByKind[column.kind] ?? { basis: 160, minWidth: 120, grow: 1 };
|
|
783
|
+
const userWidth = input.columnWidths?.[key];
|
|
784
|
+
const baseCellClassName = column.kind === "id" || column.primaryKey ? "rb-cell-id" : column.kind === "secret" ? "rb-cell-secret" : undefined;
|
|
785
|
+
return {
|
|
786
|
+
...keyColumn(key, baseColumnFor(key, column, input)),
|
|
787
|
+
title: (React.createElement(React.Fragment, null,
|
|
788
|
+
React.createElement(HeaderTitle, { columnKey: key, column: column, sort: input.sort }),
|
|
789
|
+
input.resize ? React.createElement(HeaderResizeHandle, { columnKey: key, handlers: input.resize }) : null)),
|
|
790
|
+
disabled: input.readOnly || column.primaryKey || Boolean(column.auto),
|
|
791
|
+
// A user-resized column keeps its exact width; others use kind defaults.
|
|
792
|
+
basis: userWidth ?? layout.basis,
|
|
793
|
+
minWidth: userWidth ?? layout.minWidth,
|
|
794
|
+
grow: userWidth === undefined ? layout.grow : 0,
|
|
795
|
+
shrink: userWidth === undefined ? 1 : 0,
|
|
796
|
+
cellClassName: ({ rowData, rowIndex }) => cx(baseCellClassName, isNullValue(rowData[key]) && "rb-cell-null", input.dirtyCellKeys?.get(rowIndex)?.has(key) && "rb-cell-dirty")
|
|
797
|
+
};
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
//# sourceMappingURL=cells.js.map
|