home-assistant-frontend/src/panels/config/integrations/ha-integration-card.ts

814 lines
25 KiB
TypeScript

import "@material/mwc-button";
import "@material/mwc-list/mwc-list-item";
import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item";
import {
mdiAlertCircle,
mdiChevronLeft,
mdiDotsVertical,
mdiOpenInNew,
} from "@mdi/js";
import "@polymer/paper-item";
import "@polymer/paper-listbox";
import "@polymer/paper-tooltip/paper-tooltip";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
import "../../../components/ha-button-menu";
import "../../../components/ha-card";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-svg-icon";
import { getSignedPath } from "../../../data/auth";
import {
ConfigEntry,
deleteConfigEntry,
disableConfigEntry,
DisableConfigEntryResult,
enableConfigEntry,
reloadConfigEntry,
updateConfigEntry,
ERROR_STATES,
} from "../../../data/config_entries";
import type { DeviceRegistryEntry } from "../../../data/device_registry";
import { getConfigEntryDiagnosticsDownloadUrl } from "../../../data/diagnostics";
import type { EntityRegistryEntry } from "../../../data/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
import { integrationIssuesUrl } from "../../../data/integration";
import { showConfigEntrySystemOptionsDialog } from "../../../dialogs/config-entry-system-options/show-dialog-config-entry-system-options";
import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow";
import {
showAlertDialog,
showConfirmationDialog,
showPromptDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { haStyle, haStyleScrollbar } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { fileDownload } from "../../../util/file_download";
import type { ConfigEntryExtended } from "./ha-config-integrations";
import "./ha-integration-header";
const integrationsWithPanel = {
hassio: "/hassio/dashboard",
mqtt: "/config/mqtt",
zha: "/config/zha/dashboard",
zwave_js: "/config/zwave_js/dashboard",
};
@customElement("ha-integration-card")
export class HaIntegrationCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public domain!: string;
@property() public items!: ConfigEntryExtended[];
@property() public manifest?: IntegrationManifest;
@property() public entityRegistryEntries!: EntityRegistryEntry[];
@property() public deviceRegistryEntries!: DeviceRegistryEntry[];
@property() public selectedConfigEntryId?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public supportsDiagnostics = false;
protected render(): TemplateResult {
let item = this._selectededConfigEntry;
if (this.items.length === 1) {
item = this.items[0];
} else if (this.selectedConfigEntryId) {
item = this.items.find(
(entry) => entry.entry_id === this.selectedConfigEntryId
);
}
const hasItem = item !== undefined;
return html`
<ha-card
outlined
class=${classMap({
single: hasItem,
group: !hasItem,
hasMultiple: this.items.length > 1,
disabled: this.disabled,
"state-not-loaded": hasItem && item!.state === "not_loaded",
"state-failed-unload": hasItem && item!.state === "failed_unload",
"state-error": hasItem && ERROR_STATES.includes(item!.state),
})}
.configEntry=${item}
>
<ha-integration-header
.hass=${this.hass}
.banner=${this.disabled
? this.hass.localize(
"ui.panel.config.integrations.config_entry.disable.disabled"
)
: undefined}
.domain=${this.domain}
.label=${item
? item.title || item.localized_domain_name || this.domain
: undefined}
.localizedDomainName=${item ? item.localized_domain_name : undefined}
.manifest=${this.manifest}
.configEntry=${item}
>
${this.items.length > 1
? html`
<div class="back-btn" slot="above-header">
<ha-icon-button
.path=${mdiChevronLeft}
@click=${this._back}
.label=${this.hass.localize("ui.common.back")}
></ha-icon-button>
</div>
`
: ""}
</ha-integration-header>
${item
? this._renderSingleEntry(item)
: this._renderGroupedIntegration()}
</ha-card>
`;
}
private _renderGroupedIntegration(): TemplateResult {
return html`
<paper-listbox class="ha-scrollbar">
${this.items.map(
(item) =>
html`<paper-item
.entryId=${item.entry_id}
@click=${this._selectConfigEntry}
><paper-item-body
>${item.title ||
this.hass.localize(
"ui.panel.config.integrations.config_entry.unnamed_entry"
)}</paper-item-body
>
${ERROR_STATES.includes(item.state)
? html`<span>
<ha-svg-icon
class="error"
.path=${mdiAlertCircle}
></ha-svg-icon
><paper-tooltip animation-delay="0" position="left">
${this.hass.localize(
`ui.panel.config.integrations.config_entry.state.${item.state}`
)}
</paper-tooltip>
</span>`
: ""}
<ha-icon-next></ha-icon-next>
</paper-item>`
)}
</paper-listbox>
`;
}
private _renderSingleEntry(item: ConfigEntryExtended): TemplateResult {
const devices = this._getDevices(item, this.deviceRegistryEntries);
const services = this._getServices(item, this.deviceRegistryEntries);
const entities = this._getEntities(item, this.entityRegistryEntries);
let stateText: [string, ...unknown[]] | undefined;
let stateTextExtra: TemplateResult | string | undefined;
if (item.disabled_by) {
stateText = [
"ui.panel.config.integrations.config_entry.disable.disabled_cause",
"cause",
this.hass.localize(
`ui.panel.config.integrations.config_entry.disable.disabled_by.${item.disabled_by}`
) || item.disabled_by,
];
if (item.state === "failed_unload") {
stateTextExtra = html`.
${this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_restart_confirm"
)}.`;
}
} else if (item.state === "not_loaded") {
stateText = ["ui.panel.config.integrations.config_entry.not_loaded"];
} else if (ERROR_STATES.includes(item.state)) {
stateText = [
`ui.panel.config.integrations.config_entry.state.${item.state}`,
];
if (item.reason) {
this.hass.loadBackendTranslation("config", item.domain);
stateTextExtra = html`:
${this.hass.localize(
`component.${item.domain}.config.error.${item.reason}`
) || item.reason}`;
} else {
stateTextExtra = html`
<br />
<a href=${`/config/logs/?filter=${item.domain}`}>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.check_the_logs"
)}
</a>
`;
}
}
let devicesLine: (TemplateResult | string)[] = [];
for (const [items, localizeKey] of [
[devices, "devices"],
[services, "services"],
] as [DeviceRegistryEntry[], string][]) {
if (items.length === 0) {
continue;
}
const url =
items.length === 1
? `/config/devices/device/${items[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`;
devicesLine.push(
// no white space before/after template on purpose
html`<a href=${url}
>${this.hass.localize(
`ui.panel.config.integrations.config_entry.${localizeKey}`,
"count",
items.length
)}</a
>`
);
}
if (entities.length) {
devicesLine.push(
// no white space before/after template on purpose
html`<a
href=${`/config/entities?historyBack=1&config_entry=${item.entry_id}`}
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
"count",
entities.length
)}</a
>`
);
}
if (devicesLine.length === 2) {
devicesLine = [
devicesLine[0],
` ${this.hass.localize("ui.common.and")} `,
devicesLine[1],
];
} else if (devicesLine.length === 3) {
devicesLine = [
devicesLine[0],
", ",
devicesLine[1],
` ${this.hass.localize("ui.common.and")} `,
devicesLine[2],
];
}
return html`
${stateText
? html`
<div class="message">
<ha-svg-icon .path=${mdiAlertCircle}></ha-svg-icon>
<div>${this.hass.localize(...stateText)}${stateTextExtra}</div>
</div>
`
: ""}
<div class="content">${devicesLine}</div>
<div class="actions">
<div>
${item.disabled_by === "user"
? html`<mwc-button unelevated @click=${this._handleEnable}>
${this.hass.localize("ui.common.enable")}
</mwc-button>`
: item.domain in integrationsWithPanel
? html`<a
href=${`${integrationsWithPanel[item.domain]}?config_entry=${
item.entry_id
}`}
><mwc-button>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
</mwc-button></a
>`
: item.supports_options
? html`
<mwc-button @click=${this._showOptions}>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
</mwc-button>
`
: ""}
</div>
<ha-button-menu corner="BOTTOM_START">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<mwc-list-item @request-selected=${this._handleRename}>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.rename"
)}
</mwc-list-item>
<mwc-list-item @request-selected=${this._handleSystemOptions}>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.system_options"
)}
</mwc-list-item>
${this.manifest
? html` <a
href=${this.manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this.manifest.domain}`
)
: this.manifest.documentation}
rel="noreferrer"
target="_blank"
>
<mwc-list-item hasMeta>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.documentation"
)}<ha-svg-icon
slot="meta"
.path=${mdiOpenInNew}
></ha-svg-icon>
</mwc-list-item>
</a>`
: ""}
${this.manifest &&
(this.manifest.is_built_in || this.manifest.issue_tracker)
? html`<a
href=${integrationIssuesUrl(item.domain, this.manifest)}
rel="noreferrer"
target="_blank"
>
<mwc-list-item hasMeta>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.known_issues"
)}<ha-svg-icon
slot="meta"
.path=${mdiOpenInNew}
></ha-svg-icon>
</mwc-list-item>
</a>`
: ""}
${!item.disabled_by &&
(item.state === "loaded" || item.state === "setup_retry") &&
item.supports_unload &&
item.source !== "system"
? html`<mwc-list-item @request-selected=${this._handleReload}>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.reload"
)}
</mwc-list-item>`
: ""}
${this.supportsDiagnostics && item.state === "loaded"
? html`<a
href=${getConfigEntryDiagnosticsDownloadUrl(item.entry_id)}
target="_blank"
@click=${this._signUrl}
>
<mwc-list-item>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.download_diagnostics"
)}
</mwc-list-item>
</a>`
: ""}
${item.disabled_by === "user"
? html`<mwc-list-item @request-selected=${this._handleEnable}>
${this.hass.localize("ui.common.enable")}
</mwc-list-item>`
: item.source !== "system"
? html`<mwc-list-item
class="warning"
@request-selected=${this._handleDisable}
>
${this.hass.localize("ui.common.disable")}
</mwc-list-item>`
: ""}
${item.source !== "system"
? html`<mwc-list-item
class="warning"
@request-selected=${this._handleDelete}
>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.delete"
)}
</mwc-list-item>`
: ""}
</ha-button-menu>
</div>
`;
}
private get _selectededConfigEntry(): ConfigEntryExtended | undefined {
return this.items.length === 1
? this.items[0]
: this.selectedConfigEntryId
? this.items.find(
(entry) => entry.entry_id === this.selectedConfigEntryId
)
: undefined;
}
private _selectConfigEntry(ev: Event) {
this.selectedConfigEntryId = (ev.currentTarget as any).entryId;
}
private _back() {
this.selectedConfigEntryId = undefined;
this.classList.remove("highlight");
}
private _getEntities = memoizeOne(
(
configEntry: ConfigEntry,
entityRegistryEntries: EntityRegistryEntry[]
): EntityRegistryEntry[] => {
if (!entityRegistryEntries) {
return [];
}
return entityRegistryEntries.filter(
(entity) => entity.config_entry_id === configEntry.entry_id
);
}
);
private _getDevices = memoizeOne(
(
configEntry: ConfigEntry,
deviceRegistryEntries: DeviceRegistryEntry[]
): DeviceRegistryEntry[] => {
if (!deviceRegistryEntries) {
return [];
}
return deviceRegistryEntries.filter(
(device) =>
device.config_entries.includes(configEntry.entry_id) &&
device.entry_type !== "service"
);
}
);
private _getServices = memoizeOne(
(
configEntry: ConfigEntry,
deviceRegistryEntries: DeviceRegistryEntry[]
): DeviceRegistryEntry[] => {
if (!deviceRegistryEntries) {
return [];
}
return deviceRegistryEntries.filter(
(device) =>
device.config_entries.includes(configEntry.entry_id) &&
device.entry_type === "service"
);
}
);
private _showOptions(ev) {
showOptionsFlowDialog(this, ev.target.closest("ha-card").configEntry);
}
private _handleRename(ev: CustomEvent<RequestSelectedDetail>): void {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
this._editEntryName(
((ev.target as HTMLElement).closest("ha-card") as any).configEntry
);
}
private _handleReload(ev: CustomEvent<RequestSelectedDetail>): void {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
this._reloadIntegration(
((ev.target as HTMLElement).closest("ha-card") as any).configEntry
);
}
private _handleDelete(ev: CustomEvent<RequestSelectedDetail>): void {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
this._removeIntegration(
((ev.target as HTMLElement).closest("ha-card") as any).configEntry
);
}
private _handleDisable(ev: CustomEvent<RequestSelectedDetail>): void {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
this._disableIntegration(
((ev.target as HTMLElement).closest("ha-card") as any).configEntry
);
}
private _handleEnable(ev: CustomEvent<RequestSelectedDetail>): void {
if (ev.detail.source && !shouldHandleRequestSelectedEvent(ev)) {
return;
}
this._enableIntegration(
((ev.target as HTMLElement).closest("ha-card") as any).configEntry
);
}
private _handleSystemOptions(ev: CustomEvent<RequestSelectedDetail>): void {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
this._showSystemOptions(
((ev.target as HTMLElement).closest("ha-card") as any).configEntry
);
}
private _showSystemOptions(configEntry: ConfigEntry) {
showConfigEntrySystemOptionsDialog(this, {
entry: configEntry,
manifest: this.manifest,
entryUpdated: (entry) =>
fireEvent(this, "entry-updated", {
entry,
}),
});
}
private async _disableIntegration(configEntry: ConfigEntry) {
const entryId = configEntry.entry_id;
const confirmed = await showConfirmationDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable.disable_confirm"
),
});
if (!confirmed) {
return;
}
let result: DisableConfigEntryResult;
try {
result = await disableConfigEntry(this.hass, entryId);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_error"
),
text: err.message,
});
return;
}
if (result.require_restart) {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_restart_confirm"
),
});
}
fireEvent(this, "entry-updated", {
entry: { ...configEntry, disabled_by: "user" },
});
}
private async _enableIntegration(configEntry: ConfigEntry) {
const entryId = configEntry.entry_id;
let result: DisableConfigEntryResult;
try {
result = await enableConfigEntry(this.hass, entryId);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_error"
),
text: err.message,
});
return;
}
if (result.require_restart) {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.enable_restart_confirm"
),
});
}
fireEvent(this, "entry-updated", {
entry: { ...configEntry, disabled_by: null },
});
}
private async _removeIntegration(configEntry: ConfigEntry) {
const entryId = configEntry.entry_id;
const confirmed = await showConfirmationDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm",
{ title: configEntry.title }
),
});
if (!confirmed) {
return;
}
const result = await deleteConfigEntry(this.hass, entryId);
fireEvent(this, "entry-removed", { entryId });
if (result.require_restart) {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.restart_confirm"
),
});
}
}
private async _reloadIntegration(configEntry: ConfigEntry) {
const entryId = configEntry.entry_id;
const result = await reloadConfigEntry(this.hass, entryId);
const locale_key = result.require_restart
? "reload_restart_confirm"
: "reload_confirm";
showAlertDialog(this, {
text: this.hass.localize(
`ui.panel.config.integrations.config_entry.${locale_key}`
),
});
}
private async _editEntryName(configEntry: ConfigEntry) {
const newName = await showPromptDialog(this, {
title: this.hass.localize("ui.panel.config.integrations.rename_dialog"),
defaultValue: configEntry.title,
inputLabel: this.hass.localize(
"ui.panel.config.integrations.rename_input_label"
),
});
if (newName === null) {
return;
}
const result = await updateConfigEntry(this.hass, configEntry.entry_id, {
title: newName,
});
fireEvent(this, "entry-updated", { entry: result.config_entry });
}
private async _signUrl(ev) {
const anchor = ev.target.closest("a");
ev.preventDefault();
const signedUrl = await getSignedPath(
this.hass,
anchor.getAttribute("href")
);
fileDownload(signedUrl.path);
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleScrollbar,
css`
ha-card {
display: flex;
flex-direction: column;
height: 100%;
--state-color: var(--divider-color, #e0e0e0);
--ha-card-border-color: var(--state-color);
--state-message-color: var(--state-color);
}
.state-error {
--state-color: var(--error-color);
--text-on-state-color: var(--text-primary-color);
}
.state-failed-unload {
--state-color: var(--warning-color);
--text-on-state-color: var(--primary-text-color);
}
.state-not-loaded {
--state-message-color: var(--primary-text-color);
}
:host(.highlight) ha-card {
--state-color: var(--primary-color);
--text-on-state-color: var(--text-primary-color);
}
.back-btn {
background-color: var(--state-color);
color: var(--text-on-state-color);
--mdc-icon-button-size: 32px;
transition: height 0.1s;
overflow: hidden;
}
.hasMultiple.single .back-btn {
height: 24px;
display: flex;
align-items: center;
}
.hasMultiple.group .back-btn {
height: 0px;
}
.message {
font-weight: bold;
padding-bottom: 16px;
display: flex;
margin-left: 40px;
}
.message ha-svg-icon {
color: var(--state-message-color);
}
.message div {
flex: 1;
margin-left: 8px;
padding-top: 2px;
padding-right: 2px;
overflow-wrap: break-word;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 7;
overflow: hidden;
text-overflow: ellipsis;
}
.content {
flex: 1;
padding: 0px 16px 0 72px;
}
.actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0 0 8px;
height: 48px;
}
.actions a {
text-decoration: none;
}
a {
color: var(--primary-color);
}
ha-button-menu {
color: var(--secondary-text-color);
--mdc-menu-min-width: 200px;
}
@media (min-width: 563px) {
ha-card.group {
position: relative;
min-height: 164px;
}
paper-listbox {
position: absolute;
top: 64px;
left: 0;
right: 0;
bottom: 0;
overflow: auto;
}
.disabled paper-listbox {
top: 88px;
}
}
paper-item {
cursor: pointer;
min-height: 35px;
}
paper-item-body {
word-wrap: break-word;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
}
mwc-list-item ha-svg-icon {
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-integration-card": HaIntegrationCard;
}
}