Config UI: dynamic default values, remove static HA instance default (#25525)

This commit is contained in:
andig 2025-11-25 14:38:23 +01:00 • committed by GitHub
parent 4fbd4c01a0
commit 65fce6e6b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 200 additions and 9 deletions

View file

@ -700,8 +700,20 @@ export default defineComponent({
} }
this.serviceValuesTimer = setTimeout(async () => { this.serviceValuesTimer = setTimeout(async () => {
this.serviceValues = await fetchServiceValues(this.templateParams, this.values); this.serviceValues = await fetchServiceValues(this.templateParams, this.values);
this.applyServiceDefaults();
}, 500); }, 500);
}, },
applyServiceDefaults() {
// Auto-apply single service values when field is empty and required
Object.keys(this.serviceValues).forEach((paramName) => {
const values = this.serviceValues[paramName];
const param = this.templateParams.find((p) => p.Name === paramName);
// Only auto-apply if exactly one value is returned, field is empty, and field is required
if (values?.length === 1 && !this.values[paramName] && param?.Required) {
this.values[paramName] = values[0];
}
});
},
}, },
}); });
</script> </script>

View file

@ -117,7 +117,7 @@ export function customChargerName(type: ConfigType, isHeating: boolean) {
export async function loadServiceValues(path: string) { export async function loadServiceValues(path: string) {
try { try {
const response = await api.get(`/config/service/${path}`); const response = await api.get(`/config/service/${path}`);
return response.data as string[]; return (response.data as string[]) || [];
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return []; return [];

View file

@ -89,7 +89,13 @@
:required="required" :required="required"
:autocomplete="masked || datalistId ? 'off' : null" :autocomplete="masked || datalistId ? 'off' : null"
/> />
<button v-if="showClearButton" type="button" class="form-control-clear" @click="value = ''"> <button
v-if="showClearButton"
type="button"
class="form-control-clear"
:aria-label="$t('config.general.clear')"
@click="value = ''"
>
&times; &times;
</button> </button>
<datalist v-if="showDatalist" :id="datalistId"> <datalist v-if="showDatalist" :id="datalistId">

View file

@ -42,6 +42,7 @@ import (
"github.com/evcc-io/evcc/tariff" "github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/config"
_ "github.com/evcc-io/evcc/util/demo"
"github.com/evcc-io/evcc/util/locale" "github.com/evcc-io/evcc/util/locale"
"github.com/evcc-io/evcc/util/machine" "github.com/evcc-io/evcc/util/machine"
"github.com/evcc-io/evcc/util/request" "github.com/evcc-io/evcc/util/request"

View file

@ -162,6 +162,7 @@
"authPerformHint": "Öffnet ein neues Tab. Anschließend hier weiter machen.", "authPerformHint": "Öffnet ein neues Tab. Anschließend hier weiter machen.",
"authPrepare": "Verbindung vorbereiten", "authPrepare": "Verbindung vorbereiten",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"clear": "Löschen",
"close": "Schließen", "close": "Schließen",
"customHelp": "Erstelle ein benutzerdefiniertes Gerät mit evcc's Plugin-System.", "customHelp": "Erstelle ein benutzerdefiniertes Gerät mit evcc's Plugin-System.",
"customOption": "Benutzerdefiniertes Gerät", "customOption": "Benutzerdefiniertes Gerät",

View file

@ -162,6 +162,7 @@
"authPerformHint": "Will open in a new tab. Return here to continue.", "authPerformHint": "Will open in a new tab. Return here to continue.",
"authPrepare": "Prepare connection", "authPrepare": "Prepare connection",
"cancel": "Cancel", "cancel": "Cancel",
"clear": "Clear",
"close": "Close", "close": "Close",
"customHelp": "Create a user-defined device using evcc's plugin system.", "customHelp": "Create a user-defined device using evcc's plugin system.",
"customOption": "User-defined device", "customOption": "User-defined device",

View file

@ -16,8 +16,8 @@ params:
description: description:
de: Home Assistant Instanz de: Home Assistant Instanz
en: Home Assistant Instance en: Home Assistant Instance
default: Home
service: homeassistant/homes service: homeassistant/homes
required: true
- name: switch - name: switch
description: description:
de: Entity ID des schaltbaren Geräts de: Entity ID des schaltbaren Geräts

View file

@ -4,8 +4,8 @@ products:
group: generic group: generic
requirements: requirements:
description: description:
en: Requires a running Home Assistant instance with suitable sensor entities. All values are Home Assistant entity IDs (e.g. sensor.*) en: Requires a running Home Assistant instance with suitable sensor entities. All values are Home Assistant entity IDs (e.g. `sensor.*`)
de: Erfordert eine laufende Home Assistant Instanz mit passenden Sensor-Entitäten. Alle Werte sind Home Assistant Entity IDs (z.B. sensor.*) de: Erfordert eine laufende Home Assistant Instanz mit passenden Sensor-Entitäten. Alle Werte sind Home Assistant Entity IDs (z.B. `sensor.*`)
auth: auth:
type: homeassistant type: homeassistant
params: [home] params: [home]
@ -20,7 +20,6 @@ params:
description: description:
de: Home Assistant Instanz de: Home Assistant Instanz
en: Home Assistant Instance en: Home Assistant Instance
default: Home
service: homeassistant/homes service: homeassistant/homes
required: true required: true
- name: power - name: power

View file

@ -4,8 +4,8 @@ products:
group: generic group: generic
requirements: requirements:
description: description:
en: Requires a running Home Assistant instance with suitable vehicle entities and services. All values are Home Assistant entity IDs (e.g. sensor.*, binary_sensor.*, number.*, script.*) en: Requires a running Home Assistant instance with suitable vehicle entities and services. All values are Home Assistant entity IDs (e.g. `sensor.*`, `binary_sensor.*`, `number.*`, `script.*`)
de: Erfordert eine laufende Home Assistant Instanz mit passenden Fahrzeug-Entities und Services. Alle Werte sind Home Assistant Entity IDs (z.B. sensor.*, binary_sensor.*, number.*, script.*) de: Erfordert eine laufende Home Assistant Instanz mit passenden Fahrzeug-Entities und Services. Alle Werte sind Home Assistant Entity IDs (z.B. `sensor.*`, `binary_sensor.*`, `number.*`, `script.*`)
auth: auth:
type: homeassistant type: homeassistant
params: [home] params: [home]
@ -19,7 +19,6 @@ params:
description: description:
de: Home Assistant Instanz de: Home Assistant Instanz
en: Home Assistant Instance en: Home Assistant Instance
default: Home
service: homeassistant/homes service: homeassistant/homes
required: true required: true
- name: soc - name: soc

View file

@ -0,0 +1,28 @@
template: service-demo
group: generic
products:
- description:
generic: Service Demo Meter
params:
- name: usage
choice: ["grid"]
- name: value
description:
generic: Important value
required: true
service: demo/single
- name: other-value
description:
generic: Other value
service: demo/single
- name: country
description:
generic: Country
service: demo/country
- name: city
description:
generic: City
service: demo/{country}/city
render: |
type: custom

View file

@ -0,0 +1,96 @@
import { test, expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { enableExperimental, expectModalVisible, getDatalistOptions } from "./utils";
test.use({ baseURL: baseUrl() });
const templateFlags = [
"--disable-auth",
"--template-type",
"meter",
"--template",
"tests/config-param-service-demo.tpl.yaml",
];
test.beforeAll(async () => {
await start(undefined, undefined, templateFlags);
});
test.afterAll(async () => {
await stop();
});
async function openMeterModal(page: Page) {
await page.goto("/#/config");
await enableExperimental(page, true);
await page.getByRole("button", { name: "Add grid meter" }).click();
const meterModal = page.getByTestId("meter-modal");
await expectModalVisible(meterModal);
await meterModal.getByLabel("Manufacturer").selectOption("Service Demo Meter");
return meterModal;
}
test.describe("config param service", async () => {
test("autocomplete simple", async ({ page }) => {
const meterModal = await openMeterModal(page);
await expect(meterModal.getByLabel("Important value")).toHaveValue("demo-value");
const otherValue = meterModal.getByLabel("Other value");
await expect(await getDatalistOptions(otherValue)).toEqual(["demo-value"]);
const country = meterModal.getByLabel("Country");
await expect(await getDatalistOptions(country)).toEqual(["germany", "france", "spain"]);
});
test("autocomplete dependent", async ({ page }) => {
const meterModal = await openMeterModal(page);
await expect(meterModal.getByLabel("Important value")).toHaveValue("demo-value");
const country = meterModal.getByLabel("Country");
const city = meterModal.getByLabel("City");
// initially empty
await expect(await getDatalistOptions(city)).toEqual([]);
await country.fill("germany");
await expect(city).toHaveClass(/form-select/);
await expect(await getDatalistOptions(city)).toEqual(["berlin", "munich", "hamburg"]);
await country.fill("");
await expect(city).not.toHaveClass(/form-select/);
await expect(await getDatalistOptions(city)).toEqual([]);
await country.fill("france");
await expect(city).toHaveClass(/form-select/);
await expect(await getDatalistOptions(city)).toEqual(["paris", "lyon", "marseille"]);
await country.fill("fantasy");
await expect(city).not.toHaveClass(/form-select/);
await expect(await getDatalistOptions(city)).toEqual([]);
});
test("auto-apply single service value", async ({ page }) => {
const meterModal = await openMeterModal(page);
// only required single-value field is auto-populated
await expect(meterModal.getByLabel("Important value")).toHaveValue("demo-value");
await expect(meterModal.getByLabel("Other value")).toHaveValue("");
await expect(meterModal.getByLabel("Country")).toHaveValue("");
await expect(meterModal.getByLabel("City")).toHaveValue("");
});
test("clear button", async ({ page }) => {
const meterModal = await openMeterModal(page);
const valueField = meterModal.getByLabel("Important value");
await expect(valueField).toHaveValue("demo-value");
const clearButton = valueField.locator("..").getByLabel("Clear");
await expect(clearButton).toBeVisible();
// click clear button and verify it disappears and field is cleared
await clearButton.click();
await expect(clearButton).not.toBeVisible();
await expect(valueField).toHaveValue("");
});
});

View file

@ -146,3 +146,12 @@ export async function dragElement(
await page.mouse.up(); await page.mouse.up();
} }
} }
export async function getDatalistOptions(input: Locator): Promise<string[]> {
return input.evaluate((element: HTMLInputElement) => {
const datalistId = element.getAttribute("list");
if (!datalistId) return [];
const datalist = document.getElementById(datalistId);
return Array.from(datalist?.querySelectorAll("option") || []).map((opt) => opt.value);
});
}

39
util/demo/service.go Normal file
View file

@ -0,0 +1,39 @@
package demo
import (
"encoding/json"
"net/http"
"github.com/evcc-io/evcc/server/service"
)
func init() {
mux := http.NewServeMux()
mux.HandleFunc("GET /single", getSingle)
mux.HandleFunc("GET /country", getCountry)
mux.HandleFunc("GET /{country}/city", getCity)
service.Register("demo", mux)
}
func getSingle(w http.ResponseWriter, req *http.Request) {
json.NewEncoder(w).Encode([]string{"demo-value"})
}
func getCountry(w http.ResponseWriter, req *http.Request) {
json.NewEncoder(w).Encode([]string{"germany", "france", "spain"})
}
func getCity(w http.ResponseWriter, req *http.Request) {
country := req.PathValue("country")
var cities []string
switch country {
case "germany":
cities = []string{"berlin", "munich", "hamburg"}
case "france":
cities = []string{"paris", "lyon", "marseille"}
case "spain":
cities = []string{"madrid", "barcelona", "valencia"}
}
json.NewEncoder(w).Encode(cities)
}