import { mdiAppleKeyboardCommand, mdiClose, mdiContentPaste, mdiPlus, } from "@mdi/js"; import Fuse from "fuse.js"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { ifDefined } from "lit/directives/if-defined"; import { repeat } from "lit/directives/repeat"; import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../../common/dom/fire_event"; import { computeDomain } from "../../../common/entity/compute_domain"; import { stringCompare } from "../../../common/string/compare"; import type { LocalizeFunc } from "../../../common/translations/localize"; import { deepEqual } from "../../../common/util/deep-equal"; import "../../../components/ha-dialog"; import type { HaDialog } from "../../../components/ha-dialog"; import "../../../components/ha-dialog-header"; import "../../../components/ha-domain-icon"; import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button-prev"; import "../../../components/ha-icon-next"; import "../../../components/ha-md-divider"; import "../../../components/ha-md-list"; import "../../../components/ha-md-list-item"; import "../../../components/ha-service-icon"; import "../../../components/search-input"; import { ACTION_GROUPS, ACTION_ICONS, SERVICE_PREFIX, getService, isService, } from "../../../data/action"; import type { AutomationElementGroup } from "../../../data/automation"; import { CONDITION_GROUPS, CONDITION_ICONS } from "../../../data/condition"; import { getServiceIcons } from "../../../data/icons"; import type { IntegrationManifest } from "../../../data/integration"; import { domainToName, fetchIntegrationManifests, } from "../../../data/integration"; import { TRIGGER_GROUPS, TRIGGER_ICONS } from "../../../data/trigger"; import type { HassDialog } from "../../../dialogs/make-dialog-manager"; import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin"; import { HaFuse } from "../../../resources/fuse"; import { haStyle, haStyleDialog } from "../../../resources/styles"; import type { HomeAssistant } from "../../../types"; import { isMac } from "../../../util/is_mac"; import { showToast } from "../../../util/toast"; import type { AddAutomationElementDialogParams } from "./show-add-automation-element-dialog"; import { 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; iconPath?: string; icon?: TemplateResult; group: boolean; } type DomainManifestLookup = Record; const ENTITY_DOMAINS_OTHER = new Set([ "date", "datetime", "device_tracker", "text", "time", "tts", "update", "weather", "image_processing", ]); const ENTITY_DOMAINS_MAIN = new Set(["notify"]); @customElement("add-automation-element-dialog") class DialogAddAutomationElement extends KeyboardShortcutMixin(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; @state() private _domains?: Set; @query("ha-dialog") private _dialog?: HaDialog; private _fullScreen = false; @state() private _width?: number; @state() private _height?: number; @state() private _narrow = false; public showDialog(params): void { this._params = params; this._group = params.group; this.addKeyboardShortcuts(); if (this._params?.type === "action") { this.hass.loadBackendTranslation("services"); this._fetchManifests(); this._calculateUsedDomains(); getServiceIcons(this.hass); } this._fullScreen = matchMedia( "all and (max-width: 450px), all and (max-height: 500px)" ).matches; this._narrow = matchMedia("(max-width: 870px)").matches; } public closeDialog() { this.removeKeyboardShortcuts(); if (this._params) { fireEvent(this, "dialog-closed", { dialog: this.localName }); } this._height = undefined; this._width = undefined; this._params = undefined; this._group = undefined; this._prev = undefined; this._filter = ""; this._manifests = undefined; this._domains = undefined; return true; } private _getGroups = ( type: AddAutomationElementDialogParams["type"], group: string | undefined ): AutomationElementGroup => group ? isService(group) ? {} : TYPES[type].groups[group].members! : TYPES[type].groups; 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"}` ), iconPath: options.icon || TYPES[type].icons[key], }); private _getFilteredItems = memoizeOne( ( type: AddAutomationElementDialogParams["type"], group: string | undefined, filter: string, domains: Set | undefined, localize: LocalizeFunc, services: HomeAssistant["services"], manifests?: DomainManifestLookup ): ListItem[] => { const items = this._items(type, group, localize, services, manifests); const index = this._fuseIndex(items); const fuse = new HaFuse( items, { ignoreLocation: true, includeScore: true }, index ); const results = fuse.multiTermsSearch(filter); if (results) { return results.map((result) => result.item); } return this._getGroupItems( type, group, domains, localize, services, manifests ); } ); private _items = memoizeOne( ( type: AddAutomationElementDialogParams["type"], group: string | undefined, localize: LocalizeFunc, services: HomeAssistant["services"], manifests?: DomainManifestLookup ): ListItem[] => { const groups = this._getGroups(type, group); 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)); } return items; } ); private _fuseIndex = memoizeOne((items: ListItem[]) => Fuse.createIndex(["key", "name", "description"], items) ); private _getGroupItems = memoizeOne( ( type: AddAutomationElementDialogParams["type"], group: string | undefined, domains: Set | undefined, localize: LocalizeFunc, services: HomeAssistant["services"], manifests?: DomainManifestLookup ): ListItem[] => { if (type === "action" && isService(group)) { return this._services(localize, services, manifests, group); } const groups = this._getGroups(type, group); 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, domains, undefined ) ); } else if (this._group === "helpers") { result.unshift( ...this._serviceGroups( localize, services, manifests, domains, "helper" ) ); } else if (this._group === "other") { result.unshift( ...this._serviceGroups( localize, services, manifests, domains, "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 = ( localize: LocalizeFunc, services: HomeAssistant["services"], manifests: DomainManifestLookup | undefined, domains: Set | undefined, type: "helper" | "other" | undefined ): ListItem[] => { if (!services || !manifests) { return []; } const result: ListItem[] = []; Object.keys(services).forEach((domain) => { const manifest = manifests[domain]; const domainUsed = !domains ? true : domains.has(domain); if ( (type === undefined && (ENTITY_DOMAINS_MAIN.has(domain) || (manifest?.integration_type === "entity" && domainUsed && !ENTITY_DOMAINS_OTHER.has(domain)))) || (type === "helper" && manifest?.integration_type === "helper") || (type === "other" && !ENTITY_DOMAINS_MAIN.has(domain) && (ENTITY_DOMAINS_OTHER.has(domain) || (!domainUsed && manifest?.integration_type === "entity") || !["helper", "entity"].includes(manifest?.integration_type || ""))) ) { result.push({ group: true, icon: html` `, key: `${SERVICE_PREFIX}${domain}`, name: domainToName(localize, domain, manifest), description: "", }); } }); return result.sort((a, b) => stringCompare(a.name, b.name, this.hass.locale.language) ); }; 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: html` `, 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.${dmn}.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; } private _calculateUsedDomains() { const domains = new Set(Object.keys(this.hass.states).map(computeDomain)); if (!deepEqual(domains, this._domains)) { this._domains = domains; } } protected _opened(): void { // Store the width and height so that when we search, box doesn't jump const boundingRect = this.shadowRoot!.querySelector("ha-md-list")?.getBoundingClientRect(); this._width = boundingRect?.width; this._height = boundingRect?.height; } protected willUpdate(changedProperties: PropertyValues): void { if ( this._params?.type === "action" && changedProperties.has("hass") && changedProperties.get("hass")?.states !== this.hass.states ) { this._calculateUsedDomains(); } } protected render() { if (!this._params) { return nothing; } const items = this._filter ? this._getFilteredItems( this._params.type, this._group, this._filter, this._domains, this.hass.localize, this.hass.services, this._manifests ) : this._getGroupItems( this._params.type, this._group, this._domains, 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` )}
${!this._narrow ? html` ${isMac ? html`` : this.hass.localize( "ui.panel.config.automation.editor.ctrl" )} + V ` : nothing}
` : nothing} ${repeat( items, (item) => item.key, (item) => html`
${item.name}
${item.description}
${item.icon ? html`${item.icon}` : item.iconPath ? html`` : nothing} ${item.group ? html`` : html``}
` )}
`; } private _back() { this._dialog!.scrollToPos(0, 0); if (this._filter) { this._filter = ""; return; } if (this._prev) { this._group = this._prev; this._prev = undefined; return; } this._group = undefined; } private _selected(ev) { 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; } private _addClipboard = () => { if (this._params?.clipboardItem) { this._params!.add(PASTE_VALUE); showToast(this, { message: this.hass.localize( "ui.panel.config.automation.editor.item_pasted", { item: this.hass.localize( // @ts-ignore `ui.panel.config.automation.editor.${this._params.type}s.type.${this._params.clipboardItem}.label` ), } ), }); this.closeDialog(); } }; protected supportedShortcuts(): SupportedShortcuts { return { v: () => this._addClipboard(), }; } static get styles(): CSSResultGroup { return [ haStyle, haStyleDialog, css` ha-dialog { --dialog-content-padding: 0; --mdc-dialog-max-height: 60vh; --mdc-dialog-max-height: 60dvh; } @media all and (min-width: 550px) { ha-dialog { --mdc-dialog-min-width: 500px; } } ha-icon-next { width: 24px; } ha-md-list { max-height: 468px; max-width: 100vw; --md-list-item-leading-space: 24px; --md-list-item-trailing-space: 24px; --md-list-item-supporting-text-font: var(--ha-font-size-s); } ha-md-list-item img { width: 24px; } search-input { display: block; margin: 0 16px; } .shortcut-label { display: flex; gap: var(--ha-space-3); justify-content: space-between; } .shortcut-label .supporting-text { color: var(--secondary-text-color); font-size: var(--ha-font-size-s); } .shortcut-label .shortcut { --mdc-icon-size: 12px; display: inline-flex; flex-direction: row; align-items: center; gap: 2px; } .shortcut-label .shortcut span { font-size: var(--ha-font-size-s); font-family: var(--ha-font-family-code); color: var(--ha-color-text-secondary); } `, ]; } } declare global { interface HTMLElementTagNameMap { "add-automation-element-dialog": DialogAddAutomationElement; } }