mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-07 21:45:16 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a131523a0 | ||
|
|
98a47ab63c | ||
|
|
65a747673c | ||
|
|
2613797e1e |
No files matched your search
@@ -5,6 +5,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { deepEqual } from "../common/util/deep-equal";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomCurrentTargetEvent,
|
||||
@@ -29,10 +30,16 @@ import {
|
||||
type Selector,
|
||||
type TargetSelector,
|
||||
} from "../data/selector";
|
||||
import {
|
||||
getServiceFieldDefaults,
|
||||
getServiceFieldVisibility,
|
||||
getServiceHiddenFields,
|
||||
} from "../data/service";
|
||||
import type { HassServiceTargetWithExclude } from "../data/target";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import { documentationUrl } from "../util/documentation-url";
|
||||
import { getSelectorFallbackValue } from "./ha-form/get-selector-fallback-value";
|
||||
import type { HaFormDataContainer, HaFormVisible } from "./ha-form/types";
|
||||
import "./ha-checkbox";
|
||||
import type { HaCheckbox } from "./ha-checkbox";
|
||||
import "./ha-icon-button";
|
||||
@@ -80,20 +87,26 @@ const isFullWidthSelector = (selector?: Selector): boolean =>
|
||||
const isBooleanSelector = (selector?: Selector): boolean =>
|
||||
!!selector && "boolean" in selector;
|
||||
|
||||
interface Field extends Omit<HassService["fields"][string], "selector"> {
|
||||
type ServiceField = Omit<HassService["fields"][string], "selector"> & {
|
||||
visible?: HaFormVisible;
|
||||
};
|
||||
|
||||
interface Field extends ServiceField {
|
||||
key: string;
|
||||
selector?: Selector;
|
||||
}
|
||||
|
||||
interface ExtHassService extends Omit<HassService, "fields"> {
|
||||
fields: (Omit<HassService["fields"][string], "selector"> & {
|
||||
fields: (ServiceField & {
|
||||
key: string;
|
||||
selector?: Selector;
|
||||
fields?: Record<string, Omit<HassService["fields"][string], "selector">>;
|
||||
fields?: Record<string, ServiceField>;
|
||||
collapsed?: boolean;
|
||||
})[];
|
||||
flatFields: Field[];
|
||||
hasSelector: string[];
|
||||
fieldVisibility: [string, HaFormVisible | undefined][];
|
||||
fieldDefaults: HaFormDataContainer;
|
||||
}
|
||||
|
||||
@customElement("ha-service-control")
|
||||
@@ -210,7 +223,11 @@ export class HaServiceControl extends LitElement {
|
||||
);
|
||||
if (filled.changed) {
|
||||
updatedDefaultValue = true;
|
||||
this._value.data = filled.values;
|
||||
this._value.data = this._withoutHiddenDefaults(
|
||||
serviceData,
|
||||
filled.values,
|
||||
this._value.data
|
||||
);
|
||||
}
|
||||
}
|
||||
if (updatedDefaultValue) {
|
||||
@@ -278,10 +295,87 @@ export class HaServiceControl extends LitElement {
|
||||
fields,
|
||||
flatFields,
|
||||
hasSelector,
|
||||
fieldVisibility: getServiceFieldVisibility(
|
||||
serviceDomains[domain][serviceName].fields
|
||||
),
|
||||
fieldDefaults: getServiceFieldDefaults(
|
||||
serviceDomains[domain][serviceName].fields
|
||||
),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
private _hiddenFields = memoizeOne(
|
||||
(
|
||||
serviceData: ExtHassService | undefined,
|
||||
data: Record<string, any> | undefined
|
||||
): Set<string> =>
|
||||
serviceData
|
||||
? getServiceHiddenFields(
|
||||
serviceData.fieldVisibility,
|
||||
serviceData.fieldDefaults,
|
||||
data
|
||||
)
|
||||
: new Set()
|
||||
);
|
||||
|
||||
// Defaults are not added to hidden fields, existing values are kept so
|
||||
// opening an action does not change it
|
||||
private _withoutHiddenDefaults(
|
||||
serviceData: ExtHassService,
|
||||
filled: Record<string, any>,
|
||||
original: Record<string, any>
|
||||
) {
|
||||
const data = { ...filled };
|
||||
this._hiddenFields(serviceData, filled).forEach((key) => {
|
||||
if (!(key in original)) {
|
||||
delete data[key];
|
||||
}
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
// Hidden fields lose their value, and required fields that show again get
|
||||
// their default back
|
||||
private _applyFieldVisibility(data: Record<string, any>) {
|
||||
const serviceData = this._getServiceInfo(
|
||||
this._value?.action,
|
||||
this.hass.services
|
||||
);
|
||||
if (!serviceData?.fieldVisibility.length) {
|
||||
return data;
|
||||
}
|
||||
let previous = this._value?.data;
|
||||
let current = data;
|
||||
// A restored default can hide or show other fields, settle until nothing
|
||||
// changes. The bound guards against conditions that keep flipping.
|
||||
for (let i = 0; i <= serviceData.flatFields.length; i++) {
|
||||
const hidden = this._hiddenFields(serviceData, current);
|
||||
const wasHidden = this._hiddenFields(serviceData, previous);
|
||||
const visibleData = { ...current };
|
||||
hidden.forEach((key) => {
|
||||
delete visibleData[key];
|
||||
delete this._stickySelector[key];
|
||||
this._checkedKeys.delete(key);
|
||||
});
|
||||
const shownAgain = serviceData.flatFields.filter(
|
||||
(field) => wasHidden.has(field.key) && !hidden.has(field.key)
|
||||
);
|
||||
const next = fillFieldDefaults(
|
||||
visibleData,
|
||||
shownAgain.map((field) => [field.key, field]),
|
||||
true,
|
||||
(field) => !field.required
|
||||
).values;
|
||||
if (deepEqual(next, current)) {
|
||||
return next;
|
||||
}
|
||||
previous = current;
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private _getTargetedEntities = memoizeOne((target, value) => {
|
||||
const targetSelector = target ? { target } : { target: {} };
|
||||
if (
|
||||
@@ -470,6 +564,8 @@ export class HaServiceControl extends LitElement {
|
||||
this._value
|
||||
);
|
||||
|
||||
const hiddenFields = this._hiddenFields(serviceData, this._value?.data);
|
||||
|
||||
const domain = this._value?.action
|
||||
? computeDomain(this._value.action)
|
||||
: undefined;
|
||||
@@ -577,18 +673,20 @@ export class HaServiceControl extends LitElement {
|
||||
></ha-yaml-editor>`
|
||||
: serviceData?.fields.map((dataField) => {
|
||||
if (!dataField.fields) {
|
||||
return this._renderField(
|
||||
dataField,
|
||||
hasOptional,
|
||||
domain,
|
||||
serviceName,
|
||||
targetEntities
|
||||
);
|
||||
return hiddenFields.has(dataField.key)
|
||||
? nothing
|
||||
: this._renderField(
|
||||
dataField,
|
||||
hasOptional,
|
||||
domain,
|
||||
serviceName,
|
||||
targetEntities
|
||||
);
|
||||
}
|
||||
|
||||
const fields = Object.entries(dataField.fields).map(
|
||||
([key, field]) => ({ key, ...field })
|
||||
);
|
||||
const fields = Object.entries(dataField.fields)
|
||||
.map(([key, field]) => ({ key, ...field }))
|
||||
.filter((field) => !hiddenFields.has(field.key));
|
||||
|
||||
return fields.length &&
|
||||
this._hasFilteredFields(fields, targetEntities)
|
||||
@@ -614,9 +712,9 @@ export class HaServiceControl extends LitElement {
|
||||
.service=${this._value!.action}
|
||||
.section=${dataField.key}
|
||||
></ha-service-section-icon>
|
||||
${Object.entries(dataField.fields).map(([key, field]) =>
|
||||
${fields.map((field) =>
|
||||
this._renderField(
|
||||
{ key, ...field },
|
||||
field,
|
||||
hasOptional,
|
||||
domain,
|
||||
serviceName,
|
||||
@@ -826,7 +924,7 @@ export class HaServiceControl extends LitElement {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this._value,
|
||||
data,
|
||||
data: this._applyFieldVisibility(data),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -955,7 +1053,7 @@ export class HaServiceControl extends LitElement {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this._value,
|
||||
data,
|
||||
data: this._applyFieldVisibility(data),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import type { HassService } from "home-assistant-js-websocket";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { hasTemplate } from "../common/string/has-template";
|
||||
import { isValidEntityId } from "../common/entity/valid_entity_id";
|
||||
import {
|
||||
combineVisible,
|
||||
getHiddenFields,
|
||||
} from "../components/ha-form/conditions";
|
||||
import type {
|
||||
HaFormDataContainer,
|
||||
HaFormVisible,
|
||||
} from "../components/ha-form/types";
|
||||
import type { Context, HomeAssistant, ServiceCallRequest } from "../types";
|
||||
import type { Action } from "./script";
|
||||
|
||||
@@ -40,3 +50,92 @@ export const getServiceCallEntityIds = (
|
||||
.filter(isValidEntityId)
|
||||
),
|
||||
];
|
||||
|
||||
interface ServiceFieldOrSection {
|
||||
visible?: HaFormVisible;
|
||||
default?: unknown;
|
||||
fields?: Record<string, { visible?: HaFormVisible; default?: unknown }>;
|
||||
}
|
||||
|
||||
// Fields inside a section are flattened, they are only shown when both the
|
||||
// section and the field are visible.
|
||||
export const getServiceFieldVisibility = (
|
||||
fields: HassService["fields"]
|
||||
): [string, HaFormVisible | undefined][] =>
|
||||
Object.entries(fields as Record<string, ServiceFieldOrSection>).flatMap(
|
||||
([key, field]) =>
|
||||
field.fields
|
||||
? Object.entries(field.fields).map(
|
||||
([subKey, subField]) =>
|
||||
[subKey, combineVisible(field.visible, subField.visible)] as [
|
||||
string,
|
||||
HaFormVisible,
|
||||
]
|
||||
)
|
||||
: [[key, field.visible] as [string, HaFormVisible | undefined]]
|
||||
);
|
||||
|
||||
export const getServiceFieldDefaults = (
|
||||
fields: HassService["fields"]
|
||||
): HaFormDataContainer => {
|
||||
const defaults: HaFormDataContainer = {};
|
||||
for (const [key, field] of Object.entries(
|
||||
fields as Record<string, ServiceFieldOrSection>
|
||||
)) {
|
||||
const entries = field.fields
|
||||
? Object.entries(field.fields)
|
||||
: [[key, field]];
|
||||
for (const [fieldKey, subField] of entries as [
|
||||
string,
|
||||
{ default?: HaFormDataContainer[string] },
|
||||
][]) {
|
||||
if (subField.default !== undefined) {
|
||||
defaults[fieldKey] = subField.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
};
|
||||
|
||||
const getConditionFields = (visible: HaFormVisible | undefined): string[] =>
|
||||
visible === undefined || typeof visible === "boolean"
|
||||
? []
|
||||
: ensureArray(visible).flatMap((condition) =>
|
||||
"condition" in condition
|
||||
? condition.conditions.flatMap((sub) => getConditionFields(sub))
|
||||
: [condition.field]
|
||||
);
|
||||
|
||||
// Like in config flows and blueprints, a field without a value is evaluated
|
||||
// with its default. A template is only known when the action runs, so a
|
||||
// condition on a field holding one never hides anything.
|
||||
export const getServiceHiddenFields = (
|
||||
visibility: [string, HaFormVisible | undefined][],
|
||||
defaults: HaFormDataContainer,
|
||||
data: Record<string, any> | undefined
|
||||
): Set<string> => {
|
||||
const values = { ...defaults };
|
||||
const templates = new Set<string>();
|
||||
for (const [key, value] of Object.entries(data ?? {})) {
|
||||
if (value !== undefined) {
|
||||
values[key] = value;
|
||||
}
|
||||
if (hasTemplate(value)) {
|
||||
templates.add(key);
|
||||
}
|
||||
}
|
||||
return getHiddenFields(
|
||||
visibility.map(([key, visible]) => [
|
||||
key,
|
||||
visible === undefined || typeof visible === "boolean"
|
||||
? visible
|
||||
: ensureArray(visible).filter(
|
||||
(condition) =>
|
||||
!getConditionFields(condition).some((field) =>
|
||||
templates.has(field)
|
||||
)
|
||||
),
|
||||
]),
|
||||
values
|
||||
);
|
||||
};
|
||||
@@ -42,6 +42,9 @@ import type { Action, ServiceAction } from "../../../../data/script";
|
||||
import { migrateAutomationAction } from "../../../../data/script";
|
||||
import {
|
||||
callExecuteScript,
|
||||
getServiceFieldDefaults,
|
||||
getServiceFieldVisibility,
|
||||
getServiceHiddenFields,
|
||||
serviceCallWillDisconnect,
|
||||
} from "../../../../data/service";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
@@ -403,6 +406,8 @@ class HaPanelDevAction extends MatchMinHeightMixin(
|
||||
serviceData: ServiceAction | undefined,
|
||||
fields,
|
||||
target,
|
||||
visibility: ReturnType<typeof getServiceFieldVisibility>,
|
||||
defaults: ReturnType<typeof getServiceFieldDefaults>,
|
||||
yamlMode: boolean,
|
||||
localize: LocalizeFunc
|
||||
): string | undefined => {
|
||||
@@ -421,6 +426,9 @@ class HaPanelDevAction extends MatchMinHeightMixin(
|
||||
}
|
||||
const dataIsTemplate =
|
||||
typeof serviceData.data === "string" && isTemplate(serviceData.data);
|
||||
const hidden = dataIsTemplate
|
||||
? new Set<string>()
|
||||
: getServiceHiddenFields(visibility, defaults, serviceData.data);
|
||||
if (
|
||||
target &&
|
||||
!dataIsTemplate &&
|
||||
@@ -437,6 +445,7 @@ class HaPanelDevAction extends MatchMinHeightMixin(
|
||||
if (
|
||||
field.required &&
|
||||
!dataIsTemplate &&
|
||||
!hidden.has(field.key) &&
|
||||
(!serviceData.data || serviceData.data[field.key] === undefined)
|
||||
) {
|
||||
return localize(
|
||||
@@ -452,17 +461,22 @@ class HaPanelDevAction extends MatchMinHeightMixin(
|
||||
(
|
||||
serviceDomains: HomeAssistant["services"],
|
||||
domainService: string | undefined
|
||||
): { target: boolean; fields: any[] } => {
|
||||
): {
|
||||
target: boolean;
|
||||
fields: any[];
|
||||
visibility: ReturnType<typeof getServiceFieldVisibility>;
|
||||
defaults: ReturnType<typeof getServiceFieldDefaults>;
|
||||
} => {
|
||||
if (!domainService) {
|
||||
return { target: false, fields: [] };
|
||||
return { target: false, fields: [], visibility: [], defaults: {} };
|
||||
}
|
||||
const domain = computeDomain(domainService);
|
||||
const service = computeObjectId(domainService);
|
||||
if (!(domain in serviceDomains)) {
|
||||
return { target: false, fields: [] };
|
||||
return { target: false, fields: [], visibility: [], defaults: {} };
|
||||
}
|
||||
if (!(service in serviceDomains[domain])) {
|
||||
return { target: false, fields: [] };
|
||||
return { target: false, fields: [], visibility: [], defaults: {} };
|
||||
}
|
||||
const target = "target" in serviceDomains[domain][service];
|
||||
const fields = serviceDomains[domain][service].fields;
|
||||
@@ -488,6 +502,8 @@ class HaPanelDevAction extends MatchMinHeightMixin(
|
||||
return {
|
||||
target,
|
||||
fields: result,
|
||||
visibility: getServiceFieldVisibility(fields),
|
||||
defaults: getServiceFieldDefaults(fields),
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -504,7 +520,7 @@ class HaPanelDevAction extends MatchMinHeightMixin(
|
||||
return;
|
||||
}
|
||||
|
||||
const { target, fields } = this._fields(
|
||||
const { target, fields, visibility, defaults } = this._fields(
|
||||
this.hass.services,
|
||||
this._serviceData?.action
|
||||
);
|
||||
@@ -513,6 +529,8 @@ class HaPanelDevAction extends MatchMinHeightMixin(
|
||||
this._serviceData,
|
||||
fields,
|
||||
target,
|
||||
visibility,
|
||||
defaults,
|
||||
this._yamlMode,
|
||||
this.hass.localize
|
||||
);
|
||||
|
||||
+159
-1
@@ -1,5 +1,11 @@
|
||||
import type { HassService } from "home-assistant-js-websocket";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getServiceCallEntityIds } from "../../src/data/service";
|
||||
import {
|
||||
getServiceCallEntityIds,
|
||||
getServiceFieldDefaults,
|
||||
getServiceFieldVisibility,
|
||||
getServiceHiddenFields,
|
||||
} from "../../src/data/service";
|
||||
|
||||
describe("getServiceCallEntityIds", () => {
|
||||
it("returns an empty list when no entities are targeted", () => {
|
||||
@@ -48,3 +54,155 @@ describe("getServiceCallEntityIds", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const asFields = (fields: Record<string, unknown>) =>
|
||||
fields as unknown as HassService["fields"];
|
||||
|
||||
describe("getServiceFieldVisibility", () => {
|
||||
it("keeps the condition of a top level field", () => {
|
||||
expect(
|
||||
getServiceFieldVisibility(
|
||||
asFields({
|
||||
a: { selector: { boolean: {} } },
|
||||
b: { selector: { text: {} }, visible: { field: "a", value: true } },
|
||||
})
|
||||
)
|
||||
).toEqual([
|
||||
["a", undefined],
|
||||
["b", { field: "a", value: true }],
|
||||
]);
|
||||
});
|
||||
|
||||
it("flattens the fields of a section", () => {
|
||||
expect(
|
||||
getServiceFieldVisibility(
|
||||
asFields({
|
||||
section: {
|
||||
fields: {
|
||||
a: { selector: { boolean: {} } },
|
||||
b: { selector: { text: {} } },
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
).toEqual([
|
||||
["a", []],
|
||||
["b", []],
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives the fields of a section the condition of the section", () => {
|
||||
expect(
|
||||
getServiceFieldVisibility(
|
||||
asFields({
|
||||
a: { selector: { boolean: {} } },
|
||||
section: {
|
||||
visible: { field: "a", value: true },
|
||||
fields: {
|
||||
b: { selector: { text: {} } },
|
||||
c: {
|
||||
selector: { text: {} },
|
||||
visible: { field: "b", operator: "exists" },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
).toEqual([
|
||||
["a", undefined],
|
||||
["b", [{ field: "a", value: true }]],
|
||||
[
|
||||
"c",
|
||||
[
|
||||
{ field: "a", value: true },
|
||||
{ field: "b", operator: "exists" },
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides every field of a section that is never visible", () => {
|
||||
expect(
|
||||
getServiceFieldVisibility(
|
||||
asFields({
|
||||
section: {
|
||||
visible: false,
|
||||
fields: { a: { selector: { text: {} } } },
|
||||
},
|
||||
})
|
||||
)
|
||||
).toEqual([["a", false]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getServiceHiddenFields", () => {
|
||||
const modeFields = {
|
||||
mode: {
|
||||
required: true,
|
||||
default: "duration",
|
||||
selector: { select: { options: ["duration", "end_of_track"] } },
|
||||
},
|
||||
advanced: {
|
||||
fields: {
|
||||
duration: {
|
||||
required: true,
|
||||
selector: { duration: {} },
|
||||
visible: { field: "mode", value: "duration" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as HassService["fields"];
|
||||
const visibility = getServiceFieldVisibility(modeFields);
|
||||
const defaults = getServiceFieldDefaults(modeFields);
|
||||
|
||||
it("collects the defaults of fields, including fields in sections", () => {
|
||||
expect(defaults).toEqual({ mode: "duration" });
|
||||
});
|
||||
|
||||
it("evaluates a field without a value with its default", () => {
|
||||
expect(getServiceHiddenFields(visibility, defaults, {})).toEqual(new Set());
|
||||
expect(
|
||||
getServiceHiddenFields(visibility, defaults, { mode: undefined })
|
||||
).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("never hides a field based on a template", () => {
|
||||
expect(
|
||||
getServiceHiddenFields(visibility, defaults, {
|
||||
mode: "{{ states('input_select.mode') }}",
|
||||
})
|
||||
).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("keeps the conditions that do not depend on a template", () => {
|
||||
const fields = {
|
||||
enabled: { selector: { boolean: {} } },
|
||||
mode: { selector: { text: {} } },
|
||||
section: {
|
||||
visible: { field: "enabled", value: true },
|
||||
fields: {
|
||||
duration: {
|
||||
selector: { duration: {} },
|
||||
visible: { field: "mode", value: "duration" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as HassService["fields"];
|
||||
expect(
|
||||
getServiceHiddenFields(
|
||||
getServiceFieldVisibility(fields),
|
||||
{},
|
||||
{
|
||||
enabled: false,
|
||||
mode: "{{ states('input_select.mode') }}",
|
||||
}
|
||||
)
|
||||
).toEqual(new Set(["duration"]));
|
||||
});
|
||||
|
||||
it("uses the value of the field when it has one", () => {
|
||||
expect(
|
||||
getServiceHiddenFields(visibility, defaults, { mode: "end_of_track" })
|
||||
).toEqual(new Set(["duration"]));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user