diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index 1ae47cb99a87..680910d8b8f5 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -18,6 +18,12 @@ import "../ha-icon-button"; import "../ha-svg-icon"; import "./state-badge"; 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 { friendly_name: string; @@ -25,6 +31,8 @@ interface HassEntityWithCachedName extends HassEntity, ScorableTextItem { export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean; +const CREATE_ID = "___create-new-entity___"; + @customElement("ha-entity-picker") export class HaEntityPicker extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -44,6 +52,8 @@ export class HaEntityPicker extends LitElement { @property() public helper?: string; + @property({ type: Array }) public createDomains?: string[]; + /** * Show entities from specific domains. * @type {Array} @@ -130,7 +140,11 @@ export class HaEntityPicker extends LitElement { >` : ""} ${item.friendly_name} - ${item.entity_id} + ${item.entity_id.startsWith(CREATE_ID) + ? this.hass.localize("ui.components.entity.entity-picker.new_entity") + : item.entity_id} `; private _getStates = memoizeOne( @@ -143,7 +157,8 @@ export class HaEntityPicker extends LitElement { includeDeviceClasses: this["includeDeviceClasses"], includeUnitOfMeasurement: this["includeUnitOfMeasurement"], includeEntities: this["includeEntities"], - excludeEntities: this["excludeEntities"] + excludeEntities: this["excludeEntities"], + createDomains: this["createDomains"] ): HassEntityWithCachedName[] => { let states: HassEntityWithCachedName[] = []; @@ -152,6 +167,34 @@ export class HaEntityPicker extends LitElement { } 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) { return [ { @@ -171,6 +214,7 @@ export class HaEntityPicker extends LitElement { }, strings: [], }, + ...createItems, ]; } @@ -281,9 +325,14 @@ export class HaEntityPicker extends LitElement { }, strings: [], }, + ...createItems, ]; } + if (createItems?.length) { + states.push(...createItems); + } + return states; } ); @@ -310,13 +359,18 @@ export class HaEntityPicker extends LitElement { this.includeDeviceClasses, this.includeUnitOfMeasurement, this.includeEntities, - this.excludeEntities + this.excludeEntities, + this.createDomains ); if (this._initedStates) { this.comboBox.filteredItems = this._states; } this._initedStates = true; } + + if (changedProps.has("createDomains") && this.createDomains?.length) { + this.hass.loadFragmentTranslation("config"); + } } protected render(): TemplateResult { @@ -354,6 +408,18 @@ export class HaEntityPicker extends LitElement { private _valueChanged(ev: ValueChangedEvent) { ev.stopPropagation(); 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) { this._setValue(newValue); } diff --git a/src/components/ha-selector/ha-selector-target.ts b/src/components/ha-selector/ha-selector-target.ts index fafc97b62beb..a37557c1bd91 100644 --- a/src/components/ha-selector/ha-selector-target.ts +++ b/src/components/ha-selector/ha-selector-target.ts @@ -82,6 +82,7 @@ export class HaTargetSelector extends LitElement { .deviceFilter=${this._filterDevices} .entityFilter=${this._filterEntities} .disabled=${this.disabled} + .createDomains=${this.selector.target?.create_domains} >`; } diff --git a/src/components/ha-service-control.ts b/src/components/ha-service-control.ts index 8310aee8f4c5..83da022e296a 100644 --- a/src/components/ha-service-control.ts +++ b/src/components/ha-service-control.ts @@ -33,6 +33,7 @@ import { expandFloorTarget, expandLabelTarget, Selector, + TargetSelector, } from "../data/selector"; import { HomeAssistant, ValueChangedEvent } from "../types"; import { documentationUrl } from "../util/documentation-url"; @@ -43,6 +44,7 @@ import "./ha-service-picker"; import "./ha-settings-row"; import "./ha-yaml-editor"; import type { HaYamlEditor } from "./ha-yaml-editor"; +import { isHelperDomain } from "../panels/config/helpers/const"; const attributeFilter = (values: any[], attribute: any) => { if (typeof attribute === "object") { @@ -363,6 +365,15 @@ export class HaServiceControl extends LitElement { 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() { const serviceData = this._getServiceInfo( this._value?.service, @@ -401,157 +412,152 @@ export class HaServiceControl extends LitElement { )) || serviceData?.description; - return html` - ${this.hidePicker - ? nothing - : html``} + ${this.hideDescription + ? nothing + : html` +
+ ${description ? html`

${description}

` : ""} + ${this._manifest + ? html` + + ` + : nothing} +
+ `} + ${serviceData && "target" in serviceData + ? html` + ${hasOptional + ? html`
` + : ""} + ${this.hass.localize("ui.components.service-control.target")} + ${this.hass.localize( + "ui.components.service-control.target_description" + )}`} - ${this.hideDescription - ? nothing - : html` -
- ${description ? html`

${description}

` : ""} - ${this._manifest - ? html` - - ` - : nothing} -
- `} - ${serviceData && "target" in serviceData - ? html` - ${hasOptional - ? html`
` - : ""} - ${this.hass.localize( - "ui.components.service-control.target" - )} - ${this.hass.localize( - "ui.components.service-control.target_description" - )}
` - : entityId - ? html`` - : ""} - ${shouldRenderServiceDataYaml - ? html`
` + : entityId + ? html`` - : filteredFields?.map((dataField) => { - const selector = dataField?.selector ?? { text: undefined }; - const type = Object.keys(selector)[0]; - const enhancedSelector = [ - "action", - "condition", - "trigger", - ].includes(type) - ? { - [type]: { - ...selector[type], - path: [dataField.key], - }, - } - : selector; - - const showOptional = showOptionalToggle(dataField); - - return dataField.selector && - (!dataField.advanced || - this.showAdvanced || - (this._value?.data && - this._value.data[dataField.key] !== undefined)) - ? html` - ${!showOptional - ? hasOptional - ? html`
` - : "" - : html``} - ${this.hass.localize( - `component.${domain}.services.${serviceName}.fields.${dataField.key}.name` - ) || - dataField.name || - dataField.key} - ${this.hass.localize( - `component.${domain}.services.${serviceName}.fields.${dataField.key}.description` - ) || dataField?.description} - -
` - : ""; - })} - `; + .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 + >
` + : ""} + ${shouldRenderServiceDataYaml + ? html`` + : filteredFields?.map((dataField) => { + const selector = dataField?.selector ?? { text: undefined }; + const type = Object.keys(selector)[0]; + const enhancedSelector = ["action", "condition", "trigger"].includes( + type + ) + ? { + [type]: { + ...selector[type], + path: [dataField.key], + }, + } + : selector; + + const showOptional = showOptionalToggle(dataField); + + return dataField.selector && + (!dataField.advanced || + this.showAdvanced || + (this._value?.data && + this._value.data[dataField.key] !== undefined)) + ? html` + ${!showOptional + ? hasOptional + ? html`
` + : "" + : html``} + ${this.hass.localize( + `component.${domain}.services.${serviceName}.fields.${dataField.key}.name` + ) || + dataField.name || + dataField.key} + ${this.hass.localize( + `component.${domain}.services.${serviceName}.fields.${dataField.key}.description` + ) || dataField?.description} + +
` + : ""; + })} `; } private _localizeValueCallback = (key: string) => { diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index 21d32d3ea3bc..c20a465e442e 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -65,6 +65,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { @property() public helper?: string; + @property({ type: Array }) public createDomains?: string[]; + /** * Show only targets with entities from specific domains. * @type {Array} @@ -468,6 +470,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { .includeDeviceClasses=${this.includeDeviceClasses} .includeDomains=${this.includeDomains} .excludeEntities=${ensureArray(this.value?.entity_id)} + .createDomains=${this.createDomains} @value-changed=${this._targetPicked} @click=${this._preventDefault} allow-custom-entity diff --git a/src/data/selector.ts b/src/data/selector.ts index 3abb7ae6fc71..557650635a12 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -401,6 +401,7 @@ export interface TargetSelector { target: { entity?: EntitySelectorFilter | readonly EntitySelectorFilter[]; device?: DeviceSelectorFilter | readonly DeviceSelectorFilter[]; + create_domains?: string[]; } | null; } diff --git a/src/panels/config/helpers/dialog-helper-detail.ts b/src/panels/config/helpers/dialog-helper-detail.ts index 5e9a6e07d63c..54488dccec60 100644 --- a/src/panels/config/helpers/dialog-helper-detail.ts +++ b/src/panels/config/helpers/dialog-helper-detail.ts @@ -32,7 +32,7 @@ import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-c import { haStyleDialog } from "../../../resources/styles"; import { HomeAssistant } from "../../../types"; 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"; type HelperCreators = { @@ -96,7 +96,7 @@ export class DialogHelperDetail extends LitElement { @state() private _opened = false; - @state() private _domain?: HelperDomain; + @state() private _domain?: string; @state() private _error?: string; @@ -114,8 +114,12 @@ export class DialogHelperDetail extends LitElement { this._params = params; this._domain = params.domain; this._item = undefined; + if (this._domain && this._domain in HELPERS) { + await HELPERS[this._domain].import(); + } this._opened = true; await this.updateComplete; + this.hass.loadFragmentTranslation("config"); Promise.all([ getConfigFlowHandlers(this.hass, ["helper"]), // Ensure the titles are loaded before we render the flows. @@ -141,7 +145,7 @@ export class DialogHelperDetail extends LitElement { if (this._domain) { content = html`
- ${this._error ? html`
${this._error}
` : ""} + ${this._error ? html`
${this._error}
` : ""} ${dynamicElement(`ha-${this._domain}-form`, { hass: this.hass, 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.common.back")} - + ${this._params?.domain + ? nothing + : html` + ${this.hass!.localize("ui.common.back")} + `} `; } else if (this._loading || this._helperFlows === undefined) { content = html` import("./dialog-helper-detail"); export interface ShowDialogHelperDetailParams { - domain?: HelperDomain; - // Only used for config entries - dialogClosedCallback?: DataEntryFlowDialogParams["dialogClosedCallback"]; + domain?: string; + dialogClosedCallback?: (params: { + flowFinished: boolean; + entryId?: string; + entityId?: string; + }) => void; } export const showHelperDetailDialog = ( diff --git a/src/translations/en.json b/src/translations/en.json index 21e30b9e0b89..d4f0440efd05 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -472,7 +472,9 @@ "clear": "Clear", "no_entities": "You don't have any entities", "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": { "attribute": "Attribute",