Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions assets/js/components/Config/InvalidReferenceAlert.vue
Original file line number Diff line number Diff line change
@@ -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>
68 changes: 50 additions & 18 deletions assets/js/components/Config/LoadpointModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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')"
Expand Down Expand Up @@ -568,13 +582,15 @@ 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,
type DeviceType,
type LoadpointType,
type ConfigCharger,
type ConfigMeter,
type VehicleOption,
type ConfigCircuit,
type ConfigLoadpoint,
} from "@/types/evcc";
Expand Down Expand Up @@ -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: () => [] },
Expand Down Expand Up @@ -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) => ({
Expand All @@ -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") },
Expand Down
5 changes: 5 additions & 0 deletions assets/js/types/evcc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion assets/js/views/Config.vue
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ import type {
ConfigMeter,
LoadpointType,
Timeout,
VehicleOption,
MeterType,
SiteConfig,
DeviceType,
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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"
},
Expand Down
4 changes: 4 additions & 0 deletions tests/config-invalid-references-vehicle.evcc.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
vehicles:
- name: car
type: offline
title: Legacy Vehicle
162 changes: 162 additions & 0 deletions tests/config-invalid-references.spec.ts
Original file line number Diff line number Diff line change
@@ -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