From 97d15b3e2d18ebcf56d01d018c1db0fcf6b8fb19 Mon Sep 17 00:00:00 2001 From: zipg Date: Thu, 16 Jul 2026 18:43:49 +0800 Subject: [PATCH] feat(grid): add generated cell values --- apps/desktop/src/components/grid/DataGrid.vue | 145 +++++++++++++++++- .../src/composables/useDataGridEditor.ts | 15 +- apps/desktop/src/i18n/locales/en.ts | 11 ++ apps/desktop/src/i18n/locales/es.ts | 11 ++ apps/desktop/src/i18n/locales/it.ts | 11 ++ apps/desktop/src/i18n/locales/ja.ts | 11 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 11 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 11 ++ apps/desktop/src/i18n/locales/zh-TW.ts | 11 ++ .../dataGrid/cellValueGeneration.spec.ts | 31 ++++ .../dataGrid/dataGridCellCoercion.spec.ts | 16 +- .../src/lib/dataGrid/cellValueGeneration.ts | 77 ++++++++++ .../src/lib/dataGrid/dataGridCellCoercion.ts | 3 +- .../src/lib/dataGrid/dataGridContextMenu.ts | 2 + .../app-tests/dataGridContextMenu.test.ts | 4 +- packages/app-tests/dataGridEditor.test.ts | 19 +++ 16 files changed, 376 insertions(+), 13 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/dataGrid/cellValueGeneration.spec.ts create mode 100644 apps/desktop/src/lib/dataGrid/cellValueGeneration.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 36e23f738..29382f1b3 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -37,6 +37,7 @@ import { Database, Columns3, PencilRuler, + WandSparkles, } from "@lucide/vue"; import { Button } from "@/components/ui/button"; import QueryLoadingState from "@/components/common/QueryLoadingState.vue"; @@ -65,6 +66,7 @@ import { createColumnDrafts } from "@/lib/table/tableStructureEditorState"; import type { BuildSingleColumnAlterSqlOptions } from "@/lib/table/tableStructureEditorSql"; import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql"; import { uuid } from "@/lib/common/utils"; +import { generateCellValues, type CellValueGenerationKind } from "@/lib/dataGrid/cellValueGeneration"; import { compactHeaderColumnType, resolveHeaderColumnType } from "@/lib/dataGrid/dataGridColumnType"; import { canDeleteExistingTdengineRows, @@ -508,6 +510,9 @@ const contextHeaderColumn = ref(null); const contextHeaderColumnIndex = ref(null); const bulkEditDialogOpen = ref(false); const bulkEditValue = ref(""); +const generateIncrementDialogOpen = ref(false); +const generateIncrementStartValue = ref("1"); +const generateIncrementTarget = ref<"selection" | "detail">("selection"); const detailCell = ref<{ rowIndex: number; col: number } | null>(null); const hoveredDetailCell = ref<{ rowIndex: number; col: number } | null>(null); const quickDownloadMenuCell = ref<{ rowIndex: number; col: number } | null>(null); @@ -5290,16 +5295,16 @@ function selectedVisibleColumnIndexes(): number[] { return [...selectedColumnIndexes.value].filter((index) => index >= 0 && index < visibleColumns.value.length).sort((a, b) => a - b); } -function applyVisibleCellValue(item: RowItem, visibleCol: number, value: string | null): boolean { +function applyVisibleCellValue(item: RowItem, visibleCol: number, value: string | null, options: { preserveEmptyString?: boolean } = {}): boolean { const actualCol = actualColumnIndex(visibleCol); if (!canEditCellItem(item, actualCol)) return false; - applyCellValue(item.id, actualCol, value); + applyCellValue(item.id, actualCol, value, options); return true; } -function applyVisibleSelectedCellValue(item: RowItem, visibleCol: number, value: string | null, allowDraft = selectedRangeTargetsOnlyDraftRow()): boolean { +function applyVisibleSelectedCellValue(item: RowItem, visibleCol: number, value: string | null, allowDraft = selectedRangeTargetsOnlyDraftRow(), options: { preserveEmptyString?: boolean } = {}): boolean { if (!canApplyGridSelectionValue({ isDraft: !!item.isDraft, allowDraft })) return false; - return applyVisibleCellValue(item, visibleCol, value); + return applyVisibleCellValue(item, visibleCol, value, options); } function selectedRangeTargetsOnlyDraftRow(): boolean { @@ -5380,6 +5385,94 @@ function applyBulkEditValue() { bulkEditDialogOpen.value = false; } +interface EditableSelectionCell { + item: RowItem; + visibleCol: number; +} + +function editableSelectionCells(): EditableSelectionCell[] { + const cells: EditableSelectionCell[] = []; + const range = selectedRange.value; + if (range) { + for (let rowIndex = range.startRow; rowIndex <= range.endRow; rowIndex++) { + const item = displayItemAt(rowIndex); + if (!item) continue; + for (let visibleCol = range.startCol; visibleCol <= range.endCol; visibleCol++) { + if (canEditCellItem(item, actualColumnIndex(visibleCol))) cells.push({ item, visibleCol }); + } + } + return cells; + } + + const visibleColumnIndexes = selectedVisibleColumnIndexes(); + for (let rowIndex = 0; rowIndex < displayRowCount.value; rowIndex++) { + const item = displayItemAt(rowIndex); + if (!item) continue; + for (const visibleCol of visibleColumnIndexes) { + if (canEditCellItem(item, actualColumnIndex(visibleCol))) cells.push({ item, visibleCol }); + } + } + return cells; +} + +function applyGeneratedSelectionValue(kind: CellValueGenerationKind, startValue = 1n): boolean { + if (!props.editable) return false; + const cells = editableSelectionCells(); + if (!cells.length) return false; + const values = generateCellValues(kind, cells.length, { startValue }); + const allowDraftSelectionValue = selectedRangeTargetsOnlyDraftRow(); + let applied = false; + cells.forEach((cell, index) => { + applied = applyVisibleSelectedCellValue(cell.item, cell.visibleCol, values[index] ?? null, allowDraftSelectionValue, { preserveEmptyString: kind === "empty" }) || applied; + }); + if (applied) toast(t("grid.generatedValuesApplied", { count: cells.length })); + return applied; +} + +function applyGeneratedDetailValue(kind: CellValueGenerationKind, startValue = 1n): boolean { + const detail = activeCellDetail.value; + if (!detail?.isEditable) return false; + const value = generateCellValues(kind, 1, { startValue })[0] ?? null; + applyCellValue(detail.rowId, detail.colIndex, value, { preserveEmptyString: kind === "empty" }); + detailEditValue.value = cellDetailEditorText(value); + syncEditorFromDetailEdit(); + isEditingDetail.value = activeCellDetailTab.value === "valueEditor"; + detailCell.value = { ...detailCell.value! }; + return true; +} + +function openGenerateIncrementDialog(target: "selection" | "detail") { + if (target === "selection" && (!props.editable || !selectionHasEditableCells())) return; + if (target === "detail" && !activeCellDetail.value?.isEditable) return; + generateIncrementTarget.value = target; + generateIncrementStartValue.value = "1"; + generateIncrementDialogOpen.value = true; +} + +function applyGenerateIncrementValue() { + let startValue: bigint; + try { + startValue = BigInt(generateIncrementStartValue.value.trim() || "1"); + } catch { + toast(t("grid.generateStartInvalid")); + return; + } + const applied = generateIncrementTarget.value === "detail" ? applyGeneratedDetailValue("increment", startValue) : applyGeneratedSelectionValue("increment", startValue); + if (applied) generateIncrementDialogOpen.value = false; +} + +function generateSelectionMenuItems(disabled: boolean): ContextMenuItem[] { + return [ + { label: t("grid.generateEmptyString"), action: () => applyGeneratedSelectionValue("empty"), disabled }, + { label: t("grid.generateNull"), action: () => applyGeneratedSelectionValue("null"), disabled }, + { label: t("grid.generateCurrentDatetime"), action: () => applyGeneratedSelectionValue("datetime"), disabled }, + { label: t("grid.generateCurrentDate"), action: () => applyGeneratedSelectionValue("date"), disabled }, + { label: t("grid.generateUuid"), action: () => applyGeneratedSelectionValue("uuid"), disabled }, + { label: t("grid.generateSnowflakeId"), action: () => applyGeneratedSelectionValue("snowflake"), disabled }, + { label: t("grid.generateIncrementId"), action: () => openGenerateIncrementDialog("selection"), disabled }, + ]; +} + function cutSelection() { if (!props.editable || !selectedRange.value) return; copySelectionTsv(); @@ -7178,6 +7271,7 @@ function exportSubmenu(): ContextMenuItem { const gridContextMenuItems = computed(() => { const row = contextRowItem.value; const rowLabels = rowActionLabels(); + const hasEditableSelection = selectionHasEditableCells(); const previewItems: ContextMenuItem[] = []; if (!contextHeaderColumn.value && contextCell.value) { const colType = props.result.column_types?.[contextCell.value.col]; @@ -7225,7 +7319,7 @@ const gridContextMenuItems = computed(() => { headerColumn: !!contextHeaderColumn.value, editable: props.editable, hasCellSelection: hasCellSelection.value, - hasEditableSelection: selectionHasEditableCells(), + hasEditableSelection, hasSelection: hasCellSelection.value, labels: { cellDetails: t("grid.openCellDetailsDialog"), @@ -7240,6 +7334,12 @@ const gridContextMenuItems = computed(() => { downloadItem: binaryDownloadSubmenu(contextCellDetail.value), copySubmenu: copySubmenu(), selectionSubmenu: selectionSubmenu(), + generateSubmenu: { + label: t("grid.generateValue"), + icon: WandSparkles, + disabled: !hasEditableSelection, + children: generateSelectionMenuItems(!hasEditableSelection), + }, }), createDataGridRowContextMenuItems({ editable: props.editable, @@ -8608,6 +8708,23 @@ const gridContextMenuItems = computed(() => {
+ + + + + + {{ t("grid.generateEmptyString") }} + {{ t("grid.generateNull") }} + {{ t("grid.generateCurrentDatetime") }} + {{ t("grid.generateCurrentDate") }} + {{ t("grid.generateUuid") }} + {{ t("grid.generateSnowflakeId") }} + {{ t("grid.generateIncrementId") }} + + @@ -8734,6 +8851,24 @@ const gridContextMenuItems = computed(() => { + + + + {{ t("grid.generateIncrementId") }} + +
+

+ {{ t("grid.generateSequenceDescription", { count: generateIncrementTarget === "detail" ? 1 : editableSelectionCells().length }) }} +

+ +
+ + + + +
+
+
diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index e4ffeb410..582e2452d 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -480,12 +480,17 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } // --- Cell value coercion --- - function coerceCellValue(value: string, oldValue: CellValue | undefined, columnIndex: number): CellValue { + interface ApplyCellValueOptions { + preserveEmptyString?: boolean; + } + + function coerceCellValue(value: string, oldValue: CellValue | undefined, columnIndex: number, options: ApplyCellValueOptions = {}): CellValue { return coerceDataGridCellValue({ value, oldValue, databaseType: resolvedDatabaseType.value, columnInfo: tableColumnForGridColumn(columnIndex), + preserveEmptyString: options.preserveEmptyString, }) as CellValue; } @@ -722,7 +727,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { await commitEditAndMaybeAutoSave(options); } - function applyCellValue(rowId: number, col: number, value: string | null) { + function applyCellValue(rowId: number, col: number, value: string | null, options: ApplyCellValueOptions = {}) { if (!canEditColumn(col)) return; const item = getRowItem(rowId); if (!item || item.isDeleted) return; @@ -731,7 +736,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { ensureQuickEntryDraftRow(); const oldVal = quickEntryDraftRow.value[col] ?? null; const nextDraftRow = [...quickEntryDraftRow.value]; - nextDraftRow[col] = value === null ? null : coerceCellValue(value, oldVal, col); + nextDraftRow[col] = value === null ? null : coerceCellValue(value, oldVal, col, options); if (nextDraftRow[col] === oldVal) return; pushUndoSnapshot(); quickEntryDraftRow.value = draftRowHasValue(nextDraftRow) ? nextDraftRow : emptyDraftRow(); @@ -745,7 +750,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { const row = newRows.value[item.newIndex]; if (!row) return; const oldVal = row[col]; - const newVal = value === null ? null : coerceCellValue(value, oldVal, col); + const newVal = value === null ? null : coerceCellValue(value, oldVal, col, options); if (newVal === oldVal) return; pushUndoSnapshot(); row[col] = newVal; @@ -761,7 +766,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { const rowChanges = dirtyRows.value.get(item.sourceIndex); const hasPendingCellChange = rowChanges?.has(col) ?? false; const currentVal = hasPendingCellChange ? rowChanges!.get(col) : oldVal; - const newVal = value === null ? null : coerceCellValue(value, oldVal, col); + const newVal = value === null ? null : coerceCellValue(value, oldVal, col, options); if (newVal === currentVal) return; if (newVal !== oldVal) { pushUndoSnapshot(); diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 9047f8884..e887e77b3 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -986,6 +986,17 @@ export default { bulkEditDescription: "Set {count} selected cell(s) to this value.", bulkEditValuePlaceholder: "Value, or NULL", applyBulkEdit: "Apply", + generateValue: "Generate Value", + generateEmptyString: "Empty String", + generateNull: "NULL", + generateCurrentDatetime: "Current Datetime", + generateCurrentDate: "Current Date", + generateUuid: "UUID", + generateIncrementId: "Increment ID", + generateSnowflakeId: "Snowflake ID", + generateSequenceDescription: "Generate consecutive values for {count} selected cell(s). Enter the start value.", + generateStartInvalid: "Start value must be an integer", + generatedValuesApplied: "Generated {count} value(s)", cellDetails: "Cell Details", cellDetailLayoutBottom: "Move to Bottom", cellDetailLayoutRight: "Move to Right", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 7a2743bbd..6de54ee0c 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -929,6 +929,17 @@ export default withEnglishFallback({ bulkEditDescription: "Establecer {count} celda(s) seleccionada(s) en este valor.", bulkEditValuePlaceholder: "Valor, o NULL", applyBulkEdit: "Aplicar", + generateValue: "Generar valor", + generateEmptyString: "Cadena vacía", + generateNull: "NULL", + generateCurrentDatetime: "Fecha y hora actuales", + generateCurrentDate: "Fecha actual", + generateUuid: "UUID", + generateIncrementId: "ID incremental", + generateSnowflakeId: "ID Snowflake", + generateSequenceDescription: "Generar valores consecutivos para {count} celda(s) seleccionada(s). Introduzca el valor inicial.", + generateStartInvalid: "El valor inicial debe ser un número entero", + generatedValuesApplied: "Se generaron {count} valor(es)", cellDetails: "Detalles de celda", cellDetailLayoutBottom: "Mover abajo", cellDetailLayoutRight: "Mover a la derecha", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 2bcde98e7..1e9bb7f2e 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -927,6 +927,17 @@ export default withEnglishFallback({ bulkEditDescription: "Imposta le {count} celle selezionate su questo valore.", bulkEditValuePlaceholder: "Valore, o NULL", applyBulkEdit: "Applica", + generateValue: "Genera valore", + generateEmptyString: "Stringa vuota", + generateNull: "NULL", + generateCurrentDatetime: "Data e ora correnti", + generateCurrentDate: "Data corrente", + generateUuid: "UUID", + generateIncrementId: "ID incrementale", + generateSnowflakeId: "ID Snowflake", + generateSequenceDescription: "Genera valori consecutivi per le {count} celle selezionate. Inserisci il valore iniziale.", + generateStartInvalid: "Il valore iniziale deve essere un numero intero", + generatedValuesApplied: "Generati {count} valori", cellDetails: "Dettagli Cella", cellDetailLayoutBottom: "Sposta in Basso", cellDetailLayoutRight: "Sposta a Destra", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index b3dce1be1..ad81508ca 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -924,6 +924,17 @@ export default withEnglishFallback({ bulkEditDescription: "選択した{count}セルにこの値を設定します。", bulkEditValuePlaceholder: "値、またはNULL", applyBulkEdit: "適用", + generateValue: "値を生成", + generateEmptyString: "空文字列", + generateNull: "NULL", + generateCurrentDatetime: "現在の日時", + generateCurrentDate: "現在の日付", + generateUuid: "UUID", + generateIncrementId: "連番ID", + generateSnowflakeId: "Snowflake ID", + generateSequenceDescription: "選択した{count}セルに連続値を生成します。開始値を入力してください。", + generateStartInvalid: "開始値は整数で入力してください", + generatedValuesApplied: "{count}個の値を生成しました", cellDetails: "セル詳細", cellDetailLayoutBottom: "下部に表示", cellDetailLayoutRight: "右側に表示", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 1576223a1..a12a69513 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -929,6 +929,17 @@ export default withEnglishFallback({ bulkEditDescription: "Definir {count} célula(s) selecionada(s) para este valor.", bulkEditValuePlaceholder: "Valor, ou NULL", applyBulkEdit: "Aplicar", + generateValue: "Gerar valor", + generateEmptyString: "String vazia", + generateNull: "NULL", + generateCurrentDatetime: "Data e hora atuais", + generateCurrentDate: "Data atual", + generateUuid: "UUID", + generateIncrementId: "ID incremental", + generateSnowflakeId: "ID Snowflake", + generateSequenceDescription: "Gere valores consecutivos para {count} célula(s) selecionada(s). Informe o valor inicial.", + generateStartInvalid: "O valor inicial deve ser um número inteiro", + generatedValuesApplied: "Foram gerados {count} valor(es)", cellDetails: "Detalhes da Célula", cellDetailLayoutBottom: "Mover para Baixo", cellDetailLayoutRight: "Mover para a Direita", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 1e1e20f56..44bc7a1cf 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -988,6 +988,17 @@ export default withEnglishFallback({ bulkEditDescription: "将已选 {count} 个单元格设置为这个值。", bulkEditValuePlaceholder: "输入值,或 NULL", applyBulkEdit: "应用", + generateValue: "生成值", + generateEmptyString: "空字符串", + generateNull: "NULL", + generateCurrentDatetime: "当前日期时间", + generateCurrentDate: "当前日期", + generateUuid: "UUID", + generateIncrementId: "递增 ID", + generateSnowflakeId: "雪花 ID", + generateSequenceDescription: "为已选 {count} 个单元格生成连续值,请输入起始值。", + generateStartInvalid: "起始值必须是整数", + generatedValuesApplied: "已生成 {count} 个值", cellDetails: "单元格详情", cellDetailLayoutBottom: "移动到底部", cellDetailLayoutRight: "移动到右侧", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 248c294fb..574a71341 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -929,6 +929,17 @@ export default withEnglishFallback({ bulkEditDescription: "將已選 {count} 個儲存格設定為這個值。", bulkEditValuePlaceholder: "輸入值,或 NULL", applyBulkEdit: "套用", + generateValue: "產生值", + generateEmptyString: "空字串", + generateNull: "NULL", + generateCurrentDatetime: "目前日期時間", + generateCurrentDate: "目前日期", + generateUuid: "UUID", + generateIncrementId: "遞增 ID", + generateSnowflakeId: "雪花 ID", + generateSequenceDescription: "為已選 {count} 個儲存格產生連續值,請輸入起始值。", + generateStartInvalid: "起始值必須是整數", + generatedValuesApplied: "已產生 {count} 個值", cellDetails: "儲存格詳情", cellDetailLayoutBottom: "移到底部", cellDetailLayoutRight: "移到右側", diff --git a/apps/desktop/src/lib/__tests__/dataGrid/cellValueGeneration.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/cellValueGeneration.spec.ts new file mode 100644 index 000000000..93ff7e53b --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/cellValueGeneration.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { createSnowflakeIdGenerator, generateCellValues } from "@/lib/dataGrid/cellValueGeneration"; + +describe("generateCellValues", () => { + it("generates empty strings and nulls", () => { + expect(generateCellValues("empty", 2)).toEqual(["", ""]); + expect(generateCellValues("null", 2)).toEqual([null, null]); + }); + + it("uses one local timestamp for the whole selection", () => { + const now = new Date(2026, 6, 16, 9, 8, 7); + expect(generateCellValues("datetime", 2, { now })).toEqual(["2026-07-16 09:08:07", "2026-07-16 09:08:07"]); + expect(generateCellValues("date", 2, { now })).toEqual(["2026-07-16", "2026-07-16"]); + }); + + it("generates one UUID per cell", () => { + let index = 0; + expect(generateCellValues("uuid", 3, { uuidFactory: () => `uuid-${++index}` })).toEqual(["uuid-1", "uuid-2", "uuid-3"]); + }); + + it("increments values without losing bigint precision", () => { + expect(generateCellValues("increment", 3, { startValue: 9_007_199_254_740_993n })).toEqual(["9007199254740993", "9007199254740994", "9007199254740995"]); + }); + + it("keeps snowflake IDs unique and ordered beyond one sequence window", () => { + const generator = createSnowflakeIdGenerator({ workerId: 7, now: () => 1_800_000_000_000 }); + const values = generateCellValues("snowflake", 5000, { now: new Date(1_800_000_000_000), snowflakeGenerator: generator }) as string[]; + expect(new Set(values).size).toBe(values.length); + expect(values.every((value, index) => index === 0 || BigInt(value) > BigInt(values[index - 1]))).toBe(true); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridCellCoercion.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridCellCoercion.spec.ts index 42832db5c..85485aa86 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridCellCoercion.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridCellCoercion.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { dataGridCellDisplayText } from "@/lib/dataGrid/dataGridCellCoercion"; +import { coerceDataGridCellValue, dataGridCellDisplayText } from "@/lib/dataGrid/dataGridCellCoercion"; describe("dataGridCellDisplayText", () => { it("formats Oracle DATE values without RFC3339 separators", () => { @@ -32,3 +32,17 @@ describe("dataGridCellDisplayText", () => { ).toBeUndefined(); }); }); + +describe("coerceDataGridCellValue", () => { + it("preserves an explicitly generated empty string for a null cell", () => { + const options = { + value: "", + oldValue: null, + databaseType: "mysql" as const, + columnInfo: { data_type: "varchar(255)" }, + }; + + expect(coerceDataGridCellValue(options)).toBeNull(); + expect(coerceDataGridCellValue({ ...options, preserveEmptyString: true })).toBe(""); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/cellValueGeneration.ts b/apps/desktop/src/lib/dataGrid/cellValueGeneration.ts new file mode 100644 index 000000000..bcd76866f --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/cellValueGeneration.ts @@ -0,0 +1,77 @@ +import { uuid } from "@/lib/common/utils"; + +export type CellValueGenerationKind = "empty" | "null" | "datetime" | "date" | "uuid" | "increment" | "snowflake"; + +export interface SnowflakeIdGenerator { + next(nowMs?: number): string; +} + +const SNOWFLAKE_EPOCH_MS = 1_609_459_200_000; +const SNOWFLAKE_MAX_SEQUENCE = 4095n; + +export function createSnowflakeIdGenerator(options: { workerId?: number; now?: () => number } = {}): SnowflakeIdGenerator { + const workerId = options.workerId ?? Math.floor(Math.random() * 1024); + if (!Number.isInteger(workerId) || workerId < 0 || workerId > 1023) throw new RangeError("Snowflake workerId must be between 0 and 1023"); + + const now = options.now ?? Date.now; + let lastTimestampMs = -1; + let sequence = 0n; + + return { + next(nowMs = now()) { + let timestampMs = Math.max(Math.trunc(nowMs), SNOWFLAKE_EPOCH_MS); + if (timestampMs < lastTimestampMs) timestampMs = lastTimestampMs; + if (timestampMs === lastTimestampMs) { + sequence += 1n; + if (sequence > SNOWFLAKE_MAX_SEQUENCE) { + timestampMs += 1; + sequence = 0n; + } + } else { + sequence = 0n; + } + lastTimestampMs = timestampMs; + return (((BigInt(timestampMs) - BigInt(SNOWFLAKE_EPOCH_MS)) << 22n) | (BigInt(workerId) << 12n) | sequence).toString(); + }, + }; +} + +const defaultSnowflakeIdGenerator = createSnowflakeIdGenerator(); + +export function generateCellValues( + kind: CellValueGenerationKind, + count: number, + options: { + now?: Date; + startValue?: bigint; + uuidFactory?: () => string; + snowflakeGenerator?: SnowflakeIdGenerator; + } = {}, +): Array { + const size = Math.max(0, Math.trunc(count)); + const now = options.now ?? new Date(); + const uuidFactory = options.uuidFactory ?? uuid; + const snowflakeGenerator = options.snowflakeGenerator ?? defaultSnowflakeIdGenerator; + + return Array.from({ length: size }, (_, index) => { + if (kind === "null") return null; + if (kind === "empty") return ""; + if (kind === "datetime") return localDateTimeText(now); + if (kind === "date") return localDateText(now); + if (kind === "uuid") return uuidFactory(); + if (kind === "increment") return String((options.startValue ?? 1n) + BigInt(index)); + return snowflakeGenerator.next(now.getTime()); + }); +} + +function padDatePart(value: number): string { + return String(value).padStart(2, "0"); +} + +function localDateTimeText(date: Date): string { + return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())} ${padDatePart(date.getHours())}:${padDatePart(date.getMinutes())}:${padDatePart(date.getSeconds())}`; +} + +function localDateText(date: Date): string { + return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())}`; +} diff --git a/apps/desktop/src/lib/dataGrid/dataGridCellCoercion.ts b/apps/desktop/src/lib/dataGrid/dataGridCellCoercion.ts index 84b2e2148..d60d95b71 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridCellCoercion.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridCellCoercion.ts @@ -6,12 +6,13 @@ export interface CoerceDataGridCellValueOptions { oldValue: GridCellValue | undefined; databaseType: DatabaseType | undefined; columnInfo: Pick | undefined; + preserveEmptyString?: boolean; } export function coerceDataGridCellValue(options: CoerceDataGridCellValueOptions): GridCellValue { const { value, oldValue } = options; if (value.toUpperCase() === "NULL") return null; - if (value === "" && oldValue === null) return null; + if (value === "" && oldValue === null && !options.preserveEmptyString) return null; const postgresArrayValue = coercePostgresArrayValue(options); if (postgresArrayValue !== undefined) return postgresArrayValue; if (typeof oldValue === "number") { diff --git a/apps/desktop/src/lib/dataGrid/dataGridContextMenu.ts b/apps/desktop/src/lib/dataGrid/dataGridContextMenu.ts index da666cc03..bcb9e351c 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridContextMenu.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridContextMenu.ts @@ -116,6 +116,7 @@ export function createDataGridCellContextMenuItems(options: { downloadItem?: DataGridContextMenuItem | null; copySubmenu: DataGridContextMenuItem; selectionSubmenu: DataGridContextMenuItem; + generateSubmenu?: DataGridContextMenuItem; }): DataGridContextMenuItem[] { const items: DataGridContextMenuItem[] = []; if (options.hasCell) { @@ -130,6 +131,7 @@ export function createDataGridCellContextMenuItems(options: { if (options.editable && options.hasCellSelection) { if (!options.headerColumn) items.push({ label: options.labels.setNull, action: options.actions.setNull, disabled: !options.hasEditableSelection, icon: options.icons.setNull }); items.push({ label: options.labels.bulkEdit, action: options.actions.bulkEdit, disabled: !options.hasEditableSelection, icon: options.icons.bulkEdit }); + if (options.generateSubmenu) items.push(options.generateSubmenu); } if (options.hasCell) items.push({ label: options.labels.transpose, action: options.actions.transpose, icon: options.icons.transpose }); if (options.hasSelection) items.push(options.selectionSubmenu); diff --git a/packages/app-tests/dataGridContextMenu.test.ts b/packages/app-tests/dataGridContextMenu.test.ts index 49827e2cf..843bc375b 100644 --- a/packages/app-tests/dataGridContextMenu.test.ts +++ b/packages/app-tests/dataGridContextMenu.test.ts @@ -23,7 +23,7 @@ test("set NULL applies a real null value only to editable selections", () => { assert.doesNotMatch(handler, /fillSelectionWithValue\(["'](?:NULL)?["']\)/); }); -test("editable cell selections expose set NULL before bulk edit", () => { +test("editable cell selections expose generation after bulk edit", () => { const icon = {}; const action = () => {}; const items = createDataGridCellContextMenuItems({ @@ -39,6 +39,7 @@ test("editable cell selections expose set NULL before bulk edit", () => { actions: { cellDetails: action, columnDetails: action, rowDetails: action, setNull: action, bulkEdit: action, transpose: action }, copySubmenu: { label: "copy" }, selectionSubmenu: { label: "selection" }, + generateSubmenu: { label: "generate", disabled: true }, }); assert.deepEqual( @@ -47,6 +48,7 @@ test("editable cell selections expose set NULL before bulk edit", () => { { label: "copy", disabled: undefined }, { label: "set null", disabled: true }, { label: "bulk edit", disabled: true }, + { label: "generate", disabled: true }, ], ); }); diff --git a/packages/app-tests/dataGridEditor.test.ts b/packages/app-tests/dataGridEditor.test.ts index ad79b720d..aab888fa6 100644 --- a/packages/app-tests/dataGridEditor.test.ts +++ b/packages/app-tests/dataGridEditor.test.ts @@ -665,6 +665,25 @@ test("setting an existing NULL cell to NULL is a no-op", async () => { assert.deepEqual(await editor.previewChanges(), []); }); +test("generated empty strings remain distinct from NULL cells", async () => { + setActivePinia(createPinia()); + installBrowserTestGlobals(); + + const result = computed(() => ({ + columns: ["id", "name"], + rows: [[1, null] as CellValue[]], + })); + const editor = createPeopleGridEditor(result); + + editor.applyCellValue(0, 1, ""); + assert.equal(editor.dirtyRows.value.size, 0); + + editor.applyCellValue(0, 1, "", { preserveEmptyString: true }); + assert.equal(editor.dirtyRows.value.get(0)?.get(1), ""); + assert.deepEqual(editor.rowDataWithChanges(result.value.rows[0], 0), [1, ""]); + assert.deepEqual(await editor.previewChanges(), [`UPDATE "people" SET "name" = '' WHERE "id" = 1;`]); +}); + test("a failed NULL save keeps the pending cell edit", async () => { setActivePinia(createPinia()); installBrowserTestGlobals();