diff --git a/src/common/decorators/restore-scroll.ts b/src/common/decorators/restore-scroll.ts index 9668fc47d3..ff427cf33e 100644 --- a/src/common/decorators/restore-scroll.ts +++ b/src/common/decorators/restore-scroll.ts @@ -9,10 +9,13 @@ export const restoreScroll = key: element.key, descriptor: { set(this: LitElement, value: number) { + history.replaceState({ scrollPosition: value }, ""); this[`__${String(element.key)}`] = value; }, get(this: LitElement) { - return this[`__${String(element.key)}`]; + return ( + this[`__${String(element.key)}`] || history.state?.scrollPosition + ); }, enumerable: true, configurable: true, @@ -21,12 +24,17 @@ export const restoreScroll = const connectedCallback = cls.prototype.connectedCallback; cls.prototype.connectedCallback = function () { connectedCallback.call(this); - if (this[element.key]) { - const target = this.renderRoot.querySelector(selector); - if (!target) { - return; - } - target.scrollTop = this[element.key]; + const scrollPos = this[element.key]; + if (scrollPos) { + this.updateComplete.then(() => { + const target = this.renderRoot.querySelector(selector); + if (!target) { + return; + } + setTimeout(() => { + target.scrollTop = scrollPos; + }, 0); + }); } }; }, diff --git a/src/components/ha-list-item.ts b/src/components/ha-list-item.ts index 252e80954a..7b9b633a76 100644 --- a/src/components/ha-list-item.ts +++ b/src/components/ha-list-item.ts @@ -5,6 +5,13 @@ import { customElement } from "lit/decorators"; @customElement("ha-list-item") export class HaListItem extends ListItemBase { + protected renderRipple() { + if (this.noninteractive) { + return ""; + } + return super.renderRipple(); + } + static get styles(): CSSResultGroup { return [ styles, @@ -32,6 +39,7 @@ export class HaListItem extends ListItemBase { } .mdc-deprecated-list-item__meta { display: var(--mdc-list-item-meta-display); + align-items: center; } :host([multiline-secondary]) { height: auto; @@ -60,6 +68,9 @@ export class HaListItem extends ListItemBase { :host([disabled]) { color: var(--disabled-text-color); } + :host([noninteractive]) { + pointer-events: unset; + } `, ]; } diff --git a/src/components/ha-related-items.ts b/src/components/ha-related-items.ts index 7b5574a6be..8b53feaddf 100644 --- a/src/components/ha-related-items.ts +++ b/src/components/ha-related-items.ts @@ -96,7 +96,7 @@ export class HaRelatedItems extends LitElement { } return html` diff --git a/src/data/integration.ts b/src/data/integration.ts index 04d0eb35c1..af9478925a 100644 --- a/src/data/integration.ts +++ b/src/data/integration.ts @@ -3,6 +3,14 @@ import { LocalizeFunc } from "../common/translations/localize"; import { HomeAssistant } from "../types"; import { debounce } from "../common/util/debounce"; +export const integrationsWithPanel = { + matter: "/config/matter", + mqtt: "/config/mqtt", + thread: "/config/thread", + zha: "/config/zha/dashboard", + zwave_js: "/config/zwave_js/dashboard", +}; + export type IntegrationType = | "device" | "helper" diff --git a/src/panels/config/devices/ha-config-device-page.ts b/src/panels/config/devices/ha-config-device-page.ts index 18813f5141..b3dbecd72c 100644 --- a/src/panels/config/devices/ha-config-device-page.ts +++ b/src/panels/config/devices/ha-config-device-page.ts @@ -9,7 +9,14 @@ import { mdiPlusCircle, } from "@mdi/js"; import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { + css, + CSSResultGroup, + html, + LitElement, + nothing, + TemplateResult, +} from "lit"; import { customElement, property, state } from "lit/decorators"; import { ifDefined } from "lit/directives/if-defined"; import memoizeOne from "memoize-one"; @@ -261,6 +268,9 @@ export class HaConfigDevicePage extends LitElement { } protected render() { + if (!this.devices || !this.deviceId) { + return nothing; + } const device = this._device(this.deviceId, this.devices); if (!device) { @@ -290,7 +300,29 @@ export class HaConfigDevicePage extends LitElement { : undefined; const area = this._computeArea(this.areas, device); - const deviceInfo: TemplateResult[] = []; + const deviceInfo: TemplateResult[] = integrations.map( + (integration) => + html` + + ${domainToName(this.hass.localize, + + ${domainToName(this.hass.localize, integration.domain)} + + + ` + ); const actions = [...(this._deviceActions || [])]; if (Array.isArray(this._diagnosticDownloadLinks)) { diff --git a/src/panels/config/devices/ha-config-devices-dashboard.ts b/src/panels/config/devices/ha-config-devices-dashboard.ts index e462a2c206..bd1402d0e1 100644 --- a/src/panels/config/devices/ha-config-devices-dashboard.ts +++ b/src/panels/config/devices/ha-config-devices-dashboard.ts @@ -128,6 +128,13 @@ export class HaConfigDeviceDashboard extends LitElement { ); break; } + case "domain": { + filterTexts.push( + `${this.hass.localize( + "ui.panel.config.integrations.integration" + )} "${domainToName(localize, value)}"` + ); + } } }); return filterTexts.length ? filterTexts : undefined; @@ -187,6 +194,15 @@ export class HaConfigDeviceDashboard extends LitElement { startLength = outputDevices.length; filterConfigEntry = entries.find((entry) => entry.entry_id === value); } + if (key === "domain") { + const entryIds = entries + .filter((entry) => entry.domain === value) + .map((entry) => entry.entry_id); + outputDevices = outputDevices.filter((device) => + device.config_entries.some((entryId) => entryIds.includes(entryId)) + ); + startLength = outputDevices.length; + } }); if (!showDisabled) { @@ -383,8 +399,11 @@ export class HaConfigDeviceDashboard extends LitElement { public willUpdate(changedProps) { if (changedProps.has("_searchParms")) { - if (this._searchParms.get("config_entry")) { - // If we are requested to show the devices for a given config entry, + if ( + this._searchParms.get("config_entry") || + this._searchParms.get("domain") + ) { + // If we are requested to show the devices for a given config entry / domain, // also show the disabled ones by default. this._showDisabled = true; } @@ -548,7 +567,9 @@ export class HaConfigDeviceDashboard extends LitElement { showMatterAddDeviceDialog(this); return; } - showAddIntegrationDialog(this); + showAddIntegrationDialog(this, { + domain: this._searchParms.get("domain") || undefined, + }); } private _showZJSAddDeviceDialog(filteredConfigEntry: ConfigEntry) { 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 f07961c98d..e2c48c0672 100644 --- a/src/panels/config/energy/components/ha-energy-grid-settings.ts +++ b/src/panels/config/energy/components/ha-energy-grid-settings.ts @@ -230,7 +230,7 @@ export class EnergyGridSettings extends LitElement { /> ${this._co2ConfigEntry.title} diff --git a/src/panels/config/entities/ha-config-entities.ts b/src/panels/config/entities/ha-config-entities.ts index f0970c14bc..2afab3ca2c 100644 --- a/src/panels/config/entities/ha-config-entities.ts +++ b/src/panels/config/entities/ha-config-entities.ts @@ -159,6 +159,14 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) { ); break; } + case "domain": { + this._showDisabled = true; + filterTexts.push( + `${this.hass.localize( + "ui.panel.config.integrations.integration" + )} "${domainToName(localize, value)}"` + ); + } } }); return filterTexts.length ? filterTexts : undefined; @@ -368,6 +376,22 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) { filteredDomains.push(configEntry.domain); } } + if (key === "domain") { + if (!entries) { + this._loadConfigEntries(); + return; + } + const entryIds = entries + .filter((entry) => entry.domain === value) + .map((entry) => entry.entry_id); + filteredEntities = filteredEntities.filter( + (entity) => + entity.config_entry_id && + entryIds.includes(entity.config_entry_id) + ); + filteredDomains.push(value); + startLength = filteredEntities.length; + } }); if (!showDisabled) { diff --git a/src/panels/config/integrations/ha-config-flow-card.ts b/src/panels/config/integrations/ha-config-flow-card.ts index d2005a10ff..8509ab2e5f 100644 --- a/src/panels/config/integrations/ha-config-flow-card.ts +++ b/src/panels/config/integrations/ha-config-flow-card.ts @@ -60,7 +60,7 @@ export class HaConfigFlowCard extends LitElement { }` )} > - + + configEntries + ? configEntries.filter((entry) => entry.domain === domain) + : [] + ); + + private _domainConfigEntriesInProgress = memoizeOne( + ( + domain: string, + configEntries?: DataEntryFlowProgressExtended[] + ): DataEntryFlowProgressExtended[] => + configEntries + ? configEntries.filter((entry) => entry.handler === domain) + : [] + ); + + public hassSubscribe(): UnsubscribeFunc[] { + return [ + subscribeEntityRegistry(this.hass.connection!, (entities) => { + this._entities = entities; + }), + subscribeLogInfo(this.hass.connection, (log_infos) => { + for (const log_info of log_infos) { + if (log_info.domain === this.domain) { + this._logInfo = log_info; + } + } + }), + ]; + } + + protected willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("domain")) { + this.hass.loadBackendTranslation("title", [this.domain]); + this._extraConfigEntries = undefined; + this._fetchManifest(); + this._fetchDiagnostics(); + } + } + + protected updated(changed: PropertyValues) { + super.updated(changed); + if ( + this._searchParms.has("config_entry") && + changed.has("configEntries") && + !changed.get("configEntries") && + this.configEntries + ) { + this._highlightEntry(); + } + } + + protected render() { + if (!this.configEntries || !this.domain) { + return nothing; + } + + const configEntries = this._domainConfigEntries( + this.domain, + this._extraConfigEntries || this.configEntries + ); + + const configEntriesInProgress = this._domainConfigEntriesInProgress( + this.domain, + this.configEntriesInProgress + ); + + const discoveryFlows = configEntriesInProgress.filter( + (flow) => !ATTENTION_SOURCES.includes(flow.context.source) + ); + + const attentionFlows = configEntriesInProgress.filter((flow) => + ATTENTION_SOURCES.includes(flow.context.source) + ); + + const attentionEntries = configEntries.filter((entry) => + ERROR_STATES.includes(entry.state) + ); + + return html` + +
+
+ +
+
+ ${domainToName(this.hass.localize, +
+ ${this._manifest?.is_built_in === false + ? html` + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.provided_by_custom_integration" + )}` + : ""} + ${this._manifest?.iot_class?.startsWith("cloud_") + ? html`${this.hass.localize( + "ui.panel.config.integrations.config_entry.depends_on_cloud" + )}` + : ""} +
+ +
+ ${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" + )} + + ` + : ""} + ${this._manifest + ? html` + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.documentation" + )} + + + + ` + : ""} + ${this._manifest && + (this._manifest.is_built_in || this._manifest.issue_tracker) + ? html` + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.known_issues" + )} + + + + ` + : ""} +
+
+
+
+ ${discoveryFlows.length + ? html` +

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

+ + ${discoveryFlows.map( + (flow) => html` + ${flow.localized_title} + + ` + )} + +
` + : ""} + ${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) => + this._renderConfigEntry(item) + )} + +
` + : ""} + + +

+ ${this.hass.localize( + `ui.panel.config.integrations.integration_page.entries` + )} +

+ ${configEntries.length === 0 + ? html`
+ ${this.hass.localize( + `ui.panel.config.integrations.integration_page.no_entries` + )} +
` + : nothing} + + ${configEntries + .filter((entry) => !ERROR_STATES.includes(entry.state)) + .sort((a, b) => { + if (Boolean(a.disabled_by) !== Boolean(b.disabled_by)) { + return a.disabled_by ? 1 : -1; + } + return caseInsensitiveStringCompare( + a.title, + b.title, + this.hass.locale.language + ); + }) + .map((item) => this._renderConfigEntry(item))} + +
+
+
+ + + +
+ `; + } + + private _onImageLoad(ev) { + ev.target.style.display = "inline-block"; + } + + private _onImageError(ev) { + ev.target.style.display = "none"; + } + + private _renderConfigEntry(item: ConfigEntry) { + let stateText: Parameters | undefined; + let stateTextExtra: TemplateResult | string | undefined; + let icon: string = mdiAlertCircle; + + if (item.disabled_by) { + stateText = [ + "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") { + stateTextExtra = html`. + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.disable_restart_confirm" + )}.`; + } + } else if (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}`, + ]; + if (item.reason) { + this.hass.loadBackendTranslation("config", item.domain); + stateTextExtra = html`: + ${this.hass.localize( + `component.${item.domain}.config.error.${item.reason}` + ) || item.reason}`; + } else { + stateTextExtra = html` +
+ ${this.hass.localize( + "ui.panel.config.integrations.config_entry.check_the_logs" + )} + `; + } + } + + const devices = this._getDevices(item, this.hass.devices); + const services = this._getServices(item, this.hass.devices); + const entities = this._getEntities(item, this._entities); + + 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}`; + 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 = ["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 html` + ${stateText + ? html` +
+ +
${this.hass.localize(...stateText)}
+ ${stateTextExtra + ? html`${stateTextExtra}` + : ""} +
+ ` + : ""} + ${item.title || domainToName(this.hass.localize, item.domain)} + ${devicesLine} + ${item.disabled_by === "user" + ? html` + ${this.hass.localize("ui.common.enable")} + ` + : item.domain in integrationsWithPanel && + (item.domain !== "matter" || isDevVersion(this.hass.config.version)) + ? html` + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.configure" + )} + ` + : item.supports_options && !stateText + ? html` + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.configure" + )} + + ` + : ""} + + + ${item.supports_options && stateText + ? html` + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.configure" + )} + ` + : ""} + ${!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" + )} + + ` + : ""} + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.rename" + )} + + + +
  • + + ${this._diagnosticHandler && item.state === "loaded" + ? html` + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.download_diagnostics" + )} + + + ` + : ""} + + + ${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")} + + ` + : ""} + ${item.source !== "system" + ? html` + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.delete" + )} + + ` + : ""} +
    +
    `; + } + + private async _highlightEntry() { + await nextRender(); + const entryId = this._searchParms.get("config_entry")!; + const row = this.shadowRoot!.querySelector( + `[data-entry-id="${entryId}"]` + ) as any; + if (row) { + row.scrollIntoView({ + block: "center", + }); + row.classList.add("highlight"); + } + } + + private _continueFlow(ev) { + showConfigFlowDialog(this, { + continueFlowId: ev.target.flow.flow_id, + dialogClosedCallback: () => { + // this._handleFlowUpdated(); + }, + }); + } + + private async _fetchManifest() { + if (!this.domain) { + return; + } + this._manifest = await fetchIntegrationManifest(this.hass, this.domain); + if ( + this._manifest.integration_type && + !["device", "hub", "service"].includes(this._manifest.integration_type) + ) { + this._extraConfigEntries = await getConfigEntries(this.hass, { + domain: this.domain, + }); + } + } + + private async _fetchDiagnostics() { + if (!this.domain || !isComponentLoaded(this.hass, "diagnostics")) { + return; + } + this._diagnosticHandler = await fetchDiagnosticHandler( + this.hass, + this.domain + ); + } + + private async _handleEnableDebugLogging() { + const integration = this.domain; + await setIntegrationLogLevel( + this.hass, + integration, + LogSeverity[LogSeverity.DEBUG], + "once" + ); + } + + private async _handleDisableDebugLogging(ev: Event) { + // Stop propagation since otherwise we end up here twice while we await the log level change + // and trigger two identical debug log downloads. + ev.stopPropagation(); + const integration = this.domain; + await setIntegrationLogLevel( + this.hass, + integration, + LogSeverity[LogSeverity.NOTSET], + "once" + ); + const timeString = new Date().toISOString().replace(/:/g, "-"); + const logFileName = `home-assistant_${integration}_${timeString}.log`; + const signedUrl = await getSignedPath(this.hass, getErrorLogDownloadUrl); + fileDownload(signedUrl.path, logFileName); + } + + private _getEntities = memoizeOne( + ( + configEntry: ConfigEntry, + entityRegistryEntries: EntityRegistryEntry[] + ): EntityRegistryEntry[] => { + if (!entityRegistryEntries) { + return []; + } + return entityRegistryEntries.filter( + (entity) => entity.config_entry_id === configEntry.entry_id + ); + } + ); + + private _getDevices = memoizeOne( + ( + configEntry: ConfigEntry, + deviceRegistryEntries: HomeAssistant["devices"] + ): DeviceRegistryEntry[] => { + if (!deviceRegistryEntries) { + return []; + } + return Object.values(deviceRegistryEntries).filter( + (device) => + device.config_entries.includes(configEntry.entry_id) && + device.entry_type !== "service" + ); + } + ); + + private _getServices = memoizeOne( + ( + configEntry: ConfigEntry, + deviceRegistryEntries: HomeAssistant["devices"] + ): DeviceRegistryEntry[] => { + if (!deviceRegistryEntries) { + return []; + } + 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, + this._manifest + ); + } + + private _handleRename(ev: CustomEvent): void { + if (!shouldHandleRequestSelectedEvent(ev)) { + return; + } + this._editEntryName( + ((ev.target as HTMLElement).closest(".config_entry") as any).configEntry + ); + } + + private _handleReload(ev: CustomEvent): void { + if (!shouldHandleRequestSelectedEvent(ev)) { + return; + } + this._reloadIntegration( + ((ev.target as HTMLElement).closest(".config_entry") as any).configEntry + ); + } + + private _handleDelete(ev: CustomEvent): void { + if (!shouldHandleRequestSelectedEvent(ev)) { + return; + } + this._removeIntegration( + ((ev.target as HTMLElement).closest(".config_entry") as any).configEntry + ); + } + + private _handleDisable(ev: CustomEvent): void { + if (!shouldHandleRequestSelectedEvent(ev)) { + return; + } + this._disableIntegration( + ((ev.target as HTMLElement).closest(".config_entry") as any).configEntry + ); + } + + private _handleEnable(ev: CustomEvent): void { + if (ev.detail.source && !shouldHandleRequestSelectedEvent(ev)) { + return; + } + this._enableIntegration( + ((ev.target as HTMLElement).closest(".config_entry") as any).configEntry + ); + } + + private _handleSystemOptions(ev: CustomEvent): void { + if (!shouldHandleRequestSelectedEvent(ev)) { + return; + } + 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 _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.target.closest("a"); + ev.preventDefault(); + const signedUrl = await getSignedPath( + this.hass, + anchor.getAttribute("href") + ); + fileDownload(signedUrl.path); + } + + private async _addIntegration() { + showAddIntegrationDialog(this, { + domain: this.domain, + }); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + .container { + display: flex; + flex-wrap: wrap; + margin: auto; + max-width: 1000px; + margin-top: 32px; + margin-bottom: 32px; + } + .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; + } + .card-header { + padding-bottom: 0; + } + .card-content { + padding-top: 12px; + } + .logo-container { + display: flex; + justify-content: center; + } + .card-actions { + padding: 0; + } + img { + width: 200px; + } + ha-alert { + display: block; + margin-top: 4px; + } + ha-list-item.discovered { + --mdc-list-item-meta-size: auto; + --mdc-list-item-meta-display: flex; + height: 72px; + } + ha-list-item.config_entry { + overflow: visible; + --mdc-list-item-meta-size: auto; + --mdc-list-item-meta-display: flex; + } + ha-list-item.config_entry::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + opacity: 0.12; + pointer-events: none; + content: ""; + } + ha-button-menu { + flex: 0; + } + a { + text-decoration: none; + } + .highlight::after { + background-color: var(--info-color); + } + .attention { + primary-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); + } + .state-failed::after { + background-color: var(--warning-color); + } + .state-not-loaded { + --state-message-color: var(--primary-text-color); + } + .state-setup { + --state-message-color: var(--secondary-text-color); + } + .message { + font-weight: bold; + display: flex; + align-items: center; + } + .message ha-svg-icon { + color: var(--state-message-color); + } + .message div { + flex: 1; + margin-left: 8px; + padding-top: 2px; + padding-right: 2px; + overflow-wrap: break-word; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 7; + overflow: hidden; + text-overflow: ellipsis; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-integration-page": HaConfigIntegrationPage; + } +} diff --git a/src/panels/config/integrations/ha-config-integrations-dashboard.ts b/src/panels/config/integrations/ha-config-integrations-dashboard.ts new file mode 100644 index 0000000000..5524c31d26 --- /dev/null +++ b/src/panels/config/integrations/ha-config-integrations-dashboard.ts @@ -0,0 +1,862 @@ +import { ActionDetail } from "@material/mwc-list"; +import { mdiFilterVariant, mdiPlus } from "@mdi/js"; +import Fuse from "fuse.js"; +import type { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { + css, + CSSResultGroup, + html, + LitElement, + nothing, + PropertyValues, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { ifDefined } from "lit/directives/if-defined"; +import memoizeOne from "memoize-one"; +import { isComponentLoaded } from "../../../common/config/is_component_loaded"; +import { + protocolIntegrationPicked, + PROTOCOL_INTEGRATIONS, +} from "../../../common/integrations/protocolIntegrationPicked"; +import { navigate } from "../../../common/navigate"; +import { extractSearchParam } from "../../../common/url/search-params"; +import { nextRender } from "../../../common/util/render-status"; +import "../../../components/ha-button-menu"; +import "../../../components/ha-check-list-item"; +import "../../../components/ha-checkbox"; +import "../../../components/ha-fab"; +import "../../../components/ha-icon-button"; +import "../../../components/ha-svg-icon"; +import "../../../components/search-input"; +import { ConfigEntry } from "../../../data/config_entries"; +import { getConfigFlowInProgressCollection } from "../../../data/config_flow"; +import { fetchDiagnosticHandlers } from "../../../data/diagnostics"; +import { + EntityRegistryEntry, + subscribeEntityRegistry, +} from "../../../data/entity_registry"; +import { + domainToName, + fetchIntegrationManifest, + fetchIntegrationManifests, + IntegrationLogInfo, + IntegrationManifest, + subscribeLogInfo, +} from "../../../data/integration"; +import { + findIntegration, + getIntegrationDescriptions, +} from "../../../data/integrations"; +import { scanUSBDevices } from "../../../data/usb"; +import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow"; +import { + showAlertDialog, + showConfirmationDialog, +} from "../../../dialogs/generic/show-dialog-box"; +import "../../../layouts/hass-loading-screen"; +import "../../../layouts/hass-tabs-subpage"; +import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; +import { haStyle } from "../../../resources/styles"; +import type { HomeAssistant, Route } from "../../../types"; +import { configSections } from "../ha-panel-config"; +import { isHelperDomain } from "../helpers/const"; +import "./ha-config-flow-card"; +import { DataEntryFlowProgressExtended } from "./ha-config-integrations"; +import "./ha-ignored-config-entry-card"; +import "./ha-integration-card"; +import type { HaIntegrationCard } from "./ha-integration-card"; +import "./ha-integration-overflow-menu"; +import { showAddIntegrationDialog } from "./show-add-integration-dialog"; +import "./ha-disabled-config-entry-card"; +import { caseInsensitiveStringCompare } from "../../../common/string/compare"; + +export interface ConfigEntryExtended extends ConfigEntry { + localized_domain_name?: string; +} + +const groupByIntegration = ( + entries: ConfigEntryExtended[] +): Map => { + const result = new Map(); + entries.forEach((entry) => { + if (result.has(entry.domain)) { + result.get(entry.domain).push(entry); + } else { + result.set(entry.domain, [entry]); + } + }); + return result; +}; +@customElement("ha-config-integrations-dashboard") +class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean, reflect: true }) public narrow!: boolean; + + @property() public isWide!: boolean; + + @property() public showAdvanced!: boolean; + + @property() public route!: Route; + + @property({ attribute: false }) public configEntries?: ConfigEntryExtended[]; + + @property({ attribute: false }) + public configEntriesInProgress?: DataEntryFlowProgressExtended[]; + + @state() + private _entityRegistryEntries: EntityRegistryEntry[] = []; + + @state() + private _manifests: Record = {}; + + private _extraFetchedManifests?: Set; + + @state() private _showIgnored = false; + + @state() private _showDisabled = false; + + @state() private _searchParms = new URLSearchParams( + window.location.hash.substring(1) + ); + + @state() private _filter: string = history.state?.filter || ""; + + @state() private _diagnosticHandlers?: Record; + + @state() private _logInfos?: { + [integration: string]: IntegrationLogInfo; + }; + + public hassSubscribe(): Array> { + return [ + subscribeEntityRegistry(this.hass.connection, (entries) => { + this._entityRegistryEntries = entries; + }), + subscribeLogInfo(this.hass.connection, (log_infos) => { + const logInfoLookup: { [integration: string]: IntegrationLogInfo } = {}; + for (const log_info of log_infos) { + logInfoLookup[log_info.domain] = log_info; + } + this._logInfos = logInfoLookup; + }), + ]; + } + + private _filterConfigEntries = memoizeOne( + ( + configEntries: ConfigEntryExtended[], + filter?: string + ): [ + [string, ConfigEntryExtended[]][], + ConfigEntryExtended[], + ConfigEntryExtended[] + ] => { + let filteredConfigEntries: ConfigEntryExtended[]; + const ignored: ConfigEntryExtended[] = []; + const disabled: ConfigEntryExtended[] = []; + const integrations: ConfigEntryExtended[] = []; + if (filter) { + const options: Fuse.IFuseOptions = { + keys: ["domain", "localized_domain_name", "title"], + isCaseSensitive: false, + minMatchCharLength: 2, + threshold: 0.2, + }; + const fuse = new Fuse(configEntries, options); + filteredConfigEntries = fuse + .search(filter) + .map((result) => result.item); + } else { + filteredConfigEntries = configEntries; + } + + for (const entry of filteredConfigEntries) { + if (entry.source === "ignore") { + ignored.push(entry); + } else if (entry.disabled_by !== null) { + disabled.push(entry); + } else { + integrations.push(entry); + } + } + return [ + Array.from(groupByIntegration(integrations)).sort((groupA, groupB) => + caseInsensitiveStringCompare( + groupA[1][0].localized_domain_name || groupA[0], + groupB[1][0].localized_domain_name || groupB[0], + this.hass.locale.language + ) + ), + ignored, + disabled, + ]; + } + ); + + private _filterConfigEntriesInProgress = memoizeOne( + ( + configEntriesInProgress: DataEntryFlowProgressExtended[], + filter?: string + ): DataEntryFlowProgressExtended[] => { + let filteredEntries: DataEntryFlowProgressExtended[]; + if (filter) { + const options: Fuse.IFuseOptions = { + keys: ["handler", "localized_title"], + isCaseSensitive: false, + minMatchCharLength: 2, + threshold: 0.2, + }; + const fuse = new Fuse(configEntriesInProgress, options); + filteredEntries = fuse.search(filter).map((result) => result.item); + } else { + filteredEntries = configEntriesInProgress; + } + return filteredEntries.sort((a, b) => + caseInsensitiveStringCompare( + a.localized_title || a.handler, + b.localized_title || b.handler, + this.hass.locale.language + ) + ); + } + ); + + protected firstUpdated(changed: PropertyValues) { + super.firstUpdated(changed); + this._fetchManifests(); + if (this.route.path === "/add") { + this._handleAdd(); + } + this._scanUSBDevices(); + if (isComponentLoaded(this.hass, "diagnostics")) { + fetchDiagnosticHandlers(this.hass).then((infos) => { + const handlers = {}; + for (const info of infos) { + handlers[info.domain] = info.handlers.config_entry; + } + this._diagnosticHandlers = handlers; + }); + } + } + + protected updated(changed: PropertyValues) { + super.updated(changed); + if ( + (this._searchParms.has("config_entry") || + this._searchParms.has("domain")) && + changed.has("configEntries") && + !changed.get("configEntries") && + this.configEntries + ) { + this._highlightEntry(); + } + if ( + changed.has("configEntriesInProgress") && + this.configEntriesInProgress + ) { + this._fetchIntegrationManifests( + this.configEntriesInProgress.map((flow) => flow.handler) + ); + } + } + + protected render() { + if (!this.configEntries || !this.configEntriesInProgress) { + return html``; + } + const [integrations, ignoredConfigEntries, disabledConfigEntries] = + this._filterConfigEntries(this.configEntries, this._filter); + const configEntriesInProgress = this._filterConfigEntriesInProgress( + this.configEntriesInProgress, + this._filter + ); + + const filterMenu = html` +
    + + ${this.narrow + ? html` + + ` + : ""} +
    + `; + + return html` + + ${this.narrow + ? html` +
    + +
    + ${filterMenu} + ` + : html` + + + `} + +
    + ${this._showIgnored + ? ignoredConfigEntries.map( + (entry: ConfigEntryExtended) => html` + + ` + ) + : ""} + ${configEntriesInProgress.length + ? configEntriesInProgress.map( + (flow: DataEntryFlowProgressExtended) => html` + + ` + ) + : ""} + ${this._showDisabled + ? disabledConfigEntries.map( + (entry: ConfigEntryExtended) => html` + + ` + ) + : ""} + ${integrations.length + ? integrations.map( + ([domain, items]) => + html`` + ) + : this._filter && + !configEntriesInProgress.length && + !integrations.length && + this.configEntries.length + ? html` +
    +

    + ${this.hass.localize( + "ui.panel.config.integrations.none_found" + )} +

    +

    + ${this.hass.localize( + "ui.panel.config.integrations.none_found_detail" + )} +

    + +
    + ` + : // If we have a filter, never show a card + this._filter + ? "" + : // If we're showing 0 cards, show empty state text + (!this._showIgnored || ignoredConfigEntries.length === 0) && + (!this._showDisabled || disabledConfigEntries.length === 0) && + integrations.length === 0 + ? html` +
    +

    + ${this.hass.localize("ui.panel.config.integrations.none")} +

    +

    + ${this.hass.localize( + "ui.panel.config.integrations.no_integrations" + )} +

    + +
    + ` + : ""} +
    + + + +
    + `; + } + + private _preventDefault(ev) { + ev.preventDefault(); + } + + private async _scanUSBDevices() { + if (!isComponentLoaded(this.hass, "usb")) { + return; + } + await scanUSBDevices(this.hass); + } + + private async _fetchManifests(integrations?: string[]) { + const fetched = await fetchIntegrationManifests(this.hass, integrations); + // Make a copy so we can keep track of previously loaded manifests + // for discovered flows (which are not part of these results) + const manifests = { ...this._manifests }; + for (const manifest of fetched) { + manifests[manifest.domain] = manifest; + } + this._manifests = manifests; + } + + private async _fetchIntegrationManifests(integrations: string[]) { + const manifestsToFetch: string[] = []; + for (const integration of integrations) { + if (integration in this._manifests) { + continue; + } + if (this._extraFetchedManifests) { + if (this._extraFetchedManifests.has(integration)) { + continue; + } + } else { + this._extraFetchedManifests = new Set(); + } + this._extraFetchedManifests.add(integration); + manifestsToFetch.push(integration); + } + if (manifestsToFetch.length) { + await this._fetchManifests(manifestsToFetch); + } + } + + private _handleFlowUpdated() { + getConfigFlowInProgressCollection(this.hass.connection).refresh(); + this._fetchManifests(); + } + + private _createFlow() { + showAddIntegrationDialog(this, { + initialFilter: this._filter, + }); + } + + private _handleMenuAction(ev: CustomEvent) { + switch (ev.detail.index) { + case 0: + this._showIgnored = !this._showIgnored; + break; + case 1: + this._toggleShowDisabled(); + break; + } + } + + private _toggleShowDisabled() { + this._showDisabled = !this._showDisabled; + } + + private _handleSearchChange(ev: CustomEvent) { + this._filter = ev.detail.value; + history.replaceState({ filter: this._filter }, ""); + } + + private async _highlightEntry() { + await nextRender(); + const entryId = this._searchParms.get("config_entry"); + let domain: string | null; + if (entryId) { + const configEntry = this.configEntries!.find( + (entry) => entry.entry_id === entryId + ); + if (!configEntry) { + return; + } + domain = configEntry.domain; + } else { + domain = this._searchParms.get("domain"); + } + const card: HaIntegrationCard = this.shadowRoot!.querySelector( + `[data-domain=${domain}]` + ) as HaIntegrationCard; + if (card) { + card.scrollIntoView({ + block: "center", + }); + card.classList.add("highlight"); + } + } + + private async _handleAdd() { + const brand = extractSearchParam("brand"); + const domain = extractSearchParam("domain"); + navigate("/config/integrations", { replace: true }); + + if (brand) { + showAddIntegrationDialog(this, { + brand, + }); + return; + } + if (!domain) { + return; + } + + const descriptions = await getIntegrationDescriptions(this.hass); + const integrations = { + ...descriptions.core.integration, + ...descriptions.custom.integration, + }; + + const integration = findIntegration(integrations, domain); + + if (integration?.config_flow) { + // Integration exists, so we can just create a flow + const localize = await this.hass.loadBackendTranslation( + "title", + domain, + false + ); + if ( + await showConfirmationDialog(this, { + title: localize("ui.panel.config.integrations.confirm_new", { + integration: integration.name || domainToName(localize, domain), + }), + }) + ) { + showAddIntegrationDialog(this, { + domain, + }); + } + return; + } + + if (integration?.supported_by) { + // Integration is a alias, so we can just create a flow + const localize = await this.hass.loadBackendTranslation( + "title", + domain, + false + ); + const supportedIntegration = findIntegration( + integrations, + integration.supported_by + ); + + if (!supportedIntegration) { + return; + } + + showConfirmationDialog(this, { + text: this.hass.localize( + "ui.panel.config.integrations.config_flow.supported_brand_flow", + { + supported_brand: integration.name || domainToName(localize, domain), + flow_domain_name: + supportedIntegration.name || + domainToName(localize, integration.supported_by), + } + ), + confirm: async () => { + if ( + (PROTOCOL_INTEGRATIONS as ReadonlyArray).includes( + integration.supported_by! + ) + ) { + protocolIntegrationPicked( + this, + this.hass, + integration.supported_by! + ); + return; + } + showConfigFlowDialog(this, { + dialogClosedCallback: () => { + this._handleFlowUpdated(); + }, + startFlowHandler: integration.supported_by, + manifest: await fetchIntegrationManifest( + this.hass, + integration.supported_by! + ), + showAdvanced: this.hass.userData?.showAdvanced, + }); + }, + }); + return; + } + + // If not an integration or supported brand, try helper else show alert + if (isHelperDomain(domain)) { + navigate(`/config/helpers/add?domain=${domain}`, { + replace: true, + }); + return; + } + const helpers = { + ...descriptions.core.helper, + ...descriptions.custom.helper, + }; + const helper = findIntegration(helpers, domain); + if (helper) { + navigate(`/config/helpers/add?domain=${domain}`, { + replace: true, + }); + return; + } + showAlertDialog(this, { + title: this.hass.localize( + "ui.panel.config.integrations.config_flow.error" + ), + text: this.hass.localize( + "ui.panel.config.integrations.config_flow.no_config_flow" + ), + }); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + :host([narrow]) hass-tabs-subpage { + --main-title-margin: 0; + } + ha-button-menu { + margin-left: 8px; + margin-inline-start: 8px; + margin-inline-end: initial; + direction: var(--direction); + } + .container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + grid-gap: 16px 16px; + padding: 8px 16px 16px; + margin-bottom: 64px; + } + .container > * { + max-width: 500px; + } + .empty-message { + margin: auto; + text-align: center; + } + .empty-message h1 { + margin-bottom: 0; + } + search-input { + --mdc-text-field-fill-color: var(--sidebar-background-color); + --mdc-text-field-idle-line-color: var(--divider-color); + --text-field-overflow: visible; + } + search-input.header { + display: block; + color: var(--secondary-text-color); + margin-left: 8px; + margin-inline-start: 8px; + margin-inline-end: initial; + direction: var(--direction); + --mdc-ripple-color: transparant; + } + .search { + display: flex; + justify-content: flex-end; + width: 100%; + align-items: center; + height: 56px; + position: sticky; + top: 0; + z-index: 2; + } + .search search-input { + display: block; + position: absolute; + top: 0; + right: 0; + left: 0; + } + .filters { + --mdc-text-field-fill-color: var(--input-fill-color); + --mdc-text-field-idle-line-color: var(--input-idle-line-color); + --mdc-shape-small: 4px; + --text-field-overflow: initial; + display: flex; + justify-content: flex-end; + color: var(--primary-text-color); + } + .active-filters { + color: var(--primary-text-color); + position: relative; + display: flex; + align-items: center; + padding-top: 2px; + padding-bottom: 2px; + padding-right: 2px; + padding-left: 8px; + padding-inline-start: 8px; + padding-inline-end: 2px; + font-size: 14px; + width: max-content; + cursor: initial; + direction: var(--direction); + } + .active-filters mwc-button { + margin-left: 8px; + margin-inline-start: 8px; + margin-inline-end: initial; + direction: var(--direction); + } + .active-filters::before { + background-color: var(--primary-color); + opacity: 0.12; + border-radius: 4px; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + content: ""; + } + .badge { + min-width: 20px; + box-sizing: border-box; + border-radius: 50%; + font-weight: 400; + background-color: var(--primary-color); + line-height: 20px; + text-align: center; + padding: 0px 4px; + color: var(--text-primary-color); + position: absolute; + right: 0px; + top: 4px; + font-size: 0.65em; + } + .menu-badge-container { + position: relative; + } + ha-button-menu { + color: var(--primary-text-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-integrations-dashboard": HaConfigIntegrationsDashboard; + } +} diff --git a/src/panels/config/integrations/ha-config-integrations.ts b/src/panels/config/integrations/ha-config-integrations.ts index d02d4b0b83..9bdf8465d0 100644 --- a/src/panels/config/integrations/ha-config-integrations.ts +++ b/src/panels/config/integrations/ha-config-integrations.ts @@ -1,81 +1,26 @@ -import { ActionDetail } from "@material/mwc-list"; -import { mdiFilterVariant, mdiPlus } from "@mdi/js"; -import Fuse from "fuse.js"; -import type { UnsubscribeFunc } from "home-assistant-js-websocket"; -import { - css, - CSSResultGroup, - html, - LitElement, - nothing, - PropertyValues, -} from "lit"; +import { PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { ifDefined } from "lit/directives/if-defined"; -import memoizeOne from "memoize-one"; -import { isComponentLoaded } from "../../../common/config/is_component_loaded"; -import { - protocolIntegrationPicked, - PROTOCOL_INTEGRATIONS, -} from "../../../common/integrations/protocolIntegrationPicked"; import { navigate } from "../../../common/navigate"; -import { caseInsensitiveStringCompare } from "../../../common/string/compare"; -import type { LocalizeFunc } from "../../../common/translations/localize"; -import { extractSearchParam } from "../../../common/url/search-params"; -import { nextRender } from "../../../common/util/render-status"; -import "../../../components/ha-button-menu"; -import "../../../components/ha-check-list-item"; -import "../../../components/ha-checkbox"; -import "../../../components/ha-fab"; -import "../../../components/ha-icon-button"; -import "../../../components/ha-svg-icon"; -import "../../../components/search-input"; import { ConfigEntry, subscribeConfigEntries, } from "../../../data/config_entries"; import { - getConfigFlowInProgressCollection, localizeConfigFlowTitle, subscribeConfigFlowInProgress, } from "../../../data/config_flow"; -import type { DataEntryFlowProgress } from "../../../data/data_entry_flow"; -import { fetchDiagnosticHandlers } from "../../../data/diagnostics"; -import { - EntityRegistryEntry, - subscribeEntityRegistry, -} from "../../../data/entity_registry"; -import { - domainToName, - fetchIntegrationManifest, - fetchIntegrationManifests, - IntegrationLogInfo, - IntegrationManifest, - subscribeLogInfo, -} from "../../../data/integration"; -import { - findIntegration, - getIntegrationDescriptions, -} from "../../../data/integrations"; -import { scanUSBDevices } from "../../../data/usb"; -import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow"; -import { - showAlertDialog, - showConfirmationDialog, -} from "../../../dialogs/generic/show-dialog-box"; +import { DataEntryFlowProgress } from "../../../data/data_entry_flow"; +import { domainToName } from "../../../data/integration"; import "../../../layouts/hass-loading-screen"; -import "../../../layouts/hass-tabs-subpage"; +import { + HassRouterPage, + RouterOptions, +} from "../../../layouts/hass-router-page"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; -import { haStyle } from "../../../resources/styles"; -import type { HomeAssistant, Route } from "../../../types"; -import { configSections } from "../ha-panel-config"; -import { isHelperDomain } from "../helpers/const"; -import "./ha-config-flow-card"; -import "./ha-ignored-config-entry-card"; -import "./ha-integration-card"; -import type { HaIntegrationCard } from "./ha-integration-card"; -import "./ha-integration-overflow-menu"; -import { showAddIntegrationDialog } from "./show-add-integration-dialog"; +import type { HomeAssistant } from "../../../types"; + +import "./ha-config-integration-page"; +import "./ha-config-integrations-dashboard"; export interface ConfigEntryUpdatedEvent { entry: ConfigEntry; @@ -85,6 +30,10 @@ export interface ConfigEntryRemovedEvent { entryId: string; } +export interface DataEntryFlowProgressExtended extends DataEntryFlowProgress { + localized_title?: string; +} + declare global { // for fire event interface HASSDomEvents { @@ -93,30 +42,12 @@ declare global { } } -export interface DataEntryFlowProgressExtended extends DataEntryFlowProgress { - localized_title?: string; -} - export interface ConfigEntryExtended extends ConfigEntry { localized_domain_name?: string; } -const groupByIntegration = ( - entries: ConfigEntryExtended[] -): Map => { - const result = new Map(); - entries.forEach((entry) => { - if (result.has(entry.domain)) { - result.get(entry.domain).push(entry); - } else { - result.set(entry.domain, [entry]); - } - }); - return result; -}; - @customElement("ha-config-integrations") -class HaConfigIntegrations extends SubscribeMixin(LitElement) { +class HaConfigIntegrations extends SubscribeMixin(HassRouterPage) { @property({ attribute: false }) public hass!: HomeAssistant; @property({ type: Boolean, reflect: true }) public narrow!: boolean; @@ -125,66 +56,32 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { @property() public showAdvanced!: boolean; - @property() public route!: Route; + protected routerOptions: RouterOptions = { + defaultPage: "dashboard", + routes: { + dashboard: { + tag: "ha-config-integrations-dashboard", + cache: true, + }, + integration: { + tag: "ha-config-integration-page", + }, + }, + }; @state() private _configEntries?: ConfigEntryExtended[]; @property() - private _configEntriesInProgress: DataEntryFlowProgressExtended[] = []; + private _configEntriesInProgress?: DataEntryFlowProgressExtended[]; - @state() - private _entityRegistryEntries: EntityRegistryEntry[] = []; + private _loadTranslationsPromise?: Promise; - @state() - private _manifests: Record = {}; - - private _extraFetchedManifests?: Set; - - @state() private _showIgnored = false; - - @state() private _showDisabled = false; - - @state() private _searchParms = new URLSearchParams( - window.location.hash.substring(1) - ); - - @state() private _filter: string = history.state?.filter || ""; - - @state() private _diagnosticHandlers?: Record; - - @state() private _logInfos?: { - [integration: string]: IntegrationLogInfo; - }; - - public hassSubscribe(): Array> { + public hassSubscribe() { return [ - subscribeEntityRegistry(this.hass.connection, (entries) => { - this._entityRegistryEntries = entries; - }), - subscribeConfigFlowInProgress(this.hass, async (flowsInProgress) => { - const integrations: Set = new Set(); - const manifests: Set = new Set(); - flowsInProgress.forEach((flow) => { - // To render title placeholders - if (flow.context.title_placeholders) { - integrations.add(flow.handler); - } - manifests.add(flow.handler); - }); - await this.hass.loadBackendTranslation( - "config", - Array.from(integrations) - ); - this._fetchIntegrationManifests(manifests); - await nextRender(); - this._configEntriesInProgress = flowsInProgress.map((flow) => ({ - ...flow, - localized_title: localizeConfigFlowTitle(this.hass.localize, flow), - })); - }), subscribeConfigEntries( this.hass, - (messages) => { + async (messages) => { + await this._loadTranslationsPromise; let fullUpdate = false; const newEntries: ConfigEntryExtended[] = []; messages.forEach((message) => { @@ -219,718 +116,60 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { return; } const existingEntries = fullUpdate ? [] : this._configEntries; - this._configEntries = [...existingEntries!, ...newEntries].sort( - (conf1, conf2) => - caseInsensitiveStringCompare( - conf1.localized_domain_name + conf1.title, - conf2.localized_domain_name + conf2.title, - this.hass.locale.language - ) - ); + this._configEntries = [...existingEntries!, ...newEntries]; }, { type: ["device", "hub", "service"] } ), - subscribeLogInfo(this.hass.connection, (log_infos) => { - const logInfoLookup: { [integration: string]: IntegrationLogInfo } = {}; - for (const log_info of log_infos) { - logInfoLookup[log_info.domain] = log_info; - } - this._logInfos = logInfoLookup; + subscribeConfigFlowInProgress(this.hass, async (flowsInProgress) => { + const integrations: Set = new Set(); + flowsInProgress.forEach((flow) => { + // To render title placeholders + if (flow.context.title_placeholders) { + integrations.add(flow.handler); + } + }); + await this.hass.loadBackendTranslation( + "config", + Array.from(integrations) + ); + this._configEntriesInProgress = flowsInProgress.map((flow) => ({ + ...flow, + localized_title: localizeConfigFlowTitle(this.hass.localize, flow), + })); }), ]; } - private _filterConfigEntries = memoizeOne( - ( - configEntries: ConfigEntryExtended[], - filter?: string - ): ConfigEntryExtended[] => { - if (!filter) { - return [...configEntries]; - } - const options: Fuse.IFuseOptions = { - keys: ["domain", "localized_domain_name", "title"], - isCaseSensitive: false, - minMatchCharLength: 2, - threshold: 0.2, - }; - const fuse = new Fuse(configEntries, options); - return fuse.search(filter).map((result) => result.item); - } - ); - - private _filterGroupConfigEntries = memoizeOne( - ( - configEntries: ConfigEntryExtended[], - filter?: string - ): [ - Map, - ConfigEntryExtended[], - Map, - // Counter for disabled integrations since the tuple element above will - // be grouped by the integration name and therefore not provide a valid count - number - ] => { - const filteredConfigEnties = this._filterConfigEntries( - configEntries, - filter - ); - const ignored: ConfigEntryExtended[] = []; - const disabled: ConfigEntryExtended[] = []; - for (let i = filteredConfigEnties.length - 1; i >= 0; i--) { - if (filteredConfigEnties[i].source === "ignore") { - ignored.push(filteredConfigEnties.splice(i, 1)[0]); - } else if (filteredConfigEnties[i].disabled_by !== null) { - disabled.push(filteredConfigEnties.splice(i, 1)[0]); - } - } - return [ - groupByIntegration(filteredConfigEnties), - ignored, - groupByIntegration(disabled), - disabled.length, - ]; - } - ); - - private _filterConfigEntriesInProgress = memoizeOne( - ( - configEntriesInProgress: DataEntryFlowProgressExtended[], - filter?: string - ): DataEntryFlowProgressExtended[] => { - if (!filter) { - return configEntriesInProgress; - } - const options: Fuse.IFuseOptions = { - keys: ["handler", "localized_title"], - isCaseSensitive: false, - minMatchCharLength: 2, - threshold: 0.2, - }; - const fuse = new Fuse(configEntriesInProgress, options); - return fuse.search(filter).map((result) => result.item); - } - ); - protected firstUpdated(changed: PropertyValues) { super.firstUpdated(changed); - const localizePromise = this.hass.loadBackendTranslation( + this._loadTranslationsPromise = this.hass.loadBackendTranslation( "title", undefined, true ); - this._fetchManifests(); - if (this.route.path === "/add") { - this._handleAdd(localizePromise); - } - this._scanUSBDevices(); - if (isComponentLoaded(this.hass, "diagnostics")) { - fetchDiagnosticHandlers(this.hass).then((infos) => { - const handlers = {}; - for (const info of infos) { - handlers[info.domain] = info.handlers.config_entry; + } + + protected updatePageEl(pageEl) { + pageEl.hass = this.hass; + + if (this._currentPage === "integration") { + if (this.routeTail.path) { + pageEl.domain = this.routeTail.path.substring(1); + } else if (window.location.search) { + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.has("domain")) { + const domain = urlParams.get("domain"); + pageEl.domain = domain; + navigate(`/config/integrations/integration/${domain}`); } - this._diagnosticHandlers = handlers; - }); - } - } - - protected updated(changed: PropertyValues) { - super.updated(changed); - if ( - this._searchParms.has("config_entry") && - changed.has("_configEntries") && - !changed.get("_configEntries") && - this._configEntries - ) { - this._highlightEntry(); - } - } - - protected render() { - if (!this._configEntries) { - return html``; - } - const [ - groupedConfigEntries, - ignoredConfigEntries, - disabledConfigEntries, - disabledCount, - ] = this._filterGroupConfigEntries(this._configEntries, this._filter); - const configEntriesInProgress = this._filterConfigEntriesInProgress( - this._configEntriesInProgress, - this._filter - ); - - const filterMenu = html` -
    - - ${this.narrow - ? html` - - ` - : ""} -
    - `; - - return html` - - ${this.narrow - ? html` -
    - -
    - ${filterMenu} - ` - : html` - - - `} - -
    - ${this._showIgnored - ? ignoredConfigEntries.map( - (entry: ConfigEntryExtended) => html` - - ` - ) - : ""} - ${configEntriesInProgress.length - ? configEntriesInProgress.map( - (flow: DataEntryFlowProgressExtended) => html` - - ` - ) - : ""} - ${this._showDisabled - ? Array.from(disabledConfigEntries.entries()).map( - ([domain, items]) => - html` ` - ) - : ""} - ${groupedConfigEntries.size - ? Array.from(groupedConfigEntries.entries()).map( - ([domain, items]) => - html`` - ) - : this._filter && - !configEntriesInProgress.length && - !groupedConfigEntries.size && - this._configEntries.length - ? html` -
    -

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

    -

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

    - -
    - ` - : // If we have a filter, never show a card - this._filter - ? "" - : // If we're showing 0 cards, show empty state text - (!this._showIgnored || ignoredConfigEntries.length === 0) && - (!this._showDisabled || disabledConfigEntries.size === 0) && - groupedConfigEntries.size === 0 - ? html` -
    -

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

    -

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

    - -
    - ` - : ""} -
    - - - -
    - `; - } - - private _preventDefault(ev) { - ev.preventDefault(); - } - - private async _scanUSBDevices() { - if (!isComponentLoaded(this.hass, "usb")) { - return; - } - await scanUSBDevices(this.hass); - } - - private async _fetchManifests(integrations?: string[]) { - const fetched = await fetchIntegrationManifests(this.hass, integrations); - // Make a copy so we can keep track of previously loaded manifests - // for discovered flows (which are not part of these results) - const manifests = { ...this._manifests }; - for (const manifest of fetched) { - manifests[manifest.domain] = manifest; - } - this._manifests = manifests; - } - - private async _fetchIntegrationManifests(integrations: Set) { - const manifestsToFetch: string[] = []; - for (const integration of integrations) { - if (integration in this._manifests) { - continue; } - if (this._extraFetchedManifests) { - if (this._extraFetchedManifests.has(integration)) { - continue; - } - } else { - this._extraFetchedManifests = new Set(); - } - this._extraFetchedManifests.add(integration); - manifestsToFetch.push(integration); } - if (manifestsToFetch.length) { - await this._fetchManifests(manifestsToFetch); - } - } - - private _handleFlowUpdated() { - getConfigFlowInProgressCollection(this.hass.connection).refresh(); - this._fetchManifests(); - } - - private _createFlow() { - showAddIntegrationDialog(this, { - initialFilter: this._filter, - }); - } - - private _handleMenuAction(ev: CustomEvent) { - switch (ev.detail.index) { - case 0: - this._showIgnored = !this._showIgnored; - break; - case 1: - this._toggleShowDisabled(); - break; - } - } - - private _toggleShowDisabled() { - this._showDisabled = !this._showDisabled; - } - - private _handleSearchChange(ev: CustomEvent) { - this._filter = ev.detail.value; - history.replaceState({ filter: this._filter }, ""); - } - - private async _highlightEntry() { - await nextRender(); - const entryId = this._searchParms.get("config_entry")!; - const configEntry = this._configEntries!.find( - (entry) => entry.entry_id === entryId - ); - if (!configEntry) { - return; - } - const card: HaIntegrationCard = this.shadowRoot!.querySelector( - `[data-domain=${configEntry?.domain}]` - ) as HaIntegrationCard; - if (card) { - card.scrollIntoView({ - block: "center", - }); - card.classList.add("highlight"); - card.selectedConfigEntryId = entryId; - } - } - - private async _handleAdd(localizePromise: Promise) { - const brand = extractSearchParam("brand"); - const domain = extractSearchParam("domain"); - navigate("/config/integrations", { replace: true }); - - if (brand) { - showAddIntegrationDialog(this, { - brand, - }); - return; - } - if (!domain) { - return; - } - - const descriptions = await getIntegrationDescriptions(this.hass); - const integrations = { - ...descriptions.core.integration, - ...descriptions.custom.integration, - }; - - const integration = findIntegration(integrations, domain); - - if (integration?.config_flow) { - // Integration exists, so we can just create a flow - const localize = await localizePromise; - if ( - await showConfirmationDialog(this, { - title: localize("ui.panel.config.integrations.confirm_new", { - integration: integration.name || domainToName(localize, domain), - }), - }) - ) { - showAddIntegrationDialog(this, { - domain, - }); - } - return; - } - - if (integration?.supported_by) { - // Integration is a alias, so we can just create a flow - const localize = await localizePromise; - const supportedIntegration = findIntegration( - integrations, - integration.supported_by - ); - - if (!supportedIntegration) { - return; - } - - showConfirmationDialog(this, { - text: this.hass.localize( - "ui.panel.config.integrations.config_flow.supported_brand_flow", - { - supported_brand: integration.name || domainToName(localize, domain), - flow_domain_name: - supportedIntegration.name || - domainToName(localize, integration.supported_by), - } - ), - confirm: async () => { - if ( - (PROTOCOL_INTEGRATIONS as ReadonlyArray).includes( - integration.supported_by! - ) - ) { - protocolIntegrationPicked( - this, - this.hass, - integration.supported_by! - ); - return; - } - showConfigFlowDialog(this, { - dialogClosedCallback: () => { - this._handleFlowUpdated(); - }, - startFlowHandler: integration.supported_by, - manifest: await fetchIntegrationManifest( - this.hass, - integration.supported_by! - ), - showAdvanced: this.hass.userData?.showAdvanced, - }); - }, - }); - return; - } - - // If not an integration or supported brand, try helper else show alert - if (isHelperDomain(domain)) { - navigate(`/config/helpers/add?domain=${domain}`, { - replace: true, - }); - return; - } - const helpers = { - ...descriptions.core.helper, - ...descriptions.custom.helper, - }; - const helper = findIntegration(helpers, domain); - if (helper) { - navigate(`/config/helpers/add?domain=${domain}`, { - replace: true, - }); - return; - } - showAlertDialog(this, { - title: this.hass.localize( - "ui.panel.config.integrations.config_flow.error" - ), - text: this.hass.localize( - "ui.panel.config.integrations.config_flow.no_config_flow" - ), - }); - } - - static get styles(): CSSResultGroup { - return [ - haStyle, - css` - :host([narrow]) hass-tabs-subpage { - --main-title-margin: 0; - } - ha-button-menu { - margin-left: 8px; - margin-inline-start: 8px; - margin-inline-end: initial; - direction: var(--direction); - } - .container { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - grid-gap: 16px 16px; - padding: 8px 16px 16px; - margin-bottom: 64px; - } - .container > * { - max-width: 500px; - } - .empty-message { - margin: auto; - text-align: center; - } - .empty-message h1 { - margin-bottom: 0; - } - search-input { - --mdc-text-field-fill-color: var(--sidebar-background-color); - --mdc-text-field-idle-line-color: var(--divider-color); - --text-field-overflow: visible; - } - search-input.header { - display: block; - color: var(--secondary-text-color); - margin-left: 8px; - margin-inline-start: 8px; - margin-inline-end: initial; - direction: var(--direction); - --mdc-ripple-color: transparant; - } - .search { - display: flex; - justify-content: flex-end; - width: 100%; - align-items: center; - height: 56px; - position: sticky; - top: 0; - z-index: 2; - } - .search search-input { - display: block; - position: absolute; - top: 0; - right: 0; - left: 0; - } - .filters { - --mdc-text-field-fill-color: var(--input-fill-color); - --mdc-text-field-idle-line-color: var(--input-idle-line-color); - --mdc-shape-small: 4px; - --text-field-overflow: initial; - display: flex; - justify-content: flex-end; - color: var(--primary-text-color); - } - .active-filters { - color: var(--primary-text-color); - position: relative; - display: flex; - align-items: center; - padding-top: 2px; - padding-bottom: 2px; - padding-right: 2px; - padding-left: 8px; - padding-inline-start: 8px; - padding-inline-end: 2px; - font-size: 14px; - width: max-content; - cursor: initial; - direction: var(--direction); - } - .active-filters mwc-button { - margin-left: 8px; - margin-inline-start: 8px; - margin-inline-end: initial; - direction: var(--direction); - } - .active-filters::before { - background-color: var(--primary-color); - opacity: 0.12; - border-radius: 4px; - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - content: ""; - } - .badge { - min-width: 20px; - box-sizing: border-box; - border-radius: 50%; - font-weight: 400; - background-color: var(--primary-color); - line-height: 20px; - text-align: center; - padding: 0px 4px; - color: var(--text-primary-color); - position: absolute; - right: 0px; - top: 4px; - font-size: 0.65em; - } - .menu-badge-container { - position: relative; - } - ha-button-menu { - color: var(--primary-text-color); - } - `, - ]; + pageEl.route = this.routeTail; + pageEl.configEntries = this._configEntries; + pageEl.configEntriesInProgress = this._configEntriesInProgress; + pageEl.narrow = this.narrow; + pageEl.isWide = this.isWide; + pageEl.showAdvanced = this.showAdvanced; } } diff --git a/src/panels/config/integrations/ha-disabled-config-entry-card.ts b/src/panels/config/integrations/ha-disabled-config-entry-card.ts new file mode 100644 index 0000000000..f6003515ae --- /dev/null +++ b/src/panels/config/integrations/ha-disabled-config-entry-card.ts @@ -0,0 +1,99 @@ +import { mdiCog } from "@mdi/js"; +import { css, html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators"; +import "../../../components/ha-button"; +import { + DisableConfigEntryResult, + enableConfigEntry, +} from "../../../data/config_entries"; +import type { IntegrationManifest } from "../../../data/integration"; +import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; +import type { HomeAssistant } from "../../../types"; +import type { ConfigEntryExtended } from "./ha-config-integrations"; +import "./ha-integration-action-card"; + +@customElement("ha-disabled-config-entry-card") +export class HaDisabledConfigEntryCard extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public entry!: ConfigEntryExtended; + + @property() public manifest?: IntegrationManifest; + + protected render(): TemplateResult { + return html` + + + + + + + `; + } + + 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" + ), + }); + } + } + + static styles = css` + :host { + --state-color: var(--divider-color, #e0e0e0); + } + + ha-button { + --mdc-theme-primary: var(--primary-color); + } + a ha-icon-button { + color: var(--secondary-text-color); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-disabled-config-entry-card": HaDisabledConfigEntryCard; + } +} diff --git a/src/panels/config/integrations/ha-integration-action-card.ts b/src/panels/config/integrations/ha-integration-action-card.ts index 8f24451007..bd81203095 100644 --- a/src/panels/config/integrations/ha-integration-action-card.ts +++ b/src/panels/config/integrations/ha-integration-action-card.ts @@ -3,6 +3,7 @@ import { customElement, property } from "lit/decorators"; import type { IntegrationManifest } from "../../../data/integration"; import type { HomeAssistant } from "../../../types"; import "./ha-integration-header"; +import "../../../components/ha-card"; @customElement("ha-integration-action-card") export class HaIntegrationActionCard extends LitElement { @@ -28,7 +29,9 @@ export class HaIntegrationActionCard extends LitElement { .label=${this.label} .localizedDomainName=${this.localizedDomainName} .manifest=${this.manifest} - > + > + +
    diff --git a/src/panels/config/integrations/ha-integration-card.ts b/src/panels/config/integrations/ha-integration-card.ts index bea76ba7b8..1567b44cb2 100644 --- a/src/panels/config/integrations/ha-integration-card.ts +++ b/src/panels/config/integrations/ha-integration-card.ts @@ -1,86 +1,36 @@ +import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; import "@material/mwc-button"; import "@material/mwc-list"; -import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item"; +import { mdiApplication, mdiCog, mdiDevices, mdiShape } from "@mdi/js"; import { - mdiAlertCircle, - mdiBookshelf, - mdiBug, - mdiBugPlay, - mdiBugStop, - mdiChevronLeft, - mdiCog, - mdiDelete, - mdiDotsVertical, - mdiDownload, - mdiOpenInNew, - mdiReloadAlert, - mdiProgressHelper, - mdiPlayCircleOutline, - mdiReload, - mdiRenameBox, - mdiStopCircleOutline, -} from "@mdi/js"; -import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; + css, + CSSResultGroup, + html, + LitElement, + nothing, + TemplateResult, +} from "lit"; import { customElement, property } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; +import { ifDefined } from "lit/directives/if-defined"; import memoizeOne from "memoize-one"; -import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event"; -import "../../../components/ha-button-menu"; import "../../../components/ha-card"; -import "../../../components/ha-list-item"; import "../../../components/ha-icon-button"; import "../../../components/ha-icon-next"; +import "../../../components/ha-list-item"; import "../../../components/ha-svg-icon"; -import { - fetchApplicationCredentialsConfigEntry, - deleteApplicationCredential, -} from "../../../data/application_credential"; -import { getSignedPath } from "../../../data/auth"; -import { - ConfigEntry, - deleteConfigEntry, - disableConfigEntry, - DisableConfigEntryResult, - enableConfigEntry, - reloadConfigEntry, - updateConfigEntry, - ERROR_STATES, - RECOVERABLE_STATES, -} from "../../../data/config_entries"; -import { getErrorLogDownloadUrl } from "../../../data/error_log"; +import { ConfigEntry, ERROR_STATES } from "../../../data/config_entries"; import type { DeviceRegistryEntry } from "../../../data/device_registry"; -import { getConfigEntryDiagnosticsDownloadUrl } from "../../../data/diagnostics"; import type { EntityRegistryEntry } from "../../../data/entity_registry"; -import type { IntegrationManifest } from "../../../data/integration"; import { - integrationIssuesUrl, IntegrationLogInfo, + IntegrationManifest, LogSeverity, - setIntegrationLogLevel, } from "../../../data/integration"; -import { showConfigEntrySystemOptionsDialog } from "../../../dialogs/config-entry-system-options/show-dialog-config-entry-system-options"; -import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow"; -import { - showAlertDialog, - showConfirmationDialog, - showPromptDialog, -} from "../../../dialogs/generic/show-dialog-box"; -import { haStyle, haStyleScrollbar } from "../../../resources/styles"; +import { haStyle } from "../../../resources/styles"; import type { HomeAssistant } from "../../../types"; -import { documentationUrl } from "../../../util/documentation-url"; -import { fileDownload } from "../../../util/file_download"; import type { ConfigEntryExtended } from "./ha-config-integrations"; import "./ha-integration-header"; -import { isDevVersion } from "../../../common/config/version"; - -const integrationsWithPanel = { - matter: "/config/matter", - mqtt: "/config/mqtt", - thread: "/config/thread", - zha: "/config/zha/dashboard", - zwave_js: "/config/zwave_js/dashboard", -}; @customElement("ha-integration-card") export class HaIntegrationCard extends LitElement { @@ -95,817 +45,179 @@ export class HaIntegrationCard extends LitElement { @property({ attribute: false }) public entityRegistryEntries!: EntityRegistryEntry[]; - @property() public selectedConfigEntryId?: string; - - @property({ type: Boolean }) public entryDisabled = false; - @property({ type: Boolean }) public supportsDiagnostics = false; @property() public logInfo?: IntegrationLogInfo; protected render(): TemplateResult { - let item = this._selectededConfigEntry; + const state = this._getState(this.items); - if (this.items.length === 1) { - item = this.items[0]; - } else if (this.selectedConfigEntryId) { - item = this.items.find( - (entry) => entry.entry_id === this.selectedConfigEntryId - ); - } - - const hasItem = item !== undefined; + const debugLoggingEnabled = + this.logInfo && this.logInfo.level === LogSeverity.DEBUG; return html` 1, - disabled: this.entryDisabled, - "state-not-loaded": hasItem && item!.state === "not_loaded", - "state-failed-unload": hasItem && item!.state === "failed_unload", - "state-setup": hasItem && item!.state === "setup_in_progress", - "state-error": hasItem && ERROR_STATES.includes(item!.state), + "state-loaded": state === "loaded", + "state-not-loaded": state === "not_loaded", + "state-failed-unload": state === "failed_unload", + "state-setup": state === "setup_in_progress", + "state-error": ERROR_STATES.includes(state), + "debug-logging": Boolean(debugLoggingEnabled), })} - .configEntry=${item} > - ${this.items.length > 1 - ? html` -
    - -
    - ` - : ""} + + +
    - ${item - ? this._renderSingleEntry(item) - : this._renderGroupedIntegration()} + ${this._renderSingleEntry()}
    `; } - private _renderGroupedIntegration(): TemplateResult { + private _renderSingleEntry(): TemplateResult { + const devices = this._getDevices(this.items, this.hass.devices); + const entities = this._getEntities(this.items, this.entityRegistryEntries); + + const services = !devices.some((device) => device.entry_type !== "service"); + return html` - - ${this.items.map( - (item) => - html`${item.title || - this.hass.localize( - "ui.panel.config.integrations.config_entry.unnamed_entry" +
    + ${devices.length > 0 + ? html` 1 + ? `/config/devices/dashboard?historyBack=1&domain=${this.domain}` + : undefined )} - ${item.state === "setup_in_progress" - ? html` - - ${this.hass.localize( - `ui.panel.config.integrations.config_entry.state.setup_in_progress` - )} - - ` - : ERROR_STATES.includes(item.state) - ? html` - - ${this.hass.localize( - `ui.panel.config.integrations.config_entry.state.${item.state}` - )} - - ` - : html``} - ` - )} - - `; - } - - private _renderSingleEntry(item: ConfigEntryExtended): TemplateResult { - const devices = this._getDevices(item, this.hass.devices); - const services = this._getServices(item, this.hass.devices); - const entities = this._getEntities(item, this.entityRegistryEntries); - - let stateText: Parameters | undefined; - let stateTextExtra: TemplateResult | string | undefined; - let icon: string = mdiAlertCircle; - - if (item.disabled_by) { - stateText = [ - "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") { - stateTextExtra = html`. - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.disable_restart_confirm" - )}.`; - } - } else if (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}`, - ]; - if (item.reason) { - this.hass.loadBackendTranslation("config", item.domain); - stateTextExtra = html`: - ${this.hass.localize( - `component.${item.domain}.config.error.${item.reason}` - ) || item.reason}`; - } else { - stateTextExtra = html` -
    -
    - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.check_the_logs" - )} - - `; - } - } - - 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}`; - 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 === 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 html` - ${stateText - ? html` -
    - -
    ${this.hass.localize(...stateText)}${stateTextExtra}
    -
    - ` - : ""} -
    ${devicesLine}
    -
    -
    - ${item.disabled_by === "user" - ? html` - ${this.hass.localize("ui.common.enable")} - ` - : item.domain in integrationsWithPanel && - (item.domain !== "matter" || - isDevVersion(this.hass.config.version)) - ? 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" - )} - - ` - : ""} -
    - - - ${!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" - )} - - ` - : ""} - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.rename" - )} - - - ${this.supportsDiagnostics && item.state === "loaded" - ? html` - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.download_diagnostics" - )} - - - ` - : ""} - ${this.logInfo - ? html` - ${this.logInfo.level === LogSeverity.DEBUG - ? this.hass.localize( - "ui.panel.config.integrations.config_entry.disable_debug_logging" - ) + + ${devices.length === 0 + ? "No devices" : this.hass.localize( - "ui.panel.config.integrations.config_entry.enable_debug_logging" + `ui.panel.config.integrations.config_entry.${ + services ? "services" : "devices" + }`, + "count", + devices.length )} - - ` - : ""} - ${this.manifest && - (this.manifest.is_built_in || - this.manifest.issue_tracker || - this.manifest.documentation) - ? html`
  • ` - : ""} - ${this.manifest - ? html` - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.documentation" - )} - - - - ` - : ""} - ${this.manifest && - (this.manifest.is_built_in || this.manifest.issue_tracker) - ? html` - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.known_issues" - )} - - - - ` - : ""} - -
  • - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.system_options" - )} - - - ${item.disabled_by === "user" - ? html``} + + ` + : ""} + ${entities.length > 0 + ? html` + - ${this.hass.localize("ui.common.enable")} - - ` - : item.source !== "system" - ? html` - ${this.hass.localize("ui.common.disable")} - - ` - : ""} - ${item.source !== "system" - ? html` - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.delete" - )} - - ` - : ""} -
    + + ${entities.length === 0 + ? "No entities" + : this.hass.localize( + `ui.panel.config.integrations.config_entry.entities`, + "count", + entities.length + )} + ${entities.length === 0 + ? nothing + : html``} + ` + : ""}
    `; } - private async _handleEnableDebugLogging(ev: MouseEvent) { - const configEntry = ((ev.target as HTMLElement).closest("ha-card") as any) - .configEntry; - const integration = configEntry.domain; - await setIntegrationLogLevel( - this.hass, - integration, - LogSeverity[LogSeverity.DEBUG], - "once" - ); - } - - private async _handleDisableDebugLogging(ev: MouseEvent) { - // Stop propagation since otherwise we end up here twice while we await the log level change - // and trigger two identical debug log downloads. - ev.stopPropagation(); - const configEntry = ((ev.target as HTMLElement).closest("ha-card") as any) - .configEntry; - const integration = configEntry.domain; - await setIntegrationLogLevel( - this.hass, - integration, - LogSeverity[LogSeverity.NOTSET], - "once" - ); - const timeString = new Date().toISOString().replace(/:/g, "-"); - const logFileName = `home-assistant_${integration}_${timeString}.log`; - const signedUrl = await getSignedPath(this.hass, getErrorLogDownloadUrl); - fileDownload(signedUrl.path, logFileName); - } - - private get _selectededConfigEntry(): ConfigEntryExtended | undefined { - return this.items.length === 1 - ? this.items[0] - : this.selectedConfigEntryId - ? this.items.find( - (entry) => entry.entry_id === this.selectedConfigEntryId - ) - : undefined; - } - - private _selectConfigEntry(ev: Event) { - this.selectedConfigEntryId = (ev.currentTarget as any).entryId; - } - - private _back() { - this.selectedConfigEntryId = undefined; - this.classList.remove("highlight"); - } + private _getState = memoizeOne( + (configEntry: ConfigEntry[]): ConfigEntry["state"] => { + if (configEntry.length === 1) { + return configEntry[0].state; + } + let state: ConfigEntry["state"]; + for (const entry of configEntry) { + if (ERROR_STATES.includes(entry.state)) { + return entry.state; + } + state = entry.state; + } + return state!; + } + ); private _getEntities = memoizeOne( ( - configEntry: ConfigEntry, + configEntry: ConfigEntry[], entityRegistryEntries: EntityRegistryEntry[] ): EntityRegistryEntry[] => { if (!entityRegistryEntries) { return []; } + const entryIds = configEntry.map((entry) => entry.entry_id); return entityRegistryEntries.filter( - (entity) => entity.config_entry_id === configEntry.entry_id + (entity) => + entity.config_entry_id && entryIds.includes(entity.config_entry_id) ); } ); private _getDevices = memoizeOne( ( - configEntry: ConfigEntry, + configEntry: ConfigEntry[], deviceRegistryEntries: HomeAssistant["devices"] ): DeviceRegistryEntry[] => { if (!deviceRegistryEntries) { return []; } - return Object.values(deviceRegistryEntries).filter( - (device) => - device.config_entries.includes(configEntry.entry_id) && - device.entry_type !== "service" + const entryIds = configEntry.map((entry) => entry.entry_id); + return Object.values(deviceRegistryEntries).filter((device) => + device.config_entries.some((entryId) => entryIds.includes(entryId)) ); } ); - private _getServices = memoizeOne( - ( - configEntry: ConfigEntry, - deviceRegistryEntries: HomeAssistant["devices"] - ): DeviceRegistryEntry[] => { - if (!deviceRegistryEntries) { - return []; - } - 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("ha-card").configEntry, - this.manifest - ); - } - - private _handleRename(ev: CustomEvent): void { - if (!shouldHandleRequestSelectedEvent(ev)) { - return; - } - this._editEntryName( - ((ev.target as HTMLElement).closest("ha-card") as any).configEntry - ); - } - - private _handleReload(ev: CustomEvent): void { - if (!shouldHandleRequestSelectedEvent(ev)) { - return; - } - this._reloadIntegration( - ((ev.target as HTMLElement).closest("ha-card") as any).configEntry - ); - } - - private _handleDelete(ev: CustomEvent): void { - if (!shouldHandleRequestSelectedEvent(ev)) { - return; - } - this._removeIntegration( - ((ev.target as HTMLElement).closest("ha-card") as any).configEntry - ); - } - - private _handleDisable(ev: CustomEvent): void { - if (!shouldHandleRequestSelectedEvent(ev)) { - return; - } - this._disableIntegration( - ((ev.target as HTMLElement).closest("ha-card") as any).configEntry - ); - } - - private _handleEnable(ev: CustomEvent): void { - if (ev.detail.source && !shouldHandleRequestSelectedEvent(ev)) { - return; - } - this._enableIntegration( - ((ev.target as HTMLElement).closest("ha-card") as any).configEntry - ); - } - - private _handleSystemOptions(ev: CustomEvent): void { - if (!shouldHandleRequestSelectedEvent(ev)) { - return; - } - this._showSystemOptions( - ((ev.target as HTMLElement).closest("ha-card") 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 _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.target.closest("a"); - ev.preventDefault(); - const signedUrl = await getSignedPath( - this.hass, - anchor.getAttribute("href") - ); - fileDownload(signedUrl.path); - } - static get styles(): CSSResultGroup { return [ haStyle, - haStyleScrollbar, css` ha-card { display: flex; @@ -915,6 +227,10 @@ export class HaIntegrationCard extends LitElement { --ha-card-border-color: var(--state-color); --state-message-color: var(--state-color); } + .debug-logging { + --state-color: var(--warning-color); + --text-on-state-color: var(--primary-text-color); + } .state-error { --state-color: var(--error-color); --text-on-state-color: var(--text-primary-color); @@ -933,110 +249,16 @@ export class HaIntegrationCard extends LitElement { --state-color: var(--primary-color); --text-on-state-color: var(--text-primary-color); } - - .back-btn { - background-color: var(--state-color); - color: var(--text-on-state-color); - --mdc-icon-button-size: 32px; - transition: height 0.1s; - overflow: hidden; - border-top-left-radius: var(--ha-card-border-radius, 12px); - border-top-right-radius: var(--ha-card-border-radius, 12px); - } - .hasMultiple.single .back-btn { - height: 24px; - display: flex; - align-items: center; - } - .hasMultiple.group .back-btn { - height: 0px; - } - - .message { - font-weight: bold; - padding-bottom: 16px; - display: flex; - margin-left: 40px; - } - .message ha-svg-icon { - color: var(--state-message-color); - } - .message div { - flex: 1; - margin-left: 8px; - padding-top: 2px; - padding-right: 2px; - overflow-wrap: break-word; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 7; - overflow: hidden; - text-overflow: ellipsis; - } - .content { flex: 1; - padding: 0px 16px 0 72px; - } - - .actions { - display: flex; - justify-content: space-between; - align-items: center; - padding: 8px 0 0 8px; - height: 48px; - } - .actions a { - text-decoration: none; + --mdc-list-side-padding: 16px; } a { + text-decoration: none; color: var(--primary-color); } - ha-button-menu { + a ha-icon-button { color: var(--secondary-text-color); - --mdc-menu-min-width: 200px; - } - mwc-list { - border-radius: 0 0 var(--ha-card-border-radius, 16px) - var(--ha-card-border-radius, 16px); - } - @media (min-width: 563px) { - ha-card.group { - position: relative; - min-height: 200px; - } - mwc-list { - position: absolute; - top: 64px; - left: 0; - right: 0; - bottom: 0; - overflow: auto; - } - .disabled mwc-list { - top: 88px; - } - } - ha-list-item { - word-wrap: break-word; - display: -webkit-flex; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - overflow: hidden; - text-overflow: ellipsis; - } - ha-list-item ha-svg-icon { - color: var(--secondary-text-color); - } - .config-entry { - height: 36px; - } - ha-icon-next { - width: 24px; - } - ha-svg-icon[slot="meta"] { - width: 18px; - height: 18px; } `, ]; diff --git a/src/panels/config/integrations/ha-integration-header.ts b/src/panels/config/integrations/ha-integration-header.ts index cbf5fa04b9..fbc2217a3c 100644 --- a/src/panels/config/integrations/ha-integration-header.ts +++ b/src/panels/config/integrations/ha-integration-header.ts @@ -1,9 +1,9 @@ -import { mdiBugPlay, mdiCloud, mdiPackageVariant, mdiSyncOff } from "@mdi/js"; import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; +import { mdiCloud, mdiPackageVariant } from "@mdi/js"; import { css, html, LitElement, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; import "../../../components/ha-svg-icon"; -import { ConfigEntry } from "../../../data/config_entries"; import { domainToName, IntegrationManifest } from "../../../data/integration"; import { HomeAssistant } from "../../../types"; import { brandsUrl } from "../../../util/brands-url"; @@ -14,16 +14,14 @@ export class HaIntegrationHeader extends LitElement { @property() public banner?: string; + @property() public label?: string; + @property() public localizedDomainName?: string; @property() public domain!: string; - @property() public label?: string; - @property({ attribute: false }) public manifest?: IntegrationManifest; - @property({ attribute: false }) public configEntry?: ConfigEntry; - @property({ attribute: false }) public debugLoggingEnabled?: boolean; protected render(): TemplateResult { @@ -56,10 +54,7 @@ export class HaIntegrationHeader extends LitElement { ]); } - if ( - this.manifest.iot_class && - this.manifest.iot_class.startsWith("cloud_") - ) { + if (this.manifest.iot_class?.startsWith("cloud_")) { icons.push([ mdiCloud, this.hass.localize( @@ -67,24 +62,6 @@ export class HaIntegrationHeader extends LitElement { ), ]); } - - if (this.configEntry?.pref_disable_polling) { - icons.push([ - mdiSyncOff, - this.hass.localize( - "ui.panel.config.integrations.config_entry.disabled_polling" - ), - ]); - } - } - - if (this.debugLoggingEnabled) { - icons.push([ - mdiBugPlay, - this.hass.localize( - "ui.panel.config.integrations.config_entry.debug_logging_enabled" - ), - ]); } return html` @@ -102,15 +79,17 @@ export class HaIntegrationHeader extends LitElement { @error=${this._onImageError} @load=${this._onImageLoad} /> -
    -
    ${primary}
    - ${secondary ? html`
    ${secondary}
    ` : ""} -
    - ${icons.length === 0 ? "" : html` -
    +
    ${icons.map( ([icon, description]) => html` @@ -123,6 +102,13 @@ export class HaIntegrationHeader extends LitElement { )}
    `} +
    +
    ${primary}
    +
    ${secondary}
    +
    +
    + +
    `; } @@ -175,10 +161,12 @@ export class HaIntegrationHeader extends LitElement { overflow: hidden; text-overflow: ellipsis; } + .header-button { + margin-top: 8px; + } .primary { font-size: 16px; margin-top: 16px; - margin-right: 2px; font-weight: 400; word-break: break-word; color: var(--primary-text-color); @@ -188,21 +176,30 @@ export class HaIntegrationHeader extends LitElement { color: var(--secondary-text-color); } .icons { - margin-right: 8px; - margin-left: auto; - height: 28px; - color: var(--text-on-state-color, var(--secondary-text-color)); - background-color: var(--state-color, #e0e0e0); - border-bottom-left-radius: 4px; - border-bottom-right-radius: 4px; + background: var(--warning-color); + border: 1px solid var(--card-background-color); + border-radius: 14px; + color: var(--text-primary-color); + position: absolute; + left: 40px; + top: 40px; display: flex; - float: right; + } + .icons.cloud { + background: var(--info-color); + } + .icons.double { + background: var(--warning-color); + left: 28px; } .icons ha-svg-icon { - width: 20px; - height: 20px; + width: 16px; + height: 16px; margin: 4px; } + .icons span:not(:first-child) ha-svg-icon { + margin-left: 0; + } simple-tooltip { white-space: nowrap; } diff --git a/src/panels/my/ha-panel-my.ts b/src/panels/my/ha-panel-my.ts index 70ad0f9693..85ee926bc9 100644 --- a/src/panels/my/ha-panel-my.ts +++ b/src/panels/my/ha-panel-my.ts @@ -70,6 +70,12 @@ export const getMyRedirects = (hasSupervisor: boolean): Redirects => ({ integrations: { redirect: "/config/integrations", }, + integration: { + redirect: "/config/integration", + params: { + domain: "string", + }, + }, config_mqtt: { component: "mqtt", redirect: "/config/mqtt", diff --git a/src/translations/en.json b/src/translations/en.json index 5bb432f0eb..9a9da01be8 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -3238,6 +3238,11 @@ "confirm_delete_ignore": "This will make the integration appear in your discovered integrations again when it gets discovered. This might require a restart or take some time.", "stop_ignore": "Stop ignoring" }, + "integration_page": { + "entries": "Integration entries", + "no_entries": "No entries", + "attention_entries": "Needs attention" + }, "config_entry": { "application_credentials": { "delete_title": "Application Credentials",