feat(grid): add generated cell values

This commit is contained in:
zipg 2026-07-16 18:43:49 +08:00 committed by GitHub
parent 9efdf79ad9
commit 97d15b3e2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 376 additions and 13 deletions

View File

@ -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<string | null>(null);
const contextHeaderColumnIndex = ref<number | null>(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<ContextMenuItem[]>(() => {
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<ContextMenuItem[]>(() => {
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<ContextMenuItem[]>(() => {
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<ContextMenuItem[]>(() => {
<div v-else ref="valueEditorContainer" data-cell-detail-editor-root class="min-h-0 flex-1 w-full rounded border overflow-auto" />
</div>
<div class="flex gap-1 mt-2 shrink-0">
<DropdownMenu v-if="activeCellDetail?.isEditable">
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="h-6 gap-1 text-xs" @mousedown.prevent>
<WandSparkles class="h-3 w-3" />
{{ t("grid.generateValue") }}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" class="w-44">
<DropdownMenuItem @click="applyGeneratedDetailValue('empty')">{{ t("grid.generateEmptyString") }}</DropdownMenuItem>
<DropdownMenuItem @click="applyGeneratedDetailValue('null')">{{ t("grid.generateNull") }}</DropdownMenuItem>
<DropdownMenuItem @click="applyGeneratedDetailValue('datetime')">{{ t("grid.generateCurrentDatetime") }}</DropdownMenuItem>
<DropdownMenuItem @click="applyGeneratedDetailValue('date')">{{ t("grid.generateCurrentDate") }}</DropdownMenuItem>
<DropdownMenuItem @click="applyGeneratedDetailValue('uuid')">{{ t("grid.generateUuid") }}</DropdownMenuItem>
<DropdownMenuItem @click="applyGeneratedDetailValue('snowflake')">{{ t("grid.generateSnowflakeId") }}</DropdownMenuItem>
<DropdownMenuItem @click="openGenerateIncrementDialog('detail')">{{ t("grid.generateIncrementId") }}</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button v-if="activeValueEditorActions.includes('formatJson')" variant="outline" size="sm" class="h-6 text-xs" @mousedown.prevent @click="formatValueEditorJson">
{{ t("grid.formatJson") }}
</Button>
@ -8734,6 +8851,24 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<DataGridBulkEditDialog v-if="bulkEditDialogMounted" v-model:open="bulkEditDialogOpen" v-model:value="bulkEditValue" :selected-cell-count="selectedCellCount" @apply="applyBulkEditValue" />
<Dialog v-model:open="generateIncrementDialogOpen">
<DialogContent class="sm:max-w-[380px]">
<DialogHeader>
<DialogTitle>{{ t("grid.generateIncrementId") }}</DialogTitle>
</DialogHeader>
<div class="space-y-2">
<p class="text-sm text-muted-foreground">
{{ t("grid.generateSequenceDescription", { count: generateIncrementTarget === "detail" ? 1 : editableSelectionCells().length }) }}
</p>
<Input v-model="generateIncrementStartValue" inputmode="numeric" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false" placeholder="1" @keydown.enter.prevent="applyGenerateIncrementValue" />
</div>
<DialogFooter>
<Button variant="outline" @click="generateIncrementDialogOpen = false">{{ t("dangerDialog.cancel") }}</Button>
<Button @click="applyGenerateIncrementValue">{{ t("grid.applyBulkEdit") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- SQL Preview panel for pending data changes -->
<div v-if="showSqlPreview" class="h-52 shrink-0 border-t">
<SqlPreviewPanel :sql="previewSqlText" :loading="isPreviewLoading" :can-undo="canUndoPendingChange" :can-redo="canRedoPendingChange" @undo="undoGridChange" @redo="redoGridChange" @close="closeSqlPreview" />

View File

@ -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();

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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: "右側に表示",

View File

@ -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",

View File

@ -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: "移动到右侧",

View File

@ -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: "移到右側",

View File

@ -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);
});
});

View File

@ -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("");
});
});

View File

@ -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<string | null> {
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())}`;
}

View File

@ -6,12 +6,13 @@ export interface CoerceDataGridCellValueOptions {
oldValue: GridCellValue | undefined;
databaseType: DatabaseType | undefined;
columnInfo: Pick<ColumnInfo, "data_type"> | 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") {

View File

@ -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);

View File

@ -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 },
],
);
});

View File

@ -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();