feat(grid): add data grid quick entry

This commit is contained in:
funnytime 2026-06-30 09:14:44 +08:00 committed by GitHub
parent 074cef8358
commit 3e7cf30ce5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1679 additions and 186 deletions

View File

@ -369,6 +369,7 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
const customSaveHandler = computed<CustomSaveHandler>(() => ({
save: gridSave,
preview: previewDocumentChanges,
supportsInsert: true,
}));
function stopDocumentLoadingTimer() {

View File

@ -203,6 +203,7 @@ const debugLogDownloaded = ref(false);
const editShowColumnCommentsInHeader = ref(settingsStore.editorSettings.showColumnCommentsInHeader);
const editShowColumnTypesInHeader = ref(settingsStore.editorSettings.showColumnTypesInHeader);
const editCompactColumnHeaderActions = ref(settingsStore.editorSettings.compactColumnHeaderActions);
const editDataGridQuickEntry = ref(settingsStore.editorSettings.dataGridQuickEntry);
const editInfiniteScroll = ref(settingsStore.editorSettings.infiniteScroll);
const editInfiniteScrollMaxRows = ref(settingsStore.editorSettings.infiniteScrollMaxRows);
const editTableColumnTemplateRows = ref<TableColumnTemplateGridRow[]>(tableColumnTemplateRowsFromSettings(settingsStore.editorSettings.tableColumnTemplateFields));
@ -477,6 +478,7 @@ watch(
editShowColumnCommentsInHeader.value = settingsStore.editorSettings.showColumnCommentsInHeader;
editShowColumnTypesInHeader.value = settingsStore.editorSettings.showColumnTypesInHeader;
editCompactColumnHeaderActions.value = settingsStore.editorSettings.compactColumnHeaderActions;
editDataGridQuickEntry.value = settingsStore.editorSettings.dataGridQuickEntry;
editInfiniteScroll.value = settingsStore.editorSettings.infiniteScroll;
editInfiniteScrollMaxRows.value = settingsStore.editorSettings.infiniteScrollMaxRows;
editTableColumnTemplateRows.value = tableColumnTemplateRowsFromSettings(settingsStore.editorSettings.tableColumnTemplateFields);
@ -540,6 +542,7 @@ function hasChanges(): boolean {
editShowColumnCommentsInHeader.value !== settingsStore.editorSettings.showColumnCommentsInHeader ||
editShowColumnTypesInHeader.value !== settingsStore.editorSettings.showColumnTypesInHeader ||
editCompactColumnHeaderActions.value !== settingsStore.editorSettings.compactColumnHeaderActions ||
editDataGridQuickEntry.value !== settingsStore.editorSettings.dataGridQuickEntry ||
editInfiniteScroll.value !== settingsStore.editorSettings.infiniteScroll ||
editInfiniteScrollMaxRows.value !== settingsStore.editorSettings.infiniteScrollMaxRows ||
JSON.stringify(normalizedEditTableColumnTemplateFields.value) !== JSON.stringify(settingsStore.editorSettings.tableColumnTemplateFields) ||
@ -584,6 +587,7 @@ async function persistSettings() {
showColumnCommentsInHeader: editShowColumnCommentsInHeader.value,
showColumnTypesInHeader: editShowColumnTypesInHeader.value,
compactColumnHeaderActions: editCompactColumnHeaderActions.value,
dataGridQuickEntry: editDataGridQuickEntry.value,
infiniteScroll: editInfiniteScroll.value,
infiniteScrollMaxRows: editInfiniteScrollMaxRows.value,
tableColumnTemplateFields: normalizedEditTableColumnTemplateFields.value,
@ -670,6 +674,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
editShowColumnCommentsInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader;
editShowColumnTypesInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader;
editCompactColumnHeaderActions.value = DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions;
editDataGridQuickEntry.value = DEFAULT_EDITOR_SETTINGS.dataGridQuickEntry;
editInfiniteScroll.value = DEFAULT_EDITOR_SETTINGS.infiniteScroll;
editInfiniteScrollMaxRows.value = DEFAULT_EDITOR_SETTINGS.infiniteScrollMaxRows;
editTableColumnTemplateRows.value = tableColumnTemplateRowsFromSettings(DEFAULT_EDITOR_SETTINGS.tableColumnTemplateFields);
@ -708,6 +713,7 @@ function resetAllDefaults() {
editShowColumnCommentsInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader;
editShowColumnTypesInHeader.value = DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader;
editCompactColumnHeaderActions.value = DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions;
editDataGridQuickEntry.value = DEFAULT_EDITOR_SETTINGS.dataGridQuickEntry;
editInfiniteScroll.value = DEFAULT_EDITOR_SETTINGS.infiniteScroll;
editInfiniteScrollMaxRows.value = DEFAULT_EDITOR_SETTINGS.infiniteScrollMaxRows;
editTableColumnTemplateRows.value = tableColumnTemplateRowsFromSettings(DEFAULT_EDITOR_SETTINGS.tableColumnTemplateFields);
@ -2323,6 +2329,17 @@ watch(
</div>
<Switch id="compact-column-header-actions" v-model="editCompactColumnHeaderActions" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="data-grid-quick-entry">
{{ t("settings.dataGridQuickEntry") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.dataGridQuickEntryDescription") }}
</p>
</div>
<Switch id="data-grid-quick-entry" v-model="editDataGridQuickEntry" />
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="infinite-scroll">

View File

@ -92,7 +92,7 @@ import type { BuildSingleColumnAlterSqlOptions } from "@/lib/tableStructureEdito
import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql";
import { uuid } from "@/lib/utils";
import { resolveHeaderColumnType } from "@/lib/dataGridColumnType";
import { canEditExistingTableRows, canUseKeylessRowPredicate, hiveTablePropertiesIndicateTransactional, isClickHouseExistingRowReadonlyColumn, isHiddenGridColumn, isTdengineExistingRowReadonlyColumn, usesSyntheticRowIdKey } from "@/lib/tableEditing";
import { canEditExistingTableRows, canInsertTableRows, canUseKeylessRowPredicate, hiveTablePropertiesIndicateTransactional, isClickHouseExistingRowReadonlyColumn, isHiddenGridColumn, isTdengineExistingRowReadonlyColumn, usesSyntheticRowIdKey } from "@/lib/tableEditing";
import { buildDataGridColumnDistinctValuesSql, buildDataGridContextFilterCondition, buildDataGridCountSql, buildHiveTablePropertiesSql, type DataGridContextFilterMode } from "@/lib/dataGridSql";
import {
buildVisibleTransposeRows,
@ -106,7 +106,7 @@ import {
transposeScrollLeftForRecord,
visibleTransposeRecordWindow,
} from "@/lib/dataGridTranspose";
import { matchesRowStatusFilter, type RowStatus, type RowStatusFilter } from "@/lib/gridRowStatus";
import { canApplyGridSelectionValue, canDeleteGridRowItem, canEditGridCellDetail, matchesRowStatusFilter, shouldShowQuickEntryDraftRow, type RowStatus, type RowStatusFilter } from "@/lib/gridRowStatus";
import { displayCellValue, type CellValue } from "@/lib/cellValue";
import { getApplicablePreviewActions } from "@/lib/resultPreviewRegistry";
import "@/lib/previewHandlers/geometryMapPreview";
@ -2710,7 +2710,13 @@ type DataGridTableMeta = NonNullable<typeof props.tableMeta>;
const hiveTableTransactional = ref<boolean | undefined>(undefined);
const canEditExistingRows = computed(() => !!props.customSaveHandler || canEditExistingTableRows(props.databaseType, hiveTableTransactional.value, props.tableMeta?.primaryKeys ?? []));
const customReadonlyColumns = computed(() => new Set((props.customSaveHandler?.readonlyColumns ?? []).map((column) => column.toLowerCase())));
const canInsertRows = computed(() => !props.customSaveHandler || props.customSaveHandler.canInsert !== false);
const hasDataGridSaveTarget = computed(() => !!props.tableMeta || !!props.customSaveHandler);
const hasDataGridInsertTarget = computed(() => {
const handler = props.customSaveHandler;
if (handler) return handler.supportsInsert === true || handler.canInsert === true;
return !!props.tableMeta && canInsertTableRows(props.databaseType);
});
const canInsertRows = computed(() => !!props.editable && hasDataGridInsertTarget.value);
const canDeleteRows = computed(() => !props.customSaveHandler || props.customSaveHandler.canDelete !== false);
watch(
() => [props.databaseType, props.connectionId, props.database, props.tableMeta?.schema, props.tableMeta?.tableName],
@ -2936,6 +2942,7 @@ interface RowItem {
newIndex?: number;
data: CellValue[];
isNew: boolean;
isDraft?: boolean;
isDeleted: boolean;
isDirtyCol: boolean[];
status: RowStatus;
@ -2957,6 +2964,14 @@ type DisplayRowRef =
isNew: true;
isDeleted: false;
status: RowStatus;
}
| {
id: number;
displayIndex: number;
isNew: false;
isDraft: true;
isDeleted: false;
status: RowStatus;
};
const editor = useDataGridEditor({
@ -2976,6 +2991,7 @@ const editor = useDataGridEditor({
currentWhereInput: computed(() => currentWhereInput()),
orderByInput,
rowStatusFilter,
dataGridQuickEntryEnabled: computed(() => settingsStore.editorSettings.dataGridQuickEntry),
initialEditColumn: firstVisibleColumnIndex,
getRowItem,
pageSize,
@ -2991,6 +3007,8 @@ const {
dirtyRows,
newRows,
deletedRows,
quickEntryDraftRow,
quickEntryDraftRowId,
pendingChangesVersion,
pendingChangeCount,
hasPendingChanges,
@ -3001,6 +3019,7 @@ const {
exitTransaction,
startEdit,
commitEdit,
commitEditAndMaybeAutoSave,
commitEditFromBlur,
applyCellValue,
restoreCellValue,
@ -3023,6 +3042,8 @@ const {
undoPendingChange,
redoPendingChange,
rowDataWithChanges,
ensureQuickEntryDraftRow,
isSavingNewRow,
canEditColumn,
resetGridVerticalScroll,
getResetScrollAfterResult,
@ -3032,6 +3053,7 @@ const {
isPreviewLoading,
previewChanges,
} = editor;
const pendingQuickEntryDraftCellFocus = ref<{ rowId: number; col: number } | null>(null);
const showSqlPreview = ref(false);
const previewSqlText = ref("");
@ -3090,34 +3112,45 @@ const saveActionMode = computed(() =>
const saveToolbarState = computed(() =>
dataGridSaveToolbarState({
editable: props.editable,
hasSaveTarget: !!props.tableMeta || !!props.customSaveHandler,
hasSaveTarget: hasDataGridSaveTarget.value,
hasPendingChanges: hasPendingChanges.value,
isSaving: isSaving.value,
}),
);
const hasSearchBarSlot = computed(() => !!slots["search-bar"]);
const quickEntryEnabled = computed(() => settingsStore.editorSettings.dataGridQuickEntry);
const showQuickEntryDraftRow = computed(() =>
shouldShowQuickEntryDraftRow({
editable: !!props.editable,
hasInsertTarget: hasDataGridInsertTarget.value,
quickEntryEnabled: quickEntryEnabled.value,
rowStatusFilter: rowStatusFilter.value,
hasPendingChanges: hasPendingChanges.value,
}),
);
const showDataGridTopbar = computed(
() =>
(useTransaction.value && !!props.editable && (!!props.tableMeta || !!props.customSaveHandler)) ||
(useTransaction.value && !!props.editable && hasDataGridSaveTarget.value) ||
hasLocalColumnFilters.value ||
canShowWhereSearch.value ||
hasSearchBarSlot.value ||
showQueryEditReadyBadge.value ||
props.context !== "results" ||
(!!props.editable && (!!props.tableMeta || !!props.customSaveHandler)) ||
(!!props.editable && hasDataGridSaveTarget.value) ||
transactionActive.value ||
saveToolbarState.value.showActions,
);
function canEditRowItem(item: RowItem | undefined): boolean {
return !!props.editable && !!item && !item.isDeleted && (item.isNew || canEditExistingRows.value);
return !!props.editable && !!item && !item.isDeleted && (item.isNew || item.isDraft || canEditExistingRows.value);
}
function canEditCellItem(item: RowItem | undefined, columnIndex: number): boolean {
if (!canEditRowItem(item) || !canEditColumn(columnIndex)) return false;
if (isSavingNewRow(item)) return false;
const column = props.result.columns[columnIndex] ?? "";
if (customReadonlyColumns.value.has(column.toLowerCase())) return false;
if (!item?.isNew) {
if (!item?.isNew && !item?.isDraft) {
const sourceColumn = props.sourceColumns?.[columnIndex] ?? column;
if (isClickHouseExistingRowReadonlyColumn(props.databaseType, sourceColumn, props.tableMeta?.primaryKeys ?? [], props.tableMeta?.columns ?? [])) return false;
if (isTdengineExistingRowReadonlyColumn(props.databaseType, column, props.tableMeta?.columns ?? [])) return false;
@ -3217,7 +3250,15 @@ function isDecimalColumnType(dataType: string): boolean {
}
function canDeleteRowItem(item: RowItem | undefined): boolean {
return !!props.editable && canDeleteRows.value && !!item && !item.isDeleted && (item.isNew || canEditExistingRows.value);
if (!item) return false;
return canDeleteGridRowItem({
editable: !!props.editable && canDeleteRows.value,
isDraft: !!item.isDraft,
isDeleted: item.isDeleted,
isNew: item.isNew,
canEditExistingRows: canEditExistingRows.value,
isSavingNewRow: isSavingNewRow(item),
});
}
function resetInfiniteScrollState() {
@ -3292,7 +3333,8 @@ const displayRowRefs = computed<DisplayRowRef[]>(() => {
const dirty = dirtyRows.value.get(sourceIndex);
const isDeleted = deletedRows.value.has(sourceIndex);
const status: RowStatus = isDeleted ? "deleted" : dirty?.size ? "edited" : "clean";
if (matchesRowStatusFilter(status, rowStatusFilter.value)) {
const isActiveEditingRow = quickEntryEnabled.value && editingCell.value?.rowId === sourceIndex;
if (matchesRowStatusFilter(status, rowStatusFilter.value) || isActiveEditingRow) {
refs.push({ id: sourceIndex, displayIndex: refs.length, sourceIndex, isNew: false, isDeleted, status });
}
}
@ -3309,6 +3351,17 @@ const displayRowRefs = computed<DisplayRowRef[]>(() => {
status,
});
});
if (showQuickEntryDraftRow.value) {
ensureQuickEntryDraftRow();
refs.push({
id: quickEntryDraftRowId,
displayIndex: refs.length,
isNew: false,
isDraft: true,
isDeleted: false,
status: "draft",
});
}
return refs;
});
@ -3322,6 +3375,14 @@ function rowItemFromDisplayRef(ref: DisplayRowRef): RowItem {
isDirtyCol: cleanDirtyColumns.value,
};
}
if (!("sourceIndex" in ref)) {
ensureQuickEntryDraftRow();
return {
...ref,
data: quickEntryDraftRow.value,
isDirtyCol: cleanDirtyColumns.value,
};
}
const row = props.result.rows[ref.sourceIndex] ?? [];
const dirty = dirtyRows.value.get(ref.sourceIndex);
return {
@ -3606,40 +3667,57 @@ function cellDetailButtonVisible(rowIndex: number, actualColIdx: number) {
function affectedRowIds(): number[] {
if (hasRowSelection.value && selectedRowCount.value > 0) {
return [...selectedRowIds.value];
return [...selectedRowIds.value].filter((rowId) => !getRowItem(rowId)?.isDraft);
}
const range = selectedRange.value;
if (range && range.startRow !== range.endRow) {
return displayRowRefs.value.slice(range.startRow, range.endRow + 1).map((ref) => ref.id);
return displayRowRefs.value
.slice(range.startRow, range.endRow + 1)
.filter((ref) => !("isDraft" in ref && ref.isDraft))
.map((ref) => ref.id);
}
return [];
}
function deletableRowIds(rowIds: number[]): number[] {
return rowIds.filter((rowId) => canDeleteRowItem(getRowItem(rowId)));
}
function exportSelectedRowsCsv() {
return exportCsv(affectedRowIds());
const rowIds = affectedRowIds();
if (rowIds.length === 0) return;
return exportCsv(rowIds);
}
function exportSelectedRowsXlsx() {
return exportXlsx(affectedRowIds());
const rowIds = affectedRowIds();
if (rowIds.length === 0) return;
return exportXlsx(rowIds);
}
function exportSelectedRowsJson() {
return exportJson(affectedRowIds());
const rowIds = affectedRowIds();
if (rowIds.length === 0) return;
return exportJson(rowIds);
}
function exportSelectedRowsMarkdown() {
return exportMarkdown(affectedRowIds());
const rowIds = affectedRowIds();
if (rowIds.length === 0) return;
return exportMarkdown(rowIds);
}
function exportSelectedRowsSql() {
return exportSql(affectedRowIds());
const rowIds = affectedRowIds();
if (rowIds.length === 0) return;
return exportSql(rowIds);
}
function executePreviewAction(action: { execute: (ctx: any) => any }) {
const config = action.execute({
result: props.result,
selectedRowIds: affectedRowIds(),
displayRowRefs: displayRowRefs.value,
displayRowRefs: displayRowRefs.value.filter((ref) => "sourceIndex" in ref),
});
if (config) {
previewDialogConfig.value = config;
@ -3685,7 +3763,7 @@ function cellDetailFor(rowIndex: number, columnIndex: number): DataGridCellDetai
resultColumnTypes: props.result.column_types,
commentByColumn: columnCommentMap.value,
displayValue: (value, index) => formatCellCached(value, index),
isEditable: canEditCellItem(item, columnIndex),
isEditable: canEditGridCellDetail({ canEditCell: canEditCellItem(item, columnIndex), isDraft: !!item.isDraft }),
});
}
@ -3713,7 +3791,7 @@ const rowDetail = computed(() => {
resultColumnTypes: props.result.column_types,
commentByColumn: columnCommentMap.value,
displayValue: (value, index) => formatCellCached(value, index),
isEditableColumn: (columnIndex) => canEditCellItem(item, columnIndex),
isEditableColumn: (columnIndex) => canEditGridCellDetail({ canEditCell: canEditCellItem(item, columnIndex), isDraft: !!item.isDraft }),
});
});
@ -3721,12 +3799,14 @@ const columnDetail = computed(() => {
if (columnDetailDialogColumnIndex.value === null) return null;
const columnIndex = columnDetailDialogColumnIndex.value;
return buildDataGridColumnDetail({
rows: displayItems.value.map((item) => ({
rowIndex: item.displayIndex,
rowId: item.id,
row: item.data,
isEditable: canEditCellItem(item, columnIndex),
})),
rows: displayItems.value
.filter((item) => !item.isDraft)
.map((item) => ({
rowIndex: item.displayIndex,
rowId: item.id,
row: item.data,
isEditable: canEditGridCellDetail({ canEditCell: canEditCellItem(item, columnIndex), isDraft: !!item.isDraft }),
})),
columns: props.result.columns,
columnIndex,
typeByColumn: columnTypeMap.value,
@ -4386,6 +4466,16 @@ function formatCellCached(value: CellValue, columnIndex?: number): string {
return rememberPrimitiveCellFormat(key, formatCell(value, columnIndex));
}
function rowNumberText(item: RowItem | undefined): string {
if (!item) return "";
if (item.isDraft) return "*";
return String(infiniteScrollEnabled.value ? item.displayIndex + 1 : item.displayIndex + 1 + (currentPage.value - 1) * pageSize.value);
}
function draftCellPlaceholder(item: RowItem | undefined, columnIndex: number): string | null {
return item?.isDraft && item.data[columnIndex] === null ? t("grid.quickEntryDraftPlaceholder") : null;
}
watch(() => [props.result.columns.join("\u0000"), columnFormatterSignatures.value.join("\u0000")], clearCellFormatCache);
function quoteIdent(name: string): string {
@ -4402,6 +4492,9 @@ function isNull(value: unknown): boolean {
}
function rowNumberStatusClass(item: RowItem): string {
if (item.status === "draft") {
return "border-muted-foreground/20 bg-muted/20 font-semibold text-muted-foreground";
}
if (item.status === "new") {
return "border-emerald-500/40 bg-emerald-500/15 font-semibold text-emerald-700 dark:text-emerald-300";
}
@ -4692,10 +4785,15 @@ function clearCanvasDetailHover(event?: MouseEvent) {
function onCanvasMouseDown(event: MouseEvent) {
if (event.button !== 0) return;
commitHiddenCanvasEditBeforeCellInteraction();
const hit = canvasHitTest(event);
if (!hit) return;
if (!hit) {
commitHiddenCanvasEditBeforeCellInteraction();
return;
}
const item = displayItemAt(hit.rowIndex);
const actualColIdx = hit.rowNumber ? undefined : visibleColumnIndexes.value[hit.visibleColIdx];
if (item && actualColIdx !== undefined) prepareDataCellMouseDown(item, actualColIdx);
commitHiddenCanvasEditBeforeCellInteraction();
if (!item) return;
if (hit.rowNumber) {
handleRowClick(item.displayIndex, item.id, event);
@ -4780,7 +4878,7 @@ function canvasEditingCellIsVisible() {
function commitHiddenCanvasEditBeforeCellInteraction() {
if (!editingCell.value || !useCanvasGridRows.value) return;
if (canvasEditingCellIsVisible()) return;
commitEdit();
void commitEditFromCellBlur();
}
const canvasEditingCell = computed(() => {
@ -4881,6 +4979,7 @@ function drawCanvasGrid() {
searchMatchKeys: searchMatchSet.value,
currentSearchMatch: currentSearchMatch.value,
formatCell: formatCellCached,
draftCellPlaceholder: t("grid.quickEntryDraftPlaceholder"),
isRowActive,
rowCellsUseSelectionVisual,
cellIsSelected,
@ -5299,6 +5398,7 @@ async function pasteClipboardIntoSelection() {
function pasteTextIntoSelection(text: string): boolean {
const rows = parseClipboardTable(text);
const allowDraftSelectionValue = selectedRangeTargetsOnlyDraftRow();
if (rows.length === 1 && rows[0]?.length === 1 && fillSelectionWithValue(rows[0][0])) {
toast(t("grid.pasted"));
@ -5314,7 +5414,7 @@ function pasteTextIntoSelection(text: string): boolean {
row.forEach((value, colOffset) => {
const visibleCol = start.colIndex + colOffset;
if (visibleCol >= visibleColumns.value.length) return;
applied = applyVisibleCellValue(item, visibleCol, value) || applied;
applied = applyVisibleSelectedCellValue(item, visibleCol, value, allowDraftSelectionValue) || applied;
});
});
if (applied) toast(t("grid.pasted"));
@ -5351,15 +5451,28 @@ function applyVisibleCellValue(item: RowItem, visibleCol: number, value: string
return true;
}
function applyVisibleSelectedCellValue(item: RowItem, visibleCol: number, value: string | null, allowDraft = selectedRangeTargetsOnlyDraftRow()): boolean {
if (!canApplyGridSelectionValue({ isDraft: !!item.isDraft, allowDraft })) return false;
return applyVisibleCellValue(item, visibleCol, value);
}
function selectedRangeTargetsOnlyDraftRow(): boolean {
const range = selectedRange.value;
if (!range) return false;
if (range.startRow !== range.endRow) return false;
return displayItemAt(range.startRow)?.isDraft === true;
}
function fillSelectionWithValue(value: string | null): boolean {
const range = selectedRange.value;
let applied = false;
const allowDraftSelectionValue = selectedRangeTargetsOnlyDraftRow();
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++) {
applied = applyVisibleCellValue(item, visibleCol, value) || applied;
applied = applyVisibleSelectedCellValue(item, visibleCol, value, allowDraftSelectionValue) || applied;
}
}
return applied;
@ -5372,7 +5485,7 @@ function fillSelectionWithValue(value: string | null): boolean {
const item = displayItemAt(rowIndex);
if (!item) continue;
for (const visibleCol of visibleColumnIndexes) {
applied = applyVisibleCellValue(item, visibleCol, value) || applied;
applied = applyVisibleSelectedCellValue(item, visibleCol, value) || applied;
}
}
return applied;
@ -5420,11 +5533,12 @@ function cutSelection() {
if (!props.editable || !selectedRange.value) return;
copySelectionTsv();
const range = selectedRange.value;
const allowDraftSelectionValue = selectedRangeTargetsOnlyDraftRow();
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++) {
applyCellValue(item.id, actualColumnIndex(visibleCol), null);
applyVisibleSelectedCellValue(item, visibleCol, null, allowDraftSelectionValue);
}
}
}
@ -5566,7 +5680,8 @@ function selectedOrCurrentRowIds(): number[] {
}
function copyCurrentRow(): boolean {
const rowIds = selectedOrCurrentRowIds();
if (!canInsertRows.value) return false;
const rowIds = selectedOrCurrentRowIds().filter((rowId) => !getRowItem(rowId)?.isDraft);
if (rowIds.length === 0) return false;
if (rowIds.length === 1) {
cloneRow(rowIds[0]);
@ -5580,19 +5695,46 @@ function deleteCurrentRow(): boolean {
const rowIds = selectedOrCurrentRowIds();
if (rowIds.length === 0) return false;
const deletableRowIds = rowIds.filter((rowId) => canDeleteRowItem(getRowItem(rowId)));
if (deletableRowIds.length === 0) return false;
if (deletableRowIds.length === 1) {
requestDeleteRow(deletableRowIds[0]);
const targetRowIds = deletableRowIds(rowIds);
if (targetRowIds.length === 0) return false;
if (targetRowIds.length === 1) {
requestDeleteRow(targetRowIds[0]);
return true;
}
requestDeleteRows(deletableRowIds);
requestDeleteRows(targetRowIds);
return true;
}
function commitGridEdit() {
commitEdit();
nextTick(() => gridRef.value?.focus({ preventScroll: true }));
void commitEditAndMaybeAutoSave().finally(() => nextTick(() => gridRef.value?.focus({ preventScroll: true })));
}
async function commitEditFromCellBlur() {
const target = pendingQuickEntryDraftCellFocus.value;
pendingQuickEntryDraftCellFocus.value = null;
if (target && editingCell.value?.rowId === quickEntryDraftRowId && target.rowId === quickEntryDraftRowId) {
await commitEditFromBlur({ promoteDraft: false });
nextTick(() => {
const item = getRowItem(target.rowId);
if (item && canEditCellItem(item, target.col)) startEdit(target.rowId, target.col);
});
return;
}
await commitEditFromBlur();
}
function prepareDataCellMouseDown(item: RowItem, actualColIdx: number) {
const editing = editingCell.value;
if (editing?.rowId === quickEntryDraftRowId && item.isDraft && item.id === quickEntryDraftRowId && editing.col !== actualColIdx) {
pendingQuickEntryDraftCellFocus.value = { rowId: item.id, col: actualColIdx };
} else {
pendingQuickEntryDraftCellFocus.value = null;
}
}
function prepareTransposeCellMouseDown(rowIndex: number, actualColIdx: number) {
const item = displayItemAt(rowIndex);
if (item) prepareDataCellMouseDown(item, actualColIdx);
}
async function saveGridChangesFromShortcut() {
@ -5662,7 +5804,7 @@ async function onGridKeydown(event: KeyboardEvent) {
return;
}
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key.toLowerCase() === "n") {
if (props.editable && (props.tableMeta || props.customSaveHandler) && canInsertRows.value) {
if (props.editable && hasDataGridInsertTarget.value && canInsertRows.value) {
event.preventDefault();
event.stopPropagation();
addRow();
@ -7130,7 +7272,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
if (props.editable && contextRowItem.value) {
const labels = rowActionLabels();
items.push({ label: "", separator: true });
if (canInsertRows.value) {
if (canInsertRows.value && !contextRowItem.value.isDraft) {
items.push({
label: labels.clone,
action: () => (isMultiRow.value ? cloneRows(affectedRowIds()) : cloneRow(contextRowItem.value!.id)),
@ -7146,7 +7288,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
} else if (canDeleteRowItem(contextRowItem.value)) {
items.push({
label: labels.delete,
action: () => (isMultiRow.value ? requestDeleteRows(affectedRowIds()) : requestDeleteRow(contextRowItem.value!.id)),
action: () => (isMultiRow.value ? requestDeleteRows(deletableRowIds(affectedRowIds())) : requestDeleteRow(contextRowItem.value!.id)),
icon: Trash2,
variant: "destructive" as const,
});
@ -7194,7 +7336,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
"
>
<div class="data-grid-topbar flex items-stretch relative" :class="{ 'data-grid-topbar--compact': compactDataGridToolbar }">
<div v-if="useTransaction && editable && (tableMeta || customSaveHandler)" class="flex items-center px-2 py-0.5 border-r shrink-0">
<div v-if="useTransaction && editable && hasDataGridSaveTarget" class="flex items-center px-2 py-0.5 border-r shrink-0">
<Select :model-value="rowStatusFilter" @update:model-value="(value: any) => setRowStatusFilter(String(value))">
<SelectTrigger class="h-5 max-w-28 border-0 bg-transparent px-0 py-0 text-xs font-medium text-foreground/70 shadow-none focus-visible:ring-0 data-[state=open]:text-foreground [&_svg]:size-3">
<SelectValue :placeholder="t('grid.filterRows')" />
@ -7219,7 +7361,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</template>
<template v-if="canShowWhereSearch">
<div ref="searchSplitContainerRef" class="flex flex-1 min-w-0">
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0 relative" :class="{ 'border-l': useTransaction && editable && (tableMeta || customSaveHandler) }" :style="whereSearchPaneStyle">
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0 relative" :class="{ 'border-l': useTransaction && editable && hasDataGridSaveTarget }" :style="whereSearchPaneStyle">
<Popover v-model:open="filterBuilderOpen">
<PopoverTrigger as-child>
<button
@ -7542,7 +7684,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</TooltipTrigger>
<TooltipContent side="bottom">{{ t("grid.goToColumn") }}</TooltipContent>
</Tooltip>
<Tooltip v-if="editable && (tableMeta || customSaveHandler) && canInsertRows">
<Tooltip v-if="editable && hasDataGridInsertTarget && canInsertRows">
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" :class="['data-grid-topbar-action-button h-5 shrink-0 text-xs px-1.5', compactDataGridToolbar ? 'data-grid-topbar-action-button--compact' : '']" @click="addRow">
<Plus class="data-grid-topbar-action-icon w-3 h-3" />
@ -7719,7 +7861,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
@click="selectTransposeRecord(recordIndex, $event)"
@contextmenu="selectTransposeRecord(recordIndex, $event)"
>
{{ recordIndex + 1 }}
{{ rowNumberText(displayItems[recordIndex]) }}
<div class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30" @mousedown.stop="onTransposeRecordResizeStart(recordIndex, $event)" @dblclick.stop="autoFitTransposeRecord(recordIndex)" />
</div>
<div class="shrink-0" :style="{ width: `${transposeAfterSpacerWidth}px` }" />
@ -7749,6 +7891,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
}"
:style="{ width: `${getTransposeRecordWidth(cell.recordIndex)}px` }"
:title="cell.display"
@mousedown="prepareTransposeCellMouseDown(cell.recordIndex, cell.valueIndex)"
@click="selectTransposeCell(cell.recordIndex, cell.valueIndex, $event)"
@mouseenter="onTransposeCellMouseenter(cell.recordIndex, cell.valueIndex)"
@mouseleave="onCellMouseleave(cell.recordIndex, cell.valueIndex)"
@ -7768,7 +7911,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
autocorrect="off"
spellcheck="false"
class="cell-edit-input cell-edit-input--expanded absolute left-0 top-0 min-h-full bg-background px-1.5 py-1 leading-[18px] outline-none z-10"
@blur="commitEditFromBlur"
@blur="commitEditFromCellBlur"
@click.stop
@focus="onCellEditTextareaInput"
@input="onCellEditTextareaInput"
@ -7783,7 +7926,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
autocorrect="off"
spellcheck="false"
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-1.5 py-0 leading-[26px] outline-none z-10"
@blur="commitEditFromBlur"
@blur="commitEditFromCellBlur"
@click.stop
@input="onCellEditTextareaInput"
@keydown.stop="onCellEditKeydown"
@ -7791,7 +7934,10 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
/>
</template>
<template v-else>
{{ cell.display }}
<template v-if="draftCellPlaceholder(displayItems[cell.recordIndex], cell.valueIndex)">
<span class="text-muted-foreground/70 italic">{{ draftCellPlaceholder(displayItems[cell.recordIndex], cell.valueIndex) }}</span>
</template>
<template v-else>{{ cell.display }}</template>
<div v-if="cellDetailButtonVisible(cell.recordIndex, cell.valueIndex)" class="absolute right-2 top-0.5 flex items-center gap-1">
<LightDropdownMenu
v-if="canQuickDownloadCellValue(cell.recordIndex, cell.valueIndex)"
@ -8226,7 +8372,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
autocorrect="off"
spellcheck="false"
class="cell-edit-input cell-edit-input--expanded absolute left-0 top-0 min-h-full bg-background px-2.5 py-1 leading-[18px] outline-none z-10"
@blur="commitEditFromBlur"
@blur="commitEditFromCellBlur"
@click.stop
@focus="onCellEditTextareaInput"
@input="onCellEditTextareaInput"
@ -8241,7 +8387,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
autocorrect="off"
spellcheck="false"
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-2.5 py-0 leading-[22px] outline-none z-10"
@blur="commitEditFromBlur"
@blur="commitEditFromCellBlur"
@click.stop
@input="onCellEditTextareaInput"
@keydown.stop="onCellEditKeydown"
@ -8302,7 +8448,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
:class="{
'bg-destructive/5 opacity-70': item.isDeleted,
'bg-primary/5': item.isNew && !isRowActive(item.displayIndex),
'bg-muted/30': !item.isNew && !item.isDeleted && !isRowActive(item.displayIndex) && item.displayIndex % 2 === 1,
'bg-muted/20': item.isDraft && !isRowActive(item.displayIndex),
'bg-muted/30': !item.isNew && !item.isDraft && !item.isDeleted && !isRowActive(item.displayIndex) && item.displayIndex % 2 === 1,
'active-row': isRowActive(item.displayIndex) && !item.isDeleted,
'relative z-20 overflow-visible': editingCell?.rowId === item.id,
}"
@ -8315,7 +8462,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
@dblclick.stop="toggleTranspose(item.displayIndex)"
@contextmenu="onRowContext(item.id, item.displayIndex)"
>
{{ infiniteScrollEnabled ? item.displayIndex + 1 : item.displayIndex + 1 + (currentPage - 1) * pageSize }}
{{ rowNumberText(item) }}
</div>
<div class="shrink-0" :style="{ width: `${horizontalColumnWindow.beforeWidth}px` }" />
<div
@ -8338,7 +8485,10 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
'overflow-visible z-20 border-r-transparent': editingCell?.rowId === item.id && editingCell?.col === col.actualColIdx,
'overflow-hidden': !(editingCell?.rowId === item.id && editingCell?.col === col.actualColIdx),
}"
@mousedown="handleDataCellMousedown(item.displayIndex, col.visibleColIdx, item.id, $event)"
@mousedown="
prepareDataCellMouseDown(item, col.actualColIdx);
handleDataCellMousedown(item.displayIndex, col.visibleColIdx, item.id, $event);
"
@mouseenter="onCellMouseenter(item.displayIndex, col.visibleColIdx, col.actualColIdx)"
@mouseleave="onCellMouseleave(item.displayIndex, col.actualColIdx)"
@dblclick="canEditCellItem(item, col.actualColIdx) && startDomCellEdit(item.id, col.actualColIdx, formatCellCached(item.data[col.actualColIdx], col.actualColIdx), $event)"
@ -8358,7 +8508,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
autocorrect="off"
spellcheck="false"
class="cell-edit-input cell-edit-input--expanded absolute left-0 top-0 min-h-full bg-background px-2.5 py-1 leading-[18px] outline-none z-10"
@blur="commitEditFromBlur"
@blur="commitEditFromCellBlur"
@click.stop
@focus="onCellEditTextareaInput"
@input="onCellEditTextareaInput"
@ -8373,7 +8523,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
autocorrect="off"
spellcheck="false"
class="cell-edit-input absolute inset-0 bg-background border-2 border-primary px-2.5 py-0 leading-[22px] outline-none z-10"
@blur="commitEditFromBlur"
@blur="commitEditFromCellBlur"
@click.stop
@input="onCellEditTextareaInput"
@keydown.stop="onCellEditKeydown"
@ -8381,7 +8531,10 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
/>
</template>
<template v-else>
{{ formatCellCached(item.data[col.actualColIdx], col.actualColIdx) }}
<template v-if="draftCellPlaceholder(item, col.actualColIdx)">
<span class="text-muted-foreground/70 italic">{{ draftCellPlaceholder(item, col.actualColIdx) }}</span>
</template>
<template v-else>{{ formatCellCached(item.data[col.actualColIdx], col.actualColIdx) }}</template>
<div v-if="cellDetailButtonVisible(item.displayIndex, col.actualColIdx)" class="absolute right-2 top-0.5 flex items-center gap-1">
<LightDropdownMenu
v-if="canQuickDownloadCellValue(item.displayIndex, col.actualColIdx)"
@ -8871,7 +9024,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<span v-if="!hasData" class="shrink-0">{{ t("grid.rowsAffected", { count: result.affected_rows }) }}</span>
<span class="shrink-0">{{ result.execution_time_ms }}ms</span>
<template v-if="editable && (tableMeta || customSaveHandler)">
<template v-if="editable && hasDataGridSaveTarget">
<span v-if="hasPendingChanges" class="shrink-0 text-foreground">
{{ t("grid.pendingChanges", { count: pendingChangeCount }) }}
</span>

View File

@ -348,7 +348,7 @@ const mongoQueryResultSaveHandler = computed<CustomSaveHandler | undefined>(() =
return stmts;
};
return { save, preview, canInsert: false, canDelete: false, readonlyColumns: [target.idColumn], targetLabel: target.collection };
return { save, preview, canInsert: false, canDelete: false, supportsInsert: false, readonlyColumns: [target.idColumn], targetLabel: target.collection };
});
const resultsPaneOpen = ref(false);
const resultsPaneSize = ref(Number(safeLocalStorageGet("dbx-results-pane-size")) || DEFAULT_QUERY_RESULTS_PANE_SIZE);

View File

@ -17,11 +17,30 @@ interface RowItem {
newIndex?: number;
data: CellValue[];
isNew: boolean;
isDraft?: boolean;
isDeleted: boolean;
isDirtyCol: boolean[];
status: string;
}
export const DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID = Number.MIN_SAFE_INTEGER;
type RowKind = "none" | "existing" | "new" | "draft";
type CommitEditResult =
| {
changed: false;
rowKind: RowKind;
}
| {
changed: true;
rowKind: Exclude<RowKind, "none">;
};
interface CommitEditOptions {
promoteDraft?: boolean;
}
type GridScrollerRef =
| HTMLElement
| {
@ -37,6 +56,7 @@ export interface CustomSaveHandler {
canInsert?: boolean;
canDelete?: boolean;
readonlyColumns?: string[];
supportsInsert?: boolean;
targetLabel?: string;
}
@ -65,6 +85,7 @@ export interface UseDataGridEditorOptions {
currentWhereInput: ComputedRef<string | undefined>;
orderByInput: Ref<string>;
rowStatusFilter: Ref<RowStatusFilter>;
dataGridQuickEntryEnabled?: ComputedRef<boolean>;
initialEditColumn?: ComputedRef<number>;
getRowItem: (rowId: number) => RowItem | undefined;
pageSize: Ref<number>;
@ -77,6 +98,7 @@ export interface UseDataGridEditorOptions {
interface PendingChangesSnapshot {
newRows: CellValue[][];
quickEntryDraftRow?: CellValue[];
dirtyRows: Map<number, Map<number, CellValue>>;
deletedRows: Set<number>;
editingCell?: { rowId: number; col: number } | null;
@ -87,7 +109,24 @@ interface PendingChangesSnapshot {
rowCount: number;
}
type PendingChangesHistorySnapshot = Pick<PendingChangesSnapshot, "newRows" | "dirtyRows" | "deletedRows" | "transactionActive">;
interface PendingSaveSnapshot {
newRows: CellValue[][];
newRowRefs: CellValue[][];
dirtyRows: Map<number, Map<number, CellValue>>;
deletedRows: Set<number>;
}
interface SaveChangesOptions {
autoSave?: boolean;
}
interface QueuedAutoSaveChange {
sourceIndex: number;
col: number;
value: CellValue;
}
type PendingChangesHistorySnapshot = Pick<PendingChangesSnapshot, "newRows" | "quickEntryDraftRow" | "dirtyRows" | "deletedRows" | "transactionActive">;
const pendingChangesCache = new Map<string, PendingChangesSnapshot>();
const closingPendingSnapshotTabs = new Set<string>();
@ -143,6 +182,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
searchText,
orderByInput,
rowStatusFilter,
dataGridQuickEntryEnabled = computed(() => false),
initialEditColumn,
getRowItem,
pageSize,
@ -156,12 +196,17 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const dirtyRows = ref<Map<number, Map<number, CellValue>>>(new Map());
const newRows = ref<CellValue[][]>([]);
const deletedRows = ref<Set<number>>(new Set());
const quickEntryDraftRow = ref<CellValue[]>([]);
const undoStack = ref<PendingChangesHistorySnapshot[]>([]);
const redoStack = ref<PendingChangesHistorySnapshot[]>([]);
const pendingChangesVersion = ref(0);
let restoredEditingCell = false;
let restoredTransactionActive = false;
let suppressNextBlurCommit = false;
let pendingAutoSaveRequested = false;
const queuedAutoSaveChanges = new Map<string, QueuedAutoSaveChange>();
let draftPromotionScheduled = false;
const savingNewRows = new WeakSet<CellValue[]>();
let pendingScrollRestore: PendingChangesSnapshot["scroll"] | undefined;
let saveScrollSnapshotTimer = 0;
let componentActive = true;
@ -172,6 +217,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const cached = pendingChangesCache.get(key);
if (cached && cached.columnCount === result.value.columns.length && cached.rowCount === result.value.rows.length) {
newRows.value = cached.newRows;
quickEntryDraftRow.value = cached.quickEntryDraftRow ? [...cached.quickEntryDraftRow] : [];
dirtyRows.value = cached.dirtyRows;
deletedRows.value = cached.deletedRows;
editingCell.value = cached.editingCell ?? null;
@ -243,6 +289,10 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
transactionActive.value = true;
}
function exitTransaction() {
transactionActive.value = false;
}
function touchPendingChanges() {
pendingChangesVersion.value++;
}
@ -250,6 +300,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
function pendingChangesSnapshot(): PendingChangesHistorySnapshot {
return {
newRows: newRows.value.map((row) => [...row]),
quickEntryDraftRow: quickEntryDraftRow.value.length > 0 ? [...quickEntryDraftRow.value] : undefined,
dirtyRows: new Map([...dirtyRows.value].map(([rowIndex, changes]) => [rowIndex, new Map(changes)])),
deletedRows: new Set(deletedRows.value),
transactionActive: transactionActive.value,
@ -258,9 +309,11 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
function restorePendingChangesSnapshot(snapshot: PendingChangesHistorySnapshot) {
newRows.value = snapshot.newRows.map((row) => [...row]);
quickEntryDraftRow.value = snapshot.quickEntryDraftRow ? [...snapshot.quickEntryDraftRow] : emptyDraftRow();
dirtyRows.value = new Map([...snapshot.dirtyRows].map(([rowIndex, changes]) => [rowIndex, new Map(changes)]));
deletedRows.value = new Set(snapshot.deletedRows);
transactionActive.value = snapshot.transactionActive === true && useTransaction.value === true;
queuedAutoSaveChanges.clear();
editingCell.value = null;
touchPendingChanges();
}
@ -291,10 +344,6 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
restorePendingChangesSnapshot(snapshot);
}
function exitTransaction() {
transactionActive.value = false;
}
// --- Scroll helpers ---
let isCancelling = false;
let isCommitting = false;
@ -451,13 +500,103 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
return row.map((v, colIdx) => (dirty.has(colIdx) ? dirty.get(colIdx)! : v));
}
function editingSourceRowItem(rowId: number): RowItem | undefined {
if (!dataGridQuickEntryEnabled.value || rowId < 0) return undefined;
const row = result.value.rows[rowId];
if (!row || deletedRows.value.has(rowId)) return undefined;
const dirty = dirtyRows.value.get(rowId);
return {
id: rowId,
sourceIndex: rowId,
data: rowDataWithChanges(row, rowId),
isNew: false,
isDeleted: false,
isDirtyCol: result.value.columns.map((_, colIdx) => !!dirty?.has(colIdx)),
status: dirty?.size ? "edited" : "clean",
};
}
function emptyDraftRow(): CellValue[] {
return result.value.columns.map(() => null);
}
function ensureQuickEntryDraftRow() {
if (quickEntryDraftRow.value.length !== result.value.columns.length) {
quickEntryDraftRow.value = emptyDraftRow();
}
}
function draftRowHasValue(row = quickEntryDraftRow.value): boolean {
return row.some((value) => value !== null && String(value).trim() !== "");
}
function isSavingNewRow(item: Pick<RowItem, "isNew" | "data"> | undefined): boolean {
return !!item?.isNew && savingNewRows.has(item.data);
}
function queuedAutoSaveKey(sourceIndex: number, col: number): string {
return `${sourceIndex}:${col}`;
}
function rememberQueuedAutoSaveChange(sourceIndex: number, col: number, value: CellValue) {
queuedAutoSaveChanges.set(queuedAutoSaveKey(sourceIndex, col), { sourceIndex, col, value });
}
function applyQueuedAutoSaveChanges(savedSnapshot?: PendingSaveSnapshot) {
if (queuedAutoSaveChanges.size === 0) return false;
let applied = false;
for (const change of queuedAutoSaveChanges.values()) {
if (deletedRows.value.has(change.sourceIndex) || !canEditExistingRows.value) continue;
const oldVal = result.value.rows[change.sourceIndex]?.[change.col];
const savedChanges = savedSnapshot?.dirtyRows.get(change.sourceIndex);
const baseline = savedChanges?.has(change.col) ? savedChanges.get(change.col) : oldVal;
if (change.value !== baseline) {
if (!dirtyRows.value.has(change.sourceIndex)) dirtyRows.value.set(change.sourceIndex, new Map());
dirtyRows.value.get(change.sourceIndex)!.set(change.col, change.value);
applied = true;
} else {
const rowChanges = dirtyRows.value.get(change.sourceIndex);
rowChanges?.delete(change.col);
if (rowChanges?.size === 0) dirtyRows.value.delete(change.sourceIndex);
}
}
queuedAutoSaveChanges.clear();
dirtyRows.value = new Map(dirtyRows.value);
return applied;
}
async function promoteQuickEntryDraftRow() {
draftPromotionScheduled = false;
ensureQuickEntryDraftRow();
if (!draftRowHasValue()) {
quickEntryDraftRow.value = emptyDraftRow();
return;
}
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
newRows.value = [...newRows.value, [...quickEntryDraftRow.value]];
quickEntryDraftRow.value = emptyDraftRow();
if (useTransaction.value && !transactionActive.value) {
enterTransaction();
}
if (dataGridQuickEntryEnabled.value) {
await saveChanges({ autoSave: true });
}
}
function scheduleQuickEntryDraftPromotion() {
if (draftPromotionScheduled) return;
draftPromotionScheduled = true;
void Promise.resolve().then(promoteQuickEntryDraftRow);
}
// --- Inline editing ---
function startEdit(rowId: number, colIdx: number) {
if (!editable.value) return;
if (!canEditColumn(colIdx)) return;
const item = getRowItem(rowId);
if (!item || item.isDeleted) return;
if (!item.isNew && !canEditExistingRows.value) return;
if (!item.isNew && !item.isDraft && !canEditExistingRows.value) return;
if (isSavingNewRow(item)) return;
isCancelling = false;
suppressNextBlurCommit = false;
editingCell.value = { rowId, col: colIdx };
@ -470,47 +609,76 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
focusEditInput();
}
function commitEdit() {
if (isCancelling || isCommitting) return;
if (!editingCell.value) return;
function commitEdit(options: CommitEditOptions = {}): CommitEditResult {
if (isCancelling || isCommitting) return { changed: false, rowKind: "none" };
if (!editingCell.value) return { changed: false, rowKind: "none" };
isCommitting = true;
const { rowId, col } = editingCell.value;
const item = getRowItem(rowId);
const item = getRowItem(rowId) ?? editingSourceRowItem(rowId);
if (!item || item.isDeleted) {
editingCell.value = null;
isCommitting = false;
return;
return { changed: false, rowKind: "none" };
}
if (item.isDraft) {
ensureQuickEntryDraftRow();
const oldVal = quickEntryDraftRow.value[col] ?? null;
const newVal = coerceCellValue(editValue.value, oldVal, col);
const nextDraftRow = [...quickEntryDraftRow.value];
nextDraftRow[col] = newVal;
if (newVal !== oldVal) pushUndoSnapshot();
quickEntryDraftRow.value = nextDraftRow;
editingCell.value = null;
isCommitting = false;
if (!draftRowHasValue(nextDraftRow)) {
quickEntryDraftRow.value = emptyDraftRow();
return { changed: false, rowKind: "draft" };
}
if (options.promoteDraft === false) {
return { changed: false, rowKind: "draft" };
}
rowStatusFilter.value = rowStatusFilterAfterAddingRow(rowStatusFilter.value);
newRows.value = [...newRows.value, nextDraftRow];
quickEntryDraftRow.value = emptyDraftRow();
touchPendingChanges();
if (useTransaction.value && !transactionActive.value) {
enterTransaction();
}
return { changed: true, rowKind: "draft" };
}
if (item.isNew && item.newIndex !== undefined) {
const oldVal = newRows.value[item.newIndex]?.[col];
const newVal = coerceCellValue(editValue.value, oldVal, col);
const changed = newVal !== oldVal;
if (changed) pushUndoSnapshot();
if (newRows.value[item.newIndex]) {
if (newVal !== oldVal) pushUndoSnapshot();
newRows.value[item.newIndex][col] = newVal;
}
newRows.value = [...newRows.value];
touchPendingChanges();
if (changed) touchPendingChanges();
editingCell.value = null;
isCommitting = false;
return;
return changed ? { changed: true, rowKind: "new" } : { changed: false, rowKind: "new" };
}
if (item.sourceIndex === undefined) {
editingCell.value = null;
isCommitting = false;
return;
return { changed: false, rowKind: "none" };
}
if (!canEditExistingRows.value) {
editingCell.value = null;
isCommitting = false;
return;
return { changed: false, rowKind: "existing" };
}
const oldVal = result.value.rows[item.sourceIndex]?.[col];
const newVal = coerceCellValue(editValue.value, oldVal, col);
const changed = newVal !== item.data[col];
if (newVal !== oldVal) {
pushUndoSnapshot();
if (changed) pushUndoSnapshot();
if (!dirtyRows.value.has(item.sourceIndex)) dirtyRows.value.set(item.sourceIndex, new Map());
dirtyRows.value.get(item.sourceIndex)!.set(col, newVal);
if (useTransaction.value && !transactionActive.value) {
@ -523,17 +691,28 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
if (rowChanges?.size === 0) dirtyRows.value.delete(item.sourceIndex);
}
dirtyRows.value = new Map(dirtyRows.value);
touchPendingChanges();
if (changed) touchPendingChanges();
editingCell.value = null;
isCommitting = false;
if (dataGridQuickEntryEnabled.value && isSaving.value && changed) {
rememberQueuedAutoSaveChange(item.sourceIndex, col, newVal);
}
return changed ? { changed: true, rowKind: "existing" } : { changed: false, rowKind: "existing" };
}
function commitEditFromBlur() {
async function commitEditAndMaybeAutoSave(options: CommitEditOptions = {}) {
const result = commitEdit(options);
if (dataGridQuickEntryEnabled.value && options.promoteDraft !== false && result.changed) {
await saveChanges({ autoSave: true });
}
}
async function commitEditFromBlur(options: CommitEditOptions = {}) {
if (suppressNextBlurCommit) {
suppressNextBlurCommit = false;
return;
}
commitEdit();
await commitEditAndMaybeAutoSave(options);
}
function applyCellValue(rowId: number, col: number, value: string | null) {
@ -541,7 +720,21 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const item = getRowItem(rowId);
if (!item || item.isDeleted) return;
if (item.isDraft) {
ensureQuickEntryDraftRow();
const oldVal = quickEntryDraftRow.value[col] ?? null;
const nextDraftRow = [...quickEntryDraftRow.value];
nextDraftRow[col] = value === null ? null : coerceCellValue(value, oldVal, col);
if (nextDraftRow[col] === oldVal) return;
pushUndoSnapshot();
quickEntryDraftRow.value = draftRowHasValue(nextDraftRow) ? nextDraftRow : emptyDraftRow();
touchPendingChanges();
scheduleQuickEntryDraftPromotion();
return;
}
if (item.isNew && item.newIndex !== undefined) {
if (isSavingNewRow(item)) return;
const row = newRows.value[item.newIndex];
if (!row) return;
const oldVal = row[col];
@ -584,7 +777,19 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const item = getRowItem(rowId);
if (!item || item.isDeleted) return;
if (item.isDraft) {
ensureQuickEntryDraftRow();
if (quickEntryDraftRow.value[col] === null) return;
pushUndoSnapshot();
const nextDraftRow = [...quickEntryDraftRow.value];
nextDraftRow[col] = null;
quickEntryDraftRow.value = draftRowHasValue(nextDraftRow) ? nextDraftRow : emptyDraftRow();
touchPendingChanges();
return;
}
if (item.isNew && item.newIndex !== undefined) {
if (isSavingNewRow(item)) return;
const row = newRows.value[item.newIndex];
if (!row || row[col] === null) return;
pushUndoSnapshot();
@ -614,11 +819,10 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
function onEditKeydown(e: KeyboardEvent) {
const isExpandedTextarea = e.target instanceof HTMLTextAreaElement && e.target.dataset.expandedCellEditor === "true";
const isExpandedTextarea = typeof HTMLTextAreaElement !== "undefined" && e.target instanceof HTMLTextAreaElement && e.target.dataset.expandedCellEditor === "true";
if (e.key === "Enter" && (!isExpandedTextarea || e.metaKey || e.ctrlKey)) {
e.preventDefault();
commitEdit();
nextTick(focusScrollerWithoutScrolling);
void commitEditAndMaybeAutoSave().finally(() => nextTick(focusScrollerWithoutScrolling));
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
@ -700,6 +904,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
const item = getRowItem(rowId);
if (!item) return;
if (item.isNew && item.newIndex !== undefined) {
if (isSavingNewRow(item)) return;
pushUndoSnapshot();
newRows.value.splice(item.newIndex, 1);
newRows.value = [...newRows.value];
@ -772,7 +977,81 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
// --- Save/Discard ---
function saveStatementOptions() {
function snapshotPendingSaveChanges(): PendingSaveSnapshot {
const currentNewRows = [...newRows.value];
return {
dirtyRows: new Map([...dirtyRows.value.entries()].map(([rowIndex, changes]) => [rowIndex, new Map(changes)])),
newRows: currentNewRows.map((row) => [...row]),
newRowRefs: currentNewRows,
deletedRows: new Set(deletedRows.value),
};
}
function hasPendingSaveChanges(snapshot: PendingSaveSnapshot) {
return snapshot.newRows.length > 0 || snapshot.dirtyRows.size > 0 || snapshot.deletedRows.size > 0;
}
function applyDirtyRowsToResult(snapshot: PendingSaveSnapshot) {
for (const [sourceIndex, changes] of snapshot.dirtyRows) {
const row = result.value.rows[sourceIndex];
if (row) {
for (const [colIdx, value] of changes) {
row[colIdx] = value;
}
}
}
}
function clearSavedPendingChanges(snapshot: PendingSaveSnapshot) {
for (const [sourceIndex, changes] of snapshot.dirtyRows) {
const liveChanges = dirtyRows.value.get(sourceIndex);
if (!liveChanges) continue;
for (const [colIdx, savedValue] of changes) {
if (liveChanges.get(colIdx) === savedValue) {
liveChanges.delete(colIdx);
}
}
if (liveChanges.size === 0) {
dirtyRows.value.delete(sourceIndex);
}
}
dirtyRows.value = new Map(dirtyRows.value);
if (snapshot.newRows.length > 0) {
const savedNewRows = new Set(snapshot.newRowRefs);
newRows.value = newRows.value.filter((row) => !savedNewRows.has(row));
}
for (const sourceIndex of snapshot.deletedRows) {
deletedRows.value.delete(sourceIndex);
}
deletedRows.value = new Set(deletedRows.value);
touchPendingChanges();
}
async function finishSaveChanges(savedSnapshot?: PendingSaveSnapshot) {
isSaving.value = false;
if (pendingAutoSaveRequested && dataGridQuickEntryEnabled.value) {
applyQueuedAutoSaveChanges(savedSnapshot);
} else {
queuedAutoSaveChanges.clear();
}
if (!hasPendingChanges.value) {
pendingAutoSaveRequested = false;
return;
}
if (pendingAutoSaveRequested && dataGridQuickEntryEnabled.value) {
pendingAutoSaveRequested = false;
await saveChanges({ autoSave: true });
}
}
async function finishInterruptedSaveChanges(snapshot: PendingSaveSnapshot) {
snapshot.newRowRefs.forEach((row) => savingNewRows.delete(row));
await finishSaveChanges();
}
function saveStatementOptions(snapshot = snapshotPendingSaveChanges()) {
if (!tableMeta.value) return null;
return {
databaseType: resolvedDatabaseType.value,
@ -780,9 +1059,9 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
columns: result.value.columns,
sourceColumns: sourceColumns.value,
rows: result.value.rows,
dirtyRows: [...dirtyRows.value.entries()].map(([rowIndex, changes]) => [rowIndex, [...changes.entries()]] as [number, Array<[number, CellValue]>]),
deletedRows: [...deletedRows.value],
newRows: newRows.value,
dirtyRows: [...snapshot.dirtyRows.entries()].map(([rowIndex, changes]) => [rowIndex, [...changes.entries()]] as [number, Array<[number, CellValue]>]),
deletedRows: [...snapshot.deletedRows],
newRows: snapshot.newRows,
};
}
@ -791,21 +1070,21 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
return [tableMeta.value.schema, tableMeta.value.tableName].filter(Boolean).join(".");
}
function dataChangeOperation() {
const operations = [newRows.value.length > 0 ? "INSERT" : "", dirtyRows.value.size > 0 ? "UPDATE" : "", deletedRows.value.size > 0 ? "DELETE" : ""].filter(Boolean);
function dataChangeOperation(snapshot: PendingSaveSnapshot) {
const operations = [snapshot.newRows.length > 0 ? "INSERT" : "", snapshot.dirtyRows.size > 0 ? "UPDATE" : "", snapshot.deletedRows.size > 0 ? "DELETE" : ""].filter(Boolean);
return operations.length === 1 ? operations[0] : "DATA CHANGE";
}
async function recordDataGridHistory(statements: string[], rollbackStatements: string[], elapsed: number, historyResult?: { affected_rows?: number; success?: boolean; error?: string }) {
async function recordDataGridHistory(statements: string[], rollbackStatements: string[], elapsed: number, snapshot: PendingSaveSnapshot, historyResult?: { affected_rows?: number; success?: boolean; error?: string }) {
if (!connectionId.value || !tableMeta.value) return;
const connName = connectionStore.getConfig(connectionId.value)?.name || "";
const success = historyResult?.success ?? true;
const details = {
schema: tableMeta.value.schema,
table: tableMeta.value.tableName,
inserted_rows: newRows.value.length,
updated_rows: dirtyRows.value.size,
deleted_rows: deletedRows.value.size,
inserted_rows: snapshot.newRows.length,
updated_rows: snapshot.dirtyRows.size,
deleted_rows: snapshot.deletedRows.size,
statements,
rollback_statements: success ? rollbackStatements : [],
error: success ? undefined : historyResult?.error,
@ -819,7 +1098,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
success,
error: success ? undefined : historyResult?.error,
activity_kind: "data_change",
operation: dataChangeOperation(),
operation: dataChangeOperation(snapshot),
target: tableHistoryTarget(),
affected_rows: success ? (historyResult?.affected_rows ?? statements.length) : undefined,
rollback_sql: success && rollbackStatements.length ? rollbackStatements.join("\n") : undefined,
@ -827,10 +1106,10 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
});
}
async function recordFailedDataGridHistory(statements: string[], rollbackStatements: string[], start: number, error: unknown) {
const message = normalizeDataGridSaveError(resolvedDatabaseType.value, error);
async function recordFailedDataGridHistory(statements: string[], rollbackStatements: string[], start: number, snapshot: PendingSaveSnapshot, error: unknown) {
const message = normalizeDataGridSaveError(databaseType.value, error);
try {
await recordDataGridHistory(statements, rollbackStatements, Date.now() - start, {
await recordDataGridHistory(statements, rollbackStatements, Date.now() - start, snapshot, {
success: false,
error: message,
});
@ -844,70 +1123,71 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
options.emit("reload", sql.value, searchText.value, options.currentWhereInput.value, orderByInput.value.trim() || undefined, pageSize.value, (currentPage.value - 1) * pageSize.value);
}
function applyDirtyRowsToResult() {
for (const [sourceIndex, changes] of dirtyRows.value) {
const row = result.value.rows[sourceIndex];
if (row) {
for (const [colIdx, value] of changes) {
row[colIdx] = value;
}
}
async function saveChanges(saveOptions: SaveChangesOptions = {}) {
if (isSaving.value) {
if (saveOptions.autoSave) pendingAutoSaveRequested = true;
return;
}
const snapshot = snapshotPendingSaveChanges();
if (!hasPendingSaveChanges(snapshot)) {
return;
}
const customHandler = customSaveHandler?.value;
if (customHandler && snapshot.newRows.length > 0 && customHandler.supportsInsert !== true && customHandler.canInsert !== true) {
saveError.value = "当前保存目标不支持新增行。";
return;
}
}
async function saveChanges() {
saveError.value = "";
isSaving.value = true;
const shouldReloadAfterSave = newRows.value.length > 0 || deletedRows.value.size > 0;
snapshot.newRowRefs.forEach((row) => savingNewRows.add(row));
const shouldReloadAfterSave = snapshot.newRows.length > 0 || snapshot.deletedRows.size > 0;
if (customSaveHandler?.value) {
if (customHandler) {
try {
await customSaveHandler.value.save({
dirtyRows: dirtyRows.value,
newRows: newRows.value,
deletedRows: deletedRows.value,
await customHandler.save({
dirtyRows: snapshot.dirtyRows,
newRows: snapshot.newRows,
deletedRows: snapshot.deletedRows,
columns: result.value.columns,
rows: result.value.rows,
});
} catch (e: any) {
saveError.value = normalizeDataGridSaveError(resolvedDatabaseType.value, e);
isSaving.value = false;
saveError.value = normalizeDataGridSaveError(databaseType.value, e);
await finishInterruptedSaveChanges(snapshot);
return;
}
applyDirtyRowsToResult();
dirtyRows.value.clear();
newRows.value = [];
deletedRows.value.clear();
snapshot.newRowRefs.forEach((row) => savingNewRows.delete(row));
applyDirtyRowsToResult(snapshot);
clearSavedPendingChanges(snapshot);
if (!hasPendingChanges.value) exitTransaction();
clearPendingChangeHistory();
touchPendingChanges();
exitTransaction();
isSaving.value = false;
if (shouldReloadAfterSave) {
reloadCurrentData();
}
await finishSaveChanges(snapshot);
return;
}
const stmtOptions = saveStatementOptions();
const stmtOptions = saveStatementOptions(snapshot);
let preparedSave: Awaited<ReturnType<typeof api.prepareDataGridSave>> | undefined;
if (stmtOptions) {
try {
preparedSave = await api.prepareDataGridSave(stmtOptions);
} catch (e: any) {
saveError.value = normalizeDataGridSaveError(resolvedDatabaseType.value, e);
isSaving.value = false;
saveError.value = normalizeDataGridSaveError(databaseType.value, e);
await finishInterruptedSaveChanges(snapshot);
return;
}
}
if (preparedSave?.validationError) {
saveError.value = preparedSave.validationError;
isSaving.value = false;
await finishInterruptedSaveChanges(snapshot);
return;
}
const stmts = preparedSave?.statements ?? [];
if (stmts.length === 0) {
isSaving.value = false;
await finishInterruptedSaveChanges(snapshot);
return;
}
const rollbackStmts = preparedSave?.rollbackStatements ?? [];
@ -915,7 +1195,6 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
let apiResult: { affected_rows?: number } | undefined;
console.info("[DBX][dataGrid:save-statements]", {
databaseType: databaseType.value,
resolvedDatabaseType: resolvedDatabaseType.value,
table: tableMeta.value ? [tableMeta.value.schema, tableMeta.value.tableName].filter(Boolean).join(".") : undefined,
statements: stmts,
rollbackStatements: rollbackStmts,
@ -925,16 +1204,16 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
try {
apiResult = await api.executeInTransaction(connectionId.value!, database.value ?? "", stmts, preparedSave?.executionSchema);
} catch (e: any) {
saveError.value = await recordFailedDataGridHistory(stmts, rollbackStmts, start, e);
isSaving.value = false;
saveError.value = await recordFailedDataGridHistory(stmts, rollbackStmts, start, snapshot, e);
await finishInterruptedSaveChanges(snapshot);
return;
}
} else if (hasBackendSaveTarget.value) {
try {
apiResult = await api.executeBatch(connectionId.value!, database.value ?? "", stmts, preparedSave?.executionSchema);
} catch (e: any) {
saveError.value = await recordFailedDataGridHistory(stmts, rollbackStmts, start, e);
isSaving.value = false;
saveError.value = await recordFailedDataGridHistory(stmts, rollbackStmts, start, snapshot, e);
await finishInterruptedSaveChanges(snapshot);
return;
}
} else if (onExecuteSql.value) {
@ -943,36 +1222,36 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
await onExecuteSql.value(sqlStmt);
}
} catch (e: any) {
saveError.value = await recordFailedDataGridHistory(stmts, rollbackStmts, start, e);
isSaving.value = false;
saveError.value = await recordFailedDataGridHistory(stmts, rollbackStmts, start, snapshot, e);
await finishInterruptedSaveChanges(snapshot);
return;
}
}
try {
await recordDataGridHistory(stmts, rollbackStmts, Date.now() - start, apiResult);
await recordDataGridHistory(stmts, rollbackStmts, Date.now() - start, snapshot, apiResult);
} catch (e) {
console.warn("[DBX] failed to record data grid history", e);
}
applyDirtyRowsToResult();
dirtyRows.value.clear();
newRows.value = [];
deletedRows.value.clear();
applyDirtyRowsToResult(snapshot);
snapshot.newRowRefs.forEach((row) => savingNewRows.delete(row));
clearSavedPendingChanges(snapshot);
if (!hasPendingChanges.value) exitTransaction();
clearPendingChangeHistory();
touchPendingChanges();
exitTransaction();
isSaving.value = false;
if (shouldReloadAfterSave) {
reloadCurrentData();
}
await finishSaveChanges(snapshot);
}
function discardChanges() {
dirtyRows.value.clear();
newRows.value = [];
deletedRows.value.clear();
quickEntryDraftRow.value = emptyDraftRow();
queuedAutoSaveChanges.clear();
editingCell.value = null;
clearPendingChangeHistory();
touchPendingChanges();
editingCell.value = null;
exitTransaction();
}
@ -1001,12 +1280,14 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
}
const scroll = includeScroll ? (readScrollPosition() ?? pendingScrollRestore) : undefined;
if (includeScroll) pendingScrollRestore = scroll;
if (!hasPendingChanges.value && !(includeEditing && editingCell.value) && !scroll) {
const quickEntryDraftRowSnapshot = draftRowHasValue() ? [...quickEntryDraftRow.value] : undefined;
if (!hasPendingChanges.value && !quickEntryDraftRowSnapshot && !(includeEditing && editingCell.value) && !scroll) {
pendingChangesCache.delete(k);
return;
}
pendingChangesCache.set(k, {
newRows: newRows.value.map((r) => [...r]),
quickEntryDraftRow: quickEntryDraftRowSnapshot,
dirtyRows: new Map([...dirtyRows.value].map(([i, m]) => [i, new Map(m)])),
deletedRows: new Set(deletedRows.value),
editingCell: includeEditing && editingCell.value ? { ...editingCell.value } : null,
@ -1084,7 +1365,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
previewStatements.value = stmts;
return stmts;
} catch (e: any) {
saveError.value = normalizeDataGridSaveError(resolvedDatabaseType.value, e);
saveError.value = normalizeDataGridSaveError(databaseType.value, e);
return [];
} finally {
isPreviewLoading.value = false;
@ -1098,14 +1379,14 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
dirtyRows,
newRows,
deletedRows,
pendingChangesVersion,
quickEntryDraftRow,
quickEntryDraftRowId: DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID,
dirtyRowCount,
newRowCount,
deletedRowCount,
pendingChangesVersion,
pendingChangeCount,
hasPendingChanges,
canUndoPendingChange,
canRedoPendingChange,
transactionActive,
isSaving,
saveError,
@ -1114,11 +1395,10 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
exitTransaction,
startEdit,
commitEdit,
commitEditAndMaybeAutoSave,
commitEditFromBlur,
applyCellValue,
restoreCellValue,
undoPendingChange,
redoPendingChange,
cancelEdit,
onEditKeydown,
addRow,
@ -1136,7 +1416,14 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
deleteSelectedRow,
saveChanges,
discardChanges,
canUndoPendingChange,
canRedoPendingChange,
undoPendingChange,
redoPendingChange,
rowDataWithChanges,
ensureQuickEntryDraftRow,
draftRowHasValue,
isSavingNewRow,
coerceCellValue,
canEditColumn,
resetGridVerticalScroll,
@ -1147,6 +1434,8 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
previewStatements,
isPreviewLoading,
previewChanges,
savePendingSnapshot,
restorePendingSnapshotFocus,
syncHeaderScroll: (headerRef: Ref<HTMLDivElement | undefined>) => (e: Event) => {
if (headerRef.value) {
headerRef.value.scrollLeft = (e.target as HTMLElement).scrollLeft;

View File

@ -22,6 +22,7 @@ interface RowItem {
newIndex?: number;
data: CellValue[];
isNew: boolean;
isDraft?: boolean;
isDeleted: boolean;
isDirtyCol: boolean[];
status: string;
@ -139,13 +140,13 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
function rowsToExport(rowIds?: number[]): RowItem[] {
if (!rowIds?.length) return displayItems.value;
if (rowIds === undefined) return displayItems.value.filter((item) => !item.isDraft);
const rowIdSet = new Set(rowIds);
return displayItems.value.filter((item) => rowIdSet.has(item.id));
return displayItems.value.filter((item) => rowIdSet.has(item.id) && !item.isDraft);
}
async function resultToExport(rowIds?: number[], onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void, useFullExport = true): Promise<{ columns: string[]; rows: CellValue[][] }> {
if (useFullExport && !rowIds?.length && fullExportResult) {
if (useFullExport && rowIds === undefined && fullExportResult) {
const result = await fullExportResult(onProgress);
if (result) return { columns: result.columns, rows: result.rows };
}
@ -161,19 +162,19 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
function targetedRows(): RowItem[] {
if (hasRowSelection.value && selectedRowIds.value.size > 0) {
return displayItems.value.filter((item) => selectedRowIds.value.has(item.id));
return displayItems.value.filter((item) => selectedRowIds.value.has(item.id) && !item.isDraft);
}
const range = selectedRange.value;
if (range && range.startRow !== range.endRow) {
return displayItems.value.slice(range.startRow, range.endRow + 1);
return displayItems.value.slice(range.startRow, range.endRow + 1).filter((item) => !item.isDraft);
}
if (!contextCell.value) return [];
const item = getRowItem(contextCell.value.rowId);
return item ? [item] : [];
return item && !item.isDraft ? [item] : [];
}
function updateEligibleRows(): RowItem[] {
return targetedRows().filter((item) => !item.isNew && !item.isDeleted);
return targetedRows().filter((item) => !item.isNew && !item.isDraft && !item.isDeleted);
}
function updateCopyKey(): string {
@ -389,7 +390,8 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
async function copySelectedRowsTsv() {
if (!hasRowSelection.value || selectedRowIds.value.size === 0) return;
const rows = displayItems.value.filter((item) => selectedRowIds.value.has(item.id)).map((item) => item.data);
const rows = displayItems.value.filter((item) => selectedRowIds.value.has(item.id) && !item.isDraft).map((item) => item.data);
if (rows.length === 0) return;
await copyText(formatSelectionAsTsv({ columns: columns.value, rows }));
}
@ -417,30 +419,31 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
async function copyCell() {
if (!contextCell.value || contextCell.value.col < 0) return;
const item = getRowItem(contextCell.value.rowId);
if (!item || item.isDraft) return;
const val = item?.data[contextCell.value.col] ?? null;
await copyText(displayCellValue(val));
}
async function copyRow() {
if (hasRowSelection.value && selectedRowIds.value.size > 0) {
const items = displayItems.value.filter((item) => selectedRowIds.value.has(item.id));
const items = displayItems.value.filter((item) => selectedRowIds.value.has(item.id) && !item.isDraft);
await copyRowsAsJson(items);
return;
}
const range = selectedRange.value;
if (range && range.startRow !== range.endRow) {
const items = displayItems.value.slice(range.startRow, range.endRow + 1);
const items = displayItems.value.slice(range.startRow, range.endRow + 1).filter((item) => !item.isDraft);
await copyRowsAsJson(items);
return;
}
if (!contextCell.value) return;
const item = getRowItem(contextCell.value.rowId);
if (!item) return;
if (!item || item.isDraft) return;
await copyRowsAsJson([item]);
}
function insertEligibleRows(): RowItem[] {
return targetedRows();
return targetedRows().filter((item) => !item.isDraft);
}
async function copyRowAsInsert() {
@ -480,7 +483,10 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
async function copyAll() {
const header = columns.value.join("\t");
const body = displayItems.value.map((item) => item.data.map((c) => displayCellValue(c)).join("\t")).join("\n");
const body = displayItems.value
.filter((item) => !item.isDraft)
.map((item) => item.data.map((c) => displayCellValue(c)).join("\t"))
.join("\n");
await copyText(`${header}\n${body}`);
}
@ -501,7 +507,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
if (await exportQueryResultViaBackend("csv", rowIds)) return;
if (await exportFullTableDataViaBackend("csv", rowIds)) return;
const needsFullExport = !rowIds?.length && !!fullExportResult;
const needsFullExport = rowIds === undefined && !!fullExportResult;
if (needsFullExport && exportProgressDialog && exportProgressState) {
exportProgressState.value = {
title: t("exportProgress.title"),
@ -705,7 +711,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
if (!path) return;
outputPath = path as string;
}
const needsFullExport = !rowIds?.length && !!fullExportResult;
const needsFullExport = rowIds === undefined && !!fullExportResult;
if (needsFullExport && exportProgressDialog && exportProgressState) {
exportProgressState.value = {
title: t("exportProgress.title"),
@ -815,7 +821,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
async function exportFullTableDataViaBackend(format: "csv" | "xlsx" | "json" | "markdown" | "sql", rowIds?: number[]): Promise<boolean> {
const meta = tableMeta.value;
if (rowIds?.length || context.value !== "table-data" || !meta || !connectionId.value || !database.value) {
if (rowIds !== undefined || context.value !== "table-data" || !meta || !connectionId.value || !database.value) {
return false;
}
@ -894,7 +900,7 @@ export function useDataGridExport(options: UseDataGridExportOptions) {
}
async function exportQueryResultViaBackend(format: "csv" | "xlsx", rowIds?: number[]): Promise<boolean> {
if (rowIds?.length || context.value !== "results" || !queryResultExportRequest) {
if (rowIds !== undefined || context.value !== "results" || !queryResultExportRequest) {
return false;
}

View File

@ -9,6 +9,7 @@ interface RowItem {
newIndex?: number;
data: CellValue[];
isNew: boolean;
isDraft?: boolean;
isDeleted: boolean;
isDirtyCol: boolean[];
status: string;
@ -45,21 +46,37 @@ export function useDataGridSelection(options: UseDataGridSelectionOptions) {
return normalizeSelectionRange(selectionAnchor.value, selectionFocus.value);
});
const visibleSelectionRows = computed(() => displayItems.value.map((item) => item.data));
const selectedCells = computed<SelectionData>(() => {
if (hasColumnSelection.value) {
return extractColumnsSelection(columns.value, visibleSelectionRows.value, selectedColumnIndexes.value);
const rows = displayItems.value.filter((item) => !item.isDraft).map((item) => item.data);
return extractColumnsSelection(columns.value, rows, selectedColumnIndexes.value);
}
if (selectedCellKeys.value.size > 0) {
return extractSelectedCellKeys(columns.value, visibleSelectionRows.value, selectedCellKeys.value);
const selectableKeys = [...selectedCellKeys.value].filter((key) => {
const position = parseCellKey(key);
return !!position && !displayItems.value[position.rowIndex]?.isDraft;
});
return extractSelectedCellKeys(
columns.value,
displayItems.value.map((item) => item.data),
selectableKeys,
);
}
const range = selectedRange.value;
if (!range) return { columns: [], rows: [] };
return extractSelection(columns.value, visibleSelectionRows.value, range);
const rows = displayItems.value
.slice(range.startRow, range.endRow + 1)
.filter((item) => !item.isDraft)
.map((item) => item.data);
return extractSelection(columns.value, rows, {
startRow: 0,
endRow: rows.length - 1,
startCol: range.startCol,
endCol: range.endCol,
});
});
const selectedCellCount = computed(() => (selectedCellKeys.value.size > 0 ? selectedCellKeys.value.size : selectedCells.value.columns.length * selectedCells.value.rows.length));
const selectedCellCount = computed(() => selectedCells.value.rows.reduce((count, row) => count + row.length, 0));
const hasCellSelection = computed(() => selectedCellCount.value > 0);
function clearCellSelection() {

View File

@ -707,6 +707,7 @@ export default {
discard: "Discard",
dismiss: "Dismiss",
addRow: "Add Row",
quickEntryDraftPlaceholder: "New",
cloneRow: "Clone as New Row",
deleteRow: "Delete Row",
deleteRows: "Delete {count} Rows",
@ -2554,6 +2555,8 @@ export default {
showColumnTypesInHeaderDescription: "Display each column's data type directly below grid column names.",
compactColumnHeaderActions: "Compact column header tools",
compactColumnHeaderActionsDescription: "Move formatter and local filter tools into a more menu so column names get priority.",
dataGridQuickEntry: "Quick grid entry",
dataGridQuickEntryDescription: "When enabled, editing a cell or filling the bottom blank row saves to the database as soon as focus leaves. Useful for frequent entry, but it can cause accidental writes.",
infiniteScroll: "Infinite scroll loading",
infiniteScrollDescription: "Automatically load the next page of data when scrolling to the bottom of the table.",
infiniteScrollMaxRows: "Infinite scroll max rows",

View File

@ -703,6 +703,7 @@ export default withEnglishFallback({
save: "Guardar",
discard: "Descartar",
dismiss: "Cerrar",
quickEntryDraftPlaceholder: "Nueva",
addRow: "Agregar fila",
cloneRow: "Clonar como fila nueva",
deleteRow: "Eliminar fila",
@ -2544,6 +2545,8 @@ export default withEnglishFallback({
showColumnTypesInHeaderDescription: "Muestra el tipo de dato de cada columna directamente debajo del nombre de la columna.",
compactColumnHeaderActions: "Compactar herramientas del encabezado",
compactColumnHeaderActionsDescription: "Mueve formatear y filtrar a un menú de más acciones para priorizar el nombre de columna.",
dataGridQuickEntry: "Entrada rápida en grilla",
dataGridQuickEntryDescription: "Al activarlo, editar una celda o llenar la fila vacía inferior se guarda en la base de datos al perder el foco. Agiliza la captura frecuente, pero puede causar escrituras accidentales.",
infiniteScroll: "Carga de desplazamiento infinito",
infiniteScrollDescription: "Carga automáticamente la siguiente página de datos al desplazarse hasta el final de la tabla.",
infiniteScrollMaxRows: "Máximo de filas en desplazamiento infinito",

View File

@ -710,6 +710,7 @@ export default withEnglishFallback({
save: "Salva",
discard: "Scarta",
dismiss: "Ignora",
quickEntryDraftPlaceholder: "Nuova",
addRow: "Aggiungi Riga",
cloneRow: "Clona come Nuova Riga",
deleteRow: "Elimina Riga",
@ -2547,6 +2548,8 @@ export default withEnglishFallback({
showColumnTypesInHeaderDescription: "Visualizza il tipo di dato di ciascuna colonna direttamente sotto i nomi delle colonne nella griglia.",
compactColumnHeaderActions: "Strumenti intestazione colonna compatti",
compactColumnHeaderActionsDescription: "Sposta gli strumenti del formattatore e dei filtri locali in un menu 'altro' in modo che i nomi delle colonne abbiano la priorità.",
dataGridQuickEntry: "Inserimento rapido griglia",
dataGridQuickEntryDescription: "Se attivo, la modifica di una cella o la compilazione della riga vuota in fondo viene salvata nel database appena il focus esce. Utile per inserimenti frequenti, ma può causare scritture accidentali.",
infiniteScroll: "Caricamento a scorrimento infinito",
infiniteScrollDescription: "Carica automaticamente la pagina successiva dei dati quando scorri fino in fondo alla tabella.",
infiniteScrollMaxRows: "Righe max a scorrimento infinito",

View File

@ -702,6 +702,7 @@ export default withEnglishFallback({
discard: "破棄",
dismiss: "閉じる",
addRow: "行を追加",
quickEntryDraftPlaceholder: "新規",
cloneRow: "新しい行として複製",
deleteRow: "行を削除",
deleteRows: "{count}行を削除",
@ -2541,6 +2542,8 @@ export default withEnglishFallback({
showColumnTypesInHeaderDescription: "グリッド列名の直下に各列のデータ型を表示します。",
compactColumnHeaderActions: "列ヘッダーツールをコンパクトに",
compactColumnHeaderActionsDescription: "フォーマッターとローカルフィルターツールをもっと見るメニューに移動し、列名を優先表示します。",
dataGridQuickEntry: "グリッド高速入力",
dataGridQuickEntryDescription: "有効にすると、セル編集や下部の空行入力はフォーカスが外れた時点でデータベースに保存されます。頻繁な入力に便利ですが、誤操作による書き込みのリスクがあります。",
tableColumnTemplateFields: "新規テーブルのプリセット列",
tableColumnTemplateFieldsDescription: "データベース種別を選択し、新規テーブル作成時に使うプリセット列の型を設定します。",
tableColumnTemplateAdd: "列を追加",

View File

@ -716,6 +716,7 @@ export default withEnglishFallback({
discard: "Descartar",
dismiss: "Dispensar",
addRow: "Adicionar Linha",
quickEntryDraftPlaceholder: "Nova",
cloneRow: "Clonar como Nova Linha",
deleteRow: "Excluir Linha",
deleteRows: "Excluir {count} Linhas",
@ -2556,6 +2557,8 @@ export default withEnglishFallback({
showColumnTypesInHeaderDescription: "Exibir o tipo de dado de cada coluna diretamente abaixo dos nomes das colunas da grade.",
compactColumnHeaderActions: "Ferramentas compactas no cabeçalho da coluna",
compactColumnHeaderActionsDescription: "Mover as ferramentas de formatação e filtro local para um menu de mais opções para que os nomes das colunas tenham prioridade.",
dataGridQuickEntry: "Entrada rápida na grade",
dataGridQuickEntryDescription: "Quando ativado, editar uma célula ou preencher a linha vazia inferior salva no banco de dados assim que o foco sai. Ajuda na entrada frequente, mas pode causar gravações acidentais.",
infiniteScroll: "Carregamento por rolagem infinita",
infiniteScrollDescription: "Carregar automaticamente a próxima página de dados ao rolar até o final da tabela.",
infiniteScrollMaxRows: "Máximo de linhas em rolagem infinita",

View File

@ -710,6 +710,7 @@ export default withEnglishFallback({
discard: "放弃",
dismiss: "关闭",
addRow: "新增行",
quickEntryDraftPlaceholder: "新增",
cloneRow: "克隆为新行",
deleteRow: "删除行",
deleteRows: "删除 {count} 行",
@ -2561,6 +2562,8 @@ export default withEnglishFallback({
showColumnTypesInHeaderDescription: "把每个字段的数据类型直接显示在结果表头字段名下方。",
compactColumnHeaderActions: "收起字段表头工具",
compactColumnHeaderActionsDescription: "将格式化、本地筛选收进更多菜单,优先显示字段名称。",
dataGridQuickEntry: "表格快捷录入",
dataGridQuickEntryDescription: "开启后,编辑单元格或填写底部空行并离焦会立即保存到数据库。便于高频录入,但存在误操作风险。",
infiniteScroll: "无限滚动加载",
infiniteScrollDescription: "滚动到表格底部时自动加载下一页数据,无需手动翻页。",
infiniteScrollMaxRows: "无限滚动最大行数",

View File

@ -705,6 +705,7 @@ export default withEnglishFallback({
discard: "放棄",
dismiss: "關閉",
addRow: "新增資料",
quickEntryDraftPlaceholder: "新增",
cloneRow: "複製為新資料",
deleteRow: "刪除資料",
deleteRows: "刪除 {count} 筆",
@ -2444,6 +2445,8 @@ export default withEnglishFallback({
showColumnTypesInHeaderDescription: "直接在資料表格欄位名稱下方顯示每個欄位的資料類型。",
compactColumnHeaderActions: "收起欄位表頭工具",
compactColumnHeaderActionsDescription: "將格式設定和本機篩選工具移到更多選單,讓欄位名稱優先顯示。",
dataGridQuickEntry: "表格快速輸入",
dataGridQuickEntryDescription: "開啟後,編輯儲存格或填寫底部空白列並離焦會立即儲存到資料庫。適合高頻輸入,但有誤操作風險。",
infiniteScroll: "無限滾動載入",
infiniteScrollDescription: "滾動到表格底部時自動載入下一頁資料,無需手動翻頁。",
infiniteScrollMaxRows: "無限滾動最大筆數",

View File

@ -9,6 +9,7 @@ export interface CanvasDataGridRow {
displayIndex: number;
data: CellValue[];
isNew: boolean;
isDraft?: boolean;
isDeleted: boolean;
isDirtyCol: boolean[];
status: RowStatus;
@ -50,6 +51,7 @@ export interface DrawCanvasDataGridOptions {
searchMatchKeys: ReadonlySet<string>;
currentSearchMatch: CanvasSearchMatch | null;
formatCell: (value: CellValue, columnIndex: number) => string;
draftCellPlaceholder?: string;
isRowActive: (rowIndex: number) => boolean;
rowCellsUseSelectionVisual: (rowId: number) => boolean;
cellIsSelected: (rowIndex: number, visibleColIdx: number) => boolean;
@ -222,6 +224,7 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
searchMatchKeys,
currentSearchMatch,
formatCell,
draftCellPlaceholder,
isRowActive,
rowCellsUseSelectionVisual,
cellIsSelected,
@ -275,13 +278,13 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
const y = rowIndex * CANVAS_DATA_GRID_ROW_HEIGHT - scrollTop;
const rowIsActive = isRowActive(item.displayIndex);
const rowBase = item.isDeleted ? theme.rowDeleted : item.isNew && !rowIsActive ? theme.rowNew : item.displayIndex % 2 === 1 && !rowIsActive ? theme.rowMuted : theme.background;
const rowBase = item.isDeleted ? theme.rowDeleted : item.isNew && !rowIsActive ? theme.rowNew : item.isDraft && !rowIsActive ? theme.rowMuted : item.displayIndex % 2 === 1 && !rowIsActive ? theme.rowMuted : theme.background;
const rowBorderY = crispCanvasLine(y + CANVAS_DATA_GRID_ROW_HEIGHT - 1, dpr);
ctx.globalAlpha = item.isDeleted ? 0.7 : 1;
ctx.fillStyle = rowIsActive && !item.isDeleted ? theme.cellSelectedSingle : rowBase;
ctx.fillRect(0, y, width, CANVAS_DATA_GRID_ROW_HEIGHT);
const rowNumberFill = item.status === "new" ? theme.rowNumberNew : item.status === "edited" ? theme.rowNumberEdited : item.status === "deleted" ? theme.rowNumberDeleted : theme.rowNumberDefault;
const rowNumberFill = item.status === "draft" ? theme.rowNumberDefault : item.status === "new" ? theme.rowNumberNew : item.status === "edited" ? theme.rowNumberEdited : item.status === "deleted" ? theme.rowNumberDeleted : theme.rowNumberDefault;
ctx.fillStyle = rowNumberFill;
ctx.fillRect(0, y, rowNumberWidth, CANVAS_DATA_GRID_ROW_HEIGHT);
ctx.strokeStyle = theme.border;
@ -292,10 +295,12 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
const rowNumberText = item.status === "new" ? theme.rowNumberTextNew : item.status === "edited" ? theme.rowNumberTextEdited : item.status === "deleted" ? theme.rowNumberTextDeleted : theme.rowNumberTextClean;
ctx.fillStyle = rowNumberText;
ctx.font = item.status === "new" || item.status === "edited" ? semiboldFont : normalFont;
ctx.font = item.status === "new" || item.status === "edited" || item.status === "draft" ? semiboldFont : normalFont;
ctx.textAlign = "center";
const textY = alignCanvasPixel(y + rowTextOffsetY, dpr);
if (infiniteScrollEnabled) {
if (item.isDraft) {
ctx.fillText("*", rowNumberTextX, textY);
} else if (infiniteScrollEnabled) {
ctx.fillText(String(item.displayIndex + 1), rowNumberTextX, textY);
} else {
ctx.fillText(String(item.displayIndex + 1 + pageSize * (currentPage - 1)), rowNumberTextX, textY);
@ -367,10 +372,12 @@ export function drawCanvasDataGrid(options: DrawCanvasDataGridOptions) {
ctx.font = value === null ? italicFont : tabularFont;
setCanvasNumericVariant(ctx, value === null ? "normal" : "tabular-nums");
const textLeft = alignCanvasPixel(x + 12, dpr);
const textMaxWidth = Math.max(0, x + colWidth - textLeft - 12);
const paddedMaxWidth = Math.max(0, x + colWidth - textLeft - 12);
const isEditingThisCell = editingCell?.rowId === item.id && editingCell.col === actualColIdx;
const displayText = isEditingThisCell ? "" : formatCell(value, actualColIdx);
const text = isEditingThisCell ? displayText : fitCanvasText(ctx, displayText, textMaxWidth);
const displayText = isEditingThisCell ? "" : item.isDraft && value === null ? (draftCellPlaceholder ?? "") : formatCell(value, actualColIdx);
const needsTruncation = ctx.measureText(displayText).width > paddedMaxWidth;
const textMaxWidth = needsTruncation ? Math.max(0, x + colWidth - textLeft) : paddedMaxWidth;
const text = isEditingThisCell ? displayText : fitCanvasText(ctx, displayText, textMaxWidth - 12);
ctx.fillText(text, textLeft, textY);
if (item.isDeleted && text) {
const textWidth = Math.min(ctx.measureText(text).width, textMaxWidth);

View File

@ -1,7 +1,8 @@
export type RowStatus = "clean" | "edited" | "new" | "deleted";
export type RowStatus = "clean" | "edited" | "new" | "deleted" | "draft";
export type RowStatusFilter = "all" | "changed" | "edited" | "new" | "deleted";
export function matchesRowStatusFilter(status: RowStatus, filter: RowStatusFilter): boolean {
if (status === "draft") return filter === "all";
if (filter === "all") return true;
if (filter === "changed") return status !== "clean";
return status === filter;
@ -10,3 +11,19 @@ export function matchesRowStatusFilter(status: RowStatus, filter: RowStatusFilte
export function rowStatusFilterAfterAddingRow(filter: RowStatusFilter): RowStatusFilter {
return matchesRowStatusFilter("new", filter) ? filter : "all";
}
export function shouldShowQuickEntryDraftRow(options: { editable: boolean; hasInsertTarget: boolean; quickEntryEnabled: boolean; rowStatusFilter: RowStatusFilter; hasPendingChanges: boolean }): boolean {
return options.editable && options.hasInsertTarget && options.quickEntryEnabled && !options.hasPendingChanges && matchesRowStatusFilter("draft", options.rowStatusFilter);
}
export function canDeleteGridRowItem(options: { editable: boolean; isDraft: boolean; isDeleted: boolean; isNew: boolean; canEditExistingRows: boolean; isSavingNewRow: boolean }): boolean {
return options.editable && !options.isDraft && !options.isDeleted && !options.isSavingNewRow && (options.isNew || options.canEditExistingRows);
}
export function canEditGridCellDetail(options: { canEditCell: boolean; isDraft: boolean }): boolean {
return options.canEditCell && !options.isDraft;
}
export function canApplyGridSelectionValue(options: { isDraft: boolean; allowDraft?: boolean }): boolean {
return !options.isDraft || options.allowDraft === true;
}

View File

@ -34,6 +34,10 @@ export function isTableDataEditable(databaseType: DatabaseType | undefined, prim
return primaryKeys.length > 0;
}
export function canInsertTableRows(databaseType: DatabaseType | undefined): boolean {
return getDatabaseCapability(databaseType).tableData.insert;
}
export function supportsDataGridTransaction(databaseType: DatabaseType | undefined): boolean {
return getDatabaseCapability(databaseType).tableData.transaction;
}

View File

@ -340,6 +340,7 @@ export interface EditorSettings {
showColumnCommentsInHeader: boolean;
showColumnTypesInHeader: boolean;
compactColumnHeaderActions: boolean;
dataGridQuickEntry: boolean;
dataGridRenderMode: DataGridRenderMode;
tableFontSize: number;
structureEditorDensity: StructureEditorDensity;
@ -445,6 +446,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
showColumnCommentsInHeader: true,
showColumnTypesInHeader: true,
compactColumnHeaderActions: true,
dataGridQuickEntry: false,
dataGridRenderMode: "canvas",
tableFontSize: TABLE_FONT_SIZE_DEFAULT,
structureEditorDensity: "compact",
@ -626,6 +628,7 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
showColumnCommentsInHeader: settings.showColumnCommentsInHeader ?? DEFAULT_EDITOR_SETTINGS.showColumnCommentsInHeader,
showColumnTypesInHeader: settings.showColumnTypesInHeader ?? DEFAULT_EDITOR_SETTINGS.showColumnTypesInHeader,
compactColumnHeaderActions: settings.compactColumnHeaderActions ?? DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions,
dataGridQuickEntry: settings.dataGridQuickEntry ?? DEFAULT_EDITOR_SETTINGS.dataGridQuickEntry,
dataGridRenderMode: normalizeDataGridRenderMode(settings.dataGridRenderMode),
tableFontSize: normalizeTableFontSize(settings.tableFontSize),
structureEditorDensity: normalizeStructureEditorDensity(settings.structureEditorDensity),
@ -799,6 +802,7 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.showColumnCommentsInHeader !== undefined) editorSettings.value.showColumnCommentsInHeader = partial.showColumnCommentsInHeader;
if (partial.showColumnTypesInHeader !== undefined) editorSettings.value.showColumnTypesInHeader = partial.showColumnTypesInHeader;
if (partial.compactColumnHeaderActions !== undefined) editorSettings.value.compactColumnHeaderActions = partial.compactColumnHeaderActions;
if (partial.dataGridQuickEntry !== undefined) editorSettings.value.dataGridQuickEntry = partial.dataGridQuickEntry;
if (partial.dataGridRenderMode !== undefined) editorSettings.value.dataGridRenderMode = normalizeDataGridRenderMode(partial.dataGridRenderMode);
if (partial.tableFontSize !== undefined) editorSettings.value.tableFontSize = normalizeTableFontSize(partial.tableFontSize);
if (partial.structureEditorDensity !== undefined) editorSettings.value.structureEditorDensity = normalizeStructureEditorDensity(partial.structureEditorDensity);

View File

@ -2,9 +2,10 @@ import { strict as assert } from "node:assert";
import { test } from "vitest";
import { computed, nextTick, ref } from "vue";
import { createPinia, setActivePinia } from "pinia";
import { useDataGridEditor } from "../../apps/desktop/src/composables/useDataGridEditor.ts";
import { DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, useDataGridEditor } from "../../apps/desktop/src/composables/useDataGridEditor.ts";
import type { CellValue } from "../../apps/desktop/src/lib/cellValue.ts";
import type { DataGridSaveStatementOptions } from "../../apps/desktop/src/lib/dataGridSql.ts";
import { matchesRowStatusFilter, type RowStatusFilter } from "../../apps/desktop/src/lib/gridRowStatus.ts";
import type { ColumnInfo } from "../../apps/desktop/src/types/database.ts";
function installBrowserTestGlobals() {
@ -102,8 +103,89 @@ function column(name: string, isPrimaryKey = false, extra: string | null = null)
};
}
function createQuickEntryEditor(options: { quickEntryEnabled: boolean; cacheKey?: string; rowStatusFilter?: ReturnType<typeof ref<RowStatusFilter>>; filterRowsInGetRowItem?: boolean; supportsInsert?: boolean; save?: (changes: { dirtyRows: Map<number, Map<number, CellValue>>; newRows: CellValue[][] }) => Promise<void> }) {
const result = computed(() => ({
columns: ["id", "name"],
rows: [[1, "Ada"] as CellValue[]],
}));
const rowStatusFilter = options.rowStatusFilter ?? ref<RowStatusFilter>("all");
let editor: ReturnType<typeof useDataGridEditor>;
editor = useDataGridEditor({
result,
editable: computed(() => true),
databaseType: computed(() => "postgres"),
connectionId: computed(() => undefined),
database: computed(() => undefined),
tableMeta: computed(() => ({
tableName: "people",
columns: [column("id", true), column("name")],
primaryKeys: ["id"],
})),
onExecuteSql: computed(() => undefined),
customSaveHandler: computed(() => ({
save: options.save ?? (async () => {}),
supportsInsert: options.supportsInsert ?? true,
})),
sql: computed(() => "SELECT id, name FROM people"),
searchText: ref(""),
whereFilterInput: ref(""),
orderByInput: ref(""),
currentWhereInput: computed(() => undefined),
rowStatusFilter,
dataGridQuickEntryEnabled: computed(() => options.quickEntryEnabled),
pageSize: ref(50),
currentPage: ref(1),
cacheKey: options.cacheKey ? computed(() => options.cacheKey) : undefined,
getRowItem: (rowId) => {
if (rowId === DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID) {
editor.ensureQuickEntryDraftRow();
return {
id: DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID,
data: editor.quickEntryDraftRow.value,
isNew: false,
isDraft: true,
isDeleted: false,
isDirtyCol: [false, false],
status: "draft",
};
}
if (rowId === 0) {
const dirty = editor.dirtyRows.value.get(0);
const status = dirty?.size ? "edited" : "clean";
if (options.filterRowsInGetRowItem && !matchesRowStatusFilter(status, rowStatusFilter.value)) return undefined;
return {
id: 0,
sourceIndex: 0,
data: editor.rowDataWithChanges(result.value.rows[0], 0),
isNew: false,
isDeleted: false,
isDirtyCol: [false, !!dirty?.has(1)],
status,
};
}
if (rowId < 0) {
const newIndex = -rowId - 1;
const row = editor.newRows.value[newIndex];
if (!row) return undefined;
return {
id: rowId,
newIndex,
data: row,
isNew: true,
isDeleted: false,
isDirtyCol: [false, false],
status: "new",
};
}
return undefined;
},
emit: () => {},
});
return editor;
}
function createPeopleGridEditor(result = computed(() => ({ columns: ["id", "name"], rows: [[1, "Ada"] as CellValue[]] }))) {
const rowStatusFilter = ref<"all" | "changed" | "edited" | "new" | "deleted">("all");
const rowStatusFilter = ref<RowStatusFilter>("all");
let editor: ReturnType<typeof useDataGridEditor>;
editor = useDataGridEditor({
@ -232,6 +314,7 @@ test("cloning a row copies non-generated primary key values without executing sa
save: async () => {
saveCalls += 1;
},
supportsInsert: true,
})),
sql: computed(() => undefined),
searchText: ref(""),
@ -498,6 +581,85 @@ test("saving edited rows without deletes does not reload table data", async () =
await editor.saveChanges();
assert.deepEqual(emitted, []);
assert.deepEqual(result.value.rows[0], [1, "Ada Lovelace"]);
});
test("undo and redo restore pending cell edits before save", () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
const result = computed(() => ({
columns: ["id", "name"],
rows: [[1, "Ada"] as CellValue[]],
}));
const editor = createPeopleGridEditor(result);
editor.applyCellValue(0, 1, "Ada Lovelace");
assert.equal(editor.canUndoPendingChange.value, true);
assert.equal(editor.canRedoPendingChange.value, false);
assert.deepEqual(editor.rowDataWithChanges(result.value.rows[0], 0), [1, "Ada Lovelace"]);
editor.undoPendingChange();
assert.equal(editor.canUndoPendingChange.value, false);
assert.equal(editor.canRedoPendingChange.value, true);
assert.equal(editor.dirtyRows.value.size, 0);
assert.deepEqual(editor.rowDataWithChanges(result.value.rows[0], 0), [1, "Ada"]);
editor.redoPendingChange();
assert.equal(editor.canUndoPendingChange.value, true);
assert.equal(editor.canRedoPendingChange.value, false);
assert.deepEqual(editor.rowDataWithChanges(result.value.rows[0], 0), [1, "Ada Lovelace"]);
});
test("restoring a pending cell edit records undo and redo history", () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
const result = computed(() => ({
columns: ["id", "name"],
rows: [[1, "Ada"] as CellValue[]],
}));
const editor = createPeopleGridEditor(result);
editor.applyCellValue(0, 1, "Ada Lovelace");
editor.restoreCellValue(0, 1);
assert.equal(editor.canUndoPendingChange.value, true);
assert.equal(editor.canRedoPendingChange.value, false);
assert.equal(editor.dirtyRows.value.size, 0);
assert.deepEqual(editor.rowDataWithChanges(result.value.rows[0], 0), [1, "Ada"]);
editor.undoPendingChange();
assert.equal(editor.canUndoPendingChange.value, true);
assert.equal(editor.canRedoPendingChange.value, true);
assert.deepEqual(editor.rowDataWithChanges(result.value.rows[0], 0), [1, "Ada Lovelace"]);
editor.redoPendingChange();
assert.equal(editor.canUndoPendingChange.value, true);
assert.equal(editor.canRedoPendingChange.value, false);
assert.equal(editor.dirtyRows.value.size, 0);
assert.deepEqual(editor.rowDataWithChanges(result.value.rows[0], 0), [1, "Ada"]);
});
test("undo and redo cover row add and delete operations", () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
const editor = createPeopleGridEditor();
editor.addRow();
assert.equal(editor.newRows.value.length, 1);
editor.undoPendingChange();
assert.equal(editor.newRows.value.length, 0);
editor.redoPendingChange();
assert.equal(editor.newRows.value.length, 1);
editor.applyDeleteRow(0);
assert.deepEqual([...editor.deletedRows.value], [0]);
editor.undoPendingChange();
assert.deepEqual([...editor.deletedRows.value], []);
assert.equal(editor.newRows.value.length, 1);
editor.redoPendingChange();
assert.deepEqual([...editor.deletedRows.value], [0]);
});
test("undo and redo restore pending cell edits before save", () => {
@ -795,3 +957,573 @@ test("failed table data save records a failed history entry", async () => {
assert.equal(historyEntry.affected_rows, undefined);
assert.equal(historyEntry.sql, `UPDATE "pp_questions" SET "title" = 'New title' WHERE "id" = 1;`);
});
test("quick entry off keeps blur edits pending without saving", async () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
let saveCalls = 0;
const editor = createQuickEntryEditor({
quickEntryEnabled: false,
save: async () => {
saveCalls += 1;
},
});
editor.startEdit(0, 1);
editor.editValue.value = "Ada Lovelace";
await editor.commitEditFromBlur();
assert.equal(saveCalls, 0);
assert.equal(editor.dirtyRows.value.size, 1);
assert.equal(editor.dirtyRows.value.get(0)?.get(1), "Ada Lovelace");
});
test("quick entry on saves existing row edits on blur", async () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
let saveCalls = 0;
const editor = createQuickEntryEditor({
quickEntryEnabled: true,
save: async () => {
saveCalls += 1;
},
});
editor.startEdit(0, 1);
editor.editValue.value = "Ada Lovelace";
await editor.commitEditFromBlur();
assert.equal(saveCalls, 1);
assert.equal(editor.dirtyRows.value.size, 0);
assert.equal(editor.saveError.value, "");
});
test("quick entry on saves existing row edits on Enter commit", async () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
let saveCalls = 0;
let saveFinished!: () => void;
const saveFinishedPromise = new Promise<void>((resolve) => {
saveFinished = resolve;
});
const savedDirtyRows: Array<Array<[number, Array<[number, CellValue]>]>> = [];
const editor = createQuickEntryEditor({
quickEntryEnabled: true,
save: async ({ dirtyRows }) => {
saveCalls += 1;
savedDirtyRows.push([...dirtyRows.entries()].map(([rowIndex, changes]) => [rowIndex, [...changes.entries()]]));
saveFinished();
},
});
editor.startEdit(0, 1);
editor.editValue.value = "Ada Lovelace";
editor.onEditKeydown({
key: "Enter",
preventDefault: () => {},
stopPropagation: () => {},
} as KeyboardEvent);
await saveFinishedPromise;
assert.equal(saveCalls, 1);
assert.deepEqual(savedDirtyRows, [[[0, [[1, "Ada Lovelace"]]]]]);
assert.equal(editor.dirtyRows.value.size, 0);
assert.equal(editor.saveError.value, "");
});
test("quick entry queues blur saves made while a save is running", async () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
let saveCalls = 0;
let firstSaveStarted!: () => void;
let releaseFirstSave!: () => void;
const firstSaveStartedPromise = new Promise<void>((resolve) => {
firstSaveStarted = resolve;
});
const firstSaveBlocker = new Promise<void>((resolve) => {
releaseFirstSave = resolve;
});
const savedDirtyRows: Array<Array<[number, Array<[number, CellValue]>]>> = [];
const editor = createQuickEntryEditor({
quickEntryEnabled: true,
save: async ({ dirtyRows }) => {
saveCalls += 1;
savedDirtyRows.push([...dirtyRows.entries()].map(([rowIndex, changes]) => [rowIndex, [...changes.entries()]]));
if (saveCalls === 1) {
firstSaveStarted();
await firstSaveBlocker;
}
},
});
editor.startEdit(0, 1);
editor.editValue.value = "Ada 1";
const firstCommit = editor.commitEditFromBlur();
await firstSaveStartedPromise;
editor.startEdit(0, 1);
editor.editValue.value = "Ada 2";
await editor.commitEditFromBlur();
assert.equal(saveCalls, 1);
assert.equal(editor.dirtyRows.value.get(0)?.get(1), "Ada 2");
releaseFirstSave();
await firstCommit;
assert.equal(saveCalls, 2);
assert.deepEqual(savedDirtyRows, [
[[0, [[1, "Ada 1"]]]],
[[0, [[1, "Ada 2"]]]],
]);
assert.equal(editor.dirtyRows.value.size, 0);
assert.equal(editor.saveError.value, "");
});
test("quick entry replays queued saves when row-status filters hide the saved row", async () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
const rowStatusFilter = ref<RowStatusFilter>("edited");
let saveCalls = 0;
let firstSaveStarted!: () => void;
let releaseFirstSave!: () => void;
const firstSaveStartedPromise = new Promise<void>((resolve) => {
firstSaveStarted = resolve;
});
const firstSaveBlocker = new Promise<void>((resolve) => {
releaseFirstSave = resolve;
});
const savedDirtyRows: Array<Array<[number, Array<[number, CellValue]>]>> = [];
const editor = createQuickEntryEditor({
quickEntryEnabled: true,
rowStatusFilter,
filterRowsInGetRowItem: true,
save: async ({ dirtyRows }) => {
saveCalls += 1;
savedDirtyRows.push([...dirtyRows.entries()].map(([rowIndex, changes]) => [rowIndex, [...changes.entries()]]));
if (saveCalls === 1) {
firstSaveStarted();
await firstSaveBlocker;
}
},
});
rowStatusFilter.value = "all";
editor.startEdit(0, 1);
editor.editValue.value = "Ada 1";
const firstCommit = editor.commitEditFromBlur();
await firstSaveStartedPromise;
rowStatusFilter.value = "edited";
editor.startEdit(0, 1);
editor.editValue.value = "Ada 2";
await editor.commitEditFromBlur();
assert.equal(saveCalls, 1);
assert.equal(editor.dirtyRows.value.get(0)?.get(1), "Ada 2");
releaseFirstSave();
await firstCommit;
assert.equal(saveCalls, 2);
assert.deepEqual(savedDirtyRows, [
[[0, [[1, "Ada 1"]]]],
[[0, [[1, "Ada 2"]]]],
]);
assert.equal(editor.dirtyRows.value.size, 0);
assert.equal(editor.saveError.value, "");
});
test("quick entry preserves an active edit when auto-save clears the row from a status filter", async () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
const rowStatusFilter = ref<RowStatusFilter>("edited");
let saveCalls = 0;
let firstSaveStarted!: () => void;
let releaseFirstSave!: () => void;
const firstSaveStartedPromise = new Promise<void>((resolve) => {
firstSaveStarted = resolve;
});
const firstSaveBlocker = new Promise<void>((resolve) => {
releaseFirstSave = resolve;
});
const savedDirtyRows: Array<Array<[number, Array<[number, CellValue]>]>> = [];
const editor = createQuickEntryEditor({
quickEntryEnabled: true,
rowStatusFilter,
filterRowsInGetRowItem: true,
save: async ({ dirtyRows }) => {
saveCalls += 1;
savedDirtyRows.push([...dirtyRows.entries()].map(([rowIndex, changes]) => [rowIndex, [...changes.entries()]]));
if (saveCalls === 1) {
firstSaveStarted();
await firstSaveBlocker;
}
},
});
rowStatusFilter.value = "all";
editor.startEdit(0, 1);
editor.editValue.value = "Ada 1";
const firstCommit = editor.commitEditFromBlur();
await firstSaveStartedPromise;
rowStatusFilter.value = "edited";
editor.startEdit(0, 0);
editor.editValue.value = "2";
releaseFirstSave();
await firstCommit;
assert.equal(editor.dirtyRows.value.size, 0);
assert.equal(editor.editingCell.value?.rowId, 0);
await editor.commitEditFromBlur();
assert.equal(saveCalls, 2);
assert.deepEqual(savedDirtyRows, [
[[0, [[1, "Ada 1"]]]],
[[0, [[0, 2]]]],
]);
assert.equal(editor.dirtyRows.value.size, 0);
});
test("quick entry keeps queued blur save after an earlier auto-save fails", async () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
let saveCalls = 0;
let firstSaveStarted!: () => void;
let releaseFirstSave!: () => void;
const firstSaveStartedPromise = new Promise<void>((resolve) => {
firstSaveStarted = resolve;
});
const firstSaveBlocker = new Promise<void>((resolve) => {
releaseFirstSave = resolve;
});
const savedDirtyRows: Array<Array<[number, Array<[number, CellValue]>]>> = [];
const editor = createQuickEntryEditor({
quickEntryEnabled: true,
save: async ({ dirtyRows }) => {
saveCalls += 1;
savedDirtyRows.push([...dirtyRows.entries()].map(([rowIndex, changes]) => [rowIndex, [...changes.entries()]]));
if (saveCalls === 1) {
firstSaveStarted();
await firstSaveBlocker;
throw new Error("first save failed");
}
},
});
editor.startEdit(0, 1);
editor.editValue.value = "Ada 1";
const firstCommit = editor.commitEditFromBlur();
await firstSaveStartedPromise;
editor.startEdit(0, 1);
editor.editValue.value = "Ada 2";
await editor.commitEditFromBlur();
assert.equal(saveCalls, 1);
assert.equal(editor.dirtyRows.value.get(0)?.get(1), "Ada 2");
releaseFirstSave();
await firstCommit;
assert.equal(saveCalls, 2);
assert.deepEqual(savedDirtyRows, [
[[0, [[1, "Ada 1"]]]],
[[0, [[1, "Ada 2"]]]],
]);
assert.equal(editor.dirtyRows.value.size, 0);
assert.equal(editor.saveError.value, "");
});
test("quick entry saves a queued revert made while an earlier auto-save is running", async () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
let saveCalls = 0;
let firstSaveStarted!: () => void;
let releaseFirstSave!: () => void;
const firstSaveStartedPromise = new Promise<void>((resolve) => {
firstSaveStarted = resolve;
});
const firstSaveBlocker = new Promise<void>((resolve) => {
releaseFirstSave = resolve;
});
const savedDirtyRows: Array<Array<[number, Array<[number, CellValue]>]>> = [];
const editor = createQuickEntryEditor({
quickEntryEnabled: true,
save: async ({ dirtyRows }) => {
saveCalls += 1;
savedDirtyRows.push([...dirtyRows.entries()].map(([rowIndex, changes]) => [rowIndex, [...changes.entries()]]));
if (saveCalls === 1) {
firstSaveStarted();
await firstSaveBlocker;
}
},
});
editor.startEdit(0, 1);
editor.editValue.value = "Ada 1";
const firstCommit = editor.commitEditFromBlur();
await firstSaveStartedPromise;
editor.startEdit(0, 1);
editor.editValue.value = "Ada";
await editor.commitEditFromBlur();
assert.equal(saveCalls, 1);
assert.equal(editor.dirtyRows.value.size, 0);
releaseFirstSave();
await firstCommit;
assert.equal(saveCalls, 2);
assert.deepEqual(savedDirtyRows, [
[[0, [[1, "Ada 1"]]]],
[[0, [[1, "Ada"]]]],
]);
assert.equal(editor.dirtyRows.value.size, 0);
assert.equal(editor.saveError.value, "");
});
test("quick entry blocks editing a pending new row while its insert is saving", async () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
let saveCalls = 0;
let firstSaveStarted!: () => void;
let releaseFirstSave!: () => void;
const firstSaveStartedPromise = new Promise<void>((resolve) => {
firstSaveStarted = resolve;
});
const firstSaveBlocker = new Promise<void>((resolve) => {
releaseFirstSave = resolve;
});
const savedNewRows: CellValue[][][] = [];
const editor = createQuickEntryEditor({
quickEntryEnabled: true,
save: async ({ newRows }) => {
saveCalls += 1;
savedNewRows.push(newRows.map((row) => [...row]));
if (saveCalls === 1) {
firstSaveStarted();
await firstSaveBlocker;
}
},
});
editor.startEdit(DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, 1);
editor.editValue.value = "Grace";
const firstCommit = editor.commitEditFromBlur();
await firstSaveStartedPromise;
const pendingNewRowItem = {
id: -1,
newIndex: 0,
data: editor.newRows.value[0],
isNew: true,
isDeleted: false,
isDirtyCol: [false, false],
status: "new",
};
assert.equal(editor.isSavingNewRow(pendingNewRowItem), true);
editor.startEdit(-1, 1);
assert.equal(editor.editingCell.value, null);
assert.equal(saveCalls, 1);
assert.deepEqual(editor.newRows.value, [[null, "Grace"]]);
releaseFirstSave();
await firstCommit;
assert.equal(saveCalls, 1);
assert.deepEqual(savedNewRows, [[[null, "Grace"]]]);
assert.deepEqual(editor.newRows.value, []);
assert.equal(editor.isSavingNewRow(pendingNewRowItem), false);
assert.equal(editor.saveError.value, "");
});
test("quick entry draft row ignores blank blur commits", async () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
let saveCalls = 0;
const editor = createQuickEntryEditor({
quickEntryEnabled: true,
save: async () => {
saveCalls += 1;
},
});
editor.startEdit(DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, 1);
editor.editValue.value = " ";
await editor.commitEditFromBlur();
assert.equal(saveCalls, 0);
assert.deepEqual(editor.newRows.value, []);
assert.deepEqual(editor.quickEntryDraftRow.value, [null, null]);
});
test("quick entry draft row becomes a new row and saves on blur", async () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
let saveCalls = 0;
const savedNewRows: CellValue[][][] = [];
const editor = createQuickEntryEditor({
quickEntryEnabled: true,
save: async ({ newRows }) => {
saveCalls += 1;
savedNewRows.push(newRows.map((row) => [...row]));
},
});
editor.startEdit(DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, 1);
editor.editValue.value = "Grace";
await editor.commitEditFromBlur();
assert.equal(saveCalls, 1);
assert.deepEqual(savedNewRows, [[[null, "Grace"]]]);
assert.deepEqual(editor.newRows.value, []);
assert.deepEqual(editor.quickEntryDraftRow.value, [null, null]);
assert.equal(editor.saveError.value, "");
});
test("custom save handler without insert support keeps pending new rows", async () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
let saveCalls = 0;
const editor = createQuickEntryEditor({
quickEntryEnabled: true,
supportsInsert: false,
save: async () => {
saveCalls += 1;
},
});
editor.newRows.value = [[null, "Grace"]];
await editor.saveChanges();
assert.equal(saveCalls, 0);
assert.equal(editor.saveError.value, "当前保存目标不支持新增行。");
assert.deepEqual(editor.newRows.value, [[null, "Grace"]]);
assert.equal(editor.hasPendingChanges.value, true);
});
test("quick entry draft row pasted values become a new row and save once", async () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
let saveCalls = 0;
const savedNewRows: CellValue[][][] = [];
const editor = createQuickEntryEditor({
quickEntryEnabled: true,
save: async ({ newRows }) => {
saveCalls += 1;
savedNewRows.push(newRows.map((row) => [...row]));
},
});
editor.applyCellValue(DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, 0, "2");
editor.applyCellValue(DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, 1, "Grace");
await Promise.resolve();
await Promise.resolve();
assert.equal(saveCalls, 1);
assert.deepEqual(savedNewRows, [[["2", "Grace"]]]);
assert.deepEqual(editor.newRows.value, []);
assert.deepEqual(editor.quickEntryDraftRow.value, [null, null]);
assert.equal(editor.saveError.value, "");
});
test("quick entry off keeps pasted draft row as a pending new row", async () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
let saveCalls = 0;
const editor = createQuickEntryEditor({
quickEntryEnabled: false,
save: async () => {
saveCalls += 1;
},
});
editor.applyCellValue(DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, 1, "Grace");
await Promise.resolve();
assert.equal(saveCalls, 0);
assert.deepEqual(editor.newRows.value, [[null, "Grace"]]);
assert.deepEqual(editor.quickEntryDraftRow.value, [null, null]);
assert.equal(editor.hasPendingChanges.value, true);
});
test("quick entry draft row keeps editing across draft cells without saving", async () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
let saveCalls = 0;
const editor = createQuickEntryEditor({
quickEntryEnabled: true,
save: async () => {
saveCalls += 1;
},
});
editor.startEdit(DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, 1);
editor.editValue.value = "Grace";
await editor.commitEditFromBlur({ promoteDraft: false });
assert.equal(saveCalls, 0);
assert.deepEqual(editor.newRows.value, []);
assert.deepEqual(editor.quickEntryDraftRow.value, [null, "Grace"]);
assert.equal(editor.saveError.value, "");
});
test("quick entry draft row survives pending snapshot restore", async () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
const firstEditor = createQuickEntryEditor({
quickEntryEnabled: true,
cacheKey: "quick-entry-draft-snapshot",
});
firstEditor.startEdit(DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, 1);
firstEditor.editValue.value = "Grace";
await firstEditor.commitEditFromBlur({ promoteDraft: false });
firstEditor.startEdit(DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, 0);
firstEditor.editValue.value = "2";
firstEditor.savePendingSnapshot(true, false);
const restoredEditor = createQuickEntryEditor({
quickEntryEnabled: true,
cacheKey: "quick-entry-draft-snapshot",
});
assert.deepEqual(restoredEditor.quickEntryDraftRow.value, [null, "Grace"]);
assert.deepEqual(restoredEditor.editingCell.value, { rowId: DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, col: 0 });
assert.equal(restoredEditor.editValue.value, "2");
await restoredEditor.commitEditFromBlur();
assert.deepEqual(restoredEditor.newRows.value, []);
assert.deepEqual(restoredEditor.quickEntryDraftRow.value, [null, null]);
});
test("quick entry auto-save failure keeps the pending new row", async () => {
setActivePinia(createPinia());
installBrowserTestGlobals();
const editor = createQuickEntryEditor({
quickEntryEnabled: true,
save: async () => {
throw new Error("insert failed");
},
});
editor.startEdit(DATA_GRID_QUICK_ENTRY_DRAFT_ROW_ID, 1);
editor.editValue.value = "Grace";
await editor.commitEditFromBlur();
assert.equal(editor.saveError.value, "insert failed");
assert.deepEqual(editor.newRows.value, [[null, "Grace"]]);
assert.equal(editor.hasPendingChanges.value, true);
editor.startEdit(-1, 1);
assert.deepEqual(editor.editingCell.value, { rowId: -1, col: 1 });
});

View File

@ -0,0 +1,128 @@
import { strict as assert } from "node:assert";
import { beforeEach, test, vi } from "vitest";
import { computed, ref } from "vue";
import { useDataGridExport } from "../../apps/desktop/src/composables/useDataGridExport.ts";
import { copyToClipboard } from "@/lib/clipboard";
import * as api from "@/lib/api";
vi.mock("vue-i18n", () => ({
useI18n: () => ({
t: (key: string) => key,
}),
}));
vi.mock("@/lib/clipboard", () => ({
copyToClipboard: vi.fn(async () => {}),
}));
vi.mock("@/lib/api", () => ({
exportQueryResultCsv: vi.fn(async () => {}),
}));
const draftRowId = Number.MIN_SAFE_INTEGER;
function createExportContext(options: {
contextRowId?: number;
selectedRowIds?: Set<number>;
fullExportResult?: () => Promise<{ columns: string[]; rows: Array<Array<string | number | boolean | null>>; affected_rows: number; execution_time_ms: number }>;
} = {}) {
const contextRowId = options.contextRowId ?? draftRowId;
const selectedRowIds = ref(options.selectedRowIds ?? new Set<number>());
const rows = [
{
id: 0,
sourceIndex: 0,
data: [1, "Ada"],
isNew: false,
isDeleted: false,
isDirtyCol: [false, false],
status: "clean",
},
{
id: draftRowId,
data: [null, null],
isNew: false,
isDraft: true,
isDeleted: false,
isDirtyCol: [false, false],
status: "draft",
},
];
return useDataGridExport({
columns: computed(() => ["id", "name"]),
displayItems: computed(() => rows),
sql: computed(() => undefined),
tableMeta: computed(() => undefined),
databaseType: computed(() => "postgres"),
connectionId: computed(() => undefined),
database: computed(() => undefined),
context: computed(() => "table-data"),
sourceColumns: computed(() => undefined),
columnTypes: computed(() => undefined),
whereInput: computed(() => undefined),
orderBy: computed(() => undefined),
exportBatchSize: computed(() => 1000),
hasCellSelection: computed(() => false),
selectedCells: computed(() => ({ columns: [], rows: [] })),
selectedRange: computed(() => null),
contextCell: ref({ rowId: contextRowId, rowIndex: contextRowId === draftRowId ? 1 : 0, col: 1 }),
getRowItem: (rowId) => rows.find((row) => row.id === rowId),
selectedRowIds,
hasRowSelection: computed(() => selectedRowIds.value.size > 0),
fullExportResult: options.fullExportResult,
});
}
beforeEach(() => {
globalThis.window = {
setTimeout,
clearTimeout,
} as unknown as Window & typeof globalThis;
vi.mocked(copyToClipboard).mockClear();
vi.mocked(api.exportQueryResultCsv).mockClear();
});
test("direct draft cell copy is ignored", async () => {
const exporter = createExportContext();
await exporter.copyCell();
assert.equal(vi.mocked(copyToClipboard).mock.calls.length, 0);
});
test("direct draft row copy is ignored", async () => {
const exporter = createExportContext();
await exporter.copyRow();
assert.equal(vi.mocked(copyToClipboard).mock.calls.length, 0);
});
test("selected draft row TSV copy is ignored", async () => {
const exporter = createExportContext({ selectedRowIds: new Set([draftRowId]) });
await exporter.copySelectedRowsTsv();
assert.equal(vi.mocked(copyToClipboard).mock.calls.length, 0);
});
test("empty selected row export does not fall back to full export", async () => {
let fullExportCalls = 0;
const exporter = createExportContext({
fullExportResult: async () => {
fullExportCalls += 1;
return {
columns: ["id", "name"],
rows: [[1, "Ada"]],
affected_rows: 1,
execution_time_ms: 1,
};
},
});
await exporter.exportCsv([]);
assert.equal(fullExportCalls, 0);
assert.deepEqual(vi.mocked(api.exportQueryResultCsv).mock.calls[0]?.slice(1, 3), [["id", "name"], []]);
});

View File

@ -50,6 +50,36 @@ function mouseEvent(options: Partial<MouseEvent> = {}): MouseEvent {
} as MouseEvent;
}
function createSelectionWithDraftRow() {
return useDataGridSelection({
columns: computed(() => ["id", "name"]),
displayItems: computed(() => [
{
id: 0,
sourceIndex: 0,
data: [1, "Ada"],
isNew: false,
isDeleted: false,
isDirtyCol: [false, false],
status: "clean",
},
{
id: Number.MIN_SAFE_INTEGER,
data: [null, null],
isNew: false,
isDraft: true,
isDeleted: false,
isDirtyCol: [false, false],
status: "draft",
},
]),
editingCell: ref(null),
showTranspose: ref(false),
transposeRowIndex: ref(null),
gridRef: ref(undefined),
});
}
test("cell selection does not read row data before a range exists", () => {
let displayItemsReads = 0;
const selection = useDataGridSelection({
@ -78,6 +108,57 @@ test("cell selection does not read row data before a range exists", () => {
assert.equal(displayItemsReads, 0);
});
test("all-cell selection excludes the quick entry draft row from selected data", () => {
const selection = createSelectionWithDraftRow();
selection.selectAllCells();
assert.deepEqual(selection.selectedCells.value, {
columns: ["id", "name"],
rows: [[1, "Ada"]],
});
assert.equal(selection.selectedCellCount.value, 2);
});
test("column selection excludes the quick entry draft row from selected data", () => {
const selection = createSelectionWithDraftRow();
selection.selectColumn(1);
assert.deepEqual(selection.selectedCells.value, {
columns: ["name"],
rows: [["Ada"]],
});
assert.equal(selection.selectedCellCount.value, 1);
});
test("discrete draft cell selection is excluded from selected cell count", () => {
const selection = createSelectionWithDraftRow();
selection.selectSingleCell(1, 0);
selection.handleDataCellMousedown(1, 1, Number.MIN_SAFE_INTEGER, mouseEvent({ ctrlKey: true }));
assert.deepEqual(selection.selectedCells.value, {
columns: [],
rows: [],
});
assert.equal(selection.selectedCellCount.value, 0);
assert.equal(selection.hasCellSelection.value, false);
});
test("mixed discrete cell selection counts only real data cells", () => {
const selection = createSelectionWithDraftRow();
selection.selectSingleCell(0, 0);
selection.handleDataCellMousedown(1, 1, Number.MIN_SAFE_INTEGER, mouseEvent({ ctrlKey: true }));
assert.deepEqual(selection.selectedCells.value, {
columns: ["id"],
rows: [[1]],
});
assert.equal(selection.selectedCellCount.value, 1);
});
test("ctrl clicking cells toggles only the clicked cells", () => {
const selection = createSelection();

View File

@ -1,6 +1,6 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import { matchesRowStatusFilter, rowStatusFilterAfterAddingRow, type RowStatus } from "../../apps/desktop/src/lib/gridRowStatus.ts";
import { canApplyGridSelectionValue, matchesRowStatusFilter, rowStatusFilterAfterAddingRow, type RowStatus } from "../../apps/desktop/src/lib/gridRowStatus.ts";
const statuses: RowStatus[] = ["clean", "edited", "new", "deleted"];
@ -35,3 +35,9 @@ test("adding a row switches filters that would hide new rows back to all", () =>
assert.equal(rowStatusFilterAfterAddingRow("changed"), "changed");
assert.equal(rowStatusFilterAfterAddingRow("new"), "new");
});
test("selection value fill skips quick entry draft rows", () => {
assert.equal(canApplyGridSelectionValue({ isDraft: false }), true);
assert.equal(canApplyGridSelectionValue({ isDraft: true }), false);
assert.equal(canApplyGridSelectionValue({ isDraft: true, allowDraft: true }), true);
});

View File

@ -5,6 +5,7 @@ import {
DBX_ROWID_COLUMN,
DBX_TDENGINE_TBNAME_COLUMN,
canEditExistingTableRows,
canInsertTableRows,
editablePrimaryKeys,
hiveTablePropertiesIndicateTransactional,
isHiddenGridColumn,
@ -67,6 +68,15 @@ test("does not use transactional grid saves for non-transactional engines", () =
assert.equal(supportsDataGridTransaction("postgres"), true);
});
test("detects whether table data supports inserted rows", () => {
assert.equal(canInsertTableRows("postgres"), true);
assert.equal(canInsertTableRows("mysql"), true);
assert.equal(canInsertTableRows("hive"), true);
assert.equal(canInsertTableRows("jdbc"), false);
assert.equal(canInsertTableRows("clickhouse"), true);
assert.equal(canInsertTableRows("influxdb"), false);
});
test("allows existing row edits according to database-specific key requirements", () => {
assert.equal(canEditExistingTableRows("access", undefined, []), true);
assert.equal(canEditExistingTableRows("access", undefined, ["ID"]), true);