Show yaml setup integrations in the UI (#21447)

* Show yaml setup integrations in the UI

* Update en.json

* Move config entry logic to memoize function
This commit is contained in:
Bram Kragten 2024-07-22 15:44:08 +02:00 committed by GitHub
parent bbb64870a1
commit d96ddf968c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 301 additions and 46 deletions

View File

@ -6,12 +6,12 @@ import {
mdiSofa,
} from "@mdi/js";
import {
css,
CSSResultGroup,
html,
LitElement,
nothing,
PropertyValues,
css,
html,
nothing,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
@ -20,7 +20,7 @@ import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import { Blueprints, fetchBlueprints } from "../data/blueprint";
import { ConfigEntry, getConfigEntries } from "../data/config_entries";
import { findRelated, ItemType, RelatedResult } from "../data/search";
import { ItemType, RelatedResult, findRelated } from "../data/search";
import { haStyle } from "../resources/styles";
import { HomeAssistant } from "../types";
import { brandsUrl } from "../util/brands-url";
@ -109,6 +109,26 @@ export class HaRelatedItems extends LitElement {
)
);
private _getConfigEntries = memoizeOne(
(
relatedConfigEntries: string[] | undefined,
entries: ConfigEntry[] | undefined
) => {
const configEntries =
relatedConfigEntries && entries
? relatedConfigEntries.map((entryId) =>
entries!.find((configEntry) => configEntry.entry_id === entryId)
)
: undefined;
const configEntryDomains = new Set(
configEntries?.map((entry) => entry?.domain)
);
return { configEntries, configEntryDomains };
}
);
protected render() {
if (!this._related) {
return nothing;
@ -128,22 +148,25 @@ export class HaRelatedItems extends LitElement {
</mwc-list>
`;
}
const { configEntries, configEntryDomains } = this._getConfigEntries(
this._related.config_entry,
this._entries
);
return html`
${this._related.config_entry && this._entries
${configEntries || this._related.integration
? html`<h3>
${this.hass.localize("ui.components.related-items.integration")}
</h3>
<mwc-list
>${this._related.config_entry.map((relatedConfigEntryId) => {
const entry: ConfigEntry | undefined = this._entries!.find(
(configEntry) => configEntry.entry_id === relatedConfigEntryId
);
>${configEntries?.map((entry) => {
if (!entry) {
return nothing;
}
return html`
<a
href=${`/config/integrations/integration/${entry.domain}#config_entry=${relatedConfigEntryId}`}
href=${`/config/integrations/integration/${entry.domain}#config_entry=${entry.entry_id}`}
@click=${this._navigateAwayClose}
>
<ha-list-item hasMeta graphic="icon">
@ -164,8 +187,34 @@ export class HaRelatedItems extends LitElement {
</ha-list-item>
</a>
`;
})}</mwc-list
>`
})}
${this._related.integration
?.filter((integration) => !configEntryDomains.has(integration))
.map(
(integration) =>
html`<a
href=${`/config/integrations/integration/${integration}`}
@click=${this._navigateAwayClose}
>
<ha-list-item hasMeta graphic="icon">
<img
.src=${brandsUrl({
domain: integration,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
referrerpolicy="no-referrer"
alt=${integration}
slot="graphic"
/>
${this.hass.localize(`component.${integration}.title`)}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>`
)}
</mwc-list>`
: nothing}
${this._related.device
? html`<h3>

View File

@ -32,7 +32,11 @@ export const fetchRepairsIssues = (conn: Connection) =>
type: "repairs/list_issues",
});
export const fetchRepairsIssueData = (conn: Connection, domain, issue_id) =>
export const fetchRepairsIssueData = (
conn: Connection,
domain: string,
issue_id: string
) =>
conn.sendMessagePromise<{ issue_data: { string: any } }>({
type: "repairs/get_issue_data",
domain,

View File

@ -8,6 +8,7 @@ export interface RelatedResult {
device?: string[];
entity?: string[];
group?: string[];
integration?: string[];
scene?: string[];
script?: string[];
script_blueprint?: string[];

View File

@ -507,8 +507,30 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
)
.map((entry) => entry.entry_id);
const filteredEntitiesByDomain = new Set<string>();
const entitySources = this._entitySources || {};
const entitiesByDomain = {};
for (const [entity, source] of Object.entries(entitySources)) {
if (!(source.domain in entitiesByDomain)) {
entitiesByDomain[source.domain] = [];
}
entitiesByDomain[source.domain].push(entity);
}
for (const val of filter.value) {
if (val in entitiesByDomain) {
entitiesByDomain[val].forEach((item) =>
filteredEntitiesByDomain.add(item)
);
}
}
filteredEntities = filteredEntities.filter(
(entity) =>
filteredEntitiesByDomain.has(entity.entity_id) ||
(filter.value as string[]).includes(entity.platform) ||
(entity.config_entry_id &&
entryIds.includes(entity.config_entry_id))
@ -951,6 +973,9 @@ ${
}
protected firstUpdated() {
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
this._setFiltersFromUrl();
if (Object.keys(this._filters).length) {
return;
@ -961,9 +986,6 @@ ${
items: undefined,
},
};
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
}
private _setFiltersFromUrl() {

View File

@ -108,6 +108,7 @@ import { documentationUrl } from "../../../util/documentation-url";
import { fileDownload } from "../../../util/file_download";
import { DataEntryFlowProgressExtended } from "./ha-config-integrations";
import { showAddIntegrationDialog } from "./show-add-integration-dialog";
import { fetchEntitySourcesWithCache } from "../../../data/entity_sources";
@customElement("ha-config-integration-page")
class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
@ -140,6 +141,8 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
window.location.hash.substring(1)
);
@state() private _domainEntities: Record<string, string[]> = {};
private _configPanel = memoizeOne(
(domain: string, panels: HomeAssistant["panels"]): string | undefined =>
Object.values(panels).find(
@ -185,9 +188,25 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this._extraConfigEntries = undefined;
this._fetchManifest();
this._fetchDiagnostics();
this._fetchEntitySources();
}
}
private async _fetchEntitySources() {
const entitySources = await fetchEntitySourcesWithCache(this.hass);
const entitiesByDomain = {};
for (const [entity, source] of Object.entries(entitySources)) {
if (!(source.domain in entitiesByDomain)) {
entitiesByDomain[source.domain] = [];
}
entitiesByDomain[source.domain].push(entity);
}
this._domainEntities = entitiesByDomain;
}
protected updated(changed: PropertyValues) {
super.updated(changed);
if (
@ -245,6 +264,22 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
const devices = this._getDevices(configEntries, this.hass.devices);
const entities = this._getEntities(configEntries, this._entities);
let numberOfEntities = entities.length;
if (
this.domain in this._domainEntities &&
numberOfEntities !== this._domainEntities[this.domain].length
) {
if (!numberOfEntities) {
numberOfEntities = this._domainEntities[this.domain].length;
} else {
const entityIds = new Set(entities.map((entity) => entity.entity_id));
for (const entityId of this._domainEntities[this.domain]) {
entityIds.add(entityId);
}
numberOfEntities = entityIds.size;
}
}
const services = !devices.some((device) => device.entry_type !== "service");
@ -320,7 +355,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
</ha-list-item>
</a>`
: ""}
${entities.length > 0
${numberOfEntities > 0
? html`<a
href=${`/config/entities?historyBack=1&domain=${this.domain}`}
>
@ -331,7 +366,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.entities`,
{ count: entities.length }
{ count: numberOfEntities }
)}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
@ -503,9 +538,15 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
</h1>
${normalEntries.length === 0
? html`<div class="card-content no-entries">
${this.hass.localize(
"ui.panel.config.integrations.integration_page.no_entries"
)}
${this.hass.config.components.find(
(comp) => comp.split(".")[0] === this.domain
)
? this.hass.localize(
"ui.panel.config.integrations.integration_page.yaml_entry"
)
: this.hass.localize(
"ui.panel.config.integrations.integration_page.no_entries"
)}
</div>`
: nothing}
<ha-list-new>
@ -683,7 +724,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
const configPanel = this._configPanel(item.domain, this.hass.panels);
return html` <ha-list-item-new
return html`<ha-list-item-new
class=${classMap({
config_entry: true,
"state-not-loaded": item!.state === "not_loaded",
@ -1323,6 +1364,17 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
}
private async _addIntegration() {
if (!this._manifest?.config_flow) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_flow.yaml_only_title"
),
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.yaml_only"
),
});
return;
}
if (this._manifest?.single_config_entry) {
const entries = this._domainConfigEntries(
this.domain,

View File

@ -73,8 +73,10 @@ import "./ha-integration-card";
import type { HaIntegrationCard } from "./ha-integration-card";
import "./ha-integration-overflow-menu";
import { showAddIntegrationDialog } from "./show-add-integration-dialog";
import { fetchEntitySourcesWithCache } from "../../../data/entity_sources";
export interface ConfigEntryExtended extends ConfigEntry {
export interface ConfigEntryExtended extends Omit<ConfigEntry, "entry_id"> {
entry_id?: string;
localized_domain_name?: string;
}
@ -114,6 +116,8 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) {
@state()
private _manifests: Record<string, IntegrationManifest> = {};
@state() private _domainEntities: Record<string, string[]> = {};
private _extraFetchedManifests?: Set<string>;
@state() private _showIgnored = false;
@ -149,13 +153,56 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) {
private _filterConfigEntries = memoizeOne(
(
components: string[],
manifests: Record<string, IntegrationManifest>,
configEntries: ConfigEntryExtended[],
localize: HomeAssistant["localize"],
filter?: string
): [
[string, ConfigEntryExtended[]][],
ConfigEntryExtended[],
ConfigEntryExtended[],
] => {
const entryDomains = new Set(configEntries.map((entry) => entry.domain));
const domains = new Set<string>();
for (const component of components) {
const componentDomain = component.split(".")[0];
if (
!entryDomains.has(componentDomain) &&
manifests[componentDomain] &&
(!manifests[componentDomain].integration_type ||
["device", "hub", "service", "integration"].includes(
manifests[componentDomain].integration_type!
))
) {
domains.add(componentDomain);
}
}
const nonConfigEntry: ConfigEntryExtended[] = [...domains].map(
(domain) => ({
domain,
localized_domain_name: domainToName(localize, domain),
title: domain,
source: "yaml",
state: "loaded",
supports_options: false,
supports_remove_device: false,
supports_unload: false,
supports_reconfigure: false,
pref_disable_new_entities: false,
pref_disable_polling: false,
disabled_by: null,
reason: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
})
);
const allEntries = [...configEntries, ...nonConfigEntry];
let filteredConfigEntries: ConfigEntryExtended[];
const ignored: ConfigEntryExtended[] = [];
const disabled: ConfigEntryExtended[] = [];
@ -167,12 +214,12 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) {
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2,
};
const fuse = new Fuse(configEntries, options);
const fuse = new Fuse(allEntries, options);
filteredConfigEntries = fuse
.search(filter)
.map((result) => result.item);
} else {
filteredConfigEntries = configEntries;
filteredConfigEntries = allEntries;
}
for (const entry of filteredConfigEntries) {
@ -232,6 +279,7 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) {
protected firstUpdated(changed: PropertyValues) {
super.firstUpdated(changed);
this._fetchManifests();
this._fetchEntitySources();
if (this.route.path === "/add") {
this._handleAdd();
}
@ -276,7 +324,13 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) {
></hass-loading-screen>`;
}
const [integrations, ignoredConfigEntries, disabledConfigEntries] =
this._filterConfigEntries(this.configEntries, this._filter);
this._filterConfigEntries(
this.hass.config.components,
this._manifests,
this.configEntries,
this.hass.localize,
this._filter
);
const configEntriesInProgress = this._filterConfigEntriesInProgress(
this.configEntriesInProgress,
this._filter
@ -463,6 +517,7 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) {
.items=${items}
.manifest=${this._manifests[domain]}
.entityRegistryEntries=${this._entityRegistryEntries}
.domainEntities=${this._domainEntities[domain] || []}
.supportsDiagnostics=${this._diagnosticHandlers
? this._diagnosticHandlers[domain]
: false}
@ -552,6 +607,21 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) {
await scanUSBDevices(this.hass);
}
private async _fetchEntitySources() {
const entitySources = await fetchEntitySourcesWithCache(this.hass);
const entitiesByDomain = {};
for (const [entity, source] of Object.entries(entitySources)) {
if (!(source.domain in entitiesByDomain)) {
entitiesByDomain[source.domain] = [];
}
entitiesByDomain[source.domain].push(entity);
}
this._domainEntities = entitiesByDomain;
}
private async _fetchManifests(integrations?: string[]) {
const fetched = await fetchIntegrationManifests(this.hass, integrations);
// Make a copy so we can keep track of previously loaded manifests

View File

@ -1,5 +1,5 @@
import "@lrnwebcomponents/simple-tooltip/simple-tooltip";
import { mdiCloud, mdiPackageVariant } from "@mdi/js";
import { mdiCloud, mdiCodeBraces, mdiPackageVariant } from "@mdi/js";
import {
CSSResultGroup,
LitElement,
@ -46,6 +46,8 @@ export class HaIntegrationCard extends LitElement {
@property({ attribute: false }) public logInfo?: IntegrationLogInfo;
@property({ attribute: false }) public domainEntities: string[] = [];
protected render(): TemplateResult {
const entryState = this._getState(this.items);
@ -100,9 +102,13 @@ export class HaIntegrationCard extends LitElement {
private _renderSingleEntry(): TemplateResult {
const devices = this._getDevices(this.items, this.hass.devices);
const entities = devices.length
? []
: this._getEntities(this.items, this.entityRegistryEntries);
const entitiesCount = devices.length
? 0
: this._getEntityCount(
this.items,
this.entityRegistryEntries,
this.domainEntities
);
const services = !devices.some((device) => device.entry_type !== "service");
@ -123,25 +129,32 @@ export class HaIntegrationCard extends LitElement {
)}
</ha-button>
</a>`
: entities.length > 0
: entitiesCount > 0
? html`<a
href=${`/config/entities?historyBack=1&domain=${this.domain}`}
>
<ha-button>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.entities`,
{ count: entities.length }
{ count: entitiesCount }
)}
</ha-button>
</a>`
: html`<a href=${`/config/integrations/integration/${this.domain}`}>
<ha-button>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.entries`,
{ count: this.items.length }
)}
</ha-button>
</a>`}
: this.items.find((itm) => itm.source !== "yaml")
? html`<a
href=${`/config/integrations/integration/${this.domain}`}
>
<ha-button>
${this.hass.localize(
`ui.panel.config.integrations.config_entry.entries`,
{
count: this.items.filter((itm) => itm.source !== "yaml")
.length,
}
)}
</ha-button>
</a>`
: html`<div class="spacer"></div>`}
<div class="icons">
${this.manifest && !this.manifest.is_built_in
? html`<span class="icon custom">
@ -169,6 +182,19 @@ export class HaIntegrationCard extends LitElement {
>
</div>`
: nothing}
${!this.manifest?.config_flow
? html`<div class="icon yaml">
<ha-svg-icon .path=${mdiCodeBraces}></ha-svg-icon>
<simple-tooltip
animation-delay="0"
.position=${computeRTL(this.hass) ? "right" : "left"}
offset="4"
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.no_config_flow"
)}</simple-tooltip
>
</div>`
: nothing}
</div>
</div>
`;
@ -190,19 +216,42 @@ export class HaIntegrationCard extends LitElement {
}
);
private _getEntities = memoizeOne(
private _getEntityCount = memoizeOne(
(
configEntry: ConfigEntry[],
entityRegistryEntries: EntityRegistryEntry[]
): EntityRegistryEntry[] => {
entityRegistryEntries: EntityRegistryEntry[],
domainEntities: string[]
): number => {
if (!entityRegistryEntries) {
return [];
return domainEntities.length;
}
const entryIds = configEntry.map((entry) => entry.entry_id);
return entityRegistryEntries.filter(
const entryIds = configEntry
.map((entry) => entry.entry_id)
.filter(Boolean);
if (!entryIds.length) {
return domainEntities.length;
}
const entityRegEntities = entityRegistryEntries.filter(
(entity) =>
entity.config_entry_id && entryIds.includes(entity.config_entry_id)
);
if (entityRegEntities.length === domainEntities.length) {
return domainEntities.length;
}
const entityIds = new Set<string>(
entityRegEntities.map((reg) => reg.entity_id)
);
for (const entity of domainEntities) {
entityIds.add(entity);
}
return entityIds.size;
}
);
@ -308,6 +357,9 @@ export class HaIntegrationCard extends LitElement {
.icon.custom {
background: var(--warning-color);
}
.icon.yaml {
background: var(--label-badge-grey);
}
.icon ha-svg-icon {
width: 16px;
height: 16px;
@ -316,6 +368,9 @@ export class HaIntegrationCard extends LitElement {
simple-tooltip {
white-space: nowrap;
}
.spacer {
height: 36px;
}
`,
];
}

View File

@ -4270,6 +4270,7 @@
"entries_system": "[%key:ui::panel::config::integrations::integration_page::entries%]",
"entries_entity": "[%key:ui::panel::config::integrations::integration_page::entries%]",
"no_entries": "No entries",
"yaml_entry": "This integration was not setup via the UI, you have either set it up in YAML or it is a dependency set up by another integration. If you want to configure it, you will need to do so in your configuration.yaml file.",
"attention_entries": "Needs attention",
"add_entry": "Add entry",
"add_device": "Add device",
@ -4340,6 +4341,7 @@
"custom_integration": "Custom integration",
"depends_on_cloud": "Depends on the cloud",
"yaml_only": "Needs manual configuration",
"no_config_flow": "This integration was not set up from the UI",
"disabled_polling": "Automatic polling for updated data disabled",
"debug_logging_enabled": "Debug logging enabled",
"state": {