From 0e58436ca4b23f0989fa9019be2b6e89cf7eca58 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Thu, 13 Aug 2026 17:19:50 +0200 Subject: [PATCH] Config UI: mobile optimized section navigation (#32810) --- assets/css/app.css | 37 + assets/js/components/Config/ConfigSection.vue | 47 + .../js/components/Config/ConfigSectionNav.vue | 107 ++ assets/js/components/Config/WelcomeBanner.vue | 6 - .../components/MaterialIcon/ChevronRight.vue | 15 + .../components/MaterialIcon/Integrations.vue | 18 + .../js/components/MaterialIcon/Services.vue | 18 + assets/js/components/MaterialIcon/System.vue | 18 + assets/js/components/Top/Header.vue | 35 +- assets/js/configModal.test.ts | 1 + assets/js/configModal.ts | 8 +- assets/js/mixins/breakpoint.ts | 32 +- assets/js/mixins/listDetail.ts | 56 + assets/js/router.ts | 5 + assets/js/views/Config.vue | 1089 ++++++++++------- i18n/de.json | 5 +- i18n/en.json | 11 +- tests/config-mobile-nav.spec.ts | 152 +++ tests/config-tariffs.spec.ts | 4 +- 19 files changed, 1190 insertions(+), 474 deletions(-) create mode 100644 assets/js/components/Config/ConfigSection.vue create mode 100644 assets/js/components/Config/ConfigSectionNav.vue create mode 100644 assets/js/components/MaterialIcon/ChevronRight.vue create mode 100644 assets/js/components/MaterialIcon/Integrations.vue create mode 100644 assets/js/components/MaterialIcon/Services.vue create mode 100644 assets/js/components/MaterialIcon/System.vue create mode 100644 assets/js/mixins/listDetail.ts create mode 100644 tests/config-mobile-nav.spec.ts diff --git a/assets/css/app.css b/assets/css/app.css index 40ba60f0d..a99032547 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -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 { box-shadow: 0 0 0.5rem var(--bs-danger); border: 1px solid var(--bs-danger); diff --git a/assets/js/components/Config/ConfigSection.vue b/assets/js/components/Config/ConfigSection.vue new file mode 100644 index 000000000..877ed76d1 --- /dev/null +++ b/assets/js/components/Config/ConfigSection.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/assets/js/components/Config/ConfigSectionNav.vue b/assets/js/components/Config/ConfigSectionNav.vue new file mode 100644 index 000000000..81a9d59bd --- /dev/null +++ b/assets/js/components/Config/ConfigSectionNav.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/assets/js/components/Config/WelcomeBanner.vue b/assets/js/components/Config/WelcomeBanner.vue index efa7ddf92..cc367e693 100644 --- a/assets/js/components/Config/WelcomeBanner.vue +++ b/assets/js/components/Config/WelcomeBanner.vue @@ -13,9 +13,3 @@ export default { components: { Markdown }, }; - - diff --git a/assets/js/components/MaterialIcon/ChevronRight.vue b/assets/js/components/MaterialIcon/ChevronRight.vue new file mode 100644 index 000000000..ddfe105ef --- /dev/null +++ b/assets/js/components/MaterialIcon/ChevronRight.vue @@ -0,0 +1,15 @@ + + + diff --git a/assets/js/components/MaterialIcon/Integrations.vue b/assets/js/components/MaterialIcon/Integrations.vue new file mode 100644 index 000000000..f7c2860b6 --- /dev/null +++ b/assets/js/components/MaterialIcon/Integrations.vue @@ -0,0 +1,18 @@ + + + diff --git a/assets/js/components/MaterialIcon/Services.vue b/assets/js/components/MaterialIcon/Services.vue new file mode 100644 index 000000000..6b53cbb80 --- /dev/null +++ b/assets/js/components/MaterialIcon/Services.vue @@ -0,0 +1,18 @@ + + + diff --git a/assets/js/components/MaterialIcon/System.vue b/assets/js/components/MaterialIcon/System.vue new file mode 100644 index 000000000..bf150c7d6 --- /dev/null +++ b/assets/js/components/MaterialIcon/System.vue @@ -0,0 +1,18 @@ + + + diff --git a/assets/js/components/Top/Header.vue b/assets/js/components/Top/Header.vue index ccc06eb2c..4143dba92 100644 --- a/assets/js/components/Top/Header.vue +++ b/assets/js/components/Top/Header.vue @@ -3,14 +3,30 @@ class="d-flex justify-content-between align-items-center py-3 py-md-4" data-testid="header" > -

- {{ title }} -

+
+ +

{{ title }}

+
+ +
+ +

{{ title }}

+
+
+
+ + diff --git a/assets/js/configModal.test.ts b/assets/js/configModal.test.ts index f75203299..cc250abf3 100644 --- a/assets/js/configModal.test.ts +++ b/assets/js/configModal.test.ts @@ -71,5 +71,6 @@ describe("extractQueryString", () => { expect(extractQueryString("/config")).toBe(""); 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#vehicles")).toBe("meter=1"); }); }); diff --git a/assets/js/configModal.ts b/assets/js/configModal.ts index 4422ce315..14eef1ad3 100644 --- a/assets/js/configModal.ts +++ b/assets/js/configModal.ts @@ -194,7 +194,7 @@ export function buildQuery(stack: ModalEntry[]): Record { export function extractQueryString(fullPath: string): string { const qIdx = fullPath.indexOf("?"); if (qIdx === -1) return ""; - return fullPath.substring(qIdx + 1); + return fullPath.substring(qIdx + 1).split("#")[0]!; } export function initConfigModal(router: Router): void { @@ -254,7 +254,7 @@ export function openModal( return new Promise((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 { // Update stack synchronously to prevent double-close from GenericModal's handleHidden configModal.stack = newStack; - await _router.push({ path: "/config", query }); + await _router.push({ path: "/config", query, hash: _router.currentRoute.value.hash }); resolve?.(finalResult); } @@ -299,7 +299,7 @@ export function replaceModal( const newStack = [...configModal.stack.slice(0, -1), entry]; 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 { diff --git a/assets/js/mixins/breakpoint.ts b/assets/js/mixins/breakpoint.ts index aa0738364..ad3a6ac92 100644 --- a/assets/js/mixins/breakpoint.ts +++ b/assets/js/mixins/breakpoint.ts @@ -1,18 +1,20 @@ export type Breakpoint = "xs" | "sm" | "md" | "lg" | "xl" | "xxl"; -interface BreakpointDef { - name: Breakpoint; - maxWidth: number; -} +// bootstrap breakpoints, value = last width still inside +export const MAX_WIDTH: Record = { + xs: 575, + sm: 767, + md: 991, + lg: 1199, + xl: 1399, + xxl: Infinity, +}; -const BREAKPOINTS: BreakpointDef[] = [ - { name: "xs", maxWidth: 575 }, - { name: "sm", maxWidth: 767 }, - { name: "md", maxWidth: 991 }, - { name: "lg", maxWidth: 1199 }, - { name: "xl", maxWidth: 1399 }, - { name: "xxl", maxWidth: Infinity }, -]; +const BREAKPOINTS = Object.entries(MAX_WIDTH) as [Breakpoint, number][]; + +export function isMobileWidth(width: number = window.innerWidth): boolean { + return width <= MAX_WIDTH.sm; +} export default { data() { @@ -24,9 +26,9 @@ export default { updateBreakpoint(): void { const width: number = window.innerWidth; const self = this as any; - for (const bp of BREAKPOINTS) { - if (width <= bp.maxWidth) { - self.breakpoint = bp.name; + for (const [name, maxWidth] of BREAKPOINTS) { + if (width <= maxWidth) { + self.breakpoint = name; return; } } diff --git a/assets/js/mixins/listDetail.ts b/assets/js/mixins/listDetail.ts new file mode 100644 index 000000000..20670effb --- /dev/null +++ b/assets/js/mixins/listDetail.ts @@ -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(`[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: "", + }); + } + }, + }, +}); diff --git a/assets/js/router.ts b/assets/js/router.ts index 5391dc274..b2a8e6e7e 100644 --- a/assets/js/router.ts +++ b/assets/js/router.ts @@ -13,6 +13,7 @@ import { isConfigured, } from "./components/Auth/auth"; import { initConfigModal } from "./configModal"; +import { isMobileWidth } from "./mixins/breakpoint"; import { hapticFeedback } from "./utils/haptic"; import type { VueI18nInstance } from "vue-i18n"; @@ -65,6 +66,10 @@ export default function setupRouter(i18n: VueI18nInstance) { stringifyQuery, scrollBehavior(to, from) { 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) => { const check = () => { if (document.querySelector(to.hash)) { diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index f897fd40e..bc63fa0ea 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -3,10 +3,12 @@
-
+
-

{{ $t("config.section.general") }}

- + +
+ + +
+
- -

{{ $t("config.section.loadpoints") }}

-
- - - - + + + - -
+ +
+ + + + -

{{ $t("config.section.vehicles") }}

-
- - - - - -
+ +
+
-

{{ $t("config.section.consumers") }}

-
- - - -
+ +
+ + + + + +
+
-

{{ $t("config.section.grid") }}

-
- - -
-

{{ $t("config.section.meter") }}

-
- - - -
+ +
+ + + +
+
-

{{ $t("config.section.additionalMeter") }}

-
- - -
+ +
+ + +
+
-

{{ $t("config.tariff.title") }}

-
- - - - -
-
- - - - - - - - -
+ +
+ + + +
+
-

{{ $t("config.section.integrations") }}

+ +
+ + +
+
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ +
+ + + + +
+
+ + + + + + + + +
+
-

{{ $t("config.section.services") }}

-
- - - - - - - - - - - - -
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
-
+ +
+ + + + + + + + + + + + +
+
-

{{ $t("config.section.system") }}

-
- - {{ $t("config.system.logs") }} - - - {{ $t("help.issueButton") }} - - - -
+ + {{ $t("config.system.logs") }} + + + {{ $t("help.issueButton") }} + + + +
+ >; +// section slug (anchor/deep link) -> title i18n key, in display order +const SECTION_TITLES: Record = { + general: "config.section.general", + loadpoints: "config.section.loadpoints", + vehicles: "config.section.vehicles", + consumers: "config.section.consumers", + grid: "config.section.grid", + "pv-battery": "config.section.meter", + meters: "config.section.additionalMeter", + tariffs: "config.tariff.title", + integrations: "config.section.integrations", + services: "config.section.services", + system: "config.section.system", +}; + type DeviceTags = Record< string, { value?: any; error?: boolean; warning?: boolean; muted?: boolean; options?: any } @@ -634,6 +685,8 @@ export default defineComponent({ NewDeviceButton, BackupRestoreModal, ChargerModal, + ConfigSection, + ConfigSectionNav, CircuitsIcon, CircuitsModal, CircuitTags, @@ -688,7 +741,7 @@ export default defineComponent({ ApiKeyModal, AuthProvidersCard, }, - mixins: [formatter, collector], + mixins: [formatter, collector, listDetail], props: { offline: Boolean, notifications: { type: Array as PropType, default: () => [] }, @@ -737,6 +790,134 @@ export default defineComponent({ return { title: this.$t("config.main.title") }; }, computed: { + activeSlug(): string | undefined { + const slug = this.$route.hash.slice(1); + return SECTION_TITLES[slug] ? slug : undefined; + }, + headerTitle(): string { + return this.mobile && this.activeSlug + ? this.$t(SECTION_TITLES[this.activeSlug]!) + : this.$t("config.main.title"); + }, + headerParentTitle(): string { + return this.mobile && this.activeSlug ? this.$t("config.main.title") : ""; + }, + sectionEntries(): SectionEntry[] { + const meterError = (meters: ConfigMeter[]) => + meters.some((m) => this.hasDeviceError("meter", m.name)); + const meterDisabled = (meters: ConfigMeter[]) => meters.some((m) => m.deviceDisable); + const auxAndConsumer = [...this.consumerMeters, ...this.auxMeters]; + const pvAndBattery = [...this.pvMeters, ...this.batteryMeters]; + const configuredTariffCount = + [ + this.gridTariff, + this.feedInTariff, + this.co2Tariff, + this.temperatureTariff, + this.plannerTariff, + ].filter(Boolean).length + this.solarTariffs.length; + // key = config..title i18n stem and fatal error class (see errorClass exceptions) + const integrationTags: Record = { + mqtt: this.mqttTags, + messaging: this.messagingTags, + influx: this.influxTags, + circuits: this.circuitsTags, + hems: this.hemsTags, + modbusproxy: this.modbusproxyTags, + remote: this.remoteTags, + optimizer: this.optimizerTags, + }; + const errorClass: Record = { + messaging: "messenger", + circuits: "circuit", + }; + const integrations = Object.entries(integrationTags).map(([key, tags]) => ({ + key, + error: errorClass[key] || key, + configured: !this.isUnconfigured(tags), + })); + const entries: Omit[] = [ + { + slug: "general", + icon: "shopicon-regular-settings", + subline: this.siteTitle || undefined, + error: this.hasClassError("sponsorship"), + warning: !!this.sponsor?.status?.expiresSoon, + }, + { + slug: "loadpoints", + icon: markRaw(LoadpointIcon), + count: this.loadpoints.length, + error: this.loadpoints.some((lp) => this.loadpointError(lp)), + warning: this.loadpoints.some((lp) => lp.disable), + }, + { + slug: "vehicles", + icon: "shopicon-regular-car3", + count: this.vehicles.length, + error: this.vehicles.some((v) => this.hasDeviceError("vehicle", v.name)), + warning: this.vehicles.some((v) => v.deviceDisable), + }, + { + slug: "consumers", + icon: markRaw(GenericIcon), + count: auxAndConsumer.length, + error: meterError(auxAndConsumer), + warning: meterDisabled(auxAndConsumer), + }, + { + slug: "grid", + icon: "shopicon-regular-powersupply", + count: this.gridMeter ? 1 : 0, + error: !!this.gridMeter && this.hasDeviceError("meter", this.gridMeter.name), + }, + { + slug: "pv-battery", + icon: "shopicon-regular-sun", + count: pvAndBattery.length, + error: meterError(pvAndBattery), + warning: + this.pvMeters.some((m) => this.meterBanner(m.name)) || + meterDisabled(pvAndBattery), + }, + { + slug: "meters", + icon: markRaw(MeterIcon), + count: this.extMeters.length, + error: meterError(this.extMeters), + warning: meterDisabled(this.extMeters), + }, + { + slug: "tariffs", + icon: "shopicon-regular-receivepayment", + count: this.tariffsYamlSource ? 1 : configuredTariffCount, + error: + this.hasClassError("tariff") || + this.tariffs.some((t) => this.hasDeviceError("tariff", t.name)), + }, + { + slug: "integrations", + icon: markRaw(IntegrationsIcon), + subline: + integrations + .filter((i) => i.configured) + .map((i) => this.$t(`config.${i.key}.title`)) + .join(" · ") || undefined, + error: integrations.some((i) => this.hasClassError(i.error)), + warning: Object.values(this.authProviders || {}).some((p) => !p.authenticated), + }, + { + slug: "services", + icon: markRaw(ServicesIcon), + error: ["ocpp", "shm", "eebus", "mcp"].some((c) => this.hasClassError(c)), + }, + { + slug: "system", + icon: markRaw(SystemIcon), + }, + ]; + return entries.map((e) => ({ ...e, title: this.$t(SECTION_TITLES[e.slug]!) })); + }, callbackCompleted() { return this.$route.query["callbackCompleted"] as string | undefined; }, @@ -969,6 +1150,9 @@ export default defineComponent({ } return { configured: { value: false } }; }, + circuitsTags(): DeviceTags { + return this.circuitsRoot ? {} : { configured: { value: false } }; + }, // maps an OCPP station id to its loadpoint title (fallback: charger title) stationTitles(): Record { const map: Record = {}; @@ -1056,6 +1240,17 @@ export default defineComponent({ isUnconfigured(tags: DeviceTags): boolean { return tags["configured"]?.value === false; }, + sectionProps(slug: string) { + return { + slug, + title: this.$t(SECTION_TITLES[slug]!), + mobile: this.mobile, + active: this.mobile && this.activeSlug === slug, + }; + }, + openSection(slug: string) { + this.$router.push({ path: "/config", hash: `#${slug}` }); + }, async handleDisable(deviceClass: DeviceType, id: number, disable: boolean) { const promptKey = disable ? "config.general.confirmDisable" @@ -1341,6 +1536,13 @@ export default defineComponent({ return { ...chargerTags, ...meterTags }; }, openModal, + loadpointError(loadpoint: ConfigLoadpoint): boolean { + return ( + this.hasDeviceError("loadpoint", loadpoint.name) || + this.hasDeviceError("charger", loadpoint.charger) || + this.hasDeviceError("meter", loadpoint.meter) + ); + }, hasDeviceError(type: DeviceType, name?: string) { if (!name) return false; const fatals = store.state?.fatal || []; @@ -1363,6 +1565,10 @@ export default defineComponent({ }) as any; diff --git a/i18n/de.json b/i18n/de.json index d2b121883..2b6a02b83 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1175,9 +1175,12 @@ } }, "general": { + "back": "Zurück", "download": "Download", + "error": "Fehler", "none": "keine", - "note": "Hinweis:" + "note": "Hinweis:", + "warning": "Warnung" }, "header": { "about": "Über", diff --git a/i18n/en.json b/i18n/en.json index d95423cb2..09ae539b0 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -842,8 +842,8 @@ "general": "General", "grid": "Grid", "integrations": "Integrations", - "loadpoints": "Charging points & Heaters", - "meter": "Solar & Battery", + "loadpoints": "Charging points & heaters", + "meter": "Solar & battery", "services": "Services", "system": "System", "vehicles": "Vehicles" @@ -977,7 +977,7 @@ "titleEdit": "Edit Temperature Forecast" }, "template": "Provider", - "title": "Tariffs & Forecasts", + "title": "Tariffs & forecasts", "titleChoice": "What Do You Want To Add?", "type": { "co2": "CO₂ Intensity", @@ -1175,9 +1175,12 @@ } }, "general": { + "back": "Back", "download": "Download", + "error": "Error", "none": "none", - "note": "Note:" + "note": "Note:", + "warning": "Warning" }, "header": { "about": "About", diff --git a/tests/config-mobile-nav.spec.ts b/tests/config-mobile-nav.spec.ts new file mode 100644 index 000000000..9db0ff476 --- /dev/null +++ b/tests/config-mobile-nav.spec.ts @@ -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(); + }); +}); diff --git a/tests/config-tariffs.spec.ts b/tests/config-tariffs.spec.ts index 39b695594..3af69097d 100644 --- a/tests/config-tariffs.spec.ts +++ b/tests/config-tariffs.spec.ts @@ -26,7 +26,7 @@ test.describe("tariffs", async () => { await page.goto("/#/config"); // 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(); // 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")).toContainText( [ - "Tariffs & Forecasts", + "Tariffs & forecasts", "Grid price", "30.0 öre", "Feed-in price",