Compare commits

...
Author SHA1 Message Date
WendelinandClaude Opus 5.5 8c1e82bdb2 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-06 10:24:19 +02:00
7 changed files with 521 additions and 540 deletions
@@ -110,6 +110,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",
@@ -330,6 +354,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>
`;
}
@@ -7,6 +7,7 @@ import {
} from "../../../data/integration";
import type { HomeAssistant } from "../../../types";
import "../../../components/ha-card";
import "../../../components/item/ha-row-item";
import { brandsUrl } from "../../../util/brands-url";
import { haStyle } from "../../../resources/styles";
@@ -27,8 +28,9 @@ export class HaIntegrationActionCard extends LitElement {
protected render(): TemplateResult {
return html`
<ha-card outlined>
<div class="card-content">
<ha-row-item>
<img
slot="start"
alt=""
src=${brandsUrl(
{
@@ -43,19 +45,20 @@ export class HaIntegrationActionCard extends LitElement {
@error=${this._onImageError}
@load=${this._onImageLoad}
/>
<h2>${this.label}</h2>
<h3>
<span slot="headline" role="heading" aria-level="2"
>${this.label}</span
>
<span slot="supporting-text">
${
this.localizedDomainName &&
this.localizedDomainName !== this.domain
? this.localizedDomainName
: domainToName(this.hass.localize, this.domain, this.manifest)
}
</h3>
</div>
<div class="filler"></div>
<div class="card-actions"><slot></slot></div>
<div class="header-button"><slot name="header-button"></slot></div>
</span>
<slot name="header-button" slot="end"></slot>
</ha-row-item>
<div class="actions"><slot></slot></div>
</ha-card>
`;
}
@@ -74,61 +77,35 @@ export class HaIntegrationActionCard extends LitElement {
ha-card {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
}
ha-row-item {
--ha-row-item-padding-block: var(--ha-space-4) var(--ha-space-2);
--ha-row-item-padding-inline: var(--ha-space-4) var(--ha-space-2);
--ha-row-item-gap: var(--ha-space-4);
}
ha-row-item::part(headline) {
white-space: normal;
overflow-wrap: anywhere;
font-size: var(--ha-font-size-l);
}
/* Pull the 48px icon button flush into the card corner */
ha-row-item::part(end) {
align-self: flex-start;
margin-block-start: calc(var(--ha-space-4) * -1);
margin-inline-end: calc(var(--ha-space-2) * -1);
}
img {
width: 40px;
height: 40px;
}
h2 {
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-normal);
margin-top: 8px;
margin-bottom: 0;
max-width: 100%;
}
h3 {
font-size: var(--ha-font-size-m);
margin: 0;
max-width: 100%;
text-align: center;
}
.header-button {
position: absolute;
top: 8px;
right: 8px;
inset-inline-end: 8px;
inset-inline-start: initial;
}
.filler {
flex: 1;
}
.attention {
--state-color: var(--error-color);
--text-on-state-color: var(--text-primary-color);
}
.card-content {
.actions {
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
}
.card-actions {
border-top: none;
padding-top: 0;
padding-bottom: 16px;
justify-content: center;
display: flex;
}
:host ::slotted(*) {
margin-right: 8px;
margin-inline-end: 8px;
margin-inline-start: initial;
}
:host ::slotted(:last-child) {
margin-right: 0;
margin-inline-end: 0;
margin-inline-start: initial;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--ha-space-2);
padding: 0 var(--ha-space-4) var(--ha-space-4);
}
`,
];
@@ -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,52 @@
import type { ContextType } from "@lit/context";
import {
mdiExclamationThick,
mdiFileCodeOutline,
mdiPackageVariant,
mdiWeb,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { LitElement, css, html, 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 { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/ha-card";
import "../../../components/ha-ripple";
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,
internationalizationContext,
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,
HomeAssistantInternationalization,
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 { computeIntegrationCardSummary } from "./integration-card-summary";
@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 +60,78 @@ export class HaIntegrationCard extends LitElement {
@property({ attribute: false }) public domainEntities: string[] = [];
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@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;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, boolean>({
transformer: ({ language, translationMetadata }) =>
computeRTL(language, translationMetadata.translations),
})
private _isRTL!: boolean;
protected render(): TemplateResult {
const entryState = this._getState(this.items);
const isError = ERROR_STATES.includes(entryState);
const debugLoggingEnabled = this.logInfo?.level === LogSeverity.DEBUG;
const debugLoggingEnabled =
this.logInfo && this.logInfo.level === LogSeverity.DEBUG;
let status: { level: "error" | "warning"; message: string } | undefined;
if (isError) {
status = {
level: "error",
message: this._localize(
`ui.panel.config.integrations.config_entry.state.${entryState}`
),
};
} else if (entryState === "not_loaded" || entryState === "failed_unload") {
status = {
level: "warning",
message: this._localize(
`ui.panel.config.integrations.config_entry.state.${entryState}`
),
};
} else if (debugLoggingEnabled) {
status = {
level: "warning",
message: this._localize(
"ui.panel.config.integrations.config_entry.debug_logging_enabled"
),
};
}
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",
"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,42 +139,159 @@ 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
<ha-row-item>
<div slot="start" class="icon-wrapper">
<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}
/>
${
status
? html`<span id="status-badge" class="badge ${status.level}">
<ha-svg-icon .path=${mdiExclamationThick}></ha-svg-icon>
</span>
<ha-tooltip for="status-badge"
>${status.message}</ha-tooltip
>`
: nothing
}
.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>
</div>
<span slot="headline" role="heading" aria-level="1"
>${domainName}</span
>
${this._renderSupportingText()} ${this._renderIcons()}
</ha-row-item>
</a>
</ha-card>
`;
}
private _renderSupportingText(): 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
)
);
if (!summary) {
return nothing;
}
const { unit, count, attention } = summary;
let text: string;
if (!attention) {
text = this._localize(
`ui.panel.config.integrations.config_entry.${unit}`,
{ count }
);
} else if (attention >= count) {
text = this._localize(
`ui.panel.config.integrations.config_entry.attention.${unit}_all`,
{ count }
);
} else {
text = this._localize(
`ui.panel.config.integrations.config_entry.attention.${unit}_some`,
{ count, attention }
);
}
return html`<span slot="supporting-text">${text}</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;
}
const placement = this._isRTL ? "right" : "left";
return html`
<div slot="end" class="icons">
${
isCustom
? html`<span
id="icon-custom"
class="icon ${manifest.overwrites_built_in ? "overwrites" : "custom"}"
>
<ha-svg-icon .path=${mdiPackageVariant}></ha-svg-icon>
</span>
<ha-tooltip for="icon-custom" .placement=${placement}>
${this._localize(
manifest.overwrites_built_in
? "ui.panel.config.integrations.config_entry.custom_overwrites_core"
: "ui.panel.config.integrations.config_entry.custom_integration"
)}
</ha-tooltip>`
: nothing
}
${
isCloud
? html`<span id="icon-cloud" class="icon">
<ha-svg-icon .path=${mdiWeb}></ha-svg-icon>
</span>
<ha-tooltip for="icon-cloud" .placement=${placement}>
${this._localize(
"ui.panel.config.integrations.config_entry.depends_on_cloud"
)}
</ha-tooltip>`
: nothing
}
${
isYaml
? html`<span id="icon-yaml" class="icon">
<ha-svg-icon .path=${mdiFileCodeOutline}></ha-svg-icon>
</span>
<ha-tooltip for="icon-yaml" .placement=${placement}>
${this._localize(
"ui.panel.config.integrations.config_entry.no_config_flow"
)}
</ha-tooltip>`
: nothing
}
</div>
`;
}
private _onImageLoad(ev: Event) {
(ev.target as HTMLImageElement).style.visibility = "initial";
}
private _onImageError(ev: Event) {
(ev.target as HTMLImageElement).style.visibility = "hidden";
}
private _getState = memoizeOne(
(configEntry: ConfigEntry[]): ConfigEntry["state"] => {
if (configEntry.length === 1) {
@@ -114,6 +308,60 @@ export class HaIntegrationCard extends LitElement {
}
);
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 get styles(): CSSResultGroup {
return [
haStyle,
@@ -125,7 +373,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 +395,91 @@ 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;
}
ha-row-item::part(headline) {
white-space: normal;
overflow-wrap: anywhere;
font-size: var(--ha-font-size-l);
}
.icon-wrapper {
position: relative;
width: 40px;
height: 40px;
}
.icon-wrapper img {
width: 100%;
height: 100%;
}
.badge {
position: absolute;
top: calc(var(--ha-space-1) * -1);
inset-inline-start: calc(var(--ha-space-1) * -1);
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
box-sizing: border-box;
border: 1.5px solid currentColor;
border-radius: var(--ha-border-radius-circle);
background-color: var(--card-background-color);
--mdc-icon-size: 12px;
}
.badge.error {
color: var(--error-color);
}
.badge.warning {
color: var(--warning-color);
}
.icons {
display: flex;
gap: var(--ha-space-2);
}
.icon {
display: flex;
padding: var(--ha-space-1);
color: var(--label-badge-grey);
}
.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 {
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,89 @@
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;
}
// Transient states (setup/unload in progress) and "loaded" don't need attention.
const ATTENTION_STATES: ConfigEntry["state"][] = [
...ERROR_STATES,
"not_loaded",
"failed_unload",
];
export const configEntryNeedsAttention = (entry: ConfigEntry): boolean =>
ATTENTION_STATES.includes(entry.state);
/**
* 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 some entries need attention but own none of the counted items (for
* example an entry that never set up and has no devices yet), the summary
* falls back to counting entries so the problem is never hidden.
*/
export const computeIntegrationCardSummary = (
entries: ConfigEntry[],
devices: DeviceRegistryEntry[],
entityRegistryEntries: EntityRegistryEntry[],
entityCount: number
): IntegrationCardSummary | undefined => {
const attentionEntryIds = new Set(
entries.filter(configEntryNeedsAttention).map((entry) => entry.entry_id)
);
const uiEntries = entries.filter((entry) => entry.source !== "yaml");
let summary: IntegrationCardSummary | undefined;
if (devices.length) {
summary = {
unit: devices.every((device) => device.entry_type === "service")
? "services"
: "devices",
count: devices.length,
attention: devices.filter((device) =>
device.config_entries.some((id) => attentionEntryIds.has(id))
).length,
};
} else if (entityCount > 0) {
summary = {
unit: "entities",
count: entityCount,
attention: Math.min(
entityCount,
entityRegistryEntries.filter(
(entity) =>
entity.config_entry_id &&
attentionEntryIds.has(entity.config_entry_id)
).length
),
};
} else if (uiEntries.length) {
summary = {
unit: "entries",
count: uiEntries.length,
attention: attentionEntryIds.size,
};
}
if (attentionEntryIds.size && !summary?.attention) {
return {
unit: "entries",
count: Math.max(uiEntries.length, attentionEntryIds.size),
attention: attentionEntryIds.size,
};
}
return summary;
};
+10
View File
@@ -7456,6 +7456,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} {attention, plural,\n one {needs}\n other {need}\n} attention",
"devices_all": "{count} {count, plural,\n one {device needs}\n other {devices need}\n} attention",
"services_some": "{attention} of {count} {count, plural,\n one {service}\n other {services}\n} {attention, plural,\n one {needs}\n other {need}\n} attention",
"services_all": "{count} {count, plural,\n one {service needs}\n other {services need}\n} attention",
"entities_some": "{attention} of {count} {count, plural,\n one {entity}\n other {entities}\n} {attention, plural,\n one {needs}\n other {need}\n} attention",
"entities_all": "{count} {count, plural,\n one {entity needs}\n other {entities need}\n} attention",
"entries_some": "{attention} of {count} {count, plural,\n one {entry}\n other {entries}\n} {attention, plural,\n one {needs}\n other {need}\n} attention",
"entries_all": "{count} {count, plural,\n one {entry needs}\n other {entries need}\n} attention"
},
"no_devices_or_entities": "No devices or entities",
"devices_without_subentry": "Devices that don't belong to a sub-entry",
"copy": "Copy entry ID",