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"');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user