Config UI: fixable invalid refs (#26481)
This commit is contained in:
parent
5792202879
commit
19bcd72c6d
7 changed files with 251 additions and 19 deletions
26
assets/js/components/Config/InvalidReferenceAlert.vue
Normal file
26
assets/js/components/Config/InvalidReferenceAlert.vue
Normal 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>
|
||||||
|
|
@ -413,26 +413,40 @@
|
||||||
</FormRow>
|
</FormRow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<FormRow
|
<div v-if="showCircuit">
|
||||||
v-if="showCircuit"
|
<FormRow
|
||||||
id="loadpointParamCircuit"
|
|
||||||
:label="$t('config.loadpoint.circuitLabel')"
|
|
||||||
:help="$t('config.loadpoint.circuitHelp')"
|
|
||||||
>
|
|
||||||
<PropertyField
|
|
||||||
id="loadpointParamCircuit"
|
id="loadpointParamCircuit"
|
||||||
v-model="values.circuit"
|
:label="$t('config.loadpoint.circuitLabel')"
|
||||||
type="Choice"
|
:help="$t('config.loadpoint.circuitHelp')"
|
||||||
class="me-2"
|
>
|
||||||
:choice="circuitOptions"
|
<InvalidReferenceAlert
|
||||||
required
|
v-if="invalidCircuit"
|
||||||
/>
|
:message="$t('config.loadpoint.circuitInvalid')"
|
||||||
</FormRow>
|
:value="values.circuit"
|
||||||
|
@remove="values.circuit = ''"
|
||||||
|
/>
|
||||||
|
<PropertyField
|
||||||
|
v-else
|
||||||
|
id="loadpointParamCircuit"
|
||||||
|
v-model="values.circuit"
|
||||||
|
type="Choice"
|
||||||
|
class="me-2"
|
||||||
|
:choice="circuitOptions"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</FormRow>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="!chargerIsIntegratedDevice">
|
<div v-if="!chargerIsIntegratedDevice">
|
||||||
<h6>{{ $t("config.loadpoint.vehiclesTitle") }}</h6>
|
<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
|
<FormRow
|
||||||
id="loadpointParamVehicle"
|
id="loadpointParamVehicle"
|
||||||
:label="$t('config.loadpoint.vehicleLabel')"
|
:label="$t('config.loadpoint.vehicleLabel')"
|
||||||
|
|
@ -568,6 +582,7 @@ import deepEqual from "@/utils/deepEqual";
|
||||||
import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
||||||
import EditIcon from "../MaterialIcon/Edit.vue";
|
import EditIcon from "../MaterialIcon/Edit.vue";
|
||||||
import NewDeviceButton from "./NewDeviceButton.vue";
|
import NewDeviceButton from "./NewDeviceButton.vue";
|
||||||
|
import InvalidReferenceAlert from "./InvalidReferenceAlert.vue";
|
||||||
import { handleError, customChargerName } from "./DeviceModal";
|
import { handleError, customChargerName } from "./DeviceModal";
|
||||||
import {
|
import {
|
||||||
LOADPOINT_TYPE,
|
LOADPOINT_TYPE,
|
||||||
|
|
@ -575,6 +590,7 @@ import {
|
||||||
type LoadpointType,
|
type LoadpointType,
|
||||||
type ConfigCharger,
|
type ConfigCharger,
|
||||||
type ConfigMeter,
|
type ConfigMeter,
|
||||||
|
type VehicleOption,
|
||||||
type ConfigCircuit,
|
type ConfigCircuit,
|
||||||
type ConfigLoadpoint,
|
type ConfigLoadpoint,
|
||||||
} from "@/types/evcc";
|
} from "@/types/evcc";
|
||||||
|
|
@ -610,12 +626,20 @@ const defaultThresholds = {
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "LoadpointModal",
|
name: "LoadpointModal",
|
||||||
components: { FormRow, PropertyField, GenericModal, SelectGroup, EditIcon, NewDeviceButton },
|
components: {
|
||||||
|
FormRow,
|
||||||
|
PropertyField,
|
||||||
|
GenericModal,
|
||||||
|
SelectGroup,
|
||||||
|
EditIcon,
|
||||||
|
NewDeviceButton,
|
||||||
|
InvalidReferenceAlert,
|
||||||
|
},
|
||||||
mixins: [formatter],
|
mixins: [formatter],
|
||||||
props: {
|
props: {
|
||||||
id: Number,
|
id: Number,
|
||||||
name: String,
|
name: String,
|
||||||
vehicleOptions: { type: Array, default: () => [] },
|
vehicleOptions: { type: Array as PropType<VehicleOption[]>, default: () => [] },
|
||||||
loadpointCount: { type: Number, default: 0 },
|
loadpointCount: { type: Number, default: 0 },
|
||||||
fade: String as PropType<ModalFade>,
|
fade: String as PropType<ModalFade>,
|
||||||
chargers: { type: Array as PropType<ConfigCharger[]>, default: () => [] },
|
chargers: { type: Array as PropType<ConfigCharger[]>, default: () => [] },
|
||||||
|
|
@ -718,7 +742,11 @@ export default {
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
showCircuit() {
|
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() {
|
circuitOptions() {
|
||||||
const options = this.circuits.map((c) => ({
|
const options = this.circuits.map((c) => ({
|
||||||
|
|
@ -727,6 +755,10 @@ export default {
|
||||||
}));
|
}));
|
||||||
return [{ key: "", name: "unassigned" }, ...options];
|
return [{ key: "", name: "unassigned" }, ...options];
|
||||||
},
|
},
|
||||||
|
invalidVehicle() {
|
||||||
|
const { vehicle } = this.values;
|
||||||
|
return vehicle && !this.vehicleOptions.some(({ key }) => key === vehicle);
|
||||||
|
},
|
||||||
allVehicleOptions() {
|
allVehicleOptions() {
|
||||||
return [
|
return [
|
||||||
{ key: "", name: this.$t("config.loadpoint.vehicleAutoDetection") },
|
{ key: "", name: this.$t("config.loadpoint.vehicleAutoDetection") },
|
||||||
|
|
|
||||||
|
|
@ -398,6 +398,11 @@ export interface Sponsor {
|
||||||
fromYaml: boolean;
|
fromYaml: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type VehicleOption = {
|
||||||
|
key?: string | null;
|
||||||
|
name: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export enum MODBUS_BAUDRATE {
|
export enum MODBUS_BAUDRATE {
|
||||||
_1200 = 1200,
|
_1200 = 1200,
|
||||||
_9600 = 9600,
|
_9600 = 9600,
|
||||||
|
|
|
||||||
|
|
@ -454,6 +454,7 @@ import type {
|
||||||
ConfigMeter,
|
ConfigMeter,
|
||||||
LoadpointType,
|
LoadpointType,
|
||||||
Timeout,
|
Timeout,
|
||||||
|
VehicleOption,
|
||||||
MeterType,
|
MeterType,
|
||||||
SiteConfig,
|
SiteConfig,
|
||||||
DeviceType,
|
DeviceType,
|
||||||
|
|
@ -652,7 +653,7 @@ export default defineComponent({
|
||||||
if (org) result.org = { value: org };
|
if (org) result.org = { value: org };
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
vehicleOptions() {
|
vehicleOptions(): VehicleOption[] {
|
||||||
return this.vehicles.map((v) => ({ key: v.name, name: v.config?.title || v.name }));
|
return this.vehicles.map((v) => ({ key: v.name, name: v.config?.title || v.name }));
|
||||||
},
|
},
|
||||||
shmTags(): DeviceTags {
|
shmTags(): DeviceTags {
|
||||||
|
|
|
||||||
|
|
@ -294,6 +294,7 @@
|
||||||
"chargerTypeLabel": "Charger type",
|
"chargerTypeLabel": "Charger type",
|
||||||
"chargingTitle": "Behaviour",
|
"chargingTitle": "Behaviour",
|
||||||
"circuitHelp": "Load management assignment to ensure power and current limits are not exceeded.",
|
"circuitHelp": "Load management assignment to ensure power and current limits are not exceeded.",
|
||||||
|
"circuitInvalid": "Circuit does not exist",
|
||||||
"circuitLabel": "Circuit",
|
"circuitLabel": "Circuit",
|
||||||
"circuitUnassigned": "unassigned",
|
"circuitUnassigned": "unassigned",
|
||||||
"defaultModeHelp": {
|
"defaultModeHelp": {
|
||||||
|
|
@ -370,6 +371,7 @@
|
||||||
"vehicleAutoDetection": "auto detection",
|
"vehicleAutoDetection": "auto detection",
|
||||||
"vehicleHelpAutoDetection": "Automatically selects the most plausible vehicle. Manual override is possible.",
|
"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.",
|
"vehicleHelpDefault": "Always assume this vehicle is charging here. Auto-detection disabled. Manual override is possible.",
|
||||||
|
"vehicleInvalid": "Vehicle does not exist",
|
||||||
"vehicleLabel": "Default vehicle",
|
"vehicleLabel": "Default vehicle",
|
||||||
"vehiclesTitle": "Vehicles"
|
"vehiclesTitle": "Vehicles"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
4
tests/config-invalid-references-vehicle.evcc.yaml
Normal file
4
tests/config-invalid-references-vehicle.evcc.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
vehicles:
|
||||||
|
- name: car
|
||||||
|
type: offline
|
||||||
|
title: Legacy Vehicle
|
||||||
162
tests/config-invalid-references.spec.ts
Normal file
162
tests/config-invalid-references.spec.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue