Config UI: mobile optimized section navigation (#32810)

This commit is contained in:
Michael Geers 2026-08-13 17:19:50 +02:00 • committed by GitHub
parent 070c16cb4f
commit 0e58436ca4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1190 additions and 474 deletions

View file

@ -800,6 +800,43 @@ html.app .modal-dialog {
} }
} }
/* in-place fade-swipe between two views (Vue transition classes, e.g. config list>detail) */
.fade-swap-left-enter-active,
.fade-swap-left-leave-active,
.fade-swap-right-enter-active,
.fade-swap-right-leave-active {
transition:
opacity 150ms ease,
transform 150ms ease;
}
/* leaving view overlaps the entering one instead of pushing it down;
ancestor must be position:relative and unpadded, or the view jumps when going absolute */
.fade-swap-left-leave-active,
.fade-swap-right-leave-active {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.fade-swap-left-enter-from,
.fade-swap-left-leave-to {
opacity: 0;
transform: translateX(-1rem);
}
.fade-swap-right-enter-from,
.fade-swap-right-leave-to {
opacity: 0;
transform: translateX(1rem);
}
@media (prefers-reduced-motion: reduce) {
.fade-swap-left-enter-active,
.fade-swap-left-leave-active,
.fade-swap-right-enter-active,
.fade-swap-right-leave-active {
transition: none;
}
}
.round-box--error { .round-box--error {
box-shadow: 0 0 0.5rem var(--bs-danger); box-shadow: 0 0 0.5rem var(--bs-danger);
border: 1px solid var(--bs-danger); border: 1px solid var(--bs-danger);

View file

@ -0,0 +1,47 @@
<template>
<section :id="slug">
<template v-if="!mobile">
<h2 class="my-4 mt-5">{{ title }}</h2>
<slot />
</template>
<Transition v-else name="fade-swap-right" @after-enter="focusPanel">
<div
v-if="active"
ref="panel"
class="detail-panel my-4"
role="region"
:aria-label="title"
tabindex="-1"
:data-testid="`section-detail-${slug}`"
>
<slot />
</div>
</Transition>
</section>
</template>
<script lang="ts">
import { defineComponent } from "vue";
export default defineComponent({
name: "ConfigSection",
props: {
slug: { type: String, required: true },
title: { type: String, required: true },
mobile: Boolean,
active: Boolean,
},
methods: {
focusPanel() {
(this.$refs["panel"] as HTMLElement | undefined)?.focus({ preventScroll: true });
},
},
});
</script>
<style scoped>
/* no ring on programmatic focus */
.detail-panel {
outline: none;
}
</style>

View file

@ -0,0 +1,107 @@
<template>
<nav class="nav-list round-box box-pull-out p-0" data-testid="config-section-nav">
<button
v-for="section in sections"
:key="section.slug"
type="button"
class="nav-row d-flex align-items-center gap-3 w-100 text-start px-3"
:data-slug="section.slug"
@click="open(section.slug)"
>
<div class="nav-icon d-flex align-items-center justify-content-center flex-shrink-0">
<component :is="section.icon" v-if="section.icon" />
</div>
<div class="flex-grow-1 overflow-hidden">
<div class="fw-bold text-truncate">{{ section.title }}</div>
<div v-if="section.subline" class="small text-gray text-truncate">
{{ section.subline }}
</div>
</div>
<span
v-if="section.error"
class="circle-badge bg-danger flex-shrink-0"
data-testid="section-error"
>
<span class="visually-hidden">{{ $t("general.error") }}</span>
</span>
<span
v-else-if="section.warning"
class="circle-badge bg-warning flex-shrink-0"
data-testid="section-warning"
>
<span class="visually-hidden">{{ $t("general.warning") }}</span>
</span>
<span v-if="section.count" class="fw-bold text-gray flex-shrink-0">
{{ section.count }}
</span>
<ChevronRight class="chevron flex-shrink-0" />
</button>
</nav>
</template>
<script lang="ts">
import { defineComponent, type Component, type PropType } from "vue";
import ChevronRight from "../MaterialIcon/ChevronRight.vue";
import { hapticFeedback } from "@/utils/haptic";
export interface SectionEntry {
slug: string;
title: string;
icon?: Component | string;
count?: number;
error?: boolean;
warning?: boolean;
subline?: string;
}
export default defineComponent({
name: "ConfigSectionNav",
components: { ChevronRight },
props: {
sections: { type: Array as PropType<SectionEntry[]>, required: true },
},
emits: ["open"],
methods: {
open(slug: string) {
hapticFeedback("light");
this.$emit("open", slug);
},
},
});
</script>
<style scoped>
.nav-row {
background: none;
border: 0;
border-bottom: 1px solid var(--bs-border-color-translucent);
color: var(--evcc-default-text);
min-height: 3.5rem;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
/* carry the parent .round-box rounding so focus rings and :active follow it */
.nav-row:first-child {
border-top-left-radius: 1rem;
border-top-right-radius: 1rem;
}
.nav-row:last-child {
border-bottom: none;
border-bottom-left-radius: 1rem;
border-bottom-right-radius: 1rem;
}
.nav-row:active {
background: var(--evcc-box-border);
}
/* uniform icon slot; font-size 0 collapses the shopicons' inner inline-svg line box */
.nav-icon > * {
display: block;
width: 1.5rem;
height: 1.5rem;
font-size: 0;
}
.chevron {
color: var(--evcc-gray);
margin-right: -0.5rem;
}
</style>

View file

@ -13,9 +13,3 @@ export default {
components: { Markdown }, components: { Markdown },
}; };
</script> </script>
<style scoped>
.alert {
margin-bottom: 5rem;
}
</style>

View file

@ -0,0 +1,15 @@
<template>
<svg :style="svgStyle" viewBox="0 -960 960 960">
<path fill="currentColor" d="M504-480 320-664l56-56 240 240-240 240-56-56 184-184Z" />
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "ChevronRight",
mixins: [icon],
});
</script>

View file

@ -0,0 +1,18 @@
<template>
<svg :style="svgStyle" viewBox="0 -960 960 960">
<path
fill="currentColor"
d="M200-200h520v-184l45-22q16-8 25.5-22t9.5-32q0-17-9.5-31.5T765-514l-45-21v-185H528l-10-68q-3-22-19.5-37T460-840q-23 0-39.5 15T401-788l-10 68H200v86q56 21 88 68t32 106q0 60-32 107t-88 68v85Zm0 80q-34 0-57-23t-23-57v-152q48 0 84-30.5t36-77.5q0-46-36-76t-84-32v-152q0-33 23.5-56.5T200-800h122q7-51 46-85.5t92-34.5q52 0 91 34.5t47 85.5h122q33 0 56.5 23.5T800-720v134q36 18 58 52t22 74q0 41-22 75t-58 51v134q0 34-23.5 57T720-120H200Zm300-340Z"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "Integrations",
mixins: [icon],
});
</script>

View file

@ -0,0 +1,18 @@
<template>
<svg :style="svgStyle" viewBox="0 -960 960 960">
<path
fill="currentColor"
d="M300-720q-25 0-42.5 17.5T240-660q0 25 17.5 42.5T300-600q25 0 42.5-17.5T360-660q0-25-17.5-42.5T300-720Zm0 400q-25 0-42.5 17.5T240-260q0 25 17.5 42.5T300-200q25 0 42.5-17.5T360-260q0-25-17.5-42.5T300-320ZM160-840h640q17 0 28.5 11.5T840-800v280q0 17-11.5 28.5T800-480H160q-17 0-28.5-11.5T120-520v-280q0-17 11.5-28.5T160-840Zm40 80v200h560v-200H200Zm-40 320h640q17 0 28.5 11.5T840-400v280q0 17-11.5 28.5T800-80H160q-17 0-28.5-11.5T120-120v-280q0-17 11.5-28.5T160-440Zm40 80v200h560v-200H200Zm0-400v200-200Zm0 400v200-200Z"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "Services",
mixins: [icon],
});
</script>

View file

@ -0,0 +1,18 @@
<template>
<svg :style="svgStyle" viewBox="0 -960 960 960">
<path
fill="currentColor"
d="M686-132 444-376q-20 8-40.5 12t-43.5 4q-100 0-170-70t-70-170q0-36 10-68.5t28-61.5l146 146 72-72-146-146q29-18 61.5-28t68.5-10q100 0 170 70t70 170q0 23-4 43.5T584-516l244 242q12 12 12 29t-12 29l-84 84q-12 12-29 12t-29-12Zm29-85 27-27-256-256q18-20 26-46.5t8-53.5q0-60-38.5-104.5T386-758l74 74q12 12 12 28t-12 28L332-500q-12 12-28 12t-28-12l-74-74q9 57 53.5 95.5T360-440q26 0 52-8t47-25l256 256ZM472-488Z"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "System",
mixins: [icon],
});
</script>

View file

@ -3,14 +3,30 @@
class="d-flex justify-content-between align-items-center py-3 py-md-4" class="d-flex justify-content-between align-items-center py-3 py-md-4"
data-testid="header" data-testid="header"
> >
<h1 class="mb-1 pt-1 d-flex text-nowrap text-truncate"> <div class="title position-relative flex-grow-1 mb-1">
<span class="text-truncate">{{ title }}</span> <Transition name="fade-swap-left">
</h1> <h1 v-if="!parentTitle" class="mb-0 pt-1 text-truncate">{{ title }}</h1>
</Transition>
<Transition name="fade-swap-right">
<div v-if="parentTitle" class="d-flex align-items-center text-nowrap">
<button
type="button"
class="btn btn-link back-button d-flex align-items-center p-0 border-0 me-2"
:aria-label="$t('general.back')"
@click="$emit('back')"
>
<shopicon-bold-arrowback></shopicon-bold-arrowback>
</button>
<h1 class="mb-0 pt-1 text-truncate">{{ title }}</h1>
</div>
</Transition>
</div>
<TopNavigationArea ref="navigationArea" :notifications="notifications" /> <TopNavigationArea ref="navigationArea" :notifications="notifications" />
</header> </header>
</template> </template>
<script lang="ts"> <script lang="ts">
import "@h2d2/shopicons/es/bold/arrowback";
import TopNavigationArea from "./TopNavigationArea.vue"; import TopNavigationArea from "./TopNavigationArea.vue";
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import type { Notification } from "@/types/evcc"; import type { Notification } from "@/types/evcc";
@ -22,8 +38,11 @@ export default defineComponent({
}, },
props: { props: {
title: String, title: String,
// set on drill-down pages: shows a back button next to the title
parentTitle: String,
notifications: { type: Array as PropType<Notification[]>, default: () => [] }, notifications: { type: Array as PropType<Notification[]>, default: () => [] },
}, },
emits: ["back"],
methods: { methods: {
requestAuthProvider(providerId: string) { requestAuthProvider(providerId: string) {
const navigationArea = this.$refs["navigationArea"] as const navigationArea = this.$refs["navigationArea"] as
@ -34,3 +53,13 @@ export default defineComponent({
}, },
}); });
</script> </script>
<style scoped>
/* allow the flex item to shrink so the titles can truncate */
.title {
min-width: 0;
}
.back-button {
color: var(--evcc-default-text);
}
</style>

View file

@ -71,5 +71,6 @@ describe("extractQueryString", () => {
expect(extractQueryString("/config")).toBe(""); expect(extractQueryString("/config")).toBe("");
expect(extractQueryString("/#/config?meter=1")).toBe("meter=1"); expect(extractQueryString("/#/config?meter=1")).toBe("meter=1");
expect(extractQueryString("/config?meter=1&vehicle=2")).toBe("meter=1&vehicle=2"); expect(extractQueryString("/config?meter=1&vehicle=2")).toBe("meter=1&vehicle=2");
expect(extractQueryString("/config?meter=1#vehicles")).toBe("meter=1");
}); });
}); });

View file

@ -194,7 +194,7 @@ export function buildQuery(stack: ModalEntry[]): Record<string, string> {
export function extractQueryString(fullPath: string): string { export function extractQueryString(fullPath: string): string {
const qIdx = fullPath.indexOf("?"); const qIdx = fullPath.indexOf("?");
if (qIdx === -1) return ""; if (qIdx === -1) return "";
return fullPath.substring(qIdx + 1); return fullPath.substring(qIdx + 1).split("#")[0]!;
} }
export function initConfigModal(router: Router): void { export function initConfigModal(router: Router): void {
@ -254,7 +254,7 @@ export function openModal(
return new Promise<ModalResult>((resolve) => { return new Promise<ModalResult>((resolve) => {
_resolvers.push(resolve); _resolvers.push(resolve);
_router!.push({ path: "/config", query }); _router!.push({ path: "/config", query, hash: _router!.currentRoute.value.hash });
}); });
} }
@ -280,7 +280,7 @@ export async function closeModal(result?: ModalResult): Promise<void> {
// Update stack synchronously to prevent double-close from GenericModal's handleHidden // Update stack synchronously to prevent double-close from GenericModal's handleHidden
configModal.stack = newStack; configModal.stack = newStack;
await _router.push({ path: "/config", query }); await _router.push({ path: "/config", query, hash: _router.currentRoute.value.hash });
resolve?.(finalResult); resolve?.(finalResult);
} }
@ -299,7 +299,7 @@ export function replaceModal(
const newStack = [...configModal.stack.slice(0, -1), entry]; const newStack = [...configModal.stack.slice(0, -1), entry];
const query = buildQuery(newStack); const query = buildQuery(newStack);
_router.replace({ path: "/config", query }); _router.replace({ path: "/config", query, hash: _router.currentRoute.value.hash });
} }
export function getModal(name: string): ModalEntry | undefined { export function getModal(name: string): ModalEntry | undefined {

View file

@ -1,18 +1,20 @@
export type Breakpoint = "xs" | "sm" | "md" | "lg" | "xl" | "xxl"; export type Breakpoint = "xs" | "sm" | "md" | "lg" | "xl" | "xxl";
interface BreakpointDef { // bootstrap breakpoints, value = last width still inside
name: Breakpoint; export const MAX_WIDTH: Record<Breakpoint, number> = {
maxWidth: number; xs: 575,
} sm: 767,
md: 991,
lg: 1199,
xl: 1399,
xxl: Infinity,
};
const BREAKPOINTS: BreakpointDef[] = [ const BREAKPOINTS = Object.entries(MAX_WIDTH) as [Breakpoint, number][];
{ name: "xs", maxWidth: 575 },
{ name: "sm", maxWidth: 767 }, export function isMobileWidth(width: number = window.innerWidth): boolean {
{ name: "md", maxWidth: 991 }, return width <= MAX_WIDTH.sm;
{ name: "lg", maxWidth: 1199 }, }
{ name: "xl", maxWidth: 1399 },
{ name: "xxl", maxWidth: Infinity },
];
export default { export default {
data() { data() {
@ -24,9 +26,9 @@ export default {
updateBreakpoint(): void { updateBreakpoint(): void {
const width: number = window.innerWidth; const width: number = window.innerWidth;
const self = this as any; const self = this as any;
for (const bp of BREAKPOINTS) { for (const [name, maxWidth] of BREAKPOINTS) {
if (width <= bp.maxWidth) { if (width <= maxWidth) {
self.breakpoint = bp.name; self.breakpoint = name;
return; return;
} }
} }

View file

@ -0,0 +1,56 @@
import { defineComponent } from "vue";
import breakpoint, { MAX_WIDTH, isMobileWidth } from "./breakpoint";
import { hapticFeedback } from "@/utils/haptic";
// hash-driven list>detail pages (see Config.vue): consumers override `activeSlug`,
// list rows carry data-slug; pairs with TopHeader subpageTitle + fade-swap classes
export default defineComponent({
mixins: [breakpoint],
data() {
return {
listScroll: 0,
};
},
computed: {
mobile(): boolean {
return isMobileWidth(MAX_WIDTH[this.breakpoint]);
},
activeSlug(): string | undefined {
return undefined; // consumers override
},
},
watch: {
"$route.hash"(_: string, oldHash: string) {
// new content dictates the scroll position
if (this.mobile && this.activeSlug) {
this.listScroll = window.scrollY;
window.scrollTo(0, 0);
}
// back to the list: restore its scroll and focus the originating row
if (this.mobile && !this.activeSlug && oldHash) {
this.$nextTick(() => {
window.scrollTo(0, this.listScroll);
document
.querySelector<HTMLElement>(`[data-slug="${oldHash.slice(1)}"]`)
?.focus({ preventScroll: true });
});
}
},
},
methods: {
goBack() {
hapticFeedback("light");
const back = this.$router.options.history.state["back"] as string | null;
if (back?.startsWith(this.$route.path) && !back.includes("#")) {
this.$router.back();
} else {
// direct deep link, browser back would leave the app
this.$router.replace({
path: this.$route.path,
query: this.$route.query,
hash: "",
});
}
},
},
});

View file

@ -13,6 +13,7 @@ import {
isConfigured, isConfigured,
} from "./components/Auth/auth"; } from "./components/Auth/auth";
import { initConfigModal } from "./configModal"; import { initConfigModal } from "./configModal";
import { isMobileWidth } from "./mixins/breakpoint";
import { hapticFeedback } from "./utils/haptic"; import { hapticFeedback } from "./utils/haptic";
import type { VueI18nInstance } from "vue-i18n"; import type { VueI18nInstance } from "vue-i18n";
@ -65,6 +66,10 @@ export default function setupRouter(i18n: VueI18nInstance) {
stringifyQuery, stringifyQuery,
scrollBehavior(to, from) { scrollBehavior(to, from) {
if (to.hash) { if (to.hash) {
// config section hashes open a detail panel on small screens, no scrolling
if (to.path === "/config" && isMobileWidth()) {
return false;
}
return new Promise((resolve) => { return new Promise((resolve) => {
const check = () => { const check = () => {
if (document.querySelector(to.hash)) { if (document.querySelector(to.hash)) {

File diff suppressed because it is too large Load diff

View file

@ -1175,9 +1175,12 @@
} }
}, },
"general": { "general": {
"back": "Zurück",
"download": "Download", "download": "Download",
"error": "Fehler",
"none": "keine", "none": "keine",
"note": "Hinweis:" "note": "Hinweis:",
"warning": "Warnung"
}, },
"header": { "header": {
"about": "Über", "about": "Über",

View file

@ -842,8 +842,8 @@
"general": "General", "general": "General",
"grid": "Grid", "grid": "Grid",
"integrations": "Integrations", "integrations": "Integrations",
"loadpoints": "Charging points & Heaters", "loadpoints": "Charging points & heaters",
"meter": "Solar & Battery", "meter": "Solar & battery",
"services": "Services", "services": "Services",
"system": "System", "system": "System",
"vehicles": "Vehicles" "vehicles": "Vehicles"
@ -977,7 +977,7 @@
"titleEdit": "Edit Temperature Forecast" "titleEdit": "Edit Temperature Forecast"
}, },
"template": "Provider", "template": "Provider",
"title": "Tariffs & Forecasts", "title": "Tariffs & forecasts",
"titleChoice": "What Do You Want To Add?", "titleChoice": "What Do You Want To Add?",
"type": { "type": {
"co2": "CO₂ Intensity", "co2": "CO₂ Intensity",
@ -1175,9 +1175,12 @@
} }
}, },
"general": { "general": {
"back": "Back",
"download": "Download", "download": "Download",
"error": "Error",
"none": "none", "none": "none",
"note": "Note:" "note": "Note:",
"warning": "Warning"
}, },
"header": { "header": {
"about": "About", "about": "About",

View file

@ -0,0 +1,152 @@
import { test, expect, devices } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import { expectModalHidden, expectModalVisible, newLoadpoint, addDemoCharger } from "./utils";
test.use({ baseURL: baseUrl(), viewport: devices["iPhone 12 Mini"].viewport });
test.describe.configure({ mode: "parallel" });
const desktop = devices["Desktop Chrome"].viewport;
test.describe("mobile section navigation", async () => {
test.beforeAll(async () => {
await start();
});
test.afterAll(async () => {
await stop();
});
test("shows section list instead of long page", async ({ page }) => {
await page.goto("/#/config");
const nav = page.getByTestId("config-section-nav");
await expect(nav).toBeVisible();
await expect(nav.getByRole("button", { name: "Vehicles" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Vehicles" })).not.toBeVisible();
});
test("open and close section detail", async ({ page }) => {
await page.goto("/#/config");
await page.getByRole("button", { name: "Vehicles" }).click();
const detail = page.getByTestId("section-detail-vehicles");
await expect(detail).toBeVisible();
await page.waitForURL("**/#/config#vehicles");
await expect(detail.getByTestId("add-vehicle")).toBeVisible();
await page.getByRole("button", { name: "Back" }).click();
await expect(detail).not.toBeVisible();
await page.waitForURL("**/#/config");
await expect(page.getByTestId("config-section-nav")).toBeVisible();
});
test("browser back closes section detail", async ({ page }) => {
await page.goto("/#/config");
await page.getByRole("button", { name: "Grid" }).click();
await expect(page.getByTestId("section-detail-grid")).toBeVisible();
await page.goBack();
await expect(page.getByTestId("section-detail-grid")).not.toBeVisible();
await expect(page.getByTestId("config-section-nav")).toBeVisible();
});
test("deep link opens section detail directly", async ({ page }) => {
await page.goto("/#/config#tariffs");
const detail = page.getByTestId("section-detail-tariffs");
await expect(detail).toBeVisible();
// back button must not leave the app
await page.getByRole("button", { name: "Back" }).click();
await expect(detail).not.toBeVisible();
await page.waitForURL("**/#/config");
});
test("modal keeps section detail open", async ({ page }) => {
await page.goto("/#/config#vehicles");
const detail = page.getByTestId("section-detail-vehicles");
await expect(detail).toBeVisible();
await detail.getByTestId("add-vehicle").click();
const vehicleModal = page.getByTestId("vehicle-modal");
await expectModalVisible(vehicleModal);
expect(page.url()).toContain("vehicle");
expect(page.url()).toContain("#vehicles");
await vehicleModal.getByRole("button", { name: "Cancel" }).click();
await expectModalHidden(vehicleModal);
await expect(detail).toBeVisible();
});
test("modal deep link with section hash survives reload", async ({ page }) => {
await page.goto("/#/config#integrations");
const detail = page.getByTestId("section-detail-integrations");
await expect(detail).toBeVisible();
await detail.getByTestId("mqtt").getByRole("button", { name: "edit" }).click();
const mqttModal = page.getByTestId("mqtt-modal");
await expectModalVisible(mqttModal);
await page.reload();
await expectModalVisible(mqttModal);
await mqttModal.getByRole("button", { name: "Cancel" }).click();
await expectModalHidden(mqttModal);
await expect(detail).toBeVisible();
});
test("desktop keeps long page and scrolls to anchor", async ({ page }) => {
await page.setViewportSize(desktop);
await page.goto("/#/config#tariffs");
await expect(page.getByTestId("config-section-nav")).not.toBeVisible();
await expect(page.getByTestId("section-detail-tariffs")).not.toBeVisible();
const heading = page.getByRole("heading", { name: "Tariffs & forecasts" });
await expect(heading).toBeInViewport();
});
test("resize switches between list and long page", async ({ page }) => {
await page.goto("/#/config#grid");
await expect(page.getByTestId("section-detail-grid")).toBeVisible();
await page.setViewportSize(desktop);
await expect(page.getByTestId("section-detail-grid")).not.toBeVisible();
// level 2: the header title briefly shows "Grid" while its swap transition runs
await expect(page.getByRole("heading", { name: "Grid", level: 2 })).toBeVisible();
await page.setViewportSize(devices["iPhone 12 Mini"].viewport);
await expect(page.getByTestId("section-detail-grid")).toBeVisible();
});
});
test.describe("section indicators", async () => {
test.afterEach(async () => {
await stop();
});
test("device counts", async ({ page }) => {
await start("config-with-vehicle.evcc.yaml");
await page.goto("/#/config");
const nav = page.getByTestId("config-section-nav");
await expect(nav.getByRole("button", { name: "Charging points & heaters" })).toContainText("1");
await expect(nav.getByRole("button", { name: "Vehicles" })).toContainText("1");
await expect(nav.getByRole("button", { name: "Grid" })).toContainText("1");
});
test("error indicator on fatal device error", async ({ page }) => {
await start("config-invalid-references-vehicle.evcc.yaml");
await page.goto("/#/config#loadpoints");
// loadpoint referencing a vehicle that disappears after restart
await newLoadpoint(page, "Garage");
await addDemoCharger(page);
const lpModal = page.getByTestId("loadpoint-modal");
await lpModal.getByRole("link", { name: "Advanced configuration" }).click();
await lpModal.getByLabel("Default vehicle").selectOption("Legacy Vehicle");
await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal);
await restart();
await page.goto("/#/config");
await expect(page.getByTestId("fatal-error")).toBeVisible();
const row = page.getByRole("button", { name: "Charging points & heaters" });
await expect(row.getByTestId("section-error")).toBeVisible();
});
});

View file

@ -26,7 +26,7 @@ test.describe("tariffs", async () => {
await page.goto("/#/config"); await page.goto("/#/config");
// New configuration section should show with "Add Tariff" button // New configuration section should show with "Add Tariff" button
await expect(page.getByRole("heading", { name: "Tariffs & Forecasts" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Tariffs & forecasts" })).toBeVisible();
await expect(page.getByRole("button", { name: "Add Tariff" })).toBeVisible(); await expect(page.getByRole("button", { name: "Add Tariff" })).toBeVisible();
// Old tariff card should not be shown // Old tariff card should not be shown
@ -97,7 +97,7 @@ grid:
await expect(page.getByTestId("tariffs-legacy")).toBeVisible(); await expect(page.getByTestId("tariffs-legacy")).toBeVisible();
await expect(page.getByTestId("tariffs-legacy")).toContainText( await expect(page.getByTestId("tariffs-legacy")).toContainText(
[ [
"Tariffs & Forecasts", "Tariffs & forecasts",
"Grid price", "Grid price",
"30.0 öre", "30.0 öre",
"Feed-in price", "Feed-in price",