Navigation UI: make bottom nav standard (#29357)

This commit is contained in:
Michael Geers 2026-04-23 11:39:08 +02:00 • committed by GitHub
parent d37121efd9
commit b0126db57a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 125 additions and 1234 deletions

View file

@ -94,7 +94,7 @@
--safe-area-inset-right: env(safe-area-inset-right, 0px);
--tab-bar-height: 3rem;
--bottom-space: 1.5px;
--bottom-space: calc(var(--tab-bar-height) + 1.5rem);
--tab-bar-background: var(--evcc-background);
--evcc-backdrop-blur: blur(20px);
}
@ -830,6 +830,6 @@ input::-webkit-date-and-time-value {
transition: opacity var(--evcc-transition-fast) ease 0.2s;
}
.app--bottomtabs .sticky-bottom {
.sticky-bottom {
bottom: calc(var(--tab-bar-height) + 0.25rem);
}

View file

@ -1,140 +0,0 @@
<template>
<GenericModal
id="batterySettingsModal"
:title="$t('batterySettings.modalTitle')"
size="xl"
data-testid="battery-settings-modal"
@open="modalVisible"
@closed="modalInvisible"
>
<ul v-if="gridChargePossible || batteryGridChargeLimit !== null" class="nav nav-tabs mb-4">
<li class="nav-item">
<a
class="nav-link"
:class="{ active: usageTabActive }"
href="#"
@click.prevent="showUsageTab"
>
{{ $t("batterySettings.usageTab") }}
</a>
</li>
<li class="nav-item">
<a
class="nav-link"
:class="{ active: gridTabActive }"
href="#"
@click.prevent="showGridTab"
>
{{ $t("batterySettings.gridChargeTab") }}
</a>
</li>
</ul>
<BatteryUsageSettings
v-show="usageTabActive"
:buffer-soc="bufferSoc"
:priority-soc="prioritySoc"
:buffer-start-soc="bufferStartSoc"
:battery-discharge-control="batteryDischargeControl"
:battery="battery"
/>
<SmartCostLimit
v-if="isModalVisible"
v-show="gridTabActive"
:current-limit="batteryGridChargeLimit"
:last-limit="lastSmartCostLimit"
:smart-cost-type="smartCostType"
:currency="currency"
:tariff="gridChargeTariff"
:possible="gridChargePossible"
/>
</GenericModal>
</template>
<script lang="ts">
import SmartCostLimit from "../Tariff/SmartCostLimit.vue";
import BatteryUsageSettings from "./BatteryUsageSettings.vue";
import GenericModal from "../Helper/GenericModal.vue";
import settings from "@/settings";
import { defineComponent, type PropType } from "vue";
import type { Battery, CURRENCY, Forecast } from "@/types/evcc";
import { SMART_COST_TYPE } from "@/types/evcc";
export default defineComponent({
name: "BatterySettingsModal",
components: { SmartCostLimit, BatteryUsageSettings, GenericModal },
props: {
bufferSoc: { type: Number, default: 100 },
prioritySoc: { type: Number, default: 0 },
bufferStartSoc: { type: Number, default: 0 },
batteryDischargeControl: Boolean,
battery: { type: Object as PropType<Battery> },
batteryGridChargeLimit: { type: Number, default: null },
smartCostAvailable: Boolean,
smartCostType: String as PropType<SMART_COST_TYPE>,
tariffGrid: Number,
currency: String as PropType<CURRENCY>,
forecast: Object as PropType<Forecast>,
},
data() {
return {
isModalVisible: false,
gridTabActive: false,
};
},
computed: {
usageTabActive() {
return !this.gridTabActive;
},
batteryDevices() {
return this.battery?.devices ?? [];
},
controllable() {
return this.batteryDevices.some(({ controllable }) => controllable);
},
gridChargePossible() {
return this.controllable && this.isModalVisible && this.smartCostAvailable;
},
gridChargeTariff() {
if (this.smartCostType === SMART_COST_TYPE.CO2) {
return this.forecast?.co2;
}
return this.forecast?.grid;
},
lastSmartCostLimit() {
return settings.lastBatterySmartCostLimit;
},
},
watch: {
batteryGridChargeLimit() {
this.verifyTabs();
},
gridChargePossible() {
this.verifyTabs();
},
},
methods: {
showGridTab() {
this.gridTabActive = true;
},
showUsageTab() {
this.gridTabActive = false;
},
verifyTabs() {
if (
this.gridTabActive &&
!this.gridChargePossible &&
this.batteryGridChargeLimit === null
) {
this.gridTabActive = false;
}
},
modalVisible() {
this.isModalVisible = true;
},
modalInvisible() {
this.isModalVisible = false;
},
},
});
</script>

View file

@ -92,7 +92,7 @@
:powerUnit="powerUnit"
:expanded="pvExpanded"
data-testid="energyflow-entry-production"
@details-clicked="openForecastModal"
@details-clicked="openForecastView"
@toggle="togglePv"
>
<template v-if="pv.length > 1" #expanded>
@ -123,7 +123,7 @@
:expanded="batteryExpanded"
detailsClickable
data-testid="energyflow-entry-batterydischarge"
@details-clicked="openBatterySettingsModal"
@details-clicked="openBatteryView"
@toggle="toggleBattery"
>
<template
@ -264,7 +264,7 @@
:expanded="batteryExpanded"
detailsClickable
data-testid="energyflow-entry-batterycharge"
@details-clicked="openBatterySettingsModal"
@details-clicked="openBatteryView"
@toggle="toggleBattery"
>
<template
@ -287,7 +287,7 @@
v-if="batteryGridChargeLimitSet"
type="button"
class="btn-reset d-flex justify-content-between text-start pe-4"
@click.stop="openBatterySettingsModal"
@click.stop="openBatteryView"
>
<span v-if="batteryGridChargeActive">
{{ $t("main.energyflow.batteryGridChargeActive") }}
@ -339,7 +339,6 @@
<script lang="ts">
import "@h2d2/shopicons/es/filled/square";
import Modal from "bootstrap/js/dist/modal";
import Visualization from "./Visualization.vue";
import Entry from "./Entry.vue";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
@ -651,25 +650,11 @@ export default defineComponent({
updateHeight() {
this.detailsCompleteHeight = this.$refs["detailsInner"]?.offsetHeight ?? 0;
},
openBatterySettingsModal() {
if (this.experimental) {
this.$router.push("/battery");
return;
}
const modal = Modal.getOrCreateInstance(
document.getElementById("batterySettingsModal") as HTMLElement
);
modal.show();
openBatteryView() {
this.$router.push("/battery");
},
openForecastModal() {
if (this.experimental) {
this.$router.push("/forecast");
return;
}
const modal = Modal.getOrCreateInstance(
document.getElementById("forecastModal") as HTMLElement
);
modal.show();
openForecastView() {
this.$router.push("/forecast");
},
dischargePower(power: number) {
return Math.abs(Math.max(0, power));

View file

@ -1,59 +0,0 @@
import Footer from "./Footer.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
export default {
title: "Footer/Footer",
component: Footer,
argTypes: {
version: { control: "object" },
savings: { control: "object" },
},
} as Meta<typeof Footer>;
const Template: StoryFn<typeof Footer> = (args) => ({
components: { Footer },
setup() {
return { args };
},
template: '<Footer v-bind="args" />',
});
export const Default = Template.bind({});
Default.args = {
version: {
installed: "0.401",
available: "0.401",
},
savings: {
statistics: {
"30d": { solarPercentage: 50 },
},
},
};
export const UpdateNightly = Template.bind({});
UpdateNightly.args = {
version: {
installed: "0.400",
available: "0.400",
commit: "5ce7be4",
},
savings: {
statistics: {
"30d": { solarPercentage: 22 },
},
},
};
export const UpdateAvailable = Template.bind({});
UpdateAvailable.args = {
version: {
installed: "0.400",
available: "0.500",
},
savings: {
statistics: {
"30d": { solarPercentage: 100 },
},
},
};

View file

@ -1,27 +0,0 @@
<template>
<footer class="footer" data-testid="footer">
<div class="container py-2">
<div class="d-flex justify-content-between align-items-center gap-2">
<Version v-bind="version" />
<Savings v-bind="savings" />
</div>
</div>
</footer>
</template>
<script lang="ts">
import "@h2d2/shopicons/es/filled/testtube";
import Version from "./Version.vue";
import Savings from "../Savings/Savings.vue";
import { defineComponent } from "vue";
export default defineComponent({
name: "Footer",
components: { Version, Savings },
props: {
version: Object,
savings: Object,
},
});
</script>

View file

@ -7,7 +7,7 @@
visible: visible,
'alert-danger': showError,
'alert-secondary': !showError,
'alert--bottomtabs': experimental && !blocking,
'alert--bottomtabs': !blocking,
}"
role="alert"
data-testid="bottom-banner"
@ -81,7 +81,6 @@ export default defineComponent({
offline: Boolean,
fatal: { type: Array as PropType<FatalError[]>, default: () => [] },
startupCompleted: Boolean,
experimental: Boolean,
},
data() {
return { dismissed: false };

View file

@ -1,48 +0,0 @@
import Version from "./Version.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
export default {
title: "Footer/Version",
component: Version,
parameters: {
layout: "centered",
},
argTypes: {
installed: { control: "text" },
available: { control: "text" },
commit: { control: "text" },
},
} as Meta<typeof Version>;
const Template: StoryFn<typeof Version> = (args) => ({
components: { Version },
setup() {
return { args };
},
template: '<Version v-bind="args" />',
});
export const Latest = Template.bind({});
Latest.args = {
installed: "0.303.1",
available: "0.303.1",
};
export const Nightly = Template.bind({});
Nightly.args = {
installed: "0.303.1",
available: "0.303.1",
commit: "5ce7be4",
};
export const UpdateAvailable = Template.bind({});
UpdateAvailable.args = {
installed: "0.303.0",
available: "0.303.1",
};
export const NoReleaseNotes = Template.bind({});
NoReleaseNotes.args = {
installed: "0.303.0",
available: "0.303.1",
};

View file

@ -1,86 +0,0 @@
<template>
<a
v-if="commit"
:href="githubHashUrl"
target="_blank"
rel="noopener noreferrer"
class="btn btn-link px-0 text-decoration-none evcc-default-text text-nowrap d-flex align-items-end text-truncate"
>
<Logo class="logo me-2 flex-shrink-0" />
<span class="text-decoration-underline text-truncate">v{{ installed }}</span>
<shopicon-regular-moonstars
class="ms-2 text-gray-light flex-shrink-0"
></shopicon-regular-moonstars>
</a>
<button
v-else-if="newVersionAvailable"
class="btn btn-link px-0 text-decoration-none evcc-default-text text-nowrap d-flex align-items-end text-truncate"
@click="openModal"
>
<shopicon-regular-gift class="me-2"></shopicon-regular-gift>
<span class="text-decoration-underline text-truncate">v{{ installed }}</span>
<span class="ms-2 d-none d-sm-block text-gray-medium text-decoration-underline">
{{ $t("footer.version.availableLong") }}
</span>
</button>
<a
v-else
:href="releaseNotesUrl(installed)"
target="_blank"
rel="noopener noreferrer"
class="btn btn-link evcc-default-text px-0 text-decoration-none text-nowrap d-flex align-items-end text-truncate"
>
<Logo class="logo me-2 flex-shrink-0" />
<span class="text-decoration-underline text-truncate">v{{ installed }}</span>
</a>
</template>
<script lang="ts">
import Modal from "bootstrap/js/dist/modal";
import "@h2d2/shopicons/es/regular/gift";
import "@h2d2/shopicons/es/regular/moonstars";
import Logo from "./Logo.vue";
import { isNewVersionAvailable } from "@/utils/version";
import { defineComponent } from "vue";
export default defineComponent({
name: "Version",
components: { Logo },
props: {
installed: String,
available: String,
commit: String,
},
computed: {
githubHashUrl() {
return `https://github.com/evcc-io/evcc/commit/${this.commit}`;
},
newVersionAvailable() {
return isNewVersionAvailable(this.installed, this.available);
},
},
methods: {
releaseNotesUrl(version?: string) {
return version == "0.0.0"
? `https://github.com/evcc-io/evcc/releases`
: `https://github.com/evcc-io/evcc/releases/tag/${version}`;
},
openModal() {
const modal = Modal.getOrCreateInstance(
document.getElementById("aboutModal") as HTMLElement
);
modal.show();
},
},
});
</script>
<style scoped>
.icon {
color: var(--evcc-dark-green);
}
.logo {
height: 1.1rem;
margin: 0.3rem 0 0.2rem;
}
</style>

View file

@ -1,174 +0,0 @@
<template>
<GenericModal
id="forecastModal"
:title="$t('forecast.modalTitle')"
size="xl"
data-testid="forecast-modal"
@open="modalVisible"
@closed="modalInvisible"
>
<div v-if="isModalVisible">
<div class="d-flex justify-content-between align-items-center gap-2">
<ForecastTypeSelect v-model="selectedType" :forecast="forecast" />
<ForecastActiveSlot :activeSlot="selectedSlot" />
</div>
<ForecastChart
class="my-3"
:grid="forecast.grid"
:solar="solar"
:co2="forecast.co2"
:currency="currency"
:selected="selectedType"
@selected="updateSlot"
/>
<ForecastDetails
:type="selectedType"
:grid="forecast.grid"
:solar="solar"
:co2="forecast.co2"
:currency="currency"
/>
<div v-if="showSolarAdjust" class="form-check form-switch mt-4">
<input
id="solarForecastAdjust"
:checked="solarAdjusted"
class="form-check-input"
type="checkbox"
role="switch"
@change="changeAdjusted"
/>
<div class="form-check-label">
<label for="solarForecastAdjust"
><small>🧪 {{ solarAdjustText }}</small></label
>
</div>
</div>
</div>
</GenericModal>
</template>
<script lang="ts">
import "@h2d2/shopicons/es/regular/eco1";
import "@h2d2/shopicons/es/regular/sun";
import { defineComponent } from "vue";
import type { PropType } from "vue";
import GenericModal from "../Helper/GenericModal.vue";
import Chart from "./Chart.vue";
import TypeSelect from "./TypeSelect.vue";
import Details from "./Details.vue";
import ActiveSlot from "./ActiveSlot.vue";
import formatter from "@/mixins/formatter";
import settings from "@/settings";
import type { CURRENCY, Forecast } from "@/types/evcc";
import { ForecastType, adjustedSolar } from "@/utils/forecast";
import type { ForecastSlot, TimeseriesEntry } from "./types";
export default defineComponent({
name: "ForecastModal",
components: {
GenericModal,
ForecastChart: Chart,
ForecastTypeSelect: TypeSelect,
ForecastDetails: Details,
ForecastActiveSlot: ActiveSlot,
},
mixins: [formatter],
props: {
forecast: { type: Object as PropType<Forecast>, default: () => ({}) },
currency: { type: String as PropType<CURRENCY> },
experimental: Boolean,
},
data(): {
isModalVisible: boolean;
selectedType: ForecastType;
selectedSlot: ForecastSlot | TimeseriesEntry | null;
} {
return {
isModalVisible: false,
selectedType: ForecastType.Solar,
selectedSlot: null,
};
},
computed: {
solarAdjusted() {
return settings.solarAdjusted;
},
showSolarAdjust() {
// TODO fix https://github.com/evcc-io/evcc/issues/29165
return !!this.forecast.solar && this.experimental && false;
},
solar() {
if (this.showSolarAdjust && this.solarAdjusted) {
return adjustedSolar(this.forecast.solar);
}
return this.forecast.solar ? { ...this.forecast.solar, scale: undefined } : undefined;
},
solarAdjustText() {
let percent = "";
const scale = this.forecast.solar?.scale;
if (scale) {
const percentDiff = scale * 100 - 100;
percent = ` (${this.fmtPercentage(percentDiff, 0, true)})`;
}
return this.$t("forecast.solarAdjust", { percent });
},
},
watch: {
isModalVisible(newVal) {
if (newVal) {
this.updateSelectedType();
}
},
},
mounted() {
this.updateSelectedType();
},
methods: {
modalVisible() {
this.isModalVisible = true;
},
modalInvisible() {
this.isModalVisible = false;
},
updateSlot(slot: ForecastSlot | TimeseriesEntry | null) {
this.selectedSlot = slot;
},
updateSelectedType() {
const availableTypes = {
[ForecastType.Solar]: !!this.forecast.solar,
[ForecastType.Price]: !!this.forecast.grid,
[ForecastType.Co2]: !!this.forecast.co2,
};
// selection has data, do nothing
if (availableTypes[this.selectedType]) return;
// fallback to first available type
this.selectedType =
Object.values(ForecastType).find((type) => availableTypes[type]) ||
Object.values(ForecastType)[0]!;
},
changeAdjusted() {
settings.solarAdjusted = !settings.solarAdjusted;
},
},
});
</script>
<style scoped>
.value {
font-size: 18px;
font-weight: bold;
}
.extraValue {
color: var(--evcc-gray);
font-size: 14px;
font-weight: normal;
}
.label {
color: var(--evcc-gray);
text-transform: uppercase;
}
</style>

View file

@ -71,14 +71,12 @@
:selectedId="selectedLoadpointId"
@id-changed="selectedLoadpointChanged"
/>
<Footer v-if="!experimental" v-bind="footer" />
</div>
</div>
</template>
<script lang="ts">
import "@h2d2/shopicons/es/regular/arrowup";
import Footer from "../Footer/Footer.vue";
import TopNavigationArea from "../Top/TopNavigationArea.vue";
import Energyflow from "../Energyflow/Energyflow.vue";
import HemsWarning from "../HemsWarning.vue";
@ -96,7 +94,6 @@ import type {
Notification,
Circuit,
SMART_COST_TYPE,
Sponsor,
FatalError,
EvOpt,
BATTERY_MODE,
@ -107,7 +104,6 @@ import type { Grid } from "./types";
export default defineComponent({
name: "Site",
components: {
Footer,
Loadpoints,
Energyflow,
HemsWarning,
@ -143,7 +139,6 @@ export default defineComponent({
vehicles: Object,
authProviders: { type: Object as PropType<AuthProviders>, default: () => ({}) },
currency: { type: String as PropType<CURRENCY> },
statistics: Object,
tariffFeedIn: Number,
tariffGrid: Number,
tariffCo2: Number,
@ -152,20 +147,12 @@ export default defineComponent({
tariffPriceLoadpoints: Number,
tariffCo2Loadpoints: Number,
availableVersion: String,
releaseNotes: String,
hasUpdater: Boolean,
uploadMessage: String,
uploadProgress: Number,
sponsor: { type: Object as PropType<Sponsor>, default: () => ({}) },
smartCostType: String as PropType<SMART_COST_TYPE>,
smartCostAvailable: Boolean,
smartFeedInPriorityAvailable: Boolean,
fatal: { type: Array as PropType<FatalError[]>, default: () => [] },
forecast: Object as PropType<Forecast>,
circuits: Object as PropType<Record<string, Circuit>>,
telemetry: Boolean,
experimental: Boolean,
evopt: { type: Object as PropType<EvOpt> },
},
computed: {
@ -198,24 +185,6 @@ export default defineComponent({
// work in progess
return false;
},
footer() {
return {
version: {
installed: window.evcc.version,
commit: window.evcc.commit,
available: this.availableVersion,
},
savings: {
sponsor: this.sponsor,
statistics: this.statistics,
co2Configured: this.tariffCo2 !== undefined,
currency: this.currency,
telemetry: this.telemetry,
forecast: this.forecast,
tariffGrid: this.tariffGrid,
},
};
},
hasFatalError() {
return this.fatal.length > 0;
},

View file

@ -4,22 +4,6 @@
data-testid="header"
>
<h1 class="mb-1 pt-1 d-flex text-nowrap text-truncate">
<template v-if="!experimental">
<router-link class="evcc-default-text" to="/" data-testid="home-link">
<shopicon-regular-home size="s" class="icon"></shopicon-regular-home>
</router-link>
<div v-if="showConfig" class="d-flex">
<div size="s" class="mx-2 flex-grow-0 flex-shrink-0 fw-normal">/</div>
<router-link to="/config" class="evcc-default-text text-decoration-none">
<shopicon-regular-settings
size="s"
class="icon d-block d-sm-none"
></shopicon-regular-settings>
<span class="d-none d-sm-block">{{ $t("config.main.title") }}</span>
</router-link>
</div>
<div size="s" class="mx-2 flex-grow-0 flex-shrink-0 fw-normal">/</div>
</template>
<span class="text-truncate">{{ title }}</span>
</h1>
<TopNavigationArea ref="navigationArea" :notifications="notifications" />
@ -27,10 +11,7 @@
</template>
<script lang="ts">
import "@h2d2/shopicons/es/regular/home";
import "@h2d2/shopicons/es/regular/settings";
import TopNavigationArea from "./TopNavigationArea.vue";
import store from "@/store";
import { defineComponent, type PropType } from "vue";
import type { Notification } from "@/types/evcc";
@ -40,15 +21,9 @@ export default defineComponent({
TopNavigationArea,
},
props: {
showConfig: Boolean,
title: String,
notifications: { type: Array as PropType<Notification[]>, default: () => [] },
},
computed: {
experimental(): boolean {
return store.state.experimental ?? false;
},
},
methods: {
requestAuthProvider(providerId: string) {
const navigationArea = this.$refs["navigationArea"] as
@ -59,12 +34,3 @@ export default defineComponent({
},
});
</script>
<style scoped>
.icon {
height: 22px;
width: 22px;
position: relative;
top: -3px;
}
</style>

View file

@ -1,65 +0,0 @@
import Navigation from "./Navigation.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
export default {
title: "Top/Navigation",
component: Navigation,
parameters: {
layout: "centered",
},
argTypes: {
authProviders: { control: "object" },
sponsor: { control: "object" },
},
} as Meta<typeof Navigation>;
const Template: StoryFn<typeof Navigation> = (args) => ({
components: { Navigation },
setup() {
return { args };
},
template: '<Navigation v-bind="args" />',
});
export const Standard = Template.bind({});
Standard.args = {};
export const OAuthStatus = Template.bind({});
OAuthStatus.args = {
authProviders: {
"Mercedes EQS": {
authenticated: true,
id: "mercedes-eqs-9oqwjdf9oqwjd",
},
"Nissan Leaf Pro": {
authenticated: true,
id: "nissan-leaf-pro-9oqwjdf9oqwjd",
},
},
};
export const PendingOAuthStatus = Template.bind({});
PendingOAuthStatus.args = {
authProviders: {
"Mercedes EQS": {
authenticated: true,
id: "mercedes-eqs-9oqwjdf9oqwjd",
},
"Nissan Leaf Pro": {
authenticated: false,
id: "nissan-leaf-pro-9oqwjdf9oqwjd",
},
},
};
export const TokenExpires = Template.bind({});
TokenExpires.args = {
sponsor: {
status: {
name: "Sponsor",
expiresAt: new Date().toISOString(),
expiresSoon: true,
},
yamlSource: "file",
},
};

View file

@ -1,302 +0,0 @@
<template>
<div>
<button
id="topNavigatonDropdown"
type="button"
data-bs-toggle="dropdown"
aria-expanded="false"
class="btn btn-sm btn-outline-secondary position-relative border-0 menu-button"
data-testid="topnavigation-button"
>
<span
v-if="showRootBadge"
class="position-absolute top-0 start-100 translate-middle p-2 rounded-circle"
:class="badgeClass"
>
<span class="visually-hidden">action required</span>
</span>
<shopicon-regular-menu></shopicon-regular-menu>
</button>
<ul
class="dropdown-menu dropdown-menu-end"
aria-labelledby="topNavigatonDropdown"
data-testid="topnavigation-dropdown"
>
<li>
<router-link
class="dropdown-item"
to="/sessions"
active-class="active"
data-testid="topnavigation-sessions"
>
{{ $t("header.sessions") }}
</router-link>
</li>
<li><hr class="dropdown-divider" /></li>
<li>
<button
type="button"
class="dropdown-item"
data-testid="topnavigation-settings"
@click="openSettingsModal"
>
{{ $t("settings.title") }}
</button>
</li>
<li v-if="batteryModalAvailable">
<button
type="button"
class="dropdown-item"
data-testid="topnavigation-battery"
@click="openBatterySettingsModal"
>
{{ $t("batterySettings.modalTitle") }}
</button>
</li>
<li v-if="forecastAvailable">
<button
type="button"
class="dropdown-item"
data-testid="topnavigation-forecast"
@click="openForecastModal"
>
{{ $t("forecast.modalTitle") }}
</button>
</li>
<li>
<router-link
class="dropdown-item"
to="/config"
active-class="active"
data-testid="topnavigation-config"
>
<span
v-if="showConfigBadge"
class="d-inline-block p-1 rounded-circle bg-warning rounded-circle"
:class="badgeClass"
></span>
{{ $t("config.main.title") }}
</router-link>
</li>
<li>
<router-link class="dropdown-item" to="/log" active-class="active">
{{ $t("log.title") }}
</router-link>
</li>
<li v-if="optimizeAvailable">
<router-link class="dropdown-item" to="/optimize" active-class="active">
Optimize 🧪
</router-link>
</li>
<li v-if="experimental">
<router-link class="dropdown-item" to="/history" active-class="active">
History 🧪
</router-link>
</li>
<li><hr class="dropdown-divider" /></li>
<template v-if="authorizationRequired">
<li>
<h6 class="dropdown-header">{{ $t("authProviders.authorizationRequired") }}</h6>
</li>
<li v-for="provider in providers" :key="provider.id">
<button
type="button"
class="dropdown-item"
@click="handleAuthRequired(provider.id)"
>
<span
class="d-inline-block p-1 rounded-circle border border-light"
:class="'bg-warning'"
></span>
{{ provider.title }}
</button>
</li>
<li><hr class="dropdown-divider" /></li>
</template>
<li>
<button type="button" class="dropdown-item" @click="openHelpModal">
<span>{{ $t("header.needHelp") }}</span>
</button>
</li>
<li>
<a class="dropdown-item d-flex" href="https://evcc.io/" target="_blank">
<span>evcc.io</span>
<shopicon-regular-newtab
size="s"
class="ms-2 external"
></shopicon-regular-newtab>
</a>
</li>
<li v-if="isApp">
<button
type="button"
class="dropdown-item"
data-testid="topnavigation-app"
@click="openNativeSettings"
>
{{ $t("header.nativeSettings") }}
</button>
</li>
<li v-if="showLogout">
<button type="button" class="dropdown-item" @click="logout">
{{ $t("header.logout") }}
</button>
</li>
</ul>
</div>
</template>
<script lang="ts">
import Modal from "bootstrap/js/dist/modal";
import Dropdown from "bootstrap/js/dist/dropdown";
import "@h2d2/shopicons/es/regular/gift";
import "@h2d2/shopicons/es/regular/moonstars";
import "@h2d2/shopicons/es/regular/menu";
import "@h2d2/shopicons/es/regular/newtab";
import collector from "@/mixins/collector";
import { logout, isLoggedIn, openLoginModal } from "../Auth/auth";
import { isApp, sendToApp } from "@/utils/native";
import { isUserConfigError } from "@/utils/fatal";
import { defineComponent, type PropType } from "vue";
import type { FatalError, Sponsor, EvOpt, AuthProviders, Battery } from "@/types/evcc";
export default defineComponent({
name: "TopNavigation",
mixins: [collector],
props: {
authProviders: { type: Object as PropType<AuthProviders>, default: () => ({}) },
sponsor: { type: Object as PropType<Sponsor>, default: () => ({}) },
forecast: Object,
battery: { type: Object as PropType<Battery> },
evopt: { type: Object as PropType<EvOpt>, required: false },
fatal: { type: Array as PropType<FatalError[]>, default: () => [] },
experimental: Boolean,
authDisabled: Boolean,
},
emits: ["auth-required"],
data() {
return {
isApp: isApp(),
dropdown: null as Dropdown | null,
};
},
computed: {
batteryConfigured() {
return (this.battery?.devices?.length ?? 0) > 0;
},
providers() {
return Object.entries(this.authProviders)
.filter(([, provider]) => !provider.authenticated)
.map(([title, { authenticated, id }]) => ({
title,
authenticated,
id,
}));
},
authorizationRequired() {
return this.providers.length > 0;
},
sponsorExpires(): boolean {
return !!this.sponsor?.status?.expiresSoon;
},
showConfigBadge() {
return this.sponsorExpires || isUserConfigError(this.fatal);
},
showRootBadge() {
return this.authorizationRequired || this.showConfigBadge;
},
badgeClass() {
if (this.fatal.length > 0) {
return "bg-danger";
}
return "bg-warning";
},
batteryModalAvailable() {
return this.batteryConfigured;
},
forecastAvailable() {
const { grid, solar, co2 } = this.forecast || {};
return grid || solar || co2;
},
optimizeAvailable() {
return !!this.evopt && this.experimental;
},
showLogout() {
return !this.authDisabled && isLoggedIn();
},
},
mounted() {
const $el = document.getElementById("topNavigatonDropdown");
if (!$el) {
return;
}
this.dropdown = new Dropdown(
document.getElementById("topNavigatonDropdown") as HTMLElement
);
},
unmounted() {
this.dropdown?.dispose();
},
methods: {
handleAuthRequired(providerId: string) {
this.$emit("auth-required", providerId);
},
openSettingsModal() {
const modal = Modal.getOrCreateInstance(
document.getElementById("globalSettingsModal") as HTMLElement
);
modal.show();
},
openHelpModal() {
const modal = Modal.getOrCreateInstance(
document.getElementById("helpModal") as HTMLElement
);
modal.show();
},
openBatterySettingsModal() {
if (this.experimental) {
this.$router.push("/battery");
return;
}
const modal = Modal.getOrCreateInstance(
document.getElementById("batterySettingsModal") as HTMLElement
);
modal.show();
},
openForecastModal() {
if (this.experimental) {
this.$router.push("/forecast");
return;
}
const modal = Modal.getOrCreateInstance(
document.getElementById("forecastModal") as HTMLElement
);
modal.show();
},
openNativeSettings() {
sendToApp({ type: "settings" });
},
async login() {
openLoginModal();
},
async logout() {
await logout();
this.$router.push({ path: "/" });
},
},
});
</script>
<style scoped>
.menu-button {
margin-right: -0.7rem;
}
.external {
width: 18px;
height: 20px;
}
.dropdown-menu {
/* above sticky, below modal https://getbootstrap.com/docs/5.3/layout/z-index/ */
z-index: 1045 !important;
}
</style>

View file

@ -3,10 +3,9 @@
<Notifications
:notifications="notifications"
:loadpoints="loadpoints"
:class="experimental ? 'd-flex align-items-center' : ''"
class="d-flex align-items-center"
/>
<TopNavigation v-if="!experimental" v-bind="topNavigation" @auth-required="openAuthModal" />
<Savings v-if="experimental" v-bind="savings" />
<Savings v-bind="savings" />
<AuthProviderModal :provider="authProvider" />
</div>
</template>
@ -14,24 +13,20 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import Modal from "bootstrap/js/dist/modal";
import Navigation from "./Navigation.vue";
import Notifications from "./Notifications.vue";
import AuthProviderModal from "./AuthProviderModal.vue";
import Savings from "../Savings/Savings.vue";
import type { Provider } from "./types";
import type { Notification } from "@/types/evcc";
import collector from "@/mixins/collector";
import store from "@/store";
export default defineComponent({
name: "TopNavigationArea",
components: {
TopNavigation: Navigation,
Notifications,
AuthProviderModal,
Savings,
},
mixins: [collector],
props: {
notifications: { type: Array as PropType<Notification[]>, default: () => [] },
},
@ -42,12 +37,6 @@ export default defineComponent({
};
},
computed: {
experimental(): boolean {
return store.state?.experimental || false;
},
topNavigation(): any {
return this.collectProps(Navigation, store.state);
},
savings() {
return {
sponsor: store.state.sponsor,

View file

@ -14,7 +14,6 @@ import {
} from "./components/Auth/auth";
import { initConfigModal } from "./configModal";
import { hapticFeedback } from "./utils/haptic";
import store from "./store";
import type { VueI18nInstance } from "vue-i18n";
function hideAllModals() {
@ -158,9 +157,7 @@ export default function setupRouter(i18n: VueI18nInstance) {
// Only hide modals when the actual route path changes, not query parameters
if (to.path !== from.path) {
hideAllModals();
if (store.state.experimental) {
hapticFeedback();
}
hapticFeedback();
}
});
initConfigModal(router);

View file

@ -1,16 +1,14 @@
<template>
<div class="app" :class="{ 'app--bottomtabs': state.experimental }">
<div class="app">
<router-view
v-if="showRoutes"
:notifications="notifications"
:offline="offline"
></router-view>
<BottomTabBar v-if="state.experimental" v-bind="bottomTabBarProps" />
<BottomTabBar v-bind="bottomTabBarProps" />
<GlobalSettingsModal v-bind="globalSettingsProps" />
<BatterySettingsModal v-if="batteryModalAvailabe" v-bind="batterySettingsProps" />
<ForecastModal v-bind="forecastModalProps" />
<AboutModal v-bind="aboutModalProps" />
<HelpModal />
<PasswordModal />
@ -23,8 +21,6 @@
import store from "../store";
import BottomTabBar from "../components/BottomTabs/Bar.vue";
import GlobalSettingsModal from "../components/GlobalSettings/GlobalSettingsModal.vue";
import BatterySettingsModal from "../components/Battery/BatterySettingsModal.vue";
import ForecastModal from "../components/Forecast/ForecastModal.vue";
import OfflineIndicator from "../components/Footer/OfflineIndicator.vue";
import PasswordModal from "../components/Auth/PasswordModal.vue";
import LoginModal from "../components/Auth/LoginModal.vue";
@ -50,8 +46,6 @@ export default defineComponent({
BottomTabBar,
GlobalSettingsModal,
HelpModal,
BatterySettingsModal,
ForecastModal,
PasswordModal,
LoginModal,
OfflineIndicator,
@ -76,9 +70,6 @@ export default defineComponent({
version() {
return store.state.version;
},
batteryModalAvailabe() {
return store.state.battery?.devices?.length;
},
showRoutes() {
return this.state.startupCompleted;
},
@ -89,15 +80,9 @@ export default defineComponent({
globalSettingsProps() {
return this.collectProps(GlobalSettingsModal, this.state);
},
batterySettingsProps() {
return this.collectProps(BatterySettingsModal, this.state);
},
offlineIndicatorProps() {
return this.collectProps(OfflineIndicator, this.state);
},
forecastModalProps() {
return this.collectProps(ForecastModal, this.state);
},
loginModalProps() {
return this.collectProps(LoginModal, this.state);
},
@ -247,7 +232,4 @@ export default defineComponent({
min-height: 100vh;
min-height: 100dvh;
}
.app--bottomtabs {
--bottom-space: calc(var(--tab-bar-height) + 1.5rem);
}
</style>

View file

@ -383,7 +383,7 @@
<hr class="my-5" />
<h2 class="my-4 mt-5">{{ $t("config.section.system") }}</h2>
<div class="round-box p-4 d-flex gap-4 flex-wrap">
<div data-testid="config-system" class="round-box p-4 d-flex gap-4 flex-wrap">
<router-link to="/log" class="btn btn-outline-secondary">
{{ $t("config.system.logs") }}
</router-link>

View file

@ -1,11 +1,6 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import {
expectModalHidden,
expectModalVisible,
openTopNavigation,
expectTopNavigationClosed,
} from "./utils";
import { expectModalHidden, expectModalVisible, openMoreMenu } from "./utils";
test.use({ baseURL: baseUrl() });
const BASIC = "basics.evcc.yaml";
@ -49,9 +44,8 @@ test("login", async ({ page }) => {
await page.goto("/");
// go to config
await openTopNavigation(page);
await openMoreMenu(page);
await page.getByRole("link", { name: "Configuration" }).click();
await expectTopNavigationClosed(page);
// login modal
const login = page.getByTestId("login-modal");
@ -77,9 +71,8 @@ test("http iframe hint", async ({ page }) => {
await page.goto("/");
// go to config
await openTopNavigation(page);
await openMoreMenu(page);
await page.getByRole("link", { name: "Configuration" }).click();
await expectTopNavigationClosed(page);
// login modal
const login = page.getByTestId("login-modal");
@ -129,18 +122,16 @@ test("update password", async ({ page }) => {
).not.toBeVisible();
// logout
await openTopNavigation(page);
await page.getByRole("button", { name: "Logout" }).click();
await expectTopNavigationClosed(page);
const menu = await openMoreMenu(page);
await menu.getByRole("button", { name: "Logout" }).click();
// should be redirected to home page after logout
await expect(page).toHaveURL("/#/");
// login modal
await openTopNavigation(page);
await expect(page.getByRole("button", { name: "Logout" })).not.toBeVisible();
await page.getByRole("link", { name: "Configuration" }).click();
await expectTopNavigationClosed(page);
const menu2 = await openMoreMenu(page);
await expect(menu2.getByRole("button", { name: "Logout" })).not.toBeVisible();
await menu2.getByRole("link", { name: "Configuration" }).click();
const loginNew = page.getByTestId("login-modal");
await expectModalVisible(loginNew);
await loginNew.getByLabel("Administrator Password").fill(newPassword);
@ -171,10 +162,9 @@ test("disable auth", async ({ page }) => {
await expectModalHidden(modal);
// configuration page without login
await openTopNavigation(page);
await expect(page.getByRole("button", { name: "Logout" })).not.toBeVisible();
await page.getByRole("link", { name: "Configuration" }).click();
await expectTopNavigationClosed(page);
const menu = await openMoreMenu(page);
await expect(menu.getByRole("button", { name: "Logout" })).not.toBeVisible();
await menu.getByRole("link", { name: "Configuration" }).click();
await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
await stop();

View file

@ -1,6 +1,6 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl, restart } from "./evcc";
import { openTopNavigation, expectModalVisible, expectModalHidden } from "./utils";
import { openMoreMenu, expectModalVisible, expectModalHidden } from "./utils";
import fs from "fs";
import path from "path";
@ -15,7 +15,7 @@ test.describe("reset", async () => {
await expect(page.getByTestId("sessions-entry")).toHaveCount(4);
// open backup & restore modal
await openTopNavigation(page);
await openMoreMenu(page);
await page.getByRole("link", { name: "Configuration" }).click();
await page.getByRole("button", { name: "Backup & Restore" }).click();
const modal = page.getByTestId("backup-restore-modal");

View file

@ -1,6 +1,5 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { expectModalVisible, expectModalHidden, openTopNavigation } from "./utils";
test.use({ baseURL: baseUrl() });
test.beforeAll(async () => {
@ -12,28 +11,24 @@ test.afterAll(async () => {
test.describe("battery settings co2", async () => {
test("grid charging", async ({ page }) => {
await page.goto("/");
await openTopNavigation(page);
await page.getByTestId("topnavigation-battery").click();
const modal = page.getByTestId("battery-settings-modal");
await expectModalVisible(modal);
await page.goto("/#/battery");
await modal.getByRole("link", { name: "Grid charging" }).click();
await modal.getByLabel("Enable limit").check();
await modal.getByLabel("CO₂ limit").selectOption({ label: "≤ 150 g/kWh" });
await page.getByLabel("Enable limit").check();
await page.getByLabel("CO₂ limit").selectOption({ label: "≤ 150 g/kWh" });
// CO2 demo tariff provides 72 hours, so active hours vary by test execution time
await expect(modal.getByTestId("active-hours").locator(".value")).toHaveText(/^\d+ hr/);
await expect(modal).toContainText("20 g – 150 g");
await page.getByRole("button", { name: "Close" }).click();
await expectModalHidden(modal);
await expect(page.getByTestId("active-hours").locator(".value")).toHaveText(/^\d+ hr/);
await expect(page.locator("body")).toContainText("20 g – 150 g");
// navigate back to main via bottom nav, open via energyflow deep-link
await page.getByRole("link", { name: "Charge" }).click();
await page.getByTestId("energyflow").click();
await page.getByRole("button", { name: "Grid charging: active (≤ 150 g)" }).click();
await expectModalVisible(modal);
await modal.getByLabel("CO₂ limit").selectOption({ label: "≤ 10 g/kWh" });
await expect(modal.getByTestId("active-hours")).toHaveText("Active time");
await modal.getByRole("button", { name: "Close" }).click();
await expectModalHidden(modal);
await expect(modal).not.toBeVisible();
await expect(page).toHaveURL(/#\/battery/);
await page.getByLabel("CO₂ limit").selectOption({ label: "≤ 10 g/kWh" });
await expect(page.getByTestId("active-hours")).toHaveText("Active time");
await page.getByRole("link", { name: "Charge" }).click();
await expect(page.getByRole("button", { name: "Grid charging: when ≤ 10 g" })).toBeVisible();
});
});

View file

@ -1,6 +1,5 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { expectModalVisible, expectModalHidden, openTopNavigation } from "./utils";
test.use({ baseURL: baseUrl() });
test.describe.configure({ mode: "parallel" });
@ -13,56 +12,44 @@ test.afterEach(async () => {
});
test.describe("battery settings", async () => {
test("open modal", async ({ page }) => {
await page.goto("/");
await openTopNavigation(page);
await page.getByTestId("topnavigation-battery").click();
test("battery view", async ({ page }) => {
await page.goto("/#/battery");
const modal = page.getByTestId("battery-settings-modal");
await expectModalVisible(modal);
await expect(modal.getByRole("heading", { name: "Home Battery" })).toBeVisible();
await expect(modal.getByRole("link", { name: "Grid charging" })).toBeVisible();
await expect(modal).toContainText("Battery level: 50%");
await expect(modal).toContainText("10.0 kWh of 20.0 kWh");
await expect(page.getByRole("heading", { name: "Home Battery" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Grid charging" })).toBeVisible();
await expect(page.getByTestId("header")).toContainText("Home Battery");
await expect(page.locator("body")).toContainText("Battery level: 50%");
await expect(page.locator("body")).toContainText("10.0 kWh of 20.0 kWh");
});
test("battery usage", async ({ page }) => {
await page.goto("/");
await openTopNavigation(page);
await page.getByTestId("topnavigation-battery").click();
await page.goto("/#/battery");
const modal = page.getByTestId("battery-settings-modal");
await expectModalVisible(modal);
await modal.locator("#batterySettingsPriority").selectOption({ label: "50%" });
await expect(modal.locator("label[for=batterySettingsPriorityMiddle] span")).toHaveText("50%");
await expect(modal.locator("label[for=batterySettingsPriorityBottom] span")).toHaveText("50%");
await modal.locator("#batterySettingsBufferTop").selectOption({ label: "80%" });
await modal.locator("#batterySettingsBufferStart").selectOption({ label: "when above 90%." });
await expect(modal.locator("label[for=batterySettingsBuffer] span")).toHaveText("80%");
await page.locator("#batterySettingsPriority").selectOption({ label: "50%" });
await expect(page.locator("label[for=batterySettingsPriorityMiddle] span")).toHaveText("50%");
await expect(page.locator("label[for=batterySettingsPriorityBottom] span")).toHaveText("50%");
await page.locator("#batterySettingsBufferTop").selectOption({ label: "80%" });
await page.locator("#batterySettingsBufferStart").selectOption({ label: "when above 90%." });
await expect(page.locator("label[for=batterySettingsBuffer] span")).toHaveText("80%");
});
test("grid charging", async ({ page }) => {
await page.goto("/");
await openTopNavigation(page);
await page.getByTestId("topnavigation-battery").click();
const modal = page.getByTestId("battery-settings-modal");
await expectModalVisible(modal);
await page.goto("/#/battery");
await modal.getByRole("link", { name: "Grid charging" }).click();
await modal.getByLabel("Enable limit").check();
await modal.getByLabel("Price limit").selectOption({ label: "≤ 50.0 ct/kWh" });
await expect(modal.getByTestId("active-hours")).toHaveText(["Active time", "96 hr"].join(""));
await expect(modal).toContainText("5.0 ct – 50.0 ct");
await page.getByRole("button", { name: "Close" }).click();
await expectModalHidden(modal);
await page.getByLabel("Enable limit").check();
await page.getByLabel("Price limit").selectOption({ label: "≤ 50.0 ct/kWh" });
await expect(page.getByTestId("active-hours")).toHaveText(["Active time", "96 hr"].join(""));
await expect(page.locator("body")).toContainText("5.0 ct – 50.0 ct");
await page.getByRole("link", { name: "Charge" }).click();
await page.getByTestId("energyflow").click();
await page.getByRole("button", { name: "Grid charging: active (≤ 50.0 ct)" }).click();
await expectModalVisible(modal);
await modal.getByLabel("Price limit").selectOption({ label: "≤ -10.0 ct/kWh" });
await expect(modal.getByTestId("active-hours")).toHaveText("Active time");
await modal.getByRole("button", { name: "Close" }).click();
await expectModalHidden(modal);
await expect(modal).not.toBeVisible();
await expect(page).toHaveURL(/#\/battery/);
await page.getByLabel("Price limit").selectOption({ label: "≤ -10.0 ct/kWh" });
await expect(page.getByTestId("active-hours")).toHaveText("Active time");
await page.getByRole("link", { name: "Charge" }).click();
await expect(
page.getByRole("button", { name: "Grid charging: when ≤ -10.0 ct" })
).toBeVisible();
@ -79,14 +66,12 @@ test.describe("battery settings", async () => {
await expect(charge).toContainText("Battery charging");
// enable discharge lock
await openTopNavigation(page);
await page.getByTestId("topnavigation-battery").click();
const modal = page.getByTestId("battery-settings-modal");
await expectModalVisible(modal);
await modal.getByLabel("Prevent discharge in fast mode and planned charging.").check();
await page.getByRole("button", { name: "Close" }).click();
await expectModalHidden(modal);
await page.goto("/#/battery");
await page.getByLabel("Prevent discharge in fast mode and planned charging.").check();
await page.waitForLoadState("networkidle");
await page.getByRole("link", { name: "Charge" }).click();
await page.getByTestId("energyflow").click();
await expect(discharge).toContainText("Battery (locked)");
await expect(charge).toContainText("Battery charging");
});

View file

@ -6,7 +6,6 @@ import {
newLoadpoint,
addDemoCharger,
ChargerStatus,
openTopNavigation,
} from "./utils";
test.use({ baseURL: baseUrl() });
@ -93,13 +92,10 @@ test.describe("boost", async () => {
await expectModalHidden(modal);
// enable "Prevent discharge in fast mode"
await openTopNavigation(page);
await page.getByTestId("topnavigation-battery").click();
const batteryModal = page.getByTestId("battery-settings-modal");
await expectModalVisible(batteryModal);
await batteryModal.getByLabel("Prevent discharge in fast mode and planned charging.").check();
await page.getByRole("button", { name: "Close" }).click();
await expectModalHidden(batteryModal);
await page.goto("/#/battery");
await page.getByLabel("Prevent discharge in fast mode and planned charging.").check();
await page.waitForLoadState("networkidle");
await page.goto("/");
// LP2: switch to fast mode → triggers global battery hold
const lp2 = page.getByTestId("loadpoint").nth(1);

View file

@ -32,7 +32,7 @@ test.describe("meter only", async () => {
await expect(page.getByTestId("welcome-banner")).not.toBeVisible();
await expect(page.getByTestId("grid").getByTestId("device-tag-power")).toContainText("1.0 kW");
await page.getByTestId("home-link").click();
await page.getByRole("link", { name: "Charge" }).click();
await expect(page.locator("body")).not.toContainText("Hello aboard!");
await expect(page.getByTestId("energyflow")).toBeVisible();

View file

@ -85,7 +85,7 @@ test.describe("onboarding", async () => {
await expect(restartButton).not.toBeVisible();
// navigate to main screen
await page.getByTestId("home-link").click();
await page.getByRole("link", { name: "Charge" }).click();
// verify configuration
await page.getByTestId("visualization").click();

View file

@ -1,11 +1,6 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import {
expectModalHidden,
expectModalVisible,
openTopNavigation,
expectTopNavigationClosed,
} from "./utils";
import { expectModalHidden, expectModalVisible, openMoreMenu } from "./utils";
const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
const NETWORK_HOST = "somehostname.local";
@ -23,9 +18,8 @@ test.afterAll(async () => {
test.describe("basics", async () => {
test("navigation to config", async ({ page }) => {
await page.goto("/");
await openTopNavigation(page);
await openMoreMenu(page);
await page.getByRole("link", { name: "Configuration" }).click();
await expectTopNavigationClosed(page);
await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
});
});
@ -59,7 +53,7 @@ test.describe("general", async () => {
await expect(page.getByTestId("generalconfig-title")).toContainText("Ahoy World");
// check changed value on main ui
await page.getByTestId("home-link").click();
await page.getByRole("link", { name: "Charge" }).click();
await expect(page.getByRole("heading", { name: "Ahoy World" })).toBeVisible();
});
test("enable experimental", async ({ page }) => {

View file

@ -1,6 +1,6 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { expectModalHidden, openTopNavigation, expectTopNavigationClosed } from "./utils";
import { expectModalHidden, openMoreMenu } from "./utils";
import { ChildProcess } from "child_process";
test.use({ baseURL: baseUrl() });
@ -35,9 +35,8 @@ test.describe("demo mode", async () => {
});
test("auth is locked", async ({ page }) => {
await openTopNavigation(page);
await openMoreMenu(page);
await page.getByRole("link", { name: "Configuration" }).click();
await expectTopNavigationClosed(page);
const loginModal = page.getByTestId("login-modal");
await expect(loginModal).toBeVisible();
await expect(loginModal).toContainText("Login is not supported in demo mode.");

View file

@ -1,6 +1,6 @@
import { test, expect, type Page, type Locator } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { expectModalVisible, expectModalHidden, openTopNavigation, dragElement } from "./utils";
import { expectModalVisible, expectModalHidden, openMoreMenu, dragElement } from "./utils";
const CONFIG_LOADPOINT_SORT = "loadpoint-sort.evcc.yaml";
@ -20,8 +20,8 @@ test.beforeEach(async ({ page }) => {
});
async function openModal(page: Page) {
await openTopNavigation(page);
await page.getByTestId("topnavigation-settings").click();
const menu = await openMoreMenu(page);
await menu.getByRole("button", { name: "User Interface" }).click();
const modal = page.getByTestId("global-settings-modal");
await expectModalVisible(modal);
return modal;

View file

@ -1,6 +1,6 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { openTopNavigation, expectTopNavigationClosed } from "./utils";
import { openMoreMenu } from "./utils";
test.use({ baseURL: baseUrl() });
@ -14,17 +14,16 @@ test.afterAll(async () => {
test.describe("opening logs", async () => {
test("via config", async ({ page }) => {
await page.goto("/");
await openTopNavigation(page);
await page.getByRole("link", { name: "Configuration" }).click();
await expectTopNavigationClosed(page);
await page.getByRole("link", { name: "Logs" }).click();
const menu = await openMoreMenu(page);
await menu.getByRole("link", { name: "Configuration" }).click();
await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
await page.getByTestId("config-system").getByRole("link", { name: "Logs" }).click();
await expect(page.getByRole("heading", { name: "Logs", exact: false })).toBeVisible();
});
test("via top navigation", async ({ page }) => {
test("via more menu", async ({ page }) => {
await page.goto("/");
await openTopNavigation(page);
await page.getByRole("link", { name: "Logs" }).click();
await expectTopNavigationClosed(page);
const menu = await openMoreMenu(page);
await menu.getByRole("link", { name: "Logs" }).click();
await expect(page.getByRole("heading", { name: "Logs", exact: false })).toBeVisible();
});
test("via notifications", async ({ page }) => {
@ -36,9 +35,8 @@ test.describe("opening logs", async () => {
});
test("via need help", async ({ page }) => {
await page.goto("/");
await openTopNavigation(page);
await page.getByRole("button", { name: "Need Help?" }).click();
await expectTopNavigationClosed(page);
const menu = await openMoreMenu(page);
await menu.getByRole("button", { name: "Need Help?" }).click();
await page.getByRole("link", { name: "View logs" }).click();
await expect(page.getByRole("heading", { name: "Logs", exact: false })).toBeVisible();
});

View file

@ -1,7 +1,7 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { startSimulator, stopSimulator, simulatorConfig } from "./simulator";
import { openTopNavigation, expectTopNavigationClosed, closeTopNavigation } from "./utils";
import { openMoreMenu } from "./utils";
const BASICS_CONFIG = "basics.evcc.yaml";
@ -29,10 +29,11 @@ test.describe("Basics", async () => {
await page.goto(route.path);
await expect(page.getByRole("heading", { name: route.title || title })).toBeVisible();
await openTopNavigation(page);
await expect(page.getByRole("button", { name: "User Interface" })).toBeVisible();
await expect(page.getByRole("button", { name: "Home Battery" })).not.toBeVisible();
await expect(page.getByRole("button", { name: "Need help?" })).toBeVisible();
// no battery tab when battery is not configured
await expect(page.getByRole("link", { name: "Battery" })).not.toBeVisible();
const menu = await openMoreMenu(page);
await expect(menu.getByRole("button", { name: "User Interface" })).toBeVisible();
await expect(menu.getByRole("button", { name: "Need Help?" })).toBeVisible();
}
});
@ -40,9 +41,8 @@ test.describe("Basics", async () => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Hello World" })).toBeVisible();
await openTopNavigation(page);
await page.getByRole("button", { name: "Need help?" }).click();
await expectTopNavigationClosed(page);
const menu = await openMoreMenu(page);
await menu.getByRole("button", { name: "Need Help?" }).click();
await expect(page.getByRole("heading", { name: "Need help?" })).toBeVisible();
});
@ -50,9 +50,8 @@ test.describe("Basics", async () => {
test("User Interface", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Hello World" })).toBeVisible();
await openTopNavigation(page);
await page.getByRole("button", { name: "User Interface" }).click();
await expectTopNavigationClosed(page);
const menu = await openMoreMenu(page);
await menu.getByRole("button", { name: "User Interface" }).click();
await expect(page.getByRole("heading", { name: "User Interface" })).toBeVisible();
});
@ -76,20 +75,18 @@ test.describe("Advanced", async () => {
await page.goto(route.path);
await expect(page.getByRole("heading", { name: route.title || title })).toBeVisible();
await openTopNavigation(page);
await expect(page.getByRole("button", { name: "User Interface" })).toBeVisible();
await expect(page.getByRole("button", { name: "Home Battery" })).toBeVisible();
await expect(page.getByRole("button", { name: "Need help?" })).toBeVisible();
await closeTopNavigation(page);
// battery tab visible when battery is configured
await expect(page.getByRole("link", { name: "Battery" })).toBeVisible();
const menu = await openMoreMenu(page);
await expect(menu.getByRole("button", { name: "User Interface" })).toBeVisible();
await expect(menu.getByRole("button", { name: "Need Help?" })).toBeVisible();
}
});
test("Home Battery from top navigation", async ({ page }) => {
test("Home Battery from bottom nav", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: title })).toBeVisible();
await openTopNavigation(page);
await page.getByRole("button", { name: "Home Battery" }).click();
await expectTopNavigationClosed(page);
await page.getByRole("link", { name: "Battery" }).click();
await expect(page.getByRole("heading", { name: "Home Battery" })).toBeVisible();
});

View file

@ -1,6 +1,6 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import { enableExperimental, expectModalHidden, expectModalVisible } from "./utils";
import { expectModalHidden, expectModalVisible } from "./utils";
test.use({ baseURL: baseUrl() });
@ -15,13 +15,6 @@ test.describe("bottom navigation", async () => {
await page.goto("/");
const bottomNav = page.getByTestId("bottom-tab-bar");
// bottom nav not visible without experimental
await expect(bottomNav).not.toBeVisible();
// enable experimental feature
await enableExperimental(page);
await page.goto("/");
await expect(bottomNav).toBeVisible();
const tabCharge = bottomNav.getByRole("link", { name: "Charge" });

View file

@ -1,11 +1,6 @@
import { test, expect, devices, type Page } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import {
expectModalVisible,
expectModalHidden,
openTopNavigation,
expectTopNavigationClosed,
} from "./utils";
import { expectModalVisible, expectModalHidden } from "./utils";
test.use({ baseURL: baseUrl() });
@ -38,12 +33,7 @@ async function selectVehicleFilter(page: Page, value: string) {
test.describe("basics", async () => {
test("navigation to sessions", async ({ page }) => {
await page.goto("/");
await openTopNavigation(page);
await page
.getByTestId("topnavigation-dropdown")
.getByRole("link", { name: "Charging sessions" })
.click();
await expectTopNavigationClosed(page);
await page.getByRole("link", { name: "Sessions" }).click();
await expect(page.getByRole("heading", { name: "Charging Sessions" })).toBeVisible();
});
test("month without data", async ({ page }) => {

View file

@ -1,6 +1,5 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { enableExperimental } from "./utils";
test.use({ baseURL: baseUrl() });
@ -108,7 +107,6 @@ test.describe("statistics values", async () => {
test.describe("header savings", async () => {
test("savings info in header and indicator persistence", async ({ page }) => {
await enableExperimental(page);
await page.goto("/");
// savings button visible in header

View file

@ -1,40 +1,10 @@
import { expect, type Page, type Locator } from "@playwright/test";
export async function enableExperimental(page: Page): Promise<void> {
await page.goto("/#/config");
await page
.getByTestId("generalconfig-experimental")
.getByRole("button", { name: "edit" })
.click();
const modal = page.getByTestId("experimental-modal");
await expectModalVisible(modal);
await modal.getByLabel("Enable experimental features.").click();
await modal.getByRole("button", { name: "Close" }).click();
await expectModalHidden(modal);
await expect(page.locator(".modal-backdrop")).not.toBeVisible();
}
export async function openTopNavigation(page: Page): Promise<void> {
await expect(page.getByTestId("topnavigation-button")).toBeVisible();
await page.getByTestId("topnavigation-button").click();
await expectTopNavigationOpened(page);
}
export async function closeTopNavigation(page: Page): Promise<void> {
await expectTopNavigationOpened(page);
await page.getByTestId("topnavigation-button").click();
await expectTopNavigationClosed(page);
}
export async function expectTopNavigationOpened(page: Page): Promise<void> {
await expect(page.getByTestId("topnavigation-dropdown")).toBeVisible();
await expect(page.getByTestId("topnavigation-button")).toHaveAttribute("aria-expanded", "true");
}
export async function expectTopNavigationClosed(page: Page): Promise<void> {
await expect(page.getByTestId("topnavigation-button")).toHaveAttribute("aria-expanded", "false");
await expect(page.getByTestId("topnavigation-dropdown")).not.toBeVisible();
export async function openMoreMenu(page: Page): Promise<Locator> {
const moreButton = page.getByTestId("tab-more");
await expect(moreButton).toBeVisible();
await moreButton.click();
return moreButton;
}
export async function expectModalVisible(modal: Locator): Promise<void> {