Config UI: prevent accidental modal close with unsaved changes (#32826)
This commit is contained in:
parent
9eea54d8e4
commit
ad9cd0a9a6
8 changed files with 92 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
:title="title"
|
||||
:size="size"
|
||||
:config-modal-name="name"
|
||||
:prevent-dismiss="!nothingChanged"
|
||||
@open="open"
|
||||
>
|
||||
<p v-if="description || docsLink">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue