From 73f5580555340302038f327e54822cce61477495 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 22 Mar 2022 12:47:12 -0700 Subject: [PATCH] Add support for integration type (#12077) --- src/common/entity/compute_state_domain.ts | 2 +- .../ha-selector/ha-selector-area.ts | 12 +- .../ha-selector/ha-selector-device.ts | 12 +- .../ha-selector/ha-selector-target.ts | 5 +- src/data/config_entries.ts | 20 +- src/data/config_flow.ts | 10 +- src/data/energy.ts | 8 +- src/data/helpers_crud.ts | 71 ++++ .../config-flow/show-dialog-config-flow.ts | 2 +- .../config-flow/step-flow-pick-handler.ts | 9 +- src/onboarding/onboarding-integrations.ts | 4 +- .../zwave_js/ha-device-info-zwave_js.ts | 7 +- .../components/ha-energy-grid-settings.ts | 54 ++- .../dialogs/dialog-energy-solar-settings.ts | 14 +- .../settings/entity-settings-helper-tab.ts | 93 +---- .../entities/entity-registry-settings.ts | 49 ++- src/panels/config/helpers/const.ts | 16 +- .../config/helpers/dialog-helper-detail.ts | 208 +++++++----- .../config/helpers/ha-config-helpers.ts | 317 ++++++++++++------ .../helpers/show-dialog-helper-detail.ts | 13 +- .../integrations/ha-config-integrations.ts | 38 ++- .../mqtt/mqtt-config-panel.ts | 4 +- .../zwave_js/zwave_js-config-dashboard.ts | 8 +- src/translations/en.json | 3 +- 24 files changed, 602 insertions(+), 377 deletions(-) create mode 100644 src/data/helpers_crud.ts diff --git a/src/common/entity/compute_state_domain.ts b/src/common/entity/compute_state_domain.ts index b4408257a6..1b972ea22f 100644 --- a/src/common/entity/compute_state_domain.ts +++ b/src/common/entity/compute_state_domain.ts @@ -1,4 +1,4 @@ -import { HassEntity } from "home-assistant-js-websocket"; +import type { HassEntity } from "home-assistant-js-websocket"; import { computeDomain } from "./compute_domain"; export const computeStateDomain = (stateObj: HassEntity) => diff --git a/src/components/ha-selector/ha-selector-area.ts b/src/components/ha-selector/ha-selector-area.ts index 5f307dd013..704c79282a 100644 --- a/src/components/ha-selector/ha-selector-area.ts +++ b/src/components/ha-selector/ha-selector-area.ts @@ -28,7 +28,11 @@ export class HaAreaSelector extends LitElement { oldSelector !== this.selector && this.selector.area.device?.integration ) { - this._loadConfigEntries(); + getConfigEntries(this.hass, { + domain: this.selector.area.device.integration, + }).then((entries) => { + this._configEntries = entries; + }); } } } @@ -85,12 +89,6 @@ export class HaAreaSelector extends LitElement { } return true; }; - - private async _loadConfigEntries() { - this._configEntries = (await getConfigEntries(this.hass)).filter( - (entry) => entry.domain === this.selector.area.device?.integration - ); - } } declare global { diff --git a/src/components/ha-selector/ha-selector-device.ts b/src/components/ha-selector/ha-selector-device.ts index 2b0af5ace1..945c0de795 100644 --- a/src/components/ha-selector/ha-selector-device.ts +++ b/src/components/ha-selector/ha-selector-device.ts @@ -25,7 +25,11 @@ export class HaDeviceSelector extends LitElement { if (changedProperties.has("selector")) { const oldSelector = changedProperties.get("selector"); if (oldSelector !== this.selector && this.selector.device?.integration) { - this._loadConfigEntries(); + getConfigEntries(this.hass, { + domain: this.selector.device.integration, + }).then((entries) => { + this._configEntries = entries; + }); } } } @@ -88,12 +92,6 @@ export class HaDeviceSelector extends LitElement { } return true; }; - - private async _loadConfigEntries() { - this._configEntries = (await getConfigEntries(this.hass)).filter( - (entry) => entry.domain === this.selector.device.integration - ); - } } declare global { diff --git a/src/components/ha-selector/ha-selector-target.ts b/src/components/ha-selector/ha-selector-target.ts index 4d81be67df..cf25317cce 100644 --- a/src/components/ha-selector/ha-selector-target.ts +++ b/src/components/ha-selector/ha-selector-target.ts @@ -134,9 +134,8 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) { private async _loadConfigEntries() { this._configEntries = (await getConfigEntries(this.hass)).filter( (entry) => - entry.domain === - (this.selector.target.device?.integration || - this.selector.target.entity?.integration) + entry.domain === this.selector.target.device?.integration || + entry.domain === this.selector.target.entity?.integration ); } diff --git a/src/data/config_entries.ts b/src/data/config_entries.ts index f2e84ddac0..b9e555998c 100644 --- a/src/data/config_entries.ts +++ b/src/data/config_entries.ts @@ -34,8 +34,24 @@ export const ERROR_STATES: ConfigEntry["state"][] = [ "setup_retry", ]; -export const getConfigEntries = (hass: HomeAssistant) => - hass.callApi("GET", "config/config_entries/entry"); +export const getConfigEntries = ( + hass: HomeAssistant, + filters?: { type?: "helper" | "integration"; domain?: string } +): Promise => { + const params = new URLSearchParams(); + if (filters) { + if (filters.type) { + params.append("type", filters.type); + } + if (filters.domain) { + params.append("domain", filters.domain); + } + } + return hass.callApi( + "GET", + `config/config_entries/entry?${params.toString()}` + ); +}; export const updateConfigEntry = ( hass: HomeAssistant, diff --git a/src/data/config_flow.ts b/src/data/config_flow.ts index 39019393c5..c236a1b05c 100644 --- a/src/data/config_flow.ts +++ b/src/data/config_flow.ts @@ -65,8 +65,14 @@ export const ignoreConfigFlow = ( export const deleteConfigFlow = (hass: HomeAssistant, flowId: string) => hass.callApi("DELETE", `config/config_entries/flow/${flowId}`); -export const getConfigFlowHandlers = (hass: HomeAssistant) => - hass.callApi("GET", "config/config_entries/flow_handlers"); +export const getConfigFlowHandlers = ( + hass: HomeAssistant, + type?: "helper" | "integration" +) => + hass.callApi( + "GET", + `config/config_entries/flow_handlers${type ? `?type=${type}` : ""}` + ); export const fetchConfigFlowInProgress = ( conn: Connection diff --git a/src/data/energy.ts b/src/data/energy.ts index 7fd941b325..d9f4f1c0c2 100644 --- a/src/data/energy.ts +++ b/src/data/energy.ts @@ -247,14 +247,14 @@ const getEnergyData = async ( end?: Date ): Promise => { const [configEntries, entityRegistryEntries, info] = await Promise.all([ - getConfigEntries(hass), + getConfigEntries(hass, { domain: "co2signal" }), subscribeOne(hass.connection, subscribeEntityRegistry), getEnergyInfo(hass), ]); - const co2SignalConfigEntry = configEntries.find( - (entry) => entry.domain === "co2signal" - ); + const co2SignalConfigEntry = configEntries.length + ? configEntries[0] + : undefined; let co2SignalEntity: string | undefined; diff --git a/src/data/helpers_crud.ts b/src/data/helpers_crud.ts new file mode 100644 index 0000000000..c03ce664ac --- /dev/null +++ b/src/data/helpers_crud.ts @@ -0,0 +1,71 @@ +import { fetchCounter, updateCounter, deleteCounter } from "./counter"; +import { + fetchInputBoolean, + updateInputBoolean, + deleteInputBoolean, +} from "./input_boolean"; +import { + fetchInputButton, + updateInputButton, + deleteInputButton, +} from "./input_button"; +import { + fetchInputDateTime, + updateInputDateTime, + deleteInputDateTime, +} from "./input_datetime"; +import { + fetchInputNumber, + updateInputNumber, + deleteInputNumber, +} from "./input_number"; +import { + fetchInputSelect, + updateInputSelect, + deleteInputSelect, +} from "./input_select"; +import { fetchInputText, updateInputText, deleteInputText } from "./input_text"; +import { fetchTimer, updateTimer, deleteTimer } from "./timer"; + +export const HELPERS_CRUD = { + input_boolean: { + fetch: fetchInputBoolean, + update: updateInputBoolean, + delete: deleteInputBoolean, + }, + input_button: { + fetch: fetchInputButton, + update: updateInputButton, + delete: deleteInputButton, + }, + input_text: { + fetch: fetchInputText, + update: updateInputText, + delete: deleteInputText, + }, + input_number: { + fetch: fetchInputNumber, + update: updateInputNumber, + delete: deleteInputNumber, + }, + input_datetime: { + fetch: fetchInputDateTime, + update: updateInputDateTime, + delete: deleteInputDateTime, + }, + input_select: { + fetch: fetchInputSelect, + update: updateInputSelect, + delete: deleteInputSelect, + }, + counter: { + fetch: fetchCounter, + update: updateCounter, + delete: deleteCounter, + }, + timer: { + fetch: fetchTimer, + update: updateTimer, + delete: deleteTimer, + }, +}; diff --git a/src/dialogs/config-flow/show-dialog-config-flow.ts b/src/dialogs/config-flow/show-dialog-config-flow.ts index 42337b40ff..5b7c0722b7 100644 --- a/src/dialogs/config-flow/show-dialog-config-flow.ts +++ b/src/dialogs/config-flow/show-dialog-config-flow.ts @@ -24,7 +24,7 @@ export const showConfigFlowDialog = ( loadDevicesAndAreas: true, getFlowHandlers: async (hass) => { const [handlers] = await Promise.all([ - getConfigFlowHandlers(hass), + getConfigFlowHandlers(hass, "integration"), hass.loadBackendTranslation("title", undefined, true), ]); diff --git a/src/dialogs/config-flow/step-flow-pick-handler.ts b/src/dialogs/config-flow/step-flow-pick-handler.ts index 9f2a7e6740..0beb4ac26f 100644 --- a/src/dialogs/config-flow/step-flow-pick-handler.ts +++ b/src/dialogs/config-flow/step-flow-pick-handler.ts @@ -216,15 +216,16 @@ class StepFlowPickHandler extends LitElement { if (handler.is_add) { if (handler.slug === "zwave_js") { - const entries = await getConfigEntries(this.hass); - const entry = entries.find((ent) => ent.domain === "zwave_js"); + const entries = await getConfigEntries(this.hass, { + domain: "zwave_js", + }); - if (!entry) { + if (!entries.length) { return; } showZWaveJSAddNodeDialog(this, { - entry_id: entry.entry_id, + entry_id: entries[0].entry_id, }); } else if (handler.slug === "zha") { navigate("/config/zha/add"); diff --git a/src/onboarding/onboarding-integrations.ts b/src/onboarding/onboarding-integrations.ts index 6ac3504e5e..9fc15b0c07 100644 --- a/src/onboarding/onboarding-integrations.ts +++ b/src/onboarding/onboarding-integrations.ts @@ -169,8 +169,8 @@ class OnboardingIntegrations extends LitElement { } private async _loadConfigEntries() { - const entries = await getConfigEntries(this.hass!); - // We filter out the config entry for the local weather and rpi_power. + const entries = await getConfigEntries(this.hass!, { type: "integration" }); + // We filter out the config entries that are automatically created during onboarding. // It is one that we create automatically and it will confuse the user // if it starts showing up during onboarding. this._entries = entries.filter( diff --git a/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts b/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts index 99f48c8989..78d56a0b1b 100644 --- a/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts +++ b/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-info-zwave_js.ts @@ -58,12 +58,11 @@ export class HaDeviceInfoZWaveJS extends LitElement { return; } - const configEntries = await getConfigEntries(this.hass); + const configEntries = await getConfigEntries(this.hass, { + domain: "zwave_js", + }); let zwaveJsConfEntries = 0; for (const entry of configEntries) { - if (entry.domain !== "zwave_js") { - continue; - } if (zwaveJsConfEntries) { this._multipleConfigEntries = true; } diff --git a/src/panels/config/energy/components/ha-energy-grid-settings.ts b/src/panels/config/energy/components/ha-energy-grid-settings.ts index 700329dd1c..46ac49d885 100644 --- a/src/panels/config/energy/components/ha-energy-grid-settings.ts +++ b/src/panels/config/energy/components/ha-energy-grid-settings.ts @@ -54,7 +54,7 @@ export class EnergyGridSettings extends LitElement { @property({ attribute: false }) public validationResult?: EnergyPreferencesValidation; - @state() private _configEntries?: ConfigEntry[]; + @state() private _co2ConfigEntry?: ConfigEntry; protected firstUpdated() { this._fetchCO2SignalConfigEntries(); @@ -195,28 +195,28 @@ export class EnergyGridSettings extends LitElement { "ui.panel.config.energy.grid.grid_carbon_footprint" )} - ${this._configEntries?.map( - (entry) => html`
- - ${entry.title} - - - - -
` - )} - ${this._configEntries?.length === 0 - ? html` + ${this._co2ConfigEntry + ? html`
+ + ${this._co2ConfigEntry.title} + + + + +
` + : html`
- ` - : ""} + `} `; } private async _fetchCO2SignalConfigEntries() { - this._configEntries = (await getConfigEntries(this.hass)).filter( - (entry) => entry.domain === "co2signal" - ); + const entries = await getConfigEntries(this.hass, { domain: "co2signal" }); + this._co2ConfigEntry = entries.length ? entries[0] : undefined; } private _addCO2Sensor() { diff --git a/src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts b/src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts index 1f2acaf624..8ec83a64b8 100644 --- a/src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts +++ b/src/panels/config/energy/dialogs/dialog-energy-solar-settings.ts @@ -176,9 +176,17 @@ export class DialogEnergySolarSettings private async _fetchSolarForecastConfigEntries() { const domains = this._params!.info.solar_forecast_domains; - this._configEntries = (await getConfigEntries(this.hass)).filter((entry) => - domains.includes(entry.domain) - ); + this._configEntries = + domains.length === 0 + ? [] + : domains.length === 1 + ? await getConfigEntries(this.hass, { + type: "integration", + domain: domains[0], + }) + : (await getConfigEntries(this.hass, { type: "integration" })).filter( + (entry) => domains.includes(entry.domain) + ); } private _handleForecastChanged(ev: CustomEvent) { diff --git a/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts b/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts index 913bcd8353..210d931a6c 100644 --- a/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts +++ b/src/panels/config/entities/editor-tabs/settings/entity-settings-helper-tab.ts @@ -10,50 +10,11 @@ import { customElement, property, state, query } from "lit/decorators"; import { isComponentLoaded } from "../../../../../common/config/is_component_loaded"; import { dynamicElement } from "../../../../../common/dom/dynamic-element-directive"; import { fireEvent } from "../../../../../common/dom/fire_event"; -import { - deleteCounter, - fetchCounter, - updateCounter, -} from "../../../../../data/counter"; import { ExtEntityRegistryEntry, removeEntityRegistryEntry, } from "../../../../../data/entity_registry"; -import { - deleteInputBoolean, - fetchInputBoolean, - updateInputBoolean, -} from "../../../../../data/input_boolean"; -import { - deleteInputButton, - fetchInputButton, - updateInputButton, -} from "../../../../../data/input_button"; -import { - deleteInputDateTime, - fetchInputDateTime, - updateInputDateTime, -} from "../../../../../data/input_datetime"; -import { - deleteInputNumber, - fetchInputNumber, - updateInputNumber, -} from "../../../../../data/input_number"; -import { - deleteInputSelect, - fetchInputSelect, - updateInputSelect, -} from "../../../../../data/input_select"; -import { - deleteInputText, - fetchInputText, - updateInputText, -} from "../../../../../data/input_text"; -import { - deleteTimer, - fetchTimer, - updateTimer, -} from "../../../../../data/timer"; +import { HELPERS_CRUD } from "../../../../../data/helpers_crud"; import { showConfirmationDialog } from "../../../../../dialogs/generic/show-dialog-box"; import { haStyle } from "../../../../../resources/styles"; import type { HomeAssistant } from "../../../../../types"; @@ -69,49 +30,6 @@ import "../../../helpers/forms/ha-timer-form"; import "../../entity-registry-basic-editor"; import type { HaEntityRegistryBasicEditor } from "../../entity-registry-basic-editor"; -const HELPERS = { - input_boolean: { - fetch: fetchInputBoolean, - update: updateInputBoolean, - delete: deleteInputBoolean, - }, - input_button: { - fetch: fetchInputButton, - update: updateInputButton, - delete: deleteInputButton, - }, - input_text: { - fetch: fetchInputText, - update: updateInputText, - delete: deleteInputText, - }, - input_number: { - fetch: fetchInputNumber, - update: updateInputNumber, - delete: deleteInputNumber, - }, - input_datetime: { - fetch: fetchInputDateTime, - update: updateInputDateTime, - delete: deleteInputDateTime, - }, - input_select: { - fetch: fetchInputSelect, - update: updateInputSelect, - delete: deleteInputSelect, - }, - counter: { - fetch: fetchCounter, - update: updateCounter, - delete: deleteCounter, - }, - timer: { - fetch: fetchTimer, - update: updateTimer, - delete: deleteTimer, - }, -}; - @customElement("entity-settings-helper-tab") export class EntityRegistrySettingsHelper extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -198,7 +116,7 @@ export class EntityRegistrySettingsHelper extends LitElement { } private async _getItem() { - const items = await HELPERS[this.entry.platform].fetch(this.hass!); + const items = await HELPERS_CRUD[this.entry.platform].fetch(this.hass!); this._item = items.find((item) => item.id === this.entry.unique_id) || null; } @@ -206,7 +124,7 @@ export class EntityRegistrySettingsHelper extends LitElement { this._submitting = true; try { if (this._componentLoaded && this._item) { - await HELPERS[this.entry.platform].update( + await HELPERS_CRUD[this.entry.platform].update( this.hass!, this._item.id, this._item @@ -236,7 +154,10 @@ export class EntityRegistrySettingsHelper extends LitElement { try { if (this._componentLoaded && this._item) { - await HELPERS[this.entry.platform].delete(this.hass!, this._item.id); + await HELPERS_CRUD[this.entry.platform].delete( + this.hass!, + this._item.id + ); } else { const stateObj = this.hass.states[this.entry.entity_id]; if (!stateObj?.attributes.restored) { diff --git a/src/panels/config/entities/entity-registry-settings.ts b/src/panels/config/entities/entity-registry-settings.ts index c4278a7936..34d099db0d 100644 --- a/src/panels/config/entities/entity-registry-settings.ts +++ b/src/panels/config/entities/entity-registry-settings.ts @@ -42,6 +42,12 @@ import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { haStyle } from "../../../resources/styles"; import type { HomeAssistant } from "../../../types"; import { showDeviceRegistryDetailDialog } from "../devices/device-registry-detail/show-dialog-device-registry-detail"; +import { + ConfigEntry, + deleteConfigEntry, + getConfigEntries, +} from "../../../data/config_entries"; +import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow"; const OVERRIDE_DEVICE_CLASSES = { cover: [ @@ -83,6 +89,8 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { @state() private _device?: DeviceRegistryEntry; + @state() private _helperConfigEntry?: ConfigEntry; + @state() private _error?: string; @state() private _submitting?: boolean; @@ -103,6 +111,20 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { ]; } + protected firstUpdated(changedProps: PropertyValues): void { + super.firstUpdated(changedProps); + if (this.entry.config_entry_id) { + getConfigEntries(this.hass, { + type: "helper", + domain: this.entry.platform, + }).then((entries) => { + this._helperConfigEntry = entries.find( + (ent) => ent.entry_id === this.entry.config_entry_id + ); + }); + } + } + protected updated(changedProperties: PropertyValues) { super.updated(changedProperties); if (changedProperties.has("entry")) { @@ -215,6 +237,21 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { @value-changed=${this._areaPicked} >` : ""} + ${this._helperConfigEntry + ? html` +
+ + ${this.hass.localize( + "ui.dialogs.entity_registry.editor.configure_state" + )} + +
+ ` + : ""} + ${this.hass.localize("ui.dialogs.entity_registry.editor.delete")} @@ -471,13 +508,21 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { this._submitting = true; try { - await removeEntityRegistryEntry(this.hass!, this._origEntityId); + if (this._helperConfigEntry) { + await deleteConfigEntry(this.hass, this._helperConfigEntry.entry_id); + } else { + await removeEntityRegistryEntry(this.hass!, this._origEntityId); + } fireEvent(this, "close-dialog"); } finally { this._submitting = false; } } + private async _showOptionsFlow() { + showOptionsFlowDialog(this, this._helperConfigEntry!); + } + static get styles(): CSSResultGroup { return [ haStyle, diff --git a/src/panels/config/helpers/const.ts b/src/panels/config/helpers/const.ts index 2e927f66ad..c103332573 100644 --- a/src/panels/config/helpers/const.ts +++ b/src/panels/config/helpers/const.ts @@ -1,11 +1,11 @@ -import { Counter } from "../../../data/counter"; -import { InputBoolean } from "../../../data/input_boolean"; -import { InputButton } from "../../../data/input_button"; -import { InputDateTime } from "../../../data/input_datetime"; -import { InputNumber } from "../../../data/input_number"; -import { InputSelect } from "../../../data/input_select"; -import { InputText } from "../../../data/input_text"; -import { Timer } from "../../../data/timer"; +import type { Counter } from "../../../data/counter"; +import type { InputBoolean } from "../../../data/input_boolean"; +import type { InputButton } from "../../../data/input_button"; +import type { InputDateTime } from "../../../data/input_datetime"; +import type { InputNumber } from "../../../data/input_number"; +import type { InputSelect } from "../../../data/input_select"; +import type { InputText } from "../../../data/input_text"; +import type { Timer } from "../../../data/timer"; export const HELPER_DOMAINS = [ "input_boolean", diff --git a/src/panels/config/helpers/dialog-helper-detail.ts b/src/panels/config/helpers/dialog-helper-detail.ts index bbd0b645e1..c3adf6a826 100644 --- a/src/panels/config/helpers/dialog-helper-detail.ts +++ b/src/panels/config/helpers/dialog-helper-detail.ts @@ -8,6 +8,8 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { dynamicElement } from "../../../common/dom/dynamic-element-directive"; import { domainIcon } from "../../../common/entity/domain_icon"; import "../../../components/ha-dialog"; +import "../../../components/ha-circular-progress"; +import { getConfigFlowHandlers } from "../../../data/config_flow"; import { createCounter } from "../../../data/counter"; import { createInputBoolean } from "../../../data/input_boolean"; import { createInputButton } from "../../../data/input_button"; @@ -16,6 +18,7 @@ import { createInputNumber } from "../../../data/input_number"; import { createInputSelect } from "../../../data/input_select"; import { createInputText } from "../../../data/input_text"; import { createTimer } from "../../../data/timer"; +import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow"; import { haStyleDialog } from "../../../resources/styles"; import { HomeAssistant } from "../../../types"; import { Helper } from "./const"; @@ -27,6 +30,8 @@ import "./forms/ha-input_number-form"; import "./forms/ha-input_select-form"; import "./forms/ha-input_text-form"; import "./forms/ha-timer-form"; +import { domainToName } from "../../../data/integration"; +import type { ShowDialogHelperDetailParams } from "./show-dialog-helper-detail"; const HELPERS = { input_boolean: createInputBoolean, @@ -47,7 +52,7 @@ export class DialogHelperDetail extends LitElement { @state() private _opened = false; - @state() private _platform?: string; + @state() private _domain?: string; @state() private _error?: string; @@ -55,102 +60,135 @@ export class DialogHelperDetail extends LitElement { @query(".form") private _form?: HTMLDivElement; - public async showDialog(): Promise { - this._platform = undefined; + @state() private _helperFlows?: string[]; + + private _params?: ShowDialogHelperDetailParams; + + public async showDialog(params: ShowDialogHelperDetailParams): Promise { + this._params = params; + this._domain = undefined; this._item = undefined; this._opened = true; await this.updateComplete; + Promise.all([ + getConfigFlowHandlers(this.hass, "helper"), + // Ensure the titles are loaded before we render the flows. + this.hass.loadBackendTranslation("title", undefined, true), + ]).then(([flows]) => { + this._helperFlows = flows; + }); } public closeDialog(): void { this._opened = false; this._error = ""; + this._params = undefined; } protected render(): TemplateResult { + let content: TemplateResult; + + if (this._domain) { + content = html` +
+ ${this._error ? html`
${this._error}
` : ""} + ${dynamicElement(`ha-${this._domain}-form`, { + hass: this.hass, + item: this._item, + new: true, + })} +
+ + ${this.hass!.localize("ui.panel.config.helpers.dialog.create")} + + + ${this.hass!.localize("ui.common.back")} + + `; + } else if (this._helperFlows === undefined) { + content = html``; + } else { + const items: [string, string][] = []; + + for (const helper of Object.keys(HELPERS)) { + items.push([ + helper, + this.hass.localize(`ui.panel.config.helpers.types.${helper}`) || + helper, + ]); + } + + for (const domain of this._helperFlows) { + items.push([domain, domainToName(this.hass.localize, domain)]); + } + + items.sort((a, b) => a[1].localeCompare(b[1])); + + content = html` + ${items.map(([domain, label]) => { + // Only OG helpers need to be loaded prior adding one + const isLoaded = + !(domain in HELPERS) || isComponentLoaded(this.hass, domain); + return html` + + + ${label} + + ${!isLoaded + ? html` + ${this.hass.localize( + "ui.dialogs.helper_settings.platform_not_loaded", + "platform", + domain + )} + ` + : ""} + `; + })} + + ${this.hass!.localize("ui.common.cancel")} + + `; + } + return html` - ${this._platform - ? html` -
- ${this._error - ? html`
${this._error}
` - : ""} - ${dynamicElement(`ha-${this._platform}-form`, { - hass: this.hass, - item: this._item, - new: true, - })} -
- - ${this.hass!.localize("ui.panel.config.helpers.dialog.create")} - - - ${this.hass!.localize("ui.common.back")} - - ` - : html` - ${Object.keys(HELPERS).map((platform: string) => { - const isLoaded = isComponentLoaded(this.hass, platform); - return html` - - - - ${this.hass.localize( - `ui.panel.config.helpers.types.${platform}` - ) || platform} - - - ${!isLoaded - ? html` - ${this.hass.localize( - "ui.dialogs.helper_settings.platform_not_loaded", - "platform", - platform - )} - ` - : ""} - `; - })} - - ${this.hass!.localize("ui.common.cancel")} - - `} + ${content}
`; } @@ -160,13 +198,13 @@ export class DialogHelperDetail extends LitElement { } private async _createItem(): Promise { - if (!this._platform || !this._item) { + if (!this._domain || !this._item) { return; } this._submitting = true; this._error = ""; try { - await HELPERS[this._platform](this.hass, this._item); + await HELPERS[this._domain](this.hass, this._item); this.closeDialog(); } catch (err: any) { this._error = err.message || "Unknown error"; @@ -181,12 +219,22 @@ export class DialogHelperDetail extends LitElement { } ev.stopPropagation(); ev.preventDefault(); - this._platformPicked(ev); + this._domainPicked(ev); } - private _platformPicked(ev: Event): void { - this._platform = (ev.currentTarget! as any).platform; - this._focusForm(); + private _domainPicked(ev: Event): void { + const domain = (ev.currentTarget! as any).domain; + + if (domain in HELPERS) { + this._domain = domain; + this._focusForm(); + } else { + showConfigFlowDialog(this, { + startFlowHandler: domain, + dialogClosedCallback: this._params!.dialogClosedCallback, + }); + this.closeDialog(); + } } private async _focusForm(): Promise { @@ -195,7 +243,7 @@ export class DialogHelperDetail extends LitElement { } private _goBack() { - this._platform = undefined; + this._domain = undefined; this._item = undefined; this._error = undefined; } diff --git a/src/panels/config/helpers/ha-config-helpers.ts b/src/panels/config/helpers/ha-config-helpers.ts index ac58f83e44..4ce9d00118 100644 --- a/src/panels/config/helpers/ha-config-helpers.ts +++ b/src/panels/config/helpers/ha-config-helpers.ts @@ -1,28 +1,58 @@ import { mdiPencilOff, mdiPlus } from "@mdi/js"; import "@polymer/paper-tooltip/paper-tooltip"; -import { HassEntity } from "home-assistant-js-websocket"; +import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; import { html, LitElement, PropertyValues, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; -import memoize from "memoize-one"; +import memoizeOne from "memoize-one"; import { computeStateDomain } from "../../../common/entity/compute_state_domain"; import { domainIcon } from "../../../common/entity/domain_icon"; +import { LocalizeFunc } from "../../../common/translations/localize"; import { DataTableColumnContainer, RowClickedEvent, } from "../../../components/data-table/ha-data-table"; import "../../../components/ha-fab"; +import "../../../components/ha-icon-overflow-menu"; import "../../../components/ha-icon"; import "../../../components/ha-svg-icon"; +import { ConfigEntry, getConfigEntries } from "../../../data/config_entries"; +import { + EntityRegistryEntry, + subscribeEntityRegistry, +} from "../../../data/entity_registry"; +import { domainToName } from "../../../data/integration"; import "../../../layouts/hass-loading-screen"; import "../../../layouts/hass-tabs-subpage-data-table"; +import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { HomeAssistant, Route } from "../../../types"; import { showEntityEditorDialog } from "../entities/show-dialog-entity-editor"; import { configSections } from "../ha-panel-config"; import { HELPER_DOMAINS } from "./const"; import { showHelperDetailDialog } from "./show-dialog-helper-detail"; +// This groups items by a key but only returns last entry per key. +const groupByOne = ( + items: T[], + keySelector: (item: T) => string +): Record => { + const result: Record = {}; + for (const item of items) { + result[keySelector(item)] = item; + } + return result; +}; + +const getConfigEntry = ( + entityEntries: Record, + configEntries: Record, + entityId: string +) => { + const configEntryId = entityEntries![entityId]?.config_entry_id; + return configEntryId ? configEntries![configEntryId] : undefined; +}; + @customElement("ha-config-helpers") -export class HaConfigHelpers extends LitElement { +export class HaConfigHelpers extends SubscribeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @property() public isWide!: boolean; @@ -33,98 +63,122 @@ export class HaConfigHelpers extends LitElement { @state() private _stateItems: HassEntity[] = []; - private _columns = memoize((narrow, _language): DataTableColumnContainer => { - const columns: DataTableColumnContainer = { - icon: { + @state() private _entityEntries?: Record; + + @state() private _configEntries?: Record; + + private _columns = memoizeOne( + (narrow: boolean, localize: LocalizeFunc): DataTableColumnContainer => { + const columns: DataTableColumnContainer = { + icon: { + title: "", + label: localize("ui.panel.config.helpers.picker.headers.icon"), + type: "icon", + template: (icon, helper: any) => + icon + ? html` ` + : html``, + }, + name: { + title: localize("ui.panel.config.helpers.picker.headers.name"), + sortable: true, + filterable: true, + grows: true, + direction: "asc", + template: (name, item: any) => + html` + ${name} + ${narrow + ? html`
${item.entity_id}
` + : ""} + `, + }, + }; + if (!narrow) { + columns.entity_id = { + title: localize("ui.panel.config.helpers.picker.headers.entity_id"), + sortable: true, + filterable: true, + width: "25%", + }; + } + columns.type = { + title: localize("ui.panel.config.helpers.picker.headers.type"), + sortable: true, + width: "25%", + filterable: true, + template: (type, row) => + row.configEntry + ? domainToName(localize, type) + : html` + ${localize(`ui.panel.config.helpers.types.${type}`) || type} + `, + }; + columns.editable = { title: "", label: this.hass.localize( - "ui.panel.config.helpers.picker.headers.icon" + "ui.panel.config.helpers.picker.headers.editable" ), type: "icon", - template: (icon, helper: any) => - icon - ? html` ` - : html``, - }, - name: { - title: this.hass.localize( - "ui.panel.config.helpers.picker.headers.name" - ), - sortable: true, - filterable: true, - grows: true, - direction: "asc", - template: (name, item: any) => - html` - ${name} - ${narrow - ? html`
${item.entity_id}
` - : ""} - `, - }, - }; - if (!narrow) { - columns.entity_id = { - title: this.hass.localize( - "ui.panel.config.helpers.picker.headers.entity_id" - ), - sortable: true, - filterable: true, - width: "25%", - }; - } - columns.type = { - title: this.hass.localize("ui.panel.config.helpers.picker.headers.type"), - sortable: true, - width: "25%", - filterable: true, - template: (type) => - html` - ${this.hass.localize(`ui.panel.config.helpers.types.${type}`) || type} + template: (editable) => html` + ${!editable + ? html` +
+ + + ${this.hass.localize( + "ui.panel.config.entities.picker.status.readonly" + )} + +
+ ` + : ""} `, - }; - columns.editable = { - title: "", - label: this.hass.localize( - "ui.panel.config.helpers.picker.headers.editable" - ), - type: "icon", - template: (editable) => html` - ${!editable - ? html` -
- - - ${this.hass.localize( - "ui.panel.config.entities.picker.status.readonly" - )} - -
- ` - : ""} - `, - }; - return columns; - }); + }; + return columns; + } + ); - private _getItems = memoize((stateItems: HassEntity[]) => - stateItems.map((entityState) => ({ - id: entityState.entity_id, - icon: entityState.attributes.icon, - name: entityState.attributes.friendly_name || "", - entity_id: entityState.entity_id, - editable: entityState.attributes.editable, - type: computeStateDomain(entityState), - })) + private _getItems = memoizeOne( + ( + stateItems: HassEntity[], + entityEntries: Record, + configEntries: Record + ) => + stateItems.map((entityState) => { + const configEntry = getConfigEntry( + entityEntries, + configEntries, + entityState.entity_id + ); + + return { + id: entityState.entity_id, + icon: entityState.attributes.icon, + name: entityState.attributes.friendly_name || "", + entity_id: entityState.entity_id, + editable: + configEntry !== undefined || entityState.attributes.editable, + type: configEntry + ? configEntry.domain + : computeStateDomain(entityState), + configEntry, + }; + }) ); protected render(): TemplateResult { - if (!this.hass || this._stateItems === undefined) { + if ( + !this.hass || + this._stateItems === undefined || + this._entityEntries === undefined || + this._configEntries === undefined + ) { return html` `; } @@ -135,8 +189,12 @@ export class HaConfigHelpers extends LitElement { back-path="/config" .route=${this.route} .tabs=${configSections.automations} - .columns=${this._columns(this.narrow, this.hass.language)} - .data=${this._getItems(this._stateItems)} + .columns=${this._columns(this.narrow, this.hass.localize)} + .data=${this._getItems( + this._stateItems, + this._entityEntries, + this._configEntries + )} @row-click=${this._openEditDialog} hasFab clickable @@ -160,32 +218,67 @@ export class HaConfigHelpers extends LitElement { protected firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); - this._getStates(); + this._getConfigEntries(); } - protected updated(changedProps: PropertyValues) { - super.updated(changedProps); - const oldHass = changedProps.get("hass") as HomeAssistant | undefined; - if (oldHass && this._stateItems) { - this._getStates(oldHass); + protected willUpdate(changedProps: PropertyValues) { + super.willUpdate(changedProps); + + if (!this._entityEntries || !this._configEntries) { + return; + } + + let changed = + !this._stateItems || + changedProps.has("_entityEntries") || + changedProps.has("_configEntries"); + + if (!changed && changedProps.has("hass")) { + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + changed = !oldHass || oldHass.states !== this.hass.states; + } + if (!changed) { + return; + } + + const extraEntities = new Set(); + + for (const entityEntry of Object.values(this._entityEntries)) { + if ( + entityEntry.config_entry_id && + entityEntry.config_entry_id in this._configEntries + ) { + extraEntities.add(entityEntry.entity_id); + } + } + + const newStates = Object.values(this.hass!.states).filter( + (entity) => + extraEntities.has(entity.entity_id) || + HELPER_DOMAINS.includes(computeStateDomain(entity)) + ); + + if ( + this._stateItems.length !== newStates.length || + !this._stateItems.every((val, idx) => newStates[idx] === val) + ) { + this._stateItems = newStates; } } - private _getStates(oldHass?: HomeAssistant) { - let changed = false; - const tempStates = Object.values(this.hass!.states).filter((entity) => { - if (!HELPER_DOMAINS.includes(computeStateDomain(entity))) { - return false; - } - if (oldHass?.states[entity.entity_id] !== entity) { - changed = true; - } - return true; - }); + public hassSubscribe(): UnsubscribeFunc[] { + return [ + subscribeEntityRegistry(this.hass.connection!, (entries) => { + this._entityEntries = groupByOne(entries, (entry) => entry.entity_id); + }), + ]; + } - if (changed || this._stateItems.length !== tempStates.length) { - this._stateItems = tempStates; - } + private async _getConfigEntries() { + this._configEntries = groupByOne( + await getConfigEntries(this.hass, { type: "helper" }), + (entry) => entry.entry_id + ); } private async _openEditDialog(ev: CustomEvent): Promise { @@ -196,6 +289,12 @@ export class HaConfigHelpers extends LitElement { } private _createHelpler() { - showHelperDetailDialog(this); + showHelperDetailDialog(this, { + dialogClosedCallback: (params) => { + if (params.flowFinished) { + this._getConfigEntries(); + } + }, + }); } } diff --git a/src/panels/config/helpers/show-dialog-helper-detail.ts b/src/panels/config/helpers/show-dialog-helper-detail.ts index 959f92ad75..83fbbce4ee 100644 --- a/src/panels/config/helpers/show-dialog-helper-detail.ts +++ b/src/panels/config/helpers/show-dialog-helper-detail.ts @@ -1,11 +1,20 @@ import { fireEvent } from "../../../common/dom/fire_event"; +import { DataEntryFlowDialogParams } from "../../../dialogs/config-flow/show-dialog-data-entry-flow"; export const loadHelperDetailDialog = () => import("./dialog-helper-detail"); -export const showHelperDetailDialog = (element: HTMLElement) => { +export interface ShowDialogHelperDetailParams { + // Only used for config entries + dialogClosedCallback: DataEntryFlowDialogParams["dialogClosedCallback"]; +} + +export const showHelperDetailDialog = ( + element: HTMLElement, + params: ShowDialogHelperDetailParams +) => { fireEvent(element, "show-dialog", { dialogTag: "dialog-helper-detail", dialogImport: loadHelperDetailDialog, - dialogParams: {}, + dialogParams: params, }); }; diff --git a/src/panels/config/integrations/ha-config-integrations.ts b/src/panels/config/integrations/ha-config-integrations.ts index 5255d1197e..c860953efc 100644 --- a/src/panels/config/integrations/ha-config-integrations.ts +++ b/src/panels/config/integrations/ha-config-integrations.ts @@ -521,24 +521,26 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { } private _loadConfigEntries() { - getConfigEntries(this.hass).then((configEntries) => { - this._configEntries = configEntries - .map( - (entry: ConfigEntry): ConfigEntryExtended => ({ - ...entry, - localized_domain_name: domainToName( - this.hass.localize, - entry.domain - ), - }) - ) - .sort((conf1, conf2) => - caseInsensitiveStringCompare( - conf1.localized_domain_name + conf1.title, - conf2.localized_domain_name + conf2.title + getConfigEntries(this.hass, { type: "integration" }).then( + (configEntries) => { + this._configEntries = configEntries + .map( + (entry: ConfigEntry): ConfigEntryExtended => ({ + ...entry, + localized_domain_name: domainToName( + this.hass.localize, + entry.domain + ), + }) ) - ); - }); + .sort((conf1, conf2) => + caseInsensitiveStringCompare( + conf1.localized_domain_name + conf1.title, + conf2.localized_domain_name + conf2.title + ) + ); + } + ); } private async _scanUSBDevices() { @@ -656,7 +658,7 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { if (!domain) { return; } - const handlers = await getConfigFlowHandlers(this.hass); + const handlers = await getConfigFlowHandlers(this.hass, "integration"); if (!handlers.includes(domain)) { showAlertDialog(this, { diff --git a/src/panels/config/integrations/integration-panels/mqtt/mqtt-config-panel.ts b/src/panels/config/integrations/integration-panels/mqtt/mqtt-config-panel.ts index 9337854c8e..eaa03dc98f 100644 --- a/src/panels/config/integrations/integration-panels/mqtt/mqtt-config-panel.ts +++ b/src/panels/config/integrations/integration-panels/mqtt/mqtt-config-panel.ts @@ -111,7 +111,9 @@ class HaPanelDevMqtt extends LitElement { return; } const configEntryId = searchParams.get("config_entry") as string; - const configEntries = await getConfigEntries(this.hass); + const configEntries = await getConfigEntries(this.hass, { + domain: "mqtt", + }); const configEntry = configEntries.find( (entry) => entry.entry_id === configEntryId ); diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts index 3c75b59df0..9b5a5c8e37 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts @@ -384,7 +384,9 @@ class ZWaveJSConfigDashboard extends LitElement { if (!this.configEntryId) { return; } - const configEntries = await getConfigEntries(this.hass); + const configEntries = await getConfigEntries(this.hass, { + domain: "zwave_js", + }); this._configEntry = configEntries.find( (entry) => entry.entry_id === this.configEntryId! ); @@ -467,7 +469,9 @@ class ZWaveJSConfigDashboard extends LitElement { if (!this.configEntryId) { return; } - const configEntries = await getConfigEntries(this.hass); + const configEntries = await getConfigEntries(this.hass, { + domain: "zwave_js", + }); const configEntry = configEntries.find( (entry) => entry.entry_id === this.configEntryId ); diff --git a/src/translations/en.json b/src/translations/en.json index bb75c9224e..8218353cf8 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -823,7 +823,8 @@ "area": "Set entity area only", "area_note": "By default the entities of a device are in the same area as the device. If you change the area of this entity, it will no longer follow the area of the device.", "follow_device_area": "Follow device area", - "change_device_area": "Change device area" + "change_device_area": "Change device area", + "configure_state": "Configure State" } }, "helper_settings": {