feat(grid): improve save error feedback and dirty cell highlighting

This commit is contained in:
rihkddd authored and GitHub committed 2026-08-05 01:09:59 +08:00
1 parent a2e1128670
commit 27d72f8776
15 files changed
+130 -17

No files matched your search

+44 -10
View File
@@ -656,6 +656,7 @@ const { searchText, deferredSearchText: deferredClientSearchText, overlayVisible
const orderByInput = ref(props.initialOrderByInput ?? "");
const whereFilterInput = ref(props.initialWhereInput ?? "");
const queryControlError = ref("");
const conditionColumns = computed(() => dataGridConditionColumnOptions(props.tableMeta?.columns ?? props.result.columns, resolvedDatabaseType.value));
const conditionIdentifierQuote = computed(() => dataGridConditionIdentifierQuote(resolvedDatabaseType.value, connectionStore.connectionIdentifierQuote?.(props.connectionId)));
const conditionHistoryScope = computed(() => ({
@@ -4507,7 +4508,7 @@ async function applyOrderBySearch() {
emit("update:orderByInput", orderByInput.value);
if (orderByClause) rememberDataGridConditionHistory("orderBy", conditionHistoryScope.value, orderByClause);
isApplyingWhere.value = true;
saveError.value = "";
queryControlError.value = "";
currentPage.value = 1;
clearSort();
try {
@@ -4530,7 +4531,7 @@ async function applyOrderBySearch() {
});
await props.onExecuteSql(sql);
} catch (e: any) {
saveError.value = String(e?.message || e);
queryControlError.value = String(e?.message || e);
} finally {
isApplyingWhere.value = false;
}
@@ -4541,7 +4542,7 @@ async function applyWhereFilter() {
const whereInput = currentWhereInput();
if (whereInput) rememberDataGridConditionHistory("where", conditionHistoryScope.value, whereInput);
isApplyingWhere.value = true;
saveError.value = "";
queryControlError.value = "";
currentPage.value = 1;
emit("update:whereInput", whereInput ?? "");
try {
@@ -4564,7 +4565,7 @@ async function applyWhereFilter() {
});
await props.onExecuteSql(sql);
} catch (e: any) {
saveError.value = String(e?.message || e);
queryControlError.value = String(e?.message || e);
} finally {
isApplyingWhere.value = false;
}
@@ -4773,7 +4774,7 @@ const canvasSurfaceWidth = computed(() => {
if (vw <= 0) return total;
return Math.min(vw, total);
});
const canvasRenderStyleKey = computed(() => `${settingsStore.editorSettings.theme}:${settingsStore.editorSettings.uiScale}:${canvasBackingPixelRatio.value}:${isDark.value}:${themePalette.value}:${tableFontFamily.value}:${tableFontSize.value}`);
const canvasRenderStyleKey = computed(() => `${settingsStore.editorSettings.theme}:${settingsStore.editorSettings.uiScale}:${canvasBackingPixelRatio.value}:${isDark.value}:${themePalette.value}:${tableFontFamily.value}:${tableFontSize.value}:${!!saveError.value}`);
const CANVAS_MOUSE_WHEEL_SCROLL_MULTIPLIER = 1.5;
const CANVAS_TRACKPAD_DELTA_THRESHOLD = 40;
let canvasPixelRatioMediaQuery: MediaQueryList | null = null;
@@ -5405,6 +5406,7 @@ watch(
showCellDetail,
editingCell,
frozenColumnCount,
saveError,
// Pending edit structures can contain large nested cell maps; the editor
// version ref gives the canvas a cheap invalidation signal without a deep watch.
pendingChangesVersion,
@@ -8459,7 +8461,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
data-grid-root
data-grid-active="true"
class="h-full flex flex-col overflow-hidden outline-none"
:class="{ 'data-grid--editing-cell': !!editingCell, 'data-grid--dark': isDark }"
:class="{ 'data-grid--editing-cell': !!editingCell, 'data-grid--dark': isDark, 'data-grid--has-save-error': !!saveError }"
:style="gridStyle"
tabindex="0"
@keydown="onGridKeydown"
@@ -9959,7 +9961,8 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</div>
<!-- Error bar -->
<ErrorBanner v-if="saveError" :message="saveError" copy-mode="label" dismissible @dismiss="saveError = ''" />
<ErrorBanner v-if="queryControlError" variant="card" :title="t('grid.queryError')" :message="queryControlError" copy-mode="label" dismissible @dismiss="queryControlError = ''" />
<ErrorBanner v-if="saveError" variant="card" :title="t('grid.saveErrorTitle')" :message="saveError" copy-mode="label" dismissible @dismiss="saveError = ''" />
<!-- Bottom status bar -->
<div v-if="!isErrorResult" class="grid grid-cols-[max-content_minmax(0,1fr)_max-content] items-center gap-2 px-3 py-1 border-t text-xs text-muted-foreground bg-muted/30 shrink-0">
@@ -10121,7 +10124,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
--data-grid-row-new-bg: rgb(243, 243, 243);
--data-grid-row-deleted-bg: rgb(255, 244, 244);
--data-grid-cell-active-bg: rgb(244, 248, 255);
--data-grid-cell-dirty-bg: rgb(255, 248, 230);
--data-grid-cell-dirty-bg: rgb(166, 210, 255);
--data-grid-cell-selected-bg: rgb(239, 246, 255);
--data-grid-cell-selected-single-bg: rgb(191, 219, 254);
--data-grid-cell-selected-dirty-bg: rgb(235, 224, 184);
@@ -10142,13 +10145,18 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
background-color: rgb(255, 255, 255);
}
[data-grid-root].data-grid--has-save-error {
--data-grid-cell-dirty-bg: rgb(250, 212, 216) !important;
--data-grid-cell-selected-dirty-bg: rgb(240, 192, 198) !important;
}
[data-grid-root].data-grid--dark,
:global(.dark) [data-grid-root] {
--data-grid-row-muted-bg: rgb(40, 40, 43);
--data-grid-row-new-bg: rgb(51, 51, 55);
--data-grid-row-deleted-bg: rgb(55, 31, 32);
--data-grid-cell-active-bg: rgb(25, 34, 46);
--data-grid-cell-dirty-bg: rgb(94, 75, 26);
--data-grid-cell-dirty-bg: rgb(33, 66, 131);
--data-grid-cell-selected-bg: rgb(20, 40, 60);
--data-grid-cell-selected-single-bg: rgb(30, 64, 96);
--data-grid-cell-selected-dirty-bg: rgb(76, 66, 38);
@@ -10169,12 +10177,18 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
background-color: rgb(19, 20, 22);
}
[data-grid-root].data-grid--dark.data-grid--has-save-error,
:global(.dark) [data-grid-root].data-grid--has-save-error {
--data-grid-cell-dirty-bg: rgb(94, 56, 57) !important;
--data-grid-cell-selected-dirty-bg: rgb(114, 66, 67) !important;
}
@supports (background: color-mix(in oklab, white 50%, transparent)) {
[data-grid-root] {
--data-grid-row-muted-bg: color-mix(in oklab, var(--muted) 99%, var(--foreground));
--data-grid-row-new-bg: color-mix(in oklab, var(--primary) 5%, transparent);
--data-grid-row-deleted-bg: color-mix(in oklab, var(--destructive) 5%, transparent);
--data-grid-cell-dirty-bg: color-mix(in oklab, rgb(240 177 0) 10%, transparent);
--data-grid-cell-dirty-bg: color-mix(in oklab, rgb(166 210 255) 85%, var(--background));
--data-grid-cell-selected-bg: color-mix(in oklab, rgb(59 130 246) 12%, var(--background));
--data-grid-cell-selected-single-bg: color-mix(in oklab, rgb(59 130 246) 30%, var(--background));
--data-grid-cell-selected-dirty-bg: color-mix(in oklab, rgb(234 181 50) 30%, color-mix(in oklab, rgb(59 130 246) 18%, var(--background)));
@@ -10185,6 +10199,15 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
--data-grid-row-number-deleted-bg: color-mix(in oklab, var(--destructive) 15%, var(--background));
--data-grid-row-number-selected-bg: color-mix(in oklab, rgb(59 130 246) 30%, var(--background));
}
[data-grid-root].data-grid--has-save-error {
--data-grid-cell-dirty-bg: rgb(250, 212, 216) !important;
--data-grid-cell-selected-dirty-bg: rgb(240, 192, 198) !important;
}
[data-grid-root].data-grid--dark.data-grid--has-save-error,
:global(.dark) [data-grid-root].data-grid--has-save-error {
--data-grid-cell-dirty-bg: rgb(94, 56, 57) !important;
--data-grid-cell-selected-dirty-bg: rgb(114, 66, 67) !important;
}
}
.data-grid-header-shell {
@@ -10673,6 +10696,17 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
background-color: var(--data-grid-cell-dirty-bg) !important;
}
.cell-dirty::after {
content: "";
position: absolute;
top: 0;
right: 0;
border-style: solid;
border-width: 0 5px 5px 0;
border-color: transparent #f59e0b transparent transparent;
pointer-events: none;
}
.cell-search-match {
background-color: var(--data-grid-cell-search-bg) !important;
}
+25 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from "vue";
import { Copy, TriangleAlert } from "@lucide/vue";
import { Copy, TriangleAlert, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { copyToClipboard } from "@/lib/common/clipboard";
@@ -12,7 +12,7 @@ const { toast } = useToast();
const props = withDefaults(
defineProps<{
message: string;
variant?: "banner" | "centered";
variant?: "banner" | "centered" | "card";
title?: string;
dismissible?: boolean;
copyMode?: "icon" | "label";
@@ -41,8 +41,30 @@ async function copy() {
</script>
<template>
<!-- card: 卡片类报错信息面板 -->
<div v-if="variant === 'card'" class="mx-3 my-2 rounded-lg border border-destructive/30 bg-destructive/10 p-3 shadow-sm shrink-0 select-text flex flex-col gap-2">
<div class="flex items-center justify-between gap-2 border-b border-destructive/20 pb-2">
<div class="flex items-center gap-2 font-semibold text-xs text-destructive">
<TriangleAlert class="h-4 w-4 text-destructive shrink-0" aria-hidden="true" />
<span>{{ displayTitle }}</span>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<Button variant="outline" size="sm" class="h-6 gap-1 px-2 text-[11px] border-destructive/30 text-destructive hover:bg-destructive/15 hover:text-destructive" :aria-label="t('grid.copy')" @click.stop="copy">
<Copy class="h-3 w-3" />
{{ t("grid.copy") }}
</Button>
<Button v-if="dismissible" variant="ghost" size="icon-sm" class="h-6 w-6 text-destructive/70 hover:text-destructive hover:bg-destructive/15" :aria-label="t('grid.dismiss')" @click.stop="emit('dismiss')">
<X class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div class="max-h-40 min-h-[56px] overflow-y-auto rounded bg-background/80 dark:bg-background/50 border border-destructive/20 p-2.5 text-xs font-mono leading-relaxed text-destructive break-words whitespace-pre-wrap select-text cursor-text" @mousedown.stop @click.stop>
{{ message }}
</div>
</div>
<!-- banner: 紧凑内联横幅 -->
<div v-if="variant === 'banner'" class="flex items-center gap-2 px-3 py-1.5 border-t bg-destructive/10 text-destructive text-xs shrink-0">
<div v-else-if="variant === 'banner'" class="flex items-center gap-2 px-3 py-1.5 border-t bg-destructive/10 text-destructive text-xs shrink-0">
<span class="flex-1 min-w-0 break-all">{{ message }}</span>
<button v-if="copyMode === 'label'" type="button" class="shrink-0 hover:underline" :aria-label="t('grid.copy')" @click.stop="copy">
{{ t("grid.copy") }}
+1
View File
@@ -1525,6 +1525,7 @@ export default {
searchColumn: "Search column/comment...",
noColumnsFound: "No columns found",
queryError: "Query Error",
saveErrorTitle: "Failed to Save Changes",
dataUnavailable: "Table data needs to be reloaded.",
cachedResultUnavailable: "The cached result is missing or incompatible.",
reexecuteQuery: "Run query again",
+1
View File
@@ -1408,6 +1408,7 @@ export default withEnglishFallback({
tableInfoNullable: "Nulable",
tableInfoEmpty: "Sin metadatos",
queryError: "Error de consulta",
saveErrorTitle: "Error al guardar los cambios",
dataUnavailable: "Los datos de la tabla deben volver a cargarse.",
dataUnavailableHintPrefix: "Presiona ",
dataUnavailableHintSuffix: " o haz clic en Actualizar abajo para recargar.",
+1
View File
@@ -1406,6 +1406,7 @@ export default withEnglishFallback({
searchColumn: "Cerca colonna/commento...",
noColumnsFound: "Nessuna colonna trovata",
queryError: "Errore Query",
saveErrorTitle: "Impossibile salvare le modifiche",
dataUnavailable: "I dati della tabella devono essere ricaricati.",
dataUnavailableHintPrefix: "Premi ",
dataUnavailableHintSuffix: " o fai clic su Aggiorna qui sotto per ricaricare.",
+1
View File
@@ -1424,6 +1424,7 @@ export default withEnglishFallback({
searchColumn: "列/コメントを検索...",
noColumnsFound: "列が見つかりません",
queryError: "クエリエラー",
saveErrorTitle: "変更の保存に失敗しました",
dataUnavailable: "テーブルデータを再読み込みする必要があります。",
dataUnavailableHintPrefix: "",
dataUnavailableHintSuffix: " を押すか、下の更新ボタンをクリックして再読み込みしてください。",
+1
View File
@@ -1420,6 +1420,7 @@ export default withEnglishFallback({
searchColumn: "컬럼/주석 검색...",
noColumnsFound: "컬럼이 없습니다",
queryError: "쿼리 오류",
saveErrorTitle: "변경사항 저장 실패",
dataUnavailable: "테이블 데이터를 다시 불러와야 합니다.",
cachedResultUnavailable: "캐시된 결과가 없거나 호환되지 않습니다.",
reexecuteQuery: "쿼리 다시 실행",
+1
View File
@@ -1408,6 +1408,7 @@ export default withEnglishFallback({
searchColumn: "Buscar coluna/comentário...",
noColumnsFound: "Nenhuma coluna encontrada",
queryError: "Erro na Consulta",
saveErrorTitle: "Falha ao salvar alterações",
dataUnavailable: "Os dados da tabela precisam ser recarregados.",
dataUnavailableHintPrefix: "Pressione ",
dataUnavailableHintSuffix: " ou clique em Atualizar abaixo para recarregar.",
+1
View File
@@ -1526,6 +1526,7 @@ export default withEnglishFallback({
searchColumn: "搜索列/注释...",
noColumnsFound: "未找到列",
queryError: "查询出错",
saveErrorTitle: "数据修改保存失败",
dataUnavailable: "表数据需要重新加载。",
dataUnavailableHintPrefix: "按 ",
dataUnavailableHintSuffix: " 或点击下方刷新按钮重新加载。",
+1
View File
@@ -1410,6 +1410,7 @@ export default withEnglishFallback({
dataUnavailableHintPrefix: "按 ",
dataUnavailableHintSuffix: " 或點擊下方重新整理來重新載入。",
queryError: "查詢出錯",
saveErrorTitle: "資料修改儲存失敗",
refresh: "重新整理",
undoChange: "復原變更",
redoChange: "重做變更",
@@ -66,6 +66,8 @@ describe("data grid paint theme", () => {
expect(light.cellSelected).toBe("rgb(239, 246, 255)");
expect(light.cellSelectedBorder).toBe("rgb(59, 130, 246)");
expect(light.cellSelectedSingle).toBe("rgb(191, 219, 254)");
expect(light.cellSelectedDirty).toBe("rgb(235, 224, 184)");
expect(light.cellDirty).toBe("rgb(166, 210, 255)");
expect(light.rowNumberTextNew).toBe("rgb(0, 122, 85)");
expect(light.rowNumberTextEdited).toBe("rgb(187, 77, 0)");
expect(contrastRatio(light.cellSelectedBorder, light.cellSelected)).toBeGreaterThanOrEqual(3);
@@ -73,6 +75,8 @@ describe("data grid paint theme", () => {
expect(dark.cellSelected).toBe("rgb(20, 40, 60)");
expect(dark.cellSelectedBorder).toBe("rgb(96, 165, 250)");
expect(dark.cellSelectedSingle).toBe("rgb(30, 64, 96)");
expect(dark.cellSelectedDirty).toBe("rgb(76, 66, 38)");
expect(dark.cellDirty).toBe("rgb(33, 66, 131)");
});
it("honors an explicit --data-grid-cell-selected-border token when provided in light mode", () => {
@@ -95,6 +99,19 @@ describe("data grid paint theme", () => {
expect(theme.cellSelectedBorder).toBe("rgb(37, 99, 235)");
});
it("honors explicit --data-grid-cell-dirty-bg token in dark mode when save error occurs", () => {
const vars: Record<string, string> = {
"--data-grid-cell-dirty-bg": "rgb(94, 56, 57)",
};
const theme = resolveDataGridPaintTheme({
getVar: (name) => vars[name] ?? "",
isDark: true,
});
expect(theme.cellDirty).toBe("rgb(94, 56, 57)");
});
});
describe("dbx-control-chrome cascade contract", () => {
@@ -3,7 +3,33 @@ import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createDataGridRuntimeScope } from "@/lib/dataGrid/dataGridRuntime";
const dataGridSource = readFileSync(new URL("../../../components/grid/DataGrid.vue", import.meta.url), "utf8");
function extractFunction(name: string): string {
const start = dataGridSource.indexOf(`async function ${name}(`);
if (start < 0) throw new Error(`Missing DataGrid function: ${name}`);
const bodyStart = dataGridSource.indexOf("{", start);
let depth = 0;
for (let index = bodyStart; index < dataGridSource.length; index++) {
const character = dataGridSource[index];
if (character === "{") depth++;
if (character === "}" && --depth === 0) return dataGridSource.slice(start, index + 1);
}
throw new Error(`Unterminated DataGrid function: ${name}`);
}
describe("dataGridRuntime", () => {
it("keeps WHERE and ORDER BY execution errors separate from save errors", () => {
for (const name of ["applyWhereFilter", "applyOrderBySearch"]) {
const source = extractFunction(name);
expect(source).toContain('queryControlError.value = ""');
expect(source).toContain("queryControlError.value = String(e?.message || e)");
expect(source).not.toContain("saveError.value");
}
expect(dataGridSource).toContain('v-if="queryControlError"');
expect(dataGridSource).toContain(":title=\"t('grid.queryError')\"");
});
it("disposes registered resources in reverse order and only once", () => {
const scope = createDataGridRuntimeScope();
const calls: string[] = [];
@@ -251,7 +251,7 @@ export function resolveDataGridPaintTheme(options: { getVar: (name: string) => s
const rowNew = isDark ? "rgb(51, 51, 55)" : "rgb(243, 243, 243)";
const rowDeleted = isDark ? "rgb(55, 31, 32)" : "rgb(255, 244, 244)";
const cellActive = activeSurface;
const cellDirty = isDark ? "rgb(94, 75, 26)" : "rgb(255, 248, 230)";
const cellDirty = isDark ? "rgb(33, 66, 131)" : "rgb(166, 210, 255)";
const cellSelected = isDark ? "rgb(20, 40, 60)" : "rgb(239, 246, 255)";
const cellSelectedDirty = isDark ? "rgb(76, 66, 38)" : "rgb(235, 224, 184)";
const cellSelectedBorder = isDark ? "rgb(96, 165, 250)" : "rgb(59, 130, 246)";
@@ -277,9 +277,9 @@ export function resolveDataGridPaintTheme(options: { getVar: (name: string) => s
rowNew: isDark ? rowNew : paintToken(getVar, "--data-grid-row-new-bg", rowNew),
rowDeleted: isDark ? rowDeleted : paintToken(getVar, "--data-grid-row-deleted-bg", rowDeleted),
cellActive: isDark ? cellActive : paintToken(getVar, "--data-grid-cell-active-bg", cellActive),
cellDirty: isDark ? cellDirty : paintToken(getVar, "--data-grid-cell-dirty-bg", cellDirty),
cellDirty: paintToken(getVar, "--data-grid-cell-dirty-bg", cellDirty),
cellSelected: isDark ? cellSelected : paintToken(getVar, "--data-grid-cell-selected-bg", cellSelected),
cellSelectedDirty: isDark ? cellSelectedDirty : paintToken(getVar, "--data-grid-cell-selected-dirty-bg", cellSelectedDirty),
cellSelectedDirty: paintToken(getVar, "--data-grid-cell-selected-dirty-bg", cellSelectedDirty),
cellSelectedBorder: isDark ? cellSelectedBorder : paintToken(getVar, "--data-grid-cell-selected-border", cellSelectedBorder),
cellSelectedSingle: isDark ? cellSelectedSingle : paintToken(getVar, "--data-grid-cell-selected-single-bg", cellSelectedSingle),
cellSelectedSingleBorder: isDark ? primary : paintToken(getVar, "--data-grid-cell-selected-single-border", primary),