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