fix(settings): confirm before discarding unsaved settings
This commit is contained in:
1 parent
9dbb2ef72f
commit
6a25bd1909
12 files changed
+160
-3
No files matched your search
@@ -126,7 +126,7 @@ import McpConnectionScopePicker from "@/components/settings/McpConnectionScopePi
|
||||
import ScheduledDatabaseBackupSettings from "@/components/backup/ScheduledDatabaseBackupSettings.vue";
|
||||
import SqlFormatterSettingsPanel from "./SqlFormatterSettingsPanel.vue";
|
||||
import { APP_CUSTOM_UI_COLOR_DEFS, APP_THEME_PALETTES, type AppCornerStyle, type AppCustomUiColors, type AppThemeAppearance, type AppThemeMode, type AppThemePalette } from "@/lib/app/appTheme";
|
||||
import { editorSettingsDraftChanged, editorSettingsDraftFromSettings, editorSettingsPatchFromDraft, normalizeQueryResultMaxRowsDraft, normalizeTableOpenPageSizeDraft, type EditorSettingsDraft } from "@/lib/settings/editorSettingsDraft";
|
||||
import { editorSettingsDraftChanged, editorSettingsDraftFromSettings, editorSettingsPatchFromDraft, normalizeQueryResultMaxRowsDraft, normalizeTableOpenPageSizeDraft, shouldConfirmEditorSettingsDialogClose, type EditorSettingsDraft } from "@/lib/settings/editorSettingsDraft";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||||
import { usePromptTemplateStore } from "@/stores/promptTemplateStore";
|
||||
@@ -221,11 +221,31 @@ const settingsContentClass = computed(() =>
|
||||
);
|
||||
const settingsTitleComponent = computed(() => (isSettingsPage.value ? "h2" : DialogTitle));
|
||||
|
||||
const showUnsavedSettingsCloseConfirm = ref(false);
|
||||
|
||||
function requestCloseSettings(nextOpen: boolean) {
|
||||
if (shouldConfirmEditorSettingsDialogClose(nextOpen, hasChanges())) {
|
||||
showUnsavedSettingsCloseConfirm.value = true;
|
||||
return;
|
||||
}
|
||||
emit("update:open", nextOpen);
|
||||
}
|
||||
|
||||
function onSettingsRootOpenChange(value: boolean) {
|
||||
if (!isSettingsPage.value) emit("update:open", value);
|
||||
if (isSettingsPage.value) return;
|
||||
requestCloseSettings(value);
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
requestCloseSettings(false);
|
||||
}
|
||||
|
||||
function cancelUnsavedSettingsClose() {
|
||||
showUnsavedSettingsCloseConfirm.value = false;
|
||||
}
|
||||
|
||||
function discardUnsavedSettingsAndClose() {
|
||||
showUnsavedSettingsCloseConfirm.value = false;
|
||||
emit("update:open", false);
|
||||
}
|
||||
|
||||
@@ -6928,6 +6948,24 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</component>
|
||||
|
||||
<!-- Unsaved settings confirmation: guards every close path (Escape, outside click, the X button, and the footer "Close" button) so an unapplied draft is never silently discarded. -->
|
||||
<Dialog :open="showUnsavedSettingsCloseConfirm" @update:open="(value: boolean) => !value && cancelUnsavedSettingsClose()">
|
||||
<DialogContent class="sm:max-w-[420px]" @interact-outside.prevent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("settings.unsavedChangesCloseTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p class="text-sm text-muted-foreground">{{ t("settings.unsavedChangesCloseMessage") }}</p>
|
||||
<DialogFooter class="gap-2">
|
||||
<Button variant="outline" size="sm" @click="cancelUnsavedSettingsClose">
|
||||
{{ t("settings.unsavedChangesCloseCancel") }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" @click="discardUnsavedSettingsAndClose">
|
||||
{{ t("settings.unsavedChangesCloseDiscard") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Theme Customizer Dialog -->
|
||||
<ThemeCustomizerDialog v-model:open="showThemeCustomizer" :themes="editCustomThemes" :active-theme-id="editActiveCustomThemeId" @save="handleThemeSave" />
|
||||
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8");
|
||||
|
||||
// Regression for https://github.com/t8y2/dbx/issues/5905: changing a shortcut
|
||||
// or the sidebar activation mode (and every other draft-gated editor
|
||||
// setting) was silently discarded whenever the dialog closed any way other
|
||||
// than the explicit "Apply"/"Apply and Close" buttons — Escape, clicking
|
||||
// outside, the X button, and every tab's "Close" footer button all bypassed
|
||||
// the draft entirely. Users only noticed after a later app restart (e.g. to
|
||||
// install an update) surfaced the still-default values, making it look like
|
||||
// the update itself had reset their settings.
|
||||
describe("EditorSettingsDialog unsaved-changes close guard", () => {
|
||||
it("imports the shared unsaved-changes decision helper", () => {
|
||||
expect(dialogSource).toContain('shouldConfirmEditorSettingsDialogClose, type EditorSettingsDraft } from "@/lib/settings/editorSettingsDraft"');
|
||||
});
|
||||
|
||||
it("routes every close path through the same guard instead of emitting update:open directly", () => {
|
||||
expect(dialogSource).toContain("function requestCloseSettings(nextOpen: boolean) {");
|
||||
expect(dialogSource).toContain("if (shouldConfirmEditorSettingsDialogClose(nextOpen, hasChanges())) {");
|
||||
expect(dialogSource).toContain("showUnsavedSettingsCloseConfirm.value = true;");
|
||||
|
||||
// Escape / outside click / the dialog's own X button
|
||||
expect(dialogSource).toContain("function onSettingsRootOpenChange(value: boolean) {\n if (isSettingsPage.value) return;\n requestCloseSettings(value);\n}");
|
||||
// Every tab footer's "Close" button — must work in both dialog and page
|
||||
// (variant="page") mode, since App.vue relies on closeSettings() emitting
|
||||
// update:open(false) in page mode too (see @update:open on EditorSettingsPage).
|
||||
expect(dialogSource).toContain("function closeSettings() {\n requestCloseSettings(false);\n}");
|
||||
|
||||
// Neither close entrypoint may emit update:open directly anymore - only requestCloseSettings does.
|
||||
const closeSettingsBody = dialogSource.slice(dialogSource.indexOf("function closeSettings()"), dialogSource.indexOf("function cancelUnsavedSettingsClose()"));
|
||||
expect(closeSettingsBody).not.toContain('emit("update:open"');
|
||||
});
|
||||
|
||||
it("lets the user cancel back into the dialog without losing the draft", () => {
|
||||
expect(dialogSource).toContain("function cancelUnsavedSettingsClose() {\n showUnsavedSettingsCloseConfirm.value = false;\n}");
|
||||
});
|
||||
|
||||
it("only actually closes (dropping the draft) once the user explicitly confirms discarding", () => {
|
||||
expect(dialogSource).toContain('function discardUnsavedSettingsAndClose() {\n showUnsavedSettingsCloseConfirm.value = false;\n emit("update:open", false);\n}');
|
||||
});
|
||||
|
||||
it("renders a confirmation dialog wired to the guard state and both actions", () => {
|
||||
expect(dialogSource).toContain('<Dialog :open="showUnsavedSettingsCloseConfirm" @update:open="(value: boolean) => !value && cancelUnsavedSettingsClose()">');
|
||||
expect(dialogSource).toContain('{{ t("settings.unsavedChangesCloseTitle") }}');
|
||||
expect(dialogSource).toContain('{{ t("settings.unsavedChangesCloseMessage") }}');
|
||||
expect(dialogSource).toContain('@click="cancelUnsavedSettingsClose"');
|
||||
expect(dialogSource).toContain('@click="discardUnsavedSettingsAndClose"');
|
||||
});
|
||||
});
|
||||
@@ -5807,6 +5807,10 @@ export default {
|
||||
duckDbWorkerProcessIsolationRestartRequired: "Restart DBX for this change to take effect.",
|
||||
restartDbx: "Restart DBX",
|
||||
restartDbxFailed: "Failed to restart DBX: {error}",
|
||||
unsavedChangesCloseTitle: "Unsaved changes",
|
||||
unsavedChangesCloseMessage: "You have unsaved settings changes that will be lost if you close now. Discard these changes?",
|
||||
unsavedChangesCloseCancel: "Keep editing",
|
||||
unsavedChangesCloseDiscard: "Discard changes",
|
||||
debugLogsCopy: "Copy logs",
|
||||
debugLogsCopied: "Copied",
|
||||
debugLogsDownload: "Download logs",
|
||||
|
||||
@@ -5548,6 +5548,10 @@ export default withEnglishFallback({
|
||||
duckDbWorkerProcessIsolationRestartRequired: "Reinicia DBX para que este cambio surta efecto.",
|
||||
restartDbx: "Reiniciar DBX",
|
||||
restartDbxFailed: "No se pudo reiniciar DBX: {error}",
|
||||
unsavedChangesCloseTitle: "Cambios sin guardar",
|
||||
unsavedChangesCloseMessage: "Tienes cambios de configuración sin guardar que se perderán si cierras ahora. ¿Descartar estos cambios?",
|
||||
unsavedChangesCloseCancel: "Seguir editando",
|
||||
unsavedChangesCloseDiscard: "Descartar cambios",
|
||||
debugLogsCopy: "Copiar logs",
|
||||
debugLogsCopied: "Copiado",
|
||||
debugLogsDownload: "Descargar logs",
|
||||
|
||||
@@ -5548,6 +5548,10 @@ export default withEnglishFallback({
|
||||
duckDbWorkerProcessIsolationRestartRequired: "Riavvia DBX affinché questa modifica abbia effetto.",
|
||||
restartDbx: "Riavvia DBX",
|
||||
restartDbxFailed: "Impossibile riavviare DBX: {error}",
|
||||
unsavedChangesCloseTitle: "Modifiche non salvate",
|
||||
unsavedChangesCloseMessage: "Ci sono modifiche alle impostazioni non salvate che andranno perse se chiudi ora. Vuoi scartare queste modifiche?",
|
||||
unsavedChangesCloseCancel: "Continua a modificare",
|
||||
unsavedChangesCloseDiscard: "Scarta modifiche",
|
||||
debugLogsCopy: "Copia log",
|
||||
debugLogsCopied: "Copiati",
|
||||
debugLogsDownload: "Scarica log",
|
||||
|
||||
@@ -5583,6 +5583,10 @@ export default withEnglishFallback({
|
||||
duckDbWorkerProcessIsolationRestartRequired: "この変更を有効にするには DBX を再起動してください。",
|
||||
restartDbx: "DBX を再起動",
|
||||
restartDbxFailed: "DBX の再起動に失敗しました: {error}",
|
||||
unsavedChangesCloseTitle: "未保存の変更",
|
||||
unsavedChangesCloseMessage: "未保存の設定変更があります。今すぐ閉じると失われます。これらの変更を破棄しますか?",
|
||||
unsavedChangesCloseCancel: "編集を続ける",
|
||||
unsavedChangesCloseDiscard: "変更を破棄",
|
||||
debugLogsCopy: "ログをコピー",
|
||||
debugLogsCopied: "コピーしました",
|
||||
debugLogsDownload: "ログをダウンロード",
|
||||
|
||||
@@ -5277,6 +5277,10 @@ export default withEnglishFallback({
|
||||
duckDbWorkerProcessIsolationRestartRequired: "이 변경 사항을 적용하려면 DBX를 다시 시작하세요.",
|
||||
restartDbx: "DBX 다시 시작",
|
||||
restartDbxFailed: "DBX 다시 시작 실패: {error}",
|
||||
unsavedChangesCloseTitle: "저장하지 않은 변경 사항",
|
||||
unsavedChangesCloseMessage: "저장하지 않은 설정 변경 사항이 있습니다. 지금 닫으면 사라집니다. 이 변경 사항을 취소하시겠습니까?",
|
||||
unsavedChangesCloseCancel: "계속 편집",
|
||||
unsavedChangesCloseDiscard: "변경 사항 취소",
|
||||
debugLogsCopy: "로그 복사",
|
||||
debugLogsCopied: "복사했습니다",
|
||||
debugLogsDownload: "로그 다운로드",
|
||||
|
||||
@@ -5550,6 +5550,10 @@ export default withEnglishFallback({
|
||||
duckDbWorkerProcessIsolationRestartRequired: "Reinicie o DBX para que esta alteração entre em vigor.",
|
||||
restartDbx: "Reiniciar DBX",
|
||||
restartDbxFailed: "Falha ao reiniciar o DBX: {error}",
|
||||
unsavedChangesCloseTitle: "Alterações não salvas",
|
||||
unsavedChangesCloseMessage: "Você tem alterações de configuração não salvas que serão perdidas se fechar agora. Descartar essas alterações?",
|
||||
unsavedChangesCloseCancel: "Continuar editando",
|
||||
unsavedChangesCloseDiscard: "Descartar alterações",
|
||||
debugLogsCopy: "Copiar logs",
|
||||
debugLogsCopied: "Copiado",
|
||||
debugLogsDownload: "Baixar logs",
|
||||
|
||||
@@ -5794,6 +5794,10 @@ export default withEnglishFallback({
|
||||
duckDbWorkerProcessIsolationRestartRequired: "重启 DBX 后生效。",
|
||||
restartDbx: "重启 DBX",
|
||||
restartDbxFailed: "重启 DBX 失败:{error}",
|
||||
unsavedChangesCloseTitle: "未保存的更改",
|
||||
unsavedChangesCloseMessage: "当前设置有未保存的更改,关闭后将丢失。是否放弃这些更改?",
|
||||
unsavedChangesCloseCancel: "继续编辑",
|
||||
unsavedChangesCloseDiscard: "放弃更改",
|
||||
debugLogsCopy: "复制日志",
|
||||
debugLogsCopied: "已复制",
|
||||
debugLogsDownload: "下载日志",
|
||||
|
||||
@@ -4885,6 +4885,10 @@ export default withEnglishFallback({
|
||||
duckDbWorkerProcessIsolationRestartRequired: "重啟 DBX 後生效。",
|
||||
restartDbx: "重啟 DBX",
|
||||
restartDbxFailed: "重啟 DBX 失敗:{error}",
|
||||
unsavedChangesCloseTitle: "未儲存的變更",
|
||||
unsavedChangesCloseMessage: "目前設定有未儲存的變更,關閉後將遺失。是否放棄這些變更?",
|
||||
unsavedChangesCloseCancel: "繼續編輯",
|
||||
unsavedChangesCloseDiscard: "放棄變更",
|
||||
debugLogsCopy: "複製日誌",
|
||||
debugLogsCopied: "已複製",
|
||||
debugLogsDownload: "下載日誌",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EDITOR_SETTINGS_DRAFT_KEYS, editorSettingsDraftFromSettings, editorSettingsDraftChanged, editorSettingsPatchFromDraft, normalizeQueryResultMaxRowsDraft, normalizeTableOpenPageSizeDraft } from "../editorSettingsDraft";
|
||||
import { EDITOR_SETTINGS_DRAFT_KEYS, editorSettingsDraftFromSettings, editorSettingsDraftChanged, editorSettingsPatchFromDraft, normalizeQueryResultMaxRowsDraft, normalizeTableOpenPageSizeDraft, shouldConfirmEditorSettingsDialogClose } from "../editorSettingsDraft";
|
||||
import type { EditorSettings } from "@/stores/settingsStore";
|
||||
|
||||
function makeSettings(overrides: Partial<EditorSettings> = {}): EditorSettings {
|
||||
@@ -345,6 +345,33 @@ describe("editorSettingsDraftChanged - tabLayout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldConfirmEditorSettingsDialogClose", () => {
|
||||
// Regression for https://github.com/t8y2/dbx/issues/5905: customizing a
|
||||
// shortcut or the sidebar activation mode and then dismissing the dialog
|
||||
// via Escape/outside-click/the "Close" button (anything other than Apply)
|
||||
// must not silently drop the draft.
|
||||
it("requests confirmation when the dialog is closing with an unsaved shortcut/sidebarActivation edit", () => {
|
||||
const settings = makeSettings({ sidebarActivation: "single" } as Partial<EditorSettings>);
|
||||
const base = editorSettingsDraftFromSettings(settings);
|
||||
const draft = editorSettingsDraftFromSettings(settings);
|
||||
draft.sidebarActivation = "double";
|
||||
|
||||
expect(shouldConfirmEditorSettingsDialogClose(false, editorSettingsDraftChanged(draft, base))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not block closing when there is no unsaved draft", () => {
|
||||
const settings = makeSettings();
|
||||
const base = editorSettingsDraftFromSettings(settings);
|
||||
const draft = editorSettingsDraftFromSettings(settings);
|
||||
|
||||
expect(shouldConfirmEditorSettingsDialogClose(false, editorSettingsDraftChanged(draft, base))).toBe(false);
|
||||
});
|
||||
|
||||
it("never blocks opening the dialog, even with a stale dirty flag", () => {
|
||||
expect(shouldConfirmEditorSettingsDialogClose(true, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editorSettingsPatchFromDraft - tabLayout", () => {
|
||||
it("includes tabLayout in patch when changed", () => {
|
||||
const settings = makeSettings({ tabLayout: "scroll" });
|
||||
|
||||
@@ -134,3 +134,12 @@ export function editorSettingsPatchFromDraft(draft: EditorSettingsDraft, base: E
|
||||
export function editorSettingsDraftChanged(draft: EditorSettingsDraft, base: EditorSettingsDraft): boolean {
|
||||
return EDITOR_SETTINGS_DRAFT_KEYS.some((key) => draftValueChanged(key, draft[key], base[key]));
|
||||
}
|
||||
|
||||
// Closing the settings dialog (Escape, clicking outside, the X button, or the
|
||||
// "Close" footer button) must never silently drop an unapplied draft — the
|
||||
// dialog only persists shortcuts/sidebarActivation/etc. to the store on an
|
||||
// explicit Apply. Route every close attempt through this check so an unsaved
|
||||
// draft always surfaces a confirmation instead of vanishing.
|
||||
export function shouldConfirmEditorSettingsDialogClose(nextOpen: boolean, hasUnsavedChanges: boolean): boolean {
|
||||
return nextOpen === false && hasUnsavedChanges;
|
||||
}
|
||||
Reference in new issue
Block a user