@jxsuite/studio 1.0.0 → 1.1.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/iframe-entry.js +622 -49
- package/dist/iframe-entry.js.map +15 -12
- package/dist/studio.css +1333 -0
- package/dist/studio.js +53069 -31746
- package/dist/studio.js.map +109 -74
- package/package.json +11 -9
- package/src/canvas/canvas-live-render.ts +21 -2
- package/src/canvas/canvas-render.ts +35 -16
- package/src/canvas/canvas-utils.ts +118 -72
- package/src/canvas/iframe-entry.ts +20 -0
- package/src/canvas/iframe-eval.ts +155 -0
- package/src/canvas/iframe-host.ts +131 -5
- package/src/canvas/iframe-inline-edit.ts +107 -1
- package/src/canvas/iframe-protocol.ts +43 -3
- package/src/canvas/iframe-render.ts +18 -0
- package/src/collab/collab-session.ts +23 -8
- package/src/collab/collab-state.ts +6 -3
- package/src/component-props.ts +104 -0
- package/src/editor/inline-edit-apply.ts +36 -1
- package/src/editor/inline-edit.ts +58 -1
- package/src/editor/shortcuts.ts +41 -12
- package/src/files/file-ops.ts +14 -0
- package/src/files/files.ts +53 -1
- package/src/grid/cell-editors.ts +288 -0
- package/src/grid/cell-popovers.ts +108 -0
- package/src/grid/csv-codec.ts +122 -0
- package/src/grid/edit-buffer.ts +490 -0
- package/src/grid/grid-controller.ts +417 -0
- package/src/grid/grid-layout.ts +83 -0
- package/src/grid/grid-open.ts +167 -0
- package/src/grid/grid-panel.ts +302 -0
- package/src/grid/grid-source.ts +210 -0
- package/src/grid/grid-view.ts +367 -0
- package/src/grid/schema-columns.ts +261 -0
- package/src/grid/sources/connector-source.ts +217 -0
- package/src/grid/sources/content-source.ts +542 -0
- package/src/grid/sources/csv-file-source.ts +247 -0
- package/src/grid/tabulator-tables.d.ts +120 -0
- package/src/panels/block-action-bar.ts +8 -3
- package/src/panels/chat-panel.ts +98 -0
- package/src/panels/data-grid.ts +9 -387
- package/src/panels/editors.ts +43 -17
- package/src/panels/events-panel.ts +137 -44
- package/src/panels/formula-workspace.ts +379 -0
- package/src/panels/frontmatter-fields.ts +231 -0
- package/src/panels/frontmatter-panel.ts +132 -0
- package/src/panels/git-panel.ts +1 -1
- package/src/panels/head-panel.ts +11 -175
- package/src/panels/properties-panel.ts +154 -144
- package/src/panels/right-panel.ts +9 -47
- package/src/panels/signals-panel.ts +115 -23
- package/src/panels/statement-editor.ts +710 -0
- package/src/panels/style-panel.ts +25 -1
- package/src/panels/stylebook-panel.ts +0 -2
- package/src/panels/tab-bar.ts +175 -6
- package/src/panels/tab-strip.ts +62 -6
- package/src/panels/toolbar.ts +37 -3
- package/src/services/ai-project-tools.ts +541 -0
- package/src/services/ai-session-store.ts +28 -0
- package/src/services/ai-system-prompt.ts +194 -24
- package/src/services/ai-tools.ts +88 -21
- package/src/services/automation.ts +16 -0
- package/src/services/document-assistant.ts +81 -11
- package/src/services/gated-registry.ts +72 -0
- package/src/services/live-preview.ts +0 -0
- package/src/services/preview-eval.ts +68 -0
- package/src/services/project-adoption.ts +31 -0
- package/src/site-context.ts +2 -0
- package/src/store.ts +4 -0
- package/src/studio.ts +83 -52
- package/src/tabs/patch-ops.ts +25 -0
- package/src/tabs/tab.ts +39 -1
- package/src/tabs/transact.ts +60 -13
- package/src/types.ts +12 -0
- package/src/ui/dynamic-slot.ts +272 -0
- package/src/ui/expression-editor.ts +423 -125
- package/src/ui/field-row.ts +5 -0
- package/src/ui/formula-catalog.ts +557 -0
- package/src/ui/formula-chips.ts +216 -0
- package/src/ui/formula-palette.ts +211 -0
- package/src/ui/layers.ts +40 -0
- package/src/ui/media-picker.ts +1 -1
- package/src/ui/panel-resize.ts +15 -1
- package/src/utils/preview-format.ts +26 -0
- package/src/view.ts +4 -4
- package/src/workspace/workspace.ts +14 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grid view — the ONLY module that instantiates Tabulator.
|
|
3
|
+
*
|
|
4
|
+
* Everything engine-specific stays behind this wrapper (and cell-editors' factories) so the engine
|
|
5
|
+
* remains swappable: it maps GridColumn[] to Tabulator column definitions, forwards user edits into
|
|
6
|
+
* the edit buffer, renders buffer state (dirty/error/stale/pending-delete classes), and exposes the
|
|
7
|
+
* few actions the panel toolbar needs. Data flows one way per direction: user edits go table →
|
|
8
|
+
* buffer (cellEdited); programmatic changes go buffer → table only through refreshData()
|
|
9
|
+
* (replaceData under a suppress guard).
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
ClipboardModule,
|
|
13
|
+
EditModule,
|
|
14
|
+
ExportModule,
|
|
15
|
+
FilterModule,
|
|
16
|
+
FormatModule,
|
|
17
|
+
FrozenColumnsModule,
|
|
18
|
+
InteractionModule,
|
|
19
|
+
KeybindingsModule,
|
|
20
|
+
MoveColumnsModule,
|
|
21
|
+
ResizeColumnsModule,
|
|
22
|
+
SelectRangeModule,
|
|
23
|
+
SortModule,
|
|
24
|
+
Tabulator,
|
|
25
|
+
TooltipModule,
|
|
26
|
+
} from "tabulator-tables";
|
|
27
|
+
import "tabulator-tables/dist/css/tabulator.min.css";
|
|
28
|
+
import { cellValuesEqual } from "./grid-source";
|
|
29
|
+
import { cellToText, coerceCellInput } from "./schema-columns";
|
|
30
|
+
import { rectOf } from "../utils/geometry";
|
|
31
|
+
import { editorForColumn, formatterForColumn } from "./cell-editors";
|
|
32
|
+
import { hasPopoverEditor, openCellValuePopover } from "./cell-popovers";
|
|
33
|
+
import { ROW_KEY_FIELD } from "./grid-controller";
|
|
34
|
+
import { applyGridLayout, loadGridLayout, saveGridLayout } from "./grid-layout";
|
|
35
|
+
import type {
|
|
36
|
+
CellComponent,
|
|
37
|
+
ColumnComponent,
|
|
38
|
+
ColumnDefinition,
|
|
39
|
+
RowComponent,
|
|
40
|
+
} from "tabulator-tables";
|
|
41
|
+
import type { GridCellValue, GridColumn } from "./grid-source";
|
|
42
|
+
import type { GridController } from "./grid-controller";
|
|
43
|
+
|
|
44
|
+
export interface GridView {
|
|
45
|
+
/** Re-pull controller.effectiveRows() into the table (buffer → table sync). */
|
|
46
|
+
refreshData: () => void;
|
|
47
|
+
/** Copy the range's first row down through the rest of the range, as one undo group. */
|
|
48
|
+
fillDown: () => void;
|
|
49
|
+
/** Row keys covered by the active selection range. */
|
|
50
|
+
getSelectedRowKeys: () => string[];
|
|
51
|
+
/** Local text filter across all columns (empty clears). */
|
|
52
|
+
setSearch: (term: string) => void;
|
|
53
|
+
destroy: () => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let modulesRegistered = false;
|
|
57
|
+
|
|
58
|
+
function ensureModules() {
|
|
59
|
+
if (modulesRegistered) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
Tabulator.registerModule([
|
|
63
|
+
ClipboardModule,
|
|
64
|
+
EditModule,
|
|
65
|
+
ExportModule,
|
|
66
|
+
FilterModule,
|
|
67
|
+
FormatModule,
|
|
68
|
+
FrozenColumnsModule,
|
|
69
|
+
InteractionModule,
|
|
70
|
+
KeybindingsModule,
|
|
71
|
+
MoveColumnsModule,
|
|
72
|
+
ResizeColumnsModule,
|
|
73
|
+
SelectRangeModule,
|
|
74
|
+
SortModule,
|
|
75
|
+
TooltipModule,
|
|
76
|
+
]);
|
|
77
|
+
modulesRegistered = true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* THE single sanctioned imperative element creation in the grid: Tabulator's editor/formatter
|
|
82
|
+
* contract requires returning a detached element for it to parent into the cell, and lit-html needs
|
|
83
|
+
* a container to render into. Everything rendered INTO these hosts is lit.
|
|
84
|
+
*/
|
|
85
|
+
function makeHost(className: string): HTMLElement {
|
|
86
|
+
const host = document.createElement("div");
|
|
87
|
+
host.className = className;
|
|
88
|
+
return host;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function sorterForColumn(column: GridColumn): ColumnDefinition["sorter"] {
|
|
92
|
+
switch (column.kind) {
|
|
93
|
+
case "number": {
|
|
94
|
+
return "number";
|
|
95
|
+
}
|
|
96
|
+
case "boolean": {
|
|
97
|
+
return "boolean";
|
|
98
|
+
}
|
|
99
|
+
case "array": {
|
|
100
|
+
return (a: unknown, b: unknown) =>
|
|
101
|
+
cellToText(a as GridCellValue).localeCompare(cellToText(b as GridCellValue));
|
|
102
|
+
}
|
|
103
|
+
default: {
|
|
104
|
+
return "string";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Create the Tabulator instance for a grid tab and bind it to the controller. */
|
|
110
|
+
export function createGridView(host: HTMLElement, controller: GridController): GridView {
|
|
111
|
+
ensureModules();
|
|
112
|
+
const { buffer } = controller;
|
|
113
|
+
const { columns } = controller.state;
|
|
114
|
+
const columnByField = new Map(columns.map((column) => [column.field, column]));
|
|
115
|
+
const localFilters = !controller.source.capabilities.remotePaging;
|
|
116
|
+
|
|
117
|
+
let suppress = false;
|
|
118
|
+
let built = false;
|
|
119
|
+
let pendingRefresh = false;
|
|
120
|
+
|
|
121
|
+
const rowKeyOf = (row: RowComponent) => String(row.getData()[ROW_KEY_FIELD] ?? "");
|
|
122
|
+
|
|
123
|
+
const paintRow = (row: RowComponent) => {
|
|
124
|
+
const rowState = buffer.rowState(rowKeyOf(row));
|
|
125
|
+
const el = row.getElement();
|
|
126
|
+
el.classList.toggle("jx-grid-row--pending-delete", rowState === "pending-delete");
|
|
127
|
+
el.classList.toggle("jx-grid-row--pending-insert", rowState === "pending-insert");
|
|
128
|
+
el.classList.toggle("jx-grid-row--stale", rowState === "stale");
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const paintCell = (cell: CellComponent) => {
|
|
132
|
+
const cellState = buffer.cellState(rowKeyOf(cell.getRow()), cell.getField());
|
|
133
|
+
const el = cell.getElement();
|
|
134
|
+
el.classList.toggle("jx-grid-cell--dirty", cellState === "dirty");
|
|
135
|
+
el.classList.toggle("jx-grid-cell--error", cellState === "error");
|
|
136
|
+
el.classList.toggle("jx-grid-cell--stale", cellState === "stale");
|
|
137
|
+
const error = buffer.cellError(rowKeyOf(cell.getRow()), cell.getField());
|
|
138
|
+
if (error) {
|
|
139
|
+
el.title = error;
|
|
140
|
+
} else {
|
|
141
|
+
el.removeAttribute("title");
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const cellEditable = (column: GridColumn, rowKey: string) =>
|
|
146
|
+
(column.editable || (column.insertOnly === true && buffer.isInsertKey(rowKey))) &&
|
|
147
|
+
buffer.rowState(rowKey) !== "pending-delete";
|
|
148
|
+
|
|
149
|
+
const columnDefs: ColumnDefinition[] = columns.map((column) => {
|
|
150
|
+
const baseFormatter = formatterForColumn(column, makeHost);
|
|
151
|
+
return {
|
|
152
|
+
editable: (cell: CellComponent) => cellEditable(column, rowKeyOf(cell.getRow())),
|
|
153
|
+
// Image/reference cells edit through an anchored popover (dblclick), not an editor session.
|
|
154
|
+
editor: hasPopoverEditor(column) ? undefined : editorForColumn(column, makeHost),
|
|
155
|
+
field: column.field,
|
|
156
|
+
formatter: (cell, _params, onRendered) => {
|
|
157
|
+
onRendered(() => paintCell(cell));
|
|
158
|
+
return baseFormatter(cell);
|
|
159
|
+
},
|
|
160
|
+
frozen: column.pk === true ? true : undefined,
|
|
161
|
+
headerFilter: localFilters && column.kind !== "boolean" ? "input" : undefined,
|
|
162
|
+
headerSort: !controller.source.capabilities.remoteSort,
|
|
163
|
+
sorter: sorterForColumn(column),
|
|
164
|
+
title: column.title,
|
|
165
|
+
width: column.widthHint,
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const gridId = controller.source.id;
|
|
170
|
+
const layout = loadGridLayout(gridId);
|
|
171
|
+
|
|
172
|
+
const table = new Tabulator(host, {
|
|
173
|
+
clipboard: true,
|
|
174
|
+
clipboardCopyConfig: { columnHeaders: false, rowHeaders: false },
|
|
175
|
+
clipboardCopyRowRange: "range",
|
|
176
|
+
clipboardCopyStyled: false,
|
|
177
|
+
clipboardPasteAction: "range",
|
|
178
|
+
clipboardPasteParser: "range",
|
|
179
|
+
columnDefaults: { headerSortTristate: true, resizable: "header" },
|
|
180
|
+
columns: applyGridLayout(columnDefs, layout),
|
|
181
|
+
data: controller.effectiveRows(),
|
|
182
|
+
editTriggerEvent: "dblclick",
|
|
183
|
+
headerSortClickElement: "icon",
|
|
184
|
+
height: "100%",
|
|
185
|
+
history: false,
|
|
186
|
+
index: ROW_KEY_FIELD,
|
|
187
|
+
layout: "fitDataFill",
|
|
188
|
+
movableColumns: true,
|
|
189
|
+
placeholder: "No rows",
|
|
190
|
+
reactiveData: false,
|
|
191
|
+
renderHorizontal: "virtual",
|
|
192
|
+
renderVertical: "virtual",
|
|
193
|
+
rowFormatter: paintRow,
|
|
194
|
+
selectableRange: 1,
|
|
195
|
+
selectableRangeClearCells: true,
|
|
196
|
+
selectableRangeClearCellsValue: null,
|
|
197
|
+
selectableRangeColumns: true,
|
|
198
|
+
selectableRangeRows: true,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const refreshData = () => {
|
|
202
|
+
if (!built) {
|
|
203
|
+
pendingRefresh = true;
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
suppress = true;
|
|
207
|
+
void table
|
|
208
|
+
.replaceData(controller.effectiveRows())
|
|
209
|
+
.catch(() => {})
|
|
210
|
+
.finally(() => {
|
|
211
|
+
suppress = false;
|
|
212
|
+
});
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
controller.bindView({ refreshData });
|
|
216
|
+
|
|
217
|
+
table.on("tableBuilt", () => {
|
|
218
|
+
built = true;
|
|
219
|
+
if (pendingRefresh) {
|
|
220
|
+
pendingRefresh = false;
|
|
221
|
+
refreshData();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
table.on("cellEdited", (cell: CellComponent) => {
|
|
226
|
+
if (suppress) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const field = cell.getField();
|
|
230
|
+
const column = columnByField.get(field);
|
|
231
|
+
if (!column) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const rowKey = rowKeyOf(cell.getRow());
|
|
235
|
+
const value = coerceCellInput(cell.getValue(), column);
|
|
236
|
+
buffer.setCell(rowKey, field, value);
|
|
237
|
+
// Normalize what the table shows to the typed value (e.g. pasted "42" in a number column).
|
|
238
|
+
if (!cellValuesEqual(value, cell.getValue() as GridCellValue)) {
|
|
239
|
+
suppress = true;
|
|
240
|
+
try {
|
|
241
|
+
cell.setValue(value, true);
|
|
242
|
+
} finally {
|
|
243
|
+
suppress = false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
paintCell(cell);
|
|
247
|
+
paintRow(cell.getRow());
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
table.on("columnResized", (column: ColumnComponent) => {
|
|
251
|
+
saveGridLayout(gridId, { widths: { [column.getField()]: column.getWidth() } });
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
table.on("columnMoved", (_column: ColumnComponent, ordered: ColumnComponent[]) => {
|
|
255
|
+
saveGridLayout(gridId, { order: ordered.map((c) => c.getField()) });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
table.on("cellDblClick", (_event: unknown, cell: CellComponent) => {
|
|
259
|
+
const column = columnByField.get(cell.getField());
|
|
260
|
+
const rowKey = rowKeyOf(cell.getRow());
|
|
261
|
+
if (!column || !hasPopoverEditor(column) || !cellEditable(column, rowKey)) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const rect = rectOf(cell.getElement());
|
|
265
|
+
void openCellValuePopover({
|
|
266
|
+
anchor: { bottom: rect.bottom, left: rect.left },
|
|
267
|
+
column,
|
|
268
|
+
commit: (value) => {
|
|
269
|
+
buffer.setCell(rowKey, column.field, value);
|
|
270
|
+
suppress = true;
|
|
271
|
+
try {
|
|
272
|
+
cell.setValue(value, true);
|
|
273
|
+
} finally {
|
|
274
|
+
suppress = false;
|
|
275
|
+
}
|
|
276
|
+
paintCell(cell);
|
|
277
|
+
paintRow(cell.getRow());
|
|
278
|
+
},
|
|
279
|
+
value: buffer.effectiveValue(rowKey, column.field),
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Group edit bursts (paste, range clear) into single undo entries. The burst's cellEdited
|
|
284
|
+
// Events run synchronously inside the triggering event, so a microtask reliably closes it.
|
|
285
|
+
let groupOpen = false;
|
|
286
|
+
const openBurstGroup = () => {
|
|
287
|
+
if (groupOpen) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
groupOpen = true;
|
|
291
|
+
buffer.beginGroup();
|
|
292
|
+
queueMicrotask(() => {
|
|
293
|
+
groupOpen = false;
|
|
294
|
+
buffer.endGroup();
|
|
295
|
+
});
|
|
296
|
+
};
|
|
297
|
+
host.addEventListener("paste", openBurstGroup, true);
|
|
298
|
+
host.addEventListener(
|
|
299
|
+
"keydown",
|
|
300
|
+
(e: KeyboardEvent) => {
|
|
301
|
+
if (e.key === "Delete" || e.key === "Backspace") {
|
|
302
|
+
openBurstGroup();
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
true,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
destroy() {
|
|
310
|
+
controller.bindView(null);
|
|
311
|
+
table.destroy();
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
fillDown() {
|
|
315
|
+
const [range] = table.getRanges();
|
|
316
|
+
if (!range) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const cellRows = range.getCells();
|
|
320
|
+
if (cellRows.length < 2) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const [sourceRow] = cellRows;
|
|
324
|
+
if (!sourceRow) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
buffer.group(() => {
|
|
328
|
+
for (const cellRow of cellRows.slice(1)) {
|
|
329
|
+
for (const [i, cell] of cellRow.entries()) {
|
|
330
|
+
const from = sourceRow[i];
|
|
331
|
+
if (from) {
|
|
332
|
+
cell.setValue(from.getValue(), true);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
getSelectedRowKeys() {
|
|
340
|
+
const keys = new Set<string>();
|
|
341
|
+
for (const range of table.getRanges()) {
|
|
342
|
+
for (const row of range.getRows()) {
|
|
343
|
+
keys.add(rowKeyOf(row));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return [...keys];
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
refreshData,
|
|
350
|
+
|
|
351
|
+
setSearch(term: string) {
|
|
352
|
+
const query = term.trim().toLowerCase();
|
|
353
|
+
if (query === "") {
|
|
354
|
+
table.clearFilter(false);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const fields = columns.map((column) => column.field);
|
|
358
|
+
table.setFilter((data: Record<string, unknown>) =>
|
|
359
|
+
fields.some((field) =>
|
|
360
|
+
cellToText(data[field] as GridCellValue)
|
|
361
|
+
.toLowerCase()
|
|
362
|
+
.includes(query),
|
|
363
|
+
),
|
|
364
|
+
);
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema → grid-column mapping and cell value coercion.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions shared by every grid source: derive `GridColumn[]` from a content-type JSON Schema
|
|
5
|
+
* (same field-type semantics as settings/schema-field-ui `detectFieldType`), infer columns by
|
|
6
|
+
* sniffing rows when no schema exists (pages, schemaless CSV), and convert between typed cell
|
|
7
|
+
* values and the strings that editors, clipboards, and CSV files traffic in. Coercion mirrors the
|
|
8
|
+
* parser extension's `coerceCSVRows` (currency-stripped numbers, `"true"` booleans, comma-split
|
|
9
|
+
* arrays) so grid edits round-trip identically to the runtime content loader.
|
|
10
|
+
*/
|
|
11
|
+
import type { GridCellValue, GridColumn, GridColumnKind } from "./grid-source";
|
|
12
|
+
import type { JsonSchema } from "../ui/schema-form";
|
|
13
|
+
|
|
14
|
+
/** JSON-Schema property as it appears in content-type schemas (superset of ui JsonSchema). */
|
|
15
|
+
interface PropSchema extends JsonSchema {
|
|
16
|
+
$ref?: string;
|
|
17
|
+
maxLength?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const KIND_WIDTHS: Record<GridColumnKind, number> = {
|
|
21
|
+
array: 220,
|
|
22
|
+
boolean: 70,
|
|
23
|
+
date: 130,
|
|
24
|
+
enum: 140,
|
|
25
|
+
image: 180,
|
|
26
|
+
number: 110,
|
|
27
|
+
readonly: 200,
|
|
28
|
+
reference: 200,
|
|
29
|
+
string: 180,
|
|
30
|
+
text: 340,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Longest-string width clamp used for sampled string/enum columns. */
|
|
34
|
+
function clampWidth(maxLen: number): number {
|
|
35
|
+
return Math.min(340, Math.max(90, Math.round(maxLen * 7.5)));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Field kind for one schema property — same dispatch as schema-field-ui's detectFieldType. */
|
|
39
|
+
export function kindForProp(prop: PropSchema): GridColumnKind {
|
|
40
|
+
if (prop.$ref) {
|
|
41
|
+
return "reference";
|
|
42
|
+
}
|
|
43
|
+
if (prop.enum !== undefined && Array.isArray(prop.enum)) {
|
|
44
|
+
return "enum";
|
|
45
|
+
}
|
|
46
|
+
const format = prop.type === "array" ? undefined : prop.format;
|
|
47
|
+
if (format === "image") {
|
|
48
|
+
return "image";
|
|
49
|
+
}
|
|
50
|
+
if (format === "date" || format === "date-time") {
|
|
51
|
+
return "date";
|
|
52
|
+
}
|
|
53
|
+
switch (prop.type) {
|
|
54
|
+
case "number":
|
|
55
|
+
case "integer": {
|
|
56
|
+
return "number";
|
|
57
|
+
}
|
|
58
|
+
case "boolean": {
|
|
59
|
+
return "boolean";
|
|
60
|
+
}
|
|
61
|
+
case "array": {
|
|
62
|
+
return "array";
|
|
63
|
+
}
|
|
64
|
+
case "object": {
|
|
65
|
+
// Nested objects have no inline editor — surfaced read-only for now.
|
|
66
|
+
return "readonly";
|
|
67
|
+
}
|
|
68
|
+
default: {
|
|
69
|
+
return (prop.maxLength ?? 0) > 200 || prop.format === "markdown" ? "text" : "string";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Human column title from a camelCase/snake_case field name. */
|
|
75
|
+
export function titleForField(field: string): string {
|
|
76
|
+
const spaced = field
|
|
77
|
+
.replaceAll(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
78
|
+
.replaceAll(/[_-]+/g, " ")
|
|
79
|
+
.trim();
|
|
80
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ColumnsFromSchemaOptions {
|
|
84
|
+
/** Field to pin as the identity column (frozen first, still editable unless readonly). */
|
|
85
|
+
idField?: string | undefined;
|
|
86
|
+
/** Extra fields to force read-only. */
|
|
87
|
+
readonlyFields?: string[] | undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Map a content-type/JSON schema's properties to grid columns. Order: identity field first, then
|
|
92
|
+
* required properties in declaration order, then the rest in declaration order.
|
|
93
|
+
*/
|
|
94
|
+
export function columnsFromSchema(
|
|
95
|
+
schema: { properties?: Record<string, unknown>; required?: string[] } | null | undefined,
|
|
96
|
+
opts: ColumnsFromSchemaOptions = {},
|
|
97
|
+
): GridColumn[] {
|
|
98
|
+
const props = Object.entries(schema?.properties ?? {}) as [string, PropSchema][];
|
|
99
|
+
const required = new Set(schema?.required);
|
|
100
|
+
const readonly = new Set(opts.readonlyFields);
|
|
101
|
+
|
|
102
|
+
const columns = props.map(([field, prop]): GridColumn => {
|
|
103
|
+
const kind = readonly.has(field) ? "readonly" : kindForProp(prop);
|
|
104
|
+
const enumLen = Array.isArray(prop.enum)
|
|
105
|
+
? Math.max(...(prop.enum as unknown[]).map((v) => String(v).length), 4)
|
|
106
|
+
: 0;
|
|
107
|
+
return {
|
|
108
|
+
editable: kind !== "readonly",
|
|
109
|
+
field,
|
|
110
|
+
kind,
|
|
111
|
+
pk: field === opts.idField,
|
|
112
|
+
required: required.has(field),
|
|
113
|
+
schema: prop,
|
|
114
|
+
title: titleForField(field),
|
|
115
|
+
widthHint: kind === "enum" ? clampWidth(enumLen + 3) : KIND_WIDTHS[kind],
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
columns.sort((a, b) => {
|
|
120
|
+
const rank = (c: GridColumn) => (c.pk ? 0 : c.required ? 1 : 2);
|
|
121
|
+
const diff = rank(a) - rank(b);
|
|
122
|
+
return diff === 0 ? 0 : diff;
|
|
123
|
+
});
|
|
124
|
+
return columns;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Infer columns by sniffing row values (pages and schemaless CSV). Keys appear in first-seen order;
|
|
129
|
+
* a column's kind is the narrowest type every non-empty sample value satisfies.
|
|
130
|
+
*/
|
|
131
|
+
export function inferColumnsFromRows(
|
|
132
|
+
rows: Record<string, GridCellValue>[],
|
|
133
|
+
sampleLimit = 50,
|
|
134
|
+
): GridColumn[] {
|
|
135
|
+
const sample = rows.slice(0, sampleLimit);
|
|
136
|
+
const fields: string[] = [];
|
|
137
|
+
for (const row of sample) {
|
|
138
|
+
for (const key of Object.keys(row)) {
|
|
139
|
+
if (!fields.includes(key)) {
|
|
140
|
+
fields.push(key);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return fields.map((field): GridColumn => {
|
|
146
|
+
const values = sample
|
|
147
|
+
.map((row) => row[field])
|
|
148
|
+
.filter((v): v is Exclude<GridCellValue, null> => v !== null && v !== undefined && v !== "");
|
|
149
|
+
const kind = inferKind(values);
|
|
150
|
+
const maxLen = Math.max(...values.map((v) => cellToText(v).length), field.length, 4);
|
|
151
|
+
return {
|
|
152
|
+
editable: true,
|
|
153
|
+
field,
|
|
154
|
+
kind,
|
|
155
|
+
title: titleForField(field),
|
|
156
|
+
widthHint: kind === "string" || kind === "text" ? clampWidth(maxLen) : KIND_WIDTHS[kind],
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function inferKind(values: Exclude<GridCellValue, null>[]): GridColumnKind {
|
|
162
|
+
if (values.length === 0) {
|
|
163
|
+
return "string";
|
|
164
|
+
}
|
|
165
|
+
if (values.some((v) => Array.isArray(v))) {
|
|
166
|
+
return "array";
|
|
167
|
+
}
|
|
168
|
+
const texts = values.map(String);
|
|
169
|
+
if (
|
|
170
|
+
values.every((v) => typeof v === "boolean") ||
|
|
171
|
+
texts.every((t) => /^(true|false)$/i.test(t))
|
|
172
|
+
) {
|
|
173
|
+
return "boolean";
|
|
174
|
+
}
|
|
175
|
+
if (
|
|
176
|
+
values.every((v) => typeof v === "number") ||
|
|
177
|
+
texts.every((t) => t.trim() !== "" && !Number.isNaN(Number(t.replaceAll(/[$€£¥,\s]/g, ""))))
|
|
178
|
+
) {
|
|
179
|
+
return "number";
|
|
180
|
+
}
|
|
181
|
+
if (texts.every((t) => /^\d{4}-\d{2}-\d{2}([T ].*)?$/.test(t))) {
|
|
182
|
+
return "date";
|
|
183
|
+
}
|
|
184
|
+
if (texts.some((t) => t.length > 200)) {
|
|
185
|
+
return "text";
|
|
186
|
+
}
|
|
187
|
+
return "string";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─── Value coercion ───────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Coerce editor/clipboard input to the column's typed value. Empty input clears to null. Number and
|
|
194
|
+
* array semantics mirror the parser extension's coerceCSVRows.
|
|
195
|
+
*/
|
|
196
|
+
export function coerceCellInput(raw: unknown, col: GridColumn): GridCellValue {
|
|
197
|
+
if (raw === null || raw === undefined) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
switch (col.kind) {
|
|
201
|
+
case "number": {
|
|
202
|
+
if (typeof raw === "number") {
|
|
203
|
+
return Number.isNaN(raw) ? null : raw;
|
|
204
|
+
}
|
|
205
|
+
const cleaned = String(raw)
|
|
206
|
+
.trim()
|
|
207
|
+
.replaceAll(/[$€£¥,\s]/g, "");
|
|
208
|
+
if (cleaned === "") {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
const n = Number(cleaned);
|
|
212
|
+
return Number.isNaN(n) ? null : n;
|
|
213
|
+
}
|
|
214
|
+
case "boolean": {
|
|
215
|
+
if (typeof raw === "boolean") {
|
|
216
|
+
return raw;
|
|
217
|
+
}
|
|
218
|
+
return String(raw).trim().toLowerCase() === "true";
|
|
219
|
+
}
|
|
220
|
+
case "array": {
|
|
221
|
+
if (Array.isArray(raw)) {
|
|
222
|
+
return raw.map(String);
|
|
223
|
+
}
|
|
224
|
+
const text = String(raw).trim();
|
|
225
|
+
return text === ""
|
|
226
|
+
? []
|
|
227
|
+
: text
|
|
228
|
+
.split(",")
|
|
229
|
+
.map((s) => s.trim())
|
|
230
|
+
.filter(Boolean);
|
|
231
|
+
}
|
|
232
|
+
case "reference": {
|
|
233
|
+
// Relationship VALUES are plain target-entry ids ("jane-doe") — the $ref lives on the
|
|
234
|
+
// Schema property, not in the data (specs/relationships.md; parser resolveContentTypeRefs).
|
|
235
|
+
if (typeof raw === "object" && raw !== null && "$ref" in raw) {
|
|
236
|
+
const ref = (raw as { $ref: unknown }).$ref;
|
|
237
|
+
return typeof ref === "string" && ref !== "" ? ref : null;
|
|
238
|
+
}
|
|
239
|
+
const text = String(raw).trim();
|
|
240
|
+
return text === "" ? null : text;
|
|
241
|
+
}
|
|
242
|
+
default: {
|
|
243
|
+
const text = String(raw);
|
|
244
|
+
return text === "" ? null : text;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Plain-text projection of a cell value — display, clipboard, and CSV share it. */
|
|
250
|
+
export function cellToText(value: GridCellValue): string {
|
|
251
|
+
if (value === null || value === undefined) {
|
|
252
|
+
return "";
|
|
253
|
+
}
|
|
254
|
+
if (Array.isArray(value)) {
|
|
255
|
+
return value.join(", ");
|
|
256
|
+
}
|
|
257
|
+
if (typeof value === "object") {
|
|
258
|
+
return value.$ref;
|
|
259
|
+
}
|
|
260
|
+
return String(value);
|
|
261
|
+
}
|