mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-09 14:46:37 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c1e82bdb2 |
@@ -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;
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user