diff --git a/src/panels/config/entities/ha-config-entities.ts b/src/panels/config/entities/ha-config-entities.ts index 538b9af027..e1607485cb 100644 --- a/src/panels/config/entities/ha-config-entities.ts +++ b/src/panels/config/entities/ha-config-entities.ts @@ -1115,9 +1115,10 @@ ${ const domain = this._searchParms.get("domain"); const configEntry = this._searchParms.get("config_entry"); const subEntry = this._searchParms.get("sub_entry"); + const device = this._searchParms.get("device"); const label = this._searchParms.has("label"); - if (!domain && !configEntry && !label) { + if (!domain && !configEntry && !label && !device) { return; } @@ -1126,6 +1127,7 @@ ${ this._filters = { "ha-filter-states": [], "ha-filter-integrations": domain ? [domain] : [], + "ha-filter-devices": device ? [device] : [], config_entry: configEntry ? [configEntry] : [], sub_entry: subEntry ? [subEntry] : [], }; diff --git a/src/panels/config/integrations/dialog-pick-config-entry.ts b/src/panels/config/integrations/dialog-pick-config-entry.ts new file mode 100644 index 0000000000..218e180f42 --- /dev/null +++ b/src/panels/config/integrations/dialog-pick-config-entry.ts @@ -0,0 +1,98 @@ +import { mdiClose } from "@mdi/js"; +import { LitElement, css, html, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/ha-dialog-header"; +import "../../../components/ha-icon-button"; +import "../../../components/ha-md-dialog"; +import type { HaMdDialog } from "../../../components/ha-md-dialog"; +import "../../../components/ha-md-list"; +import "../../../components/ha-md-list-item"; +import { ERROR_STATES, RECOVERABLE_STATES } from "../../../data/config_entries"; +import type { HomeAssistant } from "../../../types"; +import type { PickConfigEntryDialogParams } from "./show-pick-config-entry-dialog"; + +@customElement("dialog-pick-config-entry") +export class DialogPickConfigEntry extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: PickConfigEntryDialogParams; + + @query("ha-md-dialog") private _dialog?: HaMdDialog; + + public showDialog(params: PickConfigEntryDialogParams): void { + this._params = params; + } + + private _dialogClosed(): void { + this._params = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + public closeDialog() { + this._dialog?.close(); + return true; + } + + protected render() { + if (!this._params) { + return nothing; + } + return html` + + + + ${this.hass.localize( + `component.${this._params.domain}.config_subentries.${this._params.subFlowType}.initiate_flow.user` + )} + + + ${this._params.configEntries.map( + (entry) => + html`${entry.title}` + )} + + + `; + } + + private _itemPicked(ev: Event) { + this._params?.configEntryPicked((ev.currentTarget as any).entry); + this.closeDialog(); + } + + static styles = css` + :host { + --dialog-content-padding: 0; + } + @media all and (min-width: 600px) { + ha-dialog { + --mdc-dialog-min-width: 400px; + } + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-pick-config-entry": DialogPickConfigEntry; + } +} diff --git a/src/panels/config/integrations/ha-config-entry-device-row.ts b/src/panels/config/integrations/ha-config-entry-device-row.ts new file mode 100644 index 0000000000..d122f3c67b --- /dev/null +++ b/src/panels/config/integrations/ha-config-entry-device-row.ts @@ -0,0 +1,223 @@ +import { + mdiCogOutline, + mdiDelete, + mdiDotsVertical, + mdiStopCircleOutline, +} from "@mdi/js"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; +import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name"; +import { getDeviceContext } from "../../../common/entity/context/get_device_context"; +import { navigate } from "../../../common/navigate"; +import type { ConfigEntry } from "../../../data/config_entries"; +import { + removeConfigEntryFromDevice, + updateDeviceRegistryEntry, + type DeviceRegistryEntry, +} from "../../../data/device_registry"; +import type { EntityRegistryEntry } from "../../../data/entity_registry"; +import { haStyle } from "../../../resources/styles"; +import type { HomeAssistant } from "../../../types"; +import { + showAlertDialog, + showConfirmationDialog, +} from "../../lovelace/custom-card-helpers"; +import { showDeviceRegistryDetailDialog } from "../devices/device-registry-detail/show-dialog-device-registry-detail"; +import "./ha-config-sub-entry-row"; + +@customElement("ha-config-entry-device-row") +class HaConfigEntryDeviceRow extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean, reflect: true }) public narrow = false; + + @property({ attribute: false }) public entry!: ConfigEntry; + + @property({ attribute: false }) public device!: DeviceRegistryEntry; + + @property({ attribute: false }) public entities!: EntityRegistryEntry[]; + + protected render() { + const device = this.device; + + const entities = this._getEntities(); + + const { area } = getDeviceContext(device, this.hass); + + const supportingText = [ + device.model || device.sw_version || device.manufacturer, + area ? area.name : undefined, + ].filter(Boolean); + + return html` +
${computeDeviceNameDisplay(device, this.hass)}
+ ${supportingText.join(" • ")} + ${supportingText.length && entities.length ? " • " : nothing} + ${ + entities.length + ? html`${this.hass.localize( + "ui.panel.config.integrations.config_entry.entities", + { count: entities.length } + )}` + : nothing + } + + + +
+ ${ + !this.narrow + ? html`` + : nothing + } + + + + ${ + this.narrow + ? html` + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.device.configure" + )} + ` + : nothing + } + + + + ${ + device.disabled_by && device.disabled_by !== "user" + ? this.hass.localize( + "ui.dialogs.device-registry-detail.enabled_cause", + { + type: this.hass.localize( + `ui.dialogs.device-registry-detail.type.${ + device.entry_type || "device" + }` + ), + cause: this.hass.localize( + `config_entry.disabled_by.${device.disabled_by}` + ), + } + ) + : device.disabled_by + ? this.hass.localize( + "ui.panel.config.integrations.config_entry.device.enable" + ) + : this.hass.localize( + "ui.panel.config.integrations.config_entry.device.disable" + ) + } + + + + ${ + this.entry.supports_remove_device + ? html` + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.device.delete" + )} + ` + : nothing + } + +
`; + } + + private _getEntities = (): EntityRegistryEntry[] => + this.entities?.filter((entity) => entity.device_id === this.device.id); + + private _handleConfigureDevice() { + showDeviceRegistryDetailDialog(this, { + device: this.device, + updateEntry: async (updates) => { + await updateDeviceRegistryEntry(this.hass, this.device.id, updates); + }, + }); + } + + private async _handleDisableDevice() { + await updateDeviceRegistryEntry(this.hass, this.device.id, { + disabled_by: this.device.disabled_by === "user" ? null : "user", + }); + } + + private async _handleDeleteDevice() { + const entry = this.entry; + const confirmed = await showConfirmationDialog(this, { + text: this.hass.localize("ui.panel.config.devices.confirm_delete"), + confirmText: this.hass.localize("ui.common.delete"), + dismissText: this.hass.localize("ui.common.cancel"), + destructive: true, + }); + + if (!confirmed) { + return; + } + + try { + await removeConfigEntryFromDevice( + this.hass!, + this.device.id, + entry.entry_id + ); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize("ui.panel.config.devices.error_delete"), + text: err.message, + }); + } + } + + private _handleNavigateToDevice() { + navigate(`/config/devices/device/${this.device.id}`); + } + + static styles = [ + haStyle, + css` + :host { + border-top: 1px solid var(--divider-color); + } + ha-md-list-item { + --md-list-item-leading-space: 56px; + } + .vertical-divider { + height: 100%; + width: 1px; + background: var(--divider-color); + } + a { + text-decoration: none; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-entry-device-row": HaConfigEntryDeviceRow; + } +} diff --git a/src/panels/config/integrations/ha-config-entry-row.ts b/src/panels/config/integrations/ha-config-entry-row.ts new file mode 100644 index 0000000000..2d1c80a3fe --- /dev/null +++ b/src/panels/config/integrations/ha-config-entry-row.ts @@ -0,0 +1,761 @@ +import { + mdiAlertCircle, + mdiChevronDown, + mdiChevronUp, + mdiCogOutline, + mdiDelete, + mdiDevices, + mdiDotsVertical, + mdiDownload, + mdiHandExtendedOutline, + mdiPlayCircleOutline, + mdiPlus, + mdiProgressHelper, + mdiReload, + mdiReloadAlert, + mdiRenameBox, + mdiShapeOutline, + mdiStopCircleOutline, + mdiWrench, +} from "@mdi/js"; +import type { PropertyValues, TemplateResult } from "lit"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import memoizeOne from "memoize-one"; +import { isDevVersion } from "../../../common/config/version"; +import { + deleteApplicationCredential, + fetchApplicationCredentialsConfigEntry, +} from "../../../data/application_credential"; +import { getSignedPath } from "../../../data/auth"; +import type { + ConfigEntry, + DisableConfigEntryResult, + SubEntry, +} from "../../../data/config_entries"; +import { + deleteConfigEntry, + disableConfigEntry, + enableConfigEntry, + ERROR_STATES, + getSubEntries, + RECOVERABLE_STATES, + reloadConfigEntry, + updateConfigEntry, +} from "../../../data/config_entries"; +import type { DeviceRegistryEntry } from "../../../data/device_registry"; +import type { DiagnosticInfo } from "../../../data/diagnostics"; +import { getConfigEntryDiagnosticsDownloadUrl } from "../../../data/diagnostics"; +import type { EntityRegistryEntry } from "../../../data/entity_registry"; +import type { IntegrationManifest } from "../../../data/integration"; +import { + domainToName, + fetchIntegrationManifest, + integrationsWithPanel, +} from "../../../data/integration"; +import { showConfigEntrySystemOptionsDialog } from "../../../dialogs/config-entry-system-options/show-dialog-config-entry-system-options"; +import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow"; +import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow"; +import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow"; +import type { HomeAssistant } from "../../../types"; +import { documentationUrl } from "../../../util/documentation-url"; +import { fileDownload } from "../../../util/file_download"; +import { + showAlertDialog, + showConfirmationDialog, + showPromptDialog, +} from "../../lovelace/custom-card-helpers"; +import "./ha-config-entry-device-row"; +import { renderConfigEntryError } from "./ha-config-integration-page"; +import "./ha-config-sub-entry-row"; +import { haStyle } from "../../../resources/styles"; + +@customElement("ha-config-entry-row") +class HaConfigEntryRow extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean, reflect: true }) public narrow = false; + + @property({ attribute: false }) public manifest?: IntegrationManifest; + + @property({ attribute: false }) public diagnosticHandler?: DiagnosticInfo; + + @property({ attribute: false }) public entities!: EntityRegistryEntry[]; + + @property({ attribute: false }) public entry!: ConfigEntry; + + @state() private _expanded = true; + + @state() private _devicesExpanded = true; + + @state() private _subEntries?: SubEntry[]; + + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("entry")) { + this._fetchSubEntries(); + } + } + + protected render() { + const item = this.entry; + + let stateText: Parameters | undefined; + let stateTextExtra: TemplateResult | string | undefined; + let icon: string = mdiAlertCircle; + + if (!item.disabled_by && item.state === "not_loaded") { + stateText = ["ui.panel.config.integrations.config_entry.not_loaded"]; + } else if (item.state === "setup_in_progress") { + icon = mdiProgressHelper; + stateText = [ + "ui.panel.config.integrations.config_entry.setup_in_progress", + ]; + } else if (ERROR_STATES.includes(item.state)) { + if (item.state === "setup_retry") { + icon = mdiReloadAlert; + } + stateText = [ + `ui.panel.config.integrations.config_entry.state.${item.state}`, + ]; + stateTextExtra = renderConfigEntryError(this.hass, item); + } + + const devices = this._getDevices(); + const services = this._getServices(); + const entities = this._getEntities(); + + const ownDevices = [...devices, ...services].filter( + (device) => + !device.config_entries_subentries[item.entry_id].length || + device.config_entries_subentries[item.entry_id][0] === null + ); + + const statusLine: (TemplateResult | string)[] = []; + + if (item.disabled_by) { + statusLine.push( + this.hass.localize( + "ui.panel.config.integrations.config_entry.disable.disabled_cause", + { + cause: + this.hass.localize( + `ui.panel.config.integrations.config_entry.disable.disabled_by.${item.disabled_by}` + ) || item.disabled_by, + } + ) + ); + if (item.state === "failed_unload") { + statusLine.push(`. + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.disable_restart_confirm" + )}.`); + } + } else if (!devices.length && !services.length && entities.length) { + statusLine.push( + html`${entities.length} entities` + ); + } + + const configPanel = this._configPanel(item.domain, this.hass.panels); + + const subEntries = this._subEntries || []; + + return html` + 0, + })} + > + ${subEntries.length || ownDevices.length + ? html`` + : nothing} +
+ ${item.title || domainToName(this.hass.localize, item.domain)} +
+
+
${statusLine}
+ ${stateText + ? html` +
+ +
+ ${this.hass.localize(...stateText)}${stateTextExtra + ? html`: ${stateTextExtra}` + : nothing} +
+
+ ` + : nothing} +
+ ${item.disabled_by === "user" + ? html` + ${this.hass.localize("ui.common.enable")} + ` + : configPanel && + (item.domain !== "matter" || + isDevVersion(this.hass.config.version)) && + !stateText + ? html` + ` + : item.supports_options + ? html` + + + ` + : nothing} + + + ${devices.length + ? html` + + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.devices`, + { count: devices.length } + )} + + + ` + : nothing} + ${services.length + ? html` + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.services`, + { count: services.length } + )} + + ` + : nothing} + ${entities.length + ? html` + + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.entities`, + { count: entities.length } + )} + + + ` + : nothing} + ${!item.disabled_by && + RECOVERABLE_STATES.includes(item.state) && + item.supports_unload && + item.source !== "system" + ? html` + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.reload" + )} + + ` + : nothing} + + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.rename" + )} + + + ${Object.keys(item.supported_subentry_types).map( + (flowType) => + html` + + ${this.hass.localize( + `component.${item.domain}.config_subentries.${flowType}.initiate_flow.user` + )}` + )} + + + + ${this.diagnosticHandler && item.state === "loaded" + ? html` + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.download_diagnostics" + )} + + ` + : nothing} + ${!item.disabled_by && + item.supports_reconfigure && + item.source !== "system" + ? html` + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.reconfigure" + )} + + ` + : nothing} + + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.system_options" + )} + + ${item.disabled_by === "user" + ? html` + + + ${this.hass.localize("ui.common.enable")} + + ` + : item.source !== "system" + ? html` + + + ${this.hass.localize("ui.common.disable")} + + ` + : nothing} + ${item.source !== "system" + ? html` + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.delete" + )} + + ` + : nothing} + +
+ ${this._expanded + ? subEntries.length + ? html` + + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.devices_without_subentry" + )} + + ${this._devicesExpanded + ? ownDevices.map( + (device) => + html`` + ) + : nothing} + + ${subEntries.map( + (subEntry) => html` + + ` + )}` + : html` + ${ownDevices.map( + (device) => + html`` + )} + ` + : nothing} +
`; + } + + private async _fetchSubEntries() { + this._subEntries = this.entry.num_subentries + ? await getSubEntries(this.hass, this.entry.entry_id) + : undefined; + } + + private _configPanel = memoizeOne( + (domain: string, panels: HomeAssistant["panels"]): string | undefined => + Object.values(panels).find( + (panel) => panel.config_panel_domain === domain + )?.url_path || integrationsWithPanel[domain] + ); + + private _getEntities = (): EntityRegistryEntry[] => + this.entities.filter( + (entity) => entity.config_entry_id === this.entry.entry_id + ); + + private _getDevices = (): DeviceRegistryEntry[] => + Object.values(this.hass.devices).filter( + (device) => + device.config_entries.includes(this.entry.entry_id) && + device.entry_type !== "service" + ); + + private _getServices = (): DeviceRegistryEntry[] => + Object.values(this.hass.devices).filter( + (device) => + device.config_entries.includes(this.entry.entry_id) && + device.entry_type === "service" + ); + + private _toggleExpand() { + this._expanded = !this._expanded; + } + + private _toggleOwnDevices() { + this._devicesExpanded = !this._devicesExpanded; + } + + private _showOptions() { + showOptionsFlowDialog(this, this.entry, { manifest: this.manifest }); + } + + // Return an application credentials id for this config entry to prompt the + // user for removal. This is best effort so we don't stop overall removal + // if the integration isn't loaded or there is some other error. + private async _applicationCredentialForRemove(entryId: string) { + try { + return (await fetchApplicationCredentialsConfigEntry(this.hass, entryId)) + .application_credentials_id; + } catch (_err: any) { + // We won't prompt the user to remove credentials + return null; + } + } + + private async _removeApplicationCredential(applicationCredentialsId: string) { + const confirmed = await showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.config.integrations.config_entry.application_credentials.delete_title" + ), + text: html`${this.hass.localize( + "ui.panel.config.integrations.config_entry.application_credentials.delete_prompt" + )}, +
+
+ ${this.hass.localize( + "ui.panel.config.integrations.config_entry.application_credentials.delete_detail" + )} +
+
+ + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.application_credentials.learn_more" + )} + `, + destructive: true, + confirmText: this.hass.localize("ui.common.remove"), + dismissText: this.hass.localize( + "ui.panel.config.integrations.config_entry.application_credentials.dismiss" + ), + }); + if (!confirmed) { + return; + } + try { + await deleteApplicationCredential(this.hass, applicationCredentialsId); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.integrations.config_entry.application_credentials.delete_error_title" + ), + text: err.message, + }); + } + } + + private async _handleReload() { + const result = await reloadConfigEntry(this.hass, this.entry.entry_id); + const locale_key = result.require_restart + ? "reload_restart_confirm" + : "reload_confirm"; + showAlertDialog(this, { + text: this.hass.localize( + `ui.panel.config.integrations.config_entry.${locale_key}` + ), + }); + } + + private async _handleReconfigure() { + showConfigFlowDialog(this, { + startFlowHandler: this.entry.domain, + showAdvanced: this.hass.userData?.showAdvanced, + manifest: await fetchIntegrationManifest(this.hass, this.entry.domain), + entryId: this.entry.entry_id, + navigateToResult: true, + }); + } + + private async _handleRename() { + const newName = await showPromptDialog(this, { + title: this.hass.localize("ui.panel.config.integrations.rename_dialog"), + defaultValue: this.entry.title, + inputLabel: this.hass.localize( + "ui.panel.config.integrations.rename_input_label" + ), + }); + if (newName === null) { + return; + } + await updateConfigEntry(this.hass, this.entry.entry_id, { + title: newName, + }); + } + + private async _signUrl(ev) { + const anchor = ev.currentTarget; + ev.preventDefault(); + const signedUrl = await getSignedPath( + this.hass, + anchor.getAttribute("href") + ); + fileDownload(signedUrl.path); + } + + private async _handleDisable() { + const entryId = this.entry.entry_id; + + const confirmed = await showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.config.integrations.config_entry.disable_confirm_title", + { title: this.entry.title } + ), + text: this.hass.localize( + "ui.panel.config.integrations.config_entry.disable_confirm_text" + ), + confirmText: this.hass!.localize("ui.common.disable"), + dismissText: this.hass!.localize("ui.common.cancel"), + destructive: true, + }); + + if (!confirmed) { + return; + } + let result: DisableConfigEntryResult; + try { + result = await disableConfigEntry(this.hass, entryId); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.integrations.config_entry.disable_error" + ), + text: err.message, + }); + return; + } + if (result.require_restart) { + showAlertDialog(this, { + text: this.hass.localize( + "ui.panel.config.integrations.config_entry.disable_restart_confirm" + ), + }); + } + } + + private async _handleEnable() { + const entryId = this.entry.entry_id; + + let result: DisableConfigEntryResult; + try { + result = await enableConfigEntry(this.hass, entryId); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.integrations.config_entry.disable_error" + ), + text: err.message, + }); + return; + } + + if (result.require_restart) { + showAlertDialog(this, { + text: this.hass.localize( + "ui.panel.config.integrations.config_entry.enable_restart_confirm" + ), + }); + } + } + + private async _handleDelete() { + const entryId = this.entry.entry_id; + + const applicationCredentialsId = + await this._applicationCredentialForRemove(entryId); + + const confirmed = await showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.config.integrations.config_entry.delete_confirm_title", + { title: this.entry.title } + ), + text: this.hass.localize( + "ui.panel.config.integrations.config_entry.delete_confirm_text" + ), + confirmText: this.hass!.localize("ui.common.delete"), + dismissText: this.hass!.localize("ui.common.cancel"), + destructive: true, + }); + + if (!confirmed) { + return; + } + const result = await deleteConfigEntry(this.hass, entryId); + + if (result.require_restart) { + showAlertDialog(this, { + text: this.hass.localize( + "ui.panel.config.integrations.config_entry.restart_confirm" + ), + }); + } + if (applicationCredentialsId) { + this._removeApplicationCredential(applicationCredentialsId); + } + } + + private _handleSystemOptions() { + showConfigEntrySystemOptionsDialog(this, { + entry: this.entry, + manifest: this.manifest, + }); + } + + private _addSubEntry(ev) { + showSubConfigFlowDialog(this, this.entry, ev.target.flowType, { + startFlowHandler: this.entry.entry_id, + }); + } + + static styles = [ + haStyle, + css` + .expand-button { + margin: 0 -12px; + } + ha-md-list { + border: 1px solid var(--divider-color); + border-radius: var(--ha-card-border-radius, 12px); + padding: 0; + } + ha-md-list.devices { + margin: 16px; + margin-top: 0; + } + a ha-icon-button { + color: var( + --md-list-item-trailing-icon-color, + var(--md-sys-color-on-surface-variant, #49454f) + ); + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-entry-row": HaConfigEntryRow; + } +} diff --git a/src/panels/config/integrations/ha-config-integration-page.ts b/src/panels/config/integrations/ha-config-integration-page.ts index a60d0b32b9..148739aaaa 100644 --- a/src/panels/config/integrations/ha-config-integration-page.ts +++ b/src/panels/config/integrations/ha-config-integration-page.ts @@ -1,77 +1,37 @@ import { - mdiAlertCircle, - mdiBookshelf, mdiBug, mdiBugPlay, mdiBugStop, - mdiCog, - mdiDelete, - mdiDevices, mdiDotsVertical, - mdiDownload, mdiFileCodeOutline, - mdiHandExtendedOutline, + mdiHelpCircleOutline, mdiOpenInNew, mdiPackageVariant, - mdiPlayCircleOutline, mdiPlus, - mdiProgressHelper, - mdiReload, - mdiReloadAlert, - mdiRenameBox, - mdiShapeOutline, - mdiStopCircleOutline, mdiWeb, - mdiWrench, } from "@mdi/js"; 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 { classMap } from "lit/directives/class-map"; import { until } from "lit/directives/until"; import memoizeOne from "memoize-one"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; -import { isDevVersion } from "../../../common/config/version"; import { caseInsensitiveStringCompare } from "../../../common/string/compare"; import { nextRender } from "../../../common/util/render-status"; import "../../../components/ha-button"; -import "../../../components/ha-card"; -import "../../../components/ha-md-divider"; -import "../../../components/ha-list-item"; import "../../../components/ha-md-button-menu"; +import "../../../components/ha-md-divider"; import "../../../components/ha-md-list"; import "../../../components/ha-md-list-item"; import "../../../components/ha-md-menu-item"; -import { - deleteApplicationCredential, - fetchApplicationCredentialsConfigEntry, -} from "../../../data/application_credential"; import { getSignedPath } from "../../../data/auth"; -import type { - ConfigEntry, - DisableConfigEntryResult, - SubEntry, -} from "../../../data/config_entries"; -import { - ERROR_STATES, - RECOVERABLE_STATES, - deleteConfigEntry, - deleteSubEntry, - disableConfigEntry, - enableConfigEntry, - getConfigEntries, - getSubEntries, - reloadConfigEntry, - updateConfigEntry, -} from "../../../data/config_entries"; +import type { ConfigEntry } from "../../../data/config_entries"; +import { ERROR_STATES, getConfigEntries } from "../../../data/config_entries"; import { ATTENTION_SOURCES } from "../../../data/config_flow"; import type { DeviceRegistryEntry } from "../../../data/device_registry"; import type { DiagnosticInfo } from "../../../data/diagnostics"; -import { - fetchDiagnosticHandler, - getConfigEntryDiagnosticsDownloadUrl, -} from "../../../data/diagnostics"; +import { fetchDiagnosticHandler } from "../../../data/diagnostics"; import type { EntityRegistryEntry } from "../../../data/entity_registry"; import { subscribeEntityRegistry } from "../../../data/entity_registry"; import { fetchEntitySourcesWithCache } from "../../../data/entity_sources"; @@ -85,18 +45,13 @@ import { domainToName, fetchIntegrationManifest, integrationIssuesUrl, - integrationsWithPanel, setIntegrationLogLevel, subscribeLogInfo, } from "../../../data/integration"; -import { showConfigEntrySystemOptionsDialog } from "../../../dialogs/config-entry-system-options/show-dialog-config-entry-system-options"; +import { QUALITY_SCALE_MAP } from "../../../data/integration_quality_scale"; import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow"; -import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow"; -import { - showAlertDialog, - showConfirmationDialog, - showPromptDialog, -} from "../../../dialogs/generic/show-dialog-box"; +import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow"; +import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; import "../../../layouts/hass-error-screen"; import "../../../layouts/hass-subpage"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; @@ -105,10 +60,10 @@ import type { HomeAssistant } from "../../../types"; import { brandsUrl } from "../../../util/brands-url"; import { documentationUrl } from "../../../util/documentation-url"; import { fileDownload } from "../../../util/file_download"; +import "./ha-config-entry-row"; import type { DataEntryFlowProgressExtended } from "./ha-config-integrations"; import { showAddIntegrationDialog } from "./show-add-integration-dialog"; -import { QUALITY_SCALE_MAP } from "../../../data/integration_quality_scale"; -import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow"; +import { showPickConfigEntryDialog } from "./show-pick-config-entry-dialog"; export const renderConfigEntryError = ( hass: HomeAssistant, @@ -175,15 +130,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { @state() private _domainEntities: Record = {}; - @state() private _subEntries: Record = {}; - - private _configPanel = memoizeOne( - (domain: string, panels: HomeAssistant["panels"]): string | undefined => - Object.values(panels).find( - (panel) => panel.config_panel_domain === domain - )?.url_path || integrationsWithPanel[domain] - ); - private _domainConfigEntries = memoizeOne( (domain: string, configEntries?: ConfigEntry[]): ConfigEntry[] => configEntries @@ -225,12 +171,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { this._fetchDiagnostics(); this._fetchEntitySources(); } - if ( - changedProperties.has("configEntries") || - changedProperties.has("_extraConfigEntries") - ) { - this._fetchSubEntries(); - } } private async _fetchEntitySources() { @@ -270,6 +210,14 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { this._extraConfigEntries || this.configEntries ); + const supportedSubentryTypes = new Set(); + + configEntries.forEach((entry) => { + Object.keys(entry.supported_subentry_types).forEach((type) => { + supportedSubentryTypes.add(type); + }); + }); + const configEntriesInProgress = this._domainConfigEntriesInProgress( this.domain, this.configEntriesInProgress @@ -309,7 +257,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { ); }); - const devices = this._getDevices(configEntries, this.hass.devices); + const devicesRegs = this._getDevices(configEntries, this.hass.devices); const entities = this._getEntities(configEntries, this._entities); let numberOfEntities = entities.length; @@ -328,242 +276,278 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { } } - const services = !devices.some((device) => device.entry_type !== "service"); + const services = devicesRegs.filter( + (device) => device.entry_type === "service" + ); + const devices = devicesRegs.filter( + (device) => device.entry_type !== "service" + ); return html` - -
-
- -
-
- ${domainToName(this.hass.localize, -
- ${this._manifest?.version != null - ? html`
${this._manifest.version}
` - : nothing} - ${this._manifest?.is_built_in === false - ? html`
+ ${this._manifest + ? html` + + + + ` + : nothing} + ${this._manifest?.config_flow || this._logInfo + ? html` + + ${this._manifest && + (this._manifest.is_built_in || this._manifest.issue_tracker) + ? html` + - - - ${this.hass.localize( - this._manifest.overwrites_built_in - ? "ui.panel.config.integrations.config_entry.custom_overwrites_core" - : "ui.panel.config.integrations.config_entry.custom_integration" - )} - -
` - : nothing} - ${this._manifest?.iot_class?.startsWith("cloud_") - ? html`
- ${this.hass.localize( - "ui.panel.config.integrations.config_entry.depends_on_cloud" + "ui.panel.config.integrations.config_entry.known_issues" )} -
` - : nothing} - ${normalEntries.length === 0 && - this._manifest && - !this._manifest.config_flow && - this.hass.config.components.find( - (comp) => comp.split(".")[0] === this.domain - ) - ? html`
- ${this.hass.localize( - "ui.panel.config.integrations.config_entry.no_config_flow" - )} -
` - : nothing} -
- -
- ${this._manifest?.is_built_in && - this._manifest.quality_scale && - Object.keys(QUALITY_SCALE_MAP).includes( - this._manifest.quality_scale - ) - ? html` - - - - ${this.hass.localize( - QUALITY_SCALE_MAP[this._manifest.quality_scale] - .translationKey - )} - - - - ` - : nothing} - ${devices.length > 0 - ? html` - - - ${this.hass.localize( - `ui.panel.config.integrations.config_entry.${ - services ? "services" : "devices" - }`, - { count: devices.length } - )} - - - ` - : nothing} - ${numberOfEntities > 0 - ? html` - - - ${this.hass.localize( - `ui.panel.config.integrations.config_entry.entities`, - { count: numberOfEntities } - )} - - - ` - : nothing} - ${this._manifest - ? html` - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.documentation" - )} - - - - ` - : nothing} - ${this._manifest && - (this._manifest.is_built_in || this._manifest.issue_tracker) - ? html` - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.known_issues" - )} - - - - ` - : nothing} - ${this._logInfo - ? html` - ${this._logInfo.level === LogSeverity.DEBUG - ? this.hass.localize( - "ui.panel.config.integrations.config_entry.disable_debug_logging" - ) - : this.hass.localize( - "ui.panel.config.integrations.config_entry.enable_debug_logging" - )} + - ` - : nothing} + + ` + : nothing} + ${this._logInfo + ? html` + ${this._logInfo.level === LogSeverity.DEBUG + ? this.hass.localize( + "ui.panel.config.integrations.config_entry.disable_debug_logging" + ) + : this.hass.localize( + "ui.panel.config.integrations.config_entry.enable_debug_logging" + )} + + ` + : nothing} + ` + : nothing} + +
+
+
+
+ ${domainToName(this.hass.localize,
- +
+

${domainToName(this.hass.localize, this.domain)}

+
+ ${this._manifest?.is_built_in === false + ? html`` + : nothing} + ${this._logInfo?.level === LogSeverity.DEBUG + ? html`
+ + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.debug_logging_enabled" + )} +
` + : nothing} + ${this._manifest?.iot_class?.startsWith("cloud_") + ? html`
+ + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.depends_on_cloud" + )} +
` + : nothing} + ${normalEntries.length === 0 && + this._manifest && + !this._manifest.config_flow && + this.hass.config.components.find( + (comp) => comp.split(".")[0] === this.domain + ) + ? html`
+ ${this.hass.localize( + "ui.panel.config.integrations.config_entry.no_config_flow" + )} +
` + : nothing} + ${this._manifest?.quality_scale && + Object.keys(QUALITY_SCALE_MAP).includes( + this._manifest.quality_scale + ) + ? html` + + ` + : nothing} +
+
+ ${devices.length + ? html` + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.devices`, + { count: devices.length } + )} + + ` + : nothing} + ${devices.length && services.length ? " • " : ""} + ${services.length + ? html` + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.services`, + { count: services.length } + )} + ` + : nothing} + ${(devices.length || services.length) && numberOfEntities + ? " • " + : ""} + ${numberOfEntities + ? html` + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.entities`, + { count: numberOfEntities } + )} + + ` + : nothing} +
+
+
+
+ + ${this._manifest?.integration_type + ? this.hass.localize( + `ui.panel.config.integrations.integration_page.add_${this._manifest.integration_type}` + ) + : this.hass.localize( + `ui.panel.config.integrations.integration_page.add_entry` + )} + + ${Array.from(supportedSubentryTypes).map( + (flowType) => + html` + + ${this.hass.localize( + `component.${this.domain}.config_subentries.${flowType}.initiate_flow.user` + )}` + )} +
-
- ${discoveryFlows.length - ? html` -

+ + ${discoveryFlows.length + ? html` +
+

${this.hass.localize( "ui.panel.config.integrations.discovered" )} -

- +

+ ${discoveryFlows.map( (flow) => html` @@ -578,106 +562,104 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { ` )} -
` - : nothing} - ${attentionFlows.length || attentionEntries.length - ? html` -

+

+ ` + : nothing} + ${attentionFlows.length || attentionEntries.length + ? html` +
+

${this.hass.localize( `ui.panel.config.integrations.integration_page.attention_entries` )} -

- - ${attentionFlows.map((flow) => { - const attention = ATTENTION_SOURCES.includes( - flow.context.source - ); - return html` - ${flow.localized_title} - ${this.hass.localize( - `ui.panel.config.integrations.${ - attention ? "attention" : "discovered" - }` - )} - - `; - })} - ${attentionEntries.map( - (item, index) => - html`${this._renderConfigEntry(item)} - ${index < attentionEntries.length - 1 - ? html` ` - : nothing} ` - )} - - ` - : nothing} + + ${attentionFlows.length + ? html` + ${attentionFlows.map((flow) => { + const attention = ATTENTION_SOURCES.includes( + flow.context.source + ); + return html` + ${flow.localized_title} + ${this.hass.localize( + `ui.panel.config.integrations.${ + attention ? "attention" : "discovered" + }` + )} + ${this.hass.localize( + `ui.panel.config.integrations.${ + attention ? "reconfigure" : "configure" + }` + )} + `; + })} + ` + : nothing} + ${attentionEntries.map( + (item) => + html`` + )} +
+ ` + : nothing} - -

- ${this._manifest?.integration_type - ? this.hass.localize( - `ui.panel.config.integrations.integration_page.entries_${this._manifest.integration_type}` - ) - : this.hass.localize( - `ui.panel.config.integrations.integration_page.entries` - )} -

- ${normalEntries.length === 0 - ? html`
- ${this._manifest && - !this._manifest.config_flow && - this.hass.config.components.find( - (comp) => comp.split(".")[0] === this.domain - ) - ? this.hass.localize( - "ui.panel.config.integrations.integration_page.yaml_entry" - ) - : this.hass.localize( - "ui.panel.config.integrations.integration_page.no_entries" - )} -
` - : html` - ${normalEntries.map( - (item, index) => - html`${this._renderConfigEntry(item)} - ${index < normalEntries.length - 1 - ? html` ` - : nothing}` - )} - `} -
- - ${this._manifest?.integration_type +
+

+ ${this._manifest?.integration_type + ? this.hass.localize( + `ui.panel.config.integrations.integration_page.entries_${this._manifest.integration_type}` + ) + : this.hass.localize( + `ui.panel.config.integrations.integration_page.entries` + )} +

+ ${normalEntries.length === 0 + ? html`
+ ${this._manifest && + !this._manifest.config_flow && + this.hass.config.components.find( + (comp) => comp.split(".")[0] === this.domain + ) ? this.hass.localize( - `ui.panel.config.integrations.integration_page.add_${this._manifest.integration_type}` + "ui.panel.config.integrations.integration_page.yaml_entry" ) : this.hass.localize( - `ui.panel.config.integrations.integration_page.add_entry` + "ui.panel.config.integrations.integration_page.no_entries" )} - -
- +
` + : html` + ${normalEntries.map( + (item) => + html`` + )} + `}
@@ -692,421 +674,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { ev.target.style.display = "none"; } - private _renderDeviceLine( - item: ConfigEntry, - devices: DeviceRegistryEntry[], - services: DeviceRegistryEntry[], - entities: EntityRegistryEntry[], - subItem?: SubEntry - ) { - let devicesLine: (TemplateResult | string)[] = []; - for (const [items, localizeKey] of [ - [devices, "devices"], - [services, "services"], - ] as const) { - if (items.length === 0) { - continue; - } - const url = - items.length === 1 - ? `/config/devices/device/${items[0].id}` - : `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}${subItem ? `&sub_entry=${subItem.subentry_id}` : ""}`; - devicesLine.push( - // no white space before/after template on purpose - html`${this.hass.localize( - `ui.panel.config.integrations.config_entry.${localizeKey}`, - { count: items.length } - )}` - ); - } - - if (entities.length) { - devicesLine.push( - // no white space before/after template on purpose - html`${this.hass.localize( - "ui.panel.config.integrations.config_entry.entities", - { count: entities.length } - )}` - ); - } - - if (devicesLine.length === 0) { - devicesLine = [ - this.hass.localize( - "ui.panel.config.integrations.config_entry.no_devices_or_entities" - ), - ]; - } else if (devicesLine.length === 2) { - devicesLine = [ - devicesLine[0], - ` ${this.hass.localize("ui.common.and")} `, - devicesLine[1], - ]; - } else if (devicesLine.length === 3) { - devicesLine = [ - devicesLine[0], - ", ", - devicesLine[1], - ` ${this.hass.localize("ui.common.and")} `, - devicesLine[2], - ]; - } - return devicesLine; - } - - private _renderConfigEntry(item: ConfigEntry) { - let stateText: Parameters | undefined; - let stateTextExtra: TemplateResult | string | undefined; - let icon: string = mdiAlertCircle; - - if (!item.disabled_by && item.state === "not_loaded") { - stateText = ["ui.panel.config.integrations.config_entry.not_loaded"]; - } else if (item.state === "setup_in_progress") { - icon = mdiProgressHelper; - stateText = [ - "ui.panel.config.integrations.config_entry.setup_in_progress", - ]; - } else if (ERROR_STATES.includes(item.state)) { - if (item.state === "setup_retry") { - icon = mdiReloadAlert; - } - stateText = [ - `ui.panel.config.integrations.config_entry.state.${item.state}`, - ]; - stateTextExtra = renderConfigEntryError(this.hass, item); - } - - const devices = this._getConfigEntryDevices(item); - const services = this._getConfigEntryServices(item); - const entities = this._getConfigEntryEntities(item); - - let devicesLine: (TemplateResult | string)[] = []; - - if (item.disabled_by) { - devicesLine.push( - this.hass.localize( - "ui.panel.config.integrations.config_entry.disable.disabled_cause", - { - cause: - this.hass.localize( - `ui.panel.config.integrations.config_entry.disable.disabled_by.${item.disabled_by}` - ) || item.disabled_by, - } - ) - ); - if (item.state === "failed_unload") { - devicesLine.push(`. - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.disable_restart_confirm" - )}.`); - } - } else { - devicesLine = this._renderDeviceLine(item, devices, services, entities); - } - - const configPanel = this._configPanel(item.domain, this.hass.panels); - - const subEntries = this._subEntries[item.entry_id] || []; - - return html` -
- ${item.title || domainToName(this.hass.localize, item.domain)} -
-
-
${devicesLine}
- ${stateText - ? html` -
- -
- ${this.hass.localize(...stateText)}${stateTextExtra - ? html`: ${stateTextExtra}` - : nothing} -
-
- ` - : nothing} -
- ${item.disabled_by === "user" - ? html` - ${this.hass.localize("ui.common.enable")} - ` - : configPanel && - (item.domain !== "matter" || - isDevVersion(this.hass.config.version)) && - !stateText - ? html` - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.configure" - )} - ` - : item.supports_options - ? html` - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.configure" - )} - - ` - : nothing} - - - ${item.disabled_by && devices.length - ? html` - - - ${this.hass.localize( - `ui.panel.config.integrations.config_entry.devices`, - { count: devices.length } - )} - - - ` - : nothing} - ${item.disabled_by && services.length - ? html` - - ${this.hass.localize( - `ui.panel.config.integrations.config_entry.services`, - { count: services.length } - )} - - ` - : nothing} - ${item.disabled_by && entities.length - ? html` - - - ${this.hass.localize( - `ui.panel.config.integrations.config_entry.entities`, - { count: entities.length } - )} - - - ` - : nothing} - ${!item.disabled_by && - RECOVERABLE_STATES.includes(item.state) && - item.supports_unload && - item.source !== "system" - ? html` - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.reload" - )} - - ` - : nothing} - - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.rename" - )} - - - ${Object.keys(item.supported_subentry_types).map( - (flowType) => - html` - - ${this.hass.localize( - `component.${item.domain}.config_subentries.${flowType}.initiate_flow.user` - )}` - )} - - - - ${this._diagnosticHandler && item.state === "loaded" - ? html` - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.download_diagnostics" - )} - - ` - : nothing} - ${!item.disabled_by && - item.supports_reconfigure && - item.source !== "system" - ? html` - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.reconfigure" - )} - - ` - : nothing} - - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.system_options" - )} - - ${item.disabled_by === "user" - ? html` - - - ${this.hass.localize("ui.common.enable")} - - ` - : item.source !== "system" - ? html` - - - ${this.hass.localize("ui.common.disable")} - - ` - : nothing} - ${item.source !== "system" - ? html` - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.delete" - )} - - ` - : nothing} - -
- ${subEntries.map((subEntry) => this._renderSubEntry(item, subEntry))}`; - } - - private _renderSubEntry(configEntry: ConfigEntry, subEntry: SubEntry) { - const devices = this._getConfigEntryDevices(configEntry).filter((device) => - device.config_entries_subentries[configEntry.entry_id]?.includes( - subEntry.subentry_id - ) - ); - const services = this._getConfigEntryServices(configEntry).filter( - (device) => - device.config_entries_subentries[configEntry.entry_id]?.includes( - subEntry.subentry_id - ) - ); - const entities = this._getConfigEntryEntities(configEntry).filter( - (entity) => entity.config_subentry_id === subEntry.subentry_id - ); - - return html` - ${subEntry.title} - ${this.hass.localize( - `component.${configEntry.domain}.config_subentries.${subEntry.subentry_type}.entry_type` - )} - - - ${this._renderDeviceLine( - configEntry, - devices, - services, - entities, - subEntry - )} - ${configEntry.supported_subentry_types[subEntry.subentry_type] - ?.supports_reconfigure - ? html` - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.configure" - )} - - ` - : nothing} - - - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.delete" - )} - - - `; - } - private async _highlightEntry() { await nextRender(); const entryId = this._searchParms.get("config_entry")!; @@ -1146,27 +713,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { } } - private async _fetchSubEntries() { - const subEntriesPromises = ( - this._extraConfigEntries || this.configEntries - )?.map((entry) => - entry.num_subentries - ? getSubEntries(this.hass, entry.entry_id).then((subEntries) => ({ - entry_id: entry.entry_id, - subEntries, - })) - : undefined - ); - if (subEntriesPromises) { - const subEntries = await Promise.all(subEntriesPromises); - this._subEntries = {}; - subEntries.forEach((entry) => { - if (!entry) return; - this._subEntries[entry.entry_id] = entry.subEntries; - }); - } - } - private async _fetchDiagnostics() { if (!this.domain || !isComponentLoaded(this.hass, "diagnostics")) { return; @@ -1239,361 +785,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { } ); - private _getConfigEntryEntities = ( - configEntry: ConfigEntry - ): EntityRegistryEntry[] => { - const entries = this._domainConfigEntries( - this.domain, - this._extraConfigEntries || this.configEntries - ); - const entityRegistryEntries = this._getEntities(entries, this._entities); - return entityRegistryEntries.filter( - (entity) => entity.config_entry_id === configEntry.entry_id - ); - }; - - private _getConfigEntryDevices = ( - configEntry: ConfigEntry - ): DeviceRegistryEntry[] => { - const entries = this._domainConfigEntries( - this.domain, - this._extraConfigEntries || this.configEntries - ); - const deviceRegistryEntries = this._getDevices(entries, this.hass.devices); - return Object.values(deviceRegistryEntries).filter( - (device) => - device.config_entries.includes(configEntry.entry_id) && - device.entry_type !== "service" - ); - }; - - private _getConfigEntryServices = ( - configEntry: ConfigEntry - ): DeviceRegistryEntry[] => { - const entries = this._domainConfigEntries( - this.domain, - this._extraConfigEntries || this.configEntries - ); - const deviceRegistryEntries = this._getDevices(entries, this.hass.devices); - return Object.values(deviceRegistryEntries).filter( - (device) => - device.config_entries.includes(configEntry.entry_id) && - device.entry_type === "service" - ); - }; - - private _showOptions(ev) { - showOptionsFlowDialog( - this, - ev.target.closest(".config_entry").configEntry, - { manifest: this._manifest } - ); - } - - private _handleRename(ev: Event): void { - this._editEntryName( - ((ev.target as HTMLElement).closest(".config_entry") as any).configEntry - ); - } - - private _handleReload(ev: Event): void { - this._reloadIntegration( - ((ev.target as HTMLElement).closest(".config_entry") as any).configEntry - ); - } - - private _handleReconfigure(ev: Event): void { - this._reconfigureIntegration( - ((ev.target as HTMLElement).closest(".config_entry") as any).configEntry - ); - } - - private _handleDelete(ev: Event): void { - this._removeIntegration( - ((ev.target as HTMLElement).closest(".config_entry") as any).configEntry - ); - } - - private async _handleReconfigureSub(ev: Event): Promise { - const configEntry = ( - (ev.target as HTMLElement).closest(".sub-entry") as any - ).configEntry; - const subEntry = ((ev.target as HTMLElement).closest(".sub-entry") as any) - .subEntry; - - showSubConfigFlowDialog( - this, - configEntry, - subEntry.flowType || subEntry.subentry_type, - { - startFlowHandler: configEntry.entry_id, - subEntryId: subEntry.subentry_id, - } - ); - } - - private async _handleDeleteSub(ev: Event): Promise { - const configEntry = ( - (ev.target as HTMLElement).closest(".sub-entry") as any - ).configEntry; - const subEntry = ((ev.target as HTMLElement).closest(".sub-entry") as any) - .subEntry; - const confirmed = await showConfirmationDialog(this, { - title: this.hass.localize( - "ui.panel.config.integrations.config_entry.delete_confirm_title", - { title: subEntry.title } - ), - text: this.hass.localize( - "ui.panel.config.integrations.config_entry.delete_confirm_text" - ), - confirmText: this.hass!.localize("ui.common.delete"), - dismissText: this.hass!.localize("ui.common.cancel"), - destructive: true, - }); - - if (!confirmed) { - return; - } - await deleteSubEntry(this.hass, configEntry.entry_id, subEntry.subentry_id); - } - - private _handleDisable(ev: Event): void { - this._disableIntegration( - ((ev.target as HTMLElement).closest(".config_entry") as any).configEntry - ); - } - - private _handleEnable(ev: Event): void { - this._enableIntegration( - ((ev.target as HTMLElement).closest(".config_entry") as any).configEntry - ); - } - - private _handleSystemOptions(ev: Event): void { - this._showSystemOptions( - ((ev.target as HTMLElement).closest(".config_entry") as any).configEntry - ); - } - - private _showSystemOptions(configEntry: ConfigEntry) { - showConfigEntrySystemOptionsDialog(this, { - entry: configEntry, - manifest: this._manifest, - }); - } - - private async _disableIntegration(configEntry: ConfigEntry) { - const entryId = configEntry.entry_id; - - const confirmed = await showConfirmationDialog(this, { - title: this.hass.localize( - "ui.panel.config.integrations.config_entry.disable_confirm_title", - { title: configEntry.title } - ), - text: this.hass.localize( - "ui.panel.config.integrations.config_entry.disable_confirm_text" - ), - confirmText: this.hass!.localize("ui.common.disable"), - dismissText: this.hass!.localize("ui.common.cancel"), - destructive: true, - }); - - if (!confirmed) { - return; - } - let result: DisableConfigEntryResult; - try { - result = await disableConfigEntry(this.hass, entryId); - } catch (err: any) { - showAlertDialog(this, { - title: this.hass.localize( - "ui.panel.config.integrations.config_entry.disable_error" - ), - text: err.message, - }); - return; - } - if (result.require_restart) { - showAlertDialog(this, { - text: this.hass.localize( - "ui.panel.config.integrations.config_entry.disable_restart_confirm" - ), - }); - } - } - - private async _enableIntegration(configEntry: ConfigEntry) { - const entryId = configEntry.entry_id; - - let result: DisableConfigEntryResult; - try { - result = await enableConfigEntry(this.hass, entryId); - } catch (err: any) { - showAlertDialog(this, { - title: this.hass.localize( - "ui.panel.config.integrations.config_entry.disable_error" - ), - text: err.message, - }); - return; - } - - if (result.require_restart) { - showAlertDialog(this, { - text: this.hass.localize( - "ui.panel.config.integrations.config_entry.enable_restart_confirm" - ), - }); - } - } - - private async _removeIntegration(configEntry: ConfigEntry) { - const entryId = configEntry.entry_id; - - const applicationCredentialsId = - await this._applicationCredentialForRemove(entryId); - - const confirmed = await showConfirmationDialog(this, { - title: this.hass.localize( - "ui.panel.config.integrations.config_entry.delete_confirm_title", - { title: configEntry.title } - ), - text: this.hass.localize( - "ui.panel.config.integrations.config_entry.delete_confirm_text" - ), - confirmText: this.hass!.localize("ui.common.delete"), - dismissText: this.hass!.localize("ui.common.cancel"), - destructive: true, - }); - - if (!confirmed) { - return; - } - const result = await deleteConfigEntry(this.hass, entryId); - - if (result.require_restart) { - showAlertDialog(this, { - text: this.hass.localize( - "ui.panel.config.integrations.config_entry.restart_confirm" - ), - }); - } - if (applicationCredentialsId) { - this._removeApplicationCredential(applicationCredentialsId); - } - } - - // Return an application credentials id for this config entry to prompt the - // user for removal. This is best effort so we don't stop overall removal - // if the integration isn't loaded or there is some other error. - private async _applicationCredentialForRemove(entryId: string) { - try { - return (await fetchApplicationCredentialsConfigEntry(this.hass, entryId)) - .application_credentials_id; - } catch (_err: any) { - // We won't prompt the user to remove credentials - return null; - } - } - - private async _removeApplicationCredential(applicationCredentialsId: string) { - const confirmed = await showConfirmationDialog(this, { - title: this.hass.localize( - "ui.panel.config.integrations.config_entry.application_credentials.delete_title" - ), - text: html`${this.hass.localize( - "ui.panel.config.integrations.config_entry.application_credentials.delete_prompt" - )}, -
-
- ${this.hass.localize( - "ui.panel.config.integrations.config_entry.application_credentials.delete_detail" - )} -
-
- - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.application_credentials.learn_more" - )} - `, - destructive: true, - confirmText: this.hass.localize("ui.common.remove"), - dismissText: this.hass.localize( - "ui.panel.config.integrations.config_entry.application_credentials.dismiss" - ), - }); - if (!confirmed) { - return; - } - try { - await deleteApplicationCredential(this.hass, applicationCredentialsId); - } catch (err: any) { - showAlertDialog(this, { - title: this.hass.localize( - "ui.panel.config.integrations.config_entry.application_credentials.delete_error_title" - ), - text: err.message, - }); - } - } - - private async _reloadIntegration(configEntry: ConfigEntry) { - const entryId = configEntry.entry_id; - - const result = await reloadConfigEntry(this.hass, entryId); - const locale_key = result.require_restart - ? "reload_restart_confirm" - : "reload_confirm"; - showAlertDialog(this, { - text: this.hass.localize( - `ui.panel.config.integrations.config_entry.${locale_key}` - ), - }); - } - - private async _reconfigureIntegration(configEntry: ConfigEntry) { - showConfigFlowDialog(this, { - startFlowHandler: configEntry.domain, - showAdvanced: this.hass.userData?.showAdvanced, - manifest: await fetchIntegrationManifest(this.hass, configEntry.domain), - entryId: configEntry.entry_id, - navigateToResult: true, - }); - } - - private async _editEntryName(configEntry: ConfigEntry) { - const newName = await showPromptDialog(this, { - title: this.hass.localize("ui.panel.config.integrations.rename_dialog"), - defaultValue: configEntry.title, - inputLabel: this.hass.localize( - "ui.panel.config.integrations.rename_input_label" - ), - }); - if (newName === null) { - return; - } - await updateConfigEntry(this.hass, configEntry.entry_id, { - title: newName, - }); - } - - private async _signUrl(ev) { - const anchor = ev.currentTarget; - ev.preventDefault(); - const signedUrl = await getSignedPath( - this.hass, - anchor.getAttribute("href") - ); - fileDownload(signedUrl.path); - } - private async _addIntegration() { if (!this._manifest?.config_flow) { showAlertDialog(this, { @@ -1636,8 +827,33 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { } private async _addSubEntry(ev) { - showSubConfigFlowDialog(this, ev.target.entry, ev.target.flowType, { - startFlowHandler: ev.target.entry.entry_id, + const flowType = ev.target.flowType; + + const configEntries = this._domainConfigEntries( + this.domain, + this._extraConfigEntries || this.configEntries + ).filter((entry) => entry.source !== "ignore"); + + if (!configEntries.length) { + return; + } + + if (configEntries.length === 1 && configEntries[0].state === "loaded") { + showSubConfigFlowDialog(this, configEntries[0], flowType, { + startFlowHandler: configEntries[0].entry_id, + }); + return; + } + + showPickConfigEntryDialog(this, { + domain: this.domain, + subFlowType: flowType, + configEntries, + configEntryPicked: (entry) => { + showSubConfigFlowDialog(this, entry, flowType, { + startFlowHandler: entry.entry_id, + }); + }, }); } @@ -1650,35 +866,49 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { flex-wrap: wrap; margin: auto; max-width: 1000px; - margin-top: 32px; - margin-bottom: 32px; + padding: 32px; + } + .container > * { + flex-grow: 1; + } + .header { + display: flex; + flex-wrap: wrap; + gap: 24px; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + } + .title-container { + display: flex; + align-items: center; + } + .title { + display: flex; + gap: 8px; + flex-direction: column; + justify-content: space-between; + } + .title h1 { + font-family: Roboto; + font-size: 32px; + font-weight: 700; + line-height: 40px; + text-align: left; + text-underline-position: from-font; + text-decoration-skip-ink: none; + margin: 0; + } + .sub { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; } .card-content { padding: 16px 0 8px; } - .column { - width: 33%; - flex-grow: 1; - } - .column.small { - max-width: 300px; - } - .column, - .fullwidth { - padding: 8px; - box-sizing: border-box; - } - .column > *:not(:first-child) { - margin-top: 16px; - } - - :host([narrow]) .column { - width: 100%; - max-width: unset; - } - :host([narrow]) .container { - margin-top: 0; + padding: 16px; } .card-header { padding-bottom: 0; @@ -1689,7 +919,11 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { .logo-container { display: flex; justify-content: center; - margin-bottom: 8px; + margin-right: 16px; + padding: 0 8px; + } + .logo-container img { + width: 80px; } .version { padding-top: 8px; @@ -1710,12 +944,31 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { mask-position: left; } } + .actions { + display: flex; + gap: 8px; + } + .section { + width: 100%; + } + .section-header { + margin-inline-start: 16px; + margin-top: 6px; + margin-bottom: 6px; + font-family: Roboto; + font-size: 14px; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.10000000149011612px; + text-align: left; + text-underline-position: from-font; + text-decoration-skip-ink: none; + color: var(--secondary-text-color); + } .integration-info { display: flex; align-items: center; - gap: 20px; - padding: 0 20px; - min-height: 48px; + gap: 8px; } .integration-info ha-svg-icon { min-width: 24px; @@ -1754,7 +1007,26 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { color: var(--mdc-theme-text-icon-on-background, rgba(0, 0, 0, 0.38)); animation: unset; } + ha-md-list { + border: 1px solid var(--divider-color); + border-radius: 8px; + padding: 0; + } + .discovered { + --md-list-container-color: rgba(var(--rgb-success-color), 0.2); + } + .discovered ha-button { + --mdc-theme-primary: var(--success-color); + } + .attention { + --md-list-container-color: rgba(var(--rgb-warning-color), 0.2); + } + .attention ha-button { + --mdc-theme-primary: var(--warning-color); + } ha-md-list-item { + --md-list-item-top-space: 4px; + --md-list-item-bottom-space: 4px; position: relative; } ha-md-list-item.discovered { @@ -1770,8 +1042,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { pointer-events: none; content: ""; } - ha-md-list-item.sub-entry { - --md-list-item-leading-space: 50px; + ha-config-entry-row { + display: block; + margin-bottom: 16px; } a { text-decoration: none; @@ -1779,19 +1052,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { .highlight::after { background-color: var(--info-color); } - .attention { - primary-color: var(--error-color); - } .warning { color: var(--error-color); } - .state-error { - --state-message-color: var(--error-color); - --text-on-state-color: var(--text-primary-color); - } - .state-error::after { - background-color: var(--error-color); - } .state-failed-unload { --state-message-color: var(--warning-color); --text-on-state-color: var(--primary-text-color); @@ -1837,6 +1100,13 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { margin-top: 8px; margin-bottom: 8px; } + a[slot="toolbar-icon"] { + color: var(--sidebar-icon-color); + } + ha-svg-icon.open-external { + min-width: 14px; + width: 14px; + } `, ]; } diff --git a/src/panels/config/integrations/ha-config-sub-entry-row.ts b/src/panels/config/integrations/ha-config-sub-entry-row.ts new file mode 100644 index 0000000000..c30825a6c1 --- /dev/null +++ b/src/panels/config/integrations/ha-config-sub-entry-row.ts @@ -0,0 +1,250 @@ +import { + mdiChevronDown, + mdiChevronUp, + mdiCogOutline, + mdiDelete, + mdiDevices, + mdiDotsVertical, + mdiHandExtendedOutline, + mdiShapeOutline, +} from "@mdi/js"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import type { ConfigEntry, SubEntry } from "../../../data/config_entries"; +import { deleteSubEntry } from "../../../data/config_entries"; +import type { DeviceRegistryEntry } from "../../../data/device_registry"; +import type { DiagnosticInfo } from "../../../data/diagnostics"; +import type { EntityRegistryEntry } from "../../../data/entity_registry"; +import type { IntegrationManifest } from "../../../data/integration"; +import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow"; +import type { HomeAssistant } from "../../../types"; +import { showConfirmationDialog } from "../../lovelace/custom-card-helpers"; +import "./ha-config-entry-device-row"; + +@customElement("ha-config-sub-entry-row") +class HaConfigSubEntryRow extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean, reflect: true }) public narrow = false; + + @property({ attribute: false }) public manifest?: IntegrationManifest; + + @property({ attribute: false }) public diagnosticHandler?: DiagnosticInfo; + + @property({ attribute: false }) public entities!: EntityRegistryEntry[]; + + @property({ attribute: false }) public entry!: ConfigEntry; + + @property({ attribute: false }) public subEntry!: SubEntry; + + @state() private _expanded = true; + + protected render() { + const subEntry = this.subEntry; + const configEntry = this.entry; + + const devices = this._getDevices(); + const services = this._getServices(); + const entities = this._getEntities(); + + return html` + + ${devices.length || services.length + ? html`` + : nothing} + ${subEntry.title} + ${this.hass.localize( + `component.${configEntry.domain}.config_subentries.${subEntry.subentry_type}.entry_type` + )} + ${configEntry.supported_subentry_types[subEntry.subentry_type] + ?.supports_reconfigure + ? html` + + + ` + : nothing} + + + ${devices.length || services.length + ? html` + + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.devices`, + { count: devices.length } + )} + + + ` + : nothing} + ${services.length + ? html` + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.services`, + { count: services.length } + )} + + ` + : nothing} + ${entities.length + ? html` + + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.entities`, + { count: entities.length } + )} + + + ` + : nothing} + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.delete" + )} + + + + ${this._expanded + ? html` + ${devices.map( + (device) => + html`` + )} + ` + : nothing} + `; + } + + private _toggleExpand() { + this._expanded = !this._expanded; + } + + private _getEntities = (): EntityRegistryEntry[] => + this.entities.filter( + (entity) => entity.config_subentry_id === this.subEntry.subentry_id + ); + + private _getDevices = (): DeviceRegistryEntry[] => + Object.values(this.hass.devices).filter( + (device) => + device.config_entries_subentries[this.entry.entry_id]?.includes( + this.subEntry.subentry_id + ) && device.entry_type !== "service" + ); + + private _getServices = (): DeviceRegistryEntry[] => + Object.values(this.hass.devices).filter( + (device) => + device.config_entries_subentries[this.entry.entry_id]?.includes( + this.subEntry.subentry_id + ) && device.entry_type === "service" + ); + + private async _handleReconfigureSub(): Promise { + showSubConfigFlowDialog(this, this.entry, this.subEntry.subentry_type, { + startFlowHandler: this.entry.entry_id, + subEntryId: this.subEntry.subentry_id, + }); + } + + private async _handleDeleteSub(): Promise { + const confirmed = await showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.config.integrations.config_entry.delete_confirm_title", + { title: this.subEntry.title } + ), + text: this.hass.localize( + "ui.panel.config.integrations.config_entry.delete_confirm_text" + ), + confirmText: this.hass!.localize("ui.common.delete"), + dismissText: this.hass!.localize("ui.common.cancel"), + destructive: true, + }); + + if (!confirmed) { + return; + } + await deleteSubEntry( + this.hass, + this.entry.entry_id, + this.subEntry.subentry_id + ); + } + + static styles = css` + .expand-button { + margin: 0 -12px; + } + ha-md-list { + border: 1px solid var(--divider-color); + border-radius: var(--ha-card-border-radius, 12px); + padding: 0; + margin: 16px; + margin-top: 0; + } + ha-md-list-item.has-subentries { + border-bottom: 1px solid var(--divider-color); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-sub-entry-row": HaConfigSubEntryRow; + } +} diff --git a/src/panels/config/integrations/show-pick-config-entry-dialog.ts b/src/panels/config/integrations/show-pick-config-entry-dialog.ts new file mode 100644 index 0000000000..0bd3fbd604 --- /dev/null +++ b/src/panels/config/integrations/show-pick-config-entry-dialog.ts @@ -0,0 +1,20 @@ +import { fireEvent } from "../../../common/dom/fire_event"; +import type { ConfigEntry } from "../../../data/config_entries"; + +export interface PickConfigEntryDialogParams { + domain: string; + subFlowType: string; + configEntries: ConfigEntry[]; + configEntryPicked: (configEntry: ConfigEntry) => void; +} + +export const showPickConfigEntryDialog = ( + element: HTMLElement, + dialogParams?: PickConfigEntryDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-pick-config-entry", + dialogImport: () => import("./dialog-pick-config-entry"), + dialogParams: dialogParams, + }); +}; diff --git a/src/resources/theme/color.globals.ts b/src/resources/theme/color.globals.ts index e80932117e..56ceb72acb 100644 --- a/src/resources/theme/color.globals.ts +++ b/src/resources/theme/color.globals.ts @@ -31,6 +31,11 @@ export const colorStyles = css` --rgb-text-primary-color: 255, 255, 255; --rgb-card-background-color: 255, 255, 255; + --rgb-warning-color: 255, 166, 0; + --rgb-error-color: 219, 68, 55; + --rgb-success-color: 67, 160, 71; + --rgb-info-color: 3, 155, 229; + --scrollbar-thumb-color: rgb(194, 194, 194); --error-color: #db4437; diff --git a/src/translations/en.json b/src/translations/en.json index 028dd1a6f6..3632d746f5 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -5319,11 +5319,18 @@ "dismiss": "Keep", "learn_more": "Learn more about application credentials" }, + "device": { + "enable": "Enable device", + "disable": "Disable device", + "configure": "Configure device", + "delete": "Remove device" + }, "devices": "{count} {count, plural,\n one {device}\n other {devices}\n}", "entities": "{count} {count, plural,\n one {entity}\n other {entities}\n}", "services": "{count} {count, plural,\n one {service}\n other {services}\n}", "entries": "{count} {count, plural,\n one {entry}\n other {entries}\n}", "no_devices_or_entities": "No devices or entities", + "devices_without_subentry": "Devices that don't belong to a sub-entry", "rename": "Rename", "configure": "Configure", "system_options": "System options",