Config UI: prevent accidental modal close with unsaved changes (#32826)

This commit is contained in:
Michael Geers 2026-08-14 16:33:47 +02:00 • committed by GitHub
parent 9eea54d8e4
commit ad9cd0a9a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 92 additions and 0 deletions

View file

@ -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;

View file

@ -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<string, string[]>,
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();
}
}
},
},

View file

@ -6,6 +6,7 @@
:title="title"
:size="size"
:config-modal-name="name"
:prevent-dismiss="!nothingChanged"
@open="open"
>
<p v-if="description || docsLink">

View file

@ -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);
}

View file

@ -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");

View file

@ -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");

View file

@ -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);

View file

@ -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();