Add base support for sub entries (#23160)

* Add base support for sub entries

* add demo types

* fix translations

* Use sub entry name when deleting

* Update show-dialog-sub-config-flow.ts

* adjust for multiple sub types

* WIP, not functional

* add subentry_type

* rename to supported_subentry_types

* config_subentries -> config_entries_subentries

* Add localized sub flow title

* use Record

* rename

* more rename
pull/23358/head^2
Bram Kragten 2025-02-10 21:24:05 +01:00 committed by GitHub
parent 03a415beff
commit 0d97afb3f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 893 additions and 261 deletions

View File

@ -65,6 +65,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockEntityRegistry(hass, [
{
config_entry_id: "co2signal",
config_subentry_id: null,
device_id: "co2signal",
area_id: null,
disabled_by: null,
@ -85,6 +86,7 @@ export class HaDemo extends HomeAssistantAppEl {
},
{
config_entry_id: "co2signal",
config_subentry_id: null,
device_id: "co2signal",
area_id: null,
disabled_by: null,

View File

@ -11,6 +11,7 @@ export const mockConfigEntries = (hass: MockHomeAssistant) => {
supports_remove_device: false,
supports_unload: true,
supports_reconfigure: true,
supported_subentry_types: {},
pref_disable_new_entities: false,
pref_disable_polling: false,
disabled_by: null,

View File

@ -48,6 +48,7 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: "bedroom",
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
@ -71,6 +72,7 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: "backyard",
configuration_url: null,
config_entries: ["config_entry_2"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
@ -94,6 +96,7 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: null,
configuration_url: null,
config_entries: ["config_entry_3"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,

View File

@ -47,6 +47,7 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: "bedroom",
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
@ -70,6 +71,7 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: "backyard",
configuration_url: null,
config_entries: ["config_entry_2"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
@ -93,6 +95,7 @@ const DEVICES: DeviceRegistryEntry[] = [
area_id: null,
configuration_url: null,
config_entries: ["config_entry_3"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,

View File

@ -32,6 +32,8 @@ const createConfigEntry = (
supports_remove_device: false,
supports_unload: true,
supports_reconfigure: true,
supported_subentry_types: {},
num_subentries: 0,
disabled_by: null,
pref_disable_new_entities: false,
pref_disable_polling: false,
@ -188,6 +190,7 @@ const createEntityRegistryEntries = (
): EntityRegistryEntry[] => [
{
config_entry_id: item.entry_id,
config_subentry_id: null,
device_id: "mock-device-id",
area_id: null,
disabled_by: null,
@ -214,6 +217,7 @@ const createDeviceRegistryEntries = (
{
entry_type: null,
config_entries: [item.entry_id],
config_entries_subentries: {},
connections: [],
manufacturer: "ESPHome",
model: "Mock Device",

View File

@ -19,6 +19,8 @@ export interface ConfigEntry {
supports_remove_device: boolean;
supports_unload: boolean;
supports_reconfigure: boolean;
supported_subentry_types: Record<string, { supports_reconfigure: boolean }>;
num_subentries: number;
pref_disable_new_entities: boolean;
pref_disable_polling: boolean;
disabled_by: "user" | null;
@ -27,6 +29,30 @@ export interface ConfigEntry {
error_reason_translation_placeholders: Record<string, string> | null;
}
export interface SubEntry {
subentry_id: string;
subentry_type: string;
title: string;
unique_id: string;
}
export const getSubEntries = (hass: HomeAssistant, entry_id: string) =>
hass.callWS<SubEntry[]>({
type: "config_entries/subentries/list",
entry_id,
});
export const deleteSubEntry = (
hass: HomeAssistant,
entry_id: string,
subentry_id: string
) =>
hass.callWS({
type: "config_entries/subentries/delete",
entry_id,
subentry_id,
});
export type ConfigEntryMutableParams = Partial<
Pick<
ConfigEntry,

View File

@ -2,7 +2,11 @@ import type { Connection } from "home-assistant-js-websocket";
import type { HaFormSchema } from "../components/ha-form/types";
import type { ConfigEntry } from "./config_entries";
export type FlowType = "config_flow" | "options_flow" | "repair_flow";
export type FlowType =
| "config_flow"
| "config_subentries_flow"
| "options_flow"
| "repair_flow";
export interface DataEntryFlowProgressedEvent {
type: "data_entry_flow_progressed";

View File

@ -17,6 +17,7 @@ export {
export interface DeviceRegistryEntry extends RegistryEntry {
id: string;
config_entries: string[];
config_entries_subentries: Record<string, (string | null)[]>;
connections: [string, string][];
identifiers: [string, string][];
manufacturer: string | null;

View File

@ -50,6 +50,7 @@ export interface EntityRegistryEntry extends RegistryEntry {
icon: string | null;
platform: string;
config_entry_id: string | null;
config_subentry_id: string | null;
device_id: string | null;
area_id: string | null;
labels: string[];

View File

@ -0,0 +1,46 @@
import type { HomeAssistant } from "../types";
import type { DataEntryFlowStep } from "./data_entry_flow";
const HEADERS = {
"HA-Frontend-Base": `${location.protocol}//${location.host}`,
};
export const createSubConfigFlow = (
hass: HomeAssistant,
configEntryId: string,
subFlowType: string,
subentry_id?: string
) =>
hass.callApi<DataEntryFlowStep>(
"POST",
"config/config_entries/subentries/flow",
{
handler: [configEntryId, subFlowType],
show_advanced_options: Boolean(hass.userData?.showAdvanced),
subentry_id,
},
HEADERS
);
export const fetchSubConfigFlow = (hass: HomeAssistant, flowId: string) =>
hass.callApi<DataEntryFlowStep>(
"GET",
`config/config_entries/subentries/flow/${flowId}`,
undefined,
HEADERS
);
export const handleSubConfigFlowStep = (
hass: HomeAssistant,
flowId: string,
data: Record<string, any>
) =>
hass.callApi<DataEntryFlowStep>(
"POST",
`config/config_entries/subentries/flow/${flowId}`,
data,
HEADERS
);
export const deleteSubConfigFlow = (hass: HomeAssistant, flowId: string) =>
hass.callApi("DELETE", `config/config_entries/subentries/flow/${flowId}`);

View File

@ -63,6 +63,7 @@ export type TranslationCategory =
| "entity_component"
| "exceptions"
| "config"
| "config_subentries"
| "config_panel"
| "options"
| "device_automation"

View File

@ -77,7 +77,7 @@ export class FlowPreviewGeneric extends LitElement {
(await this._unsub)();
this._unsub = undefined;
}
if (this.flowType === "repair_flow") {
if (this.flowType !== "config_flow" && this.flowType !== "options_flow") {
return;
}
try {

View File

@ -147,7 +147,7 @@ class FlowPreviewTemplate extends LitElement {
(await this._unsub)();
this._unsub = undefined;
}
if (this.flowType === "repair_flow") {
if (this.flowType !== "config_flow" && this.flowType !== "options_flow") {
return;
}
try {

View File

@ -16,7 +16,9 @@ export const loadConfigFlowDialog = loadDataEntryFlowDialog;
export const showConfigFlowDialog = (
element: HTMLElement,
dialogParams: Omit<DataEntryFlowDialogParams, "flowConfig">
dialogParams: Omit<DataEntryFlowDialogParams, "flowConfig"> & {
entryId?: string;
}
): void =>
showFlowDialog(element, dialogParams, {
flowType: "config_flow",

View File

@ -148,7 +148,6 @@ export interface DataEntryFlowDialogParams {
}) => void;
flowConfig: FlowConfig;
showAdvanced?: boolean;
entryId?: string;
dialogParentElement?: HTMLElement;
navigateToResult?: boolean;
}

View File

@ -0,0 +1,275 @@
import { html } from "lit";
import type { ConfigEntry } from "../../data/config_entries";
import { domainToName } from "../../data/integration";
import {
createSubConfigFlow,
deleteSubConfigFlow,
fetchSubConfigFlow,
handleSubConfigFlowStep,
} from "../../data/sub_config_flow";
import type { DataEntryFlowDialogParams } from "./show-dialog-data-entry-flow";
import {
loadDataEntryFlowDialog,
showFlowDialog,
} from "./show-dialog-data-entry-flow";
export const loadSubConfigFlowDialog = loadDataEntryFlowDialog;
export const showSubConfigFlowDialog = (
element: HTMLElement,
configEntry: ConfigEntry,
flowType: string,
dialogParams: Omit<DataEntryFlowDialogParams, "flowConfig"> & {
subEntryId?: string;
}
): void =>
showFlowDialog(element, dialogParams, {
flowType: "config_subentries_flow",
showDevices: true,
createFlow: async (hass, handler) => {
const [step] = await Promise.all([
createSubConfigFlow(hass, handler, flowType, dialogParams.subEntryId),
hass.loadFragmentTranslation("config"),
hass.loadBackendTranslation("config_subentries", configEntry.domain),
hass.loadBackendTranslation("selector", configEntry.domain),
// Used as fallback if no header defined for step
hass.loadBackendTranslation("title", configEntry.domain),
]);
return step;
},
fetchFlow: async (hass, flowId) => {
const step = await fetchSubConfigFlow(hass, flowId);
await hass.loadFragmentTranslation("config");
await hass.loadBackendTranslation(
"config_subentries",
configEntry.domain
);
await hass.loadBackendTranslation("selector", configEntry.domain);
return step;
},
handleFlowStep: handleSubConfigFlowStep,
deleteFlow: deleteSubConfigFlow,
renderAbortDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.abort.${step.reason}`,
step.description_placeholders
);
return description
? html`
<ha-markdown allowsvg breaks .content=${description}></ha-markdown>
`
: step.reason;
},
renderShowFormStepHeader(hass, step) {
return (
hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.title`,
step.description_placeholders
) || hass.localize(`component.${configEntry.domain}.title`)
);
},
renderShowFormStepDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.description`,
step.description_placeholders
);
return description
? html`
<ha-markdown allowsvg breaks .content=${description}></ha-markdown>
`
: "";
},
renderShowFormStepFieldLabel(hass, step, field, options) {
if (field.type === "expandable") {
return hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.sections.${field.name}.name`
);
}
const prefix = options?.path?.[0] ? `sections.${options.path[0]}.` : "";
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.${prefix}data.${field.name}`
) || field.name
);
},
renderShowFormStepFieldHelper(hass, step, field, options) {
if (field.type === "expandable") {
return hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.sections.${field.name}.description`
);
}
const prefix = options?.path?.[0] ? `sections.${options.path[0]}.` : "";
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.${prefix}data_description.${field.name}`,
step.description_placeholders
);
return description
? html`<ha-markdown breaks .content=${description}></ha-markdown>`
: "";
},
renderShowFormStepFieldError(hass, step, error) {
return (
hass.localize(
`component.${step.translation_domain || step.translation_domain || configEntry.domain}.config_subentries.${flowType}.error.${error}`,
step.description_placeholders
) || error
);
},
renderShowFormStepFieldLocalizeValue(hass, _step, key) {
return hass.localize(`component.${configEntry.domain}.selector.${key}`);
},
renderShowFormStepSubmitButton(hass, step) {
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.submit`
) ||
hass.localize(
`ui.panel.config.integrations.config_flow.${
step.last_step === false ? "next" : "submit"
}`
)
);
},
renderExternalStepHeader(hass, step) {
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.title`
) ||
hass.localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)
);
},
renderExternalStepDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.description`,
step.description_placeholders
);
return html`
<p>
${hass.localize(
"ui.panel.config.integrations.config_flow.external_step.description"
)}
</p>
${description
? html`
<ha-markdown
allowsvg
breaks
.content=${description}
></ha-markdown>
`
: ""}
`;
},
renderCreateEntryDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.create_entry.${
step.description || "default"
}`,
step.description_placeholders
);
return html`
${description
? html`
<ha-markdown
allowsvg
breaks
.content=${description}
></ha-markdown>
`
: ""}
<p>
${hass.localize(
"ui.panel.config.integrations.config_flow.created_config",
{ name: step.title }
)}
</p>
`;
},
renderShowFormProgressHeader(hass, step) {
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.title`
) || hass.localize(`component.${configEntry.domain}.title`)
);
},
renderShowFormProgressDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.progress.${step.progress_action}`,
step.description_placeholders
);
return description
? html`
<ha-markdown allowsvg breaks .content=${description}></ha-markdown>
`
: "";
},
renderMenuHeader(hass, step) {
return (
hass.localize(
`component.${configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.title`,
step.description_placeholders
) || hass.localize(`component.${configEntry.domain}.title`)
);
},
renderMenuDescription(hass, step) {
const description = hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.description`,
step.description_placeholders
);
return description
? html`
<ha-markdown allowsvg breaks .content=${description}></ha-markdown>
`
: "";
},
renderMenuOption(hass, step, option) {
return hass.localize(
`component.${step.translation_domain || configEntry.domain}.config_subentries.${flowType}.step.${step.step_id}.menu_options.${option}`,
step.description_placeholders
);
},
renderLoadingDescription(hass, reason, handler, step) {
if (reason !== "loading_flow" && reason !== "loading_step") {
return "";
}
const domain = step?.handler || handler;
return hass.localize(
`ui.panel.config.integrations.config_flow.loading.${reason}`,
{
integration: domain
? domainToName(hass.localize, domain)
: // when we are continuing a config flow, we only know the ID and not the domain
hass.localize(
"ui.panel.config.integrations.config_flow.loading.fallback_title"
),
}
);
},
});

View File

@ -51,8 +51,8 @@ import "../../../components/ha-icon-button";
import "../../../components/ha-md-menu-item";
import "../../../components/ha-sub-menu";
import { createAreaRegistryEntry } from "../../../data/area_registry";
import type { ConfigEntry } from "../../../data/config_entries";
import { sortConfigEntries } from "../../../data/config_entries";
import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
import { getSubEntries, sortConfigEntries } from "../../../data/config_entries";
import { fullEntitiesContext } from "../../../data/context";
import type { DataTableFilters } from "../../../data/data_table_filters";
import {
@ -108,6 +108,8 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public entries!: ConfigEntry[];
@state() private _subEntries?: SubEntry[];
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
entities!: EntityRegistryEntry[];
@ -219,6 +221,7 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
private _setFiltersFromUrl() {
const domain = this._searchParms.get("domain");
const configEntry = this._searchParms.get("config_entry");
const subEntry = this._searchParms.get("sub_entry");
const label = this._searchParms.has("label");
if (!domain && !configEntry && !label) {
@ -243,6 +246,10 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
value: configEntry ? [configEntry] : [],
items: undefined,
},
sub_entry: {
value: subEntry ? [subEntry] : [],
items: undefined,
},
};
this._filterLabel();
}
@ -334,6 +341,32 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
if (configEntries.length === 1) {
filteredConfigEntry = configEntries[0];
}
} else if (
key === "sub_entry" &&
Array.isArray(filter.value) &&
filter.value.length
) {
if (
!(
Array.isArray(this._filters.config_entry?.value) &&
this._filters.config_entry.value.length === 1
)
) {
return;
}
const configEntryId = this._filters.config_entry.value[0];
outputDevices = outputDevices.filter(
(device) =>
device.config_entries_subentries[configEntryId] &&
(filter.value as string[]).some((subEntryId) =>
device.config_entries_subentries[configEntryId].includes(
subEntryId
)
)
);
if (!this._subEntries) {
this._loadSubEntries(configEntryId);
}
} else if (
key === "ha-filter-integrations" &&
Array.isArray(filter.value) &&
@ -755,7 +788,15 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
${this.entries?.find(
(entry) =>
entry.entry_id === this._filters.config_entry!.value![0]
)?.title || this._filters.config_entry.value[0]}
)?.title || this._filters.config_entry.value[0]}${this._filters
.config_entry.value.length === 1 &&
Array.isArray(this._filters.sub_entry?.value) &&
this._filters.sub_entry.value.length
? html` (${this._subEntries?.find(
(entry) =>
entry.subentry_id === this._filters.sub_entry!.value![0]
)?.title || this._filters.sub_entry!.value![0]})`
: nothing}
</ha-alert>`
: nothing}
<ha-filter-floor-areas
@ -888,6 +929,10 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
`;
}
private async _loadSubEntries(entryId: string) {
this._subEntries = await getSubEntries(this.hass, entryId);
}
private _filterExpanded(ev) {
if (ev.detail.expanded) {
this._expandedFilter = ev.target.localName;

View File

@ -66,8 +66,8 @@ import "../../../components/ha-icon-button";
import "../../../components/ha-md-menu-item";
import "../../../components/ha-sub-menu";
import "../../../components/ha-svg-icon";
import type { ConfigEntry } from "../../../data/config_entries";
import { getConfigEntries } from "../../../data/config_entries";
import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
import { getConfigEntries, getSubEntries } from "../../../data/config_entries";
import { fullEntitiesContext } from "../../../data/context";
import type {
DataTableFiltersItems,
@ -146,6 +146,8 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
@state() private _entries?: ConfigEntry[];
@state() private _subEntries?: SubEntry[];
@state() private _manifests?: IntegrationManifest[];
@state()
@ -522,6 +524,27 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
if (configEntries.length === 1) {
filteredConfigEntry = configEntries[0];
}
} else if (
key === "sub_entry" &&
Array.isArray(filter) &&
filter.length
) {
if (
!(
Array.isArray(this._filters.config_entry) &&
this._filters.config_entry.length === 1
)
) {
return;
}
filteredEntities = filteredEntities.filter(
(entity) =>
entity.config_subentry_id &&
(filter as string[]).includes(entity.config_subentry_id)
);
if (!this._subEntries) {
this._loadSubEntries(this._filters.config_entry[0]);
}
} else if (
key === "ha-filter-integrations" &&
Array.isArray(filter) &&
@ -904,14 +927,22 @@ ${
</ha-md-button-menu>
${
Array.isArray(this._filters.config_entry) &&
this._filters.config_entry?.length
this._filters.config_entry.length
? html`<ha-alert slot="filter-pane">
${this.hass.localize(
"ui.panel.config.entities.picker.filtering_by_config_entry"
)}
${this._entries?.find(
(entry) => entry.entry_id === this._filters.config_entry![0]
)?.title || this._filters.config_entry[0]}
)?.title || this._filters.config_entry[0]}${this._filters
.config_entry.length === 1 &&
Array.isArray(this._filters.sub_entry) &&
this._filters.sub_entry.length
? html` (${this._subEntries?.find(
(entry) =>
entry.subentry_id === this._filters.sub_entry![0]
)?.title || this._filters.sub_entry[0]})`
: nothing}
</ha-alert>`
: nothing
}
@ -1024,6 +1055,7 @@ ${
private _setFiltersFromUrl() {
const domain = this._searchParms.get("domain");
const configEntry = this._searchParms.get("config_entry");
const subEntry = this._searchParms.get("sub_entry");
const label = this._searchParms.has("label");
if (!domain && !configEntry && !label) {
@ -1036,6 +1068,7 @@ ${
"ha-filter-states": [],
"ha-filter-integrations": domain ? [domain] : [],
config_entry: configEntry ? [configEntry] : [],
sub_entry: subEntry ? [subEntry] : [],
};
this._filterLabel();
}
@ -1093,6 +1126,7 @@ ${
hidden_by: null,
area_id: null,
config_entry_id: null,
config_subentry_id: null,
device_id: null,
icon: null,
readonly: true,
@ -1384,6 +1418,10 @@ ${rejected
this._entries = await getConfigEntries(this.hass);
}
private async _loadSubEntries(entryId: string) {
this._subEntries = await getSubEntries(this.hass, entryId);
}
private _addDevice() {
const { filteredConfigEntry, filteredDomains } =
this._filteredEntitiesAndDomains(

View File

@ -16,6 +16,7 @@ import {
mdiOpenInNew,
mdiPackageVariant,
mdiPlayCircleOutline,
mdiPlus,
mdiProgressHelper,
mdiReload,
mdiReloadAlert,
@ -52,14 +53,17 @@ import { getSignedPath } from "../../../data/auth";
import type {
ConfigEntry,
DisableConfigEntryResult,
SubEntry,
} from "../../../data/config_entries";
import {
ERROR_STATES,
RECOVERABLE_STATES,
deleteConfigEntry,
deleteSubEntry,
disableConfigEntry,
enableConfigEntry,
getConfigEntries,
getSubEntries,
reloadConfigEntry,
updateConfigEntry,
} from "../../../data/config_entries";
@ -106,6 +110,7 @@ import { fileDownload } from "../../../util/file_download";
import type { DataEntryFlowProgressExtended } from "./ha-config-integrations";
import { showAddIntegrationDialog } from "./show-add-integration-dialog";
import { QUALITY_SCALE_MAP } from "../../../data/integration_quality_scale";
import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow";
export const renderConfigEntryError = (
hass: HomeAssistant,
@ -172,6 +177,8 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
@state() private _domainEntities: Record<string, string[]> = {};
@state() private _subEntries: Record<string, SubEntry[]> = {};
private _configPanel = memoizeOne(
(domain: string, panels: HomeAssistant["panels"]): string | undefined =>
Object.values(panels).find(
@ -214,11 +221,18 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("domain")) {
this.hass.loadBackendTranslation("title", [this.domain]);
this.hass.loadBackendTranslation("config_subentries", [this.domain]);
this._extraConfigEntries = undefined;
this._fetchManifest();
this._fetchDiagnostics();
this._fetchEntitySources();
}
if (
changedProperties.has("configEntries") ||
changedProperties.has("_extraConfigEntries")
) {
this._fetchSubEntries();
}
}
private async _fetchEntitySources() {
@ -673,6 +687,73 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
ev.target.style.display = "none";
}
private _renderDeviceLine(
item: ConfigEntry,
devices: DeviceRegistryEntry[],
services: DeviceRegistryEntry[],
entities: EntityRegistryEntry[],
subItem?: SubEntry
) {
let devicesLine: (TemplateResult | string)[] = [];
for (const [items, localizeKey] of [
[devices, "devices"],
[services, "services"],
] as const) {
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}${subItem ? `&sub_entry=${subItem.subentry_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}${subItem ? `&sub_entry=${subItem.subentry_id}` : ""}`}
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.entities",
{ count: entities.length }
)}</a
>`
);
}
if (devicesLine.length === 0) {
devicesLine = [
this.hass.localize(
"ui.panel.config.integrations.config_entry.no_devices_or_entities"
),
];
} else 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 devicesLine;
}
private _renderConfigEntry(item: ConfigEntry) {
let stateText: Parameters<typeof this.hass.localize> | undefined;
let stateTextExtra: TemplateResult | string | undefined;
@ -720,66 +801,13 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
)}.`);
}
} else {
for (const [items, localizeKey] of [
[devices, "devices"],
[services, "services"],
] as const) {
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 === 0) {
devicesLine = [
this.hass.localize(
"ui.panel.config.integrations.config_entry.no_devices_or_entities"
),
];
} else 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],
];
}
devicesLine = this._renderDeviceLine(item, devices, services, entities);
}
const configPanel = this._configPanel(item.domain, this.hass.panels);
const subEntries = this._subEntries[item.entry_id] || [];
return html`<ha-md-list-item
class=${classMap({
config_entry: true,
@ -913,6 +941,21 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
)}
</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}.title`
)}</ha-md-menu-item
>`
)}
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
${this._diagnosticHandler && item.state === "loaded"
@ -989,6 +1032,69 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
`
: nothing}
</ha-md-button-menu>
</ha-md-list-item>
${subEntries.map((subEntry) => this._renderSubEntry(item, subEntry))}`;
}
private _renderSubEntry(configEntry: ConfigEntry, subEntry: SubEntry) {
const devices = this._getConfigEntryDevices(configEntry).filter((device) =>
device.config_entries_subentries[configEntry.entry_id]?.includes(
subEntry.subentry_id
)
);
const services = this._getConfigEntryServices(configEntry).filter(
(device) =>
device.config_entries_subentries[configEntry.entry_id]?.includes(
subEntry.subentry_id
)
);
const entities = this._getConfigEntryEntities(configEntry).filter(
(entity) => entity.config_subentry_id === subEntry.subentry_id
);
return html`<ha-md-list-item
class="sub-entry"
data-entry-id=${configEntry.entry_id}
.configEntry=${configEntry}
.subEntry=${subEntry}
>
<span slot="headline">${subEntry.title}</span>
<span slot="supporting-text"
>${this._renderDeviceLine(
configEntry,
devices,
services,
entities,
subEntry
)}</span
>
${configEntry.supported_subentry_types[subEntry.subentry_type]
?.supports_reconfigure
? html`
<ha-button slot="end" @click=${this._handleReconfigureSub}>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
</ha-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>
<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>`;
}
@ -1031,6 +1137,27 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
}
}
private async _fetchSubEntries() {
const subEntriesPromises = (
this._extraConfigEntries || this.configEntries
)?.map((entry) =>
entry.num_subentries
? getSubEntries(this.hass, entry.entry_id).then((subEntries) => ({
entry_id: entry.entry_id,
subEntries,
}))
: undefined
);
if (subEntriesPromises) {
const subEntries = await Promise.all(subEntriesPromises);
this._subEntries = {};
subEntries.forEach((entry) => {
if (!entry) return;
this._subEntries[entry.entry_id] = entry.subEntries;
});
}
}
private async _fetchDiagnostics() {
if (!this.domain || !isComponentLoaded(this.hass, "diagnostics")) {
return;
@ -1178,6 +1305,49 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
);
}
private async _handleReconfigureSub(ev: Event): Promise<void> {
const configEntry = (
(ev.target as HTMLElement).closest(".sub-entry") as any
).configEntry;
const subEntry = ((ev.target as HTMLElement).closest(".sub-entry") as any)
.subEntry;
showSubConfigFlowDialog(
this,
configEntry,
subEntry.flowType || subEntry.subentry_type,
{
startFlowHandler: configEntry.entry_id,
subEntryId: subEntry.subentry_id,
}
);
}
private async _handleDeleteSub(ev: Event): Promise<void> {
const configEntry = (
(ev.target as HTMLElement).closest(".sub-entry") as any
).configEntry;
const subEntry = ((ev.target as HTMLElement).closest(".sub-entry") as any)
.subEntry;
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_entry.delete_confirm_title",
{ title: 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, configEntry.entry_id, subEntry.subentry_id);
}
private _handleDisable(ev: Event): void {
this._disableIntegration(
((ev.target as HTMLElement).closest(".config_entry") as any).configEntry
@ -1456,6 +1626,12 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
});
}
private async _addSubEntry(ev) {
showSubConfigFlowDialog(this, ev.target.entry, ev.target.flowType, {
startFlowHandler: ev.target.entry.entry_id,
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
@ -1585,6 +1761,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
pointer-events: none;
content: "";
}
ha-md-list-item.sub-entry {
--md-list-item-leading-space: 50px;
}
a {
text-decoration: none;
}

View File

@ -207,6 +207,8 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
supports_remove_device: false,
supports_unload: false,
supports_reconfigure: false,
supported_subentry_types: {},
num_subentries: 0,
pref_disable_new_entities: false,
pref_disable_polling: false,
disabled_by: null,