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 {
box-shadow: 0 0 0.5rem 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 },
};
</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"
data-testid="header"
>
<h1 class="mb-1 pt-1 d-flex text-nowrap text-truncate">
<span class="text-truncate">{{ title }}</span>
</h1>
<div class="title position-relative flex-grow-1 mb-1">
<Transition name="fade-swap-left">
<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" />
</header>
</template>
<script lang="ts">
import "@h2d2/shopicons/es/bold/arrowback";
import TopNavigationArea from "./TopNavigationArea.vue";
import { defineComponent, type PropType } from "vue";
import type { Notification } from "@/types/evcc";
@ -22,8 +38,11 @@ export default defineComponent({
},
props: {
title: String,
// set on drill-down pages: shows a back button next to the title
parentTitle: String,
notifications: { type: Array as PropType<Notification[]>, default: () => [] },
},
emits: ["back"],
methods: {
requestAuthProvider(providerId: string) {
const navigationArea = this.$refs["navigationArea"] as
@ -34,3 +53,13 @@ export default defineComponent({
},
});
</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?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");
});
});

View file

@ -194,7 +194,7 @@ export function buildQuery(stack: ModalEntry[]): Record<string, string> {
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<ModalResult>((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
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 {

View file

@ -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<Breakpoint, number> = {
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;
}
}

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,
} 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)) {

View file

@ -3,10 +3,12 @@
<div class="container px-4">
<TopHeader
ref="header"
:title="$t('config.main.title')"
:title="headerTitle"
:notifications="notifications"
:parent-title="headerParentTitle"
@back="goBack"
/>
<div class="wrapper mb-3">
<div class="wrapper position-relative mb-3">
<AuthSuccessBanner
v-if="callbackCompleted || callbackError"
:provider-id="callbackCompleted"
@ -14,16 +16,28 @@
:auth-providers="authProviders"
/>
<h2 class="my-4 mt-5">{{ $t("config.section.general") }}</h2>
<Transition name="fade-swap-left">
<div v-if="!mobile || !activeSlug">
<WelcomeBanner v-if="setupRequired" class="my-4" />
<ConfigSectionNav
v-if="mobile"
class="my-4"
:sections="sectionEntries"
@open="openSection"
/>
</div>
</Transition>
<ConfigSection v-bind="sectionProps('general')">
<GeneralConfig
class="box-pull-out"
:experimental="experimental"
:sponsor-error="hasClassError('sponsorship')"
@site-changed="siteChanged"
/>
</ConfigSection>
<WelcomeBanner v-if="setupRequired" />
<h2 class="my-4">{{ $t("config.section.loadpoints") }}</h2>
<ConfigSection v-bind="sectionProps('loadpoints')">
<div class="p-0 config-list box-pull-out">
<DeviceCard
v-for="loadpoint in loadpoints"
@ -32,7 +46,7 @@
:title="loadpoint.title"
:name="loadpoint.name"
:editable="!!loadpoint.id"
:error="hasDeviceError('loadpoint', loadpoint.name)"
:error="loadpointError(loadpoint)"
:disabled="!!loadpoint.disable"
data-testid="loadpoint"
@edit="openModal('loadpoint', { id: loadpoint.id })"
@ -56,8 +70,9 @@
@click="openModal('loadpoint')"
/>
</div>
</ConfigSection>
<h2 id="vehicles" class="my-4">{{ $t("config.section.vehicles") }}</h2>
<ConfigSection v-bind="sectionProps('vehicles')">
<div class="p-0 config-list box-pull-out">
<DeviceCard
v-for="vehicle in vehicles"
@ -85,8 +100,9 @@
@click="openModal('vehicle')"
/>
</div>
</ConfigSection>
<h2 class="my-4 mt-5">{{ $t("config.section.consumers") }}</h2>
<ConfigSection v-bind="sectionProps('consumers')">
<div class="p-0 config-list box-pull-out">
<MeterCard
v-for="meter in consumerMeters"
@ -114,8 +130,9 @@
@click="openModal('meter', { choices: ['consumer', 'aux'] })"
/>
</div>
</ConfigSection>
<h2 class="my-4 mt-5">{{ $t("config.section.grid") }}</h2>
<ConfigSection v-bind="sectionProps('grid')">
<div class="p-0 config-list box-pull-out">
<MeterCard
v-if="gridMeter"
@ -134,7 +151,9 @@
@click="openModal('meter', { type: 'grid' })"
/>
</div>
<h2 class="my-4 mt-5">{{ $t("config.section.meter") }}</h2>
</ConfigSection>
<ConfigSection v-bind="sectionProps('pv-battery')">
<div class="p-0 config-list box-pull-out">
<MeterCard
v-for="meter in pvMeters"
@ -162,8 +181,9 @@
@click="openModal('meter', { choices: ['pv', 'battery'] })"
/>
</div>
</ConfigSection>
<h2 class="my-4 mt-5">{{ $t("config.section.additionalMeter") }}</h2>
<ConfigSection v-bind="sectionProps('meters')">
<div class="p-0 config-list box-pull-out">
<MeterCard
v-for="meter in extMeters"
@ -181,8 +201,9 @@
@click="openModal('meter', { type: 'ext' })"
/>
</div>
</ConfigSection>
<h2 id="tariffs" class="my-4 mt-5">{{ $t("config.tariff.title") }}</h2>
<ConfigSection v-bind="sectionProps('tariffs')">
<div v-if="!!tariffsYamlSource" class="p-0 config-list box-pull-out">
<DeviceCard
:title="$t('config.tariff.title')"
@ -255,7 +276,10 @@
:has-error="hasDeviceError('tariff', temperatureTariff.name)"
:tags="deviceTags('tariff', temperatureTariff.name)"
@edit="
openModal('tariff', { type: 'temperature', id: temperatureTariff.id })
openModal('tariff', {
type: 'temperature',
id: temperatureTariff.id,
})
"
/>
<TariffCard
@ -274,9 +298,9 @@
@click="openModal('tariff', { choices: possibleForecastTypes })"
/>
</div>
</ConfigSection>
<h2 class="my-4 mt-5">{{ $t("config.section.integrations") }}</h2>
<ConfigSection v-bind="sectionProps('integrations')">
<div class="p-0 config-list box-pull-out">
<AuthProvidersCard
:providers="authProviders"
@ -327,19 +351,18 @@
:title="`${$t('config.circuits.title')}`"
editable
:error="hasClassError('circuit')"
:unconfigured="!circuitsRoot"
:unconfigured="isUnconfigured(circuitsTags)"
:banner="
hemsDimmed && circuitsRoot ? $t('config.deviceValue.dimmed') : undefined
hemsDimmed && circuitsRoot
? $t('config.deviceValue.dimmed')
: undefined
"
data-testid="circuits"
@edit="openModal('circuits')"
>
<template #icon><CircuitsIcon /></template>
<template #tags>
<DeviceTags
v-if="!circuitsRoot"
:tags="{ configured: { value: false } }"
/>
<DeviceTags v-if="!circuitsRoot" :tags="circuitsTags" />
<CircuitTags v-else :nodes="[circuitsRoot]" />
</template>
</DeviceCard>
@ -397,8 +420,9 @@
</template>
</DeviceCard>
</div>
</ConfigSection>
<h2 class="my-4 mt-5">{{ $t("config.section.services") }}</h2>
<ConfigSection v-bind="sectionProps('services')">
<div class="p-0 config-list box-pull-out">
<DeviceCard
:title="$t('config.ocpp.title')"
@ -437,13 +461,14 @@
<template #icon><McpIcon /></template>
</DeviceCard>
</div>
</ConfigSection>
<hr class="my-5" />
<hr v-if="!mobile" class="my-5" />
<h2 class="my-4 mt-5">{{ $t("config.section.system") }}</h2>
<ConfigSection v-bind="sectionProps('system')">
<div
data-testid="config-system"
class="round-box box-pull-out p-4 d-flex gap-4 flex-wrap"
class="round-box box-pull-out p-4 d-grid d-md-flex gap-4 flex-wrap"
>
<router-link to="/log" class="btn btn-outline-secondary">
{{ $t("config.system.logs") }}
@ -462,6 +487,7 @@
{{ $t("config.system.restart") }}
</button>
</div>
</ConfigSection>
<LoadpointModal
:vehicleOptions="vehicleOptions"
@ -535,9 +561,19 @@ import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/batterythreequarters";
import "@h2d2/shopicons/es/regular/powersupply";
import "@h2d2/shopicons/es/regular/receivepayment";
import "@h2d2/shopicons/es/regular/settings";
import "@h2d2/shopicons/es/regular/car3";
import NewDeviceButton from "../components/Config/NewDeviceButton.vue";
import api from "../api";
import listDetail from "../mixins/listDetail";
import ChargerModal from "../components/Config/ChargerModal.vue";
import ConfigSection from "../components/Config/ConfigSection.vue";
import ConfigSectionNav, { type SectionEntry } from "../components/Config/ConfigSectionNav.vue";
import IntegrationsIcon from "../components/MaterialIcon/Integrations.vue";
import ServicesIcon from "../components/MaterialIcon/Services.vue";
import SystemIcon from "../components/MaterialIcon/System.vue";
import MeterIcon from "../components/VehicleIcon/Meter.vue";
import GenericIcon from "../components/VehicleIcon/Generic.vue";
import CircuitsIcon from "../components/MaterialIcon/Circuits.vue";
import CircuitsModal from "../components/Config/CircuitsModal.vue";
import CircuitTags from "../components/Config/CircuitTags.vue";
@ -592,7 +628,7 @@ import TitleModal from "../components/Config/TitleModal.vue";
import Header from "../components/Top/Header.vue";
import VehicleIcon from "../components/VehicleIcon";
import VehicleModal from "../components/Config/VehicleModal.vue";
import { defineComponent, type PropType } from "vue";
import { defineComponent, markRaw, type PropType } from "vue";
import type {
ConfigCharger,
ConfigVehicle,
@ -615,6 +651,21 @@ import { circuitTree, type CircuitNode } from "@/utils/circuits";
type DeviceValuesMap = Record<DeviceType, Record<string, any>>;
// section slug (anchor/deep link) -> title i18n key, in display order
const SECTION_TITLES: Record<string, string> = {
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<Notification[]>, 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.<key>.title i18n stem and fatal error class (see errorClass exceptions)
const integrationTags: Record<string, DeviceTags> = {
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<string, string> = {
messaging: "messenger",
circuits: "circuit",
};
const integrations = Object.entries(integrationTags).map(([key, tags]) => ({
key,
error: errorClass[key] || key,
configured: !this.isUnconfigured(tags),
}));
const entries: Omit<SectionEntry, "title">[] = [
{
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<string, string> {
const map: Record<string, string> = {};
@ -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;
</script>
<style scoped>
/* transition transforms must not make the page x-scrollable */
.container {
overflow-x: clip;
}
.config-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
@ -1378,4 +1584,9 @@ export default defineComponent({
opacity: 0.2 !important;
display: none !important;
}
/* stacked cards; minmax lets wide content scroll inside instead of growing the track */
.detail-panel .config-list {
grid-template-columns: minmax(0, 1fr);
margin-bottom: 0;
}
</style>

View file

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

View file

@ -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",

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");
// 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",