Config UI: dynamic default values, remove static HA instance default (#25525)
This commit is contained in:
parent
4fbd4c01a0
commit
65fce6e6b4
13 changed files with 200 additions and 9 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 [];
|
||||||
|
|
|
||||||
|
|
@ -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 = ''"
|
||||||
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
<datalist v-if="showDatalist" :id="datalistId">
|
<datalist v-if="showDatalist" :id="datalistId">
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
28
tests/config-param-service-demo.tpl.yaml
Normal file
28
tests/config-param-service-demo.tpl.yaml
Normal 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
|
||||||
96
tests/config-param-service.spec.ts
Normal file
96
tests/config-param-service.spec.ts
Normal 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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
39
util/demo/service.go
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue