Compare commits

...
Author SHA1 Message Date
Wendelin 2bc8aaa3c0 Refactor integration card status messages for improved clarity and consistency 2026-10-19 15:55:03 +02:00
Wendelin 51857d8b2c Refactor integration card status handling and extract supporting text logic 2026-10-19 11:03:42 +02:00
Wendelin ea6fb578d9 Enhance integration card state handling and accessibility features 2026-10-15 15:00:52 +02:00
Wendelin 534cab1c43 Refactor integration card layout and improve attention state handling 2026-10-15 14:43:00 +02:00
WendelinandClaude Opus 5.5 9b09bab34f Redesign integration cards with row layout and attention summary
Rebuild the integration card on ha-row-item: brand icon with a status
badge, wrapping integration name, and a supporting text that shows how
many devices, services, entities or entries need attention. Discovered,
ignored and disabled cards share the same row layout with actions on
their own right-aligned line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 14:43:00 +02:00
7 changed files with 771 additions and 500 deletions

No files matched your search

@@ -109,6 +109,30 @@ const disabledFailedUnloadEntry = createConfigEntry(
}
);
// Other brands to compare how status badges look on different icons.
const brandEntries: ConfigEntryExtended[] = (
[
["hue", "Philips Hue", "loaded"],
["hue", "Philips Hue", "setup_error"],
["mqtt", "MQTT", "setup_retry"],
["zha", "Zigbee Home Automation", "not_loaded"],
["sonos", "Sonos", "setup_error"],
["shelly", "Shelly", "failed_unload"],
["spotify", "Spotify", "setup_retry"],
["tuya", "Tuya", "setup_error"],
["unifi", "UniFi Network", "not_loaded"],
["met", "Meteorologisk institutt (Met.no)", "setup_error"],
["google_assistant", "Google Assistant", "not_loaded"],
["homekit_controller", "HomeKit Device", "setup_retry"],
] as [string, string, ConfigEntryExtended["state"]][]
).map(([domain, name, entryState]) =>
createConfigEntry(`${name} ${entryState}`, {
domain,
localized_domain_name: name,
state: entryState,
})
);
const configFlows: DataEntryFlowProgressExtended[] = [
{
flow_id: "adbb401329d8439ebb78ef29837826a8",
@@ -325,6 +349,21 @@ export class DemoIntegrationCard extends LitElement {
.deviceRegistryEntries=${createDeviceRegistryEntries(loadedEntry)}
></ha-integration-card>
</div>
<div class="container">
${brandEntries.map(
(entry) => html`
<ha-integration-card
.domain=${entry.domain}
.items=${[entry]}
.manifest=${createManifest(
this.isCustomIntegration,
this.isCloud
)}
.entityRegistryEntries=${createEntityRegistryEntries(entry)}
></ha-integration-card>
`
)}
</div>
`;
}
@@ -1,253 +0,0 @@
import { mdiFileCodeOutline, mdiPackageVariant, mdiWeb } from "@mdi/js";
import type { ContextType } from "@lit/context";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consume } from "../../../common/decorators/consume";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import { computeRTL } from "../../../common/util/compute_rtl";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import {
devicesContext,
internationalizationContext,
} from "../../../data/context";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
import type { HomeAssistantInternationalization } from "../../../types";
import type { ConfigEntryExtended } from "./ha-config-integrations";
@customElement("ha-integration-card-footer")
export class HaIntegrationCardFooter extends LitElement {
@property({ attribute: false }) public manifest?: IntegrationManifest;
@property({ attribute: false }) public items!: ConfigEntryExtended[];
@property({ attribute: false })
public entityRegistryEntries!: EntityRegistryEntry[];
@property({ attribute: false }) public domainEntities: string[] = [];
@state()
@consume({ context: devicesContext, subscribe: true })
private _devices!: ContextType<typeof devicesContext>;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, boolean>({
transformer: ({ language, translationMetadata }) =>
computeRTL(language, translationMetadata.translations),
})
private _isRTL!: boolean;
protected render(): TemplateResult | typeof nothing {
const devices = this._getDevices(this.items, this._devices);
const entitiesCount = devices.length
? 0
: this._getEntityCount(
this.items,
this.entityRegistryEntries,
this.domainEntities
);
const services = !devices.some((device) => device.entry_type !== "service");
const hasIcons =
Boolean(this.manifest && !this.manifest.is_built_in) ||
Boolean(this.manifest?.iot_class?.startsWith("cloud_")) ||
Boolean(
this.manifest &&
!this.manifest.config_flow &&
!this.items.every((itm) => itm.source === "system")
);
let countBadge: TemplateResult | typeof nothing = nothing;
if (devices.length > 0) {
countBadge = html`<span class="count-badge"
>${this._localize(
`ui.panel.config.integrations.config_entry.${services ? "services" : "devices"}`,
{ count: devices.length }
)}</span
>`;
} else if (entitiesCount > 0) {
countBadge = html`<span class="count-badge"
>${this._localize(
`ui.panel.config.integrations.config_entry.entities`,
{ count: entitiesCount }
)}</span
>`;
} else if (this.items.find((itm) => itm.source !== "yaml")) {
countBadge = html`<span class="count-badge"
>${this._localize(`ui.panel.config.integrations.config_entry.entries`, {
count: this.items.filter((itm) => itm.source !== "yaml").length,
})}</span
>`;
}
if (countBadge === nothing && !hasIcons) return nothing;
const placement = this._isRTL ? "right" : "left";
return html`
<div class="footer">
${countBadge}
<div class="icons">
${
this.manifest && !this.manifest.is_built_in
? html`<span
class="icon ${
this.manifest.overwrites_built_in ? "overwrites" : "custom"
}"
>
<ha-svg-icon
id="icon-custom"
.path=${mdiPackageVariant}
></ha-svg-icon>
<ha-tooltip for="icon-custom" .placement=${placement}>
${this._localize(
this.manifest.overwrites_built_in
? "ui.panel.config.integrations.config_entry.custom_overwrites_core"
: "ui.panel.config.integrations.config_entry.custom_integration"
)}
</ha-tooltip>
</span>`
: nothing
}
${
this.manifest && this.manifest.iot_class?.startsWith("cloud_")
? html`<span class="icon cloud">
<ha-svg-icon id="icon-cloud" .path=${mdiWeb}></ha-svg-icon>
<ha-tooltip for="icon-cloud" .placement=${placement}>
${this._localize(
"ui.panel.config.integrations.config_entry.depends_on_cloud"
)}
</ha-tooltip>
</span>`
: nothing
}
${
this.manifest &&
!this.manifest?.config_flow &&
!this.items.every((itm) => itm.source === "system")
? html`<span class="icon yaml">
<ha-svg-icon
id="icon-yaml"
.path=${mdiFileCodeOutline}
></ha-svg-icon>
<ha-tooltip for="icon-yaml" .placement=${placement}>
${this._localize(
"ui.panel.config.integrations.config_entry.no_config_flow"
)}
</ha-tooltip>
</span>`
: nothing
}
</div>
</div>
`;
}
private _getEntityCount = memoizeOne(
(
configEntry: ConfigEntryExtended[],
entityRegistryEntries: EntityRegistryEntry[],
domainEntities: string[]
): number => {
if (!entityRegistryEntries) {
return domainEntities.length;
}
const entryIds = configEntry
.map((entry) => entry.entry_id)
.filter(Boolean);
if (!entryIds.length) {
return domainEntities.length;
}
const entityRegEntities = entityRegistryEntries.filter(
(entity) =>
entity.config_entry_id && entryIds.includes(entity.config_entry_id)
);
if (entityRegEntities.length === domainEntities.length) {
return domainEntities.length;
}
const entityIds = new Set<string>(
entityRegEntities.map((reg) => reg.entity_id)
);
for (const entity of domainEntities) {
entityIds.add(entity);
}
return entityIds.size;
}
);
private _getDevices = memoizeOne(
(
configEntry: ConfigEntryExtended[],
deviceRegistryEntries: ContextType<typeof devicesContext>
): DeviceRegistryEntry[] => {
if (!deviceRegistryEntries) {
return [];
}
const entryIds = configEntry.map((entry) => entry.entry_id);
return Object.values(deviceRegistryEntries).filter((device) =>
device.config_entries.some((entryId) => entryIds.includes(entryId))
);
}
);
static styles = css`
:host {
display: block;
}
.footer {
display: flex;
align-items: center;
padding-top: var(--ha-space-2);
min-height: var(--ha-space-8);
}
.count-badge {
color: var(--ha-color-text-secondary);
font-size: var(--ha-font-size-m);
}
.icons {
display: flex;
margin-inline-start: auto;
}
.icon {
color: var(--label-badge-grey);
padding: var(--ha-space-1);
margin-inline-start: var(--ha-space-2);
}
.icon.custom {
color: var(--warning-color);
}
.icon.overwrites {
color: var(--error-color);
}
.icon ha-svg-icon {
width: 24px;
height: 24px;
display: block;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-integration-card-footer": HaIntegrationCardFooter;
}
}
@@ -1,178 +0,0 @@
import { mdiAlertCircleOutline, mdiAlertOutline } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consume } from "../../../common/decorators/consume";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-svg-icon";
import { configContext, uiContext } from "../../../data/context";
import type { IntegrationManifest } from "../../../data/integration";
import { domainToName } from "../../../data/integration";
import type { HomeAssistantConfig, HomeAssistantUI } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
@customElement("ha-integration-card-header")
export class HaIntegrationCardHeader extends LitElement {
@property() public error?: string;
@property() public warning?: string;
@property({ attribute: false }) public localizedDomainName?: string;
@property() public domain!: string;
@property({ attribute: false }) public manifest?: IntegrationManifest;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: uiContext, subscribe: true })
@transform<HomeAssistantUI, boolean | undefined>({
transformer: ({ themes }) => themes?.darkMode,
})
private _darkMode?: boolean;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, string>({
transformer: ({ auth }) => auth.data.hassUrl,
})
private _hassUrl!: string;
protected render(): TemplateResult {
const domainName =
this.localizedDomainName ||
domainToName(this._localize, this.domain, this.manifest);
return html`
<div class="header">
<img
alt=""
src=${brandsUrl(
{
domain: this.domain,
type: "icon",
darkOptimized: this._darkMode,
},
this._hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
@error=${this._onImageError}
@load=${this._onImageLoad}
/>
<div class="info">
<div
class="primary ${this.warning || this.error ? "has-secondary" : ""}"
role="heading"
aria-level="1"
>
${domainName}
</div>
${
this.error
? html`
<div class="secondary error">
<ha-svg-icon .path=${mdiAlertCircleOutline}></ha-svg-icon>
<span>${this.error}</span>
</div>
`
: this.warning
? html`
<div class="secondary warning">
<ha-svg-icon .path=${mdiAlertOutline}></ha-svg-icon>
<span>${this.warning}</span>
</div>
`
: nothing
}
</div>
</div>
`;
}
private _onImageLoad(ev) {
ev.target.style.visibility = "initial";
}
private _onImageError(ev) {
ev.target.style.visibility = "hidden";
}
static styles = css`
:host {
display: block;
flex: 1;
padding-bottom: var(--ha-space-2);
}
.header {
display: flex;
align-items: center;
direction: var(--direction);
min-width: 0;
}
.header img {
margin-inline-start: initial;
margin-inline-end: var(--ha-space-4);
width: 40px;
height: 40px;
direction: var(--direction);
}
.info {
display: flex;
flex-direction: column;
flex: 1;
align-self: center;
min-width: 0;
}
.primary {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-normal);
color: var(--primary-text-color);
}
.has-secondary {
-webkit-line-clamp: 1;
font-size: var(--ha-font-size-m);
}
.secondary {
min-width: 0;
--mdc-icon-size: 20px;
font-size: var(--ha-font-size-s);
display: flex;
flex-direction: row;
align-items: flex-start;
}
.secondary > span {
position: relative;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.secondary > ha-svg-icon {
margin-inline-end: var(--ha-space-1);
flex-shrink: 0;
}
.error ha-svg-icon {
color: var(--error-color);
}
.warning ha-svg-icon {
color: var(--warning-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-integration-card-header": HaIntegrationCardHeader;
}
}
@@ -1,31 +1,72 @@
import type { ContextType } from "@lit/context";
import {
mdiAlertCircle,
mdiBugOutline,
mdiFileCodeOutline,
mdiPackageVariant,
mdiWeb,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { consume } from "../../../common/decorators/consume";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-card";
import "../../../components/ha-ripple";
import "../../../components/ha-spinner";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import "../../../components/item/ha-row-item";
import type { ConfigEntry } from "../../../data/config_entries";
import { ERROR_STATES } from "../../../data/config_entries";
import {
configContext,
devicesContext,
uiContext,
} from "../../../data/context";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type {
IntegrationLogInfo,
IntegrationManifest,
} from "../../../data/integration";
import { LogSeverity } from "../../../data/integration";
import { domainToName, LogSeverity } from "../../../data/integration";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistantConfig, HomeAssistantUI } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import type { ConfigEntryExtended } from "./ha-config-integrations";
import "./ha-integration-card-footer";
import "./ha-integration-card-header";
import type { IntegrationCardSummary } from "./integration-card-summary";
import {
computeIntegrationCardState,
computeIntegrationCardSummary,
} from "./integration-card-summary";
interface IntegrationCardStatus {
level: "error" | "warning" | "progress";
/** Icon path; omitted for "progress", which shows a spinner. */
icon?: string;
message: string;
}
// Status shown for card states that need attention or are in progress.
const STATE_STATUS: Partial<
Record<ConfigEntry["state"], Omit<IntegrationCardStatus, "message">>
> = {
setup_error: { level: "error", icon: mdiAlertCircle },
setup_retry: { level: "error", icon: mdiAlertCircle },
migration_error: { level: "error", icon: mdiAlertCircle },
not_loaded: { level: "warning", icon: mdiAlertCircle },
failed_unload: { level: "warning", icon: mdiAlertCircle },
setup_in_progress: { level: "progress" },
unload_in_progress: { level: "progress" },
};
@customElement("ha-integration-card")
export class HaIntegrationCard extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property() public domain!: string;
@property({ attribute: false }) public items!: ConfigEntryExtended[];
@@ -39,22 +80,54 @@ export class HaIntegrationCard extends LitElement {
@property({ attribute: false }) public domainEntities: string[] = [];
protected render(): TemplateResult {
const entryState = this._getState(this.items);
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
const debugLoggingEnabled =
this.logInfo && this.logInfo.level === LogSeverity.DEBUG;
@state()
@consume({ context: devicesContext, subscribe: true })
private _devices!: ContextType<typeof devicesContext>;
@state()
@consume({ context: uiContext, subscribe: true })
@transform<HomeAssistantUI, boolean | undefined>({
transformer: ({ themes }) => themes?.darkMode,
})
private _darkMode?: boolean;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, string>({
transformer: ({ auth }) => auth.data.hassUrl,
})
private _hassUrl!: string;
// There is no hover on touch devices, so a tap on a tooltip anchor shows
// the tooltip instead of following the card link.
private _lastPointerType?: string;
protected render(): TemplateResult {
const entryState = computeIntegrationCardState(this.items);
const isError = ERROR_STATES.includes(entryState);
const debugLoggingEnabled = this.logInfo?.level === LogSeverity.DEBUG;
const status = this._computeStatus(entryState, debugLoggingEnabled);
const domainName =
this.items[0].localized_domain_name ||
domainToName(this._localize, this.domain, this.manifest);
return html`
<ha-card
outlined
class=${classMap({
"state-loaded": entryState === "loaded",
"state-not-loaded": entryState === "not_loaded",
"all-not-loaded": this.items.every(
(entry) => entry.state === "not_loaded"
),
"state-failed-unload": entryState === "failed_unload",
"state-setup": entryState === "setup_in_progress",
"state-error": ERROR_STATES.includes(entryState),
"debug-logging": Boolean(debugLoggingEnabled),
"state-error": isError,
"debug-logging": debugLoggingEnabled,
})}
>
<a
@@ -62,55 +135,291 @@ export class HaIntegrationCard extends LitElement {
class="ripple-anchor"
>
<ha-ripple></ha-ripple>
<div class="card-content">
<ha-integration-card-header
.domain=${this.domain}
.localizedDomainName=${this.items[0].localized_domain_name}
.error=${
ERROR_STATES.includes(entryState)
? this._localize(
`ui.panel.config.integrations.config_entry.state.${entryState}`
)
: undefined
}
.warning=${
entryState !== "loaded" && !ERROR_STATES.includes(entryState)
? this._localize(
`ui.panel.config.integrations.config_entry.state.${entryState}`
)
: debugLoggingEnabled
? this._localize(
"ui.panel.config.integrations.config_entry.debug_logging_enabled"
)
: undefined
}
.manifest=${this.manifest}
></ha-integration-card-header>
<ha-integration-card-footer
.manifest=${this.manifest}
.items=${this.items}
.entityRegistryEntries=${this.entityRegistryEntries}
.domainEntities=${this.domainEntities}
></ha-integration-card-footer>
</div>
<ha-row-item>
<img
slot="start"
alt=""
src=${brandsUrl(
{
domain: this.domain,
type: "icon",
darkOptimized: this._darkMode,
},
this._hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
@error=${this._onImageError}
@load=${this._onImageLoad}
/>
<span slot="headline" class="name" role="heading" aria-level="1"
>${domainName}</span
>
${this._renderSupportingText(status)}
</ha-row-item>
</a>
</ha-card>
`;
}
private _getState = memoizeOne(
(configEntry: ConfigEntry[]): ConfigEntry["state"] => {
if (configEntry.length === 1) {
return configEntry[0].state;
private _computeStatus(
entryState: ConfigEntry["state"],
debugLoggingEnabled: boolean
): IntegrationCardStatus | undefined {
const stateStatus = STATE_STATUS[entryState];
if (stateStatus) {
return {
...stateStatus,
message: this._localize(
`ui.panel.config.integrations.config_entry.state.${entryState}`
),
};
}
if (debugLoggingEnabled) {
return {
level: "warning",
icon: mdiBugOutline,
message: this._localize(
"ui.panel.config.integrations.config_entry.debug_logging_enabled"
),
};
}
return undefined;
}
private _computeSupportingText(
summary?: IntegrationCardSummary,
status?: IntegrationCardStatus
): string | undefined {
// Initializing, unloading and debug logging have no attention count,
// so their message is shown instead of the plain count.
if (status && !summary?.attention) {
return status.message;
}
if (!summary) {
return undefined;
}
if (!summary.attention) {
return this._localize(
`ui.panel.config.integrations.config_entry.${summary.unit}`,
{ count: summary.count }
);
}
const attentionState = summary.attentionState ?? "other";
if (summary.attention >= summary.count) {
return this._localize(
`ui.panel.config.integrations.config_entry.attention.${summary.unit}_all`,
{ count: summary.count, state: attentionState }
);
}
return this._localize(
`ui.panel.config.integrations.config_entry.attention.${summary.unit}_some`,
{
count: summary.count,
attention: summary.attention,
state: attentionState,
}
let entryState: ConfigEntry["state"];
for (const entry of configEntry) {
if (ERROR_STATES.includes(entry.state)) {
return entry.state;
);
}
private _renderSupportingText(
status?: IntegrationCardStatus
): TemplateResult | typeof nothing {
const devices = this._getDevices(this.items, this._devices);
const summary = computeIntegrationCardSummary(
this.items,
devices,
this.entityRegistryEntries,
devices.length
? 0
: this._getEntityCount(
this.items,
this.entityRegistryEntries,
this.domainEntities
)
);
const icons = this._renderIcons();
if (!summary && !status && icons === nothing) {
return nothing;
}
const text = this._computeSupportingText(summary, status);
return html`<span slot="supporting-text" class="supporting-text">
${
status
? html`<span class="status status-${status.level}">
${
status.icon
? html`<ha-svg-icon
.path=${status.icon}
aria-hidden="true"
></ha-svg-icon>`
: html`<ha-spinner
size="tiny"
aria-hidden="true"
></ha-spinner>`
}
${text ? html`<span class="text">${text}</span>` : nothing}
</span>`
: text
? html`<span class="text">${text}</span>`
: nothing
}
${icons}
</span>`;
}
private _renderIcons(): TemplateResult | typeof nothing {
const manifest = this.manifest;
if (!manifest) {
return nothing;
}
const isCustom = !manifest.is_built_in;
const isCloud = Boolean(manifest.iot_class?.startsWith("cloud_"));
const isYaml =
!manifest.config_flow &&
!this.items.every((itm) => itm.source === "system");
if (!isCustom && !isCloud && !isYaml) {
return nothing;
}
return html`
<span class="icons">
${
isCustom
? this._renderIcon(
"icon-custom",
mdiPackageVariant,
this._localize(
manifest.overwrites_built_in
? "ui.panel.config.integrations.config_entry.custom_overwrites_core"
: "ui.panel.config.integrations.config_entry.custom_integration"
),
manifest.overwrites_built_in ? "overwrites" : "custom"
)
: nothing
}
entryState = entry.state;
${
isCloud
? this._renderIcon(
"icon-cloud",
mdiWeb,
this._localize(
"ui.panel.config.integrations.config_entry.depends_on_cloud"
)
)
: nothing
}
${
isYaml
? this._renderIcon(
"icon-yaml",
mdiFileCodeOutline,
this._localize(
"ui.panel.config.integrations.config_entry.no_config_flow"
)
)
: nothing
}
</span>
`;
}
private _renderIcon(
id: string,
path: string,
label: string,
className = ""
): TemplateResult {
return html`<span
id=${id}
class="icon ${className}"
role="img"
aria-label=${label}
@pointerdown=${this._handleTooltipPointerDown}
@click=${this._handleTooltipClick}
>
<ha-svg-icon .path=${path}></ha-svg-icon>
</span>
<ha-tooltip for=${id} trigger="hover focus click">${label}</ha-tooltip>`;
}
private _handleTooltipPointerDown(ev: PointerEvent) {
this._lastPointerType = ev.pointerType;
}
private _handleTooltipClick(ev: MouseEvent) {
if (this._lastPointerType !== "touch") {
return;
}
ev.preventDefault();
ev.stopPropagation();
}
private _onImageLoad(ev: Event) {
(ev.target as HTMLImageElement).style.visibility = "initial";
}
private _onImageError(ev: Event) {
(ev.target as HTMLImageElement).style.visibility = "hidden";
}
private _getEntityCount = memoizeOne(
(
configEntry: ConfigEntryExtended[],
entityRegistryEntries: EntityRegistryEntry[],
domainEntities: string[]
): number => {
if (!entityRegistryEntries) {
return domainEntities.length;
}
return entryState!;
const entryIds = configEntry
.map((entry) => entry.entry_id)
.filter(Boolean);
if (!entryIds.length) {
return domainEntities.length;
}
const entityRegEntities = entityRegistryEntries.filter(
(entity) =>
entity.config_entry_id && entryIds.includes(entity.config_entry_id)
);
if (entityRegEntities.length === domainEntities.length) {
return domainEntities.length;
}
const entityIds = new Set<string>(
entityRegEntities.map((reg) => reg.entity_id)
);
for (const entity of domainEntities) {
entityIds.add(entity);
}
return entityIds.size;
}
);
private _getDevices = memoizeOne(
(
configEntry: ConfigEntryExtended[],
deviceRegistryEntries: ContextType<typeof devicesContext>
): DeviceRegistryEntry[] => {
if (!deviceRegistryEntries) {
return [];
}
const entryIds = configEntry.map((entry) => entry.entry_id);
return Object.values(deviceRegistryEntries).filter((device) =>
device.config_entries.some((entryId) => entryIds.includes(entryId))
);
}
);
@@ -125,7 +434,6 @@ export class HaIntegrationCard extends LitElement {
overflow: hidden;
cursor: pointer;
--state-color: var(--divider-color, #e0e0e0);
--state-message-color: var(--state-color);
}
ha-card:hover {
background-color: var(--ha-color-fill-neutral-quiet-resting);
@@ -148,41 +456,109 @@ export class HaIntegrationCard extends LitElement {
background-color: var(--secondary-text-color);
opacity: 0.08;
}
.card-content {
padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
ha-row-item {
flex: 1;
display: flex;
flex-direction: column;
--ha-row-item-padding-block: var(--ha-space-4);
--ha-row-item-padding-inline: var(--ha-space-4);
--ha-row-item-gap: var(--ha-space-4);
}
/* Fill the card height so content stays centered when a neighboring
card in the grid row is taller */
ha-row-item::part(base) {
flex: 1;
min-width: 0;
}
ha-row-item::part(headline) {
font-size: var(--ha-font-size-l);
}
.name {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
img {
width: 40px;
height: 40px;
}
.supporting-text {
display: flex;
align-items: center;
gap: var(--ha-space-1);
min-height: calc(
var(--ha-font-size-s) * var(--ha-line-height-normal)
);
}
.supporting-text .text,
.status .text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.status {
display: flex;
align-items: center;
gap: var(--ha-space-1);
min-width: 0;
line-height: 16px;
}
.status ha-svg-icon,
.status ha-spinner {
flex-shrink: 0;
--mdc-icon-size: 16px;
}
.status ha-spinner {
--track-width: 2px;
}
.status-error ha-svg-icon {
color: var(--error-color);
}
.status-warning ha-svg-icon {
color: var(--warning-color);
}
.icons {
display: flex;
flex-shrink: 0;
margin-inline-start: auto;
margin-inline-end: calc(var(--ha-space-1) * -1);
}
.icon {
display: flex;
padding: var(--ha-space-1);
/* 24px touch target around the 16px icon */
margin-block: calc(var(--ha-space-1) * -1);
color: var(--label-badge-grey);
--mdc-icon-size: 16px;
}
.icon.custom {
color: var(--warning-color);
}
.icon.overwrites {
color: var(--error-color);
}
.debug-logging {
--state-color: var(--warning-color);
--ha-card-border-color: var(--state-color);
--text-on-state-color: var(--primary-text-color);
}
.state-error {
--state-color: var(--error-color);
--ha-card-border-color: var(--state-color);
--text-on-state-color: var(--text-primary-color);
}
.state-failed-unload {
--state-color: var(--warning-color);
--ha-card-border-color: var(--state-color);
--text-on-state-color: var(--primary-text-color);
}
.state-not-loaded {
opacity: 0.8;
--state-color: var(--warning-color);
--ha-card-border-color: var(--state-color);
--state-message-color: var(--primary-text-color);
}
.state-setup {
.all-not-loaded {
opacity: 0.8;
--state-message-color: var(--secondary-text-color);
}
:host(.highlight) ha-card {
--state-color: var(--primary-color);
--ha-card-border-color: var(--state-color);
--text-on-state-color: var(--text-primary-color);
}
a {
text-decoration: none;
@@ -0,0 +1,136 @@
import type { ConfigEntry } from "../../../data/config_entries";
import { ERROR_STATES } from "../../../data/config_entries";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
export type IntegrationCardSummaryUnit =
"devices" | "services" | "entities" | "entries";
export interface IntegrationCardSummary {
unit: IntegrationCardSummaryUnit;
/** Total number of items of `unit`. */
count: number;
/** Number of items of `unit` that belong to a config entry needing attention. */
attention: number;
/**
* The state shared by all config entries needing attention, or `undefined`
* when they are in different states.
*/
attentionState?: ConfigEntry["state"];
}
// Transient states (setup/unload in progress) and "loaded" don't need attention.
const ATTENTION_STATES: ConfigEntry["state"][] = [
...ERROR_STATES,
"not_loaded",
"failed_unload",
];
// Card state priority, most important first.
const CARD_STATE_PRIORITY: ConfigEntry["state"][] = [
...ATTENTION_STATES,
"setup_in_progress",
"unload_in_progress",
];
export const configEntryNeedsAttention = (entry: ConfigEntry): boolean =>
ATTENTION_STATES.includes(entry.state);
/**
* The state the integration card shows for a group of config entries: the
* most important state of any entry, so the card never looks healthy while
* one of its entries needs attention.
*/
export const computeIntegrationCardState = (
entries: ConfigEntry[]
): ConfigEntry["state"] => {
for (const cardState of CARD_STATE_PRIORITY) {
if (entries.some((entry) => entry.state === cardState)) {
return cardState;
}
}
return "loaded";
};
/**
* Compute what the integration card shows as supporting text: the number of
* devices, services, entities or entries, and how many of those belong to a
* config entry that needs attention.
*
* When an entry needing attention owns none of the counted items (for
* example an entry that never set up and has no devices yet), the summary
* counts entries instead so the problem is never hidden.
*/
export const computeIntegrationCardSummary = (
entries: ConfigEntry[],
devices: DeviceRegistryEntry[],
entityRegistryEntries: EntityRegistryEntry[],
entityCount: number
): IntegrationCardSummary | undefined => {
const attentionEntries = entries.filter(configEntryNeedsAttention);
const attentionEntryIds = new Set(
attentionEntries.map((entry) => entry.entry_id)
);
const attentionStates = new Set(attentionEntries.map((entry) => entry.state));
const attentionState =
attentionStates.size === 1 ? [...attentionStates][0] : undefined;
const uiEntries = entries.filter((entry) => entry.source !== "yaml");
// Attention entries that own at least one of the counted items.
const representedEntryIds = new Set<string>();
let summary: IntegrationCardSummary | undefined;
if (devices.length) {
let attention = 0;
for (const device of devices) {
const deviceAttentionIds = device.config_entries.filter((id) =>
attentionEntryIds.has(id)
);
if (deviceAttentionIds.length) {
attention++;
deviceAttentionIds.forEach((id) => representedEntryIds.add(id));
}
}
summary = {
unit: devices.every((device) => device.entry_type === "service")
? "services"
: "devices",
count: devices.length,
attention,
};
} else if (entityCount > 0) {
let attention = 0;
for (const entity of entityRegistryEntries) {
if (
entity.config_entry_id &&
attentionEntryIds.has(entity.config_entry_id)
) {
attention++;
representedEntryIds.add(entity.config_entry_id);
}
}
summary = {
unit: "entities",
count: entityCount,
attention: Math.min(entityCount, attention),
};
} else if (uiEntries.length) {
return {
unit: "entries",
count: uiEntries.length,
attention: attentionEntryIds.size,
attentionState,
};
}
if (representedEntryIds.size < attentionEntryIds.size) {
return {
unit: "entries",
count: Math.max(uiEntries.length, attentionEntryIds.size),
attention: attentionEntryIds.size,
attentionState,
};
}
return summary && { ...summary, attentionState };
};
+10
View File
@@ -7528,6 +7528,16 @@
"entities": "{count} {count, plural,\n one {entity}\n other {entities}\n}",
"services": "{count} {count, plural,\n one {service}\n other {services}\n}",
"entries": "{count} {count, plural,\n one {entry}\n other {entries}\n}",
"attention": {
"devices_some": "{attention} of {count} {count, plural,\n one {device}\n other {devices}\n} {state, select,\n setup_error {failed to set up}\n setup_retry {failed to set up, retrying}\n migration_error {failed to migrate}\n failed_unload {failed to unload}\n not_loaded {{attention, plural,\n one {is}\n other {are}\n } not loaded}\n other {{attention, plural,\n one {needs}\n other {need}\n } attention}\n}",
"devices_all": "{count} {count, plural,\n one {device}\n other {devices}\n} {state, select,\n setup_error {failed to set up}\n setup_retry {failed to set up, retrying}\n migration_error {failed to migrate}\n failed_unload {failed to unload}\n not_loaded {{count, plural,\n one {is}\n other {are}\n } not loaded}\n other {{count, plural,\n one {needs}\n other {need}\n } attention}\n}",
"services_some": "{attention} of {count} {count, plural,\n one {service}\n other {services}\n} {state, select,\n setup_error {failed to set up}\n setup_retry {failed to set up, retrying}\n migration_error {failed to migrate}\n failed_unload {failed to unload}\n not_loaded {{attention, plural,\n one {is}\n other {are}\n } not loaded}\n other {{attention, plural,\n one {needs}\n other {need}\n } attention}\n}",
"services_all": "{count} {count, plural,\n one {service}\n other {services}\n} {state, select,\n setup_error {failed to set up}\n setup_retry {failed to set up, retrying}\n migration_error {failed to migrate}\n failed_unload {failed to unload}\n not_loaded {{count, plural,\n one {is}\n other {are}\n } not loaded}\n other {{count, plural,\n one {needs}\n other {need}\n } attention}\n}",
"entities_some": "{attention} of {count} {count, plural,\n one {entity}\n other {entities}\n} {state, select,\n setup_error {failed to set up}\n setup_retry {failed to set up, retrying}\n migration_error {failed to migrate}\n failed_unload {failed to unload}\n not_loaded {{attention, plural,\n one {is}\n other {are}\n } not loaded}\n other {{attention, plural,\n one {needs}\n other {need}\n } attention}\n}",
"entities_all": "{count} {count, plural,\n one {entity}\n other {entities}\n} {state, select,\n setup_error {failed to set up}\n setup_retry {failed to set up, retrying}\n migration_error {failed to migrate}\n failed_unload {failed to unload}\n not_loaded {{count, plural,\n one {is}\n other {are}\n } not loaded}\n other {{count, plural,\n one {needs}\n other {need}\n } attention}\n}",
"entries_some": "{attention} of {count} {count, plural,\n one {entry}\n other {entries}\n} {state, select,\n setup_error {failed to set up}\n setup_retry {failed to set up, retrying}\n migration_error {failed to migrate}\n failed_unload {failed to unload}\n not_loaded {{attention, plural,\n one {is}\n other {are}\n } not loaded}\n other {{attention, plural,\n one {needs}\n other {need}\n } attention}\n}",
"entries_all": "{count} {count, plural,\n one {entry}\n other {entries}\n} {state, select,\n setup_error {failed to set up}\n setup_retry {failed to set up, retrying}\n migration_error {failed to migrate}\n failed_unload {failed to unload}\n not_loaded {{count, plural,\n one {is}\n other {are}\n } not loaded}\n other {{count, plural,\n one {needs}\n other {need}\n } attention}\n}"
},
"no_devices_or_entities": "No devices or entities",
"devices_without_subentry": "Devices that don't belong to a sub-entry",
"copy": "Copy entry ID",
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import type { ConfigEntry } from "../../../../src/data/config_entries";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../src/data/entity/entity_registry";
import {
computeIntegrationCardState,
computeIntegrationCardSummary,
} from "../../../../src/panels/config/integrations/integration-card-summary";
const entry = (
entry_id: string,
state: ConfigEntry["state"] = "loaded",
source = "user"
) => ({ entry_id, state, source }) as ConfigEntry;
const device = (
config_entries: string[],
entry_type: DeviceRegistryEntry["entry_type"] = null
) => ({ config_entries, entry_type }) as DeviceRegistryEntry;
const entity = (config_entry_id: string) =>
({ config_entry_id }) as EntityRegistryEntry;
describe("computeIntegrationCardState", () => {
it("returns the most important state regardless of entry order", () => {
expect(
computeIntegrationCardState([entry("a", "not_loaded"), entry("b")])
).toBe("not_loaded");
expect(
computeIntegrationCardState([
entry("a", "setup_in_progress"),
entry("b", "setup_retry"),
])
).toBe("setup_retry");
expect(computeIntegrationCardState([entry("a"), entry("b")])).toBe(
"loaded"
);
});
});
describe("computeIntegrationCardSummary", () => {
it("counts devices, services and entities that need attention", () => {
expect(
computeIntegrationCardSummary(
[entry("a"), entry("b", "setup_error")],
[device(["a"]), device(["b"])],
[],
0
)
).toEqual({
unit: "devices",
count: 2,
attention: 1,
attentionState: "setup_error",
});
expect(
computeIntegrationCardSummary(
[entry("a", "setup_retry")],
[device(["a"], "service")],
[],
0
)
).toEqual({
unit: "services",
count: 1,
attention: 1,
attentionState: "setup_retry",
});
expect(
computeIntegrationCardSummary(
[entry("a"), entry("b", "not_loaded")],
[],
[entity("a"), entity("b"), entity("b")],
3
)
).toEqual({
unit: "entities",
count: 3,
attention: 2,
attentionState: "not_loaded",
});
});
it("has no shared attention state when entries fail differently", () => {
expect(
computeIntegrationCardSummary(
[entry("a", "setup_error"), entry("b", "not_loaded")],
[device(["a"]), device(["b"])],
[],
0
)?.attentionState
).toBeUndefined();
});
it("counts entries when a failing entry owns none of the counted items", () => {
// "c" failed before creating any device, so counting devices would hide it.
expect(
computeIntegrationCardSummary(
[entry("a"), entry("b", "setup_error"), entry("c", "setup_error")],
[device(["a"]), device(["b"])],
[],
0
)
).toEqual({
unit: "entries",
count: 3,
attention: 2,
attentionState: "setup_error",
});
expect(
computeIntegrationCardSummary(
[entry("a"), entry("b", "setup_retry")],
[],
[entity("a")],
1
)
).toEqual({
unit: "entries",
count: 2,
attention: 1,
attentionState: "setup_retry",
});
});
it("ignores YAML entries when counting entries", () => {
expect(
computeIntegrationCardSummary(
[entry("a", "loaded", "yaml"), entry("b")],
[],
[],
0
)
).toEqual({ unit: "entries", count: 1, attention: 0 });
expect(
computeIntegrationCardSummary([entry("a", "loaded", "yaml")], [], [], 0)
).toBeUndefined();
});
});