Config UI: fix product name mixup for multi-product templates (#31548)
This commit is contained in:
parent
ab31938daa
commit
7dc1aa34c1
12 changed files with 125 additions and 40 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 || "";
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
21
tests/config-multi-product-v1.tpl.yaml
Normal file
21
tests/config-multi-product-v1.tpl.yaml
Normal 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 }}
|
||||
21
tests/config-multi-product-v2.tpl.yaml
Normal file
21
tests/config-multi-product-v2.tpl.yaml
Normal 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 }}
|
||||
46
tests/config-multi-product.spec.ts
Normal file
46
tests/config-multi-product.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue