feat(grid): add data grid quick entry
This commit is contained in:
parent
074cef8358
commit
3e7cf30ce5
|
|
@ -369,6 +369,7 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
|
|||
const customSaveHandler = computed<CustomSaveHandler>(() => ({
|
||||
save: gridSave,
|
||||
preview: previewDocumentChanges,
|
||||
supportsInsert: true,
|
||||
}));
|
||||
|
||||
function stopDocumentLoadingTimer() {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "列を追加",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "无限滚动最大行数",
|
||||
|
|
|
|||
|
|
@ -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: "無限滾動最大筆數",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"], []]);
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue