Overhaul Integrations page, add integration page (#16640)

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
This commit is contained in:
Bram Kragten 2023-05-30 20:09:26 +02:00 committed by GitHub
parent 2b4f199337
commit 70fbf68603
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 2609 additions and 1800 deletions

View File

@ -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);
});
}
};
},

View File

@ -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;
}
`,
];
}

View File

@ -96,7 +96,7 @@ export class HaRelatedItems extends LitElement {
}
return html`
<a
href=${`/config/integrations#config_entry=${relatedConfigEntryId}`}
href=${`/config/integrations/integration/${entry.domain}#config_entry=${relatedConfigEntryId}`}
@click=${this._navigateAwayClose}
>
<ha-list-item hasMeta graphic="icon">

View File

@ -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"

View File

@ -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`<a
slot="actions"
href=${`/config/integrations/integration/${integration.domain}#config_entry=${integration.entry_id}`}
>
<ha-list-item graphic="icon" hasMeta>
<img
slot="graphic"
alt=${domainToName(this.hass.localize, integration.domain)}
src=${brandsUrl({
domain: integration.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
referrerpolicy="no-referrer"
/>
${domainToName(this.hass.localize, integration.domain)}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>`
);
const actions = [...(this._deviceActions || [])];
if (Array.isArray(this._diagnosticDownloadLinks)) {

View File

@ -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) {

View File

@ -230,7 +230,7 @@ export class EnergyGridSettings extends LitElement {
/>
<span class="content">${this._co2ConfigEntry.title}</span>
<a
href=${`/config/integrations#config_entry=${this._co2ConfigEntry.entry_id}`}
href=${`/config/integrations/integration/${this._co2ConfigEntry?.domain}`}
>
<ha-icon-button .path=${mdiPencil}></ha-icon-button>
</a>

View File

@ -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) {

View File

@ -60,7 +60,7 @@ export class HaConfigFlowCard extends LitElement {
}`
)}
></mwc-button>
<ha-button-menu>
<ha-button-menu slot="header-button">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
@ -186,6 +186,9 @@ export class HaConfigFlowCard extends LitElement {
text-decoration: none;
color: var(--primary-color);
}
ha-button-menu {
color: var(--secondary-text-color);
}
ha-svg-icon[slot="meta"] {
width: 18px;
height: 18px;

File diff suppressed because it is too large Load Diff

View File

@ -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<string, ConfigEntryExtended[]> => {
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<string, IntegrationManifest> = {};
private _extraFetchedManifests?: Set<string>;
@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<string, boolean>;
@state() private _logInfos?: {
[integration: string]: IntegrationLogInfo;
};
public hassSubscribe(): Array<UnsubscribeFunc | Promise<UnsubscribeFunc>> {
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<ConfigEntryExtended> = {
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<DataEntryFlowProgressExtended> = {
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`<hass-loading-screen
.hass=${this.hass}
.narrow=${this.narrow}
></hass-loading-screen>`;
}
const [integrations, ignoredConfigEntries, disabledConfigEntries] =
this._filterConfigEntries(this.configEntries, this._filter);
const configEntriesInProgress = this._filterConfigEntriesInProgress(
this.configEntriesInProgress,
this._filter
);
const filterMenu = html`
<div slot=${ifDefined(this.narrow ? "toolbar-icon" : undefined)}>
<div class="menu-badge-container">
${!this._showDisabled && this.narrow && disabledConfigEntries.length
? html`<span class="badge">${disabledConfigEntries.length}</span>`
: ""}
<ha-button-menu
multi
@action=${this._handleMenuAction}
@click=${this._preventDefault}
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiFilterVariant}
>
</ha-icon-button>
<ha-check-list-item left .selected=${this._showIgnored}>
${this.hass.localize(
"ui.panel.config.integrations.ignore.show_ignored"
)}
</ha-check-list-item>
<ha-check-list-item left .selected=${this._showDisabled}>
${this.hass.localize(
"ui.panel.config.integrations.disable.show_disabled"
)}
</ha-check-list-item>
</ha-button-menu>
</div>
${this.narrow
? html`
<ha-integration-overflow-menu
.hass=${this.hass}
slot="toolbar-icon"
></ha-integration-overflow-menu>
`
: ""}
</div>
`;
return html`
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${configSections.devices}
>
${this.narrow
? html`
<div slot="header">
<search-input
.hass=${this.hass}
.filter=${this._filter}
class="header"
@value-changed=${this._handleSearchChange}
.label=${this.hass.localize(
"ui.panel.config.integrations.search"
)}
></search-input>
</div>
${filterMenu}
`
: html`
<ha-integration-overflow-menu
.hass=${this.hass}
slot="toolbar-icon"
></ha-integration-overflow-menu>
<div class="search">
<search-input
.hass=${this.hass}
suffix
.filter=${this._filter}
@value-changed=${this._handleSearchChange}
.label=${this.hass.localize(
"ui.panel.config.integrations.search"
)}
>
<div class="filters" slot="suffix">
${!this._showDisabled && disabledConfigEntries.length
? html`<div
class="active-filters"
@click=${this._preventDefault}
>
${this.hass.localize(
"ui.panel.config.integrations.disable.disabled_integrations",
{ number: disabledConfigEntries.length }
)}
<mwc-button
@click=${this._toggleShowDisabled}
.label=${this.hass.localize(
"ui.panel.config.integrations.disable.show"
)}
></mwc-button>
</div>`
: ""}
${filterMenu}
</div>
</search-input>
</div>
`}
<div class="container">
${this._showIgnored
? ignoredConfigEntries.map(
(entry: ConfigEntryExtended) => html`
<ha-ignored-config-entry-card
.hass=${this.hass}
.manifest=${this._manifests[entry.domain]}
.entry=${entry}
@change=${this._handleFlowUpdated}
></ha-ignored-config-entry-card>
`
)
: ""}
${configEntriesInProgress.length
? configEntriesInProgress.map(
(flow: DataEntryFlowProgressExtended) => html`
<ha-config-flow-card
.hass=${this.hass}
.manifest=${this._manifests[flow.handler]}
.flow=${flow}
@change=${this._handleFlowUpdated}
></ha-config-flow-card>
`
)
: ""}
${this._showDisabled
? disabledConfigEntries.map(
(entry: ConfigEntryExtended) => html`
<ha-disabled-config-entry-card
.hass=${this.hass}
.entry=${entry}
.manifest=${this._manifests[entry.domain]}
.entityRegistryEntries=${this._entityRegistryEntries}
></ha-disabled-config-entry-card>
`
)
: ""}
${integrations.length
? integrations.map(
([domain, items]) =>
html`<ha-integration-card
data-domain=${domain}
.hass=${this.hass}
.domain=${domain}
.items=${items}
.manifest=${this._manifests[domain]}
.entityRegistryEntries=${this._entityRegistryEntries}
.supportsDiagnostics=${this._diagnosticHandlers
? this._diagnosticHandlers[domain]
: false}
.logInfo=${this._logInfos
? this._logInfos[domain]
: nothing}
></ha-integration-card>`
)
: this._filter &&
!configEntriesInProgress.length &&
!integrations.length &&
this.configEntries.length
? html`
<div class="empty-message">
<h1>
${this.hass.localize(
"ui.panel.config.integrations.none_found"
)}
</h1>
<p>
${this.hass.localize(
"ui.panel.config.integrations.none_found_detail"
)}
</p>
<mwc-button
@click=${this._createFlow}
unelevated
.label=${this.hass.localize(
"ui.panel.config.integrations.add_integration"
)}
></mwc-button>
</div>
`
: // 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`
<div class="empty-message">
<h1>
${this.hass.localize("ui.panel.config.integrations.none")}
</h1>
<p>
${this.hass.localize(
"ui.panel.config.integrations.no_integrations"
)}
</p>
<mwc-button
@click=${this._createFlow}
unelevated
.label=${this.hass.localize(
"ui.panel.config.integrations.add_integration"
)}
></mwc-button>
</div>
`
: ""}
</div>
<ha-fab
slot="fab"
.label=${this.hass.localize(
"ui.panel.config.integrations.add_integration"
)}
extended
@click=${this._createFlow}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
</hass-tabs-subpage>
`;
}
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<ActionDetail>) {
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<string>).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;
}
}

View File

@ -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<string, ConfigEntryExtended[]> => {
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<unknown>;
@state()
private _manifests: Record<string, IntegrationManifest> = {};
private _extraFetchedManifests?: Set<string>;
@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<string, boolean>;
@state() private _logInfos?: {
[integration: string]: IntegrationLogInfo;
};
public hassSubscribe(): Array<UnsubscribeFunc | Promise<UnsubscribeFunc>> {
public hassSubscribe() {
return [
subscribeEntityRegistry(this.hass.connection, (entries) => {
this._entityRegistryEntries = entries;
}),
subscribeConfigFlowInProgress(this.hass, async (flowsInProgress) => {
const integrations: Set<string> = new Set();
const manifests: Set<string> = 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<string> = 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<ConfigEntryExtended> = {
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<string, ConfigEntryExtended[]>,
ConfigEntryExtended[],
Map<string, ConfigEntryExtended[]>,
// 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<DataEntryFlowProgressExtended> = {
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`<hass-loading-screen
.hass=${this.hass}
.narrow=${this.narrow}
></hass-loading-screen>`;
}
const [
groupedConfigEntries,
ignoredConfigEntries,
disabledConfigEntries,
disabledCount,
] = this._filterGroupConfigEntries(this._configEntries, this._filter);
const configEntriesInProgress = this._filterConfigEntriesInProgress(
this._configEntriesInProgress,
this._filter
);
const filterMenu = html`
<div slot=${ifDefined(this.narrow ? "toolbar-icon" : undefined)}>
<div class="menu-badge-container">
${!this._showDisabled && this.narrow && disabledCount
? html`<span class="badge">${disabledCount}</span>`
: ""}
<ha-button-menu
multi
@action=${this._handleMenuAction}
@click=${this._preventDefault}
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiFilterVariant}
>
</ha-icon-button>
<ha-check-list-item left .selected=${this._showIgnored}>
${this.hass.localize(
"ui.panel.config.integrations.ignore.show_ignored"
)}
</ha-check-list-item>
<ha-check-list-item left .selected=${this._showDisabled}>
${this.hass.localize(
"ui.panel.config.integrations.disable.show_disabled"
)}
</ha-check-list-item>
</ha-button-menu>
</div>
${this.narrow
? html`
<ha-integration-overflow-menu
.hass=${this.hass}
slot="toolbar-icon"
></ha-integration-overflow-menu>
`
: ""}
</div>
`;
return html`
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${configSections.devices}
>
${this.narrow
? html`
<div slot="header">
<search-input
.hass=${this.hass}
.filter=${this._filter}
class="header"
@value-changed=${this._handleSearchChange}
.label=${this.hass.localize(
"ui.panel.config.integrations.search"
)}
></search-input>
</div>
${filterMenu}
`
: html`
<ha-integration-overflow-menu
.hass=${this.hass}
slot="toolbar-icon"
></ha-integration-overflow-menu>
<div class="search">
<search-input
.hass=${this.hass}
suffix
.filter=${this._filter}
@value-changed=${this._handleSearchChange}
.label=${this.hass.localize(
"ui.panel.config.integrations.search"
)}
>
<div class="filters" slot="suffix">
${!this._showDisabled && disabledCount
? html`<div
class="active-filters"
@click=${this._preventDefault}
>
${this.hass.localize(
"ui.panel.config.integrations.disable.disabled_integrations",
{ number: disabledCount }
)}
<mwc-button
@click=${this._toggleShowDisabled}
.label=${this.hass.localize(
"ui.panel.config.integrations.disable.show"
)}
></mwc-button>
</div>`
: ""}
${filterMenu}
</div>
</search-input>
</div>
`}
<div class="container">
${this._showIgnored
? ignoredConfigEntries.map(
(entry: ConfigEntryExtended) => html`
<ha-ignored-config-entry-card
.hass=${this.hass}
.manifest=${this._manifests[entry.domain]}
.entry=${entry}
@change=${this._handleFlowUpdated}
></ha-ignored-config-entry-card>
`
)
: ""}
${configEntriesInProgress.length
? configEntriesInProgress.map(
(flow: DataEntryFlowProgressExtended) => html`
<ha-config-flow-card
.hass=${this.hass}
.manifest=${this._manifests[flow.handler]}
.flow=${flow}
@change=${this._handleFlowUpdated}
></ha-config-flow-card>
`
)
: ""}
${this._showDisabled
? Array.from(disabledConfigEntries.entries()).map(
([domain, items]) =>
html`<ha-integration-card
data-domain=${domain}
entryDisabled
.hass=${this.hass}
.domain=${domain}
.items=${items}
.manifest=${this._manifests[domain]}
.entityRegistryEntries=${this._entityRegistryEntries}
></ha-integration-card> `
)
: ""}
${groupedConfigEntries.size
? Array.from(groupedConfigEntries.entries()).map(
([domain, items]) =>
html`<ha-integration-card
data-domain=${domain}
.hass=${this.hass}
.domain=${domain}
.items=${items}
.manifest=${this._manifests[domain]}
.entityRegistryEntries=${this._entityRegistryEntries}
.supportsDiagnostics=${this._diagnosticHandlers
? this._diagnosticHandlers[domain]
: false}
.logInfo=${this._logInfos
? this._logInfos[domain]
: nothing}
></ha-integration-card>`
)
: this._filter &&
!configEntriesInProgress.length &&
!groupedConfigEntries.size &&
this._configEntries.length
? html`
<div class="empty-message">
<h1>
${this.hass.localize(
"ui.panel.config.integrations.none_found"
)}
</h1>
<p>
${this.hass.localize(
"ui.panel.config.integrations.none_found_detail"
)}
</p>
<mwc-button
@click=${this._createFlow}
unelevated
.label=${this.hass.localize(
"ui.panel.config.integrations.add_integration"
)}
></mwc-button>
</div>
`
: // 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`
<div class="empty-message">
<h1>
${this.hass.localize("ui.panel.config.integrations.none")}
</h1>
<p>
${this.hass.localize(
"ui.panel.config.integrations.no_integrations"
)}
</p>
<mwc-button
@click=${this._createFlow}
unelevated
.label=${this.hass.localize(
"ui.panel.config.integrations.add_integration"
)}
></mwc-button>
</div>
`
: ""}
</div>
<ha-fab
slot="fab"
.label=${this.hass.localize(
"ui.panel.config.integrations.add_integration"
)}
extended
@click=${this._createFlow}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
</hass-tabs-subpage>
`;
}
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<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<ActionDetail>) {
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<LocalizeFunc>) {
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<string>).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;
}
}

View File

@ -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`
<ha-integration-action-card
.hass=${this.hass}
.manifest=${this.manifest}
.banner=${this.hass.localize(
"ui.panel.config.integrations.config_entry.disable.disabled_cause",
{
cause:
this.hass.localize(
`ui.panel.config.integrations.config_entry.disable.disabled_by.${this
.entry.disabled_by!}`
) || this.entry.disabled_by,
}
)}
.domain=${this.entry.domain}
.localizedDomainName=${this.entry.localized_domain_name}
.label=${this.entry.title || this.entry.localized_domain_name}
>
<a
href=${`/config/integrations/integration/${this.entry.domain}`}
slot="header-button"
>
<ha-icon-button .path=${mdiCog}></ha-icon-button>
</a>
<ha-button
@click=${this._handleEnable}
.label=${this.hass.localize("ui.common.enable")}
></ha-button>
</ha-integration-action-card>
`;
}
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;
}
}

View File

@ -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}
></ha-integration-header>
>
<span slot="header-button"><slot name="header-button"></slot></span>
</ha-integration-header>
<div class="filler"></div>
<div class="actions"><slot></slot></div>
</ha-card>

File diff suppressed because it is too large Load Diff

View File

@ -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}
/>
<div class="info">
<div class="primary" role="heading">${primary}</div>
${secondary ? html`<div class="secondary">${secondary}</div>` : ""}
</div>
${icons.length === 0
? ""
: html`
<div class="icons">
<div
class="icons ${classMap({
double: icons.length > 1,
cloud: Boolean(
this.manifest?.iot_class?.startsWith("cloud_")
),
})}"
>
${icons.map(
([icon, description]) => html`
<span>
@ -123,6 +102,13 @@ export class HaIntegrationHeader extends LitElement {
)}
</div>
`}
<div class="info">
<div class="primary" role="heading">${primary}</div>
<div class="secondary">${secondary}</div>
</div>
<div class="header-button">
<slot name="header-button"></slot>
</div>
</div>
`;
}
@ -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;
}

View File

@ -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",

View File

@ -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",