UI: make loadpoints sortable and hideable (#23621)
Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
parent
87d66ed98c
commit
5617c19bb9
35 changed files with 908 additions and 224 deletions
|
|
@ -198,7 +198,9 @@ This file provides guidance to AI coding agents when working with code in this r
|
|||
- **Examples**:
|
||||
- `page.getByRole("button", { name: "Add charger" })`
|
||||
- `page.getByLabel("Manufacturer").selectOption("Demo charger")`
|
||||
- `page.getByRole("listitem", { name: "Draggable: First Loadpoint" })` (using aria-label)
|
||||
- `page.getByTestId("loadpoint")` (fallback only)
|
||||
- never use `.locator()` or `class` and `id`-based selectors
|
||||
|
||||
### Test Patterns
|
||||
|
||||
|
|
|
|||
|
|
@ -296,6 +296,11 @@ a:hover {
|
|||
outline-width: var(--bs-focus-ring-width);
|
||||
}
|
||||
|
||||
.btn-link:disabled {
|
||||
border-color: transparent !important;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.dark .btn-outline-secondary {
|
||||
--bs-btn-color: var(--bs-gray-bright);
|
||||
--bs-btn-border-color: var(--bs-gray-bright);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
test-id="generalconfig-telemetry"
|
||||
:label="$t('config.general.telemetry')"
|
||||
:text="$t(`config.general.${telemetryEnabled ? 'on' : 'off'}`)"
|
||||
modal-id="globalSettingsModal"
|
||||
modal-id="telemetryModal"
|
||||
/>
|
||||
|
||||
<GeneralConfigEntry
|
||||
|
|
|
|||
27
assets/js/components/Config/TelemetryModal.vue
Normal file
27
assets/js/components/Config/TelemetryModal.vue
Normal 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>
|
||||
|
|
@ -7,7 +7,7 @@ export default {
|
|||
component: Energyflow,
|
||||
} as Meta<typeof Energyflow>;
|
||||
|
||||
const Template: StoryFn<typeof Energyflow> = (args) => ({
|
||||
const Template: StoryFn<typeof Energyflow> = (args: any) => ({
|
||||
components: { Energyflow },
|
||||
setup() {
|
||||
return { args };
|
||||
|
|
@ -22,39 +22,33 @@ GridAndPV.args = {
|
|||
pvPower: 7300,
|
||||
gridPower: -2300,
|
||||
homePower: 800,
|
||||
loadpointsCompact: [
|
||||
loadpoints: [
|
||||
{
|
||||
power: 1000,
|
||||
icon: "car",
|
||||
charging: true,
|
||||
title: "Garage",
|
||||
chargePower: 1000,
|
||||
connected: true,
|
||||
index: 0,
|
||||
vehicleName: "",
|
||||
vehicleSoc: 50,
|
||||
chargerFeatureHeating: false,
|
||||
},
|
||||
{
|
||||
power: 1000,
|
||||
icon: "bike",
|
||||
charging: true,
|
||||
title: "Garage",
|
||||
chargePower: 1000,
|
||||
connected: true,
|
||||
index: 1,
|
||||
vehicleName: "",
|
||||
vehicleSoc: 50,
|
||||
chargerFeatureHeating: false,
|
||||
},
|
||||
{
|
||||
power: 2200,
|
||||
icon: "car",
|
||||
charging: true,
|
||||
title: "Garage",
|
||||
chargePower: 2200,
|
||||
connected: true,
|
||||
index: 2,
|
||||
vehicleName: "",
|
||||
vehicleSoc: 50,
|
||||
chargerFeatureHeating: false,
|
||||
|
|
@ -72,7 +66,7 @@ GridAndPV.args = {
|
|||
dayAfterTomorrow: { energy: 1000, complete: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
} as any;
|
||||
|
||||
export const BatteryAndGrid = Template.bind({});
|
||||
BatteryAndGrid.args = {
|
||||
|
|
@ -91,7 +85,7 @@ BatteryAndGrid.args = {
|
|||
{ soc: 44.999, capacity: 13.3, power: 350, controllable: true },
|
||||
{ soc: 82.3331, capacity: 21, power: 450, controllable: false },
|
||||
],
|
||||
};
|
||||
} as any;
|
||||
|
||||
export const BatteryCharging = Template.bind({});
|
||||
BatteryCharging.args = {
|
||||
|
|
@ -101,15 +95,13 @@ BatteryCharging.args = {
|
|||
pvPower: 5000,
|
||||
gridPower: -1300,
|
||||
homePower: 800,
|
||||
loadpointsCompact: [
|
||||
loadpoints: [
|
||||
{
|
||||
power: 1400,
|
||||
icon: "car",
|
||||
charging: true,
|
||||
title: "Garage",
|
||||
chargePower: 1400,
|
||||
connected: true,
|
||||
index: 0,
|
||||
vehicleName: "",
|
||||
vehicleSoc: 50,
|
||||
chargerFeatureHeating: false,
|
||||
|
|
@ -117,7 +109,7 @@ BatteryCharging.args = {
|
|||
],
|
||||
batteryPower: -1500,
|
||||
batterySoc: 75,
|
||||
};
|
||||
} as any;
|
||||
|
||||
export const GridPVAndBattery = Template.bind({});
|
||||
GridPVAndBattery.args = {
|
||||
|
|
@ -129,7 +121,7 @@ GridPVAndBattery.args = {
|
|||
homePower: 3300,
|
||||
batteryPower: 1500,
|
||||
batterySoc: 30,
|
||||
};
|
||||
} as any;
|
||||
|
||||
export const BatteryThresholds = Template.bind({});
|
||||
BatteryThresholds.args = {
|
||||
|
|
@ -138,27 +130,23 @@ BatteryThresholds.args = {
|
|||
batteryConfigured: true,
|
||||
pvPower: 8700,
|
||||
gridPower: -500,
|
||||
loadpointsCompact: [
|
||||
loadpoints: [
|
||||
{
|
||||
power: 5000,
|
||||
icon: "car",
|
||||
charging: true,
|
||||
title: "Garage",
|
||||
chargePower: 5000,
|
||||
connected: true,
|
||||
index: 0,
|
||||
vehicleName: "",
|
||||
vehicleSoc: 50,
|
||||
chargerFeatureHeating: false,
|
||||
},
|
||||
{
|
||||
power: 2500,
|
||||
icon: "bus",
|
||||
charging: true,
|
||||
title: "Garage",
|
||||
chargePower: 2500,
|
||||
connected: true,
|
||||
index: 1,
|
||||
vehicleName: "",
|
||||
vehicleSoc: 50,
|
||||
chargerFeatureHeating: false,
|
||||
|
|
@ -166,7 +154,7 @@ BatteryThresholds.args = {
|
|||
],
|
||||
batteryPower: -700,
|
||||
batterySoc: 95,
|
||||
};
|
||||
} as any;
|
||||
|
||||
export const PVThresholds = Template.bind({});
|
||||
PVThresholds.args = {
|
||||
|
|
@ -176,27 +164,23 @@ PVThresholds.args = {
|
|||
pvPower: 300,
|
||||
gridPower: 6500,
|
||||
homePower: 1000,
|
||||
loadpointsCompact: [
|
||||
loadpoints: [
|
||||
{
|
||||
power: 5000,
|
||||
icon: "car",
|
||||
charging: true,
|
||||
title: "Garage",
|
||||
chargePower: 5000,
|
||||
connected: true,
|
||||
index: 0,
|
||||
vehicleName: "",
|
||||
vehicleSoc: 50,
|
||||
chargerFeatureHeating: false,
|
||||
},
|
||||
{
|
||||
power: 1600,
|
||||
icon: "car",
|
||||
charging: true,
|
||||
title: "Garage",
|
||||
chargePower: 1600,
|
||||
connected: true,
|
||||
index: 1,
|
||||
vehicleName: "",
|
||||
vehicleSoc: 50,
|
||||
chargerFeatureHeating: false,
|
||||
|
|
@ -204,7 +188,7 @@ PVThresholds.args = {
|
|||
],
|
||||
batteryPower: 800,
|
||||
batterySoc: 76,
|
||||
};
|
||||
} as any;
|
||||
|
||||
export const GridOnly = Template.bind({});
|
||||
GridOnly.args = {
|
||||
|
|
@ -214,51 +198,43 @@ GridOnly.args = {
|
|||
pvPower: 0,
|
||||
gridPower: 6500,
|
||||
homePower: 1000,
|
||||
loadpointsCompact: [
|
||||
loadpoints: [
|
||||
{
|
||||
power: 5500,
|
||||
icon: "car",
|
||||
charging: true,
|
||||
title: "Garage",
|
||||
chargePower: 5500,
|
||||
connected: true,
|
||||
index: 0,
|
||||
vehicleName: "",
|
||||
vehicleSoc: 50,
|
||||
chargerFeatureHeating: false,
|
||||
},
|
||||
{
|
||||
power: 0,
|
||||
icon: "car",
|
||||
charging: false,
|
||||
title: "Garage",
|
||||
chargePower: 0,
|
||||
connected: false,
|
||||
index: 1,
|
||||
vehicleName: "",
|
||||
vehicleSoc: 50,
|
||||
chargerFeatureHeating: false,
|
||||
},
|
||||
{
|
||||
power: 0,
|
||||
icon: "car",
|
||||
charging: false,
|
||||
title: "Garage",
|
||||
chargePower: 0,
|
||||
connected: false,
|
||||
index: 2,
|
||||
vehicleName: "",
|
||||
vehicleSoc: 50,
|
||||
chargerFeatureHeating: false,
|
||||
},
|
||||
{
|
||||
power: 0,
|
||||
icon: "car",
|
||||
charging: false,
|
||||
title: "Garage",
|
||||
chargePower: 0,
|
||||
connected: false,
|
||||
index: 3,
|
||||
vehicleName: "",
|
||||
vehicleSoc: 50,
|
||||
chargerFeatureHeating: false,
|
||||
|
|
@ -266,7 +242,7 @@ GridOnly.args = {
|
|||
],
|
||||
batteryPower: 0,
|
||||
batterySoc: 0,
|
||||
};
|
||||
} as any;
|
||||
|
||||
export const LowPower = Template.bind({});
|
||||
LowPower.args = {
|
||||
|
|
@ -281,7 +257,7 @@ LowPower.args = {
|
|||
tariffGrid: 0.25,
|
||||
tariffFeedIn: 0.08,
|
||||
currency: CURRENCY.EUR,
|
||||
};
|
||||
} as any;
|
||||
|
||||
export const CO2 = Template.bind({});
|
||||
CO2.args = {
|
||||
|
|
@ -290,39 +266,33 @@ CO2.args = {
|
|||
pvPower: 7300,
|
||||
gridPower: -2300,
|
||||
homePower: 800,
|
||||
loadpointsCompact: [
|
||||
loadpoints: [
|
||||
{
|
||||
power: 1000,
|
||||
icon: "car",
|
||||
charging: true,
|
||||
title: "Garage",
|
||||
chargePower: 1000,
|
||||
connected: true,
|
||||
index: 0,
|
||||
vehicleName: "",
|
||||
vehicleSoc: 50,
|
||||
chargerFeatureHeating: false,
|
||||
},
|
||||
{
|
||||
power: 1000,
|
||||
icon: "car",
|
||||
charging: true,
|
||||
title: "Garage",
|
||||
chargePower: 1000,
|
||||
connected: true,
|
||||
index: 1,
|
||||
vehicleName: "",
|
||||
vehicleSoc: 50,
|
||||
chargerFeatureHeating: false,
|
||||
},
|
||||
{
|
||||
power: 2200,
|
||||
icon: "car",
|
||||
charging: true,
|
||||
title: "Garage",
|
||||
chargePower: 2200,
|
||||
connected: true,
|
||||
index: 2,
|
||||
vehicleName: "",
|
||||
vehicleSoc: 50,
|
||||
chargerFeatureHeating: false,
|
||||
|
|
@ -334,7 +304,7 @@ CO2.args = {
|
|||
smartCostType: "co2",
|
||||
currency: CURRENCY.EUR,
|
||||
pv: [{ power: 5000 }, { power: 2300 }],
|
||||
};
|
||||
} as any;
|
||||
|
||||
export const UnknownInput = Template.bind({});
|
||||
UnknownInput.args = {
|
||||
|
|
@ -342,21 +312,19 @@ UnknownInput.args = {
|
|||
pvConfigured: true,
|
||||
pvPower: 2000,
|
||||
gridPower: -2000,
|
||||
loadpointsCompact: [
|
||||
loadpoints: [
|
||||
{
|
||||
power: 1000,
|
||||
icon: "car",
|
||||
charging: true,
|
||||
title: "Garage",
|
||||
chargePower: 1000,
|
||||
connected: true,
|
||||
index: 0,
|
||||
vehicleName: "",
|
||||
vehicleSoc: 50,
|
||||
chargerFeatureHeating: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
} as any;
|
||||
|
||||
export const UnknownInputFill = Template.bind({});
|
||||
UnknownInputFill.args = {
|
||||
|
|
@ -366,8 +334,8 @@ UnknownInputFill.args = {
|
|||
pvPower: 500,
|
||||
gridPower: 0,
|
||||
batteryPower: -1000,
|
||||
loadpointsCompact: [],
|
||||
};
|
||||
loadpoints: [],
|
||||
} as any;
|
||||
|
||||
export const UnknownOutput = Template.bind({});
|
||||
UnknownOutput.args = {
|
||||
|
|
@ -375,21 +343,19 @@ UnknownOutput.args = {
|
|||
pvConfigured: true,
|
||||
pvPower: 3000,
|
||||
gridPower: -1000,
|
||||
loadpointsCompact: [
|
||||
loadpoints: [
|
||||
{
|
||||
power: 1700,
|
||||
chargePower: 1700,
|
||||
icon: "car",
|
||||
charging: true,
|
||||
title: "Garage",
|
||||
chargePower: 1700,
|
||||
connected: true,
|
||||
index: 0,
|
||||
vehicleName: "",
|
||||
vehicleSoc: 50,
|
||||
chargerFeatureHeating: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
} as any;
|
||||
|
||||
export const UnknownOutputLessThan10Percent = Template.bind({});
|
||||
UnknownOutputLessThan10Percent.args = {
|
||||
|
|
@ -397,18 +363,16 @@ UnknownOutputLessThan10Percent.args = {
|
|||
pvConfigured: true,
|
||||
pvPower: 3000,
|
||||
gridPower: -1000,
|
||||
loadpointsCompact: [
|
||||
loadpoints: [
|
||||
{
|
||||
power: 1800,
|
||||
chargePower: 1800,
|
||||
icon: "car",
|
||||
charging: true,
|
||||
title: "Garage",
|
||||
chargePower: 1800,
|
||||
connected: true,
|
||||
index: 0,
|
||||
vehicleName: "",
|
||||
vehicleSoc: 50,
|
||||
chargerFeatureHeating: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
} as any;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
:gridImport="gridImport"
|
||||
:selfPv="selfPv"
|
||||
:selfBattery="selfBattery"
|
||||
:loadpoints="loadpointsCompact"
|
||||
:loadpoints="loadpoints"
|
||||
:pvExport="pvExport"
|
||||
:batteryCharge="batteryCharge"
|
||||
:batteryDischarge="batteryDischarge"
|
||||
|
|
@ -194,14 +194,18 @@
|
|||
<template v-if="activeLoadpointsCount > 0" #expanded>
|
||||
<EnergyflowEntry
|
||||
v-for="lp in activeLoadpoints"
|
||||
:key="lp.index"
|
||||
:name="lp.title"
|
||||
:power="lp.power"
|
||||
:key="lp.id"
|
||||
:name="lp.displayTitle"
|
||||
:power="lp.chargePower"
|
||||
:powerUnit="powerUnit"
|
||||
icon="vehicle"
|
||||
:iconProps="{ names: [lp.icon] }"
|
||||
:details="lp.soc || undefined"
|
||||
:detailsFmt="lp.heating ? fmtLoadpointTemp : fmtLoadpointSoc"
|
||||
:details="lp.vehicleSoc || undefined"
|
||||
:detailsFmt="
|
||||
lp.chargerFeatureHeating
|
||||
? fmtLoadpointTemp
|
||||
: fmtLoadpointSoc
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</EnergyflowEntry>
|
||||
|
|
@ -289,7 +293,7 @@ import {
|
|||
type Battery,
|
||||
type CURRENCY,
|
||||
type Forecast,
|
||||
type LoadpointCompact,
|
||||
type UiLoadpoint,
|
||||
} from "@/types/evcc";
|
||||
|
||||
export default defineComponent({
|
||||
|
|
@ -307,7 +311,7 @@ export default defineComponent({
|
|||
pvConfigured: Boolean,
|
||||
pv: { type: Array as PropType<Pv[]> },
|
||||
pvPower: { type: Number, default: 0 },
|
||||
loadpointsCompact: { type: Array as PropType<LoadpointCompact[]>, default: () => [] },
|
||||
loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
|
||||
batteryConfigured: { type: Boolean },
|
||||
battery: { type: Array as PropType<Battery[]> },
|
||||
batteryPower: { type: Number, default: 0 },
|
||||
|
|
@ -365,7 +369,7 @@ export default defineComponent({
|
|||
return Math.min(this.batteryDischarge, this.consumption - this.selfPv);
|
||||
},
|
||||
activeLoadpoints() {
|
||||
return this.loadpointsCompact.filter((lp) => lp.charging);
|
||||
return this.loadpoints.filter((lp) => lp.charging);
|
||||
},
|
||||
activeLoadpointsCount() {
|
||||
return this.activeLoadpoints.length;
|
||||
|
|
@ -377,8 +381,8 @@ export default defineComponent({
|
|||
return ["car"];
|
||||
},
|
||||
loadpointsPower() {
|
||||
return this.loadpointsCompact.reduce((sum, lp) => {
|
||||
return sum + (lp.power || 0);
|
||||
return this.loadpoints.reduce((sum, lp) => {
|
||||
return sum + (lp.chargePower || 0);
|
||||
}, 0);
|
||||
},
|
||||
pvExport() {
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@
|
|||
<LabelBar
|
||||
v-for="(lp, index) in loadpoints"
|
||||
:key="index"
|
||||
v-bind="labelBarProps('bottom', 'loadpoints', lp.power)"
|
||||
v-bind="labelBarProps('bottom', 'loadpoints', lp.chargePower)"
|
||||
>
|
||||
<VehicleIcon :names="[lp.icon]" />
|
||||
</LabelBar>
|
||||
|
|
@ -116,7 +116,7 @@ import QuestionIcon from "../MaterialIcon/Question.vue";
|
|||
import "@h2d2/shopicons/es/regular/sun";
|
||||
import "@h2d2/shopicons/es/regular/home";
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import type { LoadpointCompact } from "@/types/evcc";
|
||||
import type { UiLoadpoint } from "@/types/evcc";
|
||||
|
||||
export default defineComponent({
|
||||
name: "Visualization",
|
||||
|
|
@ -127,7 +127,7 @@ export default defineComponent({
|
|||
selfPv: { type: Number, default: 0 },
|
||||
selfBattery: { 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 },
|
||||
batteryDischarge: { type: Number, default: 0 },
|
||||
batteryHold: { type: Boolean, default: false },
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
:title="$t('settings.title')"
|
||||
data-testid="global-settings-modal"
|
||||
>
|
||||
<UserInterfaceSettings :sponsor="sponsor" :telemetry="telemetry" />
|
||||
<UserInterfaceSettings :loadpoints="uiLoadpoints" />
|
||||
</GenericModal>
|
||||
</template>
|
||||
|
||||
|
|
@ -12,14 +12,13 @@
|
|||
import { defineComponent, type PropType } from "vue";
|
||||
import GenericModal from "../Helper/GenericModal.vue";
|
||||
import UserInterfaceSettings from "./UserInterfaceSettings.vue";
|
||||
import type { Sponsor } from "@/types/evcc";
|
||||
import type { UiLoadpoint } from "@/types/evcc";
|
||||
|
||||
export default defineComponent({
|
||||
name: "GlobalSettingsModal",
|
||||
components: { GenericModal, UserInterfaceSettings },
|
||||
props: {
|
||||
sponsor: { type: Object as PropType<Sponsor>, default: () => ({}) },
|
||||
telemetry: Boolean,
|
||||
uiLoadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
101
assets/js/components/GlobalSettings/LoadpointOrderSettings.vue
Normal file
101
assets/js/components/GlobalSettings/LoadpointOrderSettings.vue
Normal 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>
|
||||
|
|
@ -59,12 +59,20 @@
|
|||
equal-width
|
||||
/>
|
||||
</FormRow>
|
||||
<FormRow id="telemetryEnabled" :label="$t('settings.telemetry.label')">
|
||||
<TelemetrySettings
|
||||
:sponsorActive="sponsor && !!sponsor.name"
|
||||
:telemetry="telemetry"
|
||||
class="mt-1 mb-0"
|
||||
/>
|
||||
<FormRow v-if="loadpoints.length" :label="$t('settings.loadpoints.label')">
|
||||
<LoadpointOrderSettings :loadpoints="loadpoints" />
|
||||
</FormRow>
|
||||
<FormRow v-if="fullscreenAvailable" :label="$t('settings.fullscreen.label')">
|
||||
<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 id="hiddenFeaturesEnabled" :label="`${$t('settings.hiddenFeatures.label')} 🧪`">
|
||||
<div class="form-check form-switch my-1">
|
||||
|
|
@ -82,25 +90,16 @@
|
|||
</div>
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow v-if="fullscreenAvailable" :label="$t('settings.fullscreen.label')">
|
||||
<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>
|
||||
<div class="small text-muted mb-3">
|
||||
{{ $t("settings.deviceInfo") }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import TelemetrySettings from "../TelemetrySettings.vue";
|
||||
import FormRow from "../Helper/FormRow.vue";
|
||||
import SelectGroup from "../Helper/SelectGroup.vue";
|
||||
import LoadpointOrderSettings from "./LoadpointOrderSettings.vue";
|
||||
import {
|
||||
getLocalePreference,
|
||||
setLocalePreference,
|
||||
|
|
@ -112,17 +111,16 @@ import { getUnits, setUnits, is12hFormat, set12hFormat } from "@/units";
|
|||
import { getHiddenFeatures, setHiddenFeatures } from "@/featureflags.ts";
|
||||
import { isApp } from "@/utils/native";
|
||||
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_24H = "24";
|
||||
|
||||
export default defineComponent({
|
||||
name: "UserInterfaceSettings",
|
||||
components: { TelemetrySettings, FormRow, SelectGroup },
|
||||
components: { FormRow, SelectGroup, LoadpointOrderSettings },
|
||||
props: {
|
||||
sponsor: Object as PropType<Sponsor>,
|
||||
telemetry: Boolean,
|
||||
loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -184,6 +182,7 @@ export default defineComponent({
|
|||
document.removeEventListener("fullscreenchange", this.fullscreenChange);
|
||||
},
|
||||
methods: {
|
||||
isApp,
|
||||
enterFullscreen() {
|
||||
document.documentElement.requestFullscreen();
|
||||
},
|
||||
|
|
|
|||
76
assets/js/components/Helper/DragDropItem.vue
Normal file
76
assets/js/components/Helper/DragDropItem.vue
Normal 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>
|
||||
37
assets/js/components/Helper/DragDropList.vue
Normal file
37
assets/js/components/Helper/DragDropList.vue
Normal 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>
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
import Loadpoint from "./Loadpoint.vue";
|
||||
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 {
|
||||
title: "Loadpoints/Loadpoint",
|
||||
|
|
@ -12,9 +18,9 @@ export default {
|
|||
|
||||
// Based on actual API state structure from demo.evcc.io
|
||||
const baseState = {
|
||||
id: 1,
|
||||
id: "1",
|
||||
title: "Carport",
|
||||
mode: "pv",
|
||||
mode: CHARGE_MODE.PV,
|
||||
enabled: true,
|
||||
connected: true,
|
||||
socBasedCharging: true,
|
||||
|
|
@ -50,9 +56,9 @@ const baseState = {
|
|||
planProjectedStart: undefined,
|
||||
planTime: undefined,
|
||||
priority: 0,
|
||||
phaseAction: "inactive",
|
||||
phaseAction: PHASE_ACTION.INACTIVE,
|
||||
phaseRemaining: 0,
|
||||
pvAction: "inactive",
|
||||
pvAction: PV_ACTION.INACTIVE,
|
||||
pvRemaining: 0,
|
||||
smartCostActive: false,
|
||||
smartCostNextStart: undefined,
|
||||
|
|
@ -65,7 +71,7 @@ const baseState = {
|
|||
chargerFeatureIntegratedDevice: false,
|
||||
chargerIcon: "",
|
||||
chargerSinglePhase: false,
|
||||
chargerStatusReason: "unknown",
|
||||
chargerStatusReason: CHARGER_STATUS_REASON.UNKNOWN,
|
||||
connectedDuration: 0,
|
||||
// Global props that would typically come from parent
|
||||
vehicles: [
|
||||
|
|
@ -125,7 +131,7 @@ Idle.args = {
|
|||
connected: false,
|
||||
vehicleName: "",
|
||||
vehicles: [],
|
||||
mode: "off",
|
||||
mode: CHARGE_MODE.OFF,
|
||||
charging: false,
|
||||
chargePower: 0,
|
||||
offeredCurrent: 0,
|
||||
|
|
@ -139,7 +145,7 @@ DisabledLongTitle.args = {
|
|||
title: "Charging point with a very very very long title!!!1!",
|
||||
remoteDisabled: "soft",
|
||||
remoteDisabledSource: "Sunny Home Manager",
|
||||
mode: "now",
|
||||
mode: CHARGE_MODE.NOW,
|
||||
enabled: false,
|
||||
charging: false,
|
||||
chargePower: 0,
|
||||
|
|
@ -150,7 +156,7 @@ ChargerIconNoVehicle.args = {
|
|||
...baseState,
|
||||
chargerIcon: "heater",
|
||||
title: "Heating device with long name",
|
||||
mode: "now",
|
||||
mode: CHARGE_MODE.NOW,
|
||||
chargerFeatureIntegratedDevice: true,
|
||||
vehicleName: "",
|
||||
vehicles: [],
|
||||
|
|
|
|||
|
|
@ -110,6 +110,9 @@ import { defineComponent, type PropType } from "vue";
|
|||
import type {
|
||||
CHARGE_MODE,
|
||||
PHASES,
|
||||
PHASE_ACTION,
|
||||
PV_ACTION,
|
||||
CHARGER_STATUS_REASON,
|
||||
Timeout,
|
||||
Vehicle,
|
||||
Forecast,
|
||||
|
|
@ -130,12 +133,12 @@ export default defineComponent({
|
|||
},
|
||||
mixins: [formatter, collector],
|
||||
props: {
|
||||
id: Number,
|
||||
id: String,
|
||||
single: Boolean,
|
||||
|
||||
// main
|
||||
title: String,
|
||||
mode: String,
|
||||
mode: String as PropType<CHARGE_MODE>,
|
||||
effectiveLimitSoc: Number,
|
||||
limitEnergy: Number,
|
||||
remoteDisabled: String,
|
||||
|
|
@ -147,16 +150,16 @@ export default defineComponent({
|
|||
|
||||
// session
|
||||
sessionEnergy: Number,
|
||||
sessionCo2PerKWh: Number,
|
||||
sessionPricePerKWh: Number,
|
||||
sessionPrice: Number,
|
||||
sessionCo2PerKWh: Number as PropType<number | null>,
|
||||
sessionPricePerKWh: Number as PropType<number | null>,
|
||||
sessionPrice: Number as PropType<number | null>,
|
||||
sessionSolarPercentage: Number,
|
||||
|
||||
// charger
|
||||
chargerStatusReason: String,
|
||||
chargerStatusReason: String as PropType<CHARGER_STATUS_REASON | null>,
|
||||
chargerFeatureIntegratedDevice: Boolean,
|
||||
chargerFeatureHeating: Boolean,
|
||||
chargerIcon: String,
|
||||
chargerIcon: String as PropType<string | null>,
|
||||
|
||||
// vehicle
|
||||
connected: Boolean,
|
||||
|
|
@ -170,20 +173,20 @@ export default defineComponent({
|
|||
vehicleLimitSoc: Number,
|
||||
vehicles: Array as PropType<Vehicle[]>,
|
||||
planActive: Boolean,
|
||||
planProjectedStart: String,
|
||||
planProjectedEnd: String,
|
||||
planProjectedStart: String as PropType<string | null>,
|
||||
planProjectedEnd: String as PropType<string | null>,
|
||||
planOverrun: { type: Number, default: 0 },
|
||||
planEnergy: Number,
|
||||
planPrecondition: Number,
|
||||
planTime: String,
|
||||
effectivePlanTime: String,
|
||||
planTime: String as PropType<string | null>,
|
||||
effectivePlanTime: String as PropType<string | null>,
|
||||
effectivePlanSoc: Number,
|
||||
vehicleProviderLoggedIn: Boolean,
|
||||
vehicleProviderLoginPath: String,
|
||||
vehicleProviderLogoutPath: String,
|
||||
|
||||
// details
|
||||
vehicleClimaterActive: Boolean,
|
||||
vehicleClimaterActive: Boolean as PropType<boolean | null>,
|
||||
vehicleWelcomeActive: Boolean,
|
||||
chargePower: { type: Number, default: 0 },
|
||||
chargedEnergy: { type: Number, default: 0 },
|
||||
|
|
@ -200,19 +203,19 @@ export default defineComponent({
|
|||
connectedDuration: Number,
|
||||
chargeCurrents: Array,
|
||||
chargeRemainingEnergy: Number,
|
||||
phaseAction: String,
|
||||
phaseAction: String as PropType<PHASE_ACTION>,
|
||||
phaseRemaining: { type: Number, default: 0 },
|
||||
pvRemaining: { type: Number, default: 0 },
|
||||
pvAction: String,
|
||||
smartCostLimit: { type: Number, default: null },
|
||||
pvAction: String as PropType<PV_ACTION>,
|
||||
smartCostLimit: { type: Number as PropType<number | null>, default: null },
|
||||
smartCostType: String as PropType<SMART_COST_TYPE>,
|
||||
smartCostAvailable: Boolean,
|
||||
smartCostActive: Boolean,
|
||||
smartCostNextStart: String,
|
||||
smartFeedInPriorityLimit: { type: Number, default: null },
|
||||
smartCostNextStart: String as PropType<string | null>,
|
||||
smartFeedInPriorityLimit: { type: Number as PropType<number | null>, default: null },
|
||||
smartFeedInPriorityAvailable: Boolean,
|
||||
smartFeedInPriorityActive: Boolean,
|
||||
smartFeedInPriorityNextStart: String,
|
||||
smartFeedInPriorityNextStart: String as PropType<string | null>,
|
||||
tariffGrid: Number,
|
||||
tariffFeedIn: Number,
|
||||
tariffCo2: Number,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { Meta, StoryFn } from "@storybook/vue3";
|
|||
import { SMART_COST_TYPE } from "@/types/evcc";
|
||||
|
||||
// Create LoadpointCompact structure for the Loadpoints component
|
||||
const createLoadpoint = (opts = {}) => {
|
||||
const createLoadpoint = (opts: any = {}) => {
|
||||
const base = {
|
||||
icon: "car",
|
||||
title: "Carport",
|
||||
|
|
@ -18,7 +18,9 @@ const createLoadpoint = (opts = {}) => {
|
|||
vehicleSoc: 0,
|
||||
chargerFeatureHeating: false,
|
||||
};
|
||||
return { ...base, ...opts };
|
||||
const result = { ...base, ...opts };
|
||||
(result as any).id = result.index + 1;
|
||||
return result;
|
||||
};
|
||||
|
||||
export default {
|
||||
|
|
|
|||
|
|
@ -8,13 +8,12 @@
|
|||
:class="`carousel--${loadpoints.length}`"
|
||||
>
|
||||
<div
|
||||
v-for="(loadpoint, index) in loadpoints"
|
||||
:key="index"
|
||||
v-for="loadpoint in loadpoints"
|
||||
:key="loadpoint.id"
|
||||
class="flex-grow-1 mb-3 m-lg-0 p-lg-0"
|
||||
>
|
||||
<Loadpoint
|
||||
v-bind="loadpoint"
|
||||
:id="index + 1"
|
||||
data-testid="loadpoint"
|
||||
:vehicles="vehicles"
|
||||
:smartCostType="smartCostType"
|
||||
|
|
@ -30,18 +29,18 @@
|
|||
:batteryConfigured="batteryConfigured"
|
||||
:forecast="forecast"
|
||||
class="h-100"
|
||||
:class="{ 'loadpoint-unselected': !selected(index) }"
|
||||
@click="goTo(index)"
|
||||
:class="{ 'loadpoint-unselected': !selected(loadpoint.id) }"
|
||||
@click="goTo(loadpoint.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loadpoints.length > 1" class="d-flex d-lg-none justify-content-center">
|
||||
<button
|
||||
v-for="(loadpoint, index) in loadpoints"
|
||||
:key="index"
|
||||
v-for="loadpoint in loadpoints"
|
||||
: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="{ 'indicator--selected': selected(index) }"
|
||||
@click="goTo(index)"
|
||||
:class="{ 'indicator--selected': selected(loadpoint.id) }"
|
||||
@click="goTo(loadpoint.id)"
|
||||
>
|
||||
<shopicon-filled-lightning
|
||||
v-if="isCharging(loadpoint)"
|
||||
|
|
@ -64,13 +63,13 @@ import "@h2d2/shopicons/es/filled/lightning";
|
|||
|
||||
import Loadpoint from "./Loadpoint.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({
|
||||
name: "Loadpoints",
|
||||
components: { Loadpoint },
|
||||
props: {
|
||||
loadpoints: { type: Array as PropType<LoadpointCompact[]>, default: () => [] },
|
||||
loadpoints: { type: Array as PropType<UiLoadpoint[]>, default: () => [] },
|
||||
vehicles: { type: Array as PropType<Vehicle[]> },
|
||||
smartCostType: String as PropType<SMART_COST_TYPE>,
|
||||
smartCostAvailable: Boolean,
|
||||
|
|
@ -79,13 +78,13 @@ export default defineComponent({
|
|||
tariffCo2: Number,
|
||||
tariffFeedIn: Number,
|
||||
currency: String,
|
||||
selectedIndex: { type: Number, default: 0 },
|
||||
selectedId: String,
|
||||
gridConfigured: Boolean,
|
||||
pvConfigured: Boolean,
|
||||
batteryConfigured: Boolean,
|
||||
forecast: Object, // as PropType<Forecast>,
|
||||
},
|
||||
emits: ["index-changed"],
|
||||
emits: ["id-changed"],
|
||||
data() {
|
||||
return {
|
||||
snapTimeout: null as Timeout,
|
||||
|
|
@ -93,6 +92,11 @@ export default defineComponent({
|
|||
highlightedIndex: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
selectedIndex() {
|
||||
return this.indexById(this.selectedId);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedIndex(newIndex) {
|
||||
this.scrollTo(newIndex);
|
||||
|
|
@ -108,6 +112,12 @@ export default defineComponent({
|
|||
this.$refs["carousel"]?.removeEventListener("scroll", this.handleCarouselScroll);
|
||||
},
|
||||
methods: {
|
||||
indexById(id: string | undefined) {
|
||||
return this.loadpoints.findIndex((lp) => lp.id === id) || 0;
|
||||
},
|
||||
idByIndex(index: number) {
|
||||
return this.loadpoints[index]?.id;
|
||||
},
|
||||
handleCarouselScroll() {
|
||||
const { scrollLeft } = this.$refs["carousel"] as HTMLElement;
|
||||
const { offsetWidth } = this.$refs["carousel"]?.children[0] as HTMLElement;
|
||||
|
|
@ -119,18 +129,18 @@ export default defineComponent({
|
|||
}
|
||||
this.scrollTimeout = setTimeout(() => {
|
||||
if (this.highlightedIndex !== this.selectedIndex) {
|
||||
this.$emit("index-changed", this.highlightedIndex);
|
||||
this.$emit("id-changed", this.idByIndex(this.highlightedIndex));
|
||||
}
|
||||
}, 2000);
|
||||
},
|
||||
goTo(index: number) {
|
||||
this.$emit("index-changed", index);
|
||||
goTo(id: string) {
|
||||
this.$emit("id-changed", id);
|
||||
},
|
||||
isCharging(lp: LoadpointCompact) {
|
||||
isCharging(lp: UiLoadpoint) {
|
||||
return lp.charging && lp.chargePower > 0;
|
||||
},
|
||||
selected(index: number) {
|
||||
return this.highlightedIndex === index;
|
||||
selected(id: string) {
|
||||
return this.highlightedIndex === this.indexById(id);
|
||||
},
|
||||
left(index: number) {
|
||||
return (this.$refs["carousel"]?.children[0] as HTMLElement).offsetWidth * index;
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import { defineComponent, type PropType } from "vue";
|
|||
import LabelAndValue from "../Helper/LabelAndValue.vue";
|
||||
import CustomSelect from "../Helper/CustomSelect.vue";
|
||||
import formatter from "@/mixins/formatter";
|
||||
import { getSessionInfo, setSessionInfo } from "./session";
|
||||
import { getLoadpointSessionInfo, setLoadpointSessionInfo } from "@/uiLoadpoints";
|
||||
import type { CURRENCY, SelectOption, SessionInfoKey } from "@/types/evcc";
|
||||
|
||||
export default defineComponent({
|
||||
|
|
@ -42,7 +42,7 @@ export default defineComponent({
|
|||
},
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
id: { type: Number, default: 0 },
|
||||
id: String,
|
||||
sessionCo2PerKWh: { type: Number, default: 0 },
|
||||
sessionPricePerKWh: { type: Number, default: 0 },
|
||||
sessionPrice: { type: Number, default: 0 },
|
||||
|
|
@ -55,7 +55,7 @@ export default defineComponent({
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
selectedKey: getSessionInfo(this.id),
|
||||
selectedKey: this.id ? getLoadpointSessionInfo(this.id) : undefined,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -165,8 +165,8 @@ export default defineComponent({
|
|||
this.presist();
|
||||
},
|
||||
presist() {
|
||||
if (this.selectedKey) {
|
||||
setSessionInfo(this.id, this.selectedKey);
|
||||
if (this.selectedKey && this.id) {
|
||||
setLoadpointSessionInfo(this.id, this.selectedKey);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
<Loadpoints
|
||||
v-else-if="loadpoints.length > 0"
|
||||
class="mt-1 mt-sm-2 flex-grow-1"
|
||||
:loadpoints="loadpoints"
|
||||
:loadpoints="orderedVisibleLoadpoints"
|
||||
:vehicles="vehicleList"
|
||||
:smartCostType="smartCostType"
|
||||
:smartCostAvailable="smartCostAvailable"
|
||||
|
|
@ -65,8 +65,8 @@
|
|||
:batteryConfigured="batteryConfigured"
|
||||
:batterySoc="batterySoc"
|
||||
:forecast="forecast"
|
||||
:selectedIndex="selectedLoadpointIndex"
|
||||
@index-changed="selectedLoadpointChanged"
|
||||
:selectedId="selectedLoadpointId"
|
||||
@id-changed="selectedLoadpointChanged"
|
||||
/>
|
||||
<Footer v-bind="footer"></Footer>
|
||||
</div>
|
||||
|
|
@ -90,7 +90,6 @@ import type {
|
|||
Battery,
|
||||
CURRENCY,
|
||||
Forecast,
|
||||
LoadpointCompact,
|
||||
Notification,
|
||||
Circuit,
|
||||
SMART_COST_TYPE,
|
||||
|
|
@ -98,6 +97,7 @@ import type {
|
|||
FatalError,
|
||||
EvOpt,
|
||||
} from "@/types/evcc";
|
||||
import store from "@/store";
|
||||
import type { Grid } from "./types";
|
||||
|
||||
export default defineComponent({
|
||||
|
|
@ -113,8 +113,7 @@ export default defineComponent({
|
|||
},
|
||||
mixins: [formatter, collector],
|
||||
props: {
|
||||
loadpoints: { type: Array as PropType<LoadpointCompact[]>, default: () => [] },
|
||||
selectedLoadpointIndex: Number,
|
||||
selectedLoadpointId: String,
|
||||
|
||||
notifications: { type: Array as PropType<Notification[]>, default: () => [] },
|
||||
offline: Boolean,
|
||||
|
|
@ -165,6 +164,12 @@ export default defineComponent({
|
|||
evopt: { type: Object as PropType<EvOpt> },
|
||||
},
|
||||
computed: {
|
||||
loadpoints() {
|
||||
return store.uiLoadpoints.value;
|
||||
},
|
||||
orderedVisibleLoadpoints() {
|
||||
return this.loadpoints.filter((lp) => lp.visible);
|
||||
},
|
||||
batteryConfigured() {
|
||||
return this.battery?.length > 0;
|
||||
},
|
||||
|
|
@ -178,22 +183,7 @@ export default defineComponent({
|
|||
return this.collectProps(Energyflow);
|
||||
},
|
||||
loadpointTitles() {
|
||||
return this.loadpoints.map((lp) => lp.title);
|
||||
},
|
||||
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 };
|
||||
});
|
||||
return this.orderedVisibleLoadpoints.map((lp) => lp.displayTitle);
|
||||
},
|
||||
vehicleList() {
|
||||
const vehicles = this.vehicles || {};
|
||||
|
|
@ -235,11 +225,8 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
selectedLoadpointChanged(index: number) {
|
||||
this.$router.push({ query: { lp: index + 1 } });
|
||||
},
|
||||
vehicleTitle(vehicleName: string) {
|
||||
return this.vehicles?.[vehicleName]?.title;
|
||||
selectedLoadpointChanged(id: string | undefined) {
|
||||
this.$router.push({ query: { lp: id } });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export default function setupRouter(i18n: VueI18nInstance) {
|
|||
props: (route) => {
|
||||
const { lp } = route.query;
|
||||
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);
|
||||
return true;
|
||||
});
|
||||
router.afterEach(() => {
|
||||
hideAllModals();
|
||||
router.afterEach((to, from) => {
|
||||
// Only hide modals when the actual route path changes, not query parameters
|
||||
if (to.path !== from.path) {
|
||||
hideAllModals();
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,13 +11,14 @@ const SETTINGS_ENERGYFLOW_DETAILS = "settings_energyflow_details";
|
|||
const SETTINGS_ENERGYFLOW_PV = "settings_energyflow_pv";
|
||||
const SETTINGS_ENERGYFLOW_BATTERY = "settings_energyflow_battery";
|
||||
const SETTINGS_ENERGYFLOW_LOADPOINTS = "settings_energyflow_loadpoints";
|
||||
const SESSION_INFO = "session_info";
|
||||
const LOADPOINTS = "loadpoints";
|
||||
const SESSION_COLUMNS = "session_columns";
|
||||
const SAVINGS_PERIOD = "savings_period";
|
||||
const SAVINGS_REGION = "savings_region";
|
||||
const SESSIONS_GROUP = "sessions_group";
|
||||
const SESSIONS_TYPE = "sessions_type";
|
||||
const SETTINGS_SOLAR_ADJUSTED = "settings_solar_adjusted";
|
||||
|
||||
function read(key: string) {
|
||||
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 {
|
||||
locale: keyof typeof LOCALES | null;
|
||||
theme: THEME | null;
|
||||
|
|
@ -67,13 +94,13 @@ export interface Settings {
|
|||
energyflowPv: boolean;
|
||||
energyflowBattery: boolean;
|
||||
energyflowLoadpoints: boolean;
|
||||
sessionInfo: SessionInfoKey[];
|
||||
sessionColumns: string[];
|
||||
savingsPeriod: string;
|
||||
savingsRegion: string;
|
||||
sessionsGroup: string;
|
||||
sessionsType: string;
|
||||
solarAdjusted: boolean;
|
||||
loadpoints: Record<string, LoadpointSettings>;
|
||||
}
|
||||
|
||||
const settings: Settings = reactive({
|
||||
|
|
@ -86,13 +113,13 @@ const settings: Settings = reactive({
|
|||
energyflowPv: readBool(SETTINGS_ENERGYFLOW_PV),
|
||||
energyflowBattery: readBool(SETTINGS_ENERGYFLOW_BATTERY),
|
||||
energyflowLoadpoints: readBool(SETTINGS_ENERGYFLOW_LOADPOINTS),
|
||||
sessionInfo: readArray(SESSION_INFO) as SessionInfoKey[],
|
||||
sessionColumns: readArray(SESSION_COLUMNS),
|
||||
savingsPeriod: read(SAVINGS_PERIOD),
|
||||
savingsRegion: read(SAVINGS_REGION),
|
||||
sessionsGroup: read(SESSIONS_GROUP),
|
||||
sessionsType: read(SESSIONS_TYPE),
|
||||
solarAdjusted: readBool(SETTINGS_SOLAR_ADJUSTED),
|
||||
loadpoints: readJSON(LOADPOINTS),
|
||||
});
|
||||
|
||||
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.energyflowBattery, saveBool(SETTINGS_ENERGYFLOW_BATTERY));
|
||||
watch(() => settings.energyflowLoadpoints, saveBool(SETTINGS_ENERGYFLOW_LOADPOINTS));
|
||||
watch(() => settings.sessionInfo, saveArray(SESSION_INFO));
|
||||
watch(() => settings.sessionColumns as string[], saveArray(SESSION_COLUMNS));
|
||||
watch(() => settings.savingsPeriod, save(SAVINGS_PERIOD));
|
||||
watch(() => settings.savingsRegion, save(SAVINGS_REGION));
|
||||
watch(() => settings.sessionsGroup, save(SESSIONS_GROUP));
|
||||
watch(() => settings.sessionsType, save(SESSIONS_TYPE));
|
||||
watch(() => settings.solarAdjusted, saveBool(SETTINGS_SOLAR_ADJUSTED));
|
||||
watch(() => settings.loadpoints, saveJSON(LOADPOINTS), { deep: true });
|
||||
|
||||
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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { reactive } from "vue";
|
||||
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) {
|
||||
const prop = props.shift();
|
||||
|
|
@ -32,12 +35,27 @@ const initialState: State = {
|
|||
|
||||
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,
|
||||
uiLoadpoints,
|
||||
offline(value: boolean) {
|
||||
state.offline = value;
|
||||
},
|
||||
// @ts-expect-error no-explicit-any
|
||||
update(msg) {
|
||||
Object.keys(msg).forEach(function (k) {
|
||||
if (k === "log") {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export interface FatalError {
|
|||
export interface State {
|
||||
offline: boolean;
|
||||
startup?: boolean;
|
||||
loadpoints: [];
|
||||
loadpoints: Loadpoint[];
|
||||
forecast?: Forecast;
|
||||
currency?: CURRENCY;
|
||||
fatal?: FatalError[];
|
||||
|
|
@ -177,20 +177,87 @@ export enum LENGTH_UNIT {
|
|||
MILES = "mi",
|
||||
}
|
||||
|
||||
export interface LoadpointCompact {
|
||||
icon: string;
|
||||
title: string;
|
||||
charging: boolean;
|
||||
soc?: number;
|
||||
power: number;
|
||||
heating?: boolean;
|
||||
export interface Loadpoint {
|
||||
batteryBoost: boolean;
|
||||
chargeCurrents?: number[];
|
||||
chargeDuration: number;
|
||||
chargePower: number;
|
||||
connected: boolean;
|
||||
index: number;
|
||||
vehicleName: string;
|
||||
chargerIcon?: string;
|
||||
vehicleSoc: number;
|
||||
chargeRemainingDuration?: number;
|
||||
chargeRemainingEnergy?: number;
|
||||
chargeTotalImport?: number;
|
||||
chargeVoltages?: number[];
|
||||
chargedEnergy: number;
|
||||
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 {
|
||||
|
|
@ -238,6 +305,24 @@ export enum PHASES {
|
|||
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 {
|
||||
CHARGING = "charging",
|
||||
HEATING = "heating",
|
||||
|
|
|
|||
77
assets/js/uiLoadpoints.ts
Normal file
77
assets/js/uiLoadpoints.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
};
|
||||
9
assets/js/utils/debounce.ts
Normal file
9
assets/js/utils/debounce.ts
Normal 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;
|
||||
}
|
||||
17
assets/js/utils/useDebouncedComputed.ts
Normal file
17
assets/js/utils/useDebouncedComputed.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -67,20 +67,24 @@ export default defineComponent({
|
|||
batteryModalAvailabe() {
|
||||
return store.state.battery?.length;
|
||||
},
|
||||
state() {
|
||||
const { state, uiLoadpoints } = store;
|
||||
return { ...state, uiLoadpoints: uiLoadpoints.value };
|
||||
},
|
||||
globalSettingsProps() {
|
||||
return this.collectProps(GlobalSettingsModal, store.state);
|
||||
return this.collectProps(GlobalSettingsModal, this.state);
|
||||
},
|
||||
batterySettingsProps() {
|
||||
return this.collectProps(BatterySettingsModal, store.state);
|
||||
return this.collectProps(BatterySettingsModal, this.state);
|
||||
},
|
||||
offlineIndicatorProps() {
|
||||
return this.collectProps(OfflineIndicator, store.state);
|
||||
return this.collectProps(OfflineIndicator, this.state);
|
||||
},
|
||||
forecastModalProps() {
|
||||
return this.collectProps(ForecastModal, store.state);
|
||||
return this.collectProps(ForecastModal, this.state);
|
||||
},
|
||||
loginModalProps() {
|
||||
return this.collectProps(LoginModal, store.state);
|
||||
return this.collectProps(LoginModal, this.state);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
|
|
|
|||
|
|
@ -390,6 +390,7 @@
|
|||
<ShmModal @changed="loadDirty" />
|
||||
<MessagingModal @changed="yamlChanged" />
|
||||
<TariffsModal @changed="yamlChanged" />
|
||||
<TelemetryModal :sponsor="sponsor" :telemetry="telemetry" />
|
||||
<ModbusProxyModal @changed="yamlChanged" />
|
||||
<CircuitsModal
|
||||
:gridMeter="gridMeter"
|
||||
|
|
@ -443,6 +444,7 @@ import restart, { performRestart } from "../restart";
|
|||
import SponsorModal from "../components/Config/SponsorModal.vue";
|
||||
import store from "../store";
|
||||
import TariffsModal from "../components/Config/TariffsModal.vue";
|
||||
import TelemetryModal from "../components/Config/TelemetryModal.vue";
|
||||
import Header from "../components/Top/Header.vue";
|
||||
import VehicleIcon from "../components/VehicleIcon";
|
||||
import VehicleModal from "../components/Config/VehicleModal.vue";
|
||||
|
|
@ -500,6 +502,7 @@ export default defineComponent({
|
|||
NotificationIcon,
|
||||
SponsorModal,
|
||||
TariffsModal,
|
||||
TelemetryModal,
|
||||
TopHeader: Header,
|
||||
VehicleIcon,
|
||||
VehicleModal,
|
||||
|
|
@ -662,6 +665,13 @@ export default defineComponent({
|
|||
const { name } = store.state?.sponsor || {};
|
||||
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() {
|
||||
return { configured: { value: store.state?.eebus || false } };
|
||||
},
|
||||
|
|
|
|||
15
i18n/de.json
15
i18n/de.json
|
|
@ -162,6 +162,9 @@
|
|||
"customOption": "Benutzerdefiniertes Gerät",
|
||||
"delete": "Löschen",
|
||||
"docsLink": "Siehe Dokumentation.",
|
||||
"dragHandle": "Verschieben",
|
||||
"dragItem": "Verschiebbar: {title}",
|
||||
"dragList": "Sortierbare Liste",
|
||||
"experimental": "Experimentell",
|
||||
"hideAdvancedSettings": "Erweiterte Einstellungen ausblenden",
|
||||
"invalidFileSelected": "Ungültige Datei ausgewählt",
|
||||
|
|
@ -172,6 +175,7 @@
|
|||
"readFromFile": "Aus Datei lesen",
|
||||
"remove": "Entfernen",
|
||||
"required": "erforderlich",
|
||||
"reset": "Zurücksetzen",
|
||||
"save": "Speichern",
|
||||
"selectFile": "Durchsuchen",
|
||||
"showAdvancedSettings": "Erweiterte Einstellungen anzeigen",
|
||||
|
|
@ -510,6 +514,10 @@
|
|||
"description": "Hinterlege deine Stromtarife, um die Kosten zu berechnen.",
|
||||
"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": {
|
||||
"description": "Wird in der Hauptansicht und im Browser-Tab angezeigt.",
|
||||
"label": "Titel",
|
||||
|
|
@ -1088,6 +1096,7 @@
|
|||
"vehicle": "Fahrzeug"
|
||||
},
|
||||
"settings": {
|
||||
"deviceInfo": "Einstellungen, die du in diesem Dialog vornimmst, gelten nur für dieses Gerät.",
|
||||
"fullscreen": {
|
||||
"enter": "Vollbild starten",
|
||||
"exit": "Vollbild beenden",
|
||||
|
|
@ -1101,6 +1110,12 @@
|
|||
"auto": "Automatisch",
|
||||
"label": "Sprache"
|
||||
},
|
||||
"loadpoints": {
|
||||
"help": "Reihenfolge und Sichtbarkeit für die Benutzeroberfläche ändern.",
|
||||
"hide": "{title} ausblenden",
|
||||
"label": "Ladepunkte",
|
||||
"show": "{title} anzeigen"
|
||||
},
|
||||
"sponsorToken": {
|
||||
"expires": "Dein Sponsortoken läuft {inXDays} ab. {getNewToken} und aktualisiere es hier.",
|
||||
"getNew": "Hol dir ein Neues",
|
||||
|
|
|
|||
15
i18n/en.json
15
i18n/en.json
|
|
@ -162,6 +162,9 @@
|
|||
"customOption": "User-defined device",
|
||||
"delete": "Delete",
|
||||
"docsLink": "See documentation.",
|
||||
"dragHandle": "Drag handle",
|
||||
"dragItem": "Draggable: {title}",
|
||||
"dragList": "Reorderable list",
|
||||
"experimental": "Experimental",
|
||||
"hideAdvancedSettings": "Hide advanced settings",
|
||||
"invalidFileSelected": "Invalid file selected",
|
||||
|
|
@ -172,6 +175,7 @@
|
|||
"readFromFile": "Read from file",
|
||||
"remove": "Remove",
|
||||
"required": "required",
|
||||
"reset": "Reset",
|
||||
"save": "Save",
|
||||
"selectFile": "Browse",
|
||||
"showAdvancedSettings": "Show advanced settings",
|
||||
|
|
@ -510,6 +514,10 @@
|
|||
"description": "Define your energy tariffs to calculate the costs of your charging sessions.",
|
||||
"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": {
|
||||
"description": "Displayed on main screen and browser tab.",
|
||||
"label": "Title",
|
||||
|
|
@ -1088,6 +1096,7 @@
|
|||
"vehicle": "Vehicle"
|
||||
},
|
||||
"settings": {
|
||||
"deviceInfo": "Settings you make in this dialog only affect this device.",
|
||||
"fullscreen": {
|
||||
"enter": "Enter fullscreen",
|
||||
"exit": "Exit fullscreen",
|
||||
|
|
@ -1101,6 +1110,12 @@
|
|||
"auto": "Automatic",
|
||||
"label": "Language"
|
||||
},
|
||||
"loadpoints": {
|
||||
"help": "Change order and visibility for the UI.",
|
||||
"hide": "Hide {title}",
|
||||
"label": "Charging points",
|
||||
"show": "Show {title}"
|
||||
},
|
||||
"sponsorToken": {
|
||||
"expires": "You sponsor token expires {inXDays}. {getNewToken} and update it here.",
|
||||
"getNew": "Grab a fresh one",
|
||||
|
|
|
|||
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -7,6 +7,7 @@
|
|||
"name": "evcc",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@formkit/drag-and-drop": "^0.5.3",
|
||||
"@guolao/vue-monaco-editor": "^1.5.5",
|
||||
"@h2d2/shopicons": "^1.9.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
|
|
@ -2285,6 +2286,12 @@
|
|||
"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": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@guolao/vue-monaco-editor/-/vue-monaco-editor-1.5.5.tgz",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"type": "module",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"@formkit/drag-and-drop": "^0.5.3",
|
||||
"@guolao/vue-monaco-editor": "^1.5.5",
|
||||
"@h2d2/shopicons": "^1.9.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
|
|
|
|||
30
tests/loadpoint-sort.evcc.yaml
Normal file
30
tests/loadpoint-sort.evcc.yaml
Normal 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
|
||||
112
tests/loadpoint-sort.spec.ts
Normal file
112
tests/loadpoint-sort.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -123,3 +123,26 @@ export async function newLoadpoint(
|
|||
.click();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue