evcc-io/assets/js/components/Config/VehicleModal.vue
Maschga 4c73cbe197
chore: Typescript: Rewrite views (#21714)
Co-authored-by: Michael Geers <michael@geers.tv>
2025-08-05 17:56:39 +00:00

489 lines
13 KiB
Vue

<template>
<GenericModal
id="vehicleModal"
:title="$t(`config.vehicle.${isNew ? 'titleAdd' : 'titleEdit'}`)"
:size="modalSize"
data-testid="vehicle-modal"
@open="open"
@closed="closed"
>
<form ref="form" class="container mx-0 px-0">
<TemplateSelector
ref="templateSelect"
v-model="templateName"
device-type="vehicle"
:is-new="isNew"
:product-name="productName"
:groups="templateOptions"
@change="templateChanged"
/>
<YamlEntry
v-if="values.type === 'custom'"
v-model="values.yaml"
type="vehicle"
:error-line="test.errorLine"
/>
<div v-else>
<p v-if="loadingTemplate">{{ $t("config.general.templateLoading") }}</p>
<Markdown v-if="description" :markdown="description" class="my-4" />
<PropertyEntry
v-for="param in normalParams"
:id="`vehicleParam${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
/>
<PropertyCollapsible>
<template v-if="advancedParams.length" #advanced>
<PropertyEntry
v-for="param in advancedParams"
:id="`vehicleParam${param.Name}`"
:key="param.Name"
v-bind="param"
v-model="values[param.Name]"
/>
</template>
<template #more>
<h6 class="mt-3">{{ $t("config.vehicle.chargingSettings") }}</h6>
<FormRow
id="vehicleParamMode"
:label="$t('config.vehicle.defaultMode')"
:help="$t('config.vehicle.defaultModeHelp')"
>
<PropertyField
id="vehicleParamMode"
v-model="values.mode"
type="Choice"
class="w-100"
:choice="[
{ key: 'off', name: $t('main.mode.off') },
{ key: 'pv', name: $t('main.mode.pv') },
{ key: 'minpv', name: $t('main.mode.minpv') },
{ key: 'now', name: $t('main.mode.now') },
]"
/>
</FormRow>
<FormRow
id="vehicleParamPhases"
:label="$t('config.vehicle.maximumPhases')"
:help="$t('config.vehicle.maximumPhasesHelp')"
>
<SelectGroup
id="vehicleParamPhases"
v-model="values.phases"
class="w-100"
:options="[
{ name: '1-phase', value: '1' },
{ name: '2-phases', value: '2' },
{ name: '3-phases', value: '' },
]"
equal-width
transparent
/>
</FormRow>
<div class="row mb-3">
<FormRow
id="vehicleParamMinCurrent"
:label="$t('config.vehicle.minimumCurrent')"
class="col-sm-6 mb-sm-0"
:help="
values.minCurrent && values.minCurrent < 6
? $t('config.vehicle.minimumCurrentHelp')
: ''
"
>
<PropertyField
id="vehicleParamMinCurrent"
v-model="values.minCurrent"
type="Float"
unit="A"
size="w-25 w-min-200"
class="me-2"
/>
</FormRow>
<FormRow
id="vehicleParamMaxCurrent"
:label="$t('config.vehicle.maximumCurrent')"
class="col-sm-6 mb-sm-0"
:help="
values.minCurrent &&
values.maxCurrent &&
values.maxCurrent < values.minCurrent
? $t('config.vehicle.maximumCurrentHelp')
: ''
"
>
<PropertyField
id="vehicleParamMaxCurrent"
v-model="values.maxCurrent"
type="Float"
unit="A"
size="w-25 w-min-200"
class="me-2"
/>
</FormRow>
</div>
<FormRow
id="vehicleParamPriority"
:label="$t('config.vehicle.priority')"
:help="$t('config.vehicle.priorityHelp')"
>
<PropertyField
id="vehicleParamPriority"
v-model="values.priority"
type="Choice"
size="w-100"
class="me-2"
:choice="priorityOptions"
required
/>
</FormRow>
<FormRow
id="vehicleParamIdentifiers"
:label="$t('config.vehicle.identifiers')"
:help="$t('config.vehicle.identifiersHelp')"
>
<PropertyField
id="vehicleParamIdentifiers"
v-model="values.identifiers"
type="List"
property="identifiers"
size="w-100"
class="me-2"
/>
</FormRow>
</template>
</PropertyCollapsible>
</div>
<DeviceModalActions
v-if="showActions"
:is-deletable="isDeletable"
:test-state="test"
:is-saving="saving"
@save="isNew ? create() : update()"
@remove="remove"
@test="testManually"
/>
</form>
</GenericModal>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import FormRow from "./FormRow.vue";
import PropertyField from "./PropertyField.vue";
import SelectGroup from "../Helper/SelectGroup.vue";
import PropertyEntry from "./PropertyEntry.vue";
import PropertyCollapsible from "./PropertyCollapsible.vue";
import GenericModal from "../Helper/GenericModal.vue";
import Markdown from "./Markdown.vue";
import TemplateSelector, { customTemplateOption } from "./DeviceModal/TemplateSelector.vue";
import DeviceModalActions from "./DeviceModal/Actions.vue";
import YamlEntry from "./DeviceModal/YamlEntry.vue";
import { initialTestState, performTest } from "./utils/test";
import { ConfigType } from "@/types/evcc";
import {
handleError,
type DeviceValues,
type Template,
type Product,
applyDefaultsFromTemplate,
createDeviceUtils,
} from "./DeviceModal";
import defaultYaml from "./defaultYaml/vehicle.yaml?raw";
const initialValues = { type: ConfigType.Template, icon: "car" };
const device = createDeviceUtils("vehicle");
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const CUSTOM_FIELDS = ["minCurrent", "maxCurrent", "priority", "identifiers", "phases", "mode"];
type VehicleDeviceValues = DeviceValues & {
identifiers: string[] | undefined;
minCurrent: number | undefined;
maxCurrent: number | undefined;
priority: number | undefined;
phases: number | undefined;
mode: string | undefined;
};
export default defineComponent({
name: "VehicleModal",
components: {
FormRow,
PropertyField,
GenericModal,
SelectGroup,
PropertyCollapsible,
PropertyEntry,
Markdown,
TemplateSelector,
DeviceModalActions,
YamlEntry,
},
props: {
id: Number,
},
emits: ["vehicle-changed"],
data() {
return {
isModalVisible: false,
templates: [] as Template[],
products: [] as Product[],
templateName: null as string | null,
template: null as Template | null,
saving: false,
loadingTemplate: false,
values: { ...initialValues } as VehicleDeviceValues,
test: initialTestState(),
};
},
computed: {
templateOptions() {
return [
{
label: "primary",
options: [
...this.products.filter((p) => p.template === "offline"),
customTemplateOption(this.$t("config.general.customOption")),
],
},
{
label: "online",
options: this.products.filter((p) => !p.group),
},
{
label: "scooter",
options: this.products.filter((p) => p.group === "scooter"),
},
{
label: "generic",
options: this.products.filter(
(p) => p.group === "generic" && p.template !== "offline"
),
},
];
},
templateParams() {
const params = (this.template?.Params || [])
.filter((p) => !CUSTOM_FIELDS.includes(p.Name))
.map((p) => {
if (p.Name === "title" || p.Name === "icon") {
p.Required = true;
p.Advanced = false;
}
return p;
});
// non-optional fields first
params.sort((a, b) => (a.Required ? -1 : 1) - (b.Required ? -1 : 1));
// always start with title and icon field
const order: Record<string, number> = { title: -2, icon: -1 };
params.sort((a, b) => (order[a.Name] || 0) - (order[b.Name] || 0));
return params;
},
normalParams() {
return this.templateParams.filter(
(p) =>
!p.Advanced && !p.Deprecated && (p.Usages ? p.Usages.includes("vehicle") : true)
);
},
advancedParams() {
return this.templateParams.filter(
(p) =>
(p.Advanced || p.Deprecated) && (p.Usages ? p.Usages.includes("vehicle") : true)
);
},
description() {
return this.template?.Requirements?.Description;
},
productName() {
return this.values.deviceProduct || this.templateName || "";
},
apiData() {
const data: Record<string, any> = {
...this.values,
};
if (this.values.type === ConfigType.Template) {
data["template"] = this.templateName;
}
// remove icon if custom
if (this.values.type === ConfigType.Custom) {
delete data["icon"];
delete data["title"];
}
// trim and remove empty lines
if (Array.isArray(data["identifiers"])) {
data["identifiers"] = data["identifiers"].map((i) => i.trim()).filter((i) => i);
}
return data;
},
isNew() {
return this.id === undefined;
},
modalSize() {
return this.values.type === ConfigType.Custom ? "xl" : undefined;
},
isDeletable() {
return !this.isNew;
},
priorityOptions() {
const result: { key: number | undefined; name: string }[] = Array.from(
{ length: 11 },
(_, i) => ({ key: i, name: `${i}` })
);
result[0].name = "0 (default)";
result[0].key = undefined;
result[10].name = "10 (highest)";
return result;
},
showActions() {
return this.templateName || this.values.type === ConfigType.Custom;
},
},
watch: {
isModalVisible(visible) {
if (visible) {
this.reset();
this.templateName = "offline";
this.test = initialTestState();
this.loadProducts();
if (this.id !== undefined) {
this.loadConfiguration();
}
}
},
templateName() {
this.loadTemplate();
},
values: {
handler() {
this.test = initialTestState();
},
deep: true,
},
},
methods: {
reset() {
this.values = { ...initialValues } as VehicleDeviceValues;
this.test = initialTestState();
},
async loadConfiguration() {
try {
const vehicle = await device.load(this.id as number);
this.values = vehicle.config;
// convert structure to flat list
// TODO: adjust GET response to match POST/PUT formats
this.values.type = vehicle.type;
this.values.deviceProduct = vehicle.deviceProduct;
applyDefaultsFromTemplate(this.template, this.values);
this.templateName = this.values.template;
} catch (e) {
console.error(e);
}
},
async loadProducts() {
if (!this.isModalVisible) {
return;
}
try {
this.products = await device.loadProducts(this.$i18n?.locale);
} catch (e) {
console.error(e);
}
},
async loadTemplate() {
this.template = null;
if (!this.templateName || this.templateName === ConfigType.Custom) return;
this.loadingTemplate = true;
try {
this.template = await device.loadTemplate(this.templateName, this.$i18n?.locale);
applyDefaultsFromTemplate(this.template, this.values);
} catch (e) {
console.error(e);
}
this.loadingTemplate = false;
},
async create() {
// persist selected template product
if (this.template && this.$refs["templateSelect"]) {
this.values.deviceProduct = (this.$refs["templateSelect"] as any).getProductName();
}
if (this.test.isUnknown) {
const success = await performTest(this.test, this.testVehicle, this.$refs["form"]);
if (!success) return;
await sleep(100);
}
this.saving = true;
try {
const { name } = await device.create(this.apiData);
this.$emit("vehicle-changed", name);
this.closed();
} catch (e) {
handleError(e, "create failed");
}
this.saving = false;
},
async testManually() {
await performTest(this.test, this.testVehicle, this.$refs["form"]);
},
async testVehicle() {
return device.test(this.id, this.apiData);
},
async update() {
if (this.test.isUnknown) {
const success = await performTest(this.test, this.testVehicle, this.$refs["form"]);
if (!success) return;
await sleep(250);
}
this.saving = true;
try {
await device.update(this.id as number, this.apiData);
this.$emit("vehicle-changed");
this.closed();
} catch (e) {
handleError(e, "update failed");
}
this.saving = false;
},
async remove() {
try {
await device.remove(this.id as number);
this.$emit("vehicle-changed");
this.closed();
} catch (e) {
handleError(e, "remove failed");
}
},
open() {
this.isModalVisible = true;
},
closed() {
this.isModalVisible = false;
},
templateChanged(e: Event) {
const value = (e.target as HTMLSelectElement).value;
this.reset();
if (value === ConfigType.Custom) {
this.values.type = ConfigType.Custom;
this.values.yaml = defaultYaml;
}
},
},
});
</script>
<style scoped>
.container {
margin-left: calc(var(--bs-gutter-x) * -0.5);
margin-right: calc(var(--bs-gutter-x) * -0.5);
padding-right: 0;
}
</style>