feat(grid): add generated cell values
This commit is contained in:
parent
9efdf79ad9
commit
97d15b3e2d
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "右側に表示",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "移动到右侧",
|
||||
|
|
|
|||
|
|
@ -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: "移到右側",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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())}`;
|
||||
}
|
||||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue