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 {
return type === ConfigType.SgReadyBoost;
},
handleTemplateChange(e: Event, values: DeviceValues) {
const value = (e.target as HTMLSelectElement).value as ConfigType;
if (this.isYamlInput(value)) {
values.type = value;
values.yaml = this.defaultYaml(value);
handleTemplateChange(value: string, values: DeviceValues) {
if (this.isYamlInput(value as ConfigType)) {
values.type = value as ConfigType;
values.yaml = this.defaultYaml(value as ConfigType);
}
},
applyCustomDefaults(template: Template | null, values: DeviceValues) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -21,7 +21,7 @@ test.describe("fatal config handling", async () => {
const meterModal = page.getByTestId("meter-modal");
await meterModal.getByRole("button", { name: "Add solar meter" }).click();
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.getByRole("button", { name: "Validate & save" }).click();
await expectModalHidden(meterModal);
@ -72,7 +72,7 @@ test.describe("fatal config handling", async () => {
await lpModal.getByRole("button", { name: "Add dedicated energy meter" }).click();
const meterModal = page.getByTestId("meter-modal");
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.getByRole("button", { name: "Validate & save" }).click();
await expectModalHidden(meterModal);
@ -127,7 +127,7 @@ test.describe("fatal config handling", async () => {
// create grid meter
await page.getByRole("button", { name: "Add grid meter" }).click();
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.getByRole("button", { name: "Validate & save" }).click();
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");
});
});