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>
|
||||
</template>
|
||||
|
||||
<FormRow
|
||||
v-if="showCircuit"
|
||||
id="loadpointParamCircuit"
|
||||
:label="$t('config.loadpoint.circuitLabel')"
|
||||
:help="$t('config.loadpoint.circuitHelp')"
|
||||
>
|
||||
<PropertyField
|
||||
<div v-if="showCircuit">
|
||||
<FormRow
|
||||
id="loadpointParamCircuit"
|
||||
v-model="values.circuit"
|
||||
type="Choice"
|
||||
class="me-2"
|
||||
:choice="circuitOptions"
|
||||
required
|
||||
/>
|
||||
</FormRow>
|
||||
: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"
|
||||
class="me-2"
|
||||
:choice="circuitOptions"
|
||||
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") },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
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