Create helpers from automation editor (#19287)

* Create helpers from automation editor

* support multiple createDomains

* localization

* fix lint

* Move multi domain to entity picker

* Update dialog-helper-detail.ts

* Update ha-config-helpers.ts

* optimize a little

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
karwosts 2024-04-12 11:38:39 -07:00 committed by GitHub
parent 0118a5bf4c
commit 178feb7330
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 268 additions and 169 deletions

View File

@ -18,6 +18,12 @@ import "../ha-icon-button";
import "../ha-svg-icon"; import "../ha-svg-icon";
import "./state-badge"; import "./state-badge";
import { caseInsensitiveStringCompare } from "../../common/string/compare"; import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail";
import { domainToName } from "../../data/integration";
import {
isHelperDomain,
HelperDomain,
} from "../../panels/config/helpers/const";
interface HassEntityWithCachedName extends HassEntity, ScorableTextItem { interface HassEntityWithCachedName extends HassEntity, ScorableTextItem {
friendly_name: string; friendly_name: string;
@ -25,6 +31,8 @@ interface HassEntityWithCachedName extends HassEntity, ScorableTextItem {
export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean; export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean;
const CREATE_ID = "___create-new-entity___";
@customElement("ha-entity-picker") @customElement("ha-entity-picker")
export class HaEntityPicker extends LitElement { export class HaEntityPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@ -44,6 +52,8 @@ export class HaEntityPicker extends LitElement {
@property() public helper?: string; @property() public helper?: string;
@property({ type: Array }) public createDomains?: string[];
/** /**
* Show entities from specific domains. * Show entities from specific domains.
* @type {Array} * @type {Array}
@ -130,7 +140,11 @@ export class HaEntityPicker extends LitElement {
></state-badge>` ></state-badge>`
: ""} : ""}
<span>${item.friendly_name}</span> <span>${item.friendly_name}</span>
<span slot="secondary">${item.entity_id}</span> <span slot="secondary"
>${item.entity_id.startsWith(CREATE_ID)
? this.hass.localize("ui.components.entity.entity-picker.new_entity")
: item.entity_id}</span
>
</ha-list-item>`; </ha-list-item>`;
private _getStates = memoizeOne( private _getStates = memoizeOne(
@ -143,7 +157,8 @@ export class HaEntityPicker extends LitElement {
includeDeviceClasses: this["includeDeviceClasses"], includeDeviceClasses: this["includeDeviceClasses"],
includeUnitOfMeasurement: this["includeUnitOfMeasurement"], includeUnitOfMeasurement: this["includeUnitOfMeasurement"],
includeEntities: this["includeEntities"], includeEntities: this["includeEntities"],
excludeEntities: this["excludeEntities"] excludeEntities: this["excludeEntities"],
createDomains: this["createDomains"]
): HassEntityWithCachedName[] => { ): HassEntityWithCachedName[] => {
let states: HassEntityWithCachedName[] = []; let states: HassEntityWithCachedName[] = [];
@ -152,6 +167,34 @@ export class HaEntityPicker extends LitElement {
} }
let entityIds = Object.keys(hass.states); let entityIds = Object.keys(hass.states);
const createItems = createDomains?.length
? createDomains.map((domain) => {
const newFriendlyName = hass.localize(
"ui.components.entity.entity-picker.create_helper",
{
domain: isHelperDomain(domain)
? hass.localize(
`ui.panel.config.helpers.types.${domain as HelperDomain}`
)
: domainToName(hass.localize, domain),
}
);
return {
entity_id: CREATE_ID + domain,
state: "on",
last_changed: "",
last_updated: "",
context: { id: "", user_id: null, parent_id: null },
friendly_name: newFriendlyName,
attributes: {
icon: "mdi:plus",
},
strings: [domain, newFriendlyName],
};
})
: [];
if (!entityIds.length) { if (!entityIds.length) {
return [ return [
{ {
@ -171,6 +214,7 @@ export class HaEntityPicker extends LitElement {
}, },
strings: [], strings: [],
}, },
...createItems,
]; ];
} }
@ -281,9 +325,14 @@ export class HaEntityPicker extends LitElement {
}, },
strings: [], strings: [],
}, },
...createItems,
]; ];
} }
if (createItems?.length) {
states.push(...createItems);
}
return states; return states;
} }
); );
@ -310,13 +359,18 @@ export class HaEntityPicker extends LitElement {
this.includeDeviceClasses, this.includeDeviceClasses,
this.includeUnitOfMeasurement, this.includeUnitOfMeasurement,
this.includeEntities, this.includeEntities,
this.excludeEntities this.excludeEntities,
this.createDomains
); );
if (this._initedStates) { if (this._initedStates) {
this.comboBox.filteredItems = this._states; this.comboBox.filteredItems = this._states;
} }
this._initedStates = true; this._initedStates = true;
} }
if (changedProps.has("createDomains") && this.createDomains?.length) {
this.hass.loadFragmentTranslation("config");
}
} }
protected render(): TemplateResult { protected render(): TemplateResult {
@ -354,6 +408,18 @@ export class HaEntityPicker extends LitElement {
private _valueChanged(ev: ValueChangedEvent<string>) { private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation(); ev.stopPropagation();
const newValue = ev.detail.value; const newValue = ev.detail.value;
if (newValue.startsWith(CREATE_ID)) {
const domain = newValue.substring(CREATE_ID.length);
showHelperDetailDialog(this, {
domain,
dialogClosedCallback: (item) => {
if (item.entityId) this._setValue(item.entityId);
},
});
return;
}
if (newValue !== this._value) { if (newValue !== this._value) {
this._setValue(newValue); this._setValue(newValue);
} }

View File

@ -82,6 +82,7 @@ export class HaTargetSelector extends LitElement {
.deviceFilter=${this._filterDevices} .deviceFilter=${this._filterDevices}
.entityFilter=${this._filterEntities} .entityFilter=${this._filterEntities}
.disabled=${this.disabled} .disabled=${this.disabled}
.createDomains=${this.selector.target?.create_domains}
></ha-target-picker>`; ></ha-target-picker>`;
} }

View File

@ -33,6 +33,7 @@ import {
expandFloorTarget, expandFloorTarget,
expandLabelTarget, expandLabelTarget,
Selector, Selector,
TargetSelector,
} from "../data/selector"; } from "../data/selector";
import { HomeAssistant, ValueChangedEvent } from "../types"; import { HomeAssistant, ValueChangedEvent } from "../types";
import { documentationUrl } from "../util/documentation-url"; import { documentationUrl } from "../util/documentation-url";
@ -43,6 +44,7 @@ import "./ha-service-picker";
import "./ha-settings-row"; import "./ha-settings-row";
import "./ha-yaml-editor"; import "./ha-yaml-editor";
import type { HaYamlEditor } from "./ha-yaml-editor"; import type { HaYamlEditor } from "./ha-yaml-editor";
import { isHelperDomain } from "../panels/config/helpers/const";
const attributeFilter = (values: any[], attribute: any) => { const attributeFilter = (values: any[], attribute: any) => {
if (typeof attribute === "object") { if (typeof attribute === "object") {
@ -363,6 +365,15 @@ export class HaServiceControl extends LitElement {
return false; return false;
} }
private _targetSelector = memoizeOne(
(targetSelector: TargetSelector | null | undefined, domain?: string) => {
const create_domains = isHelperDomain(domain) ? [domain] : undefined;
return targetSelector
? { target: { ...targetSelector, create_domains } }
: { target: { create_domains } };
}
);
protected render() { protected render() {
const serviceData = this._getServiceInfo( const serviceData = this._getServiceInfo(
this._value?.service, this._value?.service,
@ -401,157 +412,152 @@ export class HaServiceControl extends LitElement {
)) || )) ||
serviceData?.description; serviceData?.description;
return html` return html`${this.hidePicker
${this.hidePicker ? nothing
? nothing : html`<ha-service-picker
: html`<ha-service-picker .hass=${this.hass}
.value=${this._value?.service}
.disabled=${this.disabled}
@value-changed=${this._serviceChanged}
></ha-service-picker>`}
${this.hideDescription
? nothing
: html`
<div class="description">
${description ? html`<p>${description}</p>` : ""}
${this._manifest
? html` <a
href=${this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
)
: this._manifest.documentation}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
class="help-icon"
></ha-icon-button>
</a>`
: nothing}
</div>
`}
${serviceData && "target" in serviceData
? html`<ha-settings-row .narrow=${this.narrow}>
${hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>`
: ""}
<span slot="heading"
>${this.hass.localize("ui.components.service-control.target")}</span
>
<span slot="description"
>${this.hass.localize(
"ui.components.service-control.target_description"
)}</span
><ha-selector
.hass=${this.hass} .hass=${this.hass}
.value=${this._value?.service} .selector=${this._targetSelector(
serviceData.target as TargetSelector,
domain
)}
.disabled=${this.disabled} .disabled=${this.disabled}
@value-changed=${this._serviceChanged} @value-changed=${this._targetChanged}
></ha-service-picker>`} .value=${this._value?.target}
${this.hideDescription ></ha-selector
? nothing ></ha-settings-row>`
: html` : entityId
<div class="description"> ? html`<ha-entity-picker
${description ? html`<p>${description}</p>` : ""}
${this._manifest
? html` <a
href=${this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
)
: this._manifest.documentation}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
class="help-icon"
></ha-icon-button>
</a>`
: nothing}
</div>
`}
${serviceData && "target" in serviceData
? html`<ha-settings-row .narrow=${this.narrow}>
${hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>`
: ""}
<span slot="heading"
>${this.hass.localize(
"ui.components.service-control.target"
)}</span
>
<span slot="description"
>${this.hass.localize(
"ui.components.service-control.target_description"
)}</span
><ha-selector
.hass=${this.hass}
.selector=${serviceData.target
? { target: serviceData.target }
: { target: {} }}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this._value?.target}
></ha-selector
></ha-settings-row>`
: entityId
? html`<ha-entity-picker
.hass=${this.hass}
.disabled=${this.disabled}
.value=${this._value?.data?.entity_id}
.label=${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.entity_id.description`
) || entityId.description}
@value-changed=${this._entityPicked}
allow-custom-entity
></ha-entity-picker>`
: ""}
${shouldRenderServiceDataYaml
? html`<ha-yaml-editor
.hass=${this.hass} .hass=${this.hass}
.label=${this.hass.localize("ui.components.service-control.data")} .disabled=${this.disabled}
.name=${"data"} .value=${this._value?.data?.entity_id}
.readOnly=${this.disabled} .label=${this.hass.localize(
.defaultValue=${this._value?.data} `component.${domain}.services.${serviceName}.fields.entity_id.description`
@value-changed=${this._dataChanged} ) || entityId.description}
></ha-yaml-editor>` @value-changed=${this._entityPicked}
: filteredFields?.map((dataField) => { allow-custom-entity
const selector = dataField?.selector ?? { text: undefined }; ></ha-entity-picker>`
const type = Object.keys(selector)[0]; : ""}
const enhancedSelector = [ ${shouldRenderServiceDataYaml
"action", ? html`<ha-yaml-editor
"condition", .hass=${this.hass}
"trigger", .label=${this.hass.localize("ui.components.service-control.data")}
].includes(type) .name=${"data"}
? { .readOnly=${this.disabled}
[type]: { .defaultValue=${this._value?.data}
...selector[type], @value-changed=${this._dataChanged}
path: [dataField.key], ></ha-yaml-editor>`
}, : filteredFields?.map((dataField) => {
} const selector = dataField?.selector ?? { text: undefined };
: selector; const type = Object.keys(selector)[0];
const enhancedSelector = ["action", "condition", "trigger"].includes(
type
)
? {
[type]: {
...selector[type],
path: [dataField.key],
},
}
: selector;
const showOptional = showOptionalToggle(dataField); const showOptional = showOptionalToggle(dataField);
return dataField.selector && return dataField.selector &&
(!dataField.advanced || (!dataField.advanced ||
this.showAdvanced || this.showAdvanced ||
(this._value?.data && (this._value?.data &&
this._value.data[dataField.key] !== undefined)) this._value.data[dataField.key] !== undefined))
? html`<ha-settings-row .narrow=${this.narrow}> ? html`<ha-settings-row .narrow=${this.narrow}>
${!showOptional ${!showOptional
? hasOptional ? hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>` ? html`<div slot="prefix" class="checkbox-spacer"></div>`
: "" : ""
: html`<ha-checkbox : html`<ha-checkbox
.key=${dataField.key} .key=${dataField.key}
.checked=${this._checkedKeys.has(dataField.key) || .checked=${this._checkedKeys.has(dataField.key) ||
(this._value?.data && (this._value?.data &&
this._value.data[dataField.key] !== undefined)} this._value.data[dataField.key] !== undefined)}
.disabled=${this.disabled} .disabled=${this.disabled}
@change=${this._checkboxChanged} @change=${this._checkboxChanged}
slot="prefix" slot="prefix"
></ha-checkbox>`} ></ha-checkbox>`}
<span slot="heading" <span slot="heading"
>${this.hass.localize( >${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.${dataField.key}.name` `component.${domain}.services.${serviceName}.fields.${dataField.key}.name`
) || ) ||
dataField.name || dataField.name ||
dataField.key}</span dataField.key}</span
> >
<span slot="description" <span slot="description"
>${this.hass.localize( >${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.${dataField.key}.description` `component.${domain}.services.${serviceName}.fields.${dataField.key}.description`
) || dataField?.description}</span ) || dataField?.description}</span
> >
<ha-selector <ha-selector
.disabled=${this.disabled || .disabled=${this.disabled ||
(showOptional && (showOptional &&
!this._checkedKeys.has(dataField.key) && !this._checkedKeys.has(dataField.key) &&
(!this._value?.data || (!this._value?.data ||
this._value.data[dataField.key] === undefined))} this._value.data[dataField.key] === undefined))}
.hass=${this.hass} .hass=${this.hass}
.selector=${enhancedSelector} .selector=${enhancedSelector}
.key=${dataField.key} .key=${dataField.key}
@value-changed=${this._serviceDataChanged} @value-changed=${this._serviceDataChanged}
.value=${this._value?.data .value=${this._value?.data
? this._value.data[dataField.key] ? this._value.data[dataField.key]
: undefined} : undefined}
.placeholder=${dataField.default} .placeholder=${dataField.default}
.localizeValue=${this._localizeValueCallback} .localizeValue=${this._localizeValueCallback}
@item-moved=${this._itemMoved} @item-moved=${this._itemMoved}
></ha-selector> ></ha-selector>
</ha-settings-row>` </ha-settings-row>`
: ""; : "";
})} })} `;
`;
} }
private _localizeValueCallback = (key: string) => { private _localizeValueCallback = (key: string) => {

View File

@ -65,6 +65,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property() public helper?: string; @property() public helper?: string;
@property({ type: Array }) public createDomains?: string[];
/** /**
* Show only targets with entities from specific domains. * Show only targets with entities from specific domains.
* @type {Array} * @type {Array}
@ -468,6 +470,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.includeDeviceClasses=${this.includeDeviceClasses} .includeDeviceClasses=${this.includeDeviceClasses}
.includeDomains=${this.includeDomains} .includeDomains=${this.includeDomains}
.excludeEntities=${ensureArray(this.value?.entity_id)} .excludeEntities=${ensureArray(this.value?.entity_id)}
.createDomains=${this.createDomains}
@value-changed=${this._targetPicked} @value-changed=${this._targetPicked}
@click=${this._preventDefault} @click=${this._preventDefault}
allow-custom-entity allow-custom-entity

View File

@ -401,6 +401,7 @@ export interface TargetSelector {
target: { target: {
entity?: EntitySelectorFilter | readonly EntitySelectorFilter[]; entity?: EntitySelectorFilter | readonly EntitySelectorFilter[];
device?: DeviceSelectorFilter | readonly DeviceSelectorFilter[]; device?: DeviceSelectorFilter | readonly DeviceSelectorFilter[];
create_domains?: string[];
} | null; } | null;
} }

View File

@ -32,7 +32,7 @@ import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-c
import { haStyleDialog } from "../../../resources/styles"; import { haStyleDialog } from "../../../resources/styles";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url"; import { brandsUrl } from "../../../util/brands-url";
import { Helper, HelperDomain } from "./const"; import { Helper, HelperDomain, isHelperDomain } from "./const";
import type { ShowDialogHelperDetailParams } from "./show-dialog-helper-detail"; import type { ShowDialogHelperDetailParams } from "./show-dialog-helper-detail";
type HelperCreators = { type HelperCreators = {
@ -96,7 +96,7 @@ export class DialogHelperDetail extends LitElement {
@state() private _opened = false; @state() private _opened = false;
@state() private _domain?: HelperDomain; @state() private _domain?: string;
@state() private _error?: string; @state() private _error?: string;
@ -114,8 +114,12 @@ export class DialogHelperDetail extends LitElement {
this._params = params; this._params = params;
this._domain = params.domain; this._domain = params.domain;
this._item = undefined; this._item = undefined;
if (this._domain && this._domain in HELPERS) {
await HELPERS[this._domain].import();
}
this._opened = true; this._opened = true;
await this.updateComplete; await this.updateComplete;
this.hass.loadFragmentTranslation("config");
Promise.all([ Promise.all([
getConfigFlowHandlers(this.hass, ["helper"]), getConfigFlowHandlers(this.hass, ["helper"]),
// Ensure the titles are loaded before we render the flows. // Ensure the titles are loaded before we render the flows.
@ -141,7 +145,7 @@ export class DialogHelperDetail extends LitElement {
if (this._domain) { if (this._domain) {
content = html` content = html`
<div class="form" @value-changed=${this._valueChanged}> <div class="form" @value-changed=${this._valueChanged}>
${this._error ? html` <div class="error">${this._error}</div> ` : ""} ${this._error ? html`<div class="error">${this._error}</div>` : ""}
${dynamicElement(`ha-${this._domain}-form`, { ${dynamicElement(`ha-${this._domain}-form`, {
hass: this.hass, hass: this.hass,
item: this._item, item: this._item,
@ -155,13 +159,15 @@ export class DialogHelperDetail extends LitElement {
> >
${this.hass!.localize("ui.panel.config.helpers.dialog.create")} ${this.hass!.localize("ui.panel.config.helpers.dialog.create")}
</mwc-button> </mwc-button>
<mwc-button ${this._params?.domain
slot="secondaryAction" ? nothing
@click=${this._goBack} : html`<mwc-button
.disabled=${this._submitting} slot="secondaryAction"
> @click=${this._goBack}
${this.hass!.localize("ui.common.back")} .disabled=${this._submitting}
</mwc-button> >
${this.hass!.localize("ui.common.back")}
</mwc-button>`}
`; `;
} else if (this._loading || this._helperFlows === undefined) { } else if (this._loading || this._helperFlows === undefined) {
content = html`<ha-circular-progress content = html`<ha-circular-progress
@ -253,9 +259,13 @@ export class DialogHelperDetail extends LitElement {
"ui.panel.config.helpers.dialog.create_platform", "ui.panel.config.helpers.dialog.create_platform",
{ {
platform: platform:
this.hass.localize( (isHelperDomain(this._domain) &&
`ui.panel.config.helpers.types.${this._domain}` this.hass.localize(
) || this._domain, `ui.panel.config.helpers.types.${
this._domain as HelperDomain
}`
)) ||
this._domain,
} }
) )
: this.hass.localize("ui.panel.config.helpers.dialog.create_helper") : this.hass.localize("ui.panel.config.helpers.dialog.create_helper")
@ -277,7 +287,16 @@ export class DialogHelperDetail extends LitElement {
this._submitting = true; this._submitting = true;
this._error = ""; this._error = "";
try { try {
await HELPERS[this._domain].create(this.hass, this._item); const createdEntity = await HELPERS[this._domain].create(
this.hass,
this._item
);
if (this._params?.dialogClosedCallback && createdEntity.id) {
this._params.dialogClosedCallback({
flowFinished: true,
entityId: `${this._domain}.${createdEntity.id}`,
});
}
this.closeDialog(); this.closeDialog();
} catch (err: any) { } catch (err: any) {
this._error = err.message || "Unknown error"; this._error = err.message || "Unknown error";

View File

@ -1,13 +1,14 @@
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { DataEntryFlowDialogParams } from "../../../dialogs/config-flow/show-dialog-data-entry-flow";
import { HelperDomain } from "./const";
export const loadHelperDetailDialog = () => import("./dialog-helper-detail"); export const loadHelperDetailDialog = () => import("./dialog-helper-detail");
export interface ShowDialogHelperDetailParams { export interface ShowDialogHelperDetailParams {
domain?: HelperDomain; domain?: string;
// Only used for config entries dialogClosedCallback?: (params: {
dialogClosedCallback?: DataEntryFlowDialogParams["dialogClosedCallback"]; flowFinished: boolean;
entryId?: string;
entityId?: string;
}) => void;
} }
export const showHelperDetailDialog = ( export const showHelperDetailDialog = (

View File

@ -472,7 +472,9 @@
"clear": "Clear", "clear": "Clear",
"no_entities": "You don't have any entities", "no_entities": "You don't have any entities",
"no_match": "No matching entities found", "no_match": "No matching entities found",
"show_entities": "Show entities" "show_entities": "Show entities",
"new_entity": "Create a new entity",
"create_helper": "Create a new {domain, select, \n undefined {} \n other {{domain} }\n } helper."
}, },
"entity-attribute-picker": { "entity-attribute-picker": {
"attribute": "Attribute", "attribute": "Attribute",