UI: make loadpoints sortable and hideable (#23621)

Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
Thomas Speer 2025-09-29 13:05:29 +02:00 • committed by GitHub
parent 87d66ed98c
commit 5617c19bb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 908 additions and 224 deletions

View file

@ -198,7 +198,9 @@ This file provides guidance to AI coding agents when working with code in this r
- **Examples**: - **Examples**:
- `page.getByRole("button", { name: "Add charger" })` - `page.getByRole("button", { name: "Add charger" })`
- `page.getByLabel("Manufacturer").selectOption("Demo charger")` - `page.getByLabel("Manufacturer").selectOption("Demo charger")`
- `page.getByRole("listitem", { name: "Draggable: First Loadpoint" })` (using aria-label)
- `page.getByTestId("loadpoint")` (fallback only) - `page.getByTestId("loadpoint")` (fallback only)
- never use `.locator()` or `class` and `id`-based selectors
### Test Patterns ### Test Patterns

View file

@ -296,6 +296,11 @@ a:hover {
outline-width: var(--bs-focus-ring-width); outline-width: var(--bs-focus-ring-width);
} }
.btn-link:disabled {
border-color: transparent !important;
opacity: 0.2;
}
.dark .btn-outline-secondary { .dark .btn-outline-secondary {
--bs-btn-color: var(--bs-gray-bright); --bs-btn-color: var(--bs-gray-bright);
--bs-btn-border-color: var(--bs-gray-bright); --bs-btn-border-color: var(--bs-gray-bright);

View file

@ -19,7 +19,7 @@
test-id="generalconfig-telemetry" test-id="generalconfig-telemetry"
:label="$t('config.general.telemetry')" :label="$t('config.general.telemetry')"
:text="$t(`config.general.${telemetryEnabled ? 'on' : 'off'}`)" :text="$t(`config.general.${telemetryEnabled ? 'on' : 'off'}`)"
modal-id="globalSettingsModal" modal-id="telemetryModal"
/> />
<GeneralConfigEntry <GeneralConfigEntry

View file

@ -0,0 +1,27 @@
<template>
<GenericModal
id="telemetryModal"
:title="$t('config.telemetry.title')"
size="lg"
data-testid="telemetry-modal"
>
<p>{{ $t("config.telemetry.description") }}</p>
<TelemetrySettings :sponsorActive="sponsor && !!sponsor.name" :telemetry="telemetry" />
</GenericModal>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import GenericModal from "../Helper/GenericModal.vue";
import TelemetrySettings from "../TelemetrySettings.vue";
import type { Sponsor } from "../../types/evcc";
export default defineComponent({
name: "TelemetryModal",
components: { GenericModal, TelemetrySettings },
props: {
sponsor: { type: Object as PropType<Sponsor> },
telemetry: { type: Boolean },
},
});
</script>

View file

@ -7,7 +7,7 @@ export default {
component: Energyflow, component: Energyflow,
} as Meta<typeof Energyflow>; } as Meta<typeof Energyflow>;
const Template: StoryFn<typeof Energyflow> = (args) => ({ const Template: StoryFn<typeof Energyflow> = (args: any) => ({
components: { Energyflow }, components: { Energyflow },
setup() { setup() {
return { args }; return { args };
@ -22,39 +22,33 @@ GridAndPV.args = {
pvPower: 7300, pvPower: 7300,
gridPower: -2300, gridPower: -2300,
homePower: 800, homePower: 800,
loadpointsCompact: [ loadpoints: [
{ {
power: 1000,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 1000, chargePower: 1000,
connected: true, connected: true,
index: 0,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
{ {
power: 1000,
icon: "bike", icon: "bike",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 1000, chargePower: 1000,
connected: true, connected: true,
index: 1,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
{ {
power: 2200,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 2200, chargePower: 2200,
connected: true, connected: true,
index: 2,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
@ -72,7 +66,7 @@ GridAndPV.args = {
dayAfterTomorrow: { energy: 1000, complete: false }, dayAfterTomorrow: { energy: 1000, complete: false },
}, },
}, },
}; } as any;
export const BatteryAndGrid = Template.bind({}); export const BatteryAndGrid = Template.bind({});
BatteryAndGrid.args = { BatteryAndGrid.args = {
@ -91,7 +85,7 @@ BatteryAndGrid.args = {
{ soc: 44.999, capacity: 13.3, power: 350, controllable: true }, { soc: 44.999, capacity: 13.3, power: 350, controllable: true },
{ soc: 82.3331, capacity: 21, power: 450, controllable: false }, { soc: 82.3331, capacity: 21, power: 450, controllable: false },
], ],
}; } as any;
export const BatteryCharging = Template.bind({}); export const BatteryCharging = Template.bind({});
BatteryCharging.args = { BatteryCharging.args = {
@ -101,15 +95,13 @@ BatteryCharging.args = {
pvPower: 5000, pvPower: 5000,
gridPower: -1300, gridPower: -1300,
homePower: 800, homePower: 800,
loadpointsCompact: [ loadpoints: [
{ {
power: 1400,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 1400, chargePower: 1400,
connected: true, connected: true,
index: 0,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
@ -117,7 +109,7 @@ BatteryCharging.args = {
], ],
batteryPower: -1500, batteryPower: -1500,
batterySoc: 75, batterySoc: 75,
}; } as any;
export const GridPVAndBattery = Template.bind({}); export const GridPVAndBattery = Template.bind({});
GridPVAndBattery.args = { GridPVAndBattery.args = {
@ -129,7 +121,7 @@ GridPVAndBattery.args = {
homePower: 3300, homePower: 3300,
batteryPower: 1500, batteryPower: 1500,
batterySoc: 30, batterySoc: 30,
}; } as any;
export const BatteryThresholds = Template.bind({}); export const BatteryThresholds = Template.bind({});
BatteryThresholds.args = { BatteryThresholds.args = {
@ -138,27 +130,23 @@ BatteryThresholds.args = {
batteryConfigured: true, batteryConfigured: true,
pvPower: 8700, pvPower: 8700,
gridPower: -500, gridPower: -500,
loadpointsCompact: [ loadpoints: [
{ {
power: 5000,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 5000, chargePower: 5000,
connected: true, connected: true,
index: 0,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
{ {
power: 2500,
icon: "bus", icon: "bus",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 2500, chargePower: 2500,
connected: true, connected: true,
index: 1,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
@ -166,7 +154,7 @@ BatteryThresholds.args = {
], ],
batteryPower: -700, batteryPower: -700,
batterySoc: 95, batterySoc: 95,
}; } as any;
export const PVThresholds = Template.bind({}); export const PVThresholds = Template.bind({});
PVThresholds.args = { PVThresholds.args = {
@ -176,27 +164,23 @@ PVThresholds.args = {
pvPower: 300, pvPower: 300,
gridPower: 6500, gridPower: 6500,
homePower: 1000, homePower: 1000,
loadpointsCompact: [ loadpoints: [
{ {
power: 5000,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 5000, chargePower: 5000,
connected: true, connected: true,
index: 0,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
{ {
power: 1600,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 1600, chargePower: 1600,
connected: true, connected: true,
index: 1,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
@ -204,7 +188,7 @@ PVThresholds.args = {
], ],
batteryPower: 800, batteryPower: 800,
batterySoc: 76, batterySoc: 76,
}; } as any;
export const GridOnly = Template.bind({}); export const GridOnly = Template.bind({});
GridOnly.args = { GridOnly.args = {
@ -214,51 +198,43 @@ GridOnly.args = {
pvPower: 0, pvPower: 0,
gridPower: 6500, gridPower: 6500,
homePower: 1000, homePower: 1000,
loadpointsCompact: [ loadpoints: [
{ {
power: 5500,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 5500, chargePower: 5500,
connected: true, connected: true,
index: 0,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
{ {
power: 0,
icon: "car", icon: "car",
charging: false, charging: false,
title: "Garage", title: "Garage",
chargePower: 0, chargePower: 0,
connected: false, connected: false,
index: 1,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
{ {
power: 0,
icon: "car", icon: "car",
charging: false, charging: false,
title: "Garage", title: "Garage",
chargePower: 0, chargePower: 0,
connected: false, connected: false,
index: 2,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
{ {
power: 0,
icon: "car", icon: "car",
charging: false, charging: false,
title: "Garage", title: "Garage",
chargePower: 0, chargePower: 0,
connected: false, connected: false,
index: 3,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
@ -266,7 +242,7 @@ GridOnly.args = {
], ],
batteryPower: 0, batteryPower: 0,
batterySoc: 0, batterySoc: 0,
}; } as any;
export const LowPower = Template.bind({}); export const LowPower = Template.bind({});
LowPower.args = { LowPower.args = {
@ -281,7 +257,7 @@ LowPower.args = {
tariffGrid: 0.25, tariffGrid: 0.25,
tariffFeedIn: 0.08, tariffFeedIn: 0.08,
currency: CURRENCY.EUR, currency: CURRENCY.EUR,
}; } as any;
export const CO2 = Template.bind({}); export const CO2 = Template.bind({});
CO2.args = { CO2.args = {
@ -290,39 +266,33 @@ CO2.args = {
pvPower: 7300, pvPower: 7300,
gridPower: -2300, gridPower: -2300,
homePower: 800, homePower: 800,
loadpointsCompact: [ loadpoints: [
{ {
power: 1000,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 1000, chargePower: 1000,
connected: true, connected: true,
index: 0,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
{ {
power: 1000,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 1000, chargePower: 1000,
connected: true, connected: true,
index: 1,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
{ {
power: 2200,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 2200, chargePower: 2200,
connected: true, connected: true,
index: 2,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
@ -334,7 +304,7 @@ CO2.args = {
smartCostType: "co2", smartCostType: "co2",
currency: CURRENCY.EUR, currency: CURRENCY.EUR,
pv: [{ power: 5000 }, { power: 2300 }], pv: [{ power: 5000 }, { power: 2300 }],
}; } as any;
export const UnknownInput = Template.bind({}); export const UnknownInput = Template.bind({});
UnknownInput.args = { UnknownInput.args = {
@ -342,21 +312,19 @@ UnknownInput.args = {
pvConfigured: true, pvConfigured: true,
pvPower: 2000, pvPower: 2000,
gridPower: -2000, gridPower: -2000,
loadpointsCompact: [ loadpoints: [
{ {
power: 1000,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 1000, chargePower: 1000,
connected: true, connected: true,
index: 0,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
], ],
}; } as any;
export const UnknownInputFill = Template.bind({}); export const UnknownInputFill = Template.bind({});
UnknownInputFill.args = { UnknownInputFill.args = {
@ -366,8 +334,8 @@ UnknownInputFill.args = {
pvPower: 500, pvPower: 500,
gridPower: 0, gridPower: 0,
batteryPower: -1000, batteryPower: -1000,
loadpointsCompact: [], loadpoints: [],
}; } as any;
export const UnknownOutput = Template.bind({}); export const UnknownOutput = Template.bind({});
UnknownOutput.args = { UnknownOutput.args = {
@ -375,21 +343,19 @@ UnknownOutput.args = {
pvConfigured: true, pvConfigured: true,
pvPower: 3000, pvPower: 3000,
gridPower: -1000, gridPower: -1000,
loadpointsCompact: [ loadpoints: [
{ {
power: 1700, chargePower: 1700,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 1700,
connected: true, connected: true,
index: 0,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
], ],
}; } as any;
export const UnknownOutputLessThan10Percent = Template.bind({}); export const UnknownOutputLessThan10Percent = Template.bind({});
UnknownOutputLessThan10Percent.args = { UnknownOutputLessThan10Percent.args = {
@ -397,18 +363,16 @@ UnknownOutputLessThan10Percent.args = {
pvConfigured: true, pvConfigured: true,
pvPower: 3000, pvPower: 3000,
gridPower: -1000, gridPower: -1000,
loadpointsCompact: [ loadpoints: [
{ {
power: 1800, chargePower: 1800,
icon: "car", icon: "car",
charging: true, charging: true,
title: "Garage", title: "Garage",
chargePower: 1800,
connected: true, connected: true,
index: 0,
vehicleName: "", vehicleName: "",
vehicleSoc: 50, vehicleSoc: 50,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}, },
], ],
}; } as any;

View file

@ -11,7 +11,7 @@
:gridImport="gridImport" :gridImport="gridImport"
:selfPv="selfPv" :selfPv="selfPv"
:selfBattery="selfBattery" :selfBattery="selfBattery"
:loadpoints="loadpointsCompact" :loadpoints="loadpoints"
:pvExport="pvExport" :pvExport="pvExport"
:batteryCharge="batteryCharge" :batteryCharge="batteryCharge"
:batteryDischarge="batteryDischarge" :batteryDischarge="batteryDischarge"
@ -194,14 +194,18 @@
<template v-if="activeLoadpointsCount > 0" #expanded> <template v-if="activeLoadpointsCount > 0" #expanded>
<EnergyflowEntry <EnergyflowEntry
v-for="lp in activeLoadpoints" v-for="lp in activeLoadpoints"
:key="lp.index" :key="lp.id"
:name="lp.title" :name="lp.displayTitle"
:power="lp.power" :power="lp.chargePower"
:powerUnit="powerUnit" :powerUnit="powerUnit"
icon="vehicle" icon="vehicle"
:iconProps="{ names: [lp.icon] }" :iconProps="{ names: [lp.icon] }"
:details="lp.soc || undefined" :details="lp.vehicleSoc || undefined"
:detailsFmt="lp.heating ? fmtLoadpointTemp : fmtLoadpointSoc" :detailsFmt="
lp.chargerFeatureHeating
? fmtLoadpointTemp
: fmtLoadpointSoc
"
/> />
</template> </template>
</EnergyflowEntry> </EnergyflowEntry>
@ -289,7 +293,7 @@ import {
type Battery, type Battery,
type CURRENCY, type CURRENCY,
type Forecast, type Forecast,
type LoadpointCompact, type UiLoadpoint,
} from "@/types/evcc"; } from "@/types/evcc";
export default defineComponent({ export default defineComponent({
@ -307,7 +311,7 @@ export default defineComponent({
pvConfigured: Boolean, pvConfigured: Boolean,
pv: { type: Array as PropType<Pv[]> }, pv: { type: Array as PropType<Pv[]> },
pvPower: { type: Number, default: 0 }, pvPower: { type: Number, default: 0 },
loadpointsCompact: { type: Array as PropType<LoadpointCompact[]>, default: () => [] }, loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
batteryConfigured: { type: Boolean }, batteryConfigured: { type: Boolean },
battery: { type: Array as PropType<Battery[]> }, battery: { type: Array as PropType<Battery[]> },
batteryPower: { type: Number, default: 0 }, batteryPower: { type: Number, default: 0 },
@ -365,7 +369,7 @@ export default defineComponent({
return Math.min(this.batteryDischarge, this.consumption - this.selfPv); return Math.min(this.batteryDischarge, this.consumption - this.selfPv);
}, },
activeLoadpoints() { activeLoadpoints() {
return this.loadpointsCompact.filter((lp) => lp.charging); return this.loadpoints.filter((lp) => lp.charging);
}, },
activeLoadpointsCount() { activeLoadpointsCount() {
return this.activeLoadpoints.length; return this.activeLoadpoints.length;
@ -377,8 +381,8 @@ export default defineComponent({
return ["car"]; return ["car"];
}, },
loadpointsPower() { loadpointsPower() {
return this.loadpointsCompact.reduce((sum, lp) => { return this.loadpoints.reduce((sum, lp) => {
return sum + (lp.power || 0); return sum + (lp.chargePower || 0);
}, 0); }, 0);
}, },
pvExport() { pvExport() {

View file

@ -86,7 +86,7 @@
<LabelBar <LabelBar
v-for="(lp, index) in loadpoints" v-for="(lp, index) in loadpoints"
:key="index" :key="index"
v-bind="labelBarProps('bottom', 'loadpoints', lp.power)" v-bind="labelBarProps('bottom', 'loadpoints', lp.chargePower)"
> >
<VehicleIcon :names="[lp.icon]" /> <VehicleIcon :names="[lp.icon]" />
</LabelBar> </LabelBar>
@ -116,7 +116,7 @@ import QuestionIcon from "../MaterialIcon/Question.vue";
import "@h2d2/shopicons/es/regular/sun"; import "@h2d2/shopicons/es/regular/sun";
import "@h2d2/shopicons/es/regular/home"; import "@h2d2/shopicons/es/regular/home";
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import type { LoadpointCompact } from "@/types/evcc"; import type { UiLoadpoint } from "@/types/evcc";
export default defineComponent({ export default defineComponent({
name: "Visualization", name: "Visualization",
@ -127,7 +127,7 @@ export default defineComponent({
selfPv: { type: Number, default: 0 }, selfPv: { type: Number, default: 0 },
selfBattery: { type: Number, default: 0 }, selfBattery: { type: Number, default: 0 },
pvExport: { type: Number, default: 0 }, pvExport: { type: Number, default: 0 },
loadpoints: { type: Array as PropType<LoadpointCompact[]>, default: () => [] }, loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
batteryCharge: { type: Number, default: 0 }, batteryCharge: { type: Number, default: 0 },
batteryDischarge: { type: Number, default: 0 }, batteryDischarge: { type: Number, default: 0 },
batteryHold: { type: Boolean, default: false }, batteryHold: { type: Boolean, default: false },

View file

@ -4,7 +4,7 @@
:title="$t('settings.title')" :title="$t('settings.title')"
data-testid="global-settings-modal" data-testid="global-settings-modal"
> >
<UserInterfaceSettings :sponsor="sponsor" :telemetry="telemetry" /> <UserInterfaceSettings :loadpoints="uiLoadpoints" />
</GenericModal> </GenericModal>
</template> </template>
@ -12,14 +12,13 @@
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import GenericModal from "../Helper/GenericModal.vue"; import GenericModal from "../Helper/GenericModal.vue";
import UserInterfaceSettings from "./UserInterfaceSettings.vue"; import UserInterfaceSettings from "./UserInterfaceSettings.vue";
import type { Sponsor } from "@/types/evcc"; import type { UiLoadpoint } from "@/types/evcc";
export default defineComponent({ export default defineComponent({
name: "GlobalSettingsModal", name: "GlobalSettingsModal",
components: { GenericModal, UserInterfaceSettings }, components: { GenericModal, UserInterfaceSettings },
props: { props: {
sponsor: { type: Object as PropType<Sponsor>, default: () => ({}) }, uiLoadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
telemetry: Boolean,
}, },
}); });
</script> </script>

View file

@ -0,0 +1,101 @@
<template>
<div>
<div class="small text-muted mb-2 col-form-label">
{{ $t("settings.loadpoints.help") }}
</div>
<DragDropList :values="loadpoints.map((item) => item.id)" @reorder="handleReorder">
<DragDropItem
v-for="item in loadpoints"
:key="item.id"
:title="item.title"
:visible="item.visible"
>
<template #actions>
<div class="form-check form-switch">
<input
:id="`loadpoint-visible-${item.id}`"
v-model="item.visible"
class="form-check-input"
type="checkbox"
role="switch"
:aria-label="getVisibilityLabel(item)"
:disabled="isLastVisible(item)"
@change="updateVisibility(item.id, item.visible)"
/>
</div>
</template>
</DragDropItem>
</DragDropList>
<div class="mt-2 text-end">
<button
type="button"
class="btn btn-link btn-sm text-muted"
:disabled="resetDisabled"
@click="resetOrder"
>
{{ $t("config.general.reset") }}
</button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import type { UiLoadpoint } from "@/types/evcc";
import {
setLoadpointOrder,
setLoadpointVisibility,
resetLoadpointsOrder,
resetLoadpointsVisible,
} from "@/uiLoadpoints";
import DragDropList from "@/components/Helper/DragDropList.vue";
import DragDropItem from "@/components/Helper/DragDropItem.vue";
export default defineComponent({
name: "LoadpointOrderSettings",
components: { DragDropList, DragDropItem },
props: {
loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
},
computed: {
resetDisabled() {
const allVisible = this.loadpoints.every((lp) => lp.visible);
const noOrder = this.loadpoints.every((lp) => lp.order === null);
return allVisible && noOrder;
},
visibleCount() {
return this.loadpoints.filter((lp) => lp.visible).length;
},
},
methods: {
isLastVisible(item: UiLoadpoint) {
return item.visible && this.visibleCount <= 1;
},
getVisibilityLabel(item: UiLoadpoint) {
const action = item.visible ? "hide" : "show";
return this.$t(`settings.loadpoints.${action}`, { title: item.title });
},
updateVisibility(loadpointId: string, visible: boolean) {
setLoadpointVisibility(loadpointId, visible);
},
handleReorder(newOrder: string[]) {
setLoadpointOrder(newOrder);
},
resetOrder() {
resetLoadpointsOrder();
resetLoadpointsVisible();
},
},
});
</script>
<style scoped>
.form-check-input {
margin-top: 0;
}
.form-check {
display: flex;
align-items: center;
}
</style>

View file

@ -59,12 +59,20 @@
equal-width equal-width
/> />
</FormRow> </FormRow>
<FormRow id="telemetryEnabled" :label="$t('settings.telemetry.label')"> <FormRow v-if="loadpoints.length" :label="$t('settings.loadpoints.label')">
<TelemetrySettings <LoadpointOrderSettings :loadpoints="loadpoints" />
:sponsorActive="sponsor && !!sponsor.name" </FormRow>
:telemetry="telemetry" <FormRow v-if="fullscreenAvailable" :label="$t('settings.fullscreen.label')">
class="mt-1 mb-0" <button
/> v-if="fullscreenActive"
class="btn btn-sm btn-outline-secondary"
@click="exitFullscreen"
>
{{ $t("settings.fullscreen.exit") }}
</button>
<button v-else class="btn btn-sm btn-outline-secondary" @click="enterFullscreen">
{{ $t("settings.fullscreen.enter") }}
</button>
</FormRow> </FormRow>
<FormRow id="hiddenFeaturesEnabled" :label="`${$t('settings.hiddenFeatures.label')} 🧪`"> <FormRow id="hiddenFeaturesEnabled" :label="`${$t('settings.hiddenFeatures.label')} 🧪`">
<div class="form-check form-switch my-1"> <div class="form-check form-switch my-1">
@ -82,25 +90,16 @@
</div> </div>
</div> </div>
</FormRow> </FormRow>
<FormRow v-if="fullscreenAvailable" :label="$t('settings.fullscreen.label')"> <div class="small text-muted mb-3">
<button {{ $t("settings.deviceInfo") }}
v-if="fullscreenActive" </div>
class="btn btn-sm btn-outline-secondary"
@click="exitFullscreen"
>
{{ $t("settings.fullscreen.exit") }}
</button>
<button v-else class="btn btn-sm btn-outline-secondary" @click="enterFullscreen">
{{ $t("settings.fullscreen.enter") }}
</button>
</FormRow>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import TelemetrySettings from "../TelemetrySettings.vue";
import FormRow from "../Helper/FormRow.vue"; import FormRow from "../Helper/FormRow.vue";
import SelectGroup from "../Helper/SelectGroup.vue"; import SelectGroup from "../Helper/SelectGroup.vue";
import LoadpointOrderSettings from "./LoadpointOrderSettings.vue";
import { import {
getLocalePreference, getLocalePreference,
setLocalePreference, setLocalePreference,
@ -112,17 +111,16 @@ import { getUnits, setUnits, is12hFormat, set12hFormat } from "@/units";
import { getHiddenFeatures, setHiddenFeatures } from "@/featureflags.ts"; import { getHiddenFeatures, setHiddenFeatures } from "@/featureflags.ts";
import { isApp } from "@/utils/native"; import { isApp } from "@/utils/native";
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import { LENGTH_UNIT, THEME, type Sponsor } from "@/types/evcc"; import { LENGTH_UNIT, THEME, type UiLoadpoint } from "@/types/evcc";
const TIME_12H = "12"; const TIME_12H = "12";
const TIME_24H = "24"; const TIME_24H = "24";
export default defineComponent({ export default defineComponent({
name: "UserInterfaceSettings", name: "UserInterfaceSettings",
components: { TelemetrySettings, FormRow, SelectGroup }, components: { FormRow, SelectGroup, LoadpointOrderSettings },
props: { props: {
sponsor: Object as PropType<Sponsor>, loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
telemetry: Boolean,
}, },
data() { data() {
return { return {
@ -184,6 +182,7 @@ export default defineComponent({
document.removeEventListener("fullscreenchange", this.fullscreenChange); document.removeEventListener("fullscreenchange", this.fullscreenChange);
}, },
methods: { methods: {
isApp,
enterFullscreen() { enterFullscreen() {
document.documentElement.requestFullscreen(); document.documentElement.requestFullscreen();
}, },

View file

@ -0,0 +1,76 @@
<template>
<div
class="drag-drop-item d-flex align-items-center p-2 mb-2 border rounded"
:class="{
'drag-drop-item--hidden': !visible,
}"
role="listitem"
:aria-label="$t('config.general.dragItem', { title })"
tabindex="0"
>
<div
class="drag-handle me-2"
:aria-label="$t('config.general.dragHandle')"
role="button"
tabindex="-1"
>
<shopicon-regular-menu></shopicon-regular-menu>
</div>
<div class="flex-grow-1">
<slot>
{{ title }}
</slot>
</div>
<div v-if="$slots['actions']" class="drag-drop-item__actions">
<slot name="actions"></slot>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import "@h2d2/shopicons/es/regular/menu";
export default defineComponent({
name: "DragDropItem",
props: {
title: { type: String, default: "" },
visible: { type: Boolean, default: true },
},
});
</script>
<style scoped>
.drag-drop-item {
cursor: move;
user-select: none;
background-color: var(--evcc-box);
border-color: var(--bs-border-color-translucent) !important;
transition: all var(--evcc-transition-fast) ease-in-out;
transform: translate(0, 0);
}
.drag-drop-item--hidden {
opacity: 0.6;
}
.drag-handle {
color: var(--bs-secondary);
opacity: 0.7;
transition: opacity var(--evcc-transition-fast) ease-in-out;
cursor: grab;
}
.drag-handle:active {
cursor: grabbing;
}
.drag-drop-item:hover .drag-handle {
opacity: 1;
}
.drag-drop-item__actions {
display: flex;
align-items: center;
}
</style>

View file

@ -0,0 +1,37 @@
<template>
<div ref="container" role="list" :aria-label="$t('config.general.dragList')">
<slot />
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import { dragAndDrop } from "@formkit/drag-and-drop";
export default defineComponent({
name: "DragDropList",
props: {
values: { type: Array, required: true },
},
emits: ["reorder"],
mounted() {
const container = this.$refs["container"] as HTMLElement;
if (container) {
dragAndDrop({
parent: container,
getValues: () => this.values,
setValues: (newOrder: unknown[]) => this.$emit("reorder", newOrder),
});
}
},
});
</script>
<style>
/* Overwrites Animation for Drag-Elements */
.drag-drop-item[style*="position: fixed"],
.drag-drop-item[style*="z-index: 9999"] {
transition: none !important;
animation: none !important;
}
</style>

View file

@ -1,6 +1,12 @@
import Loadpoint from "./Loadpoint.vue"; import Loadpoint from "./Loadpoint.vue";
import type { Meta, StoryFn } from "@storybook/vue3"; import type { Meta, StoryFn } from "@storybook/vue3";
import { SMART_COST_TYPE } from "@/types/evcc"; import {
SMART_COST_TYPE,
CHARGE_MODE,
CHARGER_STATUS_REASON,
PHASE_ACTION,
PV_ACTION,
} from "@/types/evcc";
export default { export default {
title: "Loadpoints/Loadpoint", title: "Loadpoints/Loadpoint",
@ -12,9 +18,9 @@ export default {
// Based on actual API state structure from demo.evcc.io // Based on actual API state structure from demo.evcc.io
const baseState = { const baseState = {
id: 1, id: "1",
title: "Carport", title: "Carport",
mode: "pv", mode: CHARGE_MODE.PV,
enabled: true, enabled: true,
connected: true, connected: true,
socBasedCharging: true, socBasedCharging: true,
@ -50,9 +56,9 @@ const baseState = {
planProjectedStart: undefined, planProjectedStart: undefined,
planTime: undefined, planTime: undefined,
priority: 0, priority: 0,
phaseAction: "inactive", phaseAction: PHASE_ACTION.INACTIVE,
phaseRemaining: 0, phaseRemaining: 0,
pvAction: "inactive", pvAction: PV_ACTION.INACTIVE,
pvRemaining: 0, pvRemaining: 0,
smartCostActive: false, smartCostActive: false,
smartCostNextStart: undefined, smartCostNextStart: undefined,
@ -65,7 +71,7 @@ const baseState = {
chargerFeatureIntegratedDevice: false, chargerFeatureIntegratedDevice: false,
chargerIcon: "", chargerIcon: "",
chargerSinglePhase: false, chargerSinglePhase: false,
chargerStatusReason: "unknown", chargerStatusReason: CHARGER_STATUS_REASON.UNKNOWN,
connectedDuration: 0, connectedDuration: 0,
// Global props that would typically come from parent // Global props that would typically come from parent
vehicles: [ vehicles: [
@ -125,7 +131,7 @@ Idle.args = {
connected: false, connected: false,
vehicleName: "", vehicleName: "",
vehicles: [], vehicles: [],
mode: "off", mode: CHARGE_MODE.OFF,
charging: false, charging: false,
chargePower: 0, chargePower: 0,
offeredCurrent: 0, offeredCurrent: 0,
@ -139,7 +145,7 @@ DisabledLongTitle.args = {
title: "Charging point with a very very very long title!!!1!", title: "Charging point with a very very very long title!!!1!",
remoteDisabled: "soft", remoteDisabled: "soft",
remoteDisabledSource: "Sunny Home Manager", remoteDisabledSource: "Sunny Home Manager",
mode: "now", mode: CHARGE_MODE.NOW,
enabled: false, enabled: false,
charging: false, charging: false,
chargePower: 0, chargePower: 0,
@ -150,7 +156,7 @@ ChargerIconNoVehicle.args = {
...baseState, ...baseState,
chargerIcon: "heater", chargerIcon: "heater",
title: "Heating device with long name", title: "Heating device with long name",
mode: "now", mode: CHARGE_MODE.NOW,
chargerFeatureIntegratedDevice: true, chargerFeatureIntegratedDevice: true,
vehicleName: "", vehicleName: "",
vehicles: [], vehicles: [],

View file

@ -110,6 +110,9 @@ import { defineComponent, type PropType } from "vue";
import type { import type {
CHARGE_MODE, CHARGE_MODE,
PHASES, PHASES,
PHASE_ACTION,
PV_ACTION,
CHARGER_STATUS_REASON,
Timeout, Timeout,
Vehicle, Vehicle,
Forecast, Forecast,
@ -130,12 +133,12 @@ export default defineComponent({
}, },
mixins: [formatter, collector], mixins: [formatter, collector],
props: { props: {
id: Number, id: String,
single: Boolean, single: Boolean,
// main // main
title: String, title: String,
mode: String, mode: String as PropType<CHARGE_MODE>,
effectiveLimitSoc: Number, effectiveLimitSoc: Number,
limitEnergy: Number, limitEnergy: Number,
remoteDisabled: String, remoteDisabled: String,
@ -147,16 +150,16 @@ export default defineComponent({
// session // session
sessionEnergy: Number, sessionEnergy: Number,
sessionCo2PerKWh: Number, sessionCo2PerKWh: Number as PropType<number | null>,
sessionPricePerKWh: Number, sessionPricePerKWh: Number as PropType<number | null>,
sessionPrice: Number, sessionPrice: Number as PropType<number | null>,
sessionSolarPercentage: Number, sessionSolarPercentage: Number,
// charger // charger
chargerStatusReason: String, chargerStatusReason: String as PropType<CHARGER_STATUS_REASON | null>,
chargerFeatureIntegratedDevice: Boolean, chargerFeatureIntegratedDevice: Boolean,
chargerFeatureHeating: Boolean, chargerFeatureHeating: Boolean,
chargerIcon: String, chargerIcon: String as PropType<string | null>,
// vehicle // vehicle
connected: Boolean, connected: Boolean,
@ -170,20 +173,20 @@ export default defineComponent({
vehicleLimitSoc: Number, vehicleLimitSoc: Number,
vehicles: Array as PropType<Vehicle[]>, vehicles: Array as PropType<Vehicle[]>,
planActive: Boolean, planActive: Boolean,
planProjectedStart: String, planProjectedStart: String as PropType<string | null>,
planProjectedEnd: String, planProjectedEnd: String as PropType<string | null>,
planOverrun: { type: Number, default: 0 }, planOverrun: { type: Number, default: 0 },
planEnergy: Number, planEnergy: Number,
planPrecondition: Number, planPrecondition: Number,
planTime: String, planTime: String as PropType<string | null>,
effectivePlanTime: String, effectivePlanTime: String as PropType<string | null>,
effectivePlanSoc: Number, effectivePlanSoc: Number,
vehicleProviderLoggedIn: Boolean, vehicleProviderLoggedIn: Boolean,
vehicleProviderLoginPath: String, vehicleProviderLoginPath: String,
vehicleProviderLogoutPath: String, vehicleProviderLogoutPath: String,
// details // details
vehicleClimaterActive: Boolean, vehicleClimaterActive: Boolean as PropType<boolean | null>,
vehicleWelcomeActive: Boolean, vehicleWelcomeActive: Boolean,
chargePower: { type: Number, default: 0 }, chargePower: { type: Number, default: 0 },
chargedEnergy: { type: Number, default: 0 }, chargedEnergy: { type: Number, default: 0 },
@ -200,19 +203,19 @@ export default defineComponent({
connectedDuration: Number, connectedDuration: Number,
chargeCurrents: Array, chargeCurrents: Array,
chargeRemainingEnergy: Number, chargeRemainingEnergy: Number,
phaseAction: String, phaseAction: String as PropType<PHASE_ACTION>,
phaseRemaining: { type: Number, default: 0 }, phaseRemaining: { type: Number, default: 0 },
pvRemaining: { type: Number, default: 0 }, pvRemaining: { type: Number, default: 0 },
pvAction: String, pvAction: String as PropType<PV_ACTION>,
smartCostLimit: { type: Number, default: null }, smartCostLimit: { type: Number as PropType<number | null>, default: null },
smartCostType: String as PropType<SMART_COST_TYPE>, smartCostType: String as PropType<SMART_COST_TYPE>,
smartCostAvailable: Boolean, smartCostAvailable: Boolean,
smartCostActive: Boolean, smartCostActive: Boolean,
smartCostNextStart: String, smartCostNextStart: String as PropType<string | null>,
smartFeedInPriorityLimit: { type: Number, default: null }, smartFeedInPriorityLimit: { type: Number as PropType<number | null>, default: null },
smartFeedInPriorityAvailable: Boolean, smartFeedInPriorityAvailable: Boolean,
smartFeedInPriorityActive: Boolean, smartFeedInPriorityActive: Boolean,
smartFeedInPriorityNextStart: String, smartFeedInPriorityNextStart: String as PropType<string | null>,
tariffGrid: Number, tariffGrid: Number,
tariffFeedIn: Number, tariffFeedIn: Number,
tariffCo2: Number, tariffCo2: Number,

View file

@ -3,7 +3,7 @@ import type { Meta, StoryFn } from "@storybook/vue3";
import { SMART_COST_TYPE } from "@/types/evcc"; import { SMART_COST_TYPE } from "@/types/evcc";
// Create LoadpointCompact structure for the Loadpoints component // Create LoadpointCompact structure for the Loadpoints component
const createLoadpoint = (opts = {}) => { const createLoadpoint = (opts: any = {}) => {
const base = { const base = {
icon: "car", icon: "car",
title: "Carport", title: "Carport",
@ -18,7 +18,9 @@ const createLoadpoint = (opts = {}) => {
vehicleSoc: 0, vehicleSoc: 0,
chargerFeatureHeating: false, chargerFeatureHeating: false,
}; };
return { ...base, ...opts }; const result = { ...base, ...opts };
(result as any).id = result.index + 1;
return result;
}; };
export default { export default {

View file

@ -8,13 +8,12 @@
:class="`carousel--${loadpoints.length}`" :class="`carousel--${loadpoints.length}`"
> >
<div <div
v-for="(loadpoint, index) in loadpoints" v-for="loadpoint in loadpoints"
:key="index" :key="loadpoint.id"
class="flex-grow-1 mb-3 m-lg-0 p-lg-0" class="flex-grow-1 mb-3 m-lg-0 p-lg-0"
> >
<Loadpoint <Loadpoint
v-bind="loadpoint" v-bind="loadpoint"
:id="index + 1"
data-testid="loadpoint" data-testid="loadpoint"
:vehicles="vehicles" :vehicles="vehicles"
:smartCostType="smartCostType" :smartCostType="smartCostType"
@ -30,18 +29,18 @@
:batteryConfigured="batteryConfigured" :batteryConfigured="batteryConfigured"
:forecast="forecast" :forecast="forecast"
class="h-100" class="h-100"
:class="{ 'loadpoint-unselected': !selected(index) }" :class="{ 'loadpoint-unselected': !selected(loadpoint.id) }"
@click="goTo(index)" @click="goTo(loadpoint.id)"
/> />
</div> </div>
</div> </div>
<div v-if="loadpoints.length > 1" class="d-flex d-lg-none justify-content-center"> <div v-if="loadpoints.length > 1" class="d-flex d-lg-none justify-content-center">
<button <button
v-for="(loadpoint, index) in loadpoints" v-for="loadpoint in loadpoints"
:key="index" :key="loadpoint.id"
class="btn btn-sm btn-link p-0 mx-1 indicator d-flex justify-content-center align-items-center evcc-default-text" class="btn btn-sm btn-link p-0 mx-1 indicator d-flex justify-content-center align-items-center evcc-default-text"
:class="{ 'indicator--selected': selected(index) }" :class="{ 'indicator--selected': selected(loadpoint.id) }"
@click="goTo(index)" @click="goTo(loadpoint.id)"
> >
<shopicon-filled-lightning <shopicon-filled-lightning
v-if="isCharging(loadpoint)" v-if="isCharging(loadpoint)"
@ -64,13 +63,13 @@ import "@h2d2/shopicons/es/filled/lightning";
import Loadpoint from "./Loadpoint.vue"; import Loadpoint from "./Loadpoint.vue";
import { defineComponent, type PropType } from "vue"; import { defineComponent, type PropType } from "vue";
import type { LoadpointCompact, SMART_COST_TYPE, Timeout, Vehicle } from "@/types/evcc"; import type { UiLoadpoint, SMART_COST_TYPE, Timeout, Vehicle } from "@/types/evcc";
export default defineComponent({ export default defineComponent({
name: "Loadpoints", name: "Loadpoints",
components: { Loadpoint }, components: { Loadpoint },
props: { props: {
loadpoints: { type: Array as PropType<LoadpointCompact[]>, default: () => [] }, loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
vehicles: { type: Array as PropType<Vehicle[]> }, vehicles: { type: Array as PropType<Vehicle[]> },
smartCostType: String as PropType<SMART_COST_TYPE>, smartCostType: String as PropType<SMART_COST_TYPE>,
smartCostAvailable: Boolean, smartCostAvailable: Boolean,
@ -79,13 +78,13 @@ export default defineComponent({
tariffCo2: Number, tariffCo2: Number,
tariffFeedIn: Number, tariffFeedIn: Number,
currency: String, currency: String,
selectedIndex: { type: Number, default: 0 }, selectedId: String,
gridConfigured: Boolean, gridConfigured: Boolean,
pvConfigured: Boolean, pvConfigured: Boolean,
batteryConfigured: Boolean, batteryConfigured: Boolean,
forecast: Object, // as PropType<Forecast>, forecast: Object, // as PropType<Forecast>,
}, },
emits: ["index-changed"], emits: ["id-changed"],
data() { data() {
return { return {
snapTimeout: null as Timeout, snapTimeout: null as Timeout,
@ -93,6 +92,11 @@ export default defineComponent({
highlightedIndex: 0, highlightedIndex: 0,
}; };
}, },
computed: {
selectedIndex() {
return this.indexById(this.selectedId);
},
},
watch: { watch: {
selectedIndex(newIndex) { selectedIndex(newIndex) {
this.scrollTo(newIndex); this.scrollTo(newIndex);
@ -108,6 +112,12 @@ export default defineComponent({
this.$refs["carousel"]?.removeEventListener("scroll", this.handleCarouselScroll); this.$refs["carousel"]?.removeEventListener("scroll", this.handleCarouselScroll);
}, },
methods: { methods: {
indexById(id: string | undefined) {
return this.loadpoints.findIndex((lp) => lp.id === id) || 0;
},
idByIndex(index: number) {
return this.loadpoints[index]?.id;
},
handleCarouselScroll() { handleCarouselScroll() {
const { scrollLeft } = this.$refs["carousel"] as HTMLElement; const { scrollLeft } = this.$refs["carousel"] as HTMLElement;
const { offsetWidth } = this.$refs["carousel"]?.children[0] as HTMLElement; const { offsetWidth } = this.$refs["carousel"]?.children[0] as HTMLElement;
@ -119,18 +129,18 @@ export default defineComponent({
} }
this.scrollTimeout = setTimeout(() => { this.scrollTimeout = setTimeout(() => {
if (this.highlightedIndex !== this.selectedIndex) { if (this.highlightedIndex !== this.selectedIndex) {
this.$emit("index-changed", this.highlightedIndex); this.$emit("id-changed", this.idByIndex(this.highlightedIndex));
} }
}, 2000); }, 2000);
}, },
goTo(index: number) { goTo(id: string) {
this.$emit("index-changed", index); this.$emit("id-changed", id);
}, },
isCharging(lp: LoadpointCompact) { isCharging(lp: UiLoadpoint) {
return lp.charging && lp.chargePower > 0; return lp.charging && lp.chargePower > 0;
}, },
selected(index: number) { selected(id: string) {
return this.highlightedIndex === index; return this.highlightedIndex === this.indexById(id);
}, },
left(index: number) { left(index: number) {
return (this.$refs["carousel"]?.children[0] as HTMLElement).offsetWidth * index; return (this.$refs["carousel"]?.children[0] as HTMLElement).offsetWidth * index;

View file

@ -31,7 +31,7 @@ import { defineComponent, type PropType } from "vue";
import LabelAndValue from "../Helper/LabelAndValue.vue"; import LabelAndValue from "../Helper/LabelAndValue.vue";
import CustomSelect from "../Helper/CustomSelect.vue"; import CustomSelect from "../Helper/CustomSelect.vue";
import formatter from "@/mixins/formatter"; import formatter from "@/mixins/formatter";
import { getSessionInfo, setSessionInfo } from "./session"; import { getLoadpointSessionInfo, setLoadpointSessionInfo } from "@/uiLoadpoints";
import type { CURRENCY, SelectOption, SessionInfoKey } from "@/types/evcc"; import type { CURRENCY, SelectOption, SessionInfoKey } from "@/types/evcc";
export default defineComponent({ export default defineComponent({
@ -42,7 +42,7 @@ export default defineComponent({
}, },
mixins: [formatter], mixins: [formatter],
props: { props: {
id: { type: Number, default: 0 }, id: String,
sessionCo2PerKWh: { type: Number, default: 0 }, sessionCo2PerKWh: { type: Number, default: 0 },
sessionPricePerKWh: { type: Number, default: 0 }, sessionPricePerKWh: { type: Number, default: 0 },
sessionPrice: { type: Number, default: 0 }, sessionPrice: { type: Number, default: 0 },
@ -55,7 +55,7 @@ export default defineComponent({
}, },
data() { data() {
return { return {
selectedKey: getSessionInfo(this.id), selectedKey: this.id ? getLoadpointSessionInfo(this.id) : undefined,
}; };
}, },
computed: { computed: {
@ -165,8 +165,8 @@ export default defineComponent({
this.presist(); this.presist();
}, },
presist() { presist() {
if (this.selectedKey) { if (this.selectedKey && this.id) {
setSessionInfo(this.id, this.selectedKey); setLoadpointSessionInfo(this.id, this.selectedKey);
} }
}, },
}, },

View file

@ -1,13 +0,0 @@
import settings from "@/settings";
import type { SessionInfoKey } from "@/types/evcc";
export function getSessionInfo(index: number): SessionInfoKey | undefined {
return settings.sessionInfo[index - 1];
}
export function setSessionInfo(index: number, value: SessionInfoKey) {
const clone = [...settings.sessionInfo];
clone[index - 1] = value;
clone.map((v) => v || "");
settings.sessionInfo = clone;
}

View file

@ -51,7 +51,7 @@
<Loadpoints <Loadpoints
v-else-if="loadpoints.length > 0" v-else-if="loadpoints.length > 0"
class="mt-1 mt-sm-2 flex-grow-1" class="mt-1 mt-sm-2 flex-grow-1"
:loadpoints="loadpoints" :loadpoints="orderedVisibleLoadpoints"
:vehicles="vehicleList" :vehicles="vehicleList"
:smartCostType="smartCostType" :smartCostType="smartCostType"
:smartCostAvailable="smartCostAvailable" :smartCostAvailable="smartCostAvailable"
@ -65,8 +65,8 @@
:batteryConfigured="batteryConfigured" :batteryConfigured="batteryConfigured"
:batterySoc="batterySoc" :batterySoc="batterySoc"
:forecast="forecast" :forecast="forecast"
:selectedIndex="selectedLoadpointIndex" :selectedId="selectedLoadpointId"
@index-changed="selectedLoadpointChanged" @id-changed="selectedLoadpointChanged"
/> />
<Footer v-bind="footer"></Footer> <Footer v-bind="footer"></Footer>
</div> </div>
@ -90,7 +90,6 @@ import type {
Battery, Battery,
CURRENCY, CURRENCY,
Forecast, Forecast,
LoadpointCompact,
Notification, Notification,
Circuit, Circuit,
SMART_COST_TYPE, SMART_COST_TYPE,
@ -98,6 +97,7 @@ import type {
FatalError, FatalError,
EvOpt, EvOpt,
} from "@/types/evcc"; } from "@/types/evcc";
import store from "@/store";
import type { Grid } from "./types"; import type { Grid } from "./types";
export default defineComponent({ export default defineComponent({
@ -113,8 +113,7 @@ export default defineComponent({
}, },
mixins: [formatter, collector], mixins: [formatter, collector],
props: { props: {
loadpoints: { type: Array as PropType<LoadpointCompact[]>, default: () => [] }, selectedLoadpointId: String,
selectedLoadpointIndex: Number,
notifications: { type: Array as PropType<Notification[]>, default: () => [] }, notifications: { type: Array as PropType<Notification[]>, default: () => [] },
offline: Boolean, offline: Boolean,
@ -165,6 +164,12 @@ export default defineComponent({
evopt: { type: Object as PropType<EvOpt> }, evopt: { type: Object as PropType<EvOpt> },
}, },
computed: { computed: {
loadpoints() {
return store.uiLoadpoints.value;
},
orderedVisibleLoadpoints() {
return this.loadpoints.filter((lp) => lp.visible);
},
batteryConfigured() { batteryConfigured() {
return this.battery?.length > 0; return this.battery?.length > 0;
}, },
@ -178,22 +183,7 @@ export default defineComponent({
return this.collectProps(Energyflow); return this.collectProps(Energyflow);
}, },
loadpointTitles() { loadpointTitles() {
return this.loadpoints.map((lp) => lp.title); return this.orderedVisibleLoadpoints.map((lp) => lp.displayTitle);
},
loadpointsCompact() {
return this.loadpoints.map((lp, index) => {
const vehicleIcon = this.vehicles?.[lp.vehicleName]?.icon;
const icon = lp.chargerIcon || vehicleIcon || "car";
const title =
this.vehicleTitle(lp.vehicleName) ||
lp.title ||
this.$t("main.loadpoint.fallbackName");
const charging = lp.charging;
const soc = lp.vehicleSoc;
const power = lp.chargePower || 0;
const heating = lp.chargerFeatureHeating;
return { icon, title, charging, power, soc, heating, index };
});
}, },
vehicleList() { vehicleList() {
const vehicles = this.vehicles || {}; const vehicles = this.vehicles || {};
@ -235,11 +225,8 @@ export default defineComponent({
}, },
}, },
methods: { methods: {
selectedLoadpointChanged(index: number) { selectedLoadpointChanged(id: string | undefined) {
this.$router.push({ query: { lp: index + 1 } }); this.$router.push({ query: { lp: id } });
},
vehicleTitle(vehicleName: string) {
return this.vehicles?.[vehicleName]?.title;
}, },
}, },
}); });

View file

@ -48,7 +48,7 @@ export default function setupRouter(i18n: VueI18nInstance) {
props: (route) => { props: (route) => {
const { lp } = route.query; const { lp } = route.query;
return { return {
selectedLoadpointIndex: lp ? parseInt(lp as string, 10) - 1 : undefined, selectedLoadpointId: lp as string | undefined,
}; };
}, },
}, },
@ -106,8 +106,11 @@ export default function setupRouter(i18n: VueI18nInstance) {
await ensureCurrentLocaleMessages(i18n); await ensureCurrentLocaleMessages(i18n);
return true; return true;
}); });
router.afterEach(() => { router.afterEach((to, from) => {
hideAllModals(); // Only hide modals when the actual route path changes, not query parameters
if (to.path !== from.path) {
hideAllModals();
}
}); });
return router; return router;
} }

View file

@ -11,13 +11,14 @@ const SETTINGS_ENERGYFLOW_DETAILS = "settings_energyflow_details";
const SETTINGS_ENERGYFLOW_PV = "settings_energyflow_pv"; const SETTINGS_ENERGYFLOW_PV = "settings_energyflow_pv";
const SETTINGS_ENERGYFLOW_BATTERY = "settings_energyflow_battery"; const SETTINGS_ENERGYFLOW_BATTERY = "settings_energyflow_battery";
const SETTINGS_ENERGYFLOW_LOADPOINTS = "settings_energyflow_loadpoints"; const SETTINGS_ENERGYFLOW_LOADPOINTS = "settings_energyflow_loadpoints";
const SESSION_INFO = "session_info"; const LOADPOINTS = "loadpoints";
const SESSION_COLUMNS = "session_columns"; const SESSION_COLUMNS = "session_columns";
const SAVINGS_PERIOD = "savings_period"; const SAVINGS_PERIOD = "savings_period";
const SAVINGS_REGION = "savings_region"; const SAVINGS_REGION = "savings_region";
const SESSIONS_GROUP = "sessions_group"; const SESSIONS_GROUP = "sessions_group";
const SESSIONS_TYPE = "sessions_type"; const SESSIONS_TYPE = "sessions_type";
const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted"; const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted";
function read(key: string) { function read(key: string) {
return window.localStorage[key]; return window.localStorage[key];
} }
@ -57,6 +58,32 @@ function saveArray(key: string) {
}; };
} }
function readJSON(key: string) {
const value = read(key);
try {
return value ? JSON.parse(value) : {};
} catch (e) {
console.error("unable to parse JSON from localStorage", { key, value, e });
return {};
}
}
function saveJSON(key: string) {
return (value: object) => {
try {
save(key)(JSON.stringify(value));
} catch (e) {
console.error("unable to stringify JSON for localStorage", { key, value, e });
}
};
}
export interface LoadpointSettings {
order?: number;
visible?: boolean;
info?: SessionInfoKey;
}
export interface Settings { export interface Settings {
locale: keyof typeof LOCALES | null; locale: keyof typeof LOCALES | null;
theme: THEME | null; theme: THEME | null;
@ -67,13 +94,13 @@ export interface Settings {
energyflowPv: boolean; energyflowPv: boolean;
energyflowBattery: boolean; energyflowBattery: boolean;
energyflowLoadpoints: boolean; energyflowLoadpoints: boolean;
sessionInfo: SessionInfoKey[];
sessionColumns: string[]; sessionColumns: string[];
savingsPeriod: string; savingsPeriod: string;
savingsRegion: string; savingsRegion: string;
sessionsGroup: string; sessionsGroup: string;
sessionsType: string; sessionsType: string;
solarAdjusted: boolean; solarAdjusted: boolean;
loadpoints: Record<string, LoadpointSettings>;
} }
const settings: Settings = reactive({ const settings: Settings = reactive({
@ -86,13 +113,13 @@ const settings: Settings = reactive({
energyflowPv: readBool(SETTINGS_ENERGYFLOW_PV), energyflowPv: readBool(SETTINGS_ENERGYFLOW_PV),
energyflowBattery: readBool(SETTINGS_ENERGYFLOW_BATTERY), energyflowBattery: readBool(SETTINGS_ENERGYFLOW_BATTERY),
energyflowLoadpoints: readBool(SETTINGS_ENERGYFLOW_LOADPOINTS), energyflowLoadpoints: readBool(SETTINGS_ENERGYFLOW_LOADPOINTS),
sessionInfo: readArray(SESSION_INFO) as SessionInfoKey[],
sessionColumns: readArray(SESSION_COLUMNS), sessionColumns: readArray(SESSION_COLUMNS),
savingsPeriod: read(SAVINGS_PERIOD), savingsPeriod: read(SAVINGS_PERIOD),
savingsRegion: read(SAVINGS_REGION), savingsRegion: read(SAVINGS_REGION),
sessionsGroup: read(SESSIONS_GROUP), sessionsGroup: read(SESSIONS_GROUP),
sessionsType: read(SESSIONS_TYPE), sessionsType: read(SESSIONS_TYPE),
solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED), solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED),
loadpoints: readJSON(LOADPOINTS),
}); });
watch(() => settings.locale, save(SETTINGS_LOCALE)); watch(() => settings.locale, save(SETTINGS_LOCALE));
@ -104,11 +131,33 @@ watch(() => settings.energyflowDetails, saveBool(SETTINGS_ENERGYFLOW_DETAILS));
watch(() => settings.energyflowPv, saveBool(SETTINGS_ENERGYFLOW_PV)); watch(() => settings.energyflowPv, saveBool(SETTINGS_ENERGYFLOW_PV));
watch(() => settings.energyflowBattery, saveBool(SETTINGS_ENERGYFLOW_BATTERY)); watch(() => settings.energyflowBattery, saveBool(SETTINGS_ENERGYFLOW_BATTERY));
watch(() => settings.energyflowLoadpoints, saveBool(SETTINGS_ENERGYFLOW_LOADPOINTS)); watch(() => settings.energyflowLoadpoints, saveBool(SETTINGS_ENERGYFLOW_LOADPOINTS));
watch(() => settings.sessionInfo, saveArray(SESSION_INFO));
watch(() => settings.sessionColumns as string[], saveArray(SESSION_COLUMNS)); watch(() => settings.sessionColumns as string[], saveArray(SESSION_COLUMNS));
watch(() => settings.savingsPeriod, save(SAVINGS_PERIOD)); watch(() => settings.savingsPeriod, save(SAVINGS_PERIOD));
watch(() => settings.savingsRegion, save(SAVINGS_REGION)); watch(() => settings.savingsRegion, save(SAVINGS_REGION));
watch(() => settings.sessionsGroup, save(SESSIONS_GROUP)); watch(() => settings.sessionsGroup, save(SESSIONS_GROUP));
watch(() => settings.sessionsType, save(SESSIONS_TYPE)); watch(() => settings.sessionsType, save(SESSIONS_TYPE));
watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED)); watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED));
watch(() => settings.loadpoints, saveJSON(LOADPOINTS), { deep: true });
export default settings; export default settings;
// MIGRATIONS
// Convert old comma-separated session_info to new loadpoints structure
// TODO: remove in later release
const SESSION_INFO = "session_info";
const oldSessionInfo = read(SESSION_INFO);
if (oldSessionInfo && Object.keys(settings.loadpoints).length === 0) {
const sessionInfoArray = oldSessionInfo.split(",");
sessionInfoArray.forEach((info: string, index: number) => {
if (info.trim()) {
const loadpointId = `${index + 1}`;
if (!settings.loadpoints[loadpointId]) {
settings.loadpoints[loadpointId] = {};
}
settings.loadpoints[loadpointId].info = info.trim() as SessionInfoKey;
}
});
// Remove the old session_info key
delete window.localStorage[SESSION_INFO];
}

View file

@ -1,5 +1,8 @@
import { reactive } from "vue"; import { reactive } from "vue";
import type { State } from "./types/evcc"; import type { State } from "./types/evcc";
import { convertToUiLoadpoints } from "./uiLoadpoints";
import { useDebouncedComputed } from "./utils/useDebouncedComputed";
import settings from "./settings";
function setProperty(obj: object, props: string[], value: any) { function setProperty(obj: object, props: string[], value: any) {
const prop = props.shift(); const prop = props.shift();
@ -32,12 +35,27 @@ const initialState: State = {
const state = reactive(initialState); const state = reactive(initialState);
const store = { // create derived loadpoints array with ui specific fields (defaults, browser settings, ...); debounce for better performance
const uiLoadpoints = useDebouncedComputed(
() => convertToUiLoadpoints(state.loadpoints, state.vehicles),
() => [state.loadpoints, state.vehicles, settings.loadpoints],
50
);
export interface Store {
state: State; // raw state from websocket
uiLoadpoints: typeof uiLoadpoints;
offline(value: boolean): void;
update(msg: any): void;
reset(): void;
}
const store: Store = {
state, state,
uiLoadpoints,
offline(value: boolean) { offline(value: boolean) {
state.offline = value; state.offline = value;
}, },
// @ts-expect-error no-explicit-any
update(msg) { update(msg) {
Object.keys(msg).forEach(function (k) { Object.keys(msg).forEach(function (k) {
if (k === "log") { if (k === "log") {

View file

@ -52,7 +52,7 @@ export interface FatalError {
export interface State { export interface State {
offline: boolean; offline: boolean;
startup?: boolean; startup?: boolean;
loadpoints: []; loadpoints: Loadpoint[];
forecast?: Forecast; forecast?: Forecast;
currency?: CURRENCY; currency?: CURRENCY;
fatal?: FatalError[]; fatal?: FatalError[];
@ -177,20 +177,87 @@ export enum LENGTH_UNIT {
MILES = "mi", MILES = "mi",
} }
export interface LoadpointCompact { export interface Loadpoint {
icon: string; batteryBoost: boolean;
title: string; chargeCurrents?: number[];
charging: boolean; chargeDuration: number;
soc?: number;
power: number;
heating?: boolean;
chargePower: number; chargePower: number;
connected: boolean; chargeRemainingDuration?: number;
index: number; chargeRemainingEnergy?: number;
vehicleName: string; chargeTotalImport?: number;
chargerIcon?: string; chargeVoltages?: number[];
vehicleSoc: number; chargedEnergy: number;
chargerFeatureHeating: boolean; chargerFeatureHeating: boolean;
chargerFeatureIntegratedDevice: boolean;
chargerIcon: string | null;
chargerPhases1p3p: boolean;
chargerSinglePhase: boolean;
chargerStatusReason: CHARGER_STATUS_REASON | null;
charging: boolean;
connected: boolean;
connectedDuration: number;
disableDelay: number;
disableThreshold: number;
effectiveLimitSoc: number;
effectiveMaxCurrent: number;
effectiveMinCurrent: number;
effectivePlanId: number;
effectivePlanSoc: number;
effectivePlanTime: string | null;
effectivePriority: number;
enableDelay: number;
enableThreshold: number;
enabled: boolean;
limitEnergy: number;
limitSoc: number;
maxCurrent: number;
minCurrent: number;
mode: CHARGE_MODE;
offeredCurrent: number;
phaseAction: PHASE_ACTION;
phaseRemaining: number;
phasesActive: number;
phasesConfigured: number;
planActive: boolean;
planEnergy: number;
planOverrun: number;
planPrecondition: number;
planProjectedEnd: string | null;
planProjectedStart: string | null;
planTime: string | null;
priority: number;
pvAction: PV_ACTION;
pvRemaining: number;
sessionCo2PerKWh: number | null;
sessionEnergy: number;
sessionPrice: number | null;
sessionPricePerKWh: number | null;
sessionSolarPercentage: number;
smartCostActive: boolean;
smartCostLimit: number | null;
smartCostNextStart: string | null;
smartFeedInPriorityActive: boolean;
smartFeedInPriorityLimit: number | null;
smartFeedInPriorityNextStart: string | null;
title: string;
vehicleClimaterActive: boolean | null;
vehicleDetectionActive: boolean;
vehicleLimitSoc: number;
vehicleName: string;
vehicleOdometer: number;
vehicleRange: number;
vehicleSoc: number;
vehicleTitle: string;
vehicleWelcomeActive: boolean;
}
export interface UiLoadpoint extends Loadpoint {
// Derived/computed fields for UI display
id: string;
displayTitle: string;
icon: string;
order: number | null;
visible: boolean;
} }
export enum THEME { export enum THEME {
@ -238,6 +305,24 @@ export enum PHASES {
THREE_PHASES = 3, THREE_PHASES = 3,
} }
export enum PHASE_ACTION {
INACTIVE = "inactive",
SCALE_1P = "scale1p",
SCALE_3P = "scale3p",
}
export enum PV_ACTION {
INACTIVE = "inactive",
ENABLE = "enable",
DISABLE = "disable",
}
export enum CHARGER_STATUS_REASON {
UNKNOWN = "unknown",
WAITING_FOR_AUTHORIZATION = "waitingforauthorization",
DISCONNECT_REQUIRED = "disconnectrequired",
}
export enum LOADPOINT_TYPE { export enum LOADPOINT_TYPE {
CHARGING = "charging", CHARGING = "charging",
HEATING = "heating", HEATING = "heating",

77
assets/js/uiLoadpoints.ts Normal file
View file

@ -0,0 +1,77 @@
import settings from "./settings";
import type { UiLoadpoint, SessionInfoKey, Loadpoint, Vehicle } from "./types/evcc";
const get = (id: string) => {
if (!settings.loadpoints[id]) {
settings.loadpoints[id] = {};
}
return settings.loadpoints[id];
};
export const convertToUiLoadpoints = (
loadpoints: Loadpoint[],
vehicles: Record<string, Vehicle>
): UiLoadpoint[] => {
if (loadpoints.length === 0) return [];
const mappedLoadpoints = loadpoints.map((lp, originalIndex) => {
const vehicle = vehicles[lp.vehicleName];
const id = `${originalIndex + 1}`;
return {
...lp,
id,
displayTitle: vehicle?.title || lp.title || "Charging point",
icon: lp.chargerIcon || vehicle?.icon || "car",
order: getLoadpointOrder(id),
visible: isLoadpointVisible(id),
sessionInfo: getLoadpointSessionInfo(id),
};
});
// Sort by order (loadpoints with no order go to the end)
return mappedLoadpoints.sort((a, b) => (a.order ?? Infinity) - (b.order ?? Infinity));
};
export const getLoadpointOrder = (id: string): number | null => {
return get(id).order ?? null;
};
export const setLoadpointOrder = (orderedIds: string[]) => {
// Update order for all loadpoints in the ordered list
orderedIds.forEach((id, index) => {
get(id).order = index;
});
};
export const isLoadpointVisible = (id: string): boolean => {
return get(id).visible ?? true; // Default to visible
};
export const setLoadpointVisibility = (id: string, visible: boolean) => {
get(id).visible = visible;
};
export const getLoadpointSessionInfo = (id: string): SessionInfoKey | undefined => {
return get(id).info;
};
export const setLoadpointSessionInfo = (id: string, value: SessionInfoKey) => {
get(id).info = value;
};
export const resetLoadpointsOrder = () => {
Object.keys(settings.loadpoints).forEach((id) => {
if (settings.loadpoints[id]) {
settings.loadpoints[id].order = undefined;
}
});
};
export const resetLoadpointsVisible = () => {
Object.keys(settings.loadpoints).forEach((id) => {
if (settings.loadpoints[id]) {
settings.loadpoints[id].visible = undefined;
}
});
};

View file

@ -0,0 +1,9 @@
import type { Timeout } from "@/types/evcc";
export function debounce<T extends (...args: any[]) => any>(fn: T, delay: number): T {
let timer: Timeout;
return ((...args: any[]) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
}) as T;
}

View file

@ -0,0 +1,17 @@
import { ref, watch, type Ref } from "vue";
import { debounce } from "./debounce";
export function useDebouncedComputed<T>(
getter: () => T,
deps: () => any,
delay: number = 100
): Ref<T> {
const result = ref<T>();
const debouncedUpdate = debounce(() => {
result.value = getter();
}, delay);
watch(deps, debouncedUpdate, { immediate: true, deep: true });
return result as Ref<T>;
}

View file

@ -67,20 +67,24 @@ export default defineComponent({
batteryModalAvailabe() { batteryModalAvailabe() {
return store.state.battery?.length; return store.state.battery?.length;
}, },
state() {
const { state, uiLoadpoints } = store;
return { ...state, uiLoadpoints: uiLoadpoints.value };
},
globalSettingsProps() { globalSettingsProps() {
return this.collectProps(GlobalSettingsModal, store.state); return this.collectProps(GlobalSettingsModal, this.state);
}, },
batterySettingsProps() { batterySettingsProps() {
return this.collectProps(BatterySettingsModal, store.state); return this.collectProps(BatterySettingsModal, this.state);
}, },
offlineIndicatorProps() { offlineIndicatorProps() {
return this.collectProps(OfflineIndicator, store.state); return this.collectProps(OfflineIndicator, this.state);
}, },
forecastModalProps() { forecastModalProps() {
return this.collectProps(ForecastModal, store.state); return this.collectProps(ForecastModal, this.state);
}, },
loginModalProps() { loginModalProps() {
return this.collectProps(LoginModal, store.state); return this.collectProps(LoginModal, this.state);
}, },
}, },
watch: { watch: {

View file

@ -390,6 +390,7 @@
<ShmModal @changed="loadDirty" /> <ShmModal @changed="loadDirty" />
<MessagingModal @changed="yamlChanged" /> <MessagingModal @changed="yamlChanged" />
<TariffsModal @changed="yamlChanged" /> <TariffsModal @changed="yamlChanged" />
<TelemetryModal :sponsor="sponsor" :telemetry="telemetry" />
<ModbusProxyModal @changed="yamlChanged" /> <ModbusProxyModal @changed="yamlChanged" />
<CircuitsModal <CircuitsModal
:gridMeter="gridMeter" :gridMeter="gridMeter"
@ -443,6 +444,7 @@ import restart, { performRestart } from "../restart";
import SponsorModal from "../components/Config/SponsorModal.vue"; import SponsorModal from "../components/Config/SponsorModal.vue";
import store from "../store"; import store from "../store";
import TariffsModal from "../components/Config/TariffsModal.vue"; import TariffsModal from "../components/Config/TariffsModal.vue";
import TelemetryModal from "../components/Config/TelemetryModal.vue";
import Header from "../components/Top/Header.vue"; import Header from "../components/Top/Header.vue";
import VehicleIcon from "../components/VehicleIcon"; import VehicleIcon from "../components/VehicleIcon";
import VehicleModal from "../components/Config/VehicleModal.vue"; import VehicleModal from "../components/Config/VehicleModal.vue";
@ -500,6 +502,7 @@ export default defineComponent({
NotificationIcon, NotificationIcon,
SponsorModal, SponsorModal,
TariffsModal, TariffsModal,
TelemetryModal,
TopHeader: Header, TopHeader: Header,
VehicleIcon, VehicleIcon,
VehicleModal, VehicleModal,
@ -662,6 +665,13 @@ export default defineComponent({
const { name } = store.state?.sponsor || {}; const { name } = store.state?.sponsor || {};
return !!name; return !!name;
}, },
sponsor() {
return store.state?.sponsor;
},
telemetry() {
// @ts-expect-error: telemetry property exists but not in TypeScript definitions
return store.state?.telemetry === true;
},
eebusTags() { eebusTags() {
return { configured: { value: store.state?.eebus || false } }; return { configured: { value: store.state?.eebus || false } };
}, },

View file

@ -162,6 +162,9 @@
"customOption": "Benutzerdefiniertes Gerät", "customOption": "Benutzerdefiniertes Gerät",
"delete": "Löschen", "delete": "Löschen",
"docsLink": "Siehe Dokumentation.", "docsLink": "Siehe Dokumentation.",
"dragHandle": "Verschieben",
"dragItem": "Verschiebbar: {title}",
"dragList": "Sortierbare Liste",
"experimental": "Experimentell", "experimental": "Experimentell",
"hideAdvancedSettings": "Erweiterte Einstellungen ausblenden", "hideAdvancedSettings": "Erweiterte Einstellungen ausblenden",
"invalidFileSelected": "Ungültige Datei ausgewählt", "invalidFileSelected": "Ungültige Datei ausgewählt",
@ -172,6 +175,7 @@
"readFromFile": "Aus Datei lesen", "readFromFile": "Aus Datei lesen",
"remove": "Entfernen", "remove": "Entfernen",
"required": "erforderlich", "required": "erforderlich",
"reset": "Zurücksetzen",
"save": "Speichern", "save": "Speichern",
"selectFile": "Durchsuchen", "selectFile": "Durchsuchen",
"showAdvancedSettings": "Erweiterte Einstellungen anzeigen", "showAdvancedSettings": "Erweiterte Einstellungen anzeigen",
@ -510,6 +514,10 @@
"description": "Hinterlege deine Stromtarife, um die Kosten zu berechnen.", "description": "Hinterlege deine Stromtarife, um die Kosten zu berechnen.",
"title": "Tarife" "title": "Tarife"
}, },
"telemetry": {
"description": "Konfiguriere die Datenfreigabe, um evcc zu verbessern. Deine Privatsphäre ist uns wichtig und die Teilnahme ist völlig freiwillig.",
"title": "Telemetrie"
},
"title": { "title": {
"description": "Wird in der Hauptansicht und im Browser-Tab angezeigt.", "description": "Wird in der Hauptansicht und im Browser-Tab angezeigt.",
"label": "Titel", "label": "Titel",
@ -1088,6 +1096,7 @@
"vehicle": "Fahrzeug" "vehicle": "Fahrzeug"
}, },
"settings": { "settings": {
"deviceInfo": "Einstellungen, die du in diesem Dialog vornimmst, gelten nur für dieses Gerät.",
"fullscreen": { "fullscreen": {
"enter": "Vollbild starten", "enter": "Vollbild starten",
"exit": "Vollbild beenden", "exit": "Vollbild beenden",
@ -1101,6 +1110,12 @@
"auto": "Automatisch", "auto": "Automatisch",
"label": "Sprache" "label": "Sprache"
}, },
"loadpoints": {
"help": "Reihenfolge und Sichtbarkeit für die Benutzeroberfläche ändern.",
"hide": "{title} ausblenden",
"label": "Ladepunkte",
"show": "{title} anzeigen"
},
"sponsorToken": { "sponsorToken": {
"expires": "Dein Sponsortoken läuft {inXDays} ab. {getNewToken} und aktualisiere es hier.", "expires": "Dein Sponsortoken läuft {inXDays} ab. {getNewToken} und aktualisiere es hier.",
"getNew": "Hol dir ein Neues", "getNew": "Hol dir ein Neues",

View file

@ -162,6 +162,9 @@
"customOption": "User-defined device", "customOption": "User-defined device",
"delete": "Delete", "delete": "Delete",
"docsLink": "See documentation.", "docsLink": "See documentation.",
"dragHandle": "Drag handle",
"dragItem": "Draggable: {title}",
"dragList": "Reorderable list",
"experimental": "Experimental", "experimental": "Experimental",
"hideAdvancedSettings": "Hide advanced settings", "hideAdvancedSettings": "Hide advanced settings",
"invalidFileSelected": "Invalid file selected", "invalidFileSelected": "Invalid file selected",
@ -172,6 +175,7 @@
"readFromFile": "Read from file", "readFromFile": "Read from file",
"remove": "Remove", "remove": "Remove",
"required": "required", "required": "required",
"reset": "Reset",
"save": "Save", "save": "Save",
"selectFile": "Browse", "selectFile": "Browse",
"showAdvancedSettings": "Show advanced settings", "showAdvancedSettings": "Show advanced settings",
@ -510,6 +514,10 @@
"description": "Define your energy tariffs to calculate the costs of your charging sessions.", "description": "Define your energy tariffs to calculate the costs of your charging sessions.",
"title": "Tariffs" "title": "Tariffs"
}, },
"telemetry": {
"description": "Configure data sharing to help improve evcc. Your privacy is important to us and participation is completely optional.",
"title": "Telemetry"
},
"title": { "title": {
"description": "Displayed on main screen and browser tab.", "description": "Displayed on main screen and browser tab.",
"label": "Title", "label": "Title",
@ -1088,6 +1096,7 @@
"vehicle": "Vehicle" "vehicle": "Vehicle"
}, },
"settings": { "settings": {
"deviceInfo": "Settings you make in this dialog only affect this device.",
"fullscreen": { "fullscreen": {
"enter": "Enter fullscreen", "enter": "Enter fullscreen",
"exit": "Exit fullscreen", "exit": "Exit fullscreen",
@ -1101,6 +1110,12 @@
"auto": "Automatic", "auto": "Automatic",
"label": "Language" "label": "Language"
}, },
"loadpoints": {
"help": "Change order and visibility for the UI.",
"hide": "Hide {title}",
"label": "Charging points",
"show": "Show {title}"
},
"sponsorToken": { "sponsorToken": {
"expires": "You sponsor token expires {inXDays}. {getNewToken} and update it here.", "expires": "You sponsor token expires {inXDays}. {getNewToken} and update it here.",
"getNew": "Grab a fresh one", "getNew": "Grab a fresh one",

7
package-lock.json generated
View file

@ -7,6 +7,7 @@
"name": "evcc", "name": "evcc",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@formkit/drag-and-drop": "^0.5.3",
"@guolao/vue-monaco-editor": "^1.5.5", "@guolao/vue-monaco-editor": "^1.5.5",
"@h2d2/shopicons": "^1.9.0", "@h2d2/shopicons": "^1.9.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
@ -2285,6 +2286,12 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@formkit/drag-and-drop": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/@formkit/drag-and-drop/-/drag-and-drop-0.5.3.tgz",
"integrity": "sha512-VGQsKYc350OOMbz7wYt69YqDbn49x7RJ32eqiJieNke54nGZYmx18XEqTw0lBiJmH3RaiH6V45gB1u+tvI/TNw==",
"license": "MIT"
},
"node_modules/@guolao/vue-monaco-editor": { "node_modules/@guolao/vue-monaco-editor": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@guolao/vue-monaco-editor/-/vue-monaco-editor-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@guolao/vue-monaco-editor/-/vue-monaco-editor-1.5.5.tgz",

View file

@ -19,6 +19,7 @@
"type": "module", "type": "module",
"main": "index.js", "main": "index.js",
"dependencies": { "dependencies": {
"@formkit/drag-and-drop": "^0.5.3",
"@guolao/vue-monaco-editor": "^1.5.5", "@guolao/vue-monaco-editor": "^1.5.5",
"@h2d2/shopicons": "^1.9.0", "@h2d2/shopicons": "^1.9.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",

View file

@ -0,0 +1,30 @@
site:
title: Loadpoint Sort Test
meters:
- name: grid
type: template
template: demo-meter
power: 200
loadpoints:
- title: First Loadpoint
charger: charger_1
mode: now
- title: Second Loadpoint
charger: charger_2
mode: now
- title: Third Loadpoint
charger: charger_3
mode: now
chargers:
- name: charger_1
type: template
template: demo-charger
- name: charger_2
type: template
template: demo-charger
- name: charger_3
type: template
template: demo-charger

View file

@ -0,0 +1,112 @@
import { test, expect, type Page, type Locator } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { expectModalVisible, expectModalHidden, openTopNavigation, dragElement } from "./utils";
const CONFIG_LOADPOINT_SORT = "loadpoint-sort.evcc.yaml";
test.use({ baseURL: baseUrl() });
test.describe.configure({ mode: "parallel" });
test.beforeAll(async () => {
await start(CONFIG_LOADPOINT_SORT);
});
test.afterAll(async () => {
await stop();
});
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
async function openModal(page: Page) {
await openTopNavigation(page);
await page.getByTestId("topnavigation-settings").click();
const modal = page.getByTestId("global-settings-modal");
await expectModalVisible(modal);
return modal;
}
async function closeModal(modal: Locator) {
await modal.getByRole("button", { name: "Close" }).click();
await expectModalHidden(modal);
}
test.describe("loadpoint ordering and hiding", async () => {
test("initial loadpoint order", async ({ page }) => {
await expect(page.getByTestId("loadpoint")).toHaveCount(3);
const loadpoints = page.getByTestId("loadpoint");
await expect(loadpoints.nth(0)).toContainText("First Loadpoint");
await expect(loadpoints.nth(1)).toContainText("Second Loadpoint");
await expect(loadpoints.nth(2)).toContainText("Third Loadpoint");
});
test("hide a loadpoint", async ({ page }) => {
const modal = await openModal(page);
await modal.getByRole("switch", { name: "Hide Second Loadpoint" }).click();
await closeModal(modal);
await expect(page.getByTestId("loadpoint")).toHaveCount(2);
await expect(page.getByRole("heading", { name: "Second Loadpoint" })).not.toBeVisible();
});
test("reorder loadpoints", async ({ page }) => {
const modal = await openModal(page);
const firstLoadpointDragItem = modal.getByRole("listitem", {
name: "Draggable: First Loadpoint",
});
const thirdLoadpointDragItem = modal.getByRole("listitem", {
name: "Draggable: Third Loadpoint",
});
await dragElement(page, firstLoadpointDragItem, thirdLoadpointDragItem);
await closeModal(modal);
await expect(page.getByTestId("loadpoint")).toHaveCount(3);
const reorderedLoadpoints = page.getByTestId("loadpoint");
await expect(reorderedLoadpoints.nth(0)).toContainText("Second Loadpoint");
await expect(reorderedLoadpoints.nth(1)).toContainText("Third Loadpoint");
await expect(reorderedLoadpoints.nth(2)).toContainText("First Loadpoint");
});
test("reset to initial state", async ({ page }) => {
const modal = await openModal(page);
await modal.getByRole("switch", { name: "Hide Second Loadpoint" }).click();
const firstLoadpointDragItem = modal.getByRole("listitem", {
name: "Draggable: First Loadpoint",
});
const thirdLoadpointDragItem = modal.getByRole("listitem", {
name: "Draggable: Third Loadpoint",
});
await dragElement(page, firstLoadpointDragItem, thirdLoadpointDragItem);
await modal.getByRole("button", { name: "Reset" }).click();
await closeModal(modal);
await expect(page.getByTestId("loadpoint")).toHaveCount(3);
const loadpoints = page.getByTestId("loadpoint");
await expect(loadpoints.nth(0)).toContainText("First Loadpoint");
await expect(loadpoints.nth(1)).toContainText("Second Loadpoint");
await expect(loadpoints.nth(2)).toContainText("Third Loadpoint");
});
test("persist changes after page reload", async ({ page }) => {
const modal = await openModal(page);
await modal.getByRole("switch", { name: "Hide Second Loadpoint" }).click();
await closeModal(modal);
await page.reload();
await expect(page.getByTestId("loadpoint")).toHaveCount(2);
await expect(page.getByRole("heading", { name: "Second Loadpoint" })).not.toBeVisible();
});
});

View file

@ -123,3 +123,26 @@ export async function newLoadpoint(
.click(); .click();
await lpModal.getByLabel("Title").fill(title); await lpModal.getByLabel("Title").fill(title);
} }
export async function dragElement(
page: Page,
sourceElement: Locator,
targetElement: Locator
): Promise<void> {
// Get bounding boxes to calculate actual positions
const sourceBox = await sourceElement.boundingBox();
const targetBox = await targetElement.boundingBox();
if (sourceBox && targetBox) {
// Move from center of source item to center of target item
const startX = sourceBox.x + sourceBox.width / 2;
const startY = sourceBox.y + sourceBox.height / 2;
const endX = targetBox.x + targetBox.width / 2;
const endY = targetBox.y + targetBox.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(endX, endY, { steps: 10 });
await page.mouse.up();
}
}