diff --git a/demo/src/ha-demo.ts b/demo/src/ha-demo.ts index d133aaa5248f..560fef69c30a 100644 --- a/demo/src/ha-demo.ts +++ b/demo/src/ha-demo.ts @@ -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, diff --git a/demo/src/stubs/config_entries.ts b/demo/src/stubs/config_entries.ts index 583ab7d773e2..01685f3848a3 100644 --- a/demo/src/stubs/config_entries.ts +++ b/demo/src/stubs/config_entries.ts @@ -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, diff --git a/gallery/src/pages/components/ha-form.ts b/gallery/src/pages/components/ha-form.ts index 8fc4b7390a83..b890f571cf8a 100644 --- a/gallery/src/pages/components/ha-form.ts +++ b/gallery/src/pages/components/ha-form.ts @@ -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, diff --git a/gallery/src/pages/components/ha-selector.ts b/gallery/src/pages/components/ha-selector.ts index 0601ae66afc5..5e0e6d8fe010 100644 --- a/gallery/src/pages/components/ha-selector.ts +++ b/gallery/src/pages/components/ha-selector.ts @@ -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, diff --git a/gallery/src/pages/misc/integration-card.ts b/gallery/src/pages/misc/integration-card.ts index f75826fcc6be..11d513331b80 100644 --- a/gallery/src/pages/misc/integration-card.ts +++ b/gallery/src/pages/misc/integration-card.ts @@ -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", diff --git a/src/data/config_entries.ts b/src/data/config_entries.ts index 3558d6e10946..216ad9765d32 100644 --- a/src/data/config_entries.ts +++ b/src/data/config_entries.ts @@ -19,6 +19,10 @@ export interface ConfigEntry { supports_remove_device: boolean; supports_unload: boolean; supports_reconfigure: boolean; + supported_subentry_types: { + [key: string]: { supports_reconfigure: boolean }; + }; + num_subentries: number; pref_disable_new_entities: boolean; pref_disable_polling: boolean; disabled_by: "user" | null; @@ -27,6 +31,30 @@ export interface ConfigEntry { error_reason_translation_placeholders: Record | null; } +export interface SubConfigEntry { + subentry_id: string; + subentry_type: string; + title: string; + unique_id: string; +} + +export const getSubConfigEntries = (hass: HomeAssistant, entry_id: string) => + hass.callWS({ + type: "config_entries/subentries/list", + entry_id, + }); + +export const deleteSubConfigEntry = ( + 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, diff --git a/src/data/data_entry_flow.ts b/src/data/data_entry_flow.ts index 33b49424e33a..ded6ecf806d9 100644 --- a/src/data/data_entry_flow.ts +++ b/src/data/data_entry_flow.ts @@ -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"; diff --git a/src/data/device_registry.ts b/src/data/device_registry.ts index 9eca8f9865b7..6700ab4c5df3 100644 --- a/src/data/device_registry.ts +++ b/src/data/device_registry.ts @@ -17,6 +17,7 @@ export { export interface DeviceRegistryEntry extends RegistryEntry { id: string; config_entries: string[]; + config_entries_subentries: { [configEntryId: string]: (string | null)[] }; connections: [string, string][]; identifiers: [string, string][]; manufacturer: string | null; diff --git a/src/data/entity_registry.ts b/src/data/entity_registry.ts index 770d998d7c81..b76773658bf3 100644 --- a/src/data/entity_registry.ts +++ b/src/data/entity_registry.ts @@ -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[]; diff --git a/src/data/sub_config_flow.ts b/src/data/sub_config_flow.ts new file mode 100644 index 000000000000..388435e2906f --- /dev/null +++ b/src/data/sub_config_flow.ts @@ -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( + "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( + "GET", + `config/config_entries/subentries/flow/${flowId}`, + undefined, + HEADERS + ); + +export const handleSubConfigFlowStep = ( + hass: HomeAssistant, + flowId: string, + data: Record +) => + hass.callApi( + "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}`); diff --git a/src/data/translation.ts b/src/data/translation.ts index 5db27ac98708..c3b10f42864e 100644 --- a/src/data/translation.ts +++ b/src/data/translation.ts @@ -63,6 +63,7 @@ export type TranslationCategory = | "entity_component" | "exceptions" | "config" + | "config_subentries" | "config_panel" | "options" | "device_automation" diff --git a/src/dialogs/config-flow/previews/flow-preview-generic.ts b/src/dialogs/config-flow/previews/flow-preview-generic.ts index bec40add9174..b04845ecb53b 100644 --- a/src/dialogs/config-flow/previews/flow-preview-generic.ts +++ b/src/dialogs/config-flow/previews/flow-preview-generic.ts @@ -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 { diff --git a/src/dialogs/config-flow/previews/flow-preview-template.ts b/src/dialogs/config-flow/previews/flow-preview-template.ts index e4c8c3eafe06..5697128a3db6 100644 --- a/src/dialogs/config-flow/previews/flow-preview-template.ts +++ b/src/dialogs/config-flow/previews/flow-preview-template.ts @@ -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 { diff --git a/src/dialogs/config-flow/show-dialog-config-flow.ts b/src/dialogs/config-flow/show-dialog-config-flow.ts index dd0aaa438e0b..f94d1687d3a2 100644 --- a/src/dialogs/config-flow/show-dialog-config-flow.ts +++ b/src/dialogs/config-flow/show-dialog-config-flow.ts @@ -16,7 +16,9 @@ export const loadConfigFlowDialog = loadDataEntryFlowDialog; export const showConfigFlowDialog = ( element: HTMLElement, - dialogParams: Omit + dialogParams: Omit & { + entryId?: string; + } ): void => showFlowDialog(element, dialogParams, { flowType: "config_flow", diff --git a/src/dialogs/config-flow/show-dialog-data-entry-flow.ts b/src/dialogs/config-flow/show-dialog-data-entry-flow.ts index bb1d2bbba226..3d67b2e9c556 100644 --- a/src/dialogs/config-flow/show-dialog-data-entry-flow.ts +++ b/src/dialogs/config-flow/show-dialog-data-entry-flow.ts @@ -148,7 +148,6 @@ export interface DataEntryFlowDialogParams { }) => void; flowConfig: FlowConfig; showAdvanced?: boolean; - entryId?: string; dialogParentElement?: HTMLElement; } diff --git a/src/dialogs/config-flow/show-dialog-sub-config-flow.ts b/src/dialogs/config-flow/show-dialog-sub-config-flow.ts new file mode 100644 index 000000000000..bc886985b272 --- /dev/null +++ b/src/dialogs/config-flow/show-dialog-sub-config-flow.ts @@ -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 & { + 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` + + ` + : 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` + + ` + : ""; + }, + + 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`` + : ""; + }, + + 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` +

+ ${hass.localize( + "ui.panel.config.integrations.config_flow.external_step.description" + )} +

+ ${description + ? html` + + ` + : ""} + `; + }, + + 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` + + ` + : ""} +

+ ${hass.localize( + "ui.panel.config.integrations.config_flow.created_config", + { name: step.title } + )} +

+ `; + }, + + 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` + + ` + : ""; + }, + + 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` + + ` + : ""; + }, + + 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" + ), + } + ); + }, + }); diff --git a/src/panels/config/devices/ha-config-devices-dashboard.ts b/src/panels/config/devices/ha-config-devices-dashboard.ts index 1ed9f65bbbc0..876f21c0f645 100644 --- a/src/panels/config/devices/ha-config-devices-dashboard.ts +++ b/src/panels/config/devices/ha-config-devices-dashboard.ts @@ -51,8 +51,11 @@ 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, SubConfigEntry } from "../../../data/config_entries"; +import { + getSubConfigEntries, + sortConfigEntries, +} from "../../../data/config_entries"; import { fullEntitiesContext } from "../../../data/context"; import type { DataTableFilters } from "../../../data/data_table_filters"; import { @@ -108,6 +111,8 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) { @property({ attribute: false }) public entries!: ConfigEntry[]; + @state() private _subConfigEntries?: SubConfigEntry[]; + @state() @consume({ context: fullEntitiesContext, subscribe: true }) entities!: EntityRegistryEntry[]; @@ -219,6 +224,7 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) { private _setFiltersFromUrl() { const domain = this._searchParms.get("domain"); const configEntry = this._searchParms.get("config_entry"); + const subConfigEntry = this._searchParms.get("sub_entry"); const label = this._searchParms.has("label"); if (!domain && !configEntry && !label) { @@ -243,6 +249,10 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) { value: configEntry ? [configEntry] : [], items: undefined, }, + sub_config_entry: { + value: subConfigEntry ? [subConfigEntry] : [], + items: undefined, + }, }; this._filterLabel(); } @@ -334,6 +344,32 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) { if (configEntries.length === 1) { filteredConfigEntry = configEntries[0]; } + } else if ( + key === "sub_config_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._subConfigEntries) { + this._loadSubConfigEntries(configEntryId); + } } else if ( key === "ha-filter-integrations" && Array.isArray(filter.value) && @@ -755,7 +791,16 @@ 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_config_entry?.value) && + this._filters.sub_config_entry.value.length + ? html` (${this._subConfigEntries?.find( + (entry) => + entry.subentry_id === + this._filters.sub_config_entry!.value![0] + )?.title || this._filters.sub_config_entry!.value![0]})` + : nothing} ` : nothing} + entity.config_subentry_id && + (filter as string[]).includes(entity.config_subentry_id) + ); + if (!this._subConfigEntries) { + this._loadSubConfigEntries(this._filters.config_entry[0]); + } } else if ( key === "ha-filter-integrations" && Array.isArray(filter) && @@ -904,14 +930,22 @@ ${ ${ Array.isArray(this._filters.config_entry) && - this._filters.config_entry?.length + this._filters.config_entry.length ? html` ${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_config_entry) && + this._filters.sub_config_entry.length + ? html` (${this._subConfigEntries?.find( + (entry) => + entry.subentry_id === this._filters.sub_config_entry![0] + )?.title || this._filters.sub_config_entry[0]})` + : nothing} ` : nothing } @@ -1024,6 +1058,7 @@ ${ private _setFiltersFromUrl() { const domain = this._searchParms.get("domain"); const configEntry = this._searchParms.get("config_entry"); + const subConfigEntry = this._searchParms.get("sub_entry"); const label = this._searchParms.has("label"); if (!domain && !configEntry && !label) { @@ -1036,6 +1071,7 @@ ${ "ha-filter-states": [], "ha-filter-integrations": domain ? [domain] : [], config_entry: configEntry ? [configEntry] : [], + sub_config_entry: subConfigEntry ? [subConfigEntry] : [], }; this._filterLabel(); } @@ -1093,6 +1129,7 @@ ${ hidden_by: null, area_id: null, config_entry_id: null, + config_subentry_id: null, device_id: null, icon: null, readonly: true, @@ -1384,6 +1421,10 @@ ${rejected this._entries = await getConfigEntries(this.hass); } + private async _loadSubConfigEntries(entryId: string) { + this._subConfigEntries = await getSubConfigEntries(this.hass, entryId); + } + private _addDevice() { const { filteredConfigEntry, filteredDomains } = this._filteredEntitiesAndDomains( diff --git a/src/panels/config/integrations/ha-config-integration-page.ts b/src/panels/config/integrations/ha-config-integration-page.ts index a01b1e015b66..b24348afd73e 100644 --- a/src/panels/config/integrations/ha-config-integration-page.ts +++ b/src/panels/config/integrations/ha-config-integration-page.ts @@ -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, + SubConfigEntry, } from "../../../data/config_entries"; import { ERROR_STATES, RECOVERABLE_STATES, deleteConfigEntry, + deleteSubConfigEntry, disableConfigEntry, enableConfigEntry, getConfigEntries, + getSubConfigEntries, 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 = {}; + @state() private _subEntries: Record = {}; + private _configPanel = memoizeOne( (domain: string, panels: HomeAssistant["panels"]): string | undefined => Object.values(panels).find( @@ -219,6 +226,12 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { this._fetchDiagnostics(); this._fetchEntitySources(); } + if ( + changedProperties.has("configEntries") || + changedProperties.has("_extraConfigEntries") + ) { + this._fetchSubEntries(); + } } private async _fetchEntitySources() { @@ -573,7 +586,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { const attention = ATTENTION_SOURCES.includes( flow.context.source ); - return html` ${flow.localized_title} @@ -673,6 +686,73 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { ev.target.style.display = "none"; } + private _renderDeviceLine( + item: ConfigEntry, + devices: DeviceRegistryEntry[], + services: DeviceRegistryEntry[], + entities: EntityRegistryEntry[], + subItem?: SubConfigEntry + ) { + 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`${this.hass.localize( + `ui.panel.config.integrations.config_entry.${localizeKey}`, + { count: items.length } + )}` + ); + } + + if (entities.length) { + devicesLine.push( + // no white space before/after template on purpose + html`${this.hass.localize( + "ui.panel.config.integrations.config_entry.entities", + { count: entities.length } + )}` + ); + } + + 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 | undefined; let stateTextExtra: TemplateResult | string | undefined; @@ -720,274 +800,297 @@ 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`${this.hass.localize( - `ui.panel.config.integrations.config_entry.${localizeKey}`, - { count: items.length } - )}` - ); - } - - if (entities.length) { - devicesLine.push( - // no white space before/after template on purpose - html`${this.hass.localize( - "ui.panel.config.integrations.config_entry.entities", - { count: entities.length } - )}` - ); - } - - 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` -
- ${item.title || domainToName(this.hass.localize, item.domain)} -
-
-
${devicesLine}
- ${stateText - ? html` -
- -
- ${this.hass.localize(...stateText)}${stateTextExtra - ? html`: ${stateTextExtra}` - : nothing} -
-
- ` - : nothing} -
- ${item.disabled_by === "user" - ? html` - ${this.hass.localize("ui.common.enable")} - ` - : configPanel && - (item.domain !== "matter" || - isDevVersion(this.hass.config.version)) && - !stateText - ? html` - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.configure" - )} - ` - : item.supports_options + 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, + })} + data-entry-id=${item.entry_id} + .configEntry=${item} + > +
+ ${item.title || domainToName(this.hass.localize, item.domain)} +
+
+
${devicesLine}
+ ${stateText ? html` - +
+ +
+ ${this.hass.localize(...stateText)}${stateTextExtra + ? html`: ${stateTextExtra}` + : nothing} +
+
+ ` + : nothing} +
+ ${item.disabled_by === "user" + ? html` + ${this.hass.localize("ui.common.enable")} + ` + : configPanel && + (item.domain !== "matter" || + isDevVersion(this.hass.config.version)) && + !stateText + ? html` ${this.hass.localize( "ui.panel.config.integrations.config_entry.configure" )} - + ` + : item.supports_options + ? html` + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.configure" + )} + + ` + : nothing} + + + ${item.disabled_by && devices.length + ? html` + + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.devices`, + { count: devices.length } + )} + + ` : nothing} - - - ${item.disabled_by && devices.length - ? html` - - - ${this.hass.localize( - `ui.panel.config.integrations.config_entry.devices`, - { count: devices.length } - )} - - - ` - : nothing} - ${item.disabled_by && services.length - ? html` - - ${this.hass.localize( - `ui.panel.config.integrations.config_entry.services`, - { count: services.length } - )} - - ` - : nothing} - ${item.disabled_by && entities.length - ? html` - ${this.hass.localize( - `ui.panel.config.integrations.config_entry.entities`, - { count: entities.length } + `ui.panel.config.integrations.config_entry.services`, + { count: services.length } )} - - ` - : nothing} - ${!item.disabled_by && - RECOVERABLE_STATES.includes(item.state) && - item.supports_unload && - item.source !== "system" - ? html` - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.reload" - )} - - ` - : nothing} + ` + : nothing} + ${item.disabled_by && entities.length + ? html` + + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.entities`, + { count: entities.length } + )} + + + ` + : nothing} + ${!item.disabled_by && + RECOVERABLE_STATES.includes(item.state) && + item.supports_unload && + item.source !== "system" + ? html` + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.reload" + )} + + ` + : nothing} - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.rename" + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.rename" + )} + + + ${Object.keys(item.supported_subentry_types).map( + (flowType) => + html` + + Add ${flowType}` )} - - - - ${this._diagnosticHandler && item.state === "loaded" - ? html` - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.download_diagnostics" - )} - - ` - : nothing} - ${!item.disabled_by && - item.supports_reconfigure && - item.source !== "system" - ? html` - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.reconfigure" - )} - - ` - : nothing} + - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.system_options" - )} - - ${item.disabled_by === "user" - ? html` - - - ${this.hass.localize("ui.common.enable")} - - ` - : item.source !== "system" + ${this._diagnosticHandler && item.state === "loaded" ? html` + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.download_diagnostics" + )} + + ` + : nothing} + ${!item.disabled_by && + item.supports_reconfigure && + item.source !== "system" + ? html` + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.reconfigure" + )} + + ` + : nothing} + + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.system_options" + )} + + ${item.disabled_by === "user" + ? html` + + ${this.hass.localize("ui.common.enable")} + + ` + : item.source !== "system" + ? html` + + + ${this.hass.localize("ui.common.disable")} + + ` + : nothing} + ${item.source !== "system" + ? html` + + - ${this.hass.localize("ui.common.disable")} + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.delete" + )} ` : nothing} - ${item.source !== "system" - ? html` - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.delete" - )} - - ` - : nothing} + +
+ ${subEntries.map((subEntry) => this._renderSubEntry(item, subEntry))}`; + } + + private _renderSubEntry(configEntry: ConfigEntry, subEntry: SubConfigEntry) { + 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` + ${subEntry.title} + ${this._renderDeviceLine( + configEntry, + devices, + services, + entities, + subEntry + )} + ${configEntry.supported_subentry_types[subEntry.subentry_type] + ?.supports_reconfigure + ? html` + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.configure" + )} + + ` + : nothing} + + + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.delete" + )} + `; } @@ -1030,6 +1133,27 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { } } + private async _fetchSubEntries() { + const subEntriesPromises = ( + this._extraConfigEntries || this.configEntries + )?.map((entry) => + entry.num_subentries + ? getSubConfigEntries(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; @@ -1177,6 +1301,53 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { ); } + private async _handleReconfigureSub(ev: Event): Promise { + const configEntry = ( + (ev.target as HTMLElement).closest(".sub-entry") as any + ).configEntry; + const subEntry = ((ev.target as HTMLElement).closest(".sub-entry") as any) + .subConfigEntry; + + showSubConfigFlowDialog( + this, + configEntry, + subEntry.flowType || subEntry.subentry_type, + { + startFlowHandler: configEntry.entry_id, + subEntryId: subEntry.subentry_id, + } + ); + } + + private async _handleDeleteSub(ev: Event): Promise { + const configEntry = ( + (ev.target as HTMLElement).closest(".sub-entry") as any + ).configEntry; + const subEntry = ((ev.target as HTMLElement).closest(".sub-entry") as any) + .subConfigEntry; + 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 deleteSubConfigEntry( + 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 @@ -1454,6 +1625,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, @@ -1583,6 +1760,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; } diff --git a/src/panels/config/integrations/ha-config-integrations-dashboard.ts b/src/panels/config/integrations/ha-config-integrations-dashboard.ts index 5a24a2d5dea0..db98344886dd 100644 --- a/src/panels/config/integrations/ha-config-integrations-dashboard.ts +++ b/src/panels/config/integrations/ha-config-integrations-dashboard.ts @@ -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,