mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-16 09:06:21 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de487d788a |
@@ -1,24 +1,31 @@
|
|||||||
|
import { consume, type ContextType } from "@lit/context";
|
||||||
import { html, LitElement, nothing } from "lit";
|
import { html, LitElement, nothing } from "lit";
|
||||||
import { customElement, property } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import { until } from "lit/directives/until";
|
import { until } from "lit/directives/until";
|
||||||
import { computeDomain } from "../common/entity/compute_domain";
|
import { computeDomain } from "../common/entity/compute_domain";
|
||||||
|
import { configContext, connectionContext } from "../data/context";
|
||||||
import {
|
import {
|
||||||
DEFAULT_SERVICE_ICON,
|
DEFAULT_SERVICE_ICON,
|
||||||
FALLBACK_DOMAIN_ICONS,
|
FALLBACK_DOMAIN_ICONS,
|
||||||
serviceIcon,
|
serviceIcon,
|
||||||
} from "../data/icons";
|
} from "../data/icons";
|
||||||
import type { HomeAssistant } from "../types";
|
|
||||||
import "./ha-icon";
|
import "./ha-icon";
|
||||||
import "./ha-svg-icon";
|
import "./ha-svg-icon";
|
||||||
|
|
||||||
@customElement("ha-service-icon")
|
@customElement("ha-service-icon")
|
||||||
export class HaServiceIcon extends LitElement {
|
export class HaServiceIcon extends LitElement {
|
||||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
|
||||||
|
|
||||||
@property() public service?: string;
|
@property() public service?: string;
|
||||||
|
|
||||||
@property() public icon?: string;
|
@property() public icon?: string;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
@consume({ context: connectionContext, subscribe: true })
|
||||||
|
protected _connection?: ContextType<typeof connectionContext>;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
@consume({ context: configContext, subscribe: true })
|
||||||
|
protected _config?: ContextType<typeof configContext>;
|
||||||
|
|
||||||
protected render() {
|
protected render() {
|
||||||
if (this.icon) {
|
if (this.icon) {
|
||||||
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
|
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
|
||||||
@@ -28,13 +35,13 @@ export class HaServiceIcon extends LitElement {
|
|||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.hass) {
|
if (!this._connection || !this._config) {
|
||||||
return this._renderFallback();
|
return this._renderFallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
const icon = serviceIcon(
|
const icon = serviceIcon(
|
||||||
this.hass.connection,
|
this._connection.connection,
|
||||||
this.hass.config,
|
this._config?.config,
|
||||||
this.service
|
this.service
|
||||||
).then((icn) => {
|
).then((icn) => {
|
||||||
if (icn) {
|
if (icn) {
|
||||||
|
|||||||
@@ -62,11 +62,7 @@ class HaServicePicker extends LitElement {
|
|||||||
index
|
index
|
||||||
) => html`
|
) => html`
|
||||||
<ha-combo-box-item type="button" .borderTop=${index !== 0}>
|
<ha-combo-box-item type="button" .borderTop=${index !== 0}>
|
||||||
<ha-service-icon
|
<ha-service-icon slot="start" .service=${item.id}></ha-service-icon>
|
||||||
slot="start"
|
|
||||||
.hass=${this.hass}
|
|
||||||
.service=${item.id}
|
|
||||||
></ha-service-icon>
|
|
||||||
<span slot="headline">${item.primary}</span>
|
<span slot="headline">${item.primary}</span>
|
||||||
<span slot="supporting-text">${item.secondary}</span>
|
<span slot="supporting-text">${item.secondary}</span>
|
||||||
${item.service_id && this.showServiceId
|
${item.service_id && this.showServiceId
|
||||||
@@ -112,11 +108,7 @@ class HaServicePicker extends LitElement {
|
|||||||
service;
|
service;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ha-service-icon
|
<ha-service-icon slot="start" .service=${serviceId}></ha-service-icon>
|
||||||
slot="start"
|
|
||||||
.hass=${this.hass}
|
|
||||||
.service=${serviceId}
|
|
||||||
></ha-service-icon>
|
|
||||||
<span slot="headline">${serviceName}</span>
|
<span slot="headline">${serviceName}</span>
|
||||||
${this.showServiceId
|
${this.showServiceId
|
||||||
? html`<span slot="supporting-text" class="code"
|
? html`<span slot="supporting-text" class="code"
|
||||||
|
|||||||
@@ -451,7 +451,6 @@ export class HatScriptGraph extends LitElement {
|
|||||||
${node.action
|
${node.action
|
||||||
? html`<ha-service-icon
|
? html`<ha-service-icon
|
||||||
slot="icon"
|
slot="icon"
|
||||||
.hass=${this.hass}
|
|
||||||
.service=${node.action}
|
.service=${node.action}
|
||||||
></ha-service-icon>`
|
></ha-service-icon>`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|||||||
@@ -653,6 +653,7 @@ export interface ActionSidebarConfig extends BaseSidebarConfig {
|
|||||||
disable: () => void;
|
disable: () => void;
|
||||||
continueOnError: () => void;
|
continueOnError: () => void;
|
||||||
duplicate: () => void;
|
duplicate: () => void;
|
||||||
|
convert: () => void;
|
||||||
cut: () => void;
|
cut: () => void;
|
||||||
copy: () => void;
|
copy: () => void;
|
||||||
insertAfter: (value: Action | Action[]) => boolean;
|
insertAfter: (value: Action | Action[]) => boolean;
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||||
|
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||||
|
import { computeObjectId } from "../../../../common/entity/compute_object_id";
|
||||||
|
import {
|
||||||
|
ACTION_BUILDING_BLOCKS,
|
||||||
|
ACTION_COMBINED_BLOCKS,
|
||||||
|
} from "../../../../data/action";
|
||||||
|
import type { Action, ServiceAction } from "../../../../data/script";
|
||||||
|
import type { HomeAssistant } from "../../../../types";
|
||||||
|
import { getAutomationActionType } from "./ha-automation-action-row";
|
||||||
|
|
||||||
|
type FieldSelector = Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
const getSelectorType = (selector: FieldSelector): string | undefined => {
|
||||||
|
if (!selector) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const keys = Object.keys(selector);
|
||||||
|
return keys.length === 1 ? keys[0] : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSelectOptionValues = (
|
||||||
|
selector: FieldSelector
|
||||||
|
): string[] | undefined => {
|
||||||
|
const config = selector?.select as
|
||||||
|
| { options?: readonly (string | { value: string })[] }
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
|
if (!config?.options) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return config.options.map((opt) =>
|
||||||
|
typeof opt === "string" ? opt : opt.value
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A value is compatible with the new field if either:
|
||||||
|
* - the new field has no selector (unknown shape — accept),
|
||||||
|
* - the old field had no selector but the new one does (best effort — accept),
|
||||||
|
* - the selector types match. For `select` selectors, additionally require
|
||||||
|
* that every supplied value is present in the new option list.
|
||||||
|
*/
|
||||||
|
const isFieldValueCompatible = (
|
||||||
|
value: unknown,
|
||||||
|
oldSelector: FieldSelector,
|
||||||
|
newSelector: FieldSelector
|
||||||
|
): boolean => {
|
||||||
|
const newType = getSelectorType(newSelector);
|
||||||
|
if (newType === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const oldType = getSelectorType(oldSelector);
|
||||||
|
if (oldType !== undefined && oldType !== newType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (newType === "select") {
|
||||||
|
const allowed = getSelectOptionValues(newSelector);
|
||||||
|
if (!allowed) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const allowedSet = new Set(allowed);
|
||||||
|
const values = Array.isArray(value) ? value : [value];
|
||||||
|
return values.every((v) => typeof v === "string" && allowedSet.has(v));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterTargetEntitiesByDomain = (
|
||||||
|
target: HassServiceTarget,
|
||||||
|
domain: string | undefined
|
||||||
|
): HassServiceTarget => {
|
||||||
|
if (!domain || target.entity_id === undefined) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
const entityIds = Array.isArray(target.entity_id)
|
||||||
|
? target.entity_id
|
||||||
|
: [target.entity_id];
|
||||||
|
const filtered = entityIds.filter((id) => computeDomain(id) === domain);
|
||||||
|
const { entity_id: _entityId, ...rest } = target;
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
return { ...rest, entity_id: filtered };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BASE_ACTION_FIELDS = [
|
||||||
|
"alias",
|
||||||
|
"note",
|
||||||
|
"enabled",
|
||||||
|
"continue_on_error",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const BUILDING_BLOCK_TYPES = new Set<string>([
|
||||||
|
...ACTION_BUILDING_BLOCKS,
|
||||||
|
...ACTION_COMBINED_BLOCKS,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const isBuildingBlockAction = (action: Action): boolean => {
|
||||||
|
const type = getAutomationActionType(action);
|
||||||
|
return type !== undefined && BUILDING_BLOCK_TYPES.has(type);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode an action as a stable picker key.
|
||||||
|
* - Service actions → `domain.service` (always contains a dot).
|
||||||
|
* - Everything else → the action type identifier (no dot).
|
||||||
|
*/
|
||||||
|
export const getActionKey = (action: Action): string | undefined => {
|
||||||
|
const type = getAutomationActionType(action);
|
||||||
|
if (type === "service") {
|
||||||
|
return (action as ServiceAction).action || undefined;
|
||||||
|
}
|
||||||
|
return type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isServiceKey = (key: string) => key.includes(".");
|
||||||
|
|
||||||
|
/** Build a fresh action with default content for the given picker key. */
|
||||||
|
export const buildActionFromKey = (key: string): Action => {
|
||||||
|
if (isServiceKey(key)) {
|
||||||
|
return {
|
||||||
|
action: key,
|
||||||
|
metadata: {},
|
||||||
|
} as ServiceAction;
|
||||||
|
}
|
||||||
|
const elClass = customElements.get(`ha-automation-action-${key}`) as
|
||||||
|
| (CustomElementConstructor & { defaultConfig?: Action })
|
||||||
|
| undefined;
|
||||||
|
if (elClass?.defaultConfig) {
|
||||||
|
return { ...elClass.defaultConfig };
|
||||||
|
}
|
||||||
|
return { [key]: {} } as Action;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isServiceAction = (action: Action): action is ServiceAction =>
|
||||||
|
"action" in action && !!(action as ServiceAction).action;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge fields from `oldAction` into `newAction` that are still compatible.
|
||||||
|
* Behavior: copy base fields if old has them, and for service → service copy
|
||||||
|
* `target` and any `data` keys that the new service still supports.
|
||||||
|
*/
|
||||||
|
export const convertAction = (
|
||||||
|
oldAction: Action,
|
||||||
|
newAction: Action,
|
||||||
|
services: HomeAssistant["services"]
|
||||||
|
): Action => {
|
||||||
|
const merged: Action = { ...newAction };
|
||||||
|
|
||||||
|
for (const field of BASE_ACTION_FIELDS) {
|
||||||
|
const oldValue = (oldAction as Record<string, unknown>)[field];
|
||||||
|
if (oldValue !== undefined) {
|
||||||
|
(merged as Record<string, unknown>)[field] = oldValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isServiceAction(oldAction) && isServiceAction(merged)) {
|
||||||
|
if (oldAction.target) {
|
||||||
|
const newDomain = merged.action
|
||||||
|
? computeDomain(merged.action)
|
||||||
|
: undefined;
|
||||||
|
merged.target = filterTargetEntitiesByDomain(oldAction.target, newDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldAction.data && merged.action && oldAction.action) {
|
||||||
|
const newDomain = computeDomain(merged.action);
|
||||||
|
const newService = computeObjectId(merged.action);
|
||||||
|
const newFields = services[newDomain]?.[newService]?.fields;
|
||||||
|
const oldDomain = computeDomain(oldAction.action);
|
||||||
|
const oldService = computeObjectId(oldAction.action);
|
||||||
|
const oldFields = services[oldDomain]?.[oldService]?.fields;
|
||||||
|
if (newFields) {
|
||||||
|
const carried: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(oldAction.data)) {
|
||||||
|
const newField = newFields[key];
|
||||||
|
if (
|
||||||
|
newField &&
|
||||||
|
isFieldValueCompatible(
|
||||||
|
value,
|
||||||
|
oldFields?.[key]?.selector,
|
||||||
|
newField.selector
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
carried[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(carried).length > 0) {
|
||||||
|
merged.data = carried;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
};
|
||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||||
|
import { consume, type ContextType } from "@lit/context";
|
||||||
|
import { mdiArrowRight, mdiRoomService } from "@mdi/js";
|
||||||
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
|
import { customElement, state } from "lit/decorators";
|
||||||
|
import memoizeOne from "memoize-one";
|
||||||
|
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||||
|
import "../../../../components/ha-alert";
|
||||||
|
import "../../../../components/ha-button";
|
||||||
|
import "../../../../components/ha-combo-box-item";
|
||||||
|
import "../../../../components/ha-dialog";
|
||||||
|
import "../../../../components/ha-dialog-footer";
|
||||||
|
import "../../../../components/ha-generic-picker";
|
||||||
|
import type { PickerComboBoxItem } from "../../../../components/ha-picker-combo-box";
|
||||||
|
import type { PickerValueRenderer } from "../../../../components/ha-picker-field";
|
||||||
|
import "../../../../components/ha-service-icon";
|
||||||
|
import "../../../../components/ha-svg-icon";
|
||||||
|
import "../../../../components/radio/ha-radio-group";
|
||||||
|
import "../../../../components/radio/ha-radio-option";
|
||||||
|
import {
|
||||||
|
ACTION_BUILDING_BLOCKS,
|
||||||
|
ACTION_COMBINED_BLOCKS,
|
||||||
|
ACTION_ICONS,
|
||||||
|
YAML_ONLY_ACTION_TYPES,
|
||||||
|
} from "../../../../data/action";
|
||||||
|
import {
|
||||||
|
internationalizationContext,
|
||||||
|
servicesContext,
|
||||||
|
} from "../../../../data/context";
|
||||||
|
import { domainToName } from "../../../../data/integration";
|
||||||
|
import { DialogMixin } from "../../../../dialogs/dialog-mixin";
|
||||||
|
import type { HomeAssistant } from "../../../../types";
|
||||||
|
import {
|
||||||
|
buildActionFromKey,
|
||||||
|
convertAction,
|
||||||
|
getActionKey,
|
||||||
|
} from "./convert-action";
|
||||||
|
import type { ConvertActionDialogParams } from "./show-dialog-convert-action";
|
||||||
|
|
||||||
|
interface ConvertItem extends PickerComboBoxItem {
|
||||||
|
isService: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXCLUDED_TYPES = new Set<string>([
|
||||||
|
...ACTION_BUILDING_BLOCKS,
|
||||||
|
...ACTION_COMBINED_BLOCKS,
|
||||||
|
...YAML_ONLY_ACTION_TYPES,
|
||||||
|
// Generic catch-all action types covered by the services list below
|
||||||
|
"service",
|
||||||
|
// Umbrella repeat — not a leaf type
|
||||||
|
"repeat",
|
||||||
|
]);
|
||||||
|
|
||||||
|
@customElement("dialog-convert-action")
|
||||||
|
class DialogConvertAction extends DialogMixin<ConvertActionDialogParams>(
|
||||||
|
LitElement
|
||||||
|
) {
|
||||||
|
@state() private _pickedKey?: string;
|
||||||
|
|
||||||
|
@state() private _mode: "current" | "new" = "current";
|
||||||
|
|
||||||
|
@state()
|
||||||
|
@consume({ context: internationalizationContext, subscribe: true })
|
||||||
|
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
@consume({ context: servicesContext, subscribe: true })
|
||||||
|
private _services!: ContextType<typeof servicesContext>;
|
||||||
|
|
||||||
|
public connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
if (this.params) {
|
||||||
|
this._pickedKey = getActionKey(this.params.currentAction);
|
||||||
|
this._mode = "current";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render() {
|
||||||
|
if (!this.params) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentKey = getActionKey(this.params.currentAction);
|
||||||
|
const canCommit =
|
||||||
|
this._pickedKey !== undefined && this._pickedKey !== currentKey;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<ha-dialog
|
||||||
|
open
|
||||||
|
header-title=${this._i18n.localize(
|
||||||
|
"ui.panel.config.automation.editor.actions.convert_dialog.title"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div class="content">
|
||||||
|
${currentKey
|
||||||
|
? html`
|
||||||
|
<div class="preview">
|
||||||
|
${this._renderActionChip(currentKey)}
|
||||||
|
<ha-svg-icon
|
||||||
|
class="arrow"
|
||||||
|
.path=${mdiArrowRight}
|
||||||
|
></ha-svg-icon>
|
||||||
|
${this._pickedKey && this._pickedKey !== currentKey
|
||||||
|
? this._renderActionChip(this._pickedKey)
|
||||||
|
: "?"}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
<ha-generic-picker
|
||||||
|
required
|
||||||
|
.value=${this._pickedKey}
|
||||||
|
.getItems=${this._getItems}
|
||||||
|
.rowRenderer=${this._rowRenderer}
|
||||||
|
.valueRenderer=${this._valueRenderer(
|
||||||
|
this._i18n.localize,
|
||||||
|
this._services
|
||||||
|
)}
|
||||||
|
.notFoundLabel=${this._i18n.localize(
|
||||||
|
"ui.panel.config.automation.editor.actions.convert_dialog.no_matches"
|
||||||
|
)}
|
||||||
|
@value-changed=${this._pickedKeyChanged}
|
||||||
|
></ha-generic-picker>
|
||||||
|
<ha-alert alert-type="warning">
|
||||||
|
${this._i18n.localize(
|
||||||
|
"ui.panel.config.automation.editor.actions.convert_dialog.warning"
|
||||||
|
)}
|
||||||
|
</ha-alert>
|
||||||
|
<ha-radio-group
|
||||||
|
name="mode"
|
||||||
|
.value=${this._mode}
|
||||||
|
@change=${this._modeChanged}
|
||||||
|
>
|
||||||
|
<ha-radio-option value="current">
|
||||||
|
${this._i18n.localize(
|
||||||
|
"ui.panel.config.automation.editor.actions.convert_dialog.mode_current"
|
||||||
|
)}
|
||||||
|
</ha-radio-option>
|
||||||
|
<ha-radio-option value="new">
|
||||||
|
${this._i18n.localize(
|
||||||
|
"ui.panel.config.automation.editor.actions.convert_dialog.mode_new"
|
||||||
|
)}
|
||||||
|
</ha-radio-option>
|
||||||
|
</ha-radio-group>
|
||||||
|
</div>
|
||||||
|
<ha-dialog-footer slot="footer">
|
||||||
|
<ha-button
|
||||||
|
slot="secondaryAction"
|
||||||
|
appearance="plain"
|
||||||
|
@click=${this.closeDialog}
|
||||||
|
>
|
||||||
|
${this._i18n.localize("ui.common.cancel")}
|
||||||
|
</ha-button>
|
||||||
|
<ha-button
|
||||||
|
slot="primaryAction"
|
||||||
|
.disabled=${!canCommit}
|
||||||
|
@click=${this._convert}
|
||||||
|
>
|
||||||
|
${this._i18n.localize(
|
||||||
|
"ui.panel.config.automation.editor.actions.convert_dialog.convert"
|
||||||
|
)}
|
||||||
|
</ha-button>
|
||||||
|
</ha-dialog-footer>
|
||||||
|
</ha-dialog>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _items = memoizeOne(
|
||||||
|
(
|
||||||
|
localize: LocalizeFunc,
|
||||||
|
services: HomeAssistant["services"]
|
||||||
|
): ConvertItem[] => {
|
||||||
|
const items: ConvertItem[] = [];
|
||||||
|
|
||||||
|
for (const type of Object.keys(ACTION_ICONS)) {
|
||||||
|
if (EXCLUDED_TYPES.has(type)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const label =
|
||||||
|
localize(
|
||||||
|
`ui.panel.config.automation.editor.actions.type.${type}.label` as any
|
||||||
|
) || type;
|
||||||
|
items.push({
|
||||||
|
id: type,
|
||||||
|
primary: label,
|
||||||
|
icon_path: ACTION_ICONS[type as keyof typeof ACTION_ICONS],
|
||||||
|
isService: false,
|
||||||
|
sorting_label: `0_${label}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (services) {
|
||||||
|
for (const domain of Object.keys(services)) {
|
||||||
|
const domainName = domainToName(localize, domain);
|
||||||
|
for (const service of Object.keys(services[domain])) {
|
||||||
|
const serviceId = `${domain}.${service}`;
|
||||||
|
const def = services[domain][service];
|
||||||
|
const serviceName =
|
||||||
|
localize(
|
||||||
|
`component.${domain}.services.${service}.name` as any,
|
||||||
|
def.description_placeholders
|
||||||
|
) ||
|
||||||
|
def.name ||
|
||||||
|
service;
|
||||||
|
const description =
|
||||||
|
localize(
|
||||||
|
`component.${domain}.services.${service}.description` as any,
|
||||||
|
def.description_placeholders
|
||||||
|
) ||
|
||||||
|
def.description ||
|
||||||
|
"";
|
||||||
|
items.push({
|
||||||
|
id: serviceId,
|
||||||
|
primary: `${domainName}: ${serviceName}`,
|
||||||
|
secondary: description,
|
||||||
|
isService: true,
|
||||||
|
search_labels: {
|
||||||
|
serviceId,
|
||||||
|
domainName,
|
||||||
|
serviceName,
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
sorting_label: `1_${domainName}_${serviceName}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
private _getItems = () => this._items(this._i18n.localize, this._services);
|
||||||
|
|
||||||
|
private _rowRenderer: RenderItemFunction<PickerComboBoxItem> = (
|
||||||
|
item,
|
||||||
|
index
|
||||||
|
) => {
|
||||||
|
const convertItem = item as ConvertItem;
|
||||||
|
return html`
|
||||||
|
<ha-combo-box-item type="button" .borderTop=${index !== 0}>
|
||||||
|
${convertItem.isService
|
||||||
|
? html`<ha-service-icon
|
||||||
|
slot="start"
|
||||||
|
.service=${item.id}
|
||||||
|
></ha-service-icon>`
|
||||||
|
: html`<ha-svg-icon
|
||||||
|
slot="start"
|
||||||
|
.path=${item.icon_path}
|
||||||
|
></ha-svg-icon>`}
|
||||||
|
<span slot="headline">${item.primary}</span>
|
||||||
|
${item.secondary
|
||||||
|
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||||
|
: nothing}
|
||||||
|
</ha-combo-box-item>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
private _valueRenderer = memoizeOne(
|
||||||
|
(
|
||||||
|
localize: LocalizeFunc,
|
||||||
|
services: HomeAssistant["services"]
|
||||||
|
): PickerValueRenderer =>
|
||||||
|
(value) => {
|
||||||
|
if (value.includes(".")) {
|
||||||
|
const [domain, service] = value.split(".");
|
||||||
|
const def = services?.[domain]?.[service];
|
||||||
|
const domainName = domainToName(localize, domain);
|
||||||
|
const serviceName =
|
||||||
|
localize(
|
||||||
|
`component.${domain}.services.${service}.name` as any,
|
||||||
|
def?.description_placeholders
|
||||||
|
) ||
|
||||||
|
def?.name ||
|
||||||
|
service;
|
||||||
|
return html`
|
||||||
|
<ha-service-icon slot="start" .service=${value}></ha-service-icon>
|
||||||
|
<span slot="headline">${domainName}: ${serviceName}</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
const iconPath = ACTION_ICONS[value as keyof typeof ACTION_ICONS];
|
||||||
|
const label =
|
||||||
|
localize(
|
||||||
|
`ui.panel.config.automation.editor.actions.type.${value}.label` as any
|
||||||
|
) || value;
|
||||||
|
return html`
|
||||||
|
${iconPath
|
||||||
|
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
|
||||||
|
: html`<ha-svg-icon
|
||||||
|
slot="start"
|
||||||
|
.path=${mdiRoomService}
|
||||||
|
></ha-svg-icon>`}
|
||||||
|
<span slot="headline">${label}</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
private _renderActionChip(key: string) {
|
||||||
|
const localize = this._i18n.localize;
|
||||||
|
if (key.includes(".")) {
|
||||||
|
const [domain, service] = key.split(".");
|
||||||
|
const def = this._services?.[domain]?.[service];
|
||||||
|
const domainName = domainToName(localize, domain);
|
||||||
|
const serviceName =
|
||||||
|
localize(
|
||||||
|
`component.${domain}.services.${service}.name` as any,
|
||||||
|
def?.description_placeholders
|
||||||
|
) ||
|
||||||
|
def?.name ||
|
||||||
|
service;
|
||||||
|
return html`
|
||||||
|
<div class="chip">
|
||||||
|
<ha-service-icon .service=${key}></ha-service-icon>
|
||||||
|
<span>${domainName}: ${serviceName}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
const iconPath = ACTION_ICONS[key as keyof typeof ACTION_ICONS];
|
||||||
|
const label =
|
||||||
|
localize(
|
||||||
|
`ui.panel.config.automation.editor.actions.type.${key}.label` as any
|
||||||
|
) || key;
|
||||||
|
return html`
|
||||||
|
<div class="chip">
|
||||||
|
<ha-svg-icon .path=${iconPath || mdiRoomService}></ha-svg-icon>
|
||||||
|
<span>${label}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _pickedKeyChanged = (ev: CustomEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
this._pickedKey = ev.detail?.value || undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
private _modeChanged = (ev: Event) => {
|
||||||
|
const value = (ev.target as HTMLInputElement).value;
|
||||||
|
if (value === "current" || value === "new") {
|
||||||
|
this._mode = value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private _convert = () => {
|
||||||
|
if (!this.params || !this._pickedKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newAction = buildActionFromKey(this._pickedKey);
|
||||||
|
const merged = convertAction(
|
||||||
|
this.params.currentAction,
|
||||||
|
newAction,
|
||||||
|
this._services
|
||||||
|
);
|
||||||
|
if (this._mode === "new") {
|
||||||
|
this.params.duplicateConvert(merged);
|
||||||
|
} else {
|
||||||
|
this.params.convert(merged);
|
||||||
|
}
|
||||||
|
this.closeDialog();
|
||||||
|
};
|
||||||
|
|
||||||
|
static styles = css`
|
||||||
|
ha-dialog {
|
||||||
|
--mdc-dialog-min-width: min(560px, 100vw);
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--ha-space-3);
|
||||||
|
}
|
||||||
|
.preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--ha-space-2);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.preview .chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--ha-space-2);
|
||||||
|
padding: var(--ha-space-1) var(--ha-space-3);
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: var(--secondary-background-color);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.preview .chip span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.preview .arrow {
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"dialog-convert-action": DialogConvertAction;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
mdiPlusCircleMultipleOutline,
|
mdiPlusCircleMultipleOutline,
|
||||||
mdiRenameBox,
|
mdiRenameBox,
|
||||||
mdiStopCircleOutline,
|
mdiStopCircleOutline,
|
||||||
|
mdiSwapHorizontal,
|
||||||
} from "@mdi/js";
|
} from "@mdi/js";
|
||||||
import deepClone from "deep-clone-simple";
|
import deepClone from "deep-clone-simple";
|
||||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||||
@@ -90,8 +91,10 @@ import { showEditorToast } from "../editor-toast";
|
|||||||
import "../ha-automation-editor-warning";
|
import "../ha-automation-editor-warning";
|
||||||
import { overflowStyles, rowStyles } from "../styles";
|
import { overflowStyles, rowStyles } from "../styles";
|
||||||
import "../target/ha-automation-row-targets";
|
import "../target/ha-automation-row-targets";
|
||||||
|
import { isBuildingBlockAction } from "./convert-action";
|
||||||
import "./ha-automation-action-editor";
|
import "./ha-automation-action-editor";
|
||||||
import type HaAutomationActionEditor from "./ha-automation-action-editor";
|
import type HaAutomationActionEditor from "./ha-automation-action-editor";
|
||||||
|
import { showConvertActionDialog } from "./show-dialog-convert-action";
|
||||||
import "./types/ha-automation-action-choose";
|
import "./types/ha-automation-action-choose";
|
||||||
import "./types/ha-automation-action-condition";
|
import "./types/ha-automation-action-condition";
|
||||||
import "./types/ha-automation-action-delay";
|
import "./types/ha-automation-action-delay";
|
||||||
@@ -308,7 +311,6 @@ export default class HaAutomationActionRow extends LitElement {
|
|||||||
<ha-service-icon
|
<ha-service-icon
|
||||||
slot="leading-icon"
|
slot="leading-icon"
|
||||||
class="action-icon"
|
class="action-icon"
|
||||||
.hass=${this.hass}
|
|
||||||
.service=${this.action.action}
|
.service=${this.action.action}
|
||||||
></ha-service-icon>
|
></ha-service-icon>
|
||||||
`
|
`
|
||||||
@@ -520,6 +522,16 @@ export default class HaAutomationActionRow extends LitElement {
|
|||||||
></ha-dropdown-item>
|
></ha-dropdown-item>
|
||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
${!isBuildingBlockAction(this.action)
|
||||||
|
? html`<ha-dropdown-item value="convert" .disabled=${this.disabled}>
|
||||||
|
<ha-svg-icon slot="icon" .path=${mdiSwapHorizontal}></ha-svg-icon>
|
||||||
|
${this._renderOverflowLabel(
|
||||||
|
this.hass.localize(
|
||||||
|
"ui.panel.config.automation.editor.actions.convert"
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</ha-dropdown-item>`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
<ha-dropdown-item
|
<ha-dropdown-item
|
||||||
value="toggle_yaml_mode"
|
value="toggle_yaml_mode"
|
||||||
@@ -977,6 +989,24 @@ export default class HaAutomationActionRow extends LitElement {
|
|||||||
fireEvent(this, "duplicate");
|
fireEvent(this, "duplicate");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private _convertAction = () => {
|
||||||
|
showConvertActionDialog(this, {
|
||||||
|
currentAction: this.action,
|
||||||
|
convert: (newAction) => {
|
||||||
|
fireEvent(this, "value-changed", { value: newAction });
|
||||||
|
|
||||||
|
if (this._selected && this.optionsInSidebar) {
|
||||||
|
this.openSidebar(newAction);
|
||||||
|
} else if (this._yamlMode) {
|
||||||
|
this._actionEditor?.yamlEditor?.setValue(newAction);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
duplicateConvert: (newAction) => {
|
||||||
|
this._insertAfter(newAction);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
private _insertAfter = (value: Action | Action[]) => {
|
private _insertAfter = (value: Action | Action[]) => {
|
||||||
if (ensureArray(value).some((val) => !isAction(val))) {
|
if (ensureArray(value).some((val) => !isAction(val))) {
|
||||||
return false;
|
return false;
|
||||||
@@ -1102,6 +1132,7 @@ export default class HaAutomationActionRow extends LitElement {
|
|||||||
paste: this._pasteAction,
|
paste: this._pasteAction,
|
||||||
pasteAvailable: this._pasteAvailable,
|
pasteAvailable: this._pasteAvailable,
|
||||||
duplicate: this._duplicateAction,
|
duplicate: this._duplicateAction,
|
||||||
|
convert: this._convertAction,
|
||||||
insertAfter: this._insertAfter,
|
insertAfter: this._insertAfter,
|
||||||
run: this._runAction,
|
run: this._runAction,
|
||||||
config: {
|
config: {
|
||||||
@@ -1206,6 +1237,9 @@ export default class HaAutomationActionRow extends LitElement {
|
|||||||
case "move_down":
|
case "move_down":
|
||||||
this._moveDown();
|
this._moveDown();
|
||||||
break;
|
break;
|
||||||
|
case "convert":
|
||||||
|
this._convertAction();
|
||||||
|
break;
|
||||||
case "toggle_yaml_mode":
|
case "toggle_yaml_mode":
|
||||||
this._toggleYamlMode(ev.target as HTMLElement);
|
this._toggleYamlMode(ev.target as HTMLElement);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||||
|
import type { Action } from "../../../../data/script";
|
||||||
|
|
||||||
|
export interface ConvertActionDialogParams {
|
||||||
|
currentAction: Action;
|
||||||
|
convert: (newAction: Action) => void;
|
||||||
|
duplicateConvert: (newAction: Action) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDialog = () => import("./dialog-convert-action");
|
||||||
|
|
||||||
|
export const showConvertActionDialog = (
|
||||||
|
element: HTMLElement,
|
||||||
|
dialogParams: ConvertActionDialogParams
|
||||||
|
): void => {
|
||||||
|
fireEvent(element, "show-dialog", {
|
||||||
|
dialogTag: "dialog-convert-action",
|
||||||
|
dialogImport: loadDialog,
|
||||||
|
dialogParams,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1638,7 +1638,6 @@ class DialogAddAutomationElement
|
|||||||
result.push({
|
result.push({
|
||||||
icon: html`
|
icon: html`
|
||||||
<ha-service-icon
|
<ha-service-icon
|
||||||
.hass=${this.hass}
|
|
||||||
.service=${`${dmn}.${service}`}
|
.service=${`${dmn}.${service}`}
|
||||||
></ha-service-icon>
|
></ha-service-icon>
|
||||||
`,
|
`,
|
||||||
@@ -1957,7 +1956,6 @@ class DialogAddAutomationElement
|
|||||||
items[domain].items.push({
|
items[domain].items.push({
|
||||||
icon: html`
|
icon: html`
|
||||||
<ha-service-icon
|
<ha-service-icon
|
||||||
.hass=${this.hass}
|
|
||||||
.service=${`${domain}.${serviceName}`}
|
.service=${`${domain}.${serviceName}`}
|
||||||
></ha-service-icon>
|
></ha-service-icon>
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
mdiPlusCircleMultipleOutline,
|
mdiPlusCircleMultipleOutline,
|
||||||
mdiRenameBox,
|
mdiRenameBox,
|
||||||
mdiStopCircleOutline,
|
mdiStopCircleOutline,
|
||||||
|
mdiSwapHorizontal,
|
||||||
} from "@mdi/js";
|
} from "@mdi/js";
|
||||||
import type { PropertyValues } from "lit";
|
import type { PropertyValues } from "lit";
|
||||||
import { html, LitElement, nothing } from "lit";
|
import { html, LitElement, nothing } from "lit";
|
||||||
@@ -27,6 +28,7 @@ import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
|
|||||||
import "../../../../components/ha-dropdown-item";
|
import "../../../../components/ha-dropdown-item";
|
||||||
import { ACTION_BUILDING_BLOCKS } from "../../../../data/action";
|
import { ACTION_BUILDING_BLOCKS } from "../../../../data/action";
|
||||||
import type { ActionSidebarConfig } from "../../../../data/automation";
|
import type { ActionSidebarConfig } from "../../../../data/automation";
|
||||||
|
import { isBuildingBlockAction } from "../action/convert-action";
|
||||||
import type { DomainManifestLookup } from "../../../../data/integration";
|
import type { DomainManifestLookup } from "../../../../data/integration";
|
||||||
import { domainToName } from "../../../../data/integration";
|
import { domainToName } from "../../../../data/integration";
|
||||||
import type {
|
import type {
|
||||||
@@ -285,6 +287,22 @@ export default class HaAutomationSidebarAction extends LitElement {
|
|||||||
</ha-dropdown-item>
|
</ha-dropdown-item>
|
||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
${!isBuildingBlockAction(this.config.config.action)
|
||||||
|
? html`<ha-dropdown-item
|
||||||
|
slot="menu-items"
|
||||||
|
value="convert"
|
||||||
|
.disabled=${this.disabled}
|
||||||
|
>
|
||||||
|
<ha-svg-icon slot="icon" .path=${mdiSwapHorizontal}></ha-svg-icon>
|
||||||
|
<div class="overflow-label">
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.automation.editor.actions.convert"
|
||||||
|
)}
|
||||||
|
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
|
||||||
|
</div>
|
||||||
|
</ha-dropdown-item>`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
<ha-dropdown-item
|
<ha-dropdown-item
|
||||||
slot="menu-items"
|
slot="menu-items"
|
||||||
value="toggle_yaml_mode"
|
value="toggle_yaml_mode"
|
||||||
@@ -451,6 +469,9 @@ export default class HaAutomationSidebarAction extends LitElement {
|
|||||||
case "duplicate":
|
case "duplicate":
|
||||||
this.config.duplicate();
|
this.config.duplicate();
|
||||||
break;
|
break;
|
||||||
|
case "convert":
|
||||||
|
this.config.convert();
|
||||||
|
break;
|
||||||
case "copy":
|
case "copy":
|
||||||
this.config.copy();
|
this.config.copy();
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -5648,6 +5648,15 @@
|
|||||||
"run_action_error": "Error running action",
|
"run_action_error": "Error running action",
|
||||||
"run_action_success": "Action ran successfully",
|
"run_action_success": "Action ran successfully",
|
||||||
"duplicate": "[%key:ui::common::duplicate%]",
|
"duplicate": "[%key:ui::common::duplicate%]",
|
||||||
|
"convert": "Convert to…",
|
||||||
|
"convert_dialog": {
|
||||||
|
"title": "Convert action",
|
||||||
|
"warning": "Compatible settings are carried over; anything else is removed. You can undo if you change your mind.",
|
||||||
|
"convert": "Convert",
|
||||||
|
"mode_current": "Convert current action",
|
||||||
|
"mode_new": "Convert into a new action",
|
||||||
|
"no_matches": "No matching actions"
|
||||||
|
},
|
||||||
"re_order": "[%key:ui::panel::config::automation::editor::triggers::re_order%]",
|
"re_order": "[%key:ui::panel::config::automation::editor::triggers::re_order%]",
|
||||||
"rename": "[%key:ui::panel::config::automation::editor::triggers::rename%]",
|
"rename": "[%key:ui::panel::config::automation::editor::triggers::rename%]",
|
||||||
"cut": "[%key:ui::panel::config::automation::editor::triggers::cut%]",
|
"cut": "[%key:ui::panel::config::automation::editor::triggers::cut%]",
|
||||||
|
|||||||
Reference in New Issue
Block a user