import { ResizeController } from "@lit-labs/observers/resize-controller"; import { consume } from "@lit/context"; import { mdiChevronRight, mdiCog, mdiContentDuplicate, mdiDelete, mdiDotsVertical, mdiHelpCircle, mdiInformationOutline, mdiMenuDown, mdiOpenInNew, mdiPlay, mdiPlus, mdiScriptText, mdiTag, mdiTextureBox, mdiTransitConnection, } from "@mdi/js"; import { differenceInDays } from "date-fns"; import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import { LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; import { computeCssColor } from "../../../common/color/compute-color"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { formatShortDateTimeWithConditionalYear } from "../../../common/datetime/format_date_time"; import { relativeTime } from "../../../common/datetime/relative_time"; import { storage } from "../../../common/decorators/storage"; import type { HASSDomEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event"; import { computeStateName } from "../../../common/entity/compute_state_name"; import { navigate } from "../../../common/navigate"; import type { LocalizeFunc } from "../../../common/translations/localize"; import { hasRejectedItems, rejectedItems, } from "../../../common/util/promise-all-settled-results"; import type { DataTableColumnContainer, RowClickedEvent, SelectionChangedEvent, SortingChangedEvent, } from "../../../components/data-table/ha-data-table"; import "../../../components/data-table/ha-data-table-labels"; import "../../../components/ha-fab"; import "../../../components/ha-filter-blueprints"; import "../../../components/ha-filter-categories"; import "../../../components/ha-filter-devices"; import "../../../components/ha-filter-entities"; import "../../../components/ha-filter-floor-areas"; import "../../../components/ha-filter-labels"; import "../../../components/ha-icon-button"; import "../../../components/ha-icon-overflow-menu"; import "../../../components/ha-md-divider"; import "../../../components/ha-md-menu"; import "../../../components/ha-md-menu-item"; import "../../../components/ha-sub-menu"; import "../../../components/ha-svg-icon"; import { createAreaRegistryEntry } from "../../../data/area_registry"; import type { CategoryRegistryEntry } from "../../../data/category_registry"; import { createCategoryRegistryEntry, subscribeCategoryRegistry, } from "../../../data/category_registry"; import { fullEntitiesContext } from "../../../data/context"; import type { DataTableFilters } from "../../../data/data_table_filters"; import { deserializeFilters, serializeFilters, } from "../../../data/data_table_filters"; import { UNAVAILABLE } from "../../../data/entity"; import type { EntityRegistryEntry, UpdateEntityRegistryEntryResult, } from "../../../data/entity_registry"; import { updateEntityRegistryEntry } from "../../../data/entity_registry"; import type { LabelRegistryEntry } from "../../../data/label_registry"; import { createLabelRegistryEntry, subscribeLabelRegistry, } from "../../../data/label_registry"; import type { ScriptEntity } from "../../../data/script"; import { deleteScript, fetchScriptFileConfig, getScriptStateConfig, hasScriptFields, showScriptEditor, triggerScript, } from "../../../data/script"; import { findRelated } from "../../../data/search"; import { showAlertDialog, showConfirmationDialog, } from "../../../dialogs/generic/show-dialog-box"; import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog"; import "../../../layouts/hass-tabs-subpage-data-table"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { haStyle } from "../../../resources/styles"; import type { HomeAssistant, Route } from "../../../types"; import { documentationUrl } from "../../../util/documentation-url"; import { showToast } from "../../../util/toast"; import { showAreaRegistryDetailDialog } from "../areas/show-dialog-area-registry-detail"; import { showNewAutomationDialog } from "../automation/show-dialog-new-automation"; import { showAssignCategoryDialog } from "../category/show-dialog-assign-category"; import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail"; import { configSections } from "../ha-panel-config"; import { showLabelDetailDialog } from "../labels/show-dialog-label-detail"; type ScriptItem = ScriptEntity & { name: string; area: string | undefined; category: string | undefined; labels: LabelRegistryEntry[]; }; @customElement("ha-script-picker") class HaScriptPicker extends SubscribeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public scripts!: ScriptEntity[]; @property({ attribute: "is-wide", type: Boolean }) public isWide = false; @property({ type: Boolean }) public narrow = false; @property({ attribute: false }) public route!: Route; @property({ attribute: false }) public entityRegistry!: EntityRegistryEntry[]; @state() private _searchParms = new URLSearchParams(window.location.search); @state() private _selected: string[] = []; @state() private _activeFilters?: string[]; @state() private _filteredScripts?: string[] | null; @state() @storage({ storage: "sessionStorage", key: "script-table-search", state: true, subscribe: false, }) private _filter = ""; @state() @storage({ storage: "sessionStorage", key: "script-table-filters-full", state: true, subscribe: false, serializer: serializeFilters, deserializer: deserializeFilters, }) private _filters: DataTableFilters = {}; @state() private _expandedFilter?: string; @state() _categories!: CategoryRegistryEntry[]; @state() _labels!: LabelRegistryEntry[]; @state() @consume({ context: fullEntitiesContext, subscribe: true }) _entityReg!: EntityRegistryEntry[]; @storage({ key: "script-table-sort", state: false, subscribe: false }) private _activeSorting?: SortingChangedEvent; @storage({ key: "script-table-grouping", state: false, subscribe: false }) private _activeGrouping?: string; @storage({ key: "script-table-collapsed", state: false, subscribe: false, }) private _activeCollapsed?: string; @storage({ key: "script-table-column-order", state: false, subscribe: false, }) private _activeColumnOrder?: string[]; @storage({ key: "script-table-hidden-columns", state: false, subscribe: false, }) private _activeHiddenColumns?: string[]; private _sizeController = new ResizeController(this, { callback: (entries) => entries[0]?.contentRect.width, }); private _scripts = memoizeOne( ( scripts: ScriptEntity[], entityReg: EntityRegistryEntry[], areas: HomeAssistant["areas"], categoryReg?: CategoryRegistryEntry[], labelReg?: LabelRegistryEntry[], filteredScripts?: string[] | null ): ScriptItem[] => { if (filteredScripts === null) { return []; } return ( filteredScripts ? scripts.filter((script) => filteredScripts!.includes(script.entity_id) ) : scripts ).map((script) => { const entityRegEntry = entityReg.find( (reg) => reg.entity_id === script.entity_id ); const category = entityRegEntry?.categories.script; const labels = labelReg && entityRegEntry?.labels; return { ...script, name: computeStateName(script), area: entityRegEntry?.area_id ? areas[entityRegEntry?.area_id]?.name : undefined, last_triggered: script.attributes.last_triggered || undefined, category: category ? categoryReg?.find((cat) => cat.category_id === category)?.name : undefined, labels: (labels || []).map( (lbl) => labelReg!.find((label) => label.label_id === lbl)! ), selectable: entityRegEntry !== undefined, }; }); } ); private _columns = memoizeOne( (localize: LocalizeFunc): DataTableColumnContainer => { const columns: DataTableColumnContainer = { icon: { title: "", showNarrow: true, moveable: false, label: localize("ui.panel.config.script.picker.headers.icon"), type: "icon", template: (script) => html``, }, name: { title: localize("ui.panel.config.script.picker.headers.name"), main: true, sortable: true, filterable: true, direction: "asc", flex: 2, extraTemplate: (script) => script.labels.length ? html`` : nothing, }, area: { title: localize("ui.panel.config.script.picker.headers.area"), defaultHidden: true, groupable: true, filterable: true, sortable: true, }, category: { title: localize("ui.panel.config.script.picker.headers.category"), defaultHidden: true, groupable: true, filterable: true, sortable: true, }, labels: { title: "", hidden: true, filterable: true, template: (script) => script.labels.map((lbl) => lbl.name).join(" "), }, last_triggered: { sortable: true, title: localize("ui.card.automation.last_triggered"), template: (script) => { const date = new Date(script.last_triggered); const now = new Date(); const dayDifference = differenceInDays(now, date); return html` ${script.last_triggered ? dayDifference > 3 ? formatShortDateTimeWithConditionalYear( date, this.hass.locale, this.hass.config ) : relativeTime(date, this.hass.locale) : this.hass.localize("ui.components.relative_time.never")} `; }, }, actions: { title: "", label: this.hass.localize("ui.panel.config.generic.headers.actions"), type: "overflow-menu", showNarrow: true, moveable: false, hideable: false, template: (script) => html` this._showInfo(script), }, { path: mdiCog, label: this.hass.localize( "ui.panel.config.automation.picker.show_settings" ), action: () => this._openSettings(script), }, { path: mdiTag, label: this.hass.localize( `ui.panel.config.script.picker.${script.category ? "edit_category" : "assign_category"}` ), action: () => this._editCategory(script), }, { path: mdiPlay, label: this.hass.localize( "ui.panel.config.script.picker.run" ), action: () => this._runScript(script), }, { path: mdiTransitConnection, label: this.hass.localize( "ui.panel.config.script.picker.show_trace" ), action: () => this._showTrace(script), }, { divider: true, }, { path: mdiContentDuplicate, label: this.hass.localize( "ui.panel.config.script.picker.duplicate" ), action: () => this._duplicate(script), }, { label: this.hass.localize( "ui.panel.config.script.picker.delete" ), path: mdiDelete, action: () => this._deleteConfirm(script), warning: true, }, ]} > `, }, }; return columns; } ); protected hassSubscribe(): (UnsubscribeFunc | Promise)[] { return [ subscribeCategoryRegistry( this.hass.connection, "script", (categories) => { this._categories = categories; } ), subscribeLabelRegistry(this.hass.connection, (labels) => { this._labels = labels; }), ]; } protected render(): TemplateResult { const categoryItems = html`${this._categories?.map( (category) => html` ${category.icon ? html`` : html``}
${category.name}
` )}
${this.hass.localize( "ui.panel.config.automation.picker.bulk_actions.no_category" )}
${this.hass.localize("ui.panel.config.category.editor.add")}
`; const labelItems = html`${this._labels?.map((label) => { const color = label.color ? computeCssColor(label.color) : undefined; const selected = this._selected.every((entityId) => this.hass.entities[entityId]?.labels.includes(label.label_id) ); const partial = !selected && this._selected.some((entityId) => this.hass.entities[entityId]?.labels.includes(label.label_id) ); return html` ${label.icon ? html`` : nothing} ${label.name} `; })}
${this.hass.localize("ui.panel.config.labels.add_label")}
`; const areaItems = html`${Object.values(this.hass.areas).map( (area) => html` ${area.icon ? html`` : html``}
${area.name}
` )}
${this.hass.localize( "ui.panel.config.devices.picker.bulk_actions.no_area" )}
${this.hass.localize( "ui.panel.config.devices.picker.bulk_actions.add_area" )}
`; const areasInOverflow = (this._sizeController.value && this._sizeController.value < 900) || (!this._sizeController.value && this.hass.dockedSidebar === "docked"); const labelsInOverflow = areasInOverflow && (!this._sizeController.value || this._sizeController.value < 700); const scripts = this._scripts( this.scripts, this._entityReg, this.hass.areas, this._categories, this._labels, this._filteredScripts ); return html` Array.isArray(filter.value) ? filter.value.length : filter.value && Object.values(filter.value).some((val) => Array.isArray(val) ? val.length : val ) ).length} .columns=${this._columns(this.hass.localize)} .data=${scripts} .empty=${!this.scripts.length} .activeFilters=${this._activeFilters} id="entity_id" .noDataText=${this.hass.localize( "ui.panel.config.script.picker.no_scripts" )} @clear-filter=${this._clearFilter} .filter=${this._filter} @search-changed=${this._handleSearchChange} has-fab clickable class=${this.narrow ? "narrow" : ""} @row-click=${this._handleRowClicked} > ${!this.narrow ? html` ${categoryItems} ${labelsInOverflow ? nothing : html` ${labelItems} `} ${areasInOverflow ? nothing : html` ${areaItems} `}` : nothing} ${this.narrow || areasInOverflow ? html` ${ this.narrow ? html` ` : html`` } ${ this.narrow ? html`
${this.hass.localize( "ui.panel.config.automation.picker.bulk_actions.move_category" )}
${categoryItems}
` : nothing } ${ this.narrow || labelsInOverflow ? html`
${this.hass.localize( "ui.panel.config.automation.picker.bulk_actions.add_label" )}
${labelItems}
` : nothing } ${ this.narrow || areasInOverflow ? html`
${this.hass.localize( "ui.panel.config.devices.picker.bulk_actions.move_area" )}
${areaItems}
` : nothing }
` : nothing} ${!this.scripts.length ? html`

${this.hass.localize( "ui.panel.config.script.picker.empty_header" )}

${this.hass.localize( "ui.panel.config.script.picker.empty_text" )}

${this.hass.localize("ui.panel.config.common.learn_more")}
` : nothing}
`; } private _handleSearchChange(ev: CustomEvent) { this._filter = ev.detail.value; } private _filterExpanded(ev) { if (ev.detail.expanded) { this._expandedFilter = ev.target.localName; } else if (this._expandedFilter === ev.target.localName) { this._expandedFilter = undefined; } } private _labelClicked = (ev: CustomEvent) => { const label = ev.detail.label; this._filters = { ...this._filters, "ha-filter-labels": { value: [label.label_id], items: undefined, }, }; this._applyFilters(); }; private _filterChanged(ev) { const type = ev.target.localName; this._filters = { ...this._filters, [type]: ev.detail }; this._applyFilters(); } private _clearFilter() { this._filters = {}; this._applyFilters(); } private _applyFilters() { const filters = Object.entries(this._filters); let items: Set | undefined; for (const [key, filter] of filters) { if (filter.items) { if (!items) { items = filter.items; continue; } items = "intersection" in items ? // @ts-ignore items.intersection(filter.items) : new Set([...items].filter((x) => filter.items!.has(x))); } if ( key === "ha-filter-categories" && Array.isArray(filter.value) && filter.value.length ) { const categoryItems = new Set(); this.scripts .filter( (script) => filter.value![0] === this._entityReg.find((reg) => reg.entity_id === script.entity_id) ?.categories.script ) .forEach((script) => categoryItems.add(script.entity_id)); if (!items) { items = categoryItems; continue; } items = "intersection" in items ? // @ts-ignore items.intersection(categoryItems) : new Set([...items].filter((x) => categoryItems!.has(x))); } if ( key === "ha-filter-labels" && Array.isArray(filter.value) && filter.value.length ) { const labelItems = new Set(); this.scripts .filter((script) => this._entityReg .find((reg) => reg.entity_id === script.entity_id) ?.labels.some((lbl) => (filter.value as string[]).includes(lbl)) ) .forEach((script) => labelItems.add(script.entity_id)); if (!items) { items = labelItems; continue; } items = "intersection" in items ? // @ts-ignore items.intersection(labelItems) : new Set([...items].filter((x) => labelItems!.has(x))); } } this._filteredScripts = items ? [...items] : undefined; } protected updated(changedProps: PropertyValues) { super.updated(changedProps); if (changedProps.has("_entityReg")) { this._applyFilters(); } } firstUpdated() { if (this._searchParms.has("blueprint")) { this._filterBlueprint(); } if (this._searchParms.has("label")) { this._filterLabel(); } } private _filterLabel() { const label = this._searchParms.get("label"); if (!label) { return; } this._filters = { ...this._filters, "ha-filter-labels": { value: [label], items: undefined, }, }; this._applyFilters(); } private async _filterBlueprint() { const blueprint = this._searchParms.get("blueprint"); if (!blueprint) { return; } const related = await findRelated(this.hass, "script_blueprint", blueprint); this._filters = { ...this._filters, "ha-filter-blueprints": { value: [blueprint], items: new Set(related.automation || []), }, }; this._applyFilters(); } private _editCategory(script: any) { const entityReg = this._entityReg.find( (reg) => reg.entity_id === script.entity_id ); if (!entityReg) { showAlertDialog(this, { title: this.hass.localize( "ui.panel.config.script.picker.no_category_support" ), text: this.hass.localize( "ui.panel.config.script.picker.no_category_entity_reg" ), }); return; } showAssignCategoryDialog(this, { scope: "script", entityReg, }); } private _handleSelectionChanged( ev: HASSDomEvent ): void { this._selected = ev.detail.value; } private _handleBulkCategory = (item) => { const category = item.value; this._bulkAddCategory(category); }; private async _bulkAddCategory(category: string) { const promises: Promise[] = []; this._selected.forEach((entityId) => { promises.push( updateEntityRegistryEntry(this.hass, entityId, { categories: { script: category }, }) ); }); const result = await Promise.allSettled(promises); if (hasRejectedItems(result)) { const rejected = rejectedItems(result); showAlertDialog(this, { title: this.hass.localize("ui.panel.config.common.multiselect.failed", { number: rejected.length, }), text: html`
${rejected
            .map((r) => r.reason.message || r.reason.code || r.reason)
            .join("\r\n")}
`, }); } } private async _handleBulkLabel(ev) { const label = ev.currentTarget.value; const action = ev.currentTarget.action; this._bulkLabel(label, action); } private async _bulkLabel(label: string, action: "add" | "remove") { const promises: Promise[] = []; this._selected.forEach((entityId) => { promises.push( updateEntityRegistryEntry(this.hass, entityId, { labels: action === "add" ? this.hass.entities[entityId].labels.concat(label) : this.hass.entities[entityId].labels.filter( (lbl) => lbl !== label ), }) ); }); const result = await Promise.allSettled(promises); if (hasRejectedItems(result)) { const rejected = rejectedItems(result); showAlertDialog(this, { title: this.hass.localize("ui.panel.config.common.multiselect.failed", { number: rejected.length, }), text: html`
${rejected
            .map((r) => r.reason.message || r.reason.code || r.reason)
            .join("\r\n")}
`, }); } } private _handleRowClicked(ev: HASSDomEvent) { const entry = this.entityRegistry.find((e) => e.entity_id === ev.detail.id); if (entry) { navigate(`/config/script/edit/${entry.unique_id}`); } else { navigate(`/config/script/show/${ev.detail.id}`); } } private _createNew() { if (isComponentLoaded(this.hass, "blueprint")) { showNewAutomationDialog(this, { mode: "script" }); } else { navigate("/config/script/edit/new"); } } private _runScript = async (script: any) => { const entry = this.entityRegistry.find( (e) => e.entity_id === script.entity_id ); if (!entry) { return; } if (hasScriptFields(this.hass, entry.unique_id)) { this._showInfo(script); } else { await triggerScript(this.hass, entry.unique_id); showToast(this, { message: this.hass.localize("ui.notification_toast.triggered", { name: computeStateName(script), }), }); } }; private _showInfo(script: any) { fireEvent(this, "hass-more-info", { entityId: script.entity_id }); } private _openSettings(script: any) { showMoreInfoDialog(this, { entityId: script.entity_id, view: "settings", }); } private _showTrace(script: any) { const entry = this.entityRegistry.find( (e) => e.entity_id === script.entity_id ); if (entry) { navigate(`/config/script/trace/${entry.unique_id}`); } } private _showHelp() { showAlertDialog(this, { title: this.hass.localize("ui.panel.config.script.caption"), text: html` ${this.hass.localize("ui.panel.config.script.picker.introduction")}

${this.hass.localize("ui.panel.config.script.picker.learn_more")}

`, }); } private async _duplicate(script: any) { try { const entry = this.entityRegistry.find( (e) => e.entity_id === script.entity_id ); if (!entry) { return; } const config = await fetchScriptFileConfig(this.hass, entry.unique_id); showScriptEditor({ ...config, alias: `${config?.alias} (${this.hass.localize( "ui.panel.config.script.picker.duplicate" )})`, }); } catch (err: any) { if (err.status_code === 404) { const response = await getScriptStateConfig( this.hass, script.entity_id ); showScriptEditor(response.config); return; } await showAlertDialog(this, { text: this.hass.localize( "ui.panel.config.script.editor.load_error_unknown", { err_no: err.status_code } ), }); } } private async _deleteConfirm(script: any) { showConfirmationDialog(this, { title: this.hass.localize( "ui.panel.config.script.editor.delete_confirm_title" ), text: this.hass.localize( "ui.panel.config.script.editor.delete_confirm_text", { name: script.name } ), confirmText: this.hass!.localize("ui.common.delete"), dismissText: this.hass!.localize("ui.common.cancel"), confirm: () => this._delete(script), destructive: true, }); } private async _delete(script: any) { try { const entry = this.entityRegistry.find( (e) => e.entity_id === script.entity_id ); if (entry) { await deleteScript(this.hass, entry.unique_id); } } catch (err: any) { await showAlertDialog(this, { text: err.status_code === 400 ? this.hass.localize( "ui.panel.config.script.editor.load_error_not_deletable" ) : this.hass.localize( "ui.panel.config.script.editor.load_error_unknown", { err_no: err.status_code } ), }); } } private _bulkCreateCategory = () => { showCategoryRegistryDetailDialog(this, { scope: "script", createEntry: async (values) => { const category = await createCategoryRegistryEntry( this.hass, "script", values ); this._bulkAddCategory(category.category_id); return category; }, }); }; private _bulkCreateLabel = () => { showLabelDetailDialog(this, { createEntry: async (values) => { const label = await createLabelRegistryEntry(this.hass, values); this._bulkLabel(label.label_id, "add"); }, }); }; private _handleBulkArea = (item) => { const area = item.value; this._bulkAddArea(area); }; private async _bulkAddArea(area: string) { const promises: Promise[] = []; this._selected.forEach((entityId) => { promises.push( updateEntityRegistryEntry(this.hass, entityId, { area_id: area, }) ); }); const result = await Promise.allSettled(promises); if (hasRejectedItems(result)) { const rejected = rejectedItems(result); showAlertDialog(this, { title: this.hass.localize("ui.panel.config.common.multiselect.failed", { number: rejected.length, }), text: html`
${rejected
            .map((r) => r.reason.message || r.reason.code || r.reason)
            .join("\r\n")}
`, }); } } private _bulkCreateArea = () => { showAreaRegistryDetailDialog(this, { createEntry: async (values) => { const area = await createAreaRegistryEntry(this.hass, values); this._bulkAddArea(area.area_id); return area; }, }); }; private _handleSortingChanged(ev: CustomEvent) { this._activeSorting = ev.detail; } private _handleGroupingChanged(ev: CustomEvent) { this._activeGrouping = ev.detail.value ?? ""; } private _handleCollapseChanged(ev: CustomEvent) { this._activeCollapsed = ev.detail.value; } private _handleColumnsChanged(ev: CustomEvent) { this._activeColumnOrder = ev.detail.columnOrder; this._activeHiddenColumns = ev.detail.hiddenColumns; } static get styles(): CSSResultGroup { return [ haStyle, css` :host { display: block; height: 100%; } hass-tabs-subpage-data-table { --data-table-row-height: 60px; } hass-tabs-subpage-data-table.narrow { --data-table-row-height: 72px; } a { text-decoration: none; } .empty { --mdc-icon-size: 80px; max-width: 500px; } .empty ha-button { --mdc-icon-size: 24px; } .empty h1 { font-size: var(--ha-font-size-3xl); } ha-assist-chip { --ha-assist-chip-container-shape: 10px; } ha-md-button-menu ha-assist-chip { --md-assist-chip-trailing-space: 8px; } ha-label { --ha-label-background-color: var(--color, var(--grey-color)); --ha-label-background-opacity: 0.5; } `, ]; } } declare global { interface HTMLElementTagNameMap { "ha-script-picker": HaScriptPicker; } }