Config UI: fixable invalid refs (#26481)

This commit is contained in:
Michael Geers 2026-01-07 10:29:24 +01:00 • committed by GitHub
parent 5792202879
commit 19bcd72c6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 251 additions and 19 deletions

View file

@ -0,0 +1,26 @@
<template>
<div
class="alert alert-danger d-flex justify-content-between"
data-testid="invalid-reference-alert"
>
<span>
{{ message }}: <strong>{{ value }}</strong>
</span>
<a href="#" class="text-danger ms-3" @click.prevent="$emit('remove')">
{{ $t("config.general.remove") }}
</a>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "InvalidReferenceAlert",
props: {
message: { type: String, required: true },
value: { type: String, default: "" },
},
emits: ["remove"],
});
</script>

View file

@ -413,13 +413,20 @@
</FormRow>
</template>
<div v-if="showCircuit">
<FormRow
v-if="showCircuit"
id="loadpointParamCircuit"
:label="$t('config.loadpoint.circuitLabel')"
:help="$t('config.loadpoint.circuitHelp')"
>
<InvalidReferenceAlert
v-if="invalidCircuit"
:message="$t('config.loadpoint.circuitInvalid')"
:value="values.circuit"
@remove="values.circuit = ''"
/>
<PropertyField
v-else
id="loadpointParamCircuit"
v-model="values.circuit"
type="Choice"
@ -428,11 +435,18 @@
required
/>
</FormRow>
</div>
<div v-if="!chargerIsIntegratedDevice">
<h6>{{ $t("config.loadpoint.vehiclesTitle") }}</h6>
<div v-if="vehicleOptions.length">
<InvalidReferenceAlert
v-if="invalidVehicle"
:message="$t('config.loadpoint.vehicleInvalid')"
:value="values.vehicle"
@remove="values.vehicle = ''"
/>
<div v-else-if="vehicleOptions.length">
<FormRow
id="loadpointParamVehicle"
:label="$t('config.loadpoint.vehicleLabel')"
@ -568,6 +582,7 @@ import deepEqual from "@/utils/deepEqual";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import EditIcon from "../MaterialIcon/Edit.vue";
import NewDeviceButton from "./NewDeviceButton.vue";
import InvalidReferenceAlert from "./InvalidReferenceAlert.vue";
import { handleError, customChargerName } from "./DeviceModal";
import {
LOADPOINT_TYPE,
@ -575,6 +590,7 @@ import {
type LoadpointType,
type ConfigCharger,
type ConfigMeter,
type VehicleOption,
type ConfigCircuit,
type ConfigLoadpoint,
} from "@/types/evcc";
@ -610,12 +626,20 @@ const defaultThresholds = {
export default {
name: "LoadpointModal",
components: { FormRow, PropertyField, GenericModal, SelectGroup, EditIcon, NewDeviceButton },
components: {
FormRow,
PropertyField,
GenericModal,
SelectGroup,
EditIcon,
NewDeviceButton,
InvalidReferenceAlert,
},
mixins: [formatter],
props: {
id: Number,
name: String,
vehicleOptions: { type: Array, default: () => [] },
vehicleOptions: { type: Array as PropType<VehicleOption[]>, default: () => [] },
loadpointCount: { type: Number, default: 0 },
fade: String as PropType<ModalFade>,
chargers: { type: Array as PropType<ConfigCharger[]>, default: () => [] },
@ -718,7 +742,11 @@ export default {
];
},
showCircuit() {
return this.circuits.length > 0;
return this.circuits.length > 0 || this.values.circuit !== "";
},
invalidCircuit() {
const { circuit } = this.values;
return circuit && !this.circuitOptions.some((c) => c.key === circuit);
},
circuitOptions() {
const options = this.circuits.map((c) => ({
@ -727,6 +755,10 @@ export default {
}));
return [{ key: "", name: "unassigned" }, ...options];
},
invalidVehicle() {
const { vehicle } = this.values;
return vehicle && !this.vehicleOptions.some(({ key }) => key === vehicle);
},
allVehicleOptions() {
return [
{ key: "", name: this.$t("config.loadpoint.vehicleAutoDetection") },

View file

@ -398,6 +398,11 @@ export interface Sponsor {
fromYaml: boolean;
}
export type VehicleOption = {
key?: string | null;
name: string | null;
};
export enum MODBUS_BAUDRATE {
_1200 = 1200,
_9600 = 9600,

View file

@ -454,6 +454,7 @@ import type {
ConfigMeter,
LoadpointType,
Timeout,
VehicleOption,
MeterType,
SiteConfig,
DeviceType,
@ -652,7 +653,7 @@ export default defineComponent({
if (org) result.org = { value: org };
return result;
},
vehicleOptions() {
vehicleOptions(): VehicleOption[] {
return this.vehicles.map((v) => ({ key: v.name, name: v.config?.title || v.name }));
},
shmTags(): DeviceTags {

View file

@ -294,6 +294,7 @@
"chargerTypeLabel": "Charger type",
"chargingTitle": "Behaviour",
"circuitHelp": "Load management assignment to ensure power and current limits are not exceeded.",
"circuitInvalid": "Circuit does not exist",
"circuitLabel": "Circuit",
"circuitUnassigned": "unassigned",
"defaultModeHelp": {
@ -370,6 +371,7 @@
"vehicleAutoDetection": "auto detection",
"vehicleHelpAutoDetection": "Automatically selects the most plausible vehicle. Manual override is possible.",
"vehicleHelpDefault": "Always assume this vehicle is charging here. Auto-detection disabled. Manual override is possible.",
"vehicleInvalid": "Vehicle does not exist",
"vehicleLabel": "Default vehicle",
"vehiclesTitle": "Vehicles"
},

View file

@ -0,0 +1,4 @@
vehicles:
- name: car
type: offline
title: Legacy Vehicle

View file

@ -0,0 +1,162 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import {
expectModalVisible,
expectModalHidden,
editorClear,
editorPaste,
addDemoCharger,
newLoadpoint,
} from "./utils";
test.use({ baseURL: baseUrl() });
test.describe.configure({ mode: "parallel" });
test.afterEach(async () => {
await stop();
});
test.describe("invalid references", async () => {
test("circuit", async ({ page }) => {
await start();
await page.goto("/#/config");
// Create circuit via UI
await page.getByTestId("circuits").getByRole("button", { name: "edit" }).click();
const circuitsModal = page.getByTestId("circuits-modal");
await expectModalVisible(circuitsModal);
const circuitEditor = circuitsModal.getByTestId("yaml-editor");
await editorClear(circuitEditor);
await editorPaste(
circuitEditor,
page,
`- name: main
title: Main`
);
await circuitsModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(circuitsModal);
// Restart
await restart();
// Create loadpoint with demo charger
const lpModal = page.getByTestId("loadpoint-modal");
await newLoadpoint(page, "Test Carport");
await addDemoCharger(page);
// Wait for circuit field to be available and assign to circuit main
await expect(lpModal.getByLabel("Circuit")).toBeVisible();
await lpModal.getByLabel("Circuit").selectOption("Main [main]");
await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal);
// Edit circuit and rename "main" to "main2"
await page.getByTestId("circuits").getByRole("button", { name: "edit" }).click();
await expectModalVisible(circuitsModal);
await editorClear(circuitEditor);
await editorPaste(
circuitEditor,
page,
`- name: main2
title: Main`
);
await circuitsModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(circuitsModal);
// Save and restart
await restart();
// Check boot error
await expect(page.getByTestId("fatal-error")).toBeVisible();
await expect(page.getByTestId("fatal-error")).toContainText("circuit: not found: main");
// Verify loadpoint tile has error class
const loadpointTile = page.getByTestId("loadpoint");
await expect(loadpointTile).toBeVisible();
await expect(loadpointTile).toHaveClass(/round-box--error/);
// Edit loadpoint
await loadpointTile.getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal);
// Verify circuit select is hidden
await expect(lpModal.getByLabel("Circuit")).not.toBeVisible();
// Verify invalid-reference-alert with correct text is visible
const alert = lpModal.getByTestId("invalid-reference-alert");
await expect(alert).toBeVisible();
await expect(alert).toContainText("Circuit does not exist: main");
// Click remove button
await alert.getByRole("link", { name: "Remove" }).click();
// Verify the circuit select is now available again
await expect(lpModal.getByLabel("Circuit")).toBeVisible();
await expect(alert).not.toBeVisible();
// Save and restart
await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal);
await restart();
// Verify no error
await expect(page.getByTestId("fatal-error")).not.toBeVisible();
await expect(loadpointTile).not.toHaveClass(/round-box--error/);
await expect(loadpointTile).toContainText("Test Carport");
});
test("vehicle", async ({ page }) => {
// Start with YAML file containing one vehicle
await start("config-invalid-references-vehicle.evcc.yaml");
await page.goto("/#/config");
const lpModal = page.getByTestId("loadpoint-modal");
// Create loadpoint with demo charger and assign vehicle
await newLoadpoint(page, "Garage");
await addDemoCharger(page);
await expect(lpModal.getByLabel("Default vehicle")).toBeVisible();
await lpModal.getByLabel("Default vehicle").selectOption("Legacy Vehicle");
await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal);
// Restart without YAML file (simulating user changed it)
await restart();
// Verify fatal error on boot
await expect(page.getByTestId("fatal-error")).toBeVisible();
await expect(page.getByTestId("fatal-error")).toContainText("vehicle: not found: car");
// Verify loadpoint has error class
const loadpointTile = page.getByTestId("loadpoint");
await expect(loadpointTile).toBeVisible();
await expect(loadpointTile).toHaveClass(/round-box--error/);
// Open loadpoint modal and verify invalid reference alert
await loadpointTile.getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal);
const alert = lpModal.getByTestId("invalid-reference-alert");
await expect(alert).toBeVisible();
await expect(alert).toContainText("Vehicle does not exist: car");
// Remove vehicle reference
await alert.getByRole("link", { name: "Remove" }).click();
await expect(alert).not.toBeVisible();
// Verify "no vehicles" message is shown
await expect(lpModal).toContainText("No vehicles are configured.");
// Save and restart
await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal);
await restart();
// Verify no fatal error and no error class
await expect(page.getByTestId("fatal-error")).not.toBeVisible();
await expect(loadpointTile).not.toHaveClass(/round-box--error/);
});
});