Integrations v2 (#13887)

* WIP: Integrations v2

* update

* manifests

* update wording

* show yaml only

* Show spinner

* Update

* Use virtulizer

* Update

* change interval if 5 min stats

* remove yaml

* fix application credentials

* Add zwave and zigbee device support

* make back button bigger

* margin
This commit is contained in:
Bram Kragten 2022-09-28 17:39:40 +02:00 committed by GitHub
parent dddb922593
commit 8e4bebb694
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1162 additions and 660 deletions

View File

@ -1,11 +1,11 @@
import { html } from "lit";
import { getConfigEntries } from "../../data/config_entries";
import { showConfigFlowDialog } from "../../dialogs/config-flow/show-dialog-config-flow";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import { showZWaveJSAddNodeDialog } from "../../panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-add-node";
import type { HomeAssistant } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import { isComponentLoaded } from "../config/is_component_loaded";
import { fireEvent } from "../dom/fire_event";
import { navigate } from "../navigate";
export const protocolIntegrationPicked = async (
@ -39,8 +39,8 @@ export const protocolIntegrationPicked = async (
"ui.panel.config.integrations.config_flow.proceed"
),
confirm: () => {
fireEvent(element, "handler-picked", {
handler: "zwave_js",
showConfigFlowDialog(element, {
startFlowHandler: "zwave_js",
});
},
});
@ -75,8 +75,8 @@ export const protocolIntegrationPicked = async (
"ui.panel.config.integrations.config_flow.proceed"
),
confirm: () => {
fireEvent(element, "handler-picked", {
handler: "zha",
showConfigFlowDialog(element, {
startFlowHandler: "zha",
});
},
});

View File

@ -91,7 +91,7 @@ export class HaDialog extends DialogBase {
.header_button {
position: absolute;
right: 16px;
top: 10px;
top: 14px;
text-decoration: none;
color: inherit;
}

37
src/data/integrations.ts Normal file
View File

@ -0,0 +1,37 @@
import { HomeAssistant } from "../types";
export type IotStandards = "z-wave" | "zigbee" | "homekit" | "matter";
export interface Integration {
name?: string;
config_flow?: boolean;
integrations?: Integrations;
iot_standards?: IotStandards[];
is_built_in?: boolean;
iot_class?: string;
}
export interface Integrations {
[domain: string]: Integration;
}
export interface IntegrationDescriptions {
core: {
integration: Integrations;
hardware: Integrations;
helper: Integrations;
translated_name: string[];
};
custom: {
integration: Integrations;
hardware: Integrations;
helper: Integrations;
};
}
export const getIntegrationDescriptions = (
hass: HomeAssistant
): Promise<IntegrationDescriptions> =>
hass.callWS<IntegrationDescriptions>({
type: "integration/descriptions",
});

View File

@ -1,6 +1,13 @@
import { SupportedBrandObj } from "../dialogs/config-flow/step-flow-pick-handler";
import type { HomeAssistant } from "../types";
export interface SupportedBrandObj {
name: string;
slug: string;
is_add?: boolean;
is_helper?: boolean;
supported_flows: string[];
}
export type SupportedBrandHandler = Record<string, string>;
export const getSupportedBrands = (hass: HomeAssistant) =>

View File

@ -18,9 +18,7 @@ import {
AreaRegistryEntry,
subscribeAreaRegistry,
} from "../../data/area_registry";
import { fetchConfigFlowInProgress } from "../../data/config_flow";
import {
DataEntryFlowProgress,
DataEntryFlowStep,
subscribeDataEntryFlowProgressed,
} from "../../data/data_entry_flow";
@ -28,14 +26,12 @@ import {
DeviceRegistryEntry,
subscribeDeviceRegistry,
} from "../../data/device_registry";
import { fetchIntegrationManifest } from "../../data/integration";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import { showAlertDialog } from "../generic/show-dialog-box";
import {
DataEntryFlowDialogParams,
FlowHandlers,
LoadingReason,
} from "./show-dialog-data-entry-flow";
import "./step-flow-abort";
@ -44,8 +40,6 @@ import "./step-flow-external";
import "./step-flow-form";
import "./step-flow-loading";
import "./step-flow-menu";
import "./step-flow-pick-flow";
import "./step-flow-pick-handler";
import "./step-flow-progress";
let instance = 0;
@ -86,12 +80,8 @@ class DataEntryFlowDialog extends LitElement {
@state() private _areas?: AreaRegistryEntry[];
@state() private _handlers?: FlowHandlers;
@state() private _handler?: string;
@state() private _flowsInProgress?: DataEntryFlowProgress[];
private _unsubAreas?: UnsubscribeFunc;
private _unsubDevices?: UnsubscribeFunc;
@ -102,15 +92,39 @@ class DataEntryFlowDialog extends LitElement {
this._params = params;
this._instance = instance++;
if (params.startFlowHandler) {
this._checkFlowsInProgress(params.startFlowHandler);
return;
}
const curInstance = this._instance;
let step: DataEntryFlowStep;
if (params.continueFlowId) {
if (params.startFlowHandler) {
this._loading = "loading_flow";
this._handler = params.startFlowHandler;
try {
step = await this._params!.flowConfig.createFlow(
this.hass,
params.startFlowHandler
);
} catch (err: any) {
this.closeDialog();
let message = err.message || err.body || "Unknown error";
if (typeof message !== "string") {
message = JSON.stringify(message);
}
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_flow.error"
),
text: `${this.hass.localize(
"ui.panel.config.integrations.config_flow.could_not_load"
)}: ${message}`,
});
return;
}
// Happens if second showDialog called
if (curInstance !== this._instance) {
return;
}
} else if (params.continueFlowId) {
this._loading = "loading_flow";
const curInstance = this._instance;
let step: DataEntryFlowStep;
try {
step = await params.flowConfig.fetchFlow(
this.hass,
@ -132,32 +146,17 @@ class DataEntryFlowDialog extends LitElement {
});
return;
}
// Happens if second showDialog called
if (curInstance !== this._instance) {
return;
}
this._processStep(step);
this._loading = undefined;
} else {
return;
}
// Create a new config flow. Show picker
if (!params.flowConfig.getFlowHandlers) {
throw new Error("No getFlowHandlers defined in flow config");
// Happens if second showDialog called
if (curInstance !== this._instance) {
return;
}
this._step = null;
// We only load the handlers once
if (this._handlers === undefined) {
this._loading = "loading_handlers";
try {
this._handlers = await params.flowConfig.getFlowHandlers(this.hass);
} finally {
this._loading = undefined;
}
}
this._processStep(step);
this._loading = undefined;
}
public closeDialog() {
@ -185,7 +184,6 @@ class DataEntryFlowDialog extends LitElement {
this._step = undefined;
this._params = undefined;
this._devices = undefined;
this._flowsInProgress = undefined;
this._handler = undefined;
if (this._unsubAreas) {
this._unsubAreas();
@ -218,15 +216,12 @@ class DataEntryFlowDialog extends LitElement {
hideActions
>
<div>
${this._loading ||
(this._step === null &&
this._handlers === undefined &&
this._handler === undefined)
${this._loading || this._step === null
? html`
<step-flow-loading
.flowConfig=${this._params.flowConfig}
.hass=${this.hass}
.loadingReason=${this._loading || "loading_handlers"}
.loadingReason=${this._loading}
.handler=${this._handler}
.step=${this._step}
></step-flow-loading>
@ -273,24 +268,7 @@ class DataEntryFlowDialog extends LitElement {
dialogAction="close"
></ha-icon-button>
</div>
${this._step === null
? this._handler
? html`<step-flow-pick-flow
.flowConfig=${this._params.flowConfig}
.hass=${this.hass}
.handler=${this._handler}
.flowsInProgress=${this._flowsInProgress}
></step-flow-pick-flow>`
: // Show handler picker
html`
<step-flow-pick-handler
.hass=${this.hass}
.handlers=${this._handlers}
.initialFilter=${this._params.searchQuery}
@handler-picked=${this._handlerPicked}
></step-flow-pick-handler>
`
: this._step.type === "form"
${this._step.type === "form"
? html`
<step-flow-form
.flowConfig=${this._params.flowConfig}
@ -400,64 +378,6 @@ class DataEntryFlowDialog extends LitElement {
});
}
private async _checkFlowsInProgress(handler: string) {
this._loading = "loading_handlers";
this._handler = handler;
const flowsInProgress = (
await fetchConfigFlowInProgress(this.hass.connection)
).filter((flow) => flow.handler === handler);
if (!flowsInProgress.length) {
// No flows in progress, create a new flow
this._loading = "loading_flow";
let step: DataEntryFlowStep;
try {
step = await this._params!.flowConfig.createFlow(this.hass, handler);
} catch (err: any) {
this.closeDialog();
const message =
err?.status_code === 404
? this.hass.localize(
"ui.panel.config.integrations.config_flow.no_config_flow"
)
: `${this.hass.localize(
"ui.panel.config.integrations.config_flow.could_not_load"
)}: ${err?.body?.message || err?.message}`;
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_flow.error"
),
text: message,
});
return;
} finally {
this._handler = undefined;
}
this._processStep(step);
if (this._params!.manifest === undefined) {
try {
this._params!.manifest = await fetchIntegrationManifest(
this.hass,
this._params?.domain || step.handler
);
} catch (_) {
// No manifest
this._params!.manifest = null;
}
}
} else {
this._step = null;
this._flowsInProgress = flowsInProgress;
}
this._loading = undefined;
}
private _handlerPicked(ev) {
this._checkFlowsInProgress(ev.detail.handler);
}
private async _processStep(
step: DataEntryFlowStep | undefined | Promise<DataEntryFlowStep>
): Promise<void> {

View File

@ -3,11 +3,9 @@ import {
createConfigFlow,
deleteConfigFlow,
fetchConfigFlow,
getConfigFlowHandlers,
handleConfigFlowStep,
} from "../../data/config_flow";
import { domainToName } from "../../data/integration";
import { getSupportedBrands } from "../../data/supported_brands";
import {
DataEntryFlowDialogParams,
loadDataEntryFlowDialog,
@ -22,16 +20,6 @@ export const showConfigFlowDialog = (
): void =>
showFlowDialog(element, dialogParams, {
loadDevicesAndAreas: true,
getFlowHandlers: async (hass) => {
const [integrations, helpers, supportedBrands] = await Promise.all([
getConfigFlowHandlers(hass, "integration"),
getConfigFlowHandlers(hass, "helper"),
getSupportedBrands(hass),
hass.loadBackendTranslation("title", undefined, true),
]);
return { integrations, helpers, supportedBrands };
},
createFlow: async (hass, handler) => {
const [step] = await Promise.all([
createConfigFlow(hass, handler),

View File

@ -22,8 +22,6 @@ export interface FlowHandlers {
export interface FlowConfig {
loadDevicesAndAreas: boolean;
getFlowHandlers?: (hass: HomeAssistant) => Promise<FlowHandlers>;
createFlow(hass: HomeAssistant, handler: string): Promise<DataEntryFlowStep>;
fetchFlow(hass: HomeAssistant, flowId: string): Promise<DataEntryFlowStep>;

View File

@ -12,10 +12,10 @@ import { DataEntryFlowStepAbort } from "../../data/data_entry_flow";
import { HomeAssistant } from "../../types";
import { showAddApplicationCredentialDialog } from "../../panels/config/application_credentials/show-dialog-add-application-credential";
import { configFlowContentStyles } from "./styles";
import { showConfirmationDialog } from "../generic/show-dialog-box";
import { domainToName } from "../../data/integration";
import { DataEntryFlowDialogParams } from "./show-dialog-data-entry-flow";
import { showConfigFlowDialog } from "./show-dialog-config-flow";
import { domainToName } from "../../data/integration";
import { showConfirmationDialog } from "../generic/show-dialog-box";
@customElement("step-flow-abort")
class StepFlowAbort extends LitElement {
@ -56,11 +56,16 @@ class StepFlowAbort extends LitElement {
private async _handleMissingCreds() {
const confirm = await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_flow.missing_credentials_title"
),
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.missing_credentials",
{
integration: domainToName(this.hass.localize, this.domain),
}
),
confirmText: this.hass.localize("ui.common.yes"),
dismissText: this.hass.localize("ui.common.no"),
});
this._flowDone();
if (!confirm) {

View File

@ -1,130 +0,0 @@
import "@polymer/paper-item";
import "@polymer/paper-item/paper-icon-item";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-icon-next";
import { localizeConfigFlowTitle } from "../../data/config_flow";
import { DataEntryFlowProgress } from "../../data/data_entry_flow";
import { domainToName } from "../../data/integration";
import { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
@customElement("step-flow-pick-flow")
class StepFlowPickFlow extends LitElement {
public flowConfig!: FlowConfig;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
public flowsInProgress!: DataEntryFlowProgress[];
@property() public handler!: string;
protected render(): TemplateResult {
return html`
<h2>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.pick_flow_step.title"
)}
</h2>
<div>
${this.flowsInProgress.map(
(flow) => html` <paper-icon-item
@click=${this._flowInProgressPicked}
.flow=${flow}
>
<img
slot="item-icon"
loading="lazy"
src=${brandsUrl({
domain: flow.handler,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
referrerpolicy="no-referrer"
/>
<paper-item-body>
${localizeConfigFlowTitle(this.hass.localize, flow)}
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-icon-item>`
)}
<paper-item @click=${this._startNewFlowPicked} .handler=${this.handler}>
<paper-item-body>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.pick_flow_step.new_flow",
"integration",
domainToName(this.hass.localize, this.handler)
)}
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
</div>
`;
}
private _startNewFlowPicked(ev) {
this._startFlow(ev.currentTarget.handler);
}
private _startFlow(handler: string) {
fireEvent(this, "flow-update", {
stepPromise: this.flowConfig.createFlow(this.hass, handler),
});
}
private _flowInProgressPicked(ev) {
const flow: DataEntryFlowProgress = ev.currentTarget.flow;
fireEvent(this, "flow-update", {
stepPromise: this.flowConfig.fetchFlow(this.hass, flow.flow_id),
});
}
static get styles(): CSSResultGroup {
return [
configFlowContentStyles,
css`
img {
width: 40px;
height: 40px;
}
ha-icon-next {
margin-right: 8px;
}
div {
overflow: auto;
max-height: 600px;
margin: 16px 0;
}
h2 {
padding-inline-end: 66px;
direction: var(--direction);
}
@media all and (max-height: 900px) {
div {
max-height: calc(100vh - 134px);
}
}
paper-icon-item,
paper-item {
cursor: pointer;
margin-bottom: 4px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"step-flow-pick-flow": StepFlowPickFlow;
}
}

View File

@ -1,372 +0,0 @@
import "@material/mwc-list/mwc-list";
import "@material/mwc-list/mwc-list-item";
import Fuse from "fuse.js";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import { protocolIntegrationPicked } from "../../common/integrations/protocolIntegrationPicked";
import { navigate } from "../../common/navigate";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { LocalizeFunc } from "../../common/translations/localize";
import "../../components/ha-icon-next";
import "../../components/search-input";
import { domainToName } from "../../data/integration";
import { haStyleScrollbar } from "../../resources/styles";
import { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import { documentationUrl } from "../../util/documentation-url";
import { showConfirmationDialog } from "../generic/show-dialog-box";
import { FlowHandlers } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
interface HandlerObj {
name: string;
slug: string;
is_add?: boolean;
is_helper?: boolean;
}
export interface SupportedBrandObj extends HandlerObj {
supported_flows: string[];
}
declare global {
// for fire event
interface HASSDomEvents {
"handler-picked": {
handler: string;
};
}
}
@customElement("step-flow-pick-handler")
class StepFlowPickHandler extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public handlers!: FlowHandlers;
@property() public initialFilter?: string;
@state() private _filter?: string;
private _width?: number;
private _height?: number;
private _filterHandlers = memoizeOne(
(
h: FlowHandlers,
filter?: string,
_localize?: LocalizeFunc
): [(HandlerObj | SupportedBrandObj)[], HandlerObj[]] => {
const integrations: (HandlerObj | SupportedBrandObj)[] =
h.integrations.map((handler) => ({
name: domainToName(this.hass.localize, handler),
slug: handler,
}));
for (const [domain, domainBrands] of Object.entries(h.supportedBrands)) {
for (const [slug, name] of Object.entries(domainBrands)) {
integrations.push({
slug,
name,
supported_flows: [domain],
});
}
}
if (filter) {
const options: Fuse.IFuseOptions<HandlerObj> = {
keys: ["name", "slug"],
isCaseSensitive: false,
minMatchCharLength: 2,
threshold: 0.2,
};
const helpers: HandlerObj[] = h.helpers.map((handler) => ({
name: domainToName(this.hass.localize, handler),
slug: handler,
is_helper: true,
}));
return [
new Fuse(integrations, options)
.search(filter)
.map((result) => result.item),
new Fuse(helpers, options)
.search(filter)
.map((result) => result.item),
];
}
return [
integrations.sort((a, b) =>
caseInsensitiveStringCompare(a.name, b.name)
),
[],
];
}
);
protected render(): TemplateResult {
const [integrations, helpers] = this._getHandlers();
const addDeviceRows: HandlerObj[] = ["zha", "zwave_js"]
.filter((domain) => isComponentLoaded(this.hass, domain))
.map((domain) => ({
name: this.hass.localize(
`ui.panel.config.integrations.add_${domain}_device`
),
slug: domain,
is_add: true,
}))
.sort((a, b) => caseInsensitiveStringCompare(a.name, b.name));
return html`
<h2>${this.hass.localize("ui.panel.config.integrations.new")}</h2>
<search-input
.hass=${this.hass}
autofocus
.filter=${this._filter}
@value-changed=${this._filterChanged}
.label=${this.hass.localize("ui.panel.config.integrations.search")}
@keypress=${this._maybeSubmit}
></search-input>
<mwc-list
style=${styleMap({
width: `${this._width}px`,
height: `${this._height}px`,
})}
class="ha-scrollbar"
>
${addDeviceRows.length
? html`
${addDeviceRows.map((handler) => this._renderRow(handler))}
<li divider padded class="divider" role="separator"></li>
`
: ""}
${integrations.length
? integrations.map((handler) => this._renderRow(handler))
: html`
<p>
${this.hass.localize(
"ui.panel.config.integrations.note_about_integrations"
)}<br />
${this.hass.localize(
"ui.panel.config.integrations.note_about_website_reference"
)}<a
href=${documentationUrl(
this.hass,
`/integrations/${
this._filter ? `#search/${this._filter}` : ""
}`
)}
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.integrations.home_assistant_website"
)}</a
>.
</p>
`}
${helpers.length
? html`
<li divider padded class="divider" role="separator"></li>
${helpers.map((handler) => this._renderRow(handler))}
`
: ""}
</mwc-list>
`;
}
private _renderRow(handler: HandlerObj) {
return html`
<mwc-list-item
graphic="medium"
.hasMeta=${!handler.is_add}
.handler=${handler}
@click=${this._handlerPicked}
>
<img
slot="graphic"
loading="lazy"
src=${brandsUrl({
domain: handler.slug,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
referrerpolicy="no-referrer"
/>
<span>${handler.name} ${handler.is_helper ? " (helper)" : ""}</span>
${handler.is_add ? "" : html`<ha-icon-next slot="meta"></ha-icon-next>`}
</mwc-list-item>
`;
}
public willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (this._filter === undefined && this.initialFilter !== undefined) {
this._filter = this.initialFilter;
}
if (this.initialFilter !== undefined && this._filter === "") {
this.initialFilter = undefined;
this._filter = "";
this._width = undefined;
this._height = undefined;
} else if (
this.hasUpdated &&
changedProps.has("_filter") &&
(!this._width || !this._height)
) {
// Store the width and height so that when we search, box doesn't jump
const boundingRect =
this.shadowRoot!.querySelector("mwc-list")!.getBoundingClientRect();
this._width = boundingRect.width;
this._height = boundingRect.height;
}
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
setTimeout(
() => this.shadowRoot!.querySelector("search-input")!.focus(),
0
);
}
private _getHandlers() {
return this._filterHandlers(
this.handlers,
this._filter,
this.hass.localize
);
}
private async _filterChanged(e) {
this._filter = e.detail.value;
}
private async _handlerPicked(ev) {
const handler: HandlerObj | SupportedBrandObj = ev.currentTarget.handler;
if (handler.is_add) {
this._handleAddPicked(handler.slug);
return;
}
if (handler.is_helper) {
navigate(`/config/helpers/add?domain=${handler.slug}`);
// This closes dialog.
fireEvent(this, "flow-update");
return;
}
if ("supported_flows" in handler) {
const slug = handler.supported_flows[0];
showConfirmationDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.supported_brand_flow",
{
supported_brand: handler.name,
flow_domain_name: domainToName(this.hass.localize, slug),
}
),
confirm: () => {
if (["zha", "zwave_js"].includes(slug)) {
this._handleAddPicked(slug);
return;
}
fireEvent(this, "handler-picked", {
handler: slug,
});
},
});
return;
}
fireEvent(this, "handler-picked", {
handler: handler.slug,
});
}
private async _handleAddPicked(slug: string): Promise<void> {
await protocolIntegrationPicked(this, this.hass, slug);
// This closes dialog.
fireEvent(this, "flow-update");
}
private _maybeSubmit(ev: KeyboardEvent) {
if (ev.key !== "Enter") {
return;
}
const handlers = this._getHandlers();
if (handlers.length > 0) {
fireEvent(this, "handler-picked", {
handler: handlers[0][0].slug,
});
}
}
static get styles(): CSSResultGroup {
return [
configFlowContentStyles,
haStyleScrollbar,
css`
img {
width: 40px;
height: 40px;
}
search-input {
display: block;
margin: 16px 16px 0;
}
ha-icon-next {
margin-right: 8px;
}
mwc-list {
overflow: auto;
max-height: 600px;
}
.divider {
border-bottom-color: var(--divider-color);
}
h2 {
padding-inline-end: 66px;
direction: var(--direction);
}
@media all and (max-height: 900px) {
mwc-list {
max-height: calc(100vh - 134px);
}
}
p {
text-align: center;
padding: 16px;
margin: 0;
}
p > a {
color: var(--primary-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"step-flow-pick-handler": StepFlowPickHandler;
}
}

View File

@ -0,0 +1,656 @@
import "@material/mwc-button";
import "@material/mwc-list/mwc-list";
import Fuse from "fuse.js";
import { css, html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import { protocolIntegrationPicked } from "../../../common/integrations/protocolIntegrationPicked";
import { navigate } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { LocalizeFunc } from "../../../common/translations/localize";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-icon-button-prev";
import "../../../components/search-input";
import { fetchConfigFlowInProgress } from "../../../data/config_flow";
import { DataEntryFlowProgress } from "../../../data/data_entry_flow";
import {
domainToName,
fetchIntegrationManifest,
} from "../../../data/integration";
import {
getIntegrationDescriptions,
Integrations,
} from "../../../data/integrations";
import {
getSupportedBrands,
SupportedBrandHandler,
} from "../../../data/supported_brands";
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { haStyleDialog, haStyleScrollbar } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import "./ha-domain-integrations";
import "./ha-integration-list-item";
export interface IntegrationListItem {
name: string;
domain: string;
config_flow?: boolean;
is_helper?: boolean;
integrations?: string[];
iot_standards?: string[];
supported_flows?: string[];
cloud?: boolean;
is_built_in?: boolean;
is_add?: boolean;
}
@customElement("dialog-add-integration")
class AddIntegrationDialog extends LitElement {
public hass!: HomeAssistant;
@state() private _integrations?: Integrations;
@state() private _helpers?: Integrations;
@state() private _supportedBrands?: Record<string, SupportedBrandHandler>;
@state() private _initialFilter?: string;
@state() private _filter?: string;
@state() private _pickedBrand?: string;
@state() private _flowsInProgress?: DataEntryFlowProgress[];
@state() private _open = false;
@state() private _narrow = false;
private _width?: number;
private _height?: number;
public showDialog(params): void {
this._open = true;
this._initialFilter = params.initialFilter;
this._narrow = matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
}
public closeDialog() {
this._open = false;
this._integrations = undefined;
this._helpers = undefined;
this._supportedBrands = undefined;
this._pickedBrand = undefined;
this._flowsInProgress = undefined;
this._filter = undefined;
this._width = undefined;
this._height = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
public willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (this._filter === undefined && this._initialFilter !== undefined) {
this._filter = this._initialFilter;
}
if (this._initialFilter !== undefined && this._filter === "") {
this._initialFilter = undefined;
this._filter = "";
this._width = undefined;
this._height = undefined;
} else if (
this.hasUpdated &&
changedProps.has("_filter") &&
(!this._width || !this._height)
) {
// Store the width and height so that when we search, box doesn't jump
const boundingRect =
this.shadowRoot!.querySelector("mwc-list")?.getBoundingClientRect();
this._width = boundingRect?.width;
this._height = boundingRect?.height;
}
}
public updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("_open") && this._open) {
this._load();
}
}
private _filterIntegrations = memoizeOne(
(
i: Integrations,
h: Integrations,
sb: Record<string, SupportedBrandHandler>,
components: HomeAssistant["config"]["components"],
localize: LocalizeFunc,
filter?: string
): IntegrationListItem[] => {
const addDeviceRows: IntegrationListItem[] = ["zha", "zwave_js"]
.filter((domain) => components.includes(domain))
.map((domain) => ({
name: localize(`ui.panel.config.integrations.add_${domain}_device`),
domain,
config_flow: true,
is_built_in: true,
is_add: true,
}))
.sort((a, b) => caseInsensitiveStringCompare(a.name, b.name));
const integrations: IntegrationListItem[] = Object.entries(i)
.filter(
([_domain, integration]) =>
integration.config_flow ||
integration.iot_standards ||
integration.integrations
)
.map(([domain, integration]) => ({
domain,
name: integration.name || domainToName(localize, domain),
config_flow: integration.config_flow,
iot_standards: integration.iot_standards,
integrations: integration.integrations
? Object.entries(integration.integrations).map(
([dom, val]) => val.name || domainToName(localize, dom)
)
: undefined,
is_built_in: integration.is_built_in !== false,
cloud: integration.iot_class?.startsWith("cloud_"),
}));
for (const [domain, domainBrands] of Object.entries(sb)) {
const integration = i[domain];
if (
!integration.config_flow &&
!integration.iot_standards &&
!integration.integrations
) {
continue;
}
for (const [slug, name] of Object.entries(domainBrands)) {
integrations.push({
domain: slug,
name,
config_flow: integration.config_flow,
supported_flows: [domain],
is_built_in: true,
cloud: integration.iot_class?.startsWith("cloud_"),
});
}
}
if (filter) {
const options: Fuse.IFuseOptions<IntegrationListItem> = {
keys: [
"name",
"domain",
"supported_flows",
"integrations",
"iot_standards",
],
isCaseSensitive: false,
minMatchCharLength: 2,
threshold: 0.2,
};
const helpers = Object.entries(h)
.filter(
([_domain, integration]) =>
integration.config_flow ||
integration.iot_standards ||
integration.integrations
)
.map(([domain, integration]) => ({
domain,
name: integration.name || domainToName(localize, domain),
config_flow: integration.config_flow,
is_helper: true,
is_built_in: integration.is_built_in !== false,
cloud: integration.iot_class?.startsWith("cloud_"),
}));
return [
...new Fuse(integrations, options)
.search(filter)
.map((result) => result.item),
...new Fuse(helpers, options)
.search(filter)
.map((result) => result.item),
];
}
return [
...addDeviceRows,
...integrations.sort((a, b) =>
caseInsensitiveStringCompare(a.name || "", b.name || "")
),
];
}
);
private _getIntegrations() {
return this._filterIntegrations(
this._integrations!,
this._helpers!,
this._supportedBrands!,
this.hass.config.components,
this.hass.localize,
this._filter
);
}
protected render(): TemplateResult {
if (!this._open) {
return html``;
}
const integrations = this._integrations
? this._getIntegrations()
: undefined;
return html`<ha-dialog
open
@closed=${this.closeDialog}
scrimClickAction
escapeKeyAction
hideActions
.heading=${this._pickedBrand
? true
: createCloseHeading(
this.hass,
this.hass.localize("ui.panel.config.integrations.new")
)}
>
${this._pickedBrand
? html`<div slot="heading">
<ha-icon-button-prev
@click=${this._prevClicked}
></ha-icon-button-prev>
<h2 class="mdc-dialog__title">
${this._calculateBrandHeading()}
</h2>
</div>
${this._renderIntegration()}`
: this._renderAll(integrations)}
</ha-dialog>`;
}
private _calculateBrandHeading() {
const brand = this._integrations?.[this._pickedBrand!];
if (
brand?.iot_standards &&
!brand.integrations &&
!this._flowsInProgress?.length
) {
return "What type of device is it?";
}
if (
!brand?.iot_standards &&
!brand?.integrations &&
this._flowsInProgress?.length
) {
return "Want to add these discovered devices?";
}
return "What do you want to add?";
}
private _renderIntegration(): TemplateResult {
return html`<ha-domain-integrations
.hass=${this.hass}
.domain=${this._pickedBrand}
.integration=${this._integrations?.[this._pickedBrand!]}
.flowsInProgress=${this._flowsInProgress}
style=${styleMap({
minWidth: `${this._width}px`,
minHeight: `581px`,
})}
@close-dialog=${this.closeDialog}
></ha-domain-integrations>`;
}
private _renderAll(integrations?: IntegrationListItem[]): TemplateResult {
return html`<search-input
.hass=${this.hass}
autofocus
dialogInitialFocus
.filter=${this._filter}
@value-changed=${this._filterChanged}
.label=${this.hass.localize(
"ui.panel.config.integrations.search_brand"
)}
@keypress=${this._maybeSubmit}
></search-input>
${integrations
? html`<mwc-list>
<lit-virtualizer
scroller
class="ha-scrollbar"
style=${styleMap({
width: `${this._width}px`,
height: this._narrow ? "calc(100vh - 184px)" : "500px",
})}
@click=${this._integrationPicked}
.items=${integrations}
.renderItem=${this._renderRow}
>
</lit-virtualizer>
</mwc-list>`
: html`<ha-circular-progress active></ha-circular-progress>`} `;
}
private _renderRow = (integration: IntegrationListItem) => {
if (!integration) {
return html``;
}
return html`
<ha-integration-list-item .hass=${this.hass} .integration=${integration}>
</ha-integration-list-item>
`;
};
private async _load() {
const [descriptions, supportedBrands] = await Promise.all([
getIntegrationDescriptions(this.hass),
getSupportedBrands(this.hass),
]);
for (const integration in descriptions.custom.integration) {
if (
!Object.prototype.hasOwnProperty.call(
descriptions.custom.integration,
integration
)
) {
continue;
}
descriptions.custom.integration[integration].is_built_in = false;
}
this._integrations = {
...descriptions.core.integration,
...descriptions.custom.integration,
};
for (const integration in descriptions.custom.helper) {
if (
!Object.prototype.hasOwnProperty.call(
descriptions.custom.helper,
integration
)
) {
continue;
}
descriptions.custom.helper[integration].is_built_in = false;
}
this._helpers = {
...descriptions.core.helper,
...descriptions.custom.helper,
};
this._supportedBrands = supportedBrands;
this.hass.loadBackendTranslation(
"title",
descriptions.core.translated_name,
true
);
}
private async _filterChanged(e) {
this._filter = e.detail.value;
}
private _integrationPicked(ev) {
const listItem = ev.target.closest("ha-integration-list-item");
const integration: IntegrationListItem = listItem.integration;
this._handleIntegrationPicked(integration);
}
private async _handleIntegrationPicked(integration: IntegrationListItem) {
if ("supported_flows" in integration) {
const domain = integration.supported_flows![0];
showConfirmationDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.supported_brand_flow",
{
supported_brand: integration.name,
flow_domain_name: domainToName(this.hass.localize, domain),
}
),
confirm: () => {
const supportIntegration = this._integrations?.[domain];
this.closeDialog();
if (["zha", "zwave_js"].includes(domain)) {
protocolIntegrationPicked(this, this.hass, domain);
return;
}
if (supportIntegration) {
this._handleIntegrationPicked({
domain,
name:
supportIntegration.name ||
domainToName(this.hass.localize, domain),
config_flow: supportIntegration.config_flow,
iot_standards: supportIntegration.iot_standards,
integrations: supportIntegration.integrations
? Object.entries(supportIntegration.integrations).map(
([dom, val]) =>
val.name || domainToName(this.hass.localize, dom)
)
: undefined,
});
} else {
showAlertDialog(this, {
text: "Integration not found",
warning: true,
});
}
},
});
return;
}
if (integration.is_add) {
protocolIntegrationPicked(this, this.hass, integration.domain);
this.closeDialog();
return;
}
if (integration.is_helper) {
this.closeDialog();
navigate(`/config/helpers/add?domain=${integration.domain}`);
return;
}
if (integration.integrations) {
this._fetchFlowsInProgress(Object.keys(integration.integrations));
this._pickedBrand = integration.domain;
return;
}
if (
["zha", "zwave_js"].includes(integration.domain) &&
isComponentLoaded(this.hass, integration.domain)
) {
this._pickedBrand = integration.domain;
return;
}
if (integration.iot_standards) {
this._pickedBrand = integration.domain;
return;
}
if (integration.config_flow) {
this._createFlow(integration);
return;
}
const manifest = await fetchIntegrationManifest(
this.hass,
integration.domain
);
this.closeDialog();
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_text",
{
link:
manifest?.is_built_in || manifest?.documentation
? html`<a
href=${manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${manifest.domain}`
)
: manifest.documentation}
target="_blank"
rel="noreferrer noopener"
>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.documentation"
)}
</a>`
: this.hass.localize(
"ui.panel.config.integrations.config_flow.documentation"
),
}
),
});
}
private async _createFlow(integration: IntegrationListItem) {
const flowsInProgress = await this._fetchFlowsInProgress([
integration.domain,
]);
if (flowsInProgress?.length) {
this._pickedBrand = integration.domain;
return;
}
const manifest = await fetchIntegrationManifest(
this.hass,
integration.domain
);
this.closeDialog();
showConfigFlowDialog(this, {
startFlowHandler: integration.domain,
showAdvanced: this.hass.userData?.showAdvanced,
manifest,
});
}
private async _fetchFlowsInProgress(domains: string[]) {
const flowsInProgress = (
await fetchConfigFlowInProgress(this.hass.connection)
).filter((flow) => domains.includes(flow.handler));
if (flowsInProgress.length) {
this._flowsInProgress = flowsInProgress;
}
return flowsInProgress;
}
private _maybeSubmit(ev: KeyboardEvent) {
if (ev.key !== "Enter") {
return;
}
const integrations = this._getIntegrations();
if (integrations.length > 0) {
this._handleIntegrationPicked(integrations[0]);
}
}
private _prevClicked() {
this._pickedBrand = undefined;
this._flowsInProgress = undefined;
}
static styles = [
haStyleScrollbar,
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: 0;
}
search-input {
display: block;
margin: 16px 16px 0;
}
.divider {
border-bottom-color: var(--divider-color);
}
h2 {
padding-inline-end: 66px;
direction: var(--direction);
}
p {
text-align: center;
padding: 16px;
margin: 0;
}
p > a {
color: var(--primary-color);
}
ha-circular-progress {
width: 100%;
display: flex;
justify-content: center;
margin: 24px 0;
}
lit-virtualizer {
contain: size layout !important;
}
ha-integration-list-item {
width: 100%;
}
ha-icon-button-prev {
color: var(--secondary-text-color);
position: absolute;
left: 16px;
top: 14px;
inset-inline-end: initial;
inset-inline-start: 16px;
direction: var(--direction);
}
.mdc-dialog__title {
margin: 0;
margin-bottom: 8px;
margin-left: 48px;
padding: 24px 24px 0 24px;
color: var(--mdc-dialog-heading-ink-color, rgba(0, 0, 0, 0.87));
font-size: var(--mdc-typography-headline6-font-size, 1.25rem);
line-height: var(--mdc-typography-headline6-line-height, 2rem);
font-weight: var(--mdc-typography-headline6-font-weight, 500);
letter-spacing: var(
--mdc-typography-headline6-letter-spacing,
0.0125em
);
text-decoration: var(
--mdc-typography-headline6-text-decoration,
inherit
);
text-transform: var(--mdc-typography-headline6-text-transform, inherit);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"dialog-add-integration": AddIntegrationDialog;
}
}

View File

@ -14,7 +14,6 @@ import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import { protocolIntegrationPicked } from "../../../common/integrations/protocolIntegrationPicked";
import { navigate } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
@ -75,6 +74,7 @@ import "./ha-ignored-config-entry-card";
import "./ha-integration-card";
import type { HaIntegrationCard } from "./ha-integration-card";
import "./ha-integration-overflow-menu";
import { showAddIntegrationDialog } from "./show-add-integration-dialog";
export interface ConfigEntryUpdatedEvent {
entry: ConfigEntry;
@ -312,7 +312,6 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
undefined,
true
);
this._fetchManifests();
if (this.route.path === "/add") {
this._handleAdd(localizePromise);
}
@ -599,7 +598,9 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
// Make a copy so we can keep track of previously loaded manifests
// for discovered flows (which are not part of these results)
const manifests = { ...this._manifests };
for (const manifest of fetched) manifests[manifest.domain] = manifest;
for (const manifest of fetched) {
manifests[manifest.domain] = manifest;
}
this._manifests = manifests;
}
@ -630,15 +631,9 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
}
private _createFlow() {
showConfigFlowDialog(this, {
searchQuery: this._filter,
dialogClosedCallback: () => {
this._handleFlowUpdated();
},
showAdvanced: this.showAdvanced,
showAddIntegrationDialog(this, {
initialFilter: this._filter,
});
// For config entries. Also loading config flow ones for added integration
this.hass.loadBackendTranslation("title", undefined, true);
}
private _handleMenuAction(ev: CustomEvent<ActionDetail>) {
@ -735,9 +730,13 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
protocolIntegrationPicked(this, this.hass, slug);
return;
}
fireEvent(this, "handler-picked", {
handler: slug,
showConfigFlowDialog(this, {
dialogClosedCallback: () => {
this._handleFlowUpdated();
},
startFlowHandler: slug,
manifest: this._manifests[slug],
showAdvanced: this.hass.userData?.showAdvanced,
});
},
});

View File

@ -0,0 +1,225 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { protocolIntegrationPicked } from "../../../common/integrations/protocolIntegrationPicked";
import { localizeConfigFlowTitle } from "../../../data/config_flow";
import { DataEntryFlowProgress } from "../../../data/data_entry_flow";
import {
domainToName,
fetchIntegrationManifest,
} from "../../../data/integration";
import { Integration } from "../../../data/integrations";
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
import { haStyle } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import "./ha-integration-list-item";
const standardToDomain = { zigbee: "zha", "z-wave": "zwave_js" } as const;
@customElement("ha-domain-integrations")
class HaDomainIntegrations extends LitElement {
public hass!: HomeAssistant;
@property() public domain!: string;
@property({ attribute: false }) public integration!: Integration;
@property({ attribute: false })
public flowsInProgress?: DataEntryFlowProgress[];
protected render() {
return html`
${this.flowsInProgress?.length
? html`<h3>We discovered the following:</h3>
${this.flowsInProgress.map(
(flow) => html`<mwc-list-item
graphic="medium"
.flow=${flow}
@click=${this._flowInProgressPicked}
hasMeta
>
<img
slot="graphic"
loading="lazy"
src=${brandsUrl({
domain: flow.handler,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
referrerpolicy="no-referrer"
/>
<span
>${localizeConfigFlowTitle(this.hass.localize, flow)}</span
>
<ha-icon-next slot="meta"></ha-icon-next>
</mwc-list-item>`
)}`
: ""}
${this.integration?.iot_standards
? this.integration.iot_standards.map((standard) => {
const domain: string = standardToDomain[standard] || standard;
return html`<mwc-list-item
graphic="medium"
.domain=${domain}
@click=${this._standardPicked}
hasMeta
>
<img
slot="graphic"
loading="lazy"
src=${brandsUrl({
domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
referrerpolicy="no-referrer"
/>
<span
>${this.hass.localize(
`ui.panel.config.integrations.add_${domain}_device`
)}</span
>
<ha-icon-next slot="meta"></ha-icon-next>
</mwc-list-item>`;
})
: ""}
${this.integration?.integrations
? Object.entries(this.integration.integrations).map(
([dom, val]) => html`<ha-integration-list-item
.hass=${this.hass}
.domain=${dom}
.integration=${{
...val,
domain: dom,
name: val.name || domainToName(this.hass.localize, dom),
is_built_in: val.is_built_in !== false,
cloud: val.iot_class?.startsWith("cloud_"),
}}
@click=${this._integrationPicked}
>
</ha-integration-list-item>`
)
: ""}
${["zha", "zwave_js"].includes(this.domain)
? html`<mwc-list-item
graphic="medium"
.domain=${this.domain}
@click=${this._standardPicked}
hasMeta
>
<img
slot="graphic"
loading="lazy"
src=${brandsUrl({
domain: this.domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
referrerpolicy="no-referrer"
/>
<span
>${this.hass.localize(
`ui.panel.config.integrations.add_${this.domain}_device`
)}</span
>
<ha-icon-next slot="meta"></ha-icon-next>
</mwc-list-item>`
: ""}
${this.integration?.config_flow
? html`${this.flowsInProgress?.length
? html`<mwc-list-item
.domain=${this.domain}
@click=${this._integrationPicked}
hasMeta
>
Setup another instance of
${this.integration.name ||
domainToName(this.hass.localize, this.domain)}
<ha-icon-next slot="meta"></ha-icon-next>
</mwc-list-item>`
: html`<ha-integration-list-item
.hass=${this.hass}
.domain=${this.domain}
.integration=${{
...this.integration,
domain: this.domain,
name:
this.integration.name ||
domainToName(this.hass.localize, this.domain),
is_built_in: this.integration.is_built_in !== false,
cloud: this.integration.iot_class?.startsWith("cloud_"),
}}
@click=${this._integrationPicked}
>
</ha-integration-list-item>`}`
: ""}
`;
}
private async _integrationPicked(ev) {
const domain = ev.currentTarget.domain;
const root = this.getRootNode();
showConfigFlowDialog(
root instanceof ShadowRoot ? (root.host as HTMLElement) : this,
{
startFlowHandler: domain,
showAdvanced: this.hass.userData?.showAdvanced,
manifest: await fetchIntegrationManifest(this.hass, domain),
}
);
fireEvent(this, "close-dialog");
}
private async _flowInProgressPicked(ev) {
const flow: DataEntryFlowProgress = ev.currentTarget.flow;
const root = this.getRootNode();
showConfigFlowDialog(
root instanceof ShadowRoot ? (root.host as HTMLElement) : this,
{
continueFlowId: flow.flow_id,
showAdvanced: this.hass.userData?.showAdvanced,
manifest: await fetchIntegrationManifest(this.hass, flow.handler),
}
);
fireEvent(this, "close-dialog");
}
private _standardPicked(ev) {
const domain = ev.currentTarget.domain;
const root = this.getRootNode();
fireEvent(this, "close-dialog");
protocolIntegrationPicked(
root instanceof ShadowRoot ? (root.host as HTMLElement) : this,
this.hass,
domain
);
}
static styles = [
haStyle,
css`
:host {
display: block;
}
h3 {
margin: 0 24px;
color: var(--primary-text-color);
font-size: 14px;
}
img {
width: 40px;
height: 40px;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-domain-integrations": HaDomainIntegrations;
}
}

View File

@ -0,0 +1,151 @@
import {
GraphicType,
ListItemBase,
} from "@material/mwc-list/mwc-list-item-base";
import { styles } from "@material/mwc-list/mwc-list-item.css";
import { mdiCloudOutline, mdiCodeBraces, mdiPackageVariant } from "@mdi/js";
import { css, CSSResultGroup, html } from "lit";
import { classMap } from "lit/directives/class-map";
import { customElement, property } from "lit/decorators";
import { domainToName } from "../../../data/integration";
import { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { IntegrationListItem } from "./dialog-add-integration";
@customElement("ha-integration-list-item")
export class HaIntegrationListItem extends ListItemBase {
public hass!: HomeAssistant;
@property({ attribute: false }) public integration?: IntegrationListItem;
@property({ type: String, reflect: true }) graphic: GraphicType = "medium";
@property({ type: Boolean }) hasMeta = true;
renderSingleLine() {
if (!this.integration) {
return html``;
}
return html`${this.integration.name ||
domainToName(this.hass.localize, this.integration.domain)}
${this.integration.is_helper ? " (helper)" : ""}`;
}
protected renderGraphic() {
if (!this.integration) {
return html``;
}
const graphicClasses = {
multi: this.multipleGraphics,
};
return html` <span
class="mdc-deprecated-list-item__graphic material-icons ${classMap(
graphicClasses
)}"
>
<img
loading="lazy"
src=${brandsUrl({
domain: this.integration.domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
referrerpolicy="no-referrer"
/>
</span>`;
}
protected renderMeta() {
if (!this.integration) {
return html``;
}
return html`<span class="mdc-deprecated-list-item__meta material-icons">
${!this.integration.config_flow &&
!this.integration.integrations &&
!this.integration.iot_standards
? html`<span
><ha-svg-icon .path=${mdiCodeBraces}></ha-svg-icon
><paper-tooltip animation-delay="0" position="left"
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.yaml_only"
)}</paper-tooltip
></span
>`
: ""}
${this.integration.cloud
? html`<span
><ha-svg-icon .path=${mdiCloudOutline}></ha-svg-icon
><paper-tooltip animation-delay="0" position="left"
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.depends_on_cloud"
)}</paper-tooltip
></span
>`
: ""}
${!this.integration.is_built_in
? html`<span
><ha-svg-icon .path=${mdiPackageVariant}></ha-svg-icon
><paper-tooltip animation-delay="0" position="left"
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.provided_by_custom_integration"
)}</paper-tooltip
></span
>`
: ""}
<ha-icon-next></ha-icon-next>
</span>`;
}
static get styles(): CSSResultGroup {
return [
styles,
css`
:host {
padding-left: var(--mdc-list-side-padding, 20px);
padding-right: var(--mdc-list-side-padding, 20px);
}
:host([graphic="avatar"]:not([twoLine])),
:host([graphic="icon"]:not([twoLine])) {
height: 48px;
}
span.material-icons:first-of-type {
margin-inline-start: 0px !important;
margin-inline-end: var(
--mdc-list-item-graphic-margin,
16px
) !important;
direction: var(--direction);
}
span.material-icons:last-of-type {
margin-inline-start: auto !important;
margin-inline-end: 0px !important;
direction: var(--direction);
}
img {
width: 40px;
height: 40px;
}
.mdc-deprecated-list-item__meta {
width: auto;
}
.mdc-deprecated-list-item__meta > * {
margin-right: 8px;
}
.mdc-deprecated-list-item__meta > *:last-child {
margin-right: 0px;
}
ha-icon-next {
margin-right: 8px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-integration-list-item": HaIntegrationListItem;
}
}

View File

@ -0,0 +1,12 @@
import { fireEvent } from "../../../common/dom/fire_event";
export const showAddIntegrationDialog = (
element: HTMLElement,
dialogParams?: any
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-add-integration",
dialogImport: () => import("./dialog-add-integration"),
dialogParams: dialogParams,
});
};

View File

@ -2835,7 +2835,7 @@
"discovered": "Discovered",
"attention": "Attention required",
"configured": "Configured",
"new": "Set up a new integration",
"new": "Select brand",
"confirm_new": "Do you want to set up {integration}?",
"add_integration": "Add integration",
"no_integrations": "Seems like you don't have any integrations configured yet. Click on the button below to add your first integration!",
@ -2852,6 +2852,7 @@
"rename_dialog": "Edit the name of this config entry",
"rename_input_label": "Entry name",
"search": "Search integrations",
"search_brand": "Search for a brand name",
"add_zwave_js_device": "Add Z-Wave device",
"add_zha_device": "Add Zigbee device",
"disable": {
@ -2922,6 +2923,7 @@
},
"provided_by_custom_integration": "Provided by a custom integration",
"depends_on_cloud": "Depends on the cloud",
"yaml_only": "Can not be setup from the UI",
"disabled_polling": "Automatic polling for updated data disabled",
"state": {
"loaded": "Loaded",
@ -2943,6 +2945,9 @@
"submit": "Submit",
"next": "Next",
"found_following_devices": "We found the following devices",
"yaml_only_title": "This device can not be added from the UI",
"yaml_only_text": "You can add this device by adding it to your `configuration.yaml`. See the {link} for more information.",
"documentation": "documentation",
"no_config_flow": "This integration does not support configuration via the UI. If you followed this link from the Home Assistant website, make sure you run the latest version of Home Assistant.",
"not_all_required_fields": "Not all required fields are filled in.",
"error_saving_area": "Error saving area: {error}",
@ -2963,6 +2968,7 @@
"error": "Error",
"could_not_load": "Config flow could not be loaded",
"not_loaded": "The integration could not be loaded, try to restart Home Assistant.",
"missing_credentials_title": "Add application credentials?",
"missing_credentials": "Setting up {integration} requires configuring application credentials. Do you want to do that now?",
"supported_brand_flow": "Support for {supported_brand} devices is provided by {flow_domain_name}. Do you want to continue?",
"missing_zwave_zigbee": "To add a {integration} device, you first need {supported_hardware_link} and the {integration} integration set up. If you already have the hardware then you can proceed with the setup of {integration}.",