Config UI: mobile optimized section navigation (#32810)
This commit is contained in:
parent
070c16cb4f
commit
0e58436ca4
19 changed files with 1190 additions and 474 deletions
|
|
@ -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);
|
||||
|
|
|
|||
47
assets/js/components/Config/ConfigSection.vue
Normal file
47
assets/js/components/Config/ConfigSection.vue
Normal 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>
|
||||
107
assets/js/components/Config/ConfigSectionNav.vue
Normal file
107
assets/js/components/Config/ConfigSectionNav.vue
Normal 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>
|
||||
|
|
@ -13,9 +13,3 @@ export default {
|
|||
components: { Markdown },
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.alert {
|
||||
margin-bottom: 5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
15
assets/js/components/MaterialIcon/ChevronRight.vue
Normal file
15
assets/js/components/MaterialIcon/ChevronRight.vue
Normal 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>
|
||||
18
assets/js/components/MaterialIcon/Integrations.vue
Normal file
18
assets/js/components/MaterialIcon/Integrations.vue
Normal 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>
|
||||
18
assets/js/components/MaterialIcon/Services.vue
Normal file
18
assets/js/components/MaterialIcon/Services.vue
Normal 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>
|
||||
18
assets/js/components/MaterialIcon/System.vue
Normal file
18
assets/js/components/MaterialIcon/System.vue
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
assets/js/mixins/listDetail.ts
Normal file
56
assets/js/mixins/listDetail.ts
Normal 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: "",
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1175,9 +1175,12 @@
|
|||
}
|
||||
},
|
||||
"general": {
|
||||
"back": "Zurück",
|
||||
"download": "Download",
|
||||
"error": "Fehler",
|
||||
"none": "keine",
|
||||
"note": "Hinweis:"
|
||||
"note": "Hinweis:",
|
||||
"warning": "Warnung"
|
||||
},
|
||||
"header": {
|
||||
"about": "Über",
|
||||
|
|
|
|||
11
i18n/en.json
11
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",
|
||||
|
|
|
|||
152
tests/config-mobile-nav.spec.ts
Normal file
152
tests/config-mobile-nav.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue