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 {
|
.modal.show .modal-dialog {
|
||||||
transform: none;
|
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 {
|
.modal-header {
|
||||||
padding: 0 0 1rem 0;
|
padding: 0 0 1rem 0;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
:data-testid="`${name}-modal`"
|
:data-testid="`${name}-modal`"
|
||||||
:size="modalSize"
|
:size="modalSize"
|
||||||
:config-modal-name="name"
|
:config-modal-name="name"
|
||||||
|
:prevent-dismiss="dirty"
|
||||||
@open="handleOpen"
|
@open="handleOpen"
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
@visibilitychange="handleVisibilityChange"
|
@visibilitychange="handleVisibilityChange"
|
||||||
|
|
@ -344,6 +345,7 @@ export default defineComponent({
|
||||||
succeeded: false,
|
succeeded: false,
|
||||||
loadingTemplate: false,
|
loadingTemplate: false,
|
||||||
values: { ...this.initialValues } as DeviceValues,
|
values: { ...this.initialValues } as DeviceValues,
|
||||||
|
baseline: JSON.stringify({ ...this.initialValues }),
|
||||||
test: initialTestState(),
|
test: initialTestState(),
|
||||||
serviceValues: {} as Record<string, string[]>,
|
serviceValues: {} as Record<string, string[]>,
|
||||||
serviceValuesTimer: null as Timeout | null,
|
serviceValuesTimer: null as Timeout | null,
|
||||||
|
|
@ -356,6 +358,9 @@ export default defineComponent({
|
||||||
device() {
|
device() {
|
||||||
return createDeviceUtils(this.deviceType);
|
return createDeviceUtils(this.deviceType);
|
||||||
},
|
},
|
||||||
|
dirty(): boolean {
|
||||||
|
return JSON.stringify(this.values) !== this.baseline;
|
||||||
|
},
|
||||||
modalSize(): string | undefined {
|
modalSize(): string | undefined {
|
||||||
return this.showYamlInput ? "xl" : undefined;
|
return this.showYamlInput ? "xl" : undefined;
|
||||||
},
|
},
|
||||||
|
|
@ -641,8 +646,12 @@ export default defineComponent({
|
||||||
this.values = { ...this.initialValues } as DeviceValues;
|
this.values = { ...this.initialValues } as DeviceValues;
|
||||||
this.test = initialTestState();
|
this.test = initialTestState();
|
||||||
this.resetAuthStatus();
|
this.resetAuthStatus();
|
||||||
|
this.rebaseline();
|
||||||
this.$emit("reset");
|
this.$emit("reset");
|
||||||
},
|
},
|
||||||
|
rebaseline() {
|
||||||
|
this.baseline = JSON.stringify(this.values);
|
||||||
|
},
|
||||||
async loadConfiguration() {
|
async loadConfiguration() {
|
||||||
try {
|
try {
|
||||||
const device = await this.device.load(this.id!);
|
const device = await this.device.load(this.id!);
|
||||||
|
|
@ -667,18 +676,24 @@ export default defineComponent({
|
||||||
if (this.onConfigurationLoaded) {
|
if (this.onConfigurationLoaded) {
|
||||||
this.onConfigurationLoaded(this.values);
|
this.onConfigurationLoaded(this.values);
|
||||||
}
|
}
|
||||||
|
this.rebaseline();
|
||||||
this.checkAuthStatus();
|
this.checkAuthStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
applyDefaults() {
|
applyDefaults() {
|
||||||
|
// late-arriving defaults must not mark a clean form dirty
|
||||||
|
const wasClean = !this.dirty;
|
||||||
applyDefaultsFromTemplate(this.template, this.values);
|
applyDefaultsFromTemplate(this.template, this.values);
|
||||||
|
|
||||||
// Allow parent to apply custom defaults
|
// Allow parent to apply custom defaults
|
||||||
if (this.applyCustomDefaults) {
|
if (this.applyCustomDefaults) {
|
||||||
this.applyCustomDefaults(this.template, this.values);
|
this.applyCustomDefaults(this.template, this.values);
|
||||||
}
|
}
|
||||||
|
if (wasClean) {
|
||||||
|
this.rebaseline();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async loadProducts() {
|
async loadProducts() {
|
||||||
if (!this.isModalVisible) {
|
if (!this.isModalVisible) {
|
||||||
|
|
@ -901,7 +916,12 @@ export default defineComponent({
|
||||||
const param = this.templateParams.find((p) => p.Name === paramName);
|
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
|
// 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) {
|
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];
|
this.values[paramName] = values[0];
|
||||||
|
if (wasClean) {
|
||||||
|
this.rebaseline();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
:title="title"
|
:title="title"
|
||||||
:size="size"
|
:size="size"
|
||||||
:config-modal-name="name"
|
:config-modal-name="name"
|
||||||
|
:prevent-dismiss="!nothingChanged"
|
||||||
@open="open"
|
@open="open"
|
||||||
>
|
>
|
||||||
<p v-if="description || docsLink">
|
<p v-if="description || docsLink">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
id="loadpointModal"
|
id="loadpointModal"
|
||||||
ref="modal"
|
ref="modal"
|
||||||
config-modal-name="loadpoint"
|
config-modal-name="loadpoint"
|
||||||
|
:prevent-dismiss="dirty"
|
||||||
:title="modalTitle"
|
:title="modalTitle"
|
||||||
data-testid="loadpoint-modal"
|
data-testid="loadpoint-modal"
|
||||||
@open="onOpen"
|
@open="onOpen"
|
||||||
|
|
@ -738,6 +739,7 @@ export default {
|
||||||
isModalVisible: false,
|
isModalVisible: false,
|
||||||
saving: false,
|
saving: false,
|
||||||
values: deepClone(defaultValues) as ConfigLoadpoint,
|
values: deepClone(defaultValues) as ConfigLoadpoint,
|
||||||
|
baseline: JSON.stringify(defaultValues),
|
||||||
chargerPower: "11kw",
|
chargerPower: "11kw",
|
||||||
solarMode: "default",
|
solarMode: "default",
|
||||||
autoCreate: false,
|
autoCreate: false,
|
||||||
|
|
@ -750,6 +752,9 @@ export default {
|
||||||
id(): number | undefined {
|
id(): number | undefined {
|
||||||
return getModal("loadpoint")?.id;
|
return getModal("loadpoint")?.id;
|
||||||
},
|
},
|
||||||
|
dirty(): boolean {
|
||||||
|
return JSON.stringify(this.values) !== this.baseline;
|
||||||
|
},
|
||||||
selectedType(): LoadpointType | undefined {
|
selectedType(): LoadpointType | undefined {
|
||||||
return getModal("loadpoint")?.type as LoadpointType | undefined;
|
return getModal("loadpoint")?.type as LoadpointType | undefined;
|
||||||
},
|
},
|
||||||
|
|
@ -926,6 +931,10 @@ export default {
|
||||||
this.autoCreate = false;
|
this.autoCreate = false;
|
||||||
this.autoCreateInProgress = false;
|
this.autoCreateInProgress = false;
|
||||||
this.updatePhases();
|
this.updatePhases();
|
||||||
|
this.rebaseline();
|
||||||
|
},
|
||||||
|
rebaseline() {
|
||||||
|
this.baseline = JSON.stringify(this.values);
|
||||||
},
|
},
|
||||||
async loadConfiguration() {
|
async loadConfiguration() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -934,6 +943,7 @@ export default {
|
||||||
this.updateChargerPower();
|
this.updateChargerPower();
|
||||||
this.updateSolarMode();
|
this.updateSolarMode();
|
||||||
this.updatePhases();
|
this.updatePhases();
|
||||||
|
this.rebaseline();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export default defineComponent({
|
||||||
title: String,
|
title: String,
|
||||||
dataTestid: String,
|
dataTestid: String,
|
||||||
uncloseable: Boolean,
|
uncloseable: Boolean,
|
||||||
|
preventDismiss: Boolean,
|
||||||
size: String,
|
size: String,
|
||||||
autofocus: { type: Boolean, default: true },
|
autofocus: { type: Boolean, default: true },
|
||||||
configModalName: String,
|
configModalName: String,
|
||||||
|
|
@ -59,6 +60,11 @@ export default defineComponent({
|
||||||
isModalVisible: false,
|
isModalVisible: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
preventDismiss() {
|
||||||
|
this.applyDismissProtection();
|
||||||
|
},
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
sizeClass() {
|
sizeClass() {
|
||||||
return this.size ? `modal-${this.size}` : "";
|
return this.size ? `modal-${this.size}` : "";
|
||||||
|
|
@ -92,6 +98,20 @@ export default defineComponent({
|
||||||
handleShow() {
|
handleShow() {
|
||||||
this.$emit("open");
|
this.$emit("open");
|
||||||
this.isModalVisible = true;
|
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() {
|
handleShown() {
|
||||||
this.$emit("opened");
|
this.$emit("opened");
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,11 @@ test.describe("charging loadpoint", async () => {
|
||||||
await expectModalVisible(lpModal);
|
await expectModalVisible(lpModal);
|
||||||
await expect(lpModal.getByRole("heading", { name: "Edit Charging Point" })).toBeVisible();
|
await expect(lpModal.getByRole("heading", { name: "Edit Charging Point" })).toBeVisible();
|
||||||
await lpModal.getByLabel("Title").fill("Solar Carport 2");
|
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 lpModal.getByRole("button", { name: "Save" }).click();
|
||||||
await expectModalHidden(lpModal);
|
await expectModalHidden(lpModal);
|
||||||
await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2");
|
await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport 2");
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,10 @@ test.describe("messaging", async () => {
|
||||||
await titleInput.fill("event-start-title");
|
await titleInput.fill("event-start-title");
|
||||||
await messageInput.fill("event-start-message");
|
await messageInput.fill("event-start-message");
|
||||||
|
|
||||||
|
// unsaved changes: ESC keeps modal open
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expectModalVisible(modal);
|
||||||
|
|
||||||
// validate connection
|
// validate connection
|
||||||
await modal.getByRole("button", { name: "Save", exact: true }).click();
|
await modal.getByRole("button", { name: "Save", exact: true }).click();
|
||||||
await expectModalHidden(modal);
|
await expectModalHidden(modal);
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,33 @@ test.describe("vehicles", async () => {
|
||||||
await expect(page.getByTestId("vehicle")).toHaveCount(0);
|
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 }) => {
|
test("config should survive restart", async ({ page }) => {
|
||||||
await start();
|
await start();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue