diff --git a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue index b094d016b..d1ddde674 100644 --- a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue +++ b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue @@ -700,8 +700,20 @@ export default defineComponent({ } this.serviceValuesTimer = setTimeout(async () => { this.serviceValues = await fetchServiceValues(this.templateParams, this.values); + this.applyServiceDefaults(); }, 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]; + } + }); + }, }, }); diff --git a/assets/js/components/Config/DeviceModal/index.ts b/assets/js/components/Config/DeviceModal/index.ts index 58d62b7b4..3962008da 100644 --- a/assets/js/components/Config/DeviceModal/index.ts +++ b/assets/js/components/Config/DeviceModal/index.ts @@ -117,7 +117,7 @@ export function customChargerName(type: ConfigType, isHeating: boolean) { export async function loadServiceValues(path: string) { try { const response = await api.get(`/config/service/${path}`); - return response.data as string[]; + return (response.data as string[]) || []; } catch (e) { console.error(e); return []; diff --git a/assets/js/components/Config/PropertyField.vue b/assets/js/components/Config/PropertyField.vue index 69af59582..14d6da7b4 100644 --- a/assets/js/components/Config/PropertyField.vue +++ b/assets/js/components/Config/PropertyField.vue @@ -89,7 +89,13 @@ :required="required" :autocomplete="masked || datalistId ? 'off' : null" /> - diff --git a/cmd/setup.go b/cmd/setup.go index ea6070909..2044618ea 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -42,6 +42,7 @@ import ( "github.com/evcc-io/evcc/tariff" "github.com/evcc-io/evcc/util" "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/machine" "github.com/evcc-io/evcc/util/request" diff --git a/i18n/de.json b/i18n/de.json index 32021a48e..cd49a259b 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -162,6 +162,7 @@ "authPerformHint": "Öffnet ein neues Tab. Anschließend hier weiter machen.", "authPrepare": "Verbindung vorbereiten", "cancel": "Abbrechen", + "clear": "Löschen", "close": "Schließen", "customHelp": "Erstelle ein benutzerdefiniertes Gerät mit evcc's Plugin-System.", "customOption": "Benutzerdefiniertes Gerät", diff --git a/i18n/en.json b/i18n/en.json index 95aff4d89..442dccb9a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -162,6 +162,7 @@ "authPerformHint": "Will open in a new tab. Return here to continue.", "authPrepare": "Prepare connection", "cancel": "Cancel", + "clear": "Clear", "close": "Close", "customHelp": "Create a user-defined device using evcc's plugin system.", "customOption": "User-defined device", diff --git a/templates/definition/charger/homeassistant-switch.yaml b/templates/definition/charger/homeassistant-switch.yaml index e4fb1fd63..76a620d0e 100644 --- a/templates/definition/charger/homeassistant-switch.yaml +++ b/templates/definition/charger/homeassistant-switch.yaml @@ -16,8 +16,8 @@ params: description: de: Home Assistant Instanz en: Home Assistant Instance - default: Home service: homeassistant/homes + required: true - name: switch description: de: Entity ID des schaltbaren Geräts diff --git a/templates/definition/meter/homeassistant.yaml b/templates/definition/meter/homeassistant.yaml index 2ced939f2..2f8117acd 100644 --- a/templates/definition/meter/homeassistant.yaml +++ b/templates/definition/meter/homeassistant.yaml @@ -4,8 +4,8 @@ products: group: generic requirements: description: - 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.*) + 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.*`) auth: type: homeassistant params: [home] @@ -20,7 +20,6 @@ params: description: de: Home Assistant Instanz en: Home Assistant Instance - default: Home service: homeassistant/homes required: true - name: power diff --git a/templates/definition/vehicle/homeassistant.yaml b/templates/definition/vehicle/homeassistant.yaml index b730b8ae8..cfa16ed7f 100644 --- a/templates/definition/vehicle/homeassistant.yaml +++ b/templates/definition/vehicle/homeassistant.yaml @@ -4,8 +4,8 @@ products: group: generic requirements: 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.*) - 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.*) + 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.*`) auth: type: homeassistant params: [home] @@ -19,7 +19,6 @@ params: description: de: Home Assistant Instanz en: Home Assistant Instance - default: Home service: homeassistant/homes required: true - name: soc diff --git a/tests/config-param-service-demo.tpl.yaml b/tests/config-param-service-demo.tpl.yaml new file mode 100644 index 000000000..c849027d1 --- /dev/null +++ b/tests/config-param-service-demo.tpl.yaml @@ -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 diff --git a/tests/config-param-service.spec.ts b/tests/config-param-service.spec.ts new file mode 100644 index 000000000..12869b224 --- /dev/null +++ b/tests/config-param-service.spec.ts @@ -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(""); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts index 8d9b9be65..688021b24 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -146,3 +146,12 @@ export async function dragElement( await page.mouse.up(); } } + +export async function getDatalistOptions(input: Locator): Promise { + 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); + }); +} diff --git a/util/demo/service.go b/util/demo/service.go new file mode 100644 index 000000000..a5aea3fc0 --- /dev/null +++ b/util/demo/service.go @@ -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) +}