Config UI: fix product name mixup for multi-product templates (#31548)

This commit is contained in:
Michael Geers 2026-07-07 10:46:27 +02:00 • committed by GitHub
parent ab31938daa
commit 7dc1aa34c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 125 additions and 40 deletions

View file

@ -266,11 +266,10 @@ export default defineComponent({
isTypeDeprecated(type: ConfigType): boolean { isTypeDeprecated(type: ConfigType): boolean {
return type === ConfigType.SgReadyBoost; return type === ConfigType.SgReadyBoost;
}, },
handleTemplateChange(e: Event, values: DeviceValues) { handleTemplateChange(value: string, values: DeviceValues) {
const value = (e.target as HTMLSelectElement).value as ConfigType; if (this.isYamlInput(value as ConfigType)) {
if (this.isYamlInput(value)) { values.type = value as ConfigType;
values.type = value; values.yaml = this.defaultYaml(value as ConfigType);
values.yaml = this.defaultYaml(value);
} }
}, },
applyCustomDefaults(template: Template | null, values: DeviceValues) { applyCustomDefaults(template: Template | null, values: DeviceValues) {

View file

@ -293,8 +293,8 @@ export default defineComponent({
isTypeDeprecated: Function as PropType<(type: ConfigType) => boolean>, isTypeDeprecated: Function as PropType<(type: ConfigType) => boolean>,
// Optional: provide template options from parent (to avoid circular dependency) // Optional: provide template options from parent (to avoid circular dependency)
provideTemplateOptions: Function as PropType<(products: Product[]) => TemplateGroup[]>, provideTemplateOptions: Function as PropType<(products: Product[]) => TemplateGroup[]>,
// Optional: handle template change (receives event and values, allows setting values.yaml) // Optional: handle template change (receives selected value and values, allows setting values.yaml)
onTemplateChange: Function as PropType<(e: Event, values: DeviceValues) => void>, onTemplateChange: Function as PropType<(value: string, values: DeviceValues) => void>,
// Optional: default template to select when opening modal for new devices // Optional: default template to select when opening modal for new devices
defaultTemplate: String, defaultTemplate: String,
// Optional: callback after configuration is loaded (receives values) // Optional: callback after configuration is loaded (receives values)
@ -829,11 +829,11 @@ export default defineComponent({
this.$emit("close"); this.$emit("close");
this.isModalVisible = false; this.isModalVisible = false;
}, },
handleTemplateChange(e: Event) { handleTemplateChange(value: string | null) {
// ensure this triggers after tempateName watcher // ensure this triggers after tempateName watcher
this.$nextTick(() => { this.$nextTick(() => {
if (this.onTemplateChange) { if (this.onTemplateChange && value !== null) {
this.onTemplateChange(e, this.values); this.onTemplateChange(value, this.values);
} }
}); });
}, },

View file

@ -3,22 +3,16 @@
<select <select
v-if="isNew" v-if="isNew"
:id="`${deviceType}Template`" :id="`${deviceType}Template`"
ref="select"
v-model="modelProxy" v-model="modelProxy"
class="form-select w-100" class="form-select w-100"
:disabled="disabled" :disabled="disabled"
@change="changed"
> >
<template v-for="group in groups" :key="group.label"> <template v-for="group in groups" :key="group.label">
<optgroup <optgroup
v-if="group.options?.length" v-if="group.options?.length"
:label="$t(`config.${deviceType}.${group.label}`)" :label="$t(`config.${deviceType}.${group.label}`)"
> >
<option <option v-for="option in group.options" :key="option.name" :value="option">
v-for="option in group.options"
:key="option.name"
:value="option.template"
>
{{ option.name }} {{ option.name }}
</option> </option>
</optgroup> </optgroup>
@ -69,23 +63,32 @@ export default defineComponent({
disabled: Boolean, disabled: Boolean,
}, },
emits: ["update:modelValue", "change"], emits: ["update:modelValue", "change"],
data() {
return {
// last user pick; the effective selection is derived in modelProxy
selected: null as TemplateOption | null,
};
},
computed: { computed: {
flatOptions(): TemplateOption[] {
return (this.groups ?? []).flatMap((g) => g.options ?? []);
},
modelProxy: { modelProxy: {
get() { get(): TemplateOption | null {
return this.modelValue; const options = this.flatOptions.filter((o) => o.template === this.modelValue);
const sameNameOption = options.find((o) => o.name === this.selected?.name);
return sameNameOption ?? options[0] ?? null;
}, },
set(value: string) { set(option: TemplateOption | null) {
this.$emit("update:modelValue", value); this.selected = option;
this.$emit("update:modelValue", option?.template ?? null);
this.$emit("change", option?.template ?? null);
}, },
}, },
}, },
methods: { methods: {
changed(e: Event) { getProductName(): string {
this.$emit("change", e); return this.modelProxy?.name || "";
},
getProductName() {
const select = this.$refs["select"] as HTMLSelectElement;
return select.options[select.selectedIndex]?.text || "";
}, },
}, },
}); });

View file

@ -164,8 +164,7 @@ export default defineComponent({
isYamlInputType(type: ConfigType): boolean { isYamlInputType(type: ConfigType): boolean {
return type === ConfigType.Custom; return type === ConfigType.Custom;
}, },
handleTemplateChange(e: Event, values: DeviceValues) { handleTemplateChange(value: string, values: DeviceValues) {
const value = (e.target as HTMLSelectElement).value as ConfigType;
if (value === ConfigType.Custom) { if (value === ConfigType.Custom) {
values.type = ConfigType.Custom; values.type = ConfigType.Custom;
values.yaml = customHemsYaml; values.yaml = customHemsYaml;

View file

@ -62,8 +62,7 @@ export default defineComponent({
}, },
]; ];
}, },
handleTemplateChange(e: Event, values: DeviceValues) { handleTemplateChange(value: string, values: DeviceValues) {
const value = (e.target as HTMLSelectElement).value;
if (value === ConfigType.Custom) { if (value === ConfigType.Custom) {
values.type = ConfigType.Custom; values.type = ConfigType.Custom;
values.yaml = defaultMessengerYaml; values.yaml = defaultMessengerYaml;

View file

@ -262,8 +262,7 @@ export default defineComponent({
} }
return data; return data;
}, },
handleTemplateChange(e: Event, values: DeviceValues) { handleTemplateChange(value: string, values: DeviceValues) {
const value = (e.target as HTMLSelectElement).value;
if (value === ConfigType.Custom) { if (value === ConfigType.Custom) {
values.type = ConfigType.Custom; values.type = ConfigType.Custom;
values.yaml = defaultMeterYaml; values.yaml = defaultMeterYaml;

View file

@ -211,8 +211,7 @@ export default defineComponent({
}, },
]; ];
}, },
handleTemplateChange(e: Event, values: DeviceValues) { handleTemplateChange(value: string, values: DeviceValues) {
const value = (e.target as HTMLSelectElement).value;
if (value === ConfigType.Custom) { if (value === ConfigType.Custom) {
values.type = ConfigType.Custom; values.type = ConfigType.Custom;
// Select appropriate YAML template based on tariff type // Select appropriate YAML template based on tariff type

View file

@ -109,8 +109,7 @@ export default defineComponent({
} }
return data; return data;
}, },
handleTemplateChange(e: Event, values: DeviceValues) { handleTemplateChange(value: string, values: DeviceValues) {
const value = (e.target as HTMLSelectElement).value;
if (value === ConfigType.Custom) { if (value === ConfigType.Custom) {
values.type = ConfigType.Custom; values.type = ConfigType.Custom;
values.yaml = defaultVehicleYaml; values.yaml = defaultVehicleYaml;

View file

@ -21,7 +21,7 @@ test.describe("fatal config handling", async () => {
const meterModal = page.getByTestId("meter-modal"); const meterModal = page.getByTestId("meter-modal");
await meterModal.getByRole("button", { name: "Add solar meter" }).click(); await meterModal.getByRole("button", { name: "Add solar meter" }).click();
await meterModal.getByLabel("Title").fill("North Roof"); await meterModal.getByLabel("Title").fill("North Roof");
await meterModal.getByLabel("Manufacturer").selectOption("shelly-1pm"); await meterModal.getByLabel("Manufacturer").selectOption("Shelly 1PM");
await meterModal.getByLabel("IP address or hostname").fill(simulatorHost()); await meterModal.getByLabel("IP address or hostname").fill(simulatorHost());
await meterModal.getByRole("button", { name: "Validate & save" }).click(); await meterModal.getByRole("button", { name: "Validate & save" }).click();
await expectModalHidden(meterModal); await expectModalHidden(meterModal);
@ -72,7 +72,7 @@ test.describe("fatal config handling", async () => {
await lpModal.getByRole("button", { name: "Add dedicated energy meter" }).click(); await lpModal.getByRole("button", { name: "Add dedicated energy meter" }).click();
const meterModal = page.getByTestId("meter-modal"); const meterModal = page.getByTestId("meter-modal");
await expectModalVisible(meterModal); await expectModalVisible(meterModal);
await meterModal.getByLabel("Manufacturer").selectOption("shelly-1pm"); await meterModal.getByLabel("Manufacturer").selectOption("Shelly 1PM");
await meterModal.getByLabel("IP address or hostname").fill(simulatorHost()); await meterModal.getByLabel("IP address or hostname").fill(simulatorHost());
await meterModal.getByRole("button", { name: "Validate & save" }).click(); await meterModal.getByRole("button", { name: "Validate & save" }).click();
await expectModalHidden(meterModal); await expectModalHidden(meterModal);
@ -127,7 +127,7 @@ test.describe("fatal config handling", async () => {
// create grid meter // create grid meter
await page.getByRole("button", { name: "Add grid meter" }).click(); await page.getByRole("button", { name: "Add grid meter" }).click();
const meterModal = page.getByTestId("meter-modal"); const meterModal = page.getByTestId("meter-modal");
await meterModal.getByLabel("Manufacturer").selectOption("shelly-1pm"); await meterModal.getByLabel("Manufacturer").selectOption("Shelly 1PM");
await meterModal.getByLabel("IP address or hostname").fill(simulatorHost()); await meterModal.getByLabel("IP address or hostname").fill(simulatorHost());
await meterModal.getByRole("button", { name: "Validate & save" }).click(); await meterModal.getByRole("button", { name: "Validate & save" }).click();
await expectModalHidden(meterModal); await expectModalHidden(meterModal);

View file

@ -0,0 +1,21 @@
template: multi-product-demo
group: generic
products:
- brand: Alpha
description:
generic: Product A
- brand: Zeta
description:
generic: Product B
params:
- name: usage
choice: ["grid"]
- name: power
description:
generic: Power
render: |
type: custom
power:
source: const
value: {{ .power }}

View file

@ -0,0 +1,21 @@
template: multi-product-demo
group: generic
products:
- brand: Alpha
description:
generic: Product A
- brand: ZetaNew
description:
generic: Product B
params:
- name: usage
choice: ["grid"]
- name: power
description:
generic: Power
render: |
type: custom
power:
source: const
value: {{ .power }}

View file

@ -0,0 +1,46 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import { expectModalVisible, expectModalHidden } from "./utils";
test.use({ baseURL: baseUrl() });
const flags = ["--disable-auth", "--template-type", "meter", "--template"];
const v1Flags = [...flags, "tests/config-multi-product-v1.tpl.yaml"];
const v2Flags = [...flags, "tests/config-multi-product-v2.tpl.yaml"];
test.beforeEach(async () => {
await start(undefined, undefined, v1Flags);
});
test.afterEach(async () => {
await stop();
});
test.describe("template with multiple products", async () => {
test("keeps the selected product name", async ({ page }) => {
await page.goto("/#/config");
await page.getByRole("button", { name: "Add grid meter" }).click();
const meterModal = page.getByTestId("meter-modal");
await expectModalVisible(meterModal);
// both products resolve to the same template, pick the second one (brand + description)
await meterModal.getByLabel("Manufacturer").selectOption("Zeta Product B");
await meterModal.getByLabel("Power").fill("5000");
await meterModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(meterModal);
await page.getByTestId("grid").getByRole("button", { name: "edit" }).click();
await expectModalVisible(meterModal);
await expect(meterModal.getByLabel("Manufacturer")).toHaveValue("Zeta Product B");
await meterModal.getByLabel("Close").click();
await expectModalHidden(meterModal);
// product renamed in template (software update): persisted name must survive
await restart(undefined, v2Flags);
await page.reload();
await page.getByTestId("grid").getByRole("button", { name: "edit" }).click();
await expectModalVisible(meterModal);
await expect(meterModal.getByLabel("Manufacturer")).toHaveValue("Zeta Product B");
});
});