Update integration page layout (#25880)

This commit is contained in:
Bram Kragten 2025-06-24 15:27:07 +02:00 committed by GitHub
parent 976bf7c512
commit bfcc36bb40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1857 additions and 1221 deletions

View File

@ -1115,9 +1115,10 @@ ${
const domain = this._searchParms.get("domain"); const domain = this._searchParms.get("domain");
const configEntry = this._searchParms.get("config_entry"); const configEntry = this._searchParms.get("config_entry");
const subEntry = this._searchParms.get("sub_entry"); const subEntry = this._searchParms.get("sub_entry");
const device = this._searchParms.get("device");
const label = this._searchParms.has("label"); const label = this._searchParms.has("label");
if (!domain && !configEntry && !label) { if (!domain && !configEntry && !label && !device) {
return; return;
} }
@ -1126,6 +1127,7 @@ ${
this._filters = { this._filters = {
"ha-filter-states": [], "ha-filter-states": [],
"ha-filter-integrations": domain ? [domain] : [], "ha-filter-integrations": domain ? [domain] : [],
"ha-filter-devices": device ? [device] : [],
config_entry: configEntry ? [configEntry] : [], config_entry: configEntry ? [configEntry] : [],
sub_entry: subEntry ? [subEntry] : [], sub_entry: subEntry ? [subEntry] : [],
}; };

View File

@ -0,0 +1,98 @@
import { mdiClose } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-dialog-header";
import "../../../components/ha-icon-button";
import "../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../components/ha-md-dialog";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import { ERROR_STATES, RECOVERABLE_STATES } from "../../../data/config_entries";
import type { HomeAssistant } from "../../../types";
import type { PickConfigEntryDialogParams } from "./show-pick-config-entry-dialog";
@customElement("dialog-pick-config-entry")
export class DialogPickConfigEntry extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: PickConfigEntryDialogParams;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
public showDialog(params: PickConfigEntryDialogParams): void {
this._params = params;
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
public closeDialog() {
this._dialog?.close();
return true;
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-md-dialog open @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
<span
slot="title"
.title=${this.hass.localize(
`component.${this._params.domain}.config_subentries.${this._params.subFlowType}.initiate_flow.user`
)}
>${this.hass.localize(
`component.${this._params.domain}.config_subentries.${this._params.subFlowType}.initiate_flow.user`
)}</span
>
</ha-dialog-header>
<ha-md-list slot="content">
${this._params.configEntries.map(
(entry) =>
html`<ha-md-list-item
type="button"
@click=${this._itemPicked}
.entry=${entry}
.disabled=${!ERROR_STATES.includes(entry.state) &&
!RECOVERABLE_STATES.includes(entry.state)}
>${entry.title}</ha-md-list-item
>`
)}
</ha-md-list>
</ha-md-dialog>
`;
}
private _itemPicked(ev: Event) {
this._params?.configEntryPicked((ev.currentTarget as any).entry);
this.closeDialog();
}
static styles = css`
:host {
--dialog-content-padding: 0;
}
@media all and (min-width: 600px) {
ha-dialog {
--mdc-dialog-min-width: 400px;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-pick-config-entry": DialogPickConfigEntry;
}
}

View File

@ -0,0 +1,223 @@
import {
mdiCogOutline,
mdiDelete,
mdiDotsVertical,
mdiStopCircleOutline,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { getDeviceContext } from "../../../common/entity/context/get_device_context";
import { navigate } from "../../../common/navigate";
import type { ConfigEntry } from "../../../data/config_entries";
import {
removeConfigEntryFromDevice,
updateDeviceRegistryEntry,
type DeviceRegistryEntry,
} from "../../../data/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity_registry";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../lovelace/custom-card-helpers";
import { showDeviceRegistryDetailDialog } from "../devices/device-registry-detail/show-dialog-device-registry-detail";
import "./ha-config-sub-entry-row";
@customElement("ha-config-entry-device-row")
class HaConfigEntryDeviceRow extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: false }) public entry!: ConfigEntry;
@property({ attribute: false }) public device!: DeviceRegistryEntry;
@property({ attribute: false }) public entities!: EntityRegistryEntry[];
protected render() {
const device = this.device;
const entities = this._getEntities();
const { area } = getDeviceContext(device, this.hass);
const supportingText = [
device.model || device.sw_version || device.manufacturer,
area ? area.name : undefined,
].filter(Boolean);
return html`<ha-md-list-item>
<div slot="headline">${computeDeviceNameDisplay(device, this.hass)}</div>
<span slot="supporting-text"
>${supportingText.join(" • ")}
${supportingText.length && entities.length ? " • " : nothing}
${
entities.length
? html`<a
href=${`/config/entities/?historyBack=1&device=${device.id}`}
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: entities.length }
)}</a
>`
: nothing
}</span
>
<ha-icon-button-next slot="end" @click=${this._handleNavigateToDevice}
>
</ha-icon-button-next>
</ha-icon-button>
<div class="vertical-divider" slot="end"></div>
${
!this.narrow
? html`<ha-icon-button
slot="end"
@click=${this._handleConfigureDevice}
.path=${mdiCogOutline}
.label=${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.configure"
)}
></ha-icon-button>`
: nothing
}
</ha-icon-button>
<ha-md-button-menu positioning="popover" slot="end">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${
this.narrow
? html`<ha-md-menu-item @click=${this._handleConfigureDevice}>
<ha-svg-icon .path=${mdiCogOutline} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.configure"
)}
</ha-md-menu-item>`
: nothing
}
<ha-md-menu-item class=${device.disabled_by !== "user" ? "warning" : ""} @click=${this._handleDisableDevice} .disabled=${device.disabled_by !== "user" && device.disabled_by}>
<ha-svg-icon .path=${mdiStopCircleOutline} slot="start"></ha-svg-icon>
${
device.disabled_by && device.disabled_by !== "user"
? this.hass.localize(
"ui.dialogs.device-registry-detail.enabled_cause",
{
type: this.hass.localize(
`ui.dialogs.device-registry-detail.type.${
device.entry_type || "device"
}`
),
cause: this.hass.localize(
`config_entry.disabled_by.${device.disabled_by}`
),
}
)
: device.disabled_by
? this.hass.localize(
"ui.panel.config.integrations.config_entry.device.enable"
)
: this.hass.localize(
"ui.panel.config.integrations.config_entry.device.disable"
)
}
</ha-md-menu-item>
${
this.entry.supports_remove_device
? html` <ha-md-menu-item
class="warning"
@click=${this._handleDeleteDevice}
>
<ha-svg-icon .path=${mdiDelete} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.device.delete"
)}
</ha-md-menu-item>`
: nothing
}
</ha-md-button-menu>
</ha-md-list-item> `;
}
private _getEntities = (): EntityRegistryEntry[] =>
this.entities?.filter((entity) => entity.device_id === this.device.id);
private _handleConfigureDevice() {
showDeviceRegistryDetailDialog(this, {
device: this.device,
updateEntry: async (updates) => {
await updateDeviceRegistryEntry(this.hass, this.device.id, updates);
},
});
}
private async _handleDisableDevice() {
await updateDeviceRegistryEntry(this.hass, this.device.id, {
disabled_by: this.device.disabled_by === "user" ? null : "user",
});
}
private async _handleDeleteDevice() {
const entry = this.entry;
const confirmed = await showConfirmationDialog(this, {
text: this.hass.localize("ui.panel.config.devices.confirm_delete"),
confirmText: this.hass.localize("ui.common.delete"),
dismissText: this.hass.localize("ui.common.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
try {
await removeConfigEntryFromDevice(
this.hass!,
this.device.id,
entry.entry_id
);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.devices.error_delete"),
text: err.message,
});
}
}
private _handleNavigateToDevice() {
navigate(`/config/devices/device/${this.device.id}`);
}
static styles = [
haStyle,
css`
:host {
border-top: 1px solid var(--divider-color);
}
ha-md-list-item {
--md-list-item-leading-space: 56px;
}
.vertical-divider {
height: 100%;
width: 1px;
background: var(--divider-color);
}
a {
text-decoration: none;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-entry-device-row": HaConfigEntryDeviceRow;
}
}

View File

@ -0,0 +1,761 @@
import {
mdiAlertCircle,
mdiChevronDown,
mdiChevronUp,
mdiCogOutline,
mdiDelete,
mdiDevices,
mdiDotsVertical,
mdiDownload,
mdiHandExtendedOutline,
mdiPlayCircleOutline,
mdiPlus,
mdiProgressHelper,
mdiReload,
mdiReloadAlert,
mdiRenameBox,
mdiShapeOutline,
mdiStopCircleOutline,
mdiWrench,
} from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { isDevVersion } from "../../../common/config/version";
import {
deleteApplicationCredential,
fetchApplicationCredentialsConfigEntry,
} from "../../../data/application_credential";
import { getSignedPath } from "../../../data/auth";
import type {
ConfigEntry,
DisableConfigEntryResult,
SubEntry,
} from "../../../data/config_entries";
import {
deleteConfigEntry,
disableConfigEntry,
enableConfigEntry,
ERROR_STATES,
getSubEntries,
RECOVERABLE_STATES,
reloadConfigEntry,
updateConfigEntry,
} from "../../../data/config_entries";
import type { DeviceRegistryEntry } from "../../../data/device_registry";
import type { DiagnosticInfo } from "../../../data/diagnostics";
import { getConfigEntryDiagnosticsDownloadUrl } from "../../../data/diagnostics";
import type { EntityRegistryEntry } from "../../../data/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
import {
domainToName,
fetchIntegrationManifest,
integrationsWithPanel,
} from "../../../data/integration";
import { showConfigEntrySystemOptionsDialog } from "../../../dialogs/config-entry-system-options/show-dialog-config-entry-system-options";
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow";
import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { fileDownload } from "../../../util/file_download";
import {
showAlertDialog,
showConfirmationDialog,
showPromptDialog,
} from "../../lovelace/custom-card-helpers";
import "./ha-config-entry-device-row";
import { renderConfigEntryError } from "./ha-config-integration-page";
import "./ha-config-sub-entry-row";
import { haStyle } from "../../../resources/styles";
@customElement("ha-config-entry-row")
class HaConfigEntryRow extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: false }) public manifest?: IntegrationManifest;
@property({ attribute: false }) public diagnosticHandler?: DiagnosticInfo;
@property({ attribute: false }) public entities!: EntityRegistryEntry[];
@property({ attribute: false }) public entry!: ConfigEntry;
@state() private _expanded = true;
@state() private _devicesExpanded = true;
@state() private _subEntries?: SubEntry[];
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("entry")) {
this._fetchSubEntries();
}
}
protected render() {
const item = this.entry;
let stateText: Parameters<typeof this.hass.localize> | undefined;
let stateTextExtra: TemplateResult | string | undefined;
let icon: string = mdiAlertCircle;
if (!item.disabled_by && item.state === "not_loaded") {
stateText = ["ui.panel.config.integrations.config_entry.not_loaded"];
} else if (item.state === "setup_in_progress") {
icon = mdiProgressHelper;
stateText = [
"ui.panel.config.integrations.config_entry.setup_in_progress",
];
} else if (ERROR_STATES.includes(item.state)) {
if (item.state === "setup_retry") {
icon = mdiReloadAlert;
}
stateText = [
`ui.panel.config.integrations.config_entry.state.${item.state}`,
];
stateTextExtra = renderConfigEntryError(this.hass, item);
}
const devices = this._getDevices();
const services = this._getServices();
const entities = this._getEntities();
const ownDevices = [...devices, ...services].filter(
(device) =>
!device.config_entries_subentries[item.entry_id].length ||
device.config_entries_subentries[item.entry_id][0] === null
);
const statusLine: (TemplateResult | string)[] = [];
if (item.disabled_by) {
statusLine.push(
this.hass.localize(
"ui.panel.config.integrations.config_entry.disable.disabled_cause",
{
cause:
this.hass.localize(
`ui.panel.config.integrations.config_entry.disable.disabled_by.${item.disabled_by}`
) || item.disabled_by,
}
)
);
if (item.state === "failed_unload") {
statusLine.push(`.
${this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_restart_confirm"
)}.`);
}
} else if (!devices.length && !services.length && entities.length) {
statusLine.push(
html`<a
href=${`/config/entities/?historyBack=1&config_entry=${item.entry_id}`}
>${entities.length} entities</a
>`
);
}
const configPanel = this._configPanel(item.domain, this.hass.panels);
const subEntries = this._subEntries || [];
return html`<ha-md-list>
<ha-md-list-item
class=${classMap({
config_entry: true,
"state-not-loaded": item!.state === "not_loaded",
"state-failed-unload": item!.state === "failed_unload",
"state-setup": item!.state === "setup_in_progress",
"state-error": ERROR_STATES.includes(item!.state),
"state-disabled": item.disabled_by !== null,
"has-subentries": this._expanded && subEntries.length > 0,
})}
>
${subEntries.length || ownDevices.length
? html`<ha-icon-button
class="expand-button"
.path=${this._expanded ? mdiChevronDown : mdiChevronUp}
slot="start"
@click=${this._toggleExpand}
></ha-icon-button>`
: nothing}
<div slot="headline">
${item.title || domainToName(this.hass.localize, item.domain)}
</div>
<div slot="supporting-text">
<div>${statusLine}</div>
${stateText
? html`
<div class="message">
<ha-svg-icon .path=${icon}></ha-svg-icon>
<div>
${this.hass.localize(...stateText)}${stateTextExtra
? html`: ${stateTextExtra}`
: nothing}
</div>
</div>
`
: nothing}
</div>
${item.disabled_by === "user"
? html`<ha-button unelevated slot="end" @click=${this._handleEnable}>
${this.hass.localize("ui.common.enable")}
</ha-button>`
: configPanel &&
(item.domain !== "matter" ||
isDevVersion(this.hass.config.version)) &&
!stateText
? html`<a
slot="end"
href=${`/${configPanel}?config_entry=${item.entry_id}`}
><ha-icon-button
.path=${mdiCogOutline}
.label=${this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
>
</ha-icon-button
></a>`
: item.supports_options
? html`
<ha-icon-button
slot="end"
@click=${this._showOptions}
.path=${mdiCogOutline}
.label=${this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
>
</ha-icon-button>
`
: nothing}
<ha-md-button-menu positioning="popover" slot="end">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${devices.length
? html`
<ha-md-menu-item
href=${devices.length === 1
? `/config/devices/device/${devices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`}
>
<ha-svg-icon .path=${mdiDevices} slot="start"></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.devices`,
{ count: devices.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
${services.length
? html`<ha-md-menu-item
href=${services.length === 1
? `/config/devices/device/${services[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`}
>
<ha-svg-icon
.path=${mdiHandExtendedOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.services`,
{ count: services.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item> `
: nothing}
${entities.length
? html`
<ha-md-menu-item
href=${`/config/entities?historyBack=1&config_entry=${item.entry_id}`}
>
<ha-svg-icon
.path=${mdiShapeOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.entities`,
{ count: entities.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
${!item.disabled_by &&
RECOVERABLE_STATES.includes(item.state) &&
item.supports_unload &&
item.source !== "system"
? html`
<ha-md-menu-item @click=${this._handleReload}>
<ha-svg-icon slot="start" .path=${mdiReload}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.reload"
)}
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item @click=${this._handleRename} graphic="icon">
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.rename"
)}
</ha-md-menu-item>
${Object.keys(item.supported_subentry_types).map(
(flowType) =>
html`<ha-md-menu-item
@click=${this._addSubEntry}
.entry=${item}
.flowType=${flowType}
graphic="icon"
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize(
`component.${item.domain}.config_subentries.${flowType}.initiate_flow.user`
)}</ha-md-menu-item
>`
)}
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
${this.diagnosticHandler && item.state === "loaded"
? html`
<ha-md-menu-item
href=${getConfigEntryDiagnosticsDownloadUrl(item.entry_id)}
target="_blank"
@click=${this._signUrl}
>
<ha-svg-icon slot="start" .path=${mdiDownload}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.download_diagnostics"
)}
</ha-md-menu-item>
`
: nothing}
${!item.disabled_by &&
item.supports_reconfigure &&
item.source !== "system"
? html`
<ha-md-menu-item @click=${this._handleReconfigure}>
<ha-svg-icon slot="start" .path=${mdiWrench}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.reconfigure"
)}
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item @click=${this._handleSystemOptions} graphic="icon">
<ha-svg-icon slot="start" .path=${mdiCogOutline}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.system_options"
)}
</ha-md-menu-item>
${item.disabled_by === "user"
? html`
<ha-md-menu-item @click=${this._handleEnable}>
<ha-svg-icon
slot="start"
.path=${mdiPlayCircleOutline}
></ha-svg-icon>
${this.hass.localize("ui.common.enable")}
</ha-md-menu-item>
`
: item.source !== "system"
? html`
<ha-md-menu-item
class="warning"
@click=${this._handleDisable}
graphic="icon"
>
<ha-svg-icon
slot="start"
class="warning"
.path=${mdiStopCircleOutline}
></ha-svg-icon>
${this.hass.localize("ui.common.disable")}
</ha-md-menu-item>
`
: nothing}
${item.source !== "system"
? html`
<ha-md-menu-item class="warning" @click=${this._handleDelete}>
<ha-svg-icon
slot="start"
class="warning"
.path=${mdiDelete}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.delete"
)}
</ha-md-menu-item>
`
: nothing}
</ha-md-button-menu>
</ha-md-list-item>
${this._expanded
? subEntries.length
? html`<ha-md-list class="devices">
<ha-md-list-item @click=${this._toggleOwnDevices} type="button">
<ha-icon-button
class="expand-button"
.path=${this._devicesExpanded
? mdiChevronDown
: mdiChevronUp}
slot="start"
>
</ha-icon-button>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.devices_without_subentry"
)}
</ha-md-list-item>
${this._devicesExpanded
? ownDevices.map(
(device) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${item}
.device=${device}
.entities=${entities}
></ha-config-entry-device-row>`
)
: nothing}
</ha-md-list>
${subEntries.map(
(subEntry) => html`
<ha-config-sub-entry-row
.hass=${this.hass}
.narrow=${this.narrow}
.manifest=${this.manifest}
.diagnosticHandler=${this.diagnosticHandler}
.entities=${this.entities}
.entry=${item}
.subEntry=${subEntry}
data-entry-id=${item.entry_id}
></ha-config-sub-entry-row>
`
)}`
: html`
${ownDevices.map(
(device) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${item}
.device=${device}
.entities=${entities}
></ha-config-entry-device-row>`
)}
`
: nothing}
</ha-md-list>`;
}
private async _fetchSubEntries() {
this._subEntries = this.entry.num_subentries
? await getSubEntries(this.hass, this.entry.entry_id)
: undefined;
}
private _configPanel = memoizeOne(
(domain: string, panels: HomeAssistant["panels"]): string | undefined =>
Object.values(panels).find(
(panel) => panel.config_panel_domain === domain
)?.url_path || integrationsWithPanel[domain]
);
private _getEntities = (): EntityRegistryEntry[] =>
this.entities.filter(
(entity) => entity.config_entry_id === this.entry.entry_id
);
private _getDevices = (): DeviceRegistryEntry[] =>
Object.values(this.hass.devices).filter(
(device) =>
device.config_entries.includes(this.entry.entry_id) &&
device.entry_type !== "service"
);
private _getServices = (): DeviceRegistryEntry[] =>
Object.values(this.hass.devices).filter(
(device) =>
device.config_entries.includes(this.entry.entry_id) &&
device.entry_type === "service"
);
private _toggleExpand() {
this._expanded = !this._expanded;
}
private _toggleOwnDevices() {
this._devicesExpanded = !this._devicesExpanded;
}
private _showOptions() {
showOptionsFlowDialog(this, this.entry, { manifest: this.manifest });
}
// Return an application credentials id for this config entry to prompt the
// user for removal. This is best effort so we don't stop overall removal
// if the integration isn't loaded or there is some other error.
private async _applicationCredentialForRemove(entryId: string) {
try {
return (await fetchApplicationCredentialsConfigEntry(this.hass, entryId))
.application_credentials_id;
} catch (_err: any) {
// We won't prompt the user to remove credentials
return null;
}
}
private async _removeApplicationCredential(applicationCredentialsId: string) {
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.delete_title"
),
text: html`${this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.delete_prompt"
)},
<br />
<br />
${this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.delete_detail"
)}
<br />
<br />
<a
href=${documentationUrl(
this.hass,
"/integrations/application_credentials/"
)}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.learn_more"
)}
</a>`,
destructive: true,
confirmText: this.hass.localize("ui.common.remove"),
dismissText: this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.dismiss"
),
});
if (!confirmed) {
return;
}
try {
await deleteApplicationCredential(this.hass, applicationCredentialsId);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.application_credentials.delete_error_title"
),
text: err.message,
});
}
}
private async _handleReload() {
const result = await reloadConfigEntry(this.hass, this.entry.entry_id);
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 _handleReconfigure() {
showConfigFlowDialog(this, {
startFlowHandler: this.entry.domain,
showAdvanced: this.hass.userData?.showAdvanced,
manifest: await fetchIntegrationManifest(this.hass, this.entry.domain),
entryId: this.entry.entry_id,
navigateToResult: true,
});
}
private async _handleRename() {
const newName = await showPromptDialog(this, {
title: this.hass.localize("ui.panel.config.integrations.rename_dialog"),
defaultValue: this.entry.title,
inputLabel: this.hass.localize(
"ui.panel.config.integrations.rename_input_label"
),
});
if (newName === null) {
return;
}
await updateConfigEntry(this.hass, this.entry.entry_id, {
title: newName,
});
}
private async _signUrl(ev) {
const anchor = ev.currentTarget;
ev.preventDefault();
const signedUrl = await getSignedPath(
this.hass,
anchor.getAttribute("href")
);
fileDownload(signedUrl.path);
}
private async _handleDisable() {
const entryId = this.entry.entry_id;
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_confirm_title",
{ title: this.entry.title }
),
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_confirm_text"
),
confirmText: this.hass!.localize("ui.common.disable"),
dismissText: this.hass!.localize("ui.common.cancel"),
destructive: true,
});
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"
),
});
}
}
private async _handleEnable() {
const entryId = this.entry.entry_id;
let result: DisableConfigEntryResult;
try {
result = await enableConfigEntry(this.hass, entryId);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.disable_error"
),
text: err.message,
});
return;
}
if (result.require_restart) {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.enable_restart_confirm"
),
});
}
}
private async _handleDelete() {
const entryId = this.entry.entry_id;
const applicationCredentialsId =
await this._applicationCredentialForRemove(entryId);
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_title",
{ title: this.entry.title }
),
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_text"
),
confirmText: this.hass!.localize("ui.common.delete"),
dismissText: this.hass!.localize("ui.common.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
const result = await deleteConfigEntry(this.hass, entryId);
if (result.require_restart) {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.restart_confirm"
),
});
}
if (applicationCredentialsId) {
this._removeApplicationCredential(applicationCredentialsId);
}
}
private _handleSystemOptions() {
showConfigEntrySystemOptionsDialog(this, {
entry: this.entry,
manifest: this.manifest,
});
}
private _addSubEntry(ev) {
showSubConfigFlowDialog(this, this.entry, ev.target.flowType, {
startFlowHandler: this.entry.entry_id,
});
}
static styles = [
haStyle,
css`
.expand-button {
margin: 0 -12px;
}
ha-md-list {
border: 1px solid var(--divider-color);
border-radius: var(--ha-card-border-radius, 12px);
padding: 0;
}
ha-md-list.devices {
margin: 16px;
margin-top: 0;
}
a ha-icon-button {
color: var(
--md-list-item-trailing-icon-color,
var(--md-sys-color-on-surface-variant, #49454f)
);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-entry-row": HaConfigEntryRow;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,250 @@
import {
mdiChevronDown,
mdiChevronUp,
mdiCogOutline,
mdiDelete,
mdiDevices,
mdiDotsVertical,
mdiHandExtendedOutline,
mdiShapeOutline,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
import { deleteSubEntry } from "../../../data/config_entries";
import type { DeviceRegistryEntry } from "../../../data/device_registry";
import type { DiagnosticInfo } from "../../../data/diagnostics";
import type { EntityRegistryEntry } from "../../../data/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow";
import type { HomeAssistant } from "../../../types";
import { showConfirmationDialog } from "../../lovelace/custom-card-helpers";
import "./ha-config-entry-device-row";
@customElement("ha-config-sub-entry-row")
class HaConfigSubEntryRow extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: false }) public manifest?: IntegrationManifest;
@property({ attribute: false }) public diagnosticHandler?: DiagnosticInfo;
@property({ attribute: false }) public entities!: EntityRegistryEntry[];
@property({ attribute: false }) public entry!: ConfigEntry;
@property({ attribute: false }) public subEntry!: SubEntry;
@state() private _expanded = true;
protected render() {
const subEntry = this.subEntry;
const configEntry = this.entry;
const devices = this._getDevices();
const services = this._getServices();
const entities = this._getEntities();
return html`<ha-md-list>
<ha-md-list-item
class="sub-entry"
data-entry-id=${configEntry.entry_id}
.configEntry=${configEntry}
.subEntry=${subEntry}
>
${devices.length || services.length
? html`<ha-icon-button
class="expand-button"
.path=${this._expanded ? mdiChevronDown : mdiChevronUp}
slot="start"
@click=${this._toggleExpand}
></ha-icon-button>`
: nothing}
<span slot="headline">${subEntry.title}</span>
<span slot="supporting-text"
>${this.hass.localize(
`component.${configEntry.domain}.config_subentries.${subEntry.subentry_type}.entry_type`
)}</span
>
${configEntry.supported_subentry_types[subEntry.subentry_type]
?.supports_reconfigure
? html`
<ha-icon-button
slot="end"
@click=${this._handleReconfigureSub}
.path=${mdiCogOutline}
.label=${this.hass.localize(
`component.${configEntry.domain}.config_subentries.${subEntry.subentry_type}.initiate_flow.reconfigure`
) ||
this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
>
</ha-icon-button>
`
: nothing}
<ha-md-button-menu positioning="popover" slot="end">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${devices.length || services.length
? html`
<ha-md-menu-item
href=${devices.length === 1
? `/config/devices/device/${devices[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`}
>
<ha-svg-icon .path=${mdiDevices} slot="start"></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.devices`,
{ count: devices.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
${services.length
? html`<ha-md-menu-item
href=${services.length === 1
? `/config/devices/device/${services[0].id}`
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`}
>
<ha-svg-icon
.path=${mdiHandExtendedOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.services`,
{ count: services.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item> `
: nothing}
${entities.length
? html`
<ha-md-menu-item
href=${`/config/entities?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`}
>
<ha-svg-icon
.path=${mdiShapeOutline}
slot="start"
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.entities`,
{ count: entities.length }
)}
<ha-icon-next slot="end"></ha-icon-next>
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item class="warning" @click=${this._handleDeleteSub}>
<ha-svg-icon
slot="start"
class="warning"
.path=${mdiDelete}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.delete"
)}
</ha-md-menu-item>
</ha-md-button-menu>
</ha-md-list-item>
${this._expanded
? html`
${devices.map(
(device) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${this.entry}
.device=${device}
.entities=${this.entities}
></ha-config-entry-device-row>`
)}
`
: nothing}
</ha-md-list>`;
}
private _toggleExpand() {
this._expanded = !this._expanded;
}
private _getEntities = (): EntityRegistryEntry[] =>
this.entities.filter(
(entity) => entity.config_subentry_id === this.subEntry.subentry_id
);
private _getDevices = (): DeviceRegistryEntry[] =>
Object.values(this.hass.devices).filter(
(device) =>
device.config_entries_subentries[this.entry.entry_id]?.includes(
this.subEntry.subentry_id
) && device.entry_type !== "service"
);
private _getServices = (): DeviceRegistryEntry[] =>
Object.values(this.hass.devices).filter(
(device) =>
device.config_entries_subentries[this.entry.entry_id]?.includes(
this.subEntry.subentry_id
) && device.entry_type === "service"
);
private async _handleReconfigureSub(): Promise<void> {
showSubConfigFlowDialog(this, this.entry, this.subEntry.subentry_type, {
startFlowHandler: this.entry.entry_id,
subEntryId: this.subEntry.subentry_id,
});
}
private async _handleDeleteSub(): Promise<void> {
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_title",
{ title: this.subEntry.title }
),
text: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_text"
),
confirmText: this.hass!.localize("ui.common.delete"),
dismissText: this.hass!.localize("ui.common.cancel"),
destructive: true,
});
if (!confirmed) {
return;
}
await deleteSubEntry(
this.hass,
this.entry.entry_id,
this.subEntry.subentry_id
);
}
static styles = css`
.expand-button {
margin: 0 -12px;
}
ha-md-list {
border: 1px solid var(--divider-color);
border-radius: var(--ha-card-border-radius, 12px);
padding: 0;
margin: 16px;
margin-top: 0;
}
ha-md-list-item.has-subentries {
border-bottom: 1px solid var(--divider-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-sub-entry-row": HaConfigSubEntryRow;
}
}

View File

@ -0,0 +1,20 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { ConfigEntry } from "../../../data/config_entries";
export interface PickConfigEntryDialogParams {
domain: string;
subFlowType: string;
configEntries: ConfigEntry[];
configEntryPicked: (configEntry: ConfigEntry) => void;
}
export const showPickConfigEntryDialog = (
element: HTMLElement,
dialogParams?: PickConfigEntryDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-pick-config-entry",
dialogImport: () => import("./dialog-pick-config-entry"),
dialogParams: dialogParams,
});
};

View File

@ -31,6 +31,11 @@ export const colorStyles = css`
--rgb-text-primary-color: 255, 255, 255; --rgb-text-primary-color: 255, 255, 255;
--rgb-card-background-color: 255, 255, 255; --rgb-card-background-color: 255, 255, 255;
--rgb-warning-color: 255, 166, 0;
--rgb-error-color: 219, 68, 55;
--rgb-success-color: 67, 160, 71;
--rgb-info-color: 3, 155, 229;
--scrollbar-thumb-color: rgb(194, 194, 194); --scrollbar-thumb-color: rgb(194, 194, 194);
--error-color: #db4437; --error-color: #db4437;

View File

@ -5319,11 +5319,18 @@
"dismiss": "Keep", "dismiss": "Keep",
"learn_more": "Learn more about application credentials" "learn_more": "Learn more about application credentials"
}, },
"device": {
"enable": "Enable device",
"disable": "Disable device",
"configure": "Configure device",
"delete": "Remove device"
},
"devices": "{count} {count, plural,\n one {device}\n other {devices}\n}", "devices": "{count} {count, plural,\n one {device}\n other {devices}\n}",
"entities": "{count} {count, plural,\n one {entity}\n other {entities}\n}", "entities": "{count} {count, plural,\n one {entity}\n other {entities}\n}",
"services": "{count} {count, plural,\n one {service}\n other {services}\n}", "services": "{count} {count, plural,\n one {service}\n other {services}\n}",
"entries": "{count} {count, plural,\n one {entry}\n other {entries}\n}", "entries": "{count} {count, plural,\n one {entry}\n other {entries}\n}",
"no_devices_or_entities": "No devices or entities", "no_devices_or_entities": "No devices or entities",
"devices_without_subentry": "Devices that don't belong to a sub-entry",
"rename": "Rename", "rename": "Rename",
"configure": "Configure", "configure": "Configure",
"system_options": "System options", "system_options": "System options",