Compare commits

...
4 changed files with 397 additions and 24 deletions

No files matched your search

+116 -18
View File
@@ -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),
},
});
}
+99
View File
@@ -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
);
};
+23 -5
View File
@@ -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
View File
@@ -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"]));
});
});