From ad9cd0a9a6eec4a7fcc1b3e28628a52ad363fd8a Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Fri, 14 Aug 2026 16:33:47 +0200 Subject: [PATCH] Config UI: prevent accidental modal close with unsaved changes (#32826) --- assets/css/app.css | 5 ++++ .../Config/DeviceModal/DeviceModalBase.vue | 20 ++++++++++++++ assets/js/components/Config/JsonModal.vue | 1 + .../js/components/Config/LoadpointModal.vue | 10 +++++++ assets/js/components/Helper/GenericModal.vue | 20 ++++++++++++++ tests/config-loadpoint.spec.ts | 5 ++++ tests/config-messaging.spec.ts | 4 +++ tests/config-vehicles.spec.ts | 27 +++++++++++++++++++ 8 files changed, 92 insertions(+) diff --git a/assets/css/app.css b/assets/css/app.css index a99032547..c6d9d023f 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -450,6 +450,11 @@ a:hover { .modal.show .modal-dialog { transform: none; } +/* restore bootstrap's dismiss-blocked zoom, neutralized by the transform reset above */ +.modal.modal-static .modal-dialog { + transform: scale(1.02); + transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); +} .modal-header { padding: 0 0 1rem 0; diff --git a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue index ad686af21..e4e34be0c 100644 --- a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue +++ b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue @@ -6,6 +6,7 @@ :data-testid="`${name}-modal`" :size="modalSize" :config-modal-name="name" + :prevent-dismiss="dirty" @open="handleOpen" @close="handleClose" @visibilitychange="handleVisibilityChange" @@ -344,6 +345,7 @@ export default defineComponent({ succeeded: false, loadingTemplate: false, values: { ...this.initialValues } as DeviceValues, + baseline: JSON.stringify({ ...this.initialValues }), test: initialTestState(), serviceValues: {} as Record, serviceValuesTimer: null as Timeout | null, @@ -356,6 +358,9 @@ export default defineComponent({ device() { return createDeviceUtils(this.deviceType); }, + dirty(): boolean { + return JSON.stringify(this.values) !== this.baseline; + }, modalSize(): string | undefined { return this.showYamlInput ? "xl" : undefined; }, @@ -641,8 +646,12 @@ export default defineComponent({ this.values = { ...this.initialValues } as DeviceValues; this.test = initialTestState(); this.resetAuthStatus(); + this.rebaseline(); this.$emit("reset"); }, + rebaseline() { + this.baseline = JSON.stringify(this.values); + }, async loadConfiguration() { try { const device = await this.device.load(this.id!); @@ -667,18 +676,24 @@ export default defineComponent({ if (this.onConfigurationLoaded) { this.onConfigurationLoaded(this.values); } + this.rebaseline(); this.checkAuthStatus(); } catch (e) { console.error(e); } }, applyDefaults() { + // late-arriving defaults must not mark a clean form dirty + const wasClean = !this.dirty; applyDefaultsFromTemplate(this.template, this.values); // Allow parent to apply custom defaults if (this.applyCustomDefaults) { this.applyCustomDefaults(this.template, this.values); } + if (wasClean) { + this.rebaseline(); + } }, async loadProducts() { if (!this.isModalVisible) { @@ -901,7 +916,12 @@ export default defineComponent({ const param = this.templateParams.find((p) => p.Name === paramName); // Only auto-apply if exactly one value is returned, field is empty, and field is required if (values?.length === 1 && !this.values[paramName] && param?.Required) { + // debounced auto-fill must not mark a clean form dirty + const wasClean = !this.dirty; this.values[paramName] = values[0]; + if (wasClean) { + this.rebaseline(); + } } }, }, diff --git a/assets/js/components/Config/JsonModal.vue b/assets/js/components/Config/JsonModal.vue index d62696227..1ea0d7192 100644 --- a/assets/js/components/Config/JsonModal.vue +++ b/assets/js/components/Config/JsonModal.vue @@ -6,6 +6,7 @@ :title="title" :size="size" :config-modal-name="name" + :prevent-dismiss="!nothingChanged" @open="open" >

diff --git a/assets/js/components/Config/LoadpointModal.vue b/assets/js/components/Config/LoadpointModal.vue index 01ecf9861..bde2eba30 100644 --- a/assets/js/components/Config/LoadpointModal.vue +++ b/assets/js/components/Config/LoadpointModal.vue @@ -3,6 +3,7 @@ id="loadpointModal" ref="modal" config-modal-name="loadpoint" + :prevent-dismiss="dirty" :title="modalTitle" data-testid="loadpoint-modal" @open="onOpen" @@ -738,6 +739,7 @@ export default { isModalVisible: false, saving: false, values: deepClone(defaultValues) as ConfigLoadpoint, + baseline: JSON.stringify(defaultValues), chargerPower: "11kw", solarMode: "default", autoCreate: false, @@ -750,6 +752,9 @@ export default { id(): number | undefined { return getModal("loadpoint")?.id; }, + dirty(): boolean { + return JSON.stringify(this.values) !== this.baseline; + }, selectedType(): LoadpointType | undefined { return getModal("loadpoint")?.type as LoadpointType | undefined; }, @@ -926,6 +931,10 @@ export default { this.autoCreate = false; this.autoCreateInProgress = false; this.updatePhases(); + this.rebaseline(); + }, + rebaseline() { + this.baseline = JSON.stringify(this.values); }, async loadConfiguration() { try { @@ -934,6 +943,7 @@ export default { this.updateChargerPower(); this.updateSolarMode(); this.updatePhases(); + this.rebaseline(); } catch (e) { console.error(e); } diff --git a/assets/js/components/Helper/GenericModal.vue b/assets/js/components/Helper/GenericModal.vue index 1c8a587c2..583b434c1 100644 --- a/assets/js/components/Helper/GenericModal.vue +++ b/assets/js/components/Helper/GenericModal.vue @@ -49,6 +49,7 @@ export default defineComponent({ title: String, dataTestid: String, uncloseable: Boolean, + preventDismiss: Boolean, size: String, autofocus: { type: Boolean, default: true }, configModalName: String, @@ -59,6 +60,11 @@ export default defineComponent({ isModalVisible: false, }; }, + watch: { + preventDismiss() { + this.applyDismissProtection(); + }, + }, computed: { sizeClass() { return this.size ? `modal-${this.size}` : ""; @@ -92,6 +98,20 @@ export default defineComponent({ handleShow() { this.$emit("open"); this.isModalVisible = true; + this.applyDismissProtection(); + }, + applyDismissProtection() { + const el = this.$refs["modal"] as HTMLElement; + const instance = el && Modal.getInstance(el); + // no instance yet: applied on next show + if (!instance) return; + const lock = this.uncloseable || this.preventDismiss; + // mutate instance config instead of data attributes: read at event time, + // keeps router/theme attribute checks and nav-close behavior intact + // @ts-expect-error bs internal + instance._config.backdrop = lock ? "static" : true; + // @ts-expect-error bs internal + instance._config.keyboard = !lock; }, handleShown() { this.$emit("opened"); diff --git a/tests/config-loadpoint.spec.ts b/tests/config-loadpoint.spec.ts index a2495cd6c..21209163d 100644 --- a/tests/config-loadpoint.spec.ts +++ b/tests/config-loadpoint.spec.ts @@ -89,6 +89,11 @@ test.describe("charging loadpoint", async () => { await expectModalVisible(lpModal); await expect(lpModal.getByRole("heading", { name: "Edit Charging Point" })).toBeVisible(); await lpModal.getByLabel("Title").fill("Solar Carport 2"); + + // unsaved changes: backdrop click keeps modal open + await lpModal.click({ position: { x: 10, y: 10 } }); + await expectModalVisible(lpModal); + await lpModal.getByRole("button", { name: "Save" }).click(); await expectModalHidden(lpModal); await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2"); diff --git a/tests/config-messaging.spec.ts b/tests/config-messaging.spec.ts index 47b174e21..1e2d7969a 100644 --- a/tests/config-messaging.spec.ts +++ b/tests/config-messaging.spec.ts @@ -111,6 +111,10 @@ test.describe("messaging", async () => { await titleInput.fill("event-start-title"); await messageInput.fill("event-start-message"); + // unsaved changes: ESC keeps modal open + await page.keyboard.press("Escape"); + await expectModalVisible(modal); + // validate connection await modal.getByRole("button", { name: "Save", exact: true }).click(); await expectModalHidden(modal); diff --git a/tests/config-vehicles.spec.ts b/tests/config-vehicles.spec.ts index 04e7ee607..ef37178f5 100644 --- a/tests/config-vehicles.spec.ts +++ b/tests/config-vehicles.spec.ts @@ -72,6 +72,33 @@ test.describe("vehicles", async () => { await expect(page.getByTestId("vehicle")).toHaveCount(0); }); + test("prevent accidental dismiss with unsaved changes", async ({ page }) => { + await start(); + + await page.goto("/#/config"); + const vehicleModal = page.getByTestId("vehicle-modal"); + + // clean: backdrop click closes + await page.getByTestId("add-vehicle").click(); + await expectModalVisible(vehicleModal); + await vehicleModal.click({ position: { x: 10, y: 10 } }); + await expectModalHidden(vehicleModal); + + // dirty: backdrop click and ESC keep modal open + await page.getByTestId("add-vehicle").click(); + await expectModalVisible(vehicleModal); + await vehicleModal.getByLabel("Manufacturer").selectOption(GENERIC_VEHICLE); + await vehicleModal.getByLabel("Title").fill("Green Car"); + await vehicleModal.click({ position: { x: 10, y: 10 } }); + await expectModalVisible(vehicleModal); + await page.keyboard.press("Escape"); + await expectModalVisible(vehicleModal); + + // close button works while dirty + await vehicleModal.getByLabel("Close").click(); + await expectModalHidden(vehicleModal); + }); + test("config should survive restart", async ({ page }) => { await start();