Config UI: refactor modal handling, add deep linking (#27344)
This commit is contained in:
parent
91e1e3303d
commit
90e7ac313f
36 changed files with 785 additions and 455 deletions
|
|
@ -4,6 +4,7 @@
|
|||
ref="modal"
|
||||
:title="title"
|
||||
:data-testid="dataTestId"
|
||||
:config-modal-name="configModalName"
|
||||
:uncloseable="isUncloseable"
|
||||
@open="open"
|
||||
@closed="closed"
|
||||
|
|
@ -114,6 +115,9 @@ export default defineComponent({
|
|||
modalId() {
|
||||
return this.updateMode ? "passwordUpdateModal" : "passwordSetupModal";
|
||||
},
|
||||
configModalName() {
|
||||
return this.updateMode ? "passwordupdate" : undefined;
|
||||
},
|
||||
dataTestId() {
|
||||
return this.updateMode ? "password-update-modal" : "password-setup-modal";
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
<div>
|
||||
<GenericModal
|
||||
id="backupRestoreModal"
|
||||
config-modal-name="backuprestore"
|
||||
:title="$t('config.system.backupRestore.title')"
|
||||
data-testid="backup-restore-modal"
|
||||
@closed="backupRestoreModalClosed"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
<template>
|
||||
<DeviceModalBase
|
||||
:id="id"
|
||||
modal-id="chargerModal"
|
||||
name="charger"
|
||||
device-type="charger"
|
||||
:fade="fade"
|
||||
:is-sponsor="isSponsor"
|
||||
:modal-title="modalTitle"
|
||||
:provide-template-options="provideTemplateOptions"
|
||||
|
|
@ -17,9 +16,9 @@
|
|||
:custom-fields="customFields"
|
||||
:get-product-name="getProductName"
|
||||
:hide-template-fields="showOcppOnboarding"
|
||||
@added="$emit('added', $event)"
|
||||
@updated="$emit('updated')"
|
||||
@removed="$emit('removed')"
|
||||
@added="(name) => emitChanged('added', name)"
|
||||
@updated="() => emitChanged('updated')"
|
||||
@removed="() => emitChanged('removed')"
|
||||
@close="$emit('close')"
|
||||
@reset="reset"
|
||||
>
|
||||
|
|
@ -86,7 +85,7 @@ import { defineComponent, type PropType } from "vue";
|
|||
import FormRow from "./FormRow.vue";
|
||||
import DeviceModalBase from "./DeviceModal/DeviceModalBase.vue";
|
||||
import { ConfigType } from "@/types/evcc";
|
||||
import type { ModalFade } from "../Helper/GenericModal.vue";
|
||||
import { getModal } from "@/configModal";
|
||||
import {
|
||||
type DeviceValues,
|
||||
type Template,
|
||||
|
|
@ -123,16 +122,13 @@ export default defineComponent({
|
|||
DeviceModalBase,
|
||||
},
|
||||
props: {
|
||||
id: Number,
|
||||
loadpointType: { type: String as PropType<LoadpointType>, default: null },
|
||||
fade: String as PropType<ModalFade>,
|
||||
ocpp: {
|
||||
type: Object as PropType<Ocpp>,
|
||||
default: () => ({ config: { port: 0 }, status: { stations: [] } }),
|
||||
},
|
||||
isSponsor: Boolean,
|
||||
},
|
||||
emits: ["added", "updated", "removed", "close"],
|
||||
emits: ["changed", "close"],
|
||||
data() {
|
||||
return {
|
||||
initialValues,
|
||||
|
|
@ -143,6 +139,12 @@ export default defineComponent({
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
id(): number | undefined {
|
||||
return getModal("charger")?.id;
|
||||
},
|
||||
loadpointType(): LoadpointType | null {
|
||||
return (getModal("charger")?.type as LoadpointType) || null;
|
||||
},
|
||||
modalTitle(): string {
|
||||
if (this.isNew) {
|
||||
return this.$t(`config.charger.titleAdd.${this.loadpointType}`);
|
||||
|
|
@ -319,6 +321,10 @@ export default defineComponent({
|
|||
this.ocppNextStepConfirmed = true;
|
||||
}
|
||||
},
|
||||
async emitChanged(action: "added" | "updated" | "removed", name?: string) {
|
||||
const result = { action, name };
|
||||
this.$emit("changed", result);
|
||||
},
|
||||
reset() {
|
||||
this.ocppNextStepConfirmed = false;
|
||||
this.currentTemplate = null;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
<template>
|
||||
<YamlModal
|
||||
id="circuitsModal"
|
||||
name="circuits"
|
||||
:title="$t('config.circuits.title')"
|
||||
:description="$t('config.circuits.description')"
|
||||
docs="/docs/features/loadmanagement"
|
||||
:defaultYaml="defaultYaml"
|
||||
removeKey="circuits"
|
||||
endpoint="/config/circuits"
|
||||
data-testid="circuits-modal"
|
||||
@changed="$emit('changed')"
|
||||
>
|
||||
<template #extra>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
id="controlModal"
|
||||
ref="modal"
|
||||
:title="$t('config.control.title')"
|
||||
config-modal-name="control"
|
||||
data-testid="control-modal"
|
||||
@open="open"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<template>
|
||||
<GenericModal
|
||||
:id="modalId"
|
||||
:id="`${name}Modal`"
|
||||
ref="modal"
|
||||
:title="modalTitle"
|
||||
:data-testid="`${deviceType}-modal`"
|
||||
:fade="fade"
|
||||
:data-testid="`${name}-modal`"
|
||||
:size="modalSize"
|
||||
:config-modal-name="name"
|
||||
@open="handleOpen"
|
||||
@close="handleClose"
|
||||
@visibilitychange="handleVisibilityChange"
|
||||
|
|
@ -166,7 +166,8 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import GenericModal, { type ModalFade } from "../../Helper/GenericModal.vue";
|
||||
import GenericModal from "../../Helper/GenericModal.vue";
|
||||
import { closeModal } from "@/configModal";
|
||||
import ErrorMessage from "../../Helper/ErrorMessage.vue";
|
||||
import PropertyEntry from "../PropertyEntry.vue";
|
||||
import PropertyCollapsible from "../PropertyCollapsible.vue";
|
||||
|
|
@ -219,13 +220,12 @@ export default defineComponent({
|
|||
props: {
|
||||
deviceType: { type: String as PropType<DeviceType>, required: true },
|
||||
id: Number as PropType<number | undefined>,
|
||||
fade: String as PropType<ModalFade>,
|
||||
name: String,
|
||||
isSponsor: Boolean,
|
||||
// Computed/derived props that must be provided by parent
|
||||
modalTitle: { type: String, required: true },
|
||||
initialValues: { type: Object as PropType<DeviceValues>, required: true },
|
||||
customFields: { type: Array as PropType<string[]>, default: () => CUSTOM_FIELDS },
|
||||
modalId: { type: String, required: true },
|
||||
// Optional: whether to show main content (for multi-step modals like MeterModal)
|
||||
showMainContent: { type: Boolean, default: true },
|
||||
// Optional: usage parameter for loadProducts (e.g., meter type: "pv", "battery", "aux", "ext")
|
||||
|
|
@ -658,7 +658,7 @@ export default defineComponent({
|
|||
this.succeeded = true;
|
||||
await sleep(500);
|
||||
this.$emit("added", name);
|
||||
(this.$refs["modal"] as any).close();
|
||||
await closeModal({ action: "added", name });
|
||||
} catch (e) {
|
||||
handleError(e, "create failed");
|
||||
this.saving = false;
|
||||
|
|
@ -687,12 +687,11 @@ export default defineComponent({
|
|||
try {
|
||||
console.log("calling device.update", this.apiData);
|
||||
await this.device.update(this.id!, this.apiData, force);
|
||||
console.log("update succeeded, closing modal");
|
||||
this.saving = false;
|
||||
this.succeeded = true;
|
||||
await sleep(500);
|
||||
this.$emit("updated");
|
||||
(this.$refs["modal"] as any).close();
|
||||
await closeModal({ action: "updated" });
|
||||
} catch (e) {
|
||||
console.error("update failed", e);
|
||||
handleError(e, "update failed");
|
||||
|
|
@ -703,7 +702,7 @@ export default defineComponent({
|
|||
try {
|
||||
await this.device.remove(this.id!);
|
||||
this.$emit("removed");
|
||||
(this.$refs["modal"] as any).close();
|
||||
await closeModal({ action: "removed" });
|
||||
} catch (e) {
|
||||
handleError(e, "remove failed");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
<template>
|
||||
<JsonModal
|
||||
id="eebusModal"
|
||||
name="eebus"
|
||||
:title="$t('config.eebus.title')"
|
||||
:description="$t('config.eebus.description')"
|
||||
docs="/docs/reference/configuration/eebus"
|
||||
endpoint="/config/eebus"
|
||||
state-key="eebus.config"
|
||||
data-testid="eebus-modal"
|
||||
:no-buttons="fromYaml"
|
||||
:confirm-remove="$t('config.eebus.removeConfirm')"
|
||||
@changed="$emit('changed')"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
<GenericModal
|
||||
id="experimentalModal"
|
||||
:title="`${$t('config.experimental.title')} 🧪`"
|
||||
config-modal-name="experimental"
|
||||
data-testid="experimental-modal"
|
||||
>
|
||||
<p>{{ $t("config.experimental.description") }}</p>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
test-id="generalconfig-title"
|
||||
:label="$t('config.general.title')"
|
||||
:text="title || '---'"
|
||||
modal-id="titleModal"
|
||||
@edit="openModal('title')"
|
||||
>
|
||||
</GeneralConfigEntry>
|
||||
|
||||
|
|
@ -12,21 +12,21 @@
|
|||
test-id="generalconfig-password"
|
||||
:label="$t('config.general.password')"
|
||||
text="*******"
|
||||
modal-id="passwordUpdateModal"
|
||||
@edit="openModal('passwordupdate')"
|
||||
/>
|
||||
|
||||
<GeneralConfigEntry
|
||||
test-id="generalconfig-telemetry"
|
||||
:label="$t('config.general.telemetry')"
|
||||
:text="$t(`config.general.${telemetryEnabled ? 'on' : 'off'}`)"
|
||||
modal-id="telemetryModal"
|
||||
@edit="openModal('telemetry')"
|
||||
/>
|
||||
|
||||
<GeneralConfigEntry
|
||||
test-id="generalconfig-experimental"
|
||||
:label="$t('config.general.experimental')"
|
||||
:text="$t(`config.general.${experimental ? 'on' : 'off'}`)"
|
||||
modal-id="experimentalModal"
|
||||
@edit="openModal('experimental')"
|
||||
/>
|
||||
|
||||
<GeneralConfigEntry
|
||||
|
|
@ -34,7 +34,7 @@
|
|||
:label="$t('config.sponsor.title')"
|
||||
:text="sponsorStatus.title"
|
||||
:text-class="sponsorStatus.textClass"
|
||||
modal-id="sponsorModal"
|
||||
@edit="openModal('sponsor')"
|
||||
>
|
||||
<template #text-prefix>
|
||||
<span
|
||||
|
|
@ -49,41 +49,37 @@
|
|||
test-id="generalconfig-network"
|
||||
:label="$t('config.network.title')"
|
||||
:text="networkStatus"
|
||||
modal-id="networkModal"
|
||||
@edit="openModal('network')"
|
||||
/>
|
||||
|
||||
<GeneralConfigEntry
|
||||
test-id="generalconfig-control"
|
||||
:label="$t('config.control.title')"
|
||||
:text="controlStatus"
|
||||
modal-id="controlModal"
|
||||
@edit="openModal('control')"
|
||||
/>
|
||||
<TitleModal ref="titleModal" @changed="load" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TitleModal from "./TitleModal.vue";
|
||||
import GeneralConfigEntry from "./GeneralConfigEntry.vue";
|
||||
import api from "@/api";
|
||||
import { openModal } from "@/configModal";
|
||||
import store from "@/store";
|
||||
import formatter from "@/mixins/formatter";
|
||||
|
||||
export default {
|
||||
name: "GeneralConfig",
|
||||
components: { TitleModal, GeneralConfigEntry },
|
||||
components: { GeneralConfigEntry },
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
sponsorError: Boolean,
|
||||
experimental: Boolean,
|
||||
},
|
||||
emits: ["site-changed"],
|
||||
data() {
|
||||
return {
|
||||
title: "",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
title() {
|
||||
return store.state?.siteTitle || "";
|
||||
},
|
||||
telemetryEnabled() {
|
||||
return store.state?.telemetry === true;
|
||||
},
|
||||
|
|
@ -121,28 +117,8 @@ export default {
|
|||
return { title, expiresSoon, textClass, badgeClass };
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.load();
|
||||
},
|
||||
methods: {
|
||||
async changed() {
|
||||
this.$emit("site-changed");
|
||||
this.load();
|
||||
},
|
||||
async load() {
|
||||
try {
|
||||
const res = await api.get("/config/site", {
|
||||
validateStatus: (code) => [200, 404].includes(code),
|
||||
});
|
||||
if (res.status === 200) {
|
||||
this.title = res.data.title;
|
||||
} else {
|
||||
console.log("TODO: implement site endpoint in config error mode");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
openModal,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
type="button"
|
||||
:title="$t('config.main.edit')"
|
||||
tabindex="0"
|
||||
@click.prevent="handleEditClick"
|
||||
@click.prevent="$emit('edit')"
|
||||
>
|
||||
<EditIcon size="xs" />
|
||||
</button>
|
||||
|
|
@ -27,7 +27,6 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import Modal from "bootstrap/js/dist/modal";
|
||||
import EditIcon from "../MaterialIcon/Edit.vue";
|
||||
|
||||
export default {
|
||||
|
|
@ -38,22 +37,7 @@ export default {
|
|||
label: { type: String, required: true },
|
||||
text: { type: String, default: "---" },
|
||||
textClass: { type: String, default: "" },
|
||||
modalId: { type: String, required: true },
|
||||
},
|
||||
emits: ["edit-clicked"],
|
||||
methods: {
|
||||
handleEditClick() {
|
||||
this.$emit("edit-clicked");
|
||||
this.openModal();
|
||||
},
|
||||
openModal() {
|
||||
const $el = document.getElementById(this.modalId);
|
||||
if ($el) {
|
||||
Modal.getOrCreateInstance($el).show();
|
||||
} else {
|
||||
console.error(`modal ${this.modalId} not found`);
|
||||
}
|
||||
},
|
||||
},
|
||||
emits: ["edit"],
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<YamlModal
|
||||
id="hemsModal"
|
||||
name="hems"
|
||||
:title="$t('config.hems.title')"
|
||||
:description="$t('config.hems.description')"
|
||||
docs="/docs/features/14a-enwg-steuve"
|
||||
|
|
@ -9,7 +9,6 @@
|
|||
removeKey="hems"
|
||||
:noYamlEditor="fromYaml"
|
||||
:disableSave="fromYaml"
|
||||
data-testid="hems-modal"
|
||||
@changed="$emit('changed')"
|
||||
@open="loadSessions"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
<template>
|
||||
<JsonModal
|
||||
id="influxModal"
|
||||
name="influx"
|
||||
:title="$t('config.influx.title')"
|
||||
:description="$t('config.influx.description')"
|
||||
docs="/docs/reference/configuration/influx"
|
||||
endpoint="/config/influx"
|
||||
state-key="influx"
|
||||
data-testid="influx-modal"
|
||||
@changed="$emit('changed')"
|
||||
>
|
||||
<template #default="{ values }">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,13 @@
|
|||
<template>
|
||||
<GenericModal ref="modal" :title="title" :size="size" @open="open">
|
||||
<GenericModal
|
||||
:id="`${name}Modal`"
|
||||
ref="modal"
|
||||
:data-testid="`${name}-modal`"
|
||||
:title="title"
|
||||
:size="size"
|
||||
:config-modal-name="name"
|
||||
@open="open"
|
||||
>
|
||||
<p v-if="description || docsLink">
|
||||
<span v-if="description">{{ description + " " }}</span>
|
||||
<a v-if="docsLink" :href="docsLink" target="_blank">
|
||||
|
|
@ -89,6 +97,7 @@ export default {
|
|||
storeValuesInArray: Boolean,
|
||||
size: { type: String },
|
||||
confirmRemove: String,
|
||||
name: String,
|
||||
},
|
||||
emits: ["changed", "open"],
|
||||
data() {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@
|
|||
<GenericModal
|
||||
id="loadpointModal"
|
||||
ref="modal"
|
||||
config-modal-name="loadpoint"
|
||||
:title="modalTitle"
|
||||
data-testid="loadpoint-modal"
|
||||
:fade="fade"
|
||||
@open="onOpen"
|
||||
@opened="onOpened"
|
||||
@close="onClose"
|
||||
>
|
||||
<div v-if="!loadpointType" class="d-flex flex-column gap-4">
|
||||
|
|
@ -576,7 +575,7 @@ import FormRow from "./FormRow.vue";
|
|||
import PropertyField from "./PropertyField.vue";
|
||||
import SelectGroup from "../Helper/SelectGroup.vue";
|
||||
import api from "@/api";
|
||||
import GenericModal, { type ModalFade } from "../Helper/GenericModal.vue";
|
||||
import GenericModal from "../Helper/GenericModal.vue";
|
||||
import deepClone from "@/utils/deepClone";
|
||||
import deepEqual from "@/utils/deepEqual";
|
||||
import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
||||
|
|
@ -584,6 +583,7 @@ import EditIcon from "../MaterialIcon/Edit.vue";
|
|||
import NewDeviceButton from "./NewDeviceButton.vue";
|
||||
import InvalidReferenceAlert from "./InvalidReferenceAlert.vue";
|
||||
import { handleError, customChargerName } from "./DeviceModal";
|
||||
import { getModal, openModal, replaceModal, closeModal } from "@/configModal";
|
||||
import {
|
||||
LOADPOINT_TYPE,
|
||||
type DeviceType,
|
||||
|
|
@ -637,11 +637,8 @@ export default {
|
|||
},
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
id: Number,
|
||||
name: String,
|
||||
vehicleOptions: { type: Array as PropType<VehicleOption[]>, default: () => [] },
|
||||
loadpointCount: { type: Number, default: 0 },
|
||||
fade: String as PropType<ModalFade>,
|
||||
chargers: { type: Array as PropType<ConfigCharger[]>, default: () => [] },
|
||||
chargerValues: { type: Object, default: () => {} },
|
||||
meters: { type: Array as PropType<ConfigMeter[]>, default: () => [] },
|
||||
|
|
@ -651,12 +648,11 @@ export default {
|
|||
default: () => false,
|
||||
},
|
||||
},
|
||||
emits: ["updated", "openMeterModal", "openChargerModal", "opened"],
|
||||
emits: ["changed"],
|
||||
data() {
|
||||
return {
|
||||
isModalVisible: false,
|
||||
saving: false,
|
||||
selectedType: null as LoadpointType | null,
|
||||
values: deepClone(defaultValues) as ConfigLoadpoint,
|
||||
chargerPower: "11kw",
|
||||
solarMode: "default",
|
||||
|
|
@ -665,6 +661,12 @@ export default {
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
id(): number | undefined {
|
||||
return getModal("loadpoint")?.id;
|
||||
},
|
||||
selectedType(): LoadpointType | undefined {
|
||||
return getModal("loadpoint")?.type as LoadpointType | undefined;
|
||||
},
|
||||
modalTitle() {
|
||||
if (this.isNew) {
|
||||
return this.$t(`config.loadpoint.titleAdd.${this.loadpointType || "unknown"}`);
|
||||
|
|
@ -814,7 +816,6 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
reset() {
|
||||
this.selectedType = null;
|
||||
this.values = deepClone(defaultValues);
|
||||
this.updatePhases();
|
||||
},
|
||||
|
|
@ -829,13 +830,17 @@ export default {
|
|||
console.error(e);
|
||||
}
|
||||
},
|
||||
async emitChanged(action: "added" | "updated" | "removed") {
|
||||
const result = { action };
|
||||
await closeModal(result);
|
||||
this.$emit("changed", result);
|
||||
},
|
||||
async update() {
|
||||
this.saving = true;
|
||||
try {
|
||||
const values = deepClone(this.values);
|
||||
await api.put(`config/loadpoints/${this.id}`, values);
|
||||
this.$emit("updated");
|
||||
(this.$refs["modal"] as any).close();
|
||||
this.emitChanged("updated");
|
||||
} catch (e) {
|
||||
handleError(e, "update failed");
|
||||
}
|
||||
|
|
@ -844,8 +849,7 @@ export default {
|
|||
async remove() {
|
||||
try {
|
||||
await api.delete(`config/loadpoints/${this.id}`);
|
||||
this.$emit("updated");
|
||||
(this.$refs["modal"] as any).close();
|
||||
this.emitChanged("removed");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("delete failed");
|
||||
|
|
@ -855,8 +859,7 @@ export default {
|
|||
this.saving = true;
|
||||
try {
|
||||
await api.post("config/loadpoints", this.values);
|
||||
this.$emit("updated");
|
||||
(this.$refs["modal"] as any).close();
|
||||
this.emitChanged("added");
|
||||
} catch (e) {
|
||||
handleError(e, "create failed");
|
||||
}
|
||||
|
|
@ -865,25 +868,32 @@ export default {
|
|||
onOpen() {
|
||||
this.isModalVisible = true;
|
||||
},
|
||||
onOpened() {
|
||||
this.$emit("opened");
|
||||
},
|
||||
onClose() {
|
||||
this.isModalVisible = false;
|
||||
},
|
||||
editCharger() {
|
||||
this.$emit("openChargerModal", this.values.charger, this.loadpointType);
|
||||
async editCharger() {
|
||||
const charger = this.chargers.find((c) => c.name === this.values.charger);
|
||||
const result = await openModal("charger", {
|
||||
id: charger?.id,
|
||||
type: this.loadpointType || undefined,
|
||||
});
|
||||
if (result.action === "added" && result.name) {
|
||||
this.values.charger = result.name;
|
||||
} else if (result.action === "removed") {
|
||||
this.values.charger = "";
|
||||
}
|
||||
},
|
||||
editMeter() {
|
||||
this.$emit("openMeterModal", this.values.meter);
|
||||
},
|
||||
// called externally
|
||||
setMeter(meter: string) {
|
||||
this.values.meter = meter;
|
||||
},
|
||||
// called externally
|
||||
setCharger(charger: string) {
|
||||
this.values.charger = charger;
|
||||
async editMeter() {
|
||||
const meter = this.meters.find((m) => m.name === this.values.meter);
|
||||
const result = await openModal("meter", {
|
||||
id: meter?.id,
|
||||
type: "charge",
|
||||
});
|
||||
if (result.action === "added" && result.name) {
|
||||
this.values.meter = result.name;
|
||||
} else if (result.action === "removed") {
|
||||
this.values.meter = "";
|
||||
}
|
||||
},
|
||||
updateChargerPower() {
|
||||
const { minCurrent, maxCurrent } = this.values;
|
||||
|
|
@ -919,7 +929,7 @@ export default {
|
|||
}
|
||||
},
|
||||
selectType(type: LoadpointType) {
|
||||
this.selectedType = type;
|
||||
replaceModal("loadpoint", { id: this.id, type });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
<template>
|
||||
<YamlModal
|
||||
id="messagingModal"
|
||||
name="messaging"
|
||||
:title="$t('config.messaging.title')"
|
||||
:description="$t('config.messaging.description')"
|
||||
docs="/docs/reference/configuration/messaging"
|
||||
:defaultYaml="defaultYaml"
|
||||
endpoint="/config/messaging"
|
||||
removeKey="messaging"
|
||||
data-testid="messaging-modal"
|
||||
@changed="$emit('changed')"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@
|
|||
<DeviceModalBase
|
||||
:id="id"
|
||||
v-model:external-template="selectedTemplate"
|
||||
modal-id="meterModal"
|
||||
name="meter"
|
||||
device-type="meter"
|
||||
:fade="fade"
|
||||
:is-sponsor="isSponsor"
|
||||
:modal-title="modalTitle"
|
||||
:provide-template-options="provideTemplateOptions"
|
||||
|
|
@ -12,19 +11,19 @@
|
|||
:transform-api-data="transformApiData"
|
||||
:filter-template-params="filterTemplateParams"
|
||||
:on-template-change="handleTemplateChange"
|
||||
:show-main-content="!!meterType"
|
||||
:show-main-content="!!selectedType"
|
||||
:apply-custom-defaults="applyCustomDefaults"
|
||||
:custom-fields="customFields"
|
||||
:preserve-on-template-change="preserveFields"
|
||||
:usage="templateUsage"
|
||||
:on-configuration-loaded="onConfigurationLoaded"
|
||||
@added="handleAdded"
|
||||
@updated="$emit('updated')"
|
||||
@removed="handleRemoved"
|
||||
@added="(name) => emitChanged('added', name)"
|
||||
@updated="() => emitChanged('updated')"
|
||||
@removed="() => emitChanged('removed')"
|
||||
@close="handleClose"
|
||||
>
|
||||
<template #pre-content>
|
||||
<div v-if="!meterType" class="d-flex flex-column gap-4">
|
||||
<div v-if="!selectedType" class="d-flex flex-column gap-4">
|
||||
<NewDeviceButton
|
||||
v-for="t in typeChoices"
|
||||
:key="t"
|
||||
|
|
@ -37,7 +36,7 @@
|
|||
|
||||
<template #description>
|
||||
<p v-if="hasDescription" class="mt-0 mb-4">
|
||||
{{ $t(`config.${meterType}.description`) }}
|
||||
{{ $t(`config.${selectedType}.description`) }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
|
|
@ -72,7 +71,7 @@
|
|||
/>
|
||||
</FormRow>
|
||||
<FormRow
|
||||
v-if="meterType === 'ext'"
|
||||
v-if="selectedType === 'ext'"
|
||||
id="meterParamExtMeterUsage"
|
||||
:label="$t('config.meter.usage.label')"
|
||||
>
|
||||
|
|
@ -90,14 +89,13 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import { defineComponent } from "vue";
|
||||
import FormRow from "./FormRow.vue";
|
||||
import PropertyField from "./PropertyField.vue";
|
||||
import NewDeviceButton from "./NewDeviceButton.vue";
|
||||
import DeviceModalBase from "./DeviceModal/DeviceModalBase.vue";
|
||||
import { ICONS } from "../VehicleIcon/VehicleIcon.vue";
|
||||
import { ConfigType, type MeterType, type MeterTemplateUsage } from "@/types/evcc";
|
||||
import type { ModalFade } from "../Helper/GenericModal.vue";
|
||||
import {
|
||||
type DeviceValues,
|
||||
type Template,
|
||||
|
|
@ -107,6 +105,7 @@ import {
|
|||
} from "./DeviceModal";
|
||||
import { customTemplateOption, type TemplateGroup } from "./DeviceModal/TemplateSelector.vue";
|
||||
import defaultMeterYaml from "./defaultYaml/meter.yaml?raw";
|
||||
import { getModal, replaceModal } from "@/configModal";
|
||||
|
||||
const initialValues = {
|
||||
type: ConfigType.Template,
|
||||
|
|
@ -134,16 +133,11 @@ export default defineComponent({
|
|||
DeviceModalBase,
|
||||
},
|
||||
props: {
|
||||
id: Number,
|
||||
type: { type: String as PropType<MeterType>, default: null },
|
||||
typeChoices: { type: Array as () => MeterType[], default: () => [] },
|
||||
fade: String as PropType<ModalFade>,
|
||||
isSponsor: Boolean,
|
||||
},
|
||||
emits: ["added", "updated", "removed", "close"],
|
||||
emits: ["changed", "close"],
|
||||
data() {
|
||||
return {
|
||||
selectedType: null as MeterType | null,
|
||||
extMeterUsage: "charge" as MeterTemplateUsage,
|
||||
selectedTemplate: null as string | null,
|
||||
iconChoices: ICONS,
|
||||
|
|
@ -153,39 +147,45 @@ export default defineComponent({
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
id(): number | undefined {
|
||||
return getModal("meter")?.id;
|
||||
},
|
||||
selectedType(): MeterType | undefined {
|
||||
return getModal("meter")?.type as MeterType | undefined;
|
||||
},
|
||||
typeChoices(): MeterType[] {
|
||||
return (getModal("meter")?.choices as MeterType[]) || [];
|
||||
},
|
||||
modalTitle(): string {
|
||||
if (this.isNew) {
|
||||
if (this.meterType) {
|
||||
return this.$t(`config.${this.meterType}.titleAdd`);
|
||||
if (this.selectedType) {
|
||||
return this.$t(`config.${this.selectedType}.titleAdd`);
|
||||
} else {
|
||||
return this.$t("config.meter.titleChoice");
|
||||
}
|
||||
}
|
||||
return this.$t(`config.${this.meterType}.titleEdit`);
|
||||
},
|
||||
meterType(): MeterType | null {
|
||||
return this.type || this.selectedType;
|
||||
return this.$t(`config.${this.selectedType}.titleEdit`);
|
||||
},
|
||||
templateUsage(): MeterTemplateUsage | undefined {
|
||||
if (!this.meterType) return undefined;
|
||||
if (!this.selectedType) return undefined;
|
||||
|
||||
// For ext meters, the user selects the template usage explicitly
|
||||
// For other meter types, the meter type IS the template usage
|
||||
if (this.meterType === "ext") {
|
||||
if (this.selectedType === "ext") {
|
||||
return this.extMeterUsage;
|
||||
}
|
||||
// For non-ext meters, meterType directly maps to template usage
|
||||
// For non-ext meters, selectedType directly maps to template usage
|
||||
// (grid->grid, pv->pv, battery->battery, charge->charge, aux->aux)
|
||||
return this.meterType;
|
||||
return this.selectedType;
|
||||
},
|
||||
hasDeviceTitle(): boolean {
|
||||
return ["pv", "battery", "aux", "ext"].includes(this.meterType || "");
|
||||
return ["pv", "battery", "aux", "ext"].includes(this.selectedType || "");
|
||||
},
|
||||
hasDeviceIcon(): boolean {
|
||||
return ["aux", "ext"].includes(this.meterType || "");
|
||||
return ["aux", "ext"].includes(this.selectedType || "");
|
||||
},
|
||||
hasDescription(): boolean {
|
||||
return ["ext", "aux"].includes(this.meterType || "");
|
||||
return ["ext", "aux"].includes(this.selectedType || "");
|
||||
},
|
||||
isNew(): boolean {
|
||||
return this.id === undefined;
|
||||
|
|
@ -200,12 +200,12 @@ export default defineComponent({
|
|||
methods: {
|
||||
onConfigurationLoaded(values: DeviceValues) {
|
||||
// Restore extMeterUsage when editing an existing ext meter
|
||||
if (this.meterType === "ext" && values.usage) {
|
||||
if (this.selectedType === "ext" && values.usage) {
|
||||
this.extMeterUsage = values.usage;
|
||||
}
|
||||
},
|
||||
selectType(type: MeterType) {
|
||||
this.selectedType = type;
|
||||
replaceModal("meter", { id: this.id, type });
|
||||
},
|
||||
provideTemplateOptions(products: Product[]): TemplateGroup[] {
|
||||
return [
|
||||
|
|
@ -233,7 +233,7 @@ export default defineComponent({
|
|||
|
||||
// Make capacity non-advanced for battery meters
|
||||
return filtered.map((p) => {
|
||||
if (this.meterType === "battery" && p.Name === "capacity") {
|
||||
if (this.selectedType === "battery" && p.Name === "capacity") {
|
||||
p.Advanced = false;
|
||||
}
|
||||
return p;
|
||||
|
|
@ -243,7 +243,7 @@ export default defineComponent({
|
|||
if (values.type === ConfigType.Template) {
|
||||
// Set the template usage (what the template should do)
|
||||
// For ext meters: user-selected usage (grid, pv, battery, charge, aux)
|
||||
// For other meters: meterType itself is the usage
|
||||
// For other meters: selectedType itself is the usage
|
||||
data.usage = this.templateUsage;
|
||||
}
|
||||
return data;
|
||||
|
|
@ -257,8 +257,8 @@ export default defineComponent({
|
|||
},
|
||||
applyCustomDefaults(_template: Template | null, values: DeviceValues) {
|
||||
// Apply default icon when template is loaded or meter type is selected
|
||||
if (this.meterType && !values.deviceIcon) {
|
||||
values.deviceIcon = defaultIcons[this.meterType] || "";
|
||||
if (this.selectedType && !values.deviceIcon) {
|
||||
values.deviceIcon = defaultIcons[this.selectedType] || "";
|
||||
}
|
||||
},
|
||||
extMeterUsageChanged() {
|
||||
|
|
@ -266,14 +266,12 @@ export default defineComponent({
|
|||
// This triggers product reload via effectiveUsage computed property change
|
||||
this.selectedTemplate = null;
|
||||
},
|
||||
handleAdded(name: string) {
|
||||
this.$emit("added", this.meterType, name);
|
||||
},
|
||||
handleRemoved() {
|
||||
this.$emit("removed", this.meterType);
|
||||
async emitChanged(action: "added" | "updated" | "removed", name?: string) {
|
||||
const type = this.selectedType;
|
||||
const result = { action, name, type };
|
||||
this.$emit("changed", result);
|
||||
},
|
||||
handleClose() {
|
||||
this.selectedType = null;
|
||||
this.extMeterUsage = "charge";
|
||||
this.$emit("close");
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<JsonModal
|
||||
id="modbusProxyModal"
|
||||
name="modbusproxy"
|
||||
:title="$t('config.modbusproxy.title')"
|
||||
:description="$t('config.modbusproxy.description')"
|
||||
docs="/docs/reference/configuration/modbusproxy"
|
||||
|
|
@ -8,7 +8,6 @@
|
|||
state-key="modbusproxy"
|
||||
store-values-in-array
|
||||
disable-remove
|
||||
data-testid="modbusproxy-modal"
|
||||
size="xl"
|
||||
@changed="$emit('changed')"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
<template>
|
||||
<JsonModal
|
||||
id="mqttModal"
|
||||
name="mqtt"
|
||||
:title="$t('config.mqtt.title')"
|
||||
:description="$t('config.mqtt.description')"
|
||||
docs="/docs/reference/configuration/mqtt"
|
||||
endpoint="/config/mqtt"
|
||||
state-key="mqtt"
|
||||
data-testid="mqtt-modal"
|
||||
@changed="$emit('changed')"
|
||||
>
|
||||
<template #default="{ values }">
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
<template>
|
||||
<JsonModal
|
||||
id="networkModal"
|
||||
name="network"
|
||||
:title="$t('config.network.title')"
|
||||
endpoint="/config/network"
|
||||
state-key="network"
|
||||
:transform-write-values="transformWriteValues"
|
||||
disable-remove
|
||||
data-testid="network-modal"
|
||||
@changed="$emit('changed')"
|
||||
>
|
||||
<template #default="{ values }">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
<template>
|
||||
<GenericModal id="ocppModal" :title="$t('config.ocpp.title')" data-testid="ocpp-modal">
|
||||
<GenericModal
|
||||
id="ocppModal"
|
||||
config-modal-name="ocpp"
|
||||
:title="$t('config.ocpp.title')"
|
||||
data-testid="ocpp-modal"
|
||||
>
|
||||
<div class="container">
|
||||
<!-- OCPP URL -->
|
||||
<div class="mb-3">
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
<template>
|
||||
<JsonModal
|
||||
id="shmModal"
|
||||
name="shm"
|
||||
:title="$t('config.shm.title')"
|
||||
:description="$t('config.shm.description')"
|
||||
docs="/docs/reference/configuration/hems"
|
||||
endpoint="/config/shm"
|
||||
state-key="shm"
|
||||
data-testid="shm-modal"
|
||||
disable-remove
|
||||
@changed="$emit('changed')"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
<template>
|
||||
<JsonModal
|
||||
id="sponsorModal"
|
||||
name="sponsor"
|
||||
:title="`${$t('config.sponsor.title')} 💚`"
|
||||
:description="$t('config.sponsor.description')"
|
||||
:error-message="$t('config.sponsor.error')"
|
||||
endpoint="/config/sponsortoken"
|
||||
:transform-read-values="transformReadValues"
|
||||
data-testid="sponsor-modal"
|
||||
size="lg"
|
||||
:no-buttons="notUiEditable"
|
||||
:disable-remove="!hasUiToken"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
<template>
|
||||
<YamlModal
|
||||
id="tariffsModal"
|
||||
name="tariffs"
|
||||
:title="$t('config.tariffs.title')"
|
||||
:description="$t('config.tariffs.description')"
|
||||
docs="/docs/tariffs"
|
||||
:defaultYaml="defaultYaml"
|
||||
removeKey="tariffs"
|
||||
endpoint="/config/tariffs"
|
||||
data-testid="tariffs-modal"
|
||||
@changed="$emit('changed')"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
id="telemetryModal"
|
||||
:title="$t('config.telemetry.title')"
|
||||
size="lg"
|
||||
config-modal-name="telemetry"
|
||||
data-testid="telemetry-modal"
|
||||
>
|
||||
<p>{{ $t("config.telemetry.description") }}</p>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
<template>
|
||||
<JsonModal
|
||||
id="titleModal"
|
||||
name="title"
|
||||
:title="$t('config.title.title')"
|
||||
endpoint="/config/site"
|
||||
state-key="siteTitle"
|
||||
save-method="put"
|
||||
:transform-read-values="transformReadValues"
|
||||
data-testid="title-modal"
|
||||
disable-remove
|
||||
@changed="$emit('changed')"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<DeviceModalBase
|
||||
:id="id"
|
||||
modal-id="vehicleModal"
|
||||
name="vehicle"
|
||||
device-type="vehicle"
|
||||
:is-sponsor="isSponsor"
|
||||
:modal-title="$t(`config.vehicle.${isNew ? 'titleAdd' : 'titleEdit'}`)"
|
||||
|
|
@ -156,6 +156,7 @@ import { ConfigType } from "@/types/evcc";
|
|||
import { customTemplateOption, type TemplateGroup } from "./DeviceModal/TemplateSelector.vue";
|
||||
import type { Product, ApiData, DeviceValues, TemplateParam } from "./DeviceModal";
|
||||
import defaultVehicleYaml from "./defaultYaml/vehicle.yaml?raw";
|
||||
import { getModal } from "@/configModal";
|
||||
|
||||
const initialValues = {
|
||||
type: ConfigType.Template,
|
||||
|
|
@ -183,7 +184,6 @@ export default defineComponent({
|
|||
DeviceModalBase,
|
||||
},
|
||||
props: {
|
||||
id: Number,
|
||||
isSponsor: Boolean,
|
||||
},
|
||||
emits: ["vehicle-changed"],
|
||||
|
|
@ -193,6 +193,9 @@ export default defineComponent({
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
id(): number | undefined {
|
||||
return getModal("vehicle")?.id;
|
||||
},
|
||||
isNew(): boolean {
|
||||
return this.id === undefined;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,14 @@
|
|||
<template>
|
||||
<GenericModal ref="modal" :size="size" :title="title" @open="open" @close="close">
|
||||
<GenericModal
|
||||
:id="`${name}Modal`"
|
||||
ref="modal"
|
||||
:size="size"
|
||||
:title="title"
|
||||
:data-testid="`${name}-modal`"
|
||||
:config-modal-name="name"
|
||||
@open="open"
|
||||
@close="close"
|
||||
>
|
||||
<p v-if="description || docsLink">
|
||||
<span v-if="description">{{ description + " " }}</span>
|
||||
<a v-if="docsLink" :href="docsLink" target="_blank">
|
||||
|
|
@ -67,6 +76,7 @@ export default {
|
|||
size: { type: String, default: "xl" },
|
||||
noYamlEditor: Boolean,
|
||||
disableSave: Boolean,
|
||||
name: String,
|
||||
},
|
||||
emits: ["changed", "open"],
|
||||
data() {
|
||||
|
|
|
|||
|
|
@ -36,9 +36,8 @@
|
|||
|
||||
<script lang="ts">
|
||||
import Modal from "bootstrap/js/dist/modal";
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
|
||||
export type ModalFade = "left" | "right" | undefined;
|
||||
import { defineComponent } from "vue";
|
||||
import { registerModal, unregisterModal, onModalHidden, getModalFade } from "@/configModal";
|
||||
|
||||
export default defineComponent({
|
||||
name: "GenericModal",
|
||||
|
|
@ -47,9 +46,9 @@ export default defineComponent({
|
|||
title: String,
|
||||
dataTestid: String,
|
||||
uncloseable: Boolean,
|
||||
fade: String as PropType<ModalFade>,
|
||||
size: String,
|
||||
autofocus: { type: Boolean, default: true },
|
||||
configModalName: String,
|
||||
},
|
||||
emits: ["open", "opened", "close", "closed", "visibilitychange"],
|
||||
data() {
|
||||
|
|
@ -59,23 +58,14 @@ export default defineComponent({
|
|||
},
|
||||
computed: {
|
||||
classes() {
|
||||
return [
|
||||
"modal",
|
||||
"fade",
|
||||
"text-dark",
|
||||
{ show: this.isModalVisible },
|
||||
this.sizeClass,
|
||||
this.fadeClass,
|
||||
];
|
||||
return ["modal", "fade", "text-dark", this.sizeClass, this.fadeClass];
|
||||
},
|
||||
sizeClass() {
|
||||
return this.size ? `modal-${this.size}` : "";
|
||||
},
|
||||
fadeClass() {
|
||||
if (this.fade) {
|
||||
return `fade-${this.fade}`;
|
||||
}
|
||||
return "";
|
||||
fadeClass(): string {
|
||||
const fade = this.configModalName && getModalFade(this.configModalName);
|
||||
return fade ? `fade-${fade}` : "";
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
|
|
@ -84,6 +74,9 @@ export default defineComponent({
|
|||
this.$refs["modal"]?.addEventListener("hide.bs.modal", this.handleHide);
|
||||
this.$refs["modal"]?.addEventListener("hidden.bs.modal", this.handleHidden);
|
||||
document.addEventListener("visibilitychange", this.handleVisibilityChange);
|
||||
if (this.configModalName) {
|
||||
registerModal(this.configModalName, this.$refs["modal"] as HTMLElement);
|
||||
}
|
||||
},
|
||||
unmounted() {
|
||||
this.$refs["modal"]?.removeEventListener("show.bs.modal", this.handleShow);
|
||||
|
|
@ -91,16 +84,16 @@ export default defineComponent({
|
|||
this.$refs["modal"]?.removeEventListener("hide.bs.modal", this.handleHide);
|
||||
this.$refs["modal"]?.removeEventListener("hidden.bs.modal", this.handleHidden);
|
||||
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
|
||||
if (this.configModalName) {
|
||||
unregisterModal(this.configModalName);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleShow() {
|
||||
console.log(this.dataTestid, "> show");
|
||||
this.$emit("open");
|
||||
},
|
||||
handleShown() {
|
||||
console.log(this.dataTestid, "> shown");
|
||||
this.$emit("opened");
|
||||
// focus first input or select if autofocus is enabled
|
||||
if (this.autofocus) {
|
||||
this.$nextTick(() => {
|
||||
const firstInput =
|
||||
|
|
@ -113,24 +106,21 @@ export default defineComponent({
|
|||
this.isModalVisible = true;
|
||||
},
|
||||
handleHide() {
|
||||
console.log(this.dataTestid, "> hide");
|
||||
this.$emit("close");
|
||||
},
|
||||
handleHidden() {
|
||||
console.log(this.dataTestid, "> hidden");
|
||||
this.$emit("closed");
|
||||
this.isModalVisible = false;
|
||||
if (this.configModalName) {
|
||||
onModalHidden(this.configModalName);
|
||||
}
|
||||
},
|
||||
open() {
|
||||
const modal = this.$refs["modal"] as HTMLElement;
|
||||
// @ts-expect-error bs internal
|
||||
console.log(this.dataTestid, "> open", modal._isShown);
|
||||
Modal.getOrCreateInstance(modal).show();
|
||||
},
|
||||
close() {
|
||||
const modal = this.$refs["modal"] as HTMLElement;
|
||||
// @ts-expect-error bs internal
|
||||
console.log(this.dataTestid, "> close", modal._isShown);
|
||||
Modal.getOrCreateInstance(modal).hide();
|
||||
},
|
||||
handleVisibilityChange() {
|
||||
|
|
|
|||
75
assets/js/configModal.test.ts
Normal file
75
assets/js/configModal.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { parseKey, parseQueryString, buildQuery, extractQueryString } from "./configModal";
|
||||
|
||||
describe("parseKey", () => {
|
||||
test("parses bracket notation", () => {
|
||||
expect(parseKey("meter")).toEqual({ name: "meter" });
|
||||
expect(parseKey("meter[type:grid]")).toEqual({ name: "meter", type: "grid" });
|
||||
expect(parseKey("meter[choices:pv,battery]")).toEqual({
|
||||
name: "meter",
|
||||
choices: ["pv", "battery"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseQueryString", () => {
|
||||
test("parses basic queries", () => {
|
||||
expect(parseQueryString("")).toEqual([]);
|
||||
expect(parseQueryString("messaging")).toEqual([{ name: "messaging" }]);
|
||||
expect(parseQueryString("meter=1")).toEqual([{ name: "meter", id: 1 }]);
|
||||
expect(parseQueryString("messaging=")).toEqual([{ name: "messaging" }]);
|
||||
});
|
||||
|
||||
test("parses multiple modals", () => {
|
||||
expect(parseQueryString("meter=1&vehicle=2")).toEqual([
|
||||
{ name: "meter", id: 1 },
|
||||
{ name: "vehicle", id: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses type and choices", () => {
|
||||
expect(parseQueryString("meter[type:grid]=1")).toEqual([
|
||||
{ name: "meter", type: "grid", id: 1 },
|
||||
]);
|
||||
expect(parseQueryString("meter[choices:pv,battery]")).toEqual([
|
||||
{ name: "meter", choices: ["pv", "battery"] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles edge cases", () => {
|
||||
expect(parseQueryString("meter=1&callbackCompleted=true")).toEqual([{ name: "meter", id: 1 }]);
|
||||
expect(parseQueryString("meter%5Btype%3Agrid%5D=1")).toEqual([
|
||||
{ name: "meter", type: "grid", id: 1 },
|
||||
]);
|
||||
expect(parseQueryString("meter=abc")).toEqual([{ name: "meter" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildQuery", () => {
|
||||
test("builds query objects", () => {
|
||||
expect(buildQuery([])).toEqual({});
|
||||
expect(buildQuery([{ name: "messaging" }])).toEqual({ messaging: "" });
|
||||
expect(buildQuery([{ name: "meter", id: 1 }])).toEqual({ meter: "1" });
|
||||
expect(buildQuery([{ name: "meter", type: "grid", id: 1 }])).toEqual({
|
||||
"meter[type:grid]": "1",
|
||||
});
|
||||
expect(buildQuery([{ name: "meter", choices: ["pv", "battery"] }])).toEqual({
|
||||
"meter[choices:pv,battery]": "",
|
||||
});
|
||||
expect(
|
||||
buildQuery([
|
||||
{ name: "meter", id: 1 },
|
||||
{ name: "vehicle", id: 2 },
|
||||
])
|
||||
).toEqual({ meter: "1", vehicle: "2" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractQueryString", () => {
|
||||
test("extracts query from paths", () => {
|
||||
expect(extractQueryString("/config?meter=1")).toBe("meter=1");
|
||||
expect(extractQueryString("/config")).toBe("");
|
||||
expect(extractQueryString("/#/config?meter=1")).toBe("meter=1");
|
||||
expect(extractQueryString("/config?meter=1&vehicle=2")).toBe("meter=1&vehicle=2");
|
||||
});
|
||||
});
|
||||
302
assets/js/configModal.ts
Normal file
302
assets/js/configModal.ts
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import { reactive, watch } from "vue";
|
||||
import type { Router } from "vue-router";
|
||||
import Modal from "bootstrap/js/dist/modal";
|
||||
|
||||
export interface ModalEntry {
|
||||
name: string;
|
||||
id?: number;
|
||||
type?: string;
|
||||
choices?: string[];
|
||||
}
|
||||
|
||||
export interface ModalResult {
|
||||
action: "added" | "updated" | "removed" | "cancelled";
|
||||
name?: string;
|
||||
id?: number;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const configModal = reactive({
|
||||
stack: [] as ModalEntry[],
|
||||
});
|
||||
|
||||
export type ModalFade = "left" | "right" | undefined;
|
||||
|
||||
let _router: Router | null = null;
|
||||
const _resolvers: Array<(result: ModalResult) => void> = [];
|
||||
const _modals = new Map<string, HTMLElement>();
|
||||
const _dismissingViaRoute = new Set<string>();
|
||||
|
||||
// --- Modal element registry (called by GenericModal) ---
|
||||
|
||||
export function registerModal(name: string, el: HTMLElement): void {
|
||||
_modals.set(name, el);
|
||||
syncModal(name);
|
||||
}
|
||||
|
||||
export function unregisterModal(name: string): void {
|
||||
_modals.delete(name);
|
||||
_dismissingViaRoute.delete(name);
|
||||
}
|
||||
|
||||
// Called by GenericModal on hidden.bs.modal (user ESC/backdrop)
|
||||
export function onModalHidden(name: string): void {
|
||||
if (_dismissingViaRoute.has(name)) {
|
||||
_dismissingViaRoute.delete(name);
|
||||
return;
|
||||
}
|
||||
// User dismissed via backdrop/ESC — sync route
|
||||
if (configModal.stack.some((m) => m.name === name)) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Reactive fade direction for a named modal
|
||||
export function getModalFade(name: string): ModalFade {
|
||||
const stackLen = configModal.stack.length;
|
||||
const idx = configModal.stack.findIndex((m) => m.name === name);
|
||||
if (idx === -1) return undefined;
|
||||
if (idx === stackLen - 1 && stackLen > 1) return "right";
|
||||
if (idx < stackLen - 1) return "left";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// --- Internal Bootstrap show/hide ---
|
||||
|
||||
function showElement(el: HTMLElement): void {
|
||||
const instance = Modal.getOrCreateInstance(el);
|
||||
// @ts-expect-error bs internal
|
||||
if (!el._isShown) {
|
||||
instance.show();
|
||||
}
|
||||
}
|
||||
|
||||
function hideElement(name: string, el: HTMLElement): void {
|
||||
const instance = Modal.getInstance(el);
|
||||
if (instance) {
|
||||
// Check if modal is actually visible
|
||||
const isVisible = el.classList.contains("show") || el.style.display !== "none";
|
||||
if (isVisible) {
|
||||
_dismissingViaRoute.add(name);
|
||||
instance.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncModal(name: string): void {
|
||||
const el = _modals.get(name);
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
const inStack = configModal.stack.some((m) => m.name === name);
|
||||
const onTop = isTopModal(name);
|
||||
if (inStack && onTop) {
|
||||
showElement(el);
|
||||
} else {
|
||||
hideElement(name, el);
|
||||
}
|
||||
}
|
||||
|
||||
function syncAllModals(): void {
|
||||
for (const name of _modals.keys()) {
|
||||
syncModal(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse brackets: "meter[type:grid]" => { name: "meter", type: "grid" }
|
||||
// "meter[choices:pv,battery]" => { name: "meter", choices: ["pv", "battery"] }
|
||||
export function parseKey(key: string): { name: string; type?: string; choices?: string[] } {
|
||||
const bracketMatch = key.match(/^([^[]+)\[([^\]]+)\]$/);
|
||||
if (!bracketMatch) {
|
||||
return { name: key };
|
||||
}
|
||||
const name = bracketMatch[1]!;
|
||||
const inner = bracketMatch[2]!;
|
||||
// inner is "type:grid" or "choices:pv,battery"
|
||||
const colonIdx = inner.indexOf(":");
|
||||
if (colonIdx === -1) {
|
||||
return { name };
|
||||
}
|
||||
const paramKey = inner.substring(0, colonIdx);
|
||||
const paramValue = inner.substring(colonIdx + 1);
|
||||
if (paramKey === "type") {
|
||||
return { name, type: paramValue };
|
||||
}
|
||||
if (paramKey === "choices") {
|
||||
return { name, choices: paramValue.split(",") };
|
||||
}
|
||||
return { name };
|
||||
}
|
||||
|
||||
// Parse raw query string into ordered stack entries
|
||||
export function parseQueryString(queryString: string): ModalEntry[] {
|
||||
if (!queryString) return [];
|
||||
const entries: ModalEntry[] = [];
|
||||
const parts = queryString.split("&");
|
||||
for (const part of parts) {
|
||||
const eqIdx = part.indexOf("=");
|
||||
let key: string;
|
||||
let value: string | undefined;
|
||||
if (eqIdx === -1) {
|
||||
key = decodeURIComponent(part);
|
||||
} else {
|
||||
key = decodeURIComponent(part.substring(0, eqIdx));
|
||||
value = decodeURIComponent(part.substring(eqIdx + 1));
|
||||
}
|
||||
// skip non-modal query params (e.g. callbackCompleted, callbackError)
|
||||
if (key.includes("callback") || key.includes("Callback")) {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseKey(key);
|
||||
const entry: ModalEntry = { name: parsed.name };
|
||||
if (value !== undefined && value !== "") {
|
||||
entry.id = parseInt(value, 10);
|
||||
if (isNaN(entry.id)) {
|
||||
entry.id = undefined;
|
||||
}
|
||||
}
|
||||
if (parsed.type) entry.type = parsed.type;
|
||||
if (parsed.choices) entry.choices = parsed.choices;
|
||||
entries.push(entry);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// Build query object from stack entries for router.push
|
||||
export function buildQuery(stack: ModalEntry[]): Record<string, string> {
|
||||
const query: Record<string, string> = {};
|
||||
for (const entry of stack) {
|
||||
let key = entry.name;
|
||||
if (entry.type) {
|
||||
key += `[type:${entry.type}]`;
|
||||
} else if (entry.choices?.length) {
|
||||
key += `[choices:${entry.choices.join(",")}]`;
|
||||
}
|
||||
query[key] = entry.id !== undefined ? String(entry.id) : "";
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
// Extract raw query string from fullPath
|
||||
export function extractQueryString(fullPath: string): string {
|
||||
const qIdx = fullPath.indexOf("?");
|
||||
if (qIdx === -1) return "";
|
||||
return fullPath.substring(qIdx + 1);
|
||||
}
|
||||
|
||||
export function initConfigModal(router: Router): void {
|
||||
_router = router;
|
||||
|
||||
watch(
|
||||
() => configModal.stack,
|
||||
() => syncAllModals()
|
||||
);
|
||||
|
||||
router.afterEach((to) => {
|
||||
if (to.path !== "/config") {
|
||||
// Clear stack when leaving config page
|
||||
if (configModal.stack.length > 0) {
|
||||
// Resolve any pending promises
|
||||
while (_resolvers.length > 0) {
|
||||
const resolve = _resolvers.pop();
|
||||
resolve?.({ action: "cancelled" });
|
||||
}
|
||||
configModal.stack = [];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const newStack = parseQueryString(extractQueryString(to.fullPath));
|
||||
const oldStack = configModal.stack;
|
||||
|
||||
// Resolve promises for modals that were removed from stack (browser back, etc.)
|
||||
if (newStack.length < oldStack.length) {
|
||||
const removed = oldStack.length - newStack.length;
|
||||
for (let i = 0; i < removed; i++) {
|
||||
const resolve = _resolvers.pop();
|
||||
resolve?.({ action: "cancelled" });
|
||||
}
|
||||
}
|
||||
|
||||
configModal.stack = newStack;
|
||||
});
|
||||
}
|
||||
|
||||
export function openModal(
|
||||
name: string,
|
||||
params?: { id?: number; type?: string; choices?: string[] }
|
||||
): Promise<ModalResult> {
|
||||
if (!_router) {
|
||||
return Promise.resolve({ action: "cancelled" });
|
||||
}
|
||||
|
||||
const entry: ModalEntry = { name };
|
||||
if (params?.id !== undefined) entry.id = params.id;
|
||||
if (params?.type) entry.type = params.type;
|
||||
if (params?.choices) entry.choices = params.choices;
|
||||
|
||||
const newStack = [...configModal.stack, entry];
|
||||
const query = buildQuery(newStack);
|
||||
|
||||
return new Promise<ModalResult>((resolve) => {
|
||||
_resolvers.push(resolve);
|
||||
_router!.push({ path: "/config", query });
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeModal(result?: ModalResult): Promise<void> {
|
||||
if (!_router) {
|
||||
return;
|
||||
}
|
||||
if (configModal.stack.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolve = _resolvers.pop();
|
||||
const currentModal = configModal.stack[configModal.stack.length - 1];
|
||||
const newStack = configModal.stack.slice(0, -1);
|
||||
const query = buildQuery(newStack);
|
||||
|
||||
// Merge type from modal stack into result if not provided
|
||||
const finalResult = result ?? { action: "cancelled" };
|
||||
if (currentModal?.type && !finalResult.type) {
|
||||
finalResult.type = currentModal.type;
|
||||
}
|
||||
|
||||
// Update stack synchronously to prevent double-close from GenericModal's handleHidden
|
||||
configModal.stack = newStack;
|
||||
|
||||
await _router.push({ path: "/config", query });
|
||||
resolve?.(finalResult);
|
||||
}
|
||||
|
||||
export function replaceModal(
|
||||
name: string,
|
||||
params?: { id?: number; type?: string; choices?: string[] }
|
||||
): void {
|
||||
if (!_router) return;
|
||||
|
||||
const entry: ModalEntry = { name };
|
||||
if (params?.id !== undefined) entry.id = params.id;
|
||||
if (params?.type) entry.type = params.type;
|
||||
if (params?.choices) entry.choices = params.choices;
|
||||
|
||||
const newStack = [...configModal.stack.slice(0, -1), entry];
|
||||
const query = buildQuery(newStack);
|
||||
|
||||
_router.replace({ path: "/config", query });
|
||||
}
|
||||
|
||||
export function getModal(name: string): ModalEntry | undefined {
|
||||
return configModal.stack.find((m) => m.name === name);
|
||||
}
|
||||
|
||||
export function topModal(): ModalEntry | undefined {
|
||||
return configModal.stack[configModal.stack.length - 1];
|
||||
}
|
||||
|
||||
export function isTopModal(name: string): boolean {
|
||||
return topModal()?.name === name;
|
||||
}
|
||||
|
||||
export default configModal;
|
||||
51
assets/js/router.test.ts
Normal file
51
assets/js/router.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { stringifyQuery } from "./router";
|
||||
|
||||
describe("stringifyQuery", () => {
|
||||
test("empty inputs", () => {
|
||||
expect(stringifyQuery()).toBe("");
|
||||
expect(stringifyQuery({})).toBe("");
|
||||
expect(stringifyQuery({ items: [] })).toBe("");
|
||||
});
|
||||
|
||||
test("basic key-value pairs", () => {
|
||||
expect(stringifyQuery({ foo: "bar" })).toBe("foo=bar");
|
||||
expect(stringifyQuery({ foo: "bar", baz: "qux" })).toBe("foo=bar&baz=qux");
|
||||
});
|
||||
|
||||
test("URI encoding", () => {
|
||||
expect(stringifyQuery({ key: "value with spaces" })).toBe("key=value%20with%20spaces");
|
||||
expect(stringifyQuery({ key: "special&chars=here" })).toBe("key=special%26chars%3Dhere");
|
||||
expect(stringifyQuery({ "key[0]": "value" })).toBe("key[0]=value");
|
||||
});
|
||||
|
||||
test("array values", () => {
|
||||
expect(stringifyQuery({ items: ["a", "b", "c"] })).toBe("items=a&items=b&items=c");
|
||||
expect(stringifyQuery({ items: ["foo bar", "baz qux"] })).toBe(
|
||||
"items=foo%20bar&items=baz%20qux"
|
||||
);
|
||||
expect(stringifyQuery({ items: ["a", "", "c"] })).toBe("items=a&items&items=c");
|
||||
});
|
||||
|
||||
test("falsy values", () => {
|
||||
expect(stringifyQuery({ key: "" })).toBe("key");
|
||||
expect(stringifyQuery({ key: null })).toBe("key");
|
||||
expect(stringifyQuery({ key: undefined })).toBe("key");
|
||||
expect(stringifyQuery({ key: 0 })).toBe("key");
|
||||
expect(stringifyQuery({ key: false })).toBe("key");
|
||||
});
|
||||
|
||||
test("truthy values", () => {
|
||||
expect(stringifyQuery({ key: "value" })).toBe("key=value");
|
||||
expect(stringifyQuery({ key: 123 })).toBe("key=123");
|
||||
expect(stringifyQuery({ key: true })).toBe("key=true");
|
||||
});
|
||||
|
||||
test("complex scenarios", () => {
|
||||
expect(stringifyQuery({ a: "value", b: "", c: "another" })).toBe("a=value&b&c=another");
|
||||
expect(stringifyQuery({ filter: ["tag1", "tag2"], sort: "name", page: 1, empty: "" })).toBe(
|
||||
"filter=tag1&filter=tag2&sort=name&page=1&empty"
|
||||
);
|
||||
expect(stringifyQuery({ z: "last", a: "first", m: "middle" })).toBe("z=last&a=first&m=middle");
|
||||
});
|
||||
});
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
isLoggedIn,
|
||||
isConfigured,
|
||||
} from "./components/Auth/auth";
|
||||
import { initConfigModal } from "./configModal";
|
||||
import type { VueI18nInstance } from "vue-i18n";
|
||||
|
||||
function hideAllModals() {
|
||||
|
|
@ -38,9 +39,29 @@ async function ensureAuth(to: RouteLocationNormalizedGeneric) {
|
|||
return true;
|
||||
}
|
||||
|
||||
// Custom stringifyQuery to keep brackets unencoded in URLs
|
||||
export function stringifyQuery(query?: Record<string, any>): string {
|
||||
if (!query) return "";
|
||||
const parts: string[] = [];
|
||||
for (const key of Object.keys(query)) {
|
||||
const value = query[key];
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) {
|
||||
parts.push(v ? `${key}=${encodeURIComponent(v)}` : key);
|
||||
}
|
||||
} else if (value) {
|
||||
parts.push(`${key}=${encodeURIComponent(value)}`);
|
||||
} else {
|
||||
parts.push(key);
|
||||
}
|
||||
}
|
||||
return parts.join("&");
|
||||
}
|
||||
|
||||
export default function setupRouter(i18n: VueI18nInstance) {
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
stringifyQuery,
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
|
|
@ -112,5 +133,6 @@ export default function setupRouter(i18n: VueI18nInstance) {
|
|||
hideAllModals();
|
||||
}
|
||||
});
|
||||
initConfigModal(router);
|
||||
return router;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
:editable="!!loadpoint.id"
|
||||
:error="hasDeviceError('loadpoint', loadpoint.name)"
|
||||
data-testid="loadpoint"
|
||||
@edit="editLoadpoint(loadpoint.id)"
|
||||
@edit="openModal('loadpoint', { id: loadpoint.id })"
|
||||
>
|
||||
<template #tags>
|
||||
<DeviceTags :tags="loadpointTags(loadpoint)" />
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
<NewDeviceButton
|
||||
data-testid="add-loadpoint"
|
||||
:title="$t('config.main.addLoadpoint')"
|
||||
@click="newLoadpoint"
|
||||
@click="openModal('loadpoint')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -63,7 +63,7 @@
|
|||
:editable="vehicle.id >= 0"
|
||||
:error="hasDeviceError('vehicle', vehicle.name)"
|
||||
data-testid="vehicle"
|
||||
@edit="editVehicle(vehicle.id)"
|
||||
@edit="openModal('vehicle', { id: vehicle.id })"
|
||||
>
|
||||
<template #icon>
|
||||
<VehicleIcon :name="vehicle.config?.icon" />
|
||||
|
|
@ -75,7 +75,7 @@
|
|||
<NewDeviceButton
|
||||
data-testid="add-vehicle"
|
||||
:title="$t('config.main.addVehicle')"
|
||||
@click="newVehicle"
|
||||
@click="openModal('vehicle')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -88,13 +88,13 @@
|
|||
meter-type="grid"
|
||||
:has-error="hasDeviceError('meter', gridMeter.name)"
|
||||
:tags="deviceTags('meter', gridMeter.name)"
|
||||
@edit="editMeter"
|
||||
@edit="(type, id) => openModal('meter', { type, id })"
|
||||
/>
|
||||
<NewDeviceButton
|
||||
v-else
|
||||
:title="$t('config.main.addGrid')"
|
||||
data-testid="add-grid"
|
||||
@click="newMeter('grid')"
|
||||
@click="openModal('meter', { type: 'grid' })"
|
||||
/>
|
||||
<DeviceCard
|
||||
:title="$t('config.tariffs.title')"
|
||||
|
|
@ -102,7 +102,7 @@
|
|||
:unconfigured="isUnconfigured(tariffTags)"
|
||||
:error="hasClassError('tariff')"
|
||||
data-testid="tariffs"
|
||||
@edit="openModal('tariffsModal')"
|
||||
@edit="openModal('tariffs')"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-receivepayment></shopicon-regular-receivepayment>
|
||||
|
|
@ -121,7 +121,7 @@
|
|||
meter-type="pv"
|
||||
:has-error="hasDeviceError('meter', meter.name)"
|
||||
:tags="deviceTags('meter', meter.name)"
|
||||
@edit="editMeter"
|
||||
@edit="(type, id) => openModal('meter', { type, id })"
|
||||
/>
|
||||
<MeterCard
|
||||
v-for="meter in batteryMeters"
|
||||
|
|
@ -130,11 +130,11 @@
|
|||
meter-type="battery"
|
||||
:has-error="hasDeviceError('meter', meter.name)"
|
||||
:tags="deviceTags('meter', meter.name)"
|
||||
@edit="editMeter"
|
||||
@edit="(type, id) => openModal('meter', { type, id })"
|
||||
/>
|
||||
<NewDeviceButton
|
||||
:title="$t('config.main.addPvBattery')"
|
||||
@click="addSolarBatteryMeter"
|
||||
@click="openModal('meter', { choices: ['pv', 'battery'] })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -147,7 +147,7 @@
|
|||
meter-type="aux"
|
||||
:has-error="hasDeviceError('meter', meter.name)"
|
||||
:tags="deviceTags('meter', meter.name)"
|
||||
@edit="editMeter"
|
||||
@edit="(type, id) => openModal('meter', { type, id })"
|
||||
/>
|
||||
<MeterCard
|
||||
v-for="meter in extMeters"
|
||||
|
|
@ -156,11 +156,11 @@
|
|||
meter-type="ext"
|
||||
:has-error="hasDeviceError('meter', meter.name)"
|
||||
:tags="deviceTags('meter', meter.name)"
|
||||
@edit="editMeter"
|
||||
@edit="(type, id) => openModal('meter', { type, id })"
|
||||
/>
|
||||
<NewDeviceButton
|
||||
:title="$t('config.main.addAdditional')"
|
||||
@click="newAdditionalMeter"
|
||||
@click="openModal('meter', { choices: ['aux', 'ext'] })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -178,7 +178,7 @@
|
|||
:error="hasClassError('mqtt')"
|
||||
:unconfigured="isUnconfigured(mqttTags)"
|
||||
data-testid="mqtt"
|
||||
@edit="openModal('mqttModal')"
|
||||
@edit="openModal('mqtt')"
|
||||
>
|
||||
<template #icon><MqttIcon /></template>
|
||||
<template #tags>
|
||||
|
|
@ -191,7 +191,7 @@
|
|||
:error="hasClassError('messenger')"
|
||||
:unconfigured="isUnconfigured(messagingTags)"
|
||||
data-testid="messaging"
|
||||
@edit="openModal('messagingModal')"
|
||||
@edit="openModal('messaging')"
|
||||
>
|
||||
<template #icon><NotificationIcon /></template>
|
||||
<template #tags>
|
||||
|
|
@ -204,7 +204,7 @@
|
|||
:error="hasClassError('influx')"
|
||||
:unconfigured="isUnconfigured(influxTags)"
|
||||
data-testid="influx"
|
||||
@edit="openModal('influxModal')"
|
||||
@edit="openModal('influx')"
|
||||
>
|
||||
<template #icon><InfluxIcon /></template>
|
||||
<template #tags>
|
||||
|
|
@ -217,7 +217,7 @@
|
|||
:error="hasClassError('circuit')"
|
||||
:unconfigured="circuitsSorted.length === 0"
|
||||
data-testid="circuits"
|
||||
@edit="openModal('circuitsModal')"
|
||||
@edit="openModal('circuits')"
|
||||
>
|
||||
<template #icon><CircuitsIcon /></template>
|
||||
<template #tags>
|
||||
|
|
@ -245,7 +245,7 @@
|
|||
:error="hasClassError('modbusproxy')"
|
||||
:unconfigured="isUnconfigured(modbusproxyTags)"
|
||||
data-testid="modbusproxy"
|
||||
@edit="openModal('modbusProxyModal')"
|
||||
@edit="openModal('modbusproxy')"
|
||||
>
|
||||
<template #icon><ModbusProxyIcon /></template>
|
||||
<template #tags>
|
||||
|
|
@ -258,7 +258,7 @@
|
|||
:error="hasClassError('hems')"
|
||||
:unconfigured="isUnconfigured(hemsTags)"
|
||||
data-testid="hems"
|
||||
@edit="openModal('hemsModal')"
|
||||
@edit="openModal('hems')"
|
||||
>
|
||||
<template #icon><HemsIcon /></template>
|
||||
<template #tags>
|
||||
|
|
@ -274,7 +274,7 @@
|
|||
editable
|
||||
:error="hasClassError('ocpp')"
|
||||
data-testid="ocpp"
|
||||
@edit="openModal('ocppModal')"
|
||||
@edit="openModal('ocpp')"
|
||||
>
|
||||
<template #icon><OcppIcon /></template>
|
||||
</DeviceCard>
|
||||
|
|
@ -283,7 +283,7 @@
|
|||
editable
|
||||
:error="hasClassError('shm')"
|
||||
data-testid="shm"
|
||||
@edit="openModal('shmModal')"
|
||||
@edit="openModal('shm')"
|
||||
>
|
||||
<template #icon><ShmIcon /></template>
|
||||
</DeviceCard>
|
||||
|
|
@ -292,7 +292,7 @@
|
|||
editable
|
||||
:error="hasClassError('eebus')"
|
||||
data-testid="eebus"
|
||||
@edit="openModal('eebusModal')"
|
||||
@edit="openModal('eebus')"
|
||||
>
|
||||
<template #icon><EebusIcon /></template>
|
||||
</DeviceCard>
|
||||
|
|
@ -311,7 +311,7 @@
|
|||
<button
|
||||
data-testid="backup-restore"
|
||||
class="btn btn-outline-secondary text-truncate"
|
||||
@click="openModal('backupRestoreModal')"
|
||||
@click="openModal('backuprestore')"
|
||||
>
|
||||
{{ $t("config.system.backupRestore.title") }}
|
||||
</button>
|
||||
|
|
@ -321,48 +321,18 @@
|
|||
</div>
|
||||
|
||||
<LoadpointModal
|
||||
:id="selectedLoadpointId"
|
||||
ref="loadpointModal"
|
||||
:vehicleOptions="vehicleOptions"
|
||||
:loadpointCount="loadpoints.length"
|
||||
:chargers="chargers"
|
||||
:chargerValues="deviceValues['charger']"
|
||||
:meters="meters"
|
||||
:circuits="circuits"
|
||||
:fade="loadpointSubModalOpen ? 'left' : undefined"
|
||||
:hasDeviceError="hasDeviceError"
|
||||
@updated="loadpointChanged"
|
||||
@open-charger-modal="editLoadpointCharger"
|
||||
@open-meter-modal="editLoadpointMeter"
|
||||
@opened="loadpointSubModalOpen = false"
|
||||
/>
|
||||
<VehicleModal
|
||||
:id="selectedVehicleId"
|
||||
:is-sponsor="isSponsor"
|
||||
@vehicle-changed="vehicleChanged"
|
||||
/>
|
||||
<MeterModal
|
||||
:id="selectedMeterId"
|
||||
:type="selectedMeterType"
|
||||
:typeChoices="selectedMeterTypeChoices"
|
||||
:fade="loadpointSubModalOpen ? 'right' : undefined"
|
||||
:is-sponsor="isSponsor"
|
||||
@added="meterAdded"
|
||||
@updated="meterChanged"
|
||||
@removed="meterRemoved"
|
||||
@close="meterModalClosed"
|
||||
/>
|
||||
<ChargerModal
|
||||
:id="selectedChargerId"
|
||||
:loadpointType="selectedLoadpointType"
|
||||
:fade="loadpointSubModalOpen ? 'right' : undefined"
|
||||
:is-sponsor="isSponsor"
|
||||
:ocpp="ocpp"
|
||||
@added="chargerAdded"
|
||||
@updated="chargerChanged"
|
||||
@removed="chargerRemoved"
|
||||
@close="chargerModalClosed"
|
||||
@changed="loadpointChanged"
|
||||
/>
|
||||
<VehicleModal :is-sponsor="isSponsor" @vehicle-changed="vehicleChanged" />
|
||||
<MeterModal :is-sponsor="isSponsor" @changed="meterChanged" />
|
||||
<ChargerModal :is-sponsor="isSponsor" :ocpp="ocpp" @changed="chargerChanged" />
|
||||
<InfluxModal @changed="loadDirty" />
|
||||
<MqttModal @changed="loadDirty" />
|
||||
<NetworkModal @changed="loadDirty" />
|
||||
|
|
@ -373,6 +343,7 @@
|
|||
<TariffsModal @changed="yamlChanged" />
|
||||
<TelemetryModal :sponsor="sponsor" :telemetry="telemetry" />
|
||||
<ExperimentalModal :experimental="experimental" />
|
||||
<TitleModal @changed="loadDirty" />
|
||||
<ModbusProxyModal :is-sponsor="isSponsor" @changed="loadDirty" />
|
||||
<CircuitsModal
|
||||
:gridMeter="gridMeter"
|
||||
|
|
@ -424,7 +395,7 @@ import LoadpointIcon from "../components/MaterialIcon/Loadpoint.vue";
|
|||
import MessagingModal from "../components/Config/MessagingModal.vue";
|
||||
import MeterModal from "../components/Config/MeterModal.vue";
|
||||
import MeterCard from "../components/Config/MeterCard.vue";
|
||||
import Modal from "bootstrap/js/dist/modal";
|
||||
import { openModal, type ModalResult } from "@/configModal";
|
||||
import ModbusProxyIcon from "../components/MaterialIcon/ModbusProxy.vue";
|
||||
import ModbusProxyModal from "../components/Config/ModbusProxyModal.vue";
|
||||
import MqttIcon from "../components/MaterialIcon/Mqtt.vue";
|
||||
|
|
@ -437,6 +408,7 @@ import store from "../store";
|
|||
import TariffsModal from "../components/Config/TariffsModal.vue";
|
||||
import TelemetryModal from "../components/Config/TelemetryModal.vue";
|
||||
import ExperimentalModal from "../components/Config/ExperimentalModal.vue";
|
||||
import TitleModal from "../components/Config/TitleModal.vue";
|
||||
import Header from "../components/Top/Header.vue";
|
||||
import VehicleIcon from "../components/VehicleIcon";
|
||||
import VehicleModal from "../components/Config/VehicleModal.vue";
|
||||
|
|
@ -448,7 +420,6 @@ import type {
|
|||
ConfigCircuit,
|
||||
ConfigLoadpoint,
|
||||
ConfigMeter,
|
||||
LoadpointType,
|
||||
Timeout,
|
||||
VehicleOption,
|
||||
MeterType,
|
||||
|
|
@ -507,6 +478,7 @@ export default defineComponent({
|
|||
TariffsModal,
|
||||
TelemetryModal,
|
||||
ExperimentalModal,
|
||||
TitleModal,
|
||||
TopHeader: Header,
|
||||
VehicleIcon,
|
||||
VehicleModal,
|
||||
|
|
@ -527,14 +499,6 @@ export default defineComponent({
|
|||
loadpoints: [] as ConfigLoadpoint[],
|
||||
chargers: [] as ConfigCharger[],
|
||||
circuits: [] as ConfigCircuit[],
|
||||
selectedVehicleId: undefined as number | undefined,
|
||||
selectedMeterId: undefined as number | undefined,
|
||||
selectedMeterType: undefined as MeterType | undefined,
|
||||
selectedMeterTypeChoices: [] as MeterType[],
|
||||
selectedChargerId: undefined as number | undefined,
|
||||
selectedLoadpointId: undefined as number | undefined,
|
||||
selectedLoadpointType: undefined as LoadpointType | undefined,
|
||||
loadpointSubModalOpen: false,
|
||||
site: {
|
||||
grid: "",
|
||||
pv: [] as string[],
|
||||
|
|
@ -593,12 +557,6 @@ export default defineComponent({
|
|||
const names = this.site?.ext;
|
||||
return this.getMetersByNames(names);
|
||||
},
|
||||
selectedMeterName() {
|
||||
return this.getMeterById(this.selectedMeterId)?.name;
|
||||
},
|
||||
selectedChargerName() {
|
||||
return this.getChargerById(this.selectedChargerId)?.name;
|
||||
},
|
||||
tariffTags(): DeviceTags {
|
||||
const { currency, tariffGrid, tariffFeedIn, tariffCo2, tariffSolar } = store.state;
|
||||
if (
|
||||
|
|
@ -805,86 +763,46 @@ export default defineComponent({
|
|||
.map((name) => this.meters.find((m) => m.name === name))
|
||||
.filter((m): m is ConfigMeter => m !== undefined);
|
||||
},
|
||||
getMeterById(id?: number) {
|
||||
if (!id || !this.meters) {
|
||||
return undefined;
|
||||
async meterChanged(result: ModalResult) {
|
||||
// Added: update site config
|
||||
if (result.action === "added") {
|
||||
const type = result.type as MeterType;
|
||||
const name = result.name!;
|
||||
|
||||
switch (type) {
|
||||
case "grid":
|
||||
this.site.grid = name;
|
||||
this.saveSite(type);
|
||||
break;
|
||||
case "pv":
|
||||
if (!this.site.pv) this.site.pv = [];
|
||||
this.site.pv.push(name);
|
||||
this.saveSite(type);
|
||||
break;
|
||||
case "battery":
|
||||
if (!this.site.battery) this.site.battery = [];
|
||||
this.site.battery.push(name);
|
||||
this.saveSite(type);
|
||||
break;
|
||||
case "aux":
|
||||
if (!this.site.aux) this.site.aux = [];
|
||||
this.site.aux.push(name);
|
||||
this.saveSite(type);
|
||||
break;
|
||||
case "ext":
|
||||
if (!this.site.ext) this.site.ext = [];
|
||||
this.site.ext.push(name);
|
||||
this.saveSite(type);
|
||||
break;
|
||||
}
|
||||
return this.meters.find((m) => m.id === id);
|
||||
},
|
||||
getChargerById(id?: number) {
|
||||
if (!id || !this.chargers) {
|
||||
return undefined;
|
||||
}
|
||||
return this.chargers.find((c) => c.id === id);
|
||||
},
|
||||
vehicleModal() {
|
||||
return Modal.getOrCreateInstance(
|
||||
document.getElementById("vehicleModal") as HTMLElement
|
||||
);
|
||||
},
|
||||
meterModal() {
|
||||
return Modal.getOrCreateInstance(document.getElementById("meterModal") as HTMLElement);
|
||||
},
|
||||
loadpointModal() {
|
||||
return Modal.getOrCreateInstance(
|
||||
document.getElementById("loadpointModal") as HTMLElement
|
||||
);
|
||||
},
|
||||
chargerModal() {
|
||||
return Modal.getOrCreateInstance(
|
||||
document.getElementById("chargerModal") as HTMLElement
|
||||
);
|
||||
},
|
||||
editLoadpointCharger(name: string, loadpointType?: LoadpointType) {
|
||||
this.loadpointSubModalOpen = true;
|
||||
const charger = this.chargers.find((c) => c.name === name);
|
||||
if (charger && charger.id === undefined) {
|
||||
alert(
|
||||
"yaml configured chargers can not be edited. Remove charger from yaml first."
|
||||
);
|
||||
return;
|
||||
|
||||
// Removed: reload site config
|
||||
if (result.action === "removed") {
|
||||
await this.loadSite();
|
||||
}
|
||||
this.loadpointModal().hide();
|
||||
this.$nextTick(() => this.editCharger(charger?.id, loadpointType));
|
||||
},
|
||||
editLoadpointMeter(name: string) {
|
||||
this.loadpointSubModalOpen = true;
|
||||
const meter = this.meters.find((m) => m.name === name);
|
||||
if (meter && meter.id === undefined) {
|
||||
alert("yaml configured meters can not be edited. Remove meter from yaml first.");
|
||||
return;
|
||||
}
|
||||
this.loadpointModal().hide();
|
||||
this.$nextTick(() => this.editMeter("charge", meter?.id));
|
||||
},
|
||||
editMeter(type: MeterType, id?: number) {
|
||||
this.selectedMeterType = type;
|
||||
this.selectedMeterId = id;
|
||||
this.$nextTick(() => this.meterModal().show());
|
||||
},
|
||||
newMeter(type: MeterType) {
|
||||
this.selectedMeterId = undefined;
|
||||
this.selectedMeterType = type;
|
||||
this.$nextTick(() => this.meterModal().show());
|
||||
},
|
||||
addSolarBatteryMeter() {
|
||||
this.selectedMeterId = undefined;
|
||||
this.selectedMeterType = undefined;
|
||||
this.selectedMeterTypeChoices = ["pv", "battery"] as MeterType[];
|
||||
this.$nextTick(() => this.meterModal().show());
|
||||
},
|
||||
newAdditionalMeter() {
|
||||
this.selectedMeterId = undefined;
|
||||
this.selectedMeterType = undefined;
|
||||
this.selectedMeterTypeChoices = ["aux", "ext"] as MeterType[];
|
||||
this.$nextTick(() => this.meterModal().show());
|
||||
},
|
||||
editCharger(id?: number, loadpointType?: LoadpointType) {
|
||||
this.selectedChargerId = id;
|
||||
this.selectedLoadpointType = loadpointType;
|
||||
this.$nextTick(() => this.chargerModal().show());
|
||||
},
|
||||
async meterChanged() {
|
||||
|
||||
// Reload meters and update UI
|
||||
await this.loadMeters();
|
||||
await this.loadDirty();
|
||||
this.updateValues();
|
||||
|
|
@ -894,34 +812,11 @@ export default defineComponent({
|
|||
await this.loadDirty();
|
||||
this.updateValues();
|
||||
},
|
||||
editLoadpoint(id?: number) {
|
||||
if (!id) alert("missing loadpoint id");
|
||||
this.selectedLoadpointId = id;
|
||||
this.$nextTick(() => this.loadpointModal().show());
|
||||
},
|
||||
newLoadpoint() {
|
||||
this.selectedLoadpointId = undefined;
|
||||
(
|
||||
this.$refs["loadpointModal"] as InstanceType<typeof LoadpointModal> | undefined
|
||||
)?.reset();
|
||||
this.$nextTick(() => this.loadpointModal().show());
|
||||
},
|
||||
async loadpointChanged() {
|
||||
this.selectedLoadpointId = undefined;
|
||||
await this.loadLoadpoints();
|
||||
this.loadDirty();
|
||||
},
|
||||
editVehicle(id: number) {
|
||||
this.selectedVehicleId = id;
|
||||
this.$nextTick(() => this.vehicleModal().show());
|
||||
},
|
||||
newVehicle() {
|
||||
this.selectedVehicleId = undefined;
|
||||
this.$nextTick(() => this.vehicleModal().show());
|
||||
},
|
||||
vehicleChanged() {
|
||||
this.selectedVehicleId = undefined;
|
||||
this.vehicleModal().hide();
|
||||
this.loadVehicles();
|
||||
this.loadDirty();
|
||||
},
|
||||
|
|
@ -931,75 +826,6 @@ export default defineComponent({
|
|||
yamlChanged() {
|
||||
this.loadDirty();
|
||||
},
|
||||
meterAdded(type: MeterType, name: string) {
|
||||
if (type === "charge") {
|
||||
// update loadpoint
|
||||
(
|
||||
this.$refs["loadpointModal"] as InstanceType<typeof LoadpointModal> | undefined
|
||||
)?.setMeter(name);
|
||||
} else if (type === "grid") {
|
||||
// update site grid
|
||||
this.site.grid = name;
|
||||
this.saveSite(type);
|
||||
} else {
|
||||
// update site pv, battery, aux, ext with type-safe approach
|
||||
switch (type) {
|
||||
case "pv":
|
||||
if (!this.site.pv) this.site.pv = [];
|
||||
this.site.pv.push(name);
|
||||
break;
|
||||
case "battery":
|
||||
if (!this.site.battery) this.site.battery = [];
|
||||
this.site.battery.push(name);
|
||||
break;
|
||||
case "aux":
|
||||
if (!this.site.aux) this.site.aux = [];
|
||||
this.site.aux.push(name);
|
||||
break;
|
||||
case "ext":
|
||||
if (!this.site.ext) this.site.ext = [];
|
||||
this.site.ext.push(name);
|
||||
break;
|
||||
}
|
||||
this.saveSite(type);
|
||||
}
|
||||
this.meterChanged();
|
||||
},
|
||||
meterRemoved(type: MeterType) {
|
||||
if (type === "charge") {
|
||||
// update loadpoint
|
||||
(
|
||||
this.$refs["loadpointModal"] as InstanceType<typeof LoadpointModal> | undefined
|
||||
)?.setMeter("");
|
||||
} else {
|
||||
// update site grid, pv, battery, aux, ext
|
||||
this.loadSite();
|
||||
this.loadDirty();
|
||||
}
|
||||
this.meterChanged();
|
||||
},
|
||||
async chargerAdded(name: string) {
|
||||
await this.chargerChanged();
|
||||
(
|
||||
this.$refs["loadpointModal"] as InstanceType<typeof LoadpointModal> | undefined
|
||||
)?.setCharger(name);
|
||||
},
|
||||
chargerRemoved() {
|
||||
(
|
||||
this.$refs["loadpointModal"] as InstanceType<typeof LoadpointModal> | undefined
|
||||
)?.setCharger("");
|
||||
this.chargerChanged();
|
||||
},
|
||||
meterModalClosed() {
|
||||
if (this.selectedMeterType === "charge") {
|
||||
// reopen loadpoint modal
|
||||
this.loadpointModal().show();
|
||||
}
|
||||
},
|
||||
chargerModalClosed() {
|
||||
// reopen loadpoint modal
|
||||
this.loadpointModal().show();
|
||||
},
|
||||
async saveSite(key: keyof SiteConfig) {
|
||||
const body = key ? { [key]: this.site[key] } : this.site;
|
||||
await api.put("/config/site", body);
|
||||
|
|
@ -1060,14 +886,7 @@ export default defineComponent({
|
|||
const meterTags = meter ? this.deviceTags("meter", meter) : {};
|
||||
return { ...chargerTags, ...meterTags };
|
||||
},
|
||||
openModal(id: string) {
|
||||
const $el = document.getElementById(id);
|
||||
if ($el) {
|
||||
Modal.getOrCreateInstance($el).show();
|
||||
} else {
|
||||
console.error(`modal ${id} not found`);
|
||||
}
|
||||
},
|
||||
openModal,
|
||||
circuitTags(circuit: ConfigCircuit) {
|
||||
const circuits = store.state?.circuits || {};
|
||||
const data =
|
||||
|
|
|
|||
65
tests/config-deeplink.spec.ts
Normal file
65
tests/config-deeplink.spec.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { start, stop, baseUrl } from "./evcc";
|
||||
import { expectModalHidden, expectModalVisible } from "./utils";
|
||||
|
||||
test.use({ baseURL: baseUrl() });
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await start();
|
||||
});
|
||||
test.afterAll(async () => {
|
||||
await stop();
|
||||
});
|
||||
|
||||
test.describe("deep linking", async () => {
|
||||
test("browser back closes modal", async ({ page }) => {
|
||||
await page.goto("/#/config");
|
||||
|
||||
// Open grid meter modal and verify URL param is added
|
||||
await page.getByTestId("add-grid").click();
|
||||
const meterModal = page.getByTestId("meter-modal");
|
||||
await expectModalVisible(meterModal);
|
||||
const urlWithModal = page.url();
|
||||
expect(urlWithModal).toContain("meter[type:grid]");
|
||||
|
||||
// Use browser back to close modal and verify URL param is removed
|
||||
await page.goBack();
|
||||
await expectModalHidden(meterModal);
|
||||
const urlAfterBack = page.url();
|
||||
expect(urlAfterBack).not.toContain("meter[type:grid]");
|
||||
expect(urlAfterBack).toContain("/#/config");
|
||||
});
|
||||
|
||||
test("direct navigation opens modal", async ({ page }) => {
|
||||
// Navigate directly with query param and verify modal opens without clicking
|
||||
await page.goto("/#/config?mqtt");
|
||||
const mqttModal = page.getByTestId("mqtt-modal");
|
||||
await expectModalVisible(mqttModal);
|
||||
|
||||
// Close modal and verify URL is updated back to config page
|
||||
await mqttModal.getByRole("button", { name: "Cancel" }).click();
|
||||
await expectModalHidden(mqttModal);
|
||||
await page.waitForURL("**/#/config");
|
||||
});
|
||||
|
||||
test("deep link persists after page reload", async ({ page }) => {
|
||||
await page.goto("/#/config");
|
||||
|
||||
// Create and save an offline vehicle with title "Test Car"
|
||||
await page.getByTestId("add-vehicle").click();
|
||||
const vehicleModal = page.getByTestId("vehicle-modal");
|
||||
await expectModalVisible(vehicleModal);
|
||||
await vehicleModal.getByLabel("Title").fill("Test Car");
|
||||
await vehicleModal.getByRole("button", { name: "Save" }).click();
|
||||
await expectModalHidden(vehicleModal);
|
||||
|
||||
// Edit vehicle, verify URL param persists after reload
|
||||
await page.getByTestId("vehicle").getByRole("button", { name: "edit" }).click();
|
||||
await expectModalVisible(vehicleModal);
|
||||
expect(page.url()).toContain("vehicle=1");
|
||||
await page.reload();
|
||||
await expectModalVisible(vehicleModal);
|
||||
await expect(vehicleModal.getByLabel("Title")).toHaveValue("Test Car");
|
||||
});
|
||||
});
|
||||
|
|
@ -59,6 +59,7 @@ test.describe("charging loadpoint", async () => {
|
|||
|
||||
// create loadpoint
|
||||
await lpModal.getByRole("button", { name: "Save" }).click();
|
||||
await expectModalHidden(lpModal);
|
||||
await expect(page.getByTestId("loadpoint")).toHaveCount(1);
|
||||
await expect(page.getByTestId("loadpoint")).toContainText("Solar Carport");
|
||||
await expect(page.getByTestId("loadpoint")).toContainText("charging");
|
||||
|
|
@ -124,6 +125,7 @@ test.describe("charging loadpoint", async () => {
|
|||
await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
|
||||
await expectModalVisible(lpModal);
|
||||
await lpModal.getByRole("button", { name: "Delete" }).click();
|
||||
await expectModalHidden(lpModal);
|
||||
await expect(page.getByTestId("loadpoint")).toHaveCount(0);
|
||||
|
||||
// restart
|
||||
|
|
@ -265,6 +267,7 @@ test.describe("charging loadpoint", async () => {
|
|||
await lpModal.getByRole("button", { name: "Save" }).click();
|
||||
await expectModalHidden(lpModal);
|
||||
await restart();
|
||||
await page.reload();
|
||||
|
||||
// change on main ui
|
||||
await page.goto("/");
|
||||
|
|
@ -346,6 +349,10 @@ test.describe("charging loadpoint", async () => {
|
|||
await expectModalVisible(lpModal);
|
||||
await expect(lpModal.getByRole("heading", { name: "Edit Charger or Heater" })).toBeVisible();
|
||||
|
||||
// close modal before restarting
|
||||
await lpModal.getByRole("button", { name: "Close" }).click();
|
||||
await expectModalHidden(lpModal);
|
||||
|
||||
// restart without saving loadpoint
|
||||
await restart();
|
||||
await page.reload();
|
||||
|
|
@ -379,6 +386,9 @@ test.describe("charging loadpoint", async () => {
|
|||
await expectModalVisible(meterModal);
|
||||
await meterModal.getByRole("button", { name: "Delete" }).click();
|
||||
await expectModalHidden(meterModal);
|
||||
await expectModalVisible(lpModal);
|
||||
await lpModal.getByRole("button", { name: "Close" }).click();
|
||||
await expectModalHidden(lpModal);
|
||||
|
||||
// restart without saving loadpoint
|
||||
await restart();
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ test.describe("messaging", async () => {
|
|||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await expectModalHidden(modal);
|
||||
|
||||
page.reload();
|
||||
await page.goto("/#/config");
|
||||
|
||||
await page.getByTestId("messaging").getByRole("button", { name: "edit" }).click();
|
||||
await expectModalVisible(modal);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue