Config UI: refactor modal handling, add deep linking (#27344)

This commit is contained in:
Michael Geers 2026-02-11 09:14:16 +01:00 • committed by GitHub
parent 91e1e3303d
commit 90e7ac313f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 785 additions and 455 deletions

View file

@ -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";
},

View file

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

View file

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

View file

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

View file

@ -3,6 +3,7 @@
id="controlModal"
ref="modal"
:title="$t('config.control.title')"
config-modal-name="control"
data-testid="control-modal"
@open="open"
>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 }">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 }">

View file

@ -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 }">

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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
View 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
View 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");
});
});

View file

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

View file

@ -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;
// Removed: reload site config
if (result.action === "removed") {
await this.loadSite();
}
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;
}
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 =

View 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");
});
});

View file

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

View file

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