From 8f07e6f141ddd04ea053bf17eb7709004014db45 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 21 Dec 2023 21:01:27 +0100 Subject: [PATCH] Group add automation elements in dialog (#19086) * Group add automation elements in dialog * Add search * clear filter on close * Split out services * group services by integration type * Update add-automation-element-dialog.ts * fix typing * clear filter on back * Update add-automation-element-dialog.ts * Fix search * scroll to top * Add service descriptions * fix clipboard * Move play media, sort services * use helpers * move to data * Move building blocks to a group * fix search * Update add-automation-element-dialog.ts * Update en.json * fix alignment of single line and multi line items * use repeat instead of map --- src/components/ha-list-item.ts | 7 + src/data/action.ts | 45 +- src/data/automation.ts | 4 + src/data/condition.ts | 27 +- src/data/integration.ts | 4 +- src/data/trigger.ts | 29 +- .../action/ha-automation-action-row.ts | 8 +- .../automation/action/ha-automation-action.ts | 173 ++---- .../types/ha-automation-action-condition.ts | 4 +- .../add-automation-element-dialog.ts | 553 ++++++++++++++++++ .../condition/ha-automation-condition-row.ts | 4 +- .../condition/ha-automation-condition.ts | 176 +++--- .../show-add-automation-element-dialog.ts | 22 + .../trigger/ha-automation-trigger-row.ts | 4 +- .../trigger/ha-automation-trigger.ts | 161 ++--- .../ha-config-integration-page.ts | 4 +- src/translations/en.json | 53 +- 17 files changed, 926 insertions(+), 352 deletions(-) create mode 100644 src/panels/config/automation/add-automation-element-dialog.ts create mode 100644 src/panels/config/automation/show-add-automation-element-dialog.ts diff --git a/src/components/ha-list-item.ts b/src/components/ha-list-item.ts index a5179008b5..faf8a0663a 100644 --- a/src/components/ha-list-item.ts +++ b/src/components/ha-list-item.ts @@ -47,6 +47,13 @@ export class HaListItem extends ListItemBase { display: var(--mdc-list-item-meta-display); align-items: center; } + :host([graphic="icon"]:not([twoline])) + .mdc-deprecated-list-item__graphic { + margin-inline-end: var( + --mdc-list-item-graphic-margin, + 20px + ) !important; + } :host([multiline-secondary]) { height: auto; } diff --git a/src/data/action.ts b/src/data/action.ts index 6dea0b308f..a7ed018409 100644 --- a/src/data/action.ts +++ b/src/data/action.ts @@ -5,6 +5,8 @@ import { mdiCallSplit, mdiCodeBraces, mdiDevices, + mdiDotsHorizontal, + mdiExcavator, mdiGestureDoubleTap, mdiHandBackRight, mdiPalette, @@ -13,10 +15,12 @@ import { mdiRoomService, mdiShuffleDisabled, mdiTimerOutline, + mdiTools, mdiTrafficLight, } from "@mdi/js"; +import { AutomationElementGroup } from "./automation"; -export const ACTION_TYPES = { +export const ACTION_ICONS = { condition: mdiAbTesting, delay: mdiTimerOutline, event: mdiGestureDoubleTap, @@ -34,6 +38,43 @@ export const ACTION_TYPES = { variables: mdiApplicationVariableOutline, } as const; -export const YAML_ONLY_ACTION_TYPES = new Set([ +export const YAML_ONLY_ACTION_TYPES = new Set([ "variables", ]); + +export const ACTION_GROUPS: AutomationElementGroup = { + device_id: {}, + helpers: { + icon: mdiTools, + members: {}, + }, + building_blocks: { + icon: mdiExcavator, + members: { + condition: {}, + delay: {}, + wait_template: {}, + wait_for_trigger: {}, + repeat: {}, + choose: {}, + if: {}, + stop: {}, + parallel: {}, + variables: {}, + }, + }, + other: { + icon: mdiDotsHorizontal, + members: { + event: {}, + }, + }, +} as const; + +export const SERVICE_PREFIX = "__SERVICE__"; + +export const isService = (key: string | undefined): boolean | undefined => + key?.startsWith(SERVICE_PREFIX); + +export const getService = (key: string): string => + key.substring(SERVICE_PREFIX.length); diff --git a/src/data/automation.ts b/src/data/automation.ts index f2cc6802a1..1527822e71 100644 --- a/src/data/automation.ts +++ b/src/data/automation.ts @@ -275,6 +275,10 @@ export interface ShorthandNotCondition extends ShorthandBaseCondition { not: Condition[]; } +export interface AutomationElementGroup { + [key: string]: { icon?: string; members?: AutomationElementGroup }; +} + export type Condition = | StateCondition | NumericStateCondition diff --git a/src/data/condition.ts b/src/data/condition.ts index 6e63cd5850..44a0e21860 100644 --- a/src/data/condition.ts +++ b/src/data/condition.ts @@ -3,16 +3,21 @@ import { mdiClockOutline, mdiCodeBraces, mdiDevices, + mdiDotsHorizontal, + mdiExcavator, mdiGateOr, mdiIdentifier, + mdiMapClock, mdiMapMarkerRadius, mdiNotEqualVariant, mdiNumeric, + mdiShape, mdiStateMachine, mdiWeatherSunny, } from "@mdi/js"; +import { AutomationElementGroup } from "./automation"; -export const CONDITION_TYPES = { +export const CONDITION_ICONS = { device: mdiDevices, and: mdiAmpersand, or: mdiGateOr, @@ -25,3 +30,23 @@ export const CONDITION_TYPES = { trigger: mdiIdentifier, zone: mdiMapMarkerRadius, }; + +export const CONDITION_GROUPS: AutomationElementGroup = { + device: {}, + entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } }, + time_location: { + icon: mdiMapClock, + members: { sun: {}, time: {}, zone: {} }, + }, + building_blocks: { + icon: mdiExcavator, + members: { and: {}, or: {}, not: {} }, + }, + other: { + icon: mdiDotsHorizontal, + members: { + template: {}, + trigger: {}, + }, + }, +} as const; diff --git a/src/data/integration.ts b/src/data/integration.ts index 151140bebd..5c331c814a 100644 --- a/src/data/integration.ts +++ b/src/data/integration.ts @@ -16,7 +16,9 @@ export type IntegrationType = | "helper" | "hub" | "service" - | "hardware"; + | "hardware" + | "entity" + | "system"; export interface IntegrationManifest { is_built_in: boolean; diff --git a/src/data/trigger.ts b/src/data/trigger.ts index 859595a78a..c68fed612b 100644 --- a/src/data/trigger.ts +++ b/src/data/trigger.ts @@ -4,13 +4,16 @@ import { mdiClockOutline, mdiCodeBraces, mdiDevices, + mdiDotsHorizontal, mdiGestureDoubleTap, + mdiMapClock, mdiMapMarker, mdiMapMarkerRadius, mdiMessageAlert, mdiMicrophoneMessage, mdiNfcVariant, mdiNumeric, + mdiShape, mdiStateMachine, mdiSwapHorizontal, mdiWeatherSunny, @@ -18,8 +21,9 @@ import { } from "@mdi/js"; import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg"; +import { AutomationElementGroup } from "./automation"; -export const TRIGGER_TYPES = { +export const TRIGGER_ICONS = { calendar: mdiCalendar, device: mdiDevices, event: mdiGestureDoubleTap, @@ -38,3 +42,26 @@ export const TRIGGER_TYPES = { persistent_notification: mdiMessageAlert, zone: mdiMapMarkerRadius, }; + +export const TRIGGER_GROUPS: AutomationElementGroup = { + device: {}, + entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } }, + time_location: { + icon: mdiMapClock, + members: { calendar: {}, sun: {}, time: {}, time_pattern: {}, zone: {} }, + }, + other: { + icon: mdiDotsHorizontal, + members: { + event: {}, + geo_location: {}, + homeassistant: {}, + mqtt: {}, + conversation: {}, + tag: {}, + template: {}, + webhook: {}, + persistent_notification: {}, + }, + }, +} as const; diff --git a/src/panels/config/automation/action/ha-automation-action-row.ts b/src/panels/config/automation/action/ha-automation-action-row.ts index 2c114cd946..f09c901c8e 100644 --- a/src/panels/config/automation/action/ha-automation-action-row.ts +++ b/src/panels/config/automation/action/ha-automation-action-row.ts @@ -37,7 +37,7 @@ import "../../../../components/ha-card"; import "../../../../components/ha-expansion-panel"; import "../../../../components/ha-icon-button"; import type { HaYamlEditor } from "../../../../components/ha-yaml-editor"; -import { ACTION_TYPES, YAML_ONLY_ACTION_TYPES } from "../../../../data/action"; +import { ACTION_ICONS, YAML_ONLY_ACTION_TYPES } from "../../../../data/action"; import { AutomationClipboard } from "../../../../data/automation"; import { validateConfig } from "../../../../data/config"; import { fullEntitiesContext } from "../../../../data/context"; @@ -82,9 +82,9 @@ export const getType = (action: Action | undefined) => { if (["and", "or", "not"].some((key) => key in action)) { return "condition" as const; } - return Object.keys(ACTION_TYPES).find( + return Object.keys(ACTION_ICONS).find( (option) => option in action - ) as keyof typeof ACTION_TYPES; + ) as keyof typeof ACTION_ICONS; }; export interface ActionElement extends LitElement { @@ -190,7 +190,7 @@ export default class HaAutomationActionRow extends LitElement {

${capitalizeFirstLetter( describeAction(this.hass, this._entityReg, this.action) diff --git a/src/panels/config/automation/action/ha-automation-action.ts b/src/panels/config/automation/action/ha-automation-action.ts index 5c85b509c1..df140e94ea 100644 --- a/src/panels/config/automation/action/ha-automation-action.ts +++ b/src/panels/config/automation/action/ha-automation-action.ts @@ -1,57 +1,26 @@ import "@material/mwc-button"; -import type { ActionDetail } from "@material/mwc-list"; -import { - mdiArrowDown, - mdiArrowUp, - mdiContentPaste, - mdiDrag, - mdiPlus, -} from "@mdi/js"; +import { mdiArrowDown, mdiArrowUp, mdiDrag, mdiPlus } from "@mdi/js"; import deepClone from "deep-clone-simple"; -import { - CSSResultGroup, - LitElement, - PropertyValues, - css, - html, - nothing, -} from "lit"; +import { CSSResultGroup, LitElement, PropertyValues, css, html } from "lit"; import { customElement, property } from "lit/decorators"; import { repeat } from "lit/directives/repeat"; -import memoizeOne from "memoize-one"; import type { SortableEvent } from "sortablejs"; import { storage } from "../../../../common/decorators/storage"; import { fireEvent } from "../../../../common/dom/fire_event"; -import { stringCompare } from "../../../../common/string/compare"; -import { LocalizeFunc } from "../../../../common/translations/localize"; import "../../../../components/ha-button"; -import "../../../../components/ha-button-menu"; -import type { HaSelect } from "../../../../components/ha-select"; import "../../../../components/ha-svg-icon"; -import { ACTION_TYPES } from "../../../../data/action"; -import { AutomationClipboard } from "../../../../data/automation"; +import { getService, isService } from "../../../../data/action"; +import type { AutomationClipboard } from "../../../../data/automation"; import { Action } from "../../../../data/script"; import { sortableStyles } from "../../../../resources/ha-sortable-style"; import type { SortableInstance } from "../../../../resources/sortable"; -import { Entries, HomeAssistant } from "../../../../types"; +import { HomeAssistant } from "../../../../types"; +import { + PASTE_VALUE, + showAddAutomationElementDialog, +} from "../show-add-automation-element-dialog"; import type HaAutomationActionRow from "./ha-automation-action-row"; import { getType } from "./ha-automation-action-row"; -import "./types/ha-automation-action-activate_scene"; -import "./types/ha-automation-action-choose"; -import "./types/ha-automation-action-condition"; -import "./types/ha-automation-action-delay"; -import "./types/ha-automation-action-device_id"; -import "./types/ha-automation-action-event"; -import "./types/ha-automation-action-if"; -import "./types/ha-automation-action-parallel"; -import "./types/ha-automation-action-play_media"; -import "./types/ha-automation-action-repeat"; -import "./types/ha-automation-action-service"; -import "./types/ha-automation-action-stop"; -import "./types/ha-automation-action-wait_for_trigger"; -import "./types/ha-automation-action-wait_template"; - -const PASTE_VALUE = "__paste__"; @customElement("ha-automation-action") export default class HaAutomationAction extends LitElement { @@ -150,42 +119,26 @@ export default class HaAutomationAction extends LitElement { ` )} - - - - - ${this._clipboard?.action - ? html` - ${this.hass.localize( - "ui.panel.config.automation.editor.actions.paste" - )} - (${this.hass.localize( - `ui.panel.config.automation.editor.actions.type.${ - getType(this._clipboard.action) || "unknown" - }.label` - )}) - ` - : nothing} - ${this._processedTypes(this.hass.localize).map( - ([opt, label, icon]) => html` - - ${label} - ` + .label=${this.hass.localize( + "ui.panel.config.automation.editor.actions.add" )} - + @click=${this._addActionDialog} + > + + + + + `; } @@ -213,6 +166,43 @@ export default class HaAutomationAction extends LitElement { } } + private _addActionDialog() { + showAddAutomationElementDialog(this, { + type: "action", + add: this._addAction, + clipboardItem: getType(this._clipboard?.action), + }); + } + + private _addActionBuildingBlockDialog() { + showAddAutomationElementDialog(this, { + type: "action", + add: this._addAction, + clipboardItem: getType(this._clipboard?.action), + group: "building_blocks", + }); + } + + private _addAction = (action: string) => { + let actions: Action[]; + if (action === PASTE_VALUE) { + actions = this.actions.concat(deepClone(this._clipboard!.action)); + } else if (isService(action)) { + actions = this.actions.concat({ + service: getService(action), + }); + } else { + const elClass = customElements.get( + `ha-automation-action-${action}` + ) as CustomElementConstructor & { defaultConfig: Action }; + actions = this.actions.concat( + elClass ? { ...elClass.defaultConfig } : { [action]: {} } + ); + } + this._focusLastActionOnChange = true; + fireEvent(this, "value-changed", { value: actions }); + }; + private async _enterReOrderMode(ev: CustomEvent) { if (this.nested) return; ev.stopPropagation(); @@ -258,25 +248,6 @@ export default class HaAutomationAction extends LitElement { return this._actionKeys.get(action)!; } - private _addAction(ev: CustomEvent) { - const action = (ev.currentTarget as HaSelect).items[ev.detail.index].value; - - let actions: Action[]; - if (action === PASTE_VALUE) { - actions = this.actions.concat(deepClone(this._clipboard!.action)); - } else { - const elClass = customElements.get( - `ha-automation-action-${action}` - ) as CustomElementConstructor & { defaultConfig: Action }; - - actions = this.actions.concat( - elClass ? { ...elClass.defaultConfig } : { [action]: {} } - ); - } - this._focusLastActionOnChange = true; - fireEvent(this, "value-changed", { value: actions }); - } - private _moveUp(ev) { const index = (ev.target as any).index; const newIndex = index - 1; @@ -328,22 +299,6 @@ export default class HaAutomationAction extends LitElement { }); } - private _processedTypes = memoizeOne( - (localize: LocalizeFunc): [string, string, string][] => - (Object.entries(ACTION_TYPES) as Entries) - .map( - ([action, icon]) => - [ - action, - localize( - `ui.panel.config.automation.editor.actions.type.${action}.label` - ), - icon, - ] as [string, string, string] - ) - .sort((a, b) => stringCompare(a[1], b[1], this.hass.locale.language)) - ); - static get styles(): CSSResultGroup { return [ sortableStyles, diff --git a/src/panels/config/automation/action/types/ha-automation-action-condition.ts b/src/panels/config/automation/action/types/ha-automation-action-condition.ts index c72da97aac..d62176812d 100644 --- a/src/panels/config/automation/action/types/ha-automation-action-condition.ts +++ b/src/panels/config/automation/action/types/ha-automation-action-condition.ts @@ -7,7 +7,7 @@ import type { LocalizeFunc } from "../../../../../common/translations/localize"; import "../../../../../components/ha-select"; import type { HaSelect } from "../../../../../components/ha-select"; import type { Condition } from "../../../../../data/automation"; -import { CONDITION_TYPES } from "../../../../../data/condition"; +import { CONDITION_ICONS } from "../../../../../data/condition"; import { Entries, HomeAssistant } from "../../../../../types"; import "../../condition/ha-automation-condition-editor"; import type { ActionElement } from "../ha-automation-action-row"; @@ -55,7 +55,7 @@ export class HaConditionAction extends LitElement implements ActionElement { private _processedTypes = memoizeOne( (localize: LocalizeFunc): [string, string, string][] => - (Object.entries(CONDITION_TYPES) as Entries) + (Object.entries(CONDITION_ICONS) as Entries) .map( ([condition, icon]) => [ diff --git a/src/panels/config/automation/add-automation-element-dialog.ts b/src/panels/config/automation/add-automation-element-dialog.ts new file mode 100644 index 0000000000..b24bb71121 --- /dev/null +++ b/src/panels/config/automation/add-automation-element-dialog.ts @@ -0,0 +1,553 @@ +import "@material/mwc-list/mwc-list"; +import { mdiClose, mdiContentPaste, mdiPlus } from "@mdi/js"; +import Fuse, { IFuseOptions } from "fuse.js"; +import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { repeat } from "lit/directives/repeat"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { domainIcon } from "../../../common/entity/domain_icon"; +import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event"; +import { stringCompare } from "../../../common/string/compare"; +import { LocalizeFunc } from "../../../common/translations/localize"; +import "../../../components/ha-dialog"; +import type { HaDialog } from "../../../components/ha-dialog"; +import "../../../components/ha-header-bar"; +import "../../../components/ha-icon-button"; +import "../../../components/ha-icon-button-prev"; +import "../../../components/ha-icon-next"; +import "../../../components/ha-list-item"; +import "../../../components/search-input"; +import { + ACTION_GROUPS, + ACTION_ICONS, + SERVICE_PREFIX, + getService, + isService, +} from "../../../data/action"; +import { AutomationElementGroup } from "../../../data/automation"; +import { CONDITION_GROUPS, CONDITION_ICONS } from "../../../data/condition"; +import { + IntegrationManifest, + domainToName, + fetchIntegrationManifests, +} from "../../../data/integration"; +import { TRIGGER_GROUPS, TRIGGER_ICONS } from "../../../data/trigger"; +import { HassDialog } from "../../../dialogs/make-dialog-manager"; +import { haStyle, haStyleDialog } from "../../../resources/styles"; +import { HomeAssistant } from "../../../types"; +import { + AddAutomationElementDialogParams, + PASTE_VALUE, +} from "./show-add-automation-element-dialog"; + +const TYPES = { + trigger: { groups: TRIGGER_GROUPS, icons: TRIGGER_ICONS }, + condition: { + groups: CONDITION_GROUPS, + icons: CONDITION_ICONS, + }, + action: { + groups: ACTION_GROUPS, + icons: ACTION_ICONS, + }, +}; + +interface ListItem { + key: string; + name: string; + description: string; + icon: string; + group: boolean; +} + +interface DomainManifestLookup { + [domain: string]: IntegrationManifest; +} + +const ENTITY_DOMAINS_OTHER = new Set([ + "date", + "datetime", + "device_tracker", + "text", + "time", + "tts", + "update", + "weather", + "image_processing", +]); + +@customElement("add-automation-element-dialog") +class DialogAddAutomationElement extends LitElement implements HassDialog { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: AddAutomationElementDialogParams; + + @state() private _group?: string; + + @state() private _prev?: string; + + @state() private _filter = ""; + + @state() private _manifests?: DomainManifestLookup; + + @query("ha-dialog") private _dialog?: HaDialog; + + public showDialog(params): void { + this._params = params; + this._group = params.group; + if (this._params?.type === "action") { + this.hass.loadBackendTranslation("services"); + this._fetchManifests(); + } + } + + public closeDialog(): void { + if (this._params) { + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + this._params = undefined; + this._group = undefined; + this._prev = undefined; + this._filter = ""; + this._manifests = undefined; + } + + private _convertToItem = ( + key: string, + options, + type: AddAutomationElementDialogParams["type"], + localize: LocalizeFunc + ): ListItem => ({ + group: Boolean(options.members), + key, + name: localize( + // @ts-ignore + `ui.panel.config.automation.editor.${type}s.${ + options.members ? "groups" : "type" + }.${key}.label` + ), + description: localize( + // @ts-ignore + `ui.panel.config.automation.editor.${type}s.${ + options.members ? "groups" : "type" + }.${key}.description${options.members ? "" : ".picker"}` + ), + icon: options.icon || TYPES[type].icons[key], + }); + + private _getFilteredItems = memoizeOne( + ( + type: AddAutomationElementDialogParams["type"], + group: string | undefined, + filter: string, + localize: LocalizeFunc, + services: HomeAssistant["services"], + manifests?: DomainManifestLookup + ): ListItem[] => { + const groups: AutomationElementGroup = group + ? isService(group) + ? {} + : TYPES[type].groups[group].members! + : TYPES[type].groups; + + const flattenGroups = (grp: AutomationElementGroup) => + Object.entries(grp).map(([key, options]) => + options.members + ? flattenGroups(options.members) + : this._convertToItem(key, options, type, localize) + ); + + const items = flattenGroups(groups).flat(); + + if (type === "action") { + items.push(...this._services(localize, services, manifests, group)); + } + + const options: IFuseOptions = { + keys: ["key", "name", "description"], + isCaseSensitive: false, + minMatchCharLength: Math.min(filter.length, 2), + threshold: 0.2, + }; + const fuse = new Fuse(items, options); + return fuse.search(filter).map((result) => result.item); + } + ); + + private _getGroupItems = memoizeOne( + ( + type: AddAutomationElementDialogParams["type"], + group: string | undefined, + localize: LocalizeFunc, + services: HomeAssistant["services"], + manifests?: DomainManifestLookup + ): ListItem[] => { + if (type === "action" && isService(group)) { + const result = this._services(localize, services, manifests, group); + if (group === "service_media_player") { + result.unshift(this._convertToItem("play_media", {}, type, localize)); + } + return result; + } + + const groups: AutomationElementGroup = group + ? TYPES[type].groups[group].members! + : TYPES[type].groups; + + const result = Object.entries(groups).map(([key, options]) => + this._convertToItem(key, options, type, localize) + ); + + if (type === "action") { + if (!this._group) { + result.unshift( + ...this._serviceGroups(localize, services, manifests, undefined) + ); + } else if (this._group === "helpers") { + result.unshift( + ...this._serviceGroups(localize, services, manifests, "helper") + ); + } else if (this._group === "other") { + result.unshift( + ...this._serviceGroups(localize, services, manifests, "other") + ); + } + } + + return result.sort((a, b) => { + if (a.group && b.group) { + return 0; + } + if (a.group && !b.group) { + return 1; + } + if (!a.group && b.group) { + return -1; + } + return stringCompare(a.name, b.name, this.hass.locale.language); + }); + } + ); + + private _serviceGroups = memoizeOne( + ( + localize: LocalizeFunc, + services: HomeAssistant["services"], + manifests: DomainManifestLookup | undefined, + type: "helper" | "other" | undefined + ): ListItem[] => { + if (!services || !manifests) { + return []; + } + const result: ListItem[] = []; + Object.keys(services) + .sort() + .forEach((domain) => { + const manifest = manifests[domain]; + if ( + (type === undefined && + manifest?.integration_type === "entity" && + !ENTITY_DOMAINS_OTHER.has(domain)) || + (type === "helper" && manifest?.integration_type === "helper") || + (type === "other" && + (ENTITY_DOMAINS_OTHER.has(domain) || + !["helper", "entity"].includes( + manifest?.integration_type || "" + ))) + ) { + result.push({ + group: true, + icon: domainIcon(domain), + key: `${SERVICE_PREFIX}${domain}`, + name: domainToName(localize, domain, manifest), + description: "", + }); + } + }); + return result; + } + ); + + private _services = memoizeOne( + ( + localize: LocalizeFunc, + services: HomeAssistant["services"], + manifests: DomainManifestLookup | undefined, + group?: string + ): ListItem[] => { + if (!services) { + return []; + } + const result: ListItem[] = []; + + let domain: string | undefined; + + if (isService(group)) { + domain = getService(group!); + } + + const addDomain = (dmn: string) => { + const services_keys = Object.keys(services[dmn]); + + for (const service of services_keys) { + result.push({ + group: false, + icon: domainIcon(dmn), + key: `${SERVICE_PREFIX}${dmn}.${service}`, + name: `${domain ? "" : `${domainToName(localize, dmn)}: `}${ + this.hass.localize(`component.${dmn}.services.${service}.name`) || + services[dmn][service]?.name || + service + }`, + description: + this.hass.localize( + `component.${domain}.services.${service}.description` + ) || services[dmn][service]?.description, + }); + } + }; + + if (domain) { + addDomain(domain); + return result.sort((a, b) => + stringCompare(a.name, b.name, this.hass.locale.language) + ); + } + + if (group && !["helpers", "other"].includes(group)) { + return []; + } + + Object.keys(services) + .sort() + .forEach((dmn) => { + const manifest = manifests?.[dmn]; + if (group === "helpers" && manifest?.integration_type !== "helper") { + return; + } + if ( + group === "other" && + (ENTITY_DOMAINS_OTHER.has(dmn) || + ["helper", "entity"].includes(manifest?.integration_type || "")) + ) { + return; + } + addDomain(dmn); + }); + + return result; + } + ); + + private async _fetchManifests() { + const manifests = {}; + const fetched = await fetchIntegrationManifests(this.hass); + for (const manifest of fetched) { + manifests[manifest.domain] = manifest; + } + this._manifests = manifests; + } + + protected render() { + if (!this._params) { + return nothing; + } + + const items = this._filter + ? this._getFilteredItems( + this._params.type, + this._group, + this._filter, + this.hass.localize, + this.hass.services, + this._manifests + ) + : this._getGroupItems( + this._params.type, + this._group, + this.hass.localize, + this.hass.services, + this._manifests + ); + + const groupName = isService(this._group) + ? domainToName( + this.hass.localize, + getService(this._group!), + this._manifests?.[getService(this._group!)] + ) + : this.hass.localize( + // @ts-ignore + `ui.panel.config.automation.editor.${this._params.type}s.groups.${this._group}.label` + ); + + return html` + +
+ + ${this._group + ? groupName + : this.hass.localize( + `ui.panel.config.automation.editor.${this._params.type}s.add` + )} + ${this._group && this._group !== this._params.group + ? html`` + : html``} + + +
+ + ${this._params.clipboardItem && + !this._filter && + (!this._group || + items.find((item) => item.key === this._params!.clipboardItem)) + ? html` + ${this.hass.localize( + `ui.panel.config.automation.editor.${this._params.type}s.paste` + )} + ${this.hass.localize( + // @ts-ignore + `ui.panel.config.automation.editor.${this._params.type}s.type.${this._params.clipboardItem}.label` + )} + + +
  • ` + : ""} + ${repeat( + items, + (item) => item.key, + (item) => html` + + ${item.name} + ${item.description} + + ${item.group + ? html`` + : html``} + + ` + )} +
    +
    + `; + } + + private _back() { + if (this._filter) { + this._filter = ""; + return; + } + if (this._prev) { + this._group = this._prev; + this._prev = undefined; + return; + } + this._group = undefined; + } + + private _selected(ev) { + if (!shouldHandleRequestSelectedEvent(ev)) { + return; + } + this._dialog!.scrollToPos(0, 0); + const item = ev.currentTarget; + if (item.group) { + this._prev = this._group; + this._group = item.value; + return; + } + this._params!.add(item.value); + this.closeDialog(); + } + + private _filterChanged(ev) { + this._filter = ev.detail.value; + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleDialog, + css` + ha-dialog { + --dialog-content-padding: 0; + --mdc-dialog-max-height: 60vh; + } + @media all and (min-width: 550px) { + ha-dialog { + --mdc-dialog-min-width: 500px; + } + } + ha-header-bar { + --mdc-theme-on-primary: var(--primary-text-color); + --mdc-theme-primary: var(--mdc-theme-surface); + margin-top: 8px; + display: block; + } + ha-icon-next { + width: 24px; + } + search-input { + display: block; + margin: 0 16px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "add-automation-element-dialog": DialogAddAutomationElement; + } +} diff --git a/src/panels/config/automation/condition/ha-automation-condition-row.ts b/src/panels/config/automation/condition/ha-automation-condition-row.ts index 7e8bb4e270..1b51a9d278 100644 --- a/src/panels/config/automation/condition/ha-automation-condition-row.ts +++ b/src/panels/config/automation/condition/ha-automation-condition-row.ts @@ -29,7 +29,7 @@ import "../../../../components/ha-icon-button"; import type { AutomationClipboard } from "../../../../data/automation"; import { Condition, testCondition } from "../../../../data/automation"; import { describeCondition } from "../../../../data/automation_i18n"; -import { CONDITION_TYPES } from "../../../../data/condition"; +import { CONDITION_ICONS } from "../../../../data/condition"; import { validateConfig } from "../../../../data/config"; import { fullEntitiesContext } from "../../../../data/context"; import { EntityRegistryEntry } from "../../../../data/entity_registry"; @@ -123,7 +123,7 @@ export default class HaAutomationConditionRow extends LitElement {

    ${capitalizeFirstLetter( describeCondition(this.condition, this.hass, this._entityReg) diff --git a/src/panels/config/automation/condition/ha-automation-condition.ts b/src/panels/config/automation/condition/ha-automation-condition.ts index f562d12203..11744d7454 100644 --- a/src/panels/config/automation/condition/ha-automation-condition.ts +++ b/src/panels/config/automation/condition/ha-automation-condition.ts @@ -1,25 +1,18 @@ import "@material/mwc-button"; -import type { ActionDetail } from "@material/mwc-list"; -import { - mdiArrowDown, - mdiArrowUp, - mdiContentPaste, - mdiDrag, - mdiPlus, -} from "@mdi/js"; +import { mdiArrowDown, mdiArrowUp, mdiDrag, mdiPlus } from "@mdi/js"; import deepClone from "deep-clone-simple"; import { - css, CSSResultGroup, - html, LitElement, - nothing, PropertyValues, + css, + html, + nothing, } from "lit"; import { customElement, property } from "lit/decorators"; import { repeat } from "lit/directives/repeat"; -import memoizeOne from "memoize-one"; import type { SortableEvent } from "sortablejs"; +import { storage } from "../../../../common/decorators/storage"; import { fireEvent } from "../../../../common/dom/fire_event"; import "../../../../components/ha-button"; import "../../../../components/ha-button-menu"; @@ -28,30 +21,15 @@ import type { AutomationClipboard, Condition, } from "../../../../data/automation"; -import type { Entries, HomeAssistant } from "../../../../types"; -import "./ha-automation-condition-row"; -import type HaAutomationConditionRow from "./ha-automation-condition-row"; -// Uncommenting these and this element doesn't load -// import "./types/ha-automation-condition-not"; -// import "./types/ha-automation-condition-or"; -import { storage } from "../../../../common/decorators/storage"; -import { stringCompare } from "../../../../common/string/compare"; -import type { LocalizeFunc } from "../../../../common/translations/localize"; -import type { HaSelect } from "../../../../components/ha-select"; -import { CONDITION_TYPES } from "../../../../data/condition"; import { sortableStyles } from "../../../../resources/ha-sortable-style"; import type { SortableInstance } from "../../../../resources/sortable"; -import "./types/ha-automation-condition-and"; -import "./types/ha-automation-condition-device"; -import "./types/ha-automation-condition-numeric_state"; -import "./types/ha-automation-condition-state"; -import "./types/ha-automation-condition-sun"; -import "./types/ha-automation-condition-template"; -import "./types/ha-automation-condition-time"; -import "./types/ha-automation-condition-trigger"; -import "./types/ha-automation-condition-zone"; - -const PASTE_VALUE = "__paste__"; +import type { HomeAssistant } from "../../../../types"; +import { + PASTE_VALUE, + showAddAutomationElementDialog, +} from "../show-add-automation-element-dialog"; +import "./ha-automation-condition-row"; +import type HaAutomationConditionRow from "./ha-automation-condition-row"; @customElement("ha-automation-condition") export default class HaAutomationCondition extends LitElement { @@ -197,43 +175,67 @@ export default class HaAutomationCondition extends LitElement { ` )} - - - - - ${this._clipboard?.condition - ? html` - ${this.hass.localize( - "ui.panel.config.automation.editor.conditions.paste" - )} - (${this.hass.localize( - `ui.panel.config.automation.editor.conditions.type.${this._clipboard.condition.condition}.label` - )}) - ` - : nothing} - ${this._processedTypes(this.hass.localize).map( - ([opt, label, icon]) => html` - - ${label} - ` + .label=${this.hass.localize( + "ui.panel.config.automation.editor.conditions.add" )} - + @click=${this._addConditionDialog} + > + + + + + `; } + private _addConditionDialog() { + showAddAutomationElementDialog(this, { + type: "condition", + add: this._addCondition, + clipboardItem: this._clipboard?.condition?.condition, + }); + } + + private _addConditionBuildingBlockDialog() { + showAddAutomationElementDialog(this, { + type: "condition", + add: this._addCondition, + clipboardItem: this._clipboard?.condition?.condition, + group: "building_blocks", + }); + } + + private _addCondition = (value) => { + let conditions: Condition[]; + if (value === PASTE_VALUE) { + conditions = this.conditions.concat( + deepClone(this._clipboard!.condition) + ); + } else { + const condition = value as Condition["condition"]; + const elClass = customElements.get( + `ha-automation-condition-${condition}` + ) as CustomElementConstructor & { + defaultConfig: Omit; + }; + conditions = this.conditions.concat({ + condition: condition as any, + ...elClass.defaultConfig, + }); + } + this._focusLastConditionOnChange = true; + fireEvent(this, "value-changed", { value: conditions }); + }; + private async _enterReOrderMode(ev: CustomEvent) { if (this.nested) return; ev.stopPropagation(); @@ -282,32 +284,6 @@ export default class HaAutomationCondition extends LitElement { return this._conditionKeys.get(condition)!; } - private _addCondition(ev: CustomEvent) { - const value = (ev.currentTarget as HaSelect).items[ev.detail.index].value; - - let conditions: Condition[]; - if (value === PASTE_VALUE) { - conditions = this.conditions.concat( - deepClone(this._clipboard!.condition) - ); - } else { - const condition = value as Condition["condition"]; - - const elClass = customElements.get( - `ha-automation-condition-${condition}` - ) as CustomElementConstructor & { - defaultConfig: Omit; - }; - - conditions = this.conditions.concat({ - condition: condition as any, - ...elClass.defaultConfig, - }); - } - this._focusLastConditionOnChange = true; - fireEvent(this, "value-changed", { value: conditions }); - } - private _moveUp(ev) { const index = (ev.target as any).index; const newIndex = index - 1; @@ -361,22 +337,6 @@ export default class HaAutomationCondition extends LitElement { }); } - private _processedTypes = memoizeOne( - (localize: LocalizeFunc): [string, string, string][] => - (Object.entries(CONDITION_TYPES) as Entries) - .map( - ([condition, icon]) => - [ - condition, - localize( - `ui.panel.config.automation.editor.conditions.type.${condition}.label` - ), - icon, - ] as [string, string, string] - ) - .sort((a, b) => stringCompare(a[1], b[1], this.hass.locale.language)) - ); - static get styles(): CSSResultGroup { return [ sortableStyles, diff --git a/src/panels/config/automation/show-add-automation-element-dialog.ts b/src/panels/config/automation/show-add-automation-element-dialog.ts new file mode 100644 index 0000000000..c497ca85c6 --- /dev/null +++ b/src/panels/config/automation/show-add-automation-element-dialog.ts @@ -0,0 +1,22 @@ +import { fireEvent } from "../../../common/dom/fire_event"; + +export const PASTE_VALUE = "__paste__"; + +export interface AddAutomationElementDialogParams { + type: "trigger" | "condition" | "action"; + add: (key: string) => void; + clipboardItem: string | undefined; + group?: string; +} +const loadDialog = () => import("./add-automation-element-dialog"); + +export const showAddAutomationElementDialog = ( + element: HTMLElement, + dialogParams: AddAutomationElementDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "add-automation-element-dialog", + dialogImport: loadDialog, + dialogParams, + }); +}; diff --git a/src/panels/config/automation/trigger/ha-automation-trigger-row.ts b/src/panels/config/automation/trigger/ha-automation-trigger-row.ts index 128f7288be..5a4acb43c6 100644 --- a/src/panels/config/automation/trigger/ha-automation-trigger-row.ts +++ b/src/panels/config/automation/trigger/ha-automation-trigger-row.ts @@ -37,7 +37,7 @@ import { describeTrigger } from "../../../../data/automation_i18n"; import { validateConfig } from "../../../../data/config"; import { fullEntitiesContext } from "../../../../data/context"; import { EntityRegistryEntry } from "../../../../data/entity_registry"; -import { TRIGGER_TYPES } from "../../../../data/trigger"; +import { TRIGGER_ICONS } from "../../../../data/trigger"; import { showAlertDialog, showConfirmationDialog, @@ -150,7 +150,7 @@ export default class HaAutomationTriggerRow extends LitElement {

    ${describeTrigger(this.trigger, this.hass, this._entityReg)}

    diff --git a/src/panels/config/automation/trigger/ha-automation-trigger.ts b/src/panels/config/automation/trigger/ha-automation-trigger.ts index 3be0e59161..d95e9d2924 100644 --- a/src/panels/config/automation/trigger/ha-automation-trigger.ts +++ b/src/panels/config/automation/trigger/ha-automation-trigger.ts @@ -1,59 +1,25 @@ import "@material/mwc-button"; -import type { ActionDetail } from "@material/mwc-list"; -import { - mdiArrowDown, - mdiArrowUp, - mdiContentPaste, - mdiDrag, - mdiPlus, -} from "@mdi/js"; +import { mdiArrowDown, mdiArrowUp, mdiDrag, mdiPlus } from "@mdi/js"; import deepClone from "deep-clone-simple"; -import { - CSSResultGroup, - LitElement, - PropertyValues, - css, - html, - nothing, -} from "lit"; +import { CSSResultGroup, LitElement, PropertyValues, css, html } from "lit"; import { customElement, property } from "lit/decorators"; import { repeat } from "lit/directives/repeat"; -import memoizeOne from "memoize-one"; import type { SortableEvent } from "sortablejs"; import { storage } from "../../../../common/decorators/storage"; import { fireEvent } from "../../../../common/dom/fire_event"; -import { stringCompare } from "../../../../common/string/compare"; -import type { LocalizeFunc } from "../../../../common/translations/localize"; import "../../../../components/ha-button"; import "../../../../components/ha-button-menu"; -import type { HaSelect } from "../../../../components/ha-select"; import "../../../../components/ha-svg-icon"; import { AutomationClipboard, Trigger } from "../../../../data/automation"; -import { TRIGGER_TYPES } from "../../../../data/trigger"; import { sortableStyles } from "../../../../resources/ha-sortable-style"; import type { SortableInstance } from "../../../../resources/sortable"; -import { Entries, HomeAssistant } from "../../../../types"; +import { HomeAssistant } from "../../../../types"; import "./ha-automation-trigger-row"; import type HaAutomationTriggerRow from "./ha-automation-trigger-row"; -import "./types/ha-automation-trigger-calendar"; -import "./types/ha-automation-trigger-conversation"; -import "./types/ha-automation-trigger-device"; -import "./types/ha-automation-trigger-event"; -import "./types/ha-automation-trigger-geo_location"; -import "./types/ha-automation-trigger-homeassistant"; -import "./types/ha-automation-trigger-mqtt"; -import "./types/ha-automation-trigger-numeric_state"; -import "./types/ha-automation-trigger-persistent_notification"; -import "./types/ha-automation-trigger-state"; -import "./types/ha-automation-trigger-sun"; -import "./types/ha-automation-trigger-tag"; -import "./types/ha-automation-trigger-template"; -import "./types/ha-automation-trigger-time"; -import "./types/ha-automation-trigger-time_pattern"; -import "./types/ha-automation-trigger-webhook"; -import "./types/ha-automation-trigger-zone"; - -const PASTE_VALUE = "__paste__"; +import { + PASTE_VALUE, + showAddAutomationElementDialog, +} from "../show-add-automation-element-dialog"; @customElement("ha-automation-trigger") export default class HaAutomationTrigger extends LitElement { @@ -147,47 +113,48 @@ export default class HaAutomationTrigger extends LitElement { ` )} - - - - - ${this._clipboard?.trigger - ? html` - ${this.hass.localize( - "ui.panel.config.automation.editor.triggers.paste" - )} - (${this.hass.localize( - `ui.panel.config.automation.editor.triggers.type.${this._clipboard.trigger.platform}.label` - )}) - ` - : nothing} - ${this._processedTypes(this.hass.localize).map( - ([opt, label, icon]) => html` - - ${label} - ` + + .disabled=${this.disabled} + @click=${this._addTriggerDialog} + > + + `; } + private _addTriggerDialog() { + showAddAutomationElementDialog(this, { + type: "trigger", + add: this._addTrigger, + clipboardItem: this._clipboard?.trigger?.platform, + }); + } + + private _addTrigger = (value: string) => { + let triggers: Trigger[]; + if (value === PASTE_VALUE) { + triggers = this.triggers.concat(deepClone(this._clipboard!.trigger)); + } else { + const platform = value as Trigger["platform"]; + const elClass = customElements.get( + `ha-automation-trigger-${platform}` + ) as CustomElementConstructor & { + defaultConfig: Omit; + }; + triggers = this.triggers.concat({ + platform: platform as any, + ...elClass.defaultConfig, + }); + } + this._focusLastTriggerOnChange = true; + fireEvent(this, "value-changed", { value: triggers }); + }; + protected updated(changedProps: PropertyValues) { super.updated(changedProps); @@ -261,30 +228,6 @@ export default class HaAutomationTrigger extends LitElement { return this._triggerKeys.get(action)!; } - private _addTrigger(ev: CustomEvent) { - const value = (ev.currentTarget as HaSelect).items[ev.detail.index].value; - - let triggers: Trigger[]; - if (value === PASTE_VALUE) { - triggers = this.triggers.concat(deepClone(this._clipboard!.trigger)); - } else { - const platform = value as Trigger["platform"]; - - const elClass = customElements.get( - `ha-automation-trigger-${platform}` - ) as CustomElementConstructor & { - defaultConfig: Omit; - }; - - triggers = this.triggers.concat({ - platform: platform as any, - ...elClass.defaultConfig, - }); - } - this._focusLastTriggerOnChange = true; - fireEvent(this, "value-changed", { value: triggers }); - } - private _moveUp(ev) { const index = (ev.target as any).index; const newIndex = index - 1; @@ -336,22 +279,6 @@ export default class HaAutomationTrigger extends LitElement { }); } - private _processedTypes = memoizeOne( - (localize: LocalizeFunc): [string, string, string][] => - (Object.entries(TRIGGER_TYPES) as Entries) - .map( - ([action, icon]) => - [ - action, - localize( - `ui.panel.config.automation.editor.triggers.type.${action}.label` - ), - icon, - ] as [string, string, string] - ) - .sort((a, b) => stringCompare(a[1], b[1], this.hass.locale.language)) - ); - static get styles(): CSSResultGroup { return [ sortableStyles, diff --git a/src/panels/config/integrations/ha-config-integration-page.ts b/src/panels/config/integrations/ha-config-integration-page.ts index 628f3635e8..6e2a2dd04b 100644 --- a/src/panels/config/integrations/ha-config-integration-page.ts +++ b/src/panels/config/integrations/ha-config-integration-page.ts @@ -487,7 +487,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {

    ${this._manifest?.integration_type ? this.hass.localize( - `ui.panel.config.integrations.integration_page.entries_${this._manifest?.integration_type}` + `ui.panel.config.integrations.integration_page.entries_${this._manifest.integration_type}` ) : this.hass.localize( `ui.panel.config.integrations.integration_page.entries` @@ -507,7 +507,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { ${this._manifest?.integration_type ? this.hass.localize( - `ui.panel.config.integrations.integration_page.add_${this._manifest?.integration_type}` + `ui.panel.config.integrations.integration_page.add_${this._manifest.integration_type}` ) : this.hass.localize( `ui.panel.config.integrations.integration_page.add_entry` diff --git a/src/translations/en.json b/src/translations/en.json index 8606439e43..3bbffcec76 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2441,6 +2441,7 @@ "edit_yaml": "Edit in YAML", "edit_ui": "Edit in visual editor", "copy_to_clipboard": "Copy to clipboard", + "search_in": "Search ยท {group}", "triggers": { "name": "Triggers", "header": "When", @@ -2448,6 +2449,7 @@ "learn_more": "Learn more about triggers", "triggered": "Triggered", "add": "Add trigger", + "search": "Search trigger", "id": "Trigger ID", "edit_id": "Edit ID", "duplicate": "[%key:ui::common::duplicate%]", @@ -2464,6 +2466,17 @@ "unsupported_platform": "No visual editor support for platform: {platform}", "type_select": "Trigger type", "unknown_trigger": "[%key:ui::panel::config::devices::automation::triggers::unknown_trigger%]", + "groups": { + "entity": { + "label": "Entity", + "description": "When something happens to an entity" + }, + "time_location": { + "label": "Time and location", + "description": "When someone enters or leaves a zone, or at a specific time." + }, + "other": { "label": "Other" } + }, "type": { "calendar": { "label": "Calendar", @@ -2482,6 +2495,9 @@ "below": "Below", "for": "Duration (optional)", "zone": "[%key:ui::panel::config::automation::editor::triggers::type::zone::label%]" + }, + "description": { + "picker": "When something happens to a device. Great way to start." } }, "event": { @@ -2643,6 +2659,8 @@ "description": "This list of conditions needs to be satisfied for the automation to run. A condition can be satisfied or not at any given time, for example: ''If {user} is home''. You can use building blocks to create more complex conditions.", "learn_more": "Learn more about conditions", "add": "Add condition", + "search": "Search condition", + "add_building_block": "Add building block", "test": "Test", "testing_error": "Condition did not pass", "testing_pass": "Condition passes", @@ -2662,6 +2680,21 @@ "unsupported_condition": "No visual editor support for condition: {condition}", "type_select": "Condition type", "unknown_condition": "[%key:ui::panel::config::devices::automation::conditions::unknown_condition%]", + "groups": { + "entity": { + "label": "Entity", + "description": "If an entity is in a specific state" + }, + "time_location": { + "label": "Time and location", + "description": "If someone is in a zone or if the current time is before or after a specified time" + }, + "other": { "label": "Other" }, + "building_blocks": { + "label": "Building blocks", + "description": "Build more complex conditions" + } + }, "type": { "and": { "label": "And", @@ -2679,6 +2712,9 @@ "for": "Duration", "hvac_mode": "HVAC mode", "preset_mode": "Preset mode" + }, + "description": { + "picker": "If something happens to a device. Great way to start." } }, "not": { @@ -2781,6 +2817,8 @@ "description": "This list of actions will be performed in sequence when the automation runs. An action usually controls one of your areas, devices, or entities, for example: 'Turn on the lights'. You can use building blocks to create more complex sequences of actions.", "learn_more": "Learn more about actions", "add": "Add action", + "search": "Search action", + "add_building_block": "Add building block", "invalid_action": "Invalid action", "run": "Run", "run_action_error": "Error running action", @@ -2802,6 +2840,14 @@ "unsupported_action": "No visual editor support for this action", "type_select": "Action type", "continue_on_error": "Continue on error", + "groups": { + "helpers": { "label": "Helpers" }, + "other": { "label": "Other" }, + "building_blocks": { + "label": "Building blocks", + "description": "Build more complex sequences of actions" + } + }, "type": { "service": { "label": "Call service", @@ -2821,6 +2867,7 @@ "play_media": { "label": "Play media", "description": { + "picker": "Play media on a media player", "full": "Play {hasMedia, select, \n true {{media}} \n other {media}\n } on {hasMediaPlayer, select, \n true {{mediaPlayer}} \n other {a media player}\n }" } }, @@ -3703,6 +3750,8 @@ "entries_service": "Services", "entries_helper": "Helpers", "entries_hardware": "Hardware", + "entries_system": "[%key:ui::panel::config::integrations::integration_page::entries%]", + "entries_entity": "[%key:ui::panel::config::integrations::integration_page::entries%]", "no_entries": "No entries", "attention_entries": "Needs attention", "add_entry": "Add entry", @@ -3710,7 +3759,9 @@ "add_hub": "Add hub", "add_service": "Add service", "add_helper": "Add helper", - "add_hardware": "Add hardware" + "add_hardware": "Add hardware", + "add_entity": "[%key:ui::panel::config::integrations::integration_page::add_entry%]", + "add_system": "[%key:ui::panel::config::integrations::integration_page::add_entry%]" }, "config_entry": { "application_credentials": {