Group add automation elements in dialog (#19086)

* Group add automation elements in dialog

* Add search

* clear filter on close

* Split out services

* group services by integration type

* Update add-automation-element-dialog.ts

* fix typing

* clear filter on back

* Update add-automation-element-dialog.ts

* Fix search

* scroll to top

* Add service descriptions

* fix clipboard

* Move play media, sort services

* use helpers

* move to data

* Move building blocks to a group

* fix search

* Update add-automation-element-dialog.ts

* Update en.json

* fix alignment of single line and multi line items

* use repeat instead of map
This commit is contained in:
Bram Kragten 2023-12-21 21:01:27 +01:00 committed by GitHub
parent 7b6b5724e1
commit 8f07e6f141
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 926 additions and 352 deletions

View File

@ -47,6 +47,13 @@ export class HaListItem extends ListItemBase {
display: var(--mdc-list-item-meta-display);
align-items: center;
}
:host([graphic="icon"]:not([twoline]))
.mdc-deprecated-list-item__graphic {
margin-inline-end: var(
--mdc-list-item-graphic-margin,
20px
) !important;
}
:host([multiline-secondary]) {
height: auto;
}

View File

@ -5,6 +5,8 @@ import {
mdiCallSplit,
mdiCodeBraces,
mdiDevices,
mdiDotsHorizontal,
mdiExcavator,
mdiGestureDoubleTap,
mdiHandBackRight,
mdiPalette,
@ -13,10 +15,12 @@ import {
mdiRoomService,
mdiShuffleDisabled,
mdiTimerOutline,
mdiTools,
mdiTrafficLight,
} from "@mdi/js";
import { AutomationElementGroup } from "./automation";
export const ACTION_TYPES = {
export const ACTION_ICONS = {
condition: mdiAbTesting,
delay: mdiTimerOutline,
event: mdiGestureDoubleTap,
@ -34,6 +38,43 @@ export const ACTION_TYPES = {
variables: mdiApplicationVariableOutline,
} as const;
export const YAML_ONLY_ACTION_TYPES = new Set<keyof typeof ACTION_TYPES>([
export const YAML_ONLY_ACTION_TYPES = new Set<keyof typeof ACTION_ICONS>([
"variables",
]);
export const ACTION_GROUPS: AutomationElementGroup = {
device_id: {},
helpers: {
icon: mdiTools,
members: {},
},
building_blocks: {
icon: mdiExcavator,
members: {
condition: {},
delay: {},
wait_template: {},
wait_for_trigger: {},
repeat: {},
choose: {},
if: {},
stop: {},
parallel: {},
variables: {},
},
},
other: {
icon: mdiDotsHorizontal,
members: {
event: {},
},
},
} as const;
export const SERVICE_PREFIX = "__SERVICE__";
export const isService = (key: string | undefined): boolean | undefined =>
key?.startsWith(SERVICE_PREFIX);
export const getService = (key: string): string =>
key.substring(SERVICE_PREFIX.length);

View File

@ -275,6 +275,10 @@ export interface ShorthandNotCondition extends ShorthandBaseCondition {
not: Condition[];
}
export interface AutomationElementGroup {
[key: string]: { icon?: string; members?: AutomationElementGroup };
}
export type Condition =
| StateCondition
| NumericStateCondition

View File

@ -3,16 +3,21 @@ import {
mdiClockOutline,
mdiCodeBraces,
mdiDevices,
mdiDotsHorizontal,
mdiExcavator,
mdiGateOr,
mdiIdentifier,
mdiMapClock,
mdiMapMarkerRadius,
mdiNotEqualVariant,
mdiNumeric,
mdiShape,
mdiStateMachine,
mdiWeatherSunny,
} from "@mdi/js";
import { AutomationElementGroup } from "./automation";
export const CONDITION_TYPES = {
export const CONDITION_ICONS = {
device: mdiDevices,
and: mdiAmpersand,
or: mdiGateOr,
@ -25,3 +30,23 @@ export const CONDITION_TYPES = {
trigger: mdiIdentifier,
zone: mdiMapMarkerRadius,
};
export const CONDITION_GROUPS: AutomationElementGroup = {
device: {},
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
time_location: {
icon: mdiMapClock,
members: { sun: {}, time: {}, zone: {} },
},
building_blocks: {
icon: mdiExcavator,
members: { and: {}, or: {}, not: {} },
},
other: {
icon: mdiDotsHorizontal,
members: {
template: {},
trigger: {},
},
},
} as const;

View File

@ -16,7 +16,9 @@ export type IntegrationType =
| "helper"
| "hub"
| "service"
| "hardware";
| "hardware"
| "entity"
| "system";
export interface IntegrationManifest {
is_built_in: boolean;

View File

@ -4,13 +4,16 @@ import {
mdiClockOutline,
mdiCodeBraces,
mdiDevices,
mdiDotsHorizontal,
mdiGestureDoubleTap,
mdiMapClock,
mdiMapMarker,
mdiMapMarkerRadius,
mdiMessageAlert,
mdiMicrophoneMessage,
mdiNfcVariant,
mdiNumeric,
mdiShape,
mdiStateMachine,
mdiSwapHorizontal,
mdiWeatherSunny,
@ -18,8 +21,9 @@ import {
} from "@mdi/js";
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
import { AutomationElementGroup } from "./automation";
export const TRIGGER_TYPES = {
export const TRIGGER_ICONS = {
calendar: mdiCalendar,
device: mdiDevices,
event: mdiGestureDoubleTap,
@ -38,3 +42,26 @@ export const TRIGGER_TYPES = {
persistent_notification: mdiMessageAlert,
zone: mdiMapMarkerRadius,
};
export const TRIGGER_GROUPS: AutomationElementGroup = {
device: {},
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
time_location: {
icon: mdiMapClock,
members: { calendar: {}, sun: {}, time: {}, time_pattern: {}, zone: {} },
},
other: {
icon: mdiDotsHorizontal,
members: {
event: {},
geo_location: {},
homeassistant: {},
mqtt: {},
conversation: {},
tag: {},
template: {},
webhook: {},
persistent_notification: {},
},
},
} as const;

View File

@ -37,7 +37,7 @@ import "../../../../components/ha-card";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import { ACTION_TYPES, YAML_ONLY_ACTION_TYPES } from "../../../../data/action";
import { ACTION_ICONS, YAML_ONLY_ACTION_TYPES } from "../../../../data/action";
import { AutomationClipboard } from "../../../../data/automation";
import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
@ -82,9 +82,9 @@ export const getType = (action: Action | undefined) => {
if (["and", "or", "not"].some((key) => key in action)) {
return "condition" as const;
}
return Object.keys(ACTION_TYPES).find(
return Object.keys(ACTION_ICONS).find(
(option) => option in action
) as keyof typeof ACTION_TYPES;
) as keyof typeof ACTION_ICONS;
};
export interface ActionElement extends LitElement {
@ -190,7 +190,7 @@ export default class HaAutomationActionRow extends LitElement {
<h3 slot="header">
<ha-svg-icon
class="action-icon"
.path=${ACTION_TYPES[type!]}
.path=${ACTION_ICONS[type!]}
></ha-svg-icon>
${capitalizeFirstLetter(
describeAction(this.hass, this._entityReg, this.action)

View File

@ -1,57 +1,26 @@
import "@material/mwc-button";
import type { ActionDetail } from "@material/mwc-list";
import {
mdiArrowDown,
mdiArrowUp,
mdiContentPaste,
mdiDrag,
mdiPlus,
} from "@mdi/js";
import { mdiArrowDown, mdiArrowUp, mdiDrag, mdiPlus } from "@mdi/js";
import deepClone from "deep-clone-simple";
import {
CSSResultGroup,
LitElement,
PropertyValues,
css,
html,
nothing,
} from "lit";
import { CSSResultGroup, LitElement, PropertyValues, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import type { SortableEvent } from "sortablejs";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stringCompare } from "../../../../common/string/compare";
import { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-button";
import "../../../../components/ha-button-menu";
import type { HaSelect } from "../../../../components/ha-select";
import "../../../../components/ha-svg-icon";
import { ACTION_TYPES } from "../../../../data/action";
import { AutomationClipboard } from "../../../../data/automation";
import { getService, isService } from "../../../../data/action";
import type { AutomationClipboard } from "../../../../data/automation";
import { Action } from "../../../../data/script";
import { sortableStyles } from "../../../../resources/ha-sortable-style";
import type { SortableInstance } from "../../../../resources/sortable";
import { Entries, HomeAssistant } from "../../../../types";
import { HomeAssistant } from "../../../../types";
import {
PASTE_VALUE,
showAddAutomationElementDialog,
} from "../show-add-automation-element-dialog";
import type HaAutomationActionRow from "./ha-automation-action-row";
import { getType } from "./ha-automation-action-row";
import "./types/ha-automation-action-activate_scene";
import "./types/ha-automation-action-choose";
import "./types/ha-automation-action-condition";
import "./types/ha-automation-action-delay";
import "./types/ha-automation-action-device_id";
import "./types/ha-automation-action-event";
import "./types/ha-automation-action-if";
import "./types/ha-automation-action-parallel";
import "./types/ha-automation-action-play_media";
import "./types/ha-automation-action-repeat";
import "./types/ha-automation-action-service";
import "./types/ha-automation-action-stop";
import "./types/ha-automation-action-wait_for_trigger";
import "./types/ha-automation-action-wait_template";
const PASTE_VALUE = "__paste__";
@customElement("ha-automation-action")
export default class HaAutomationAction extends LitElement {
@ -150,42 +119,26 @@ export default class HaAutomationAction extends LitElement {
`
)}
</div>
<ha-button-menu
@action=${this._addAction}
<ha-button
slot="trigger"
outlined
.disabled=${this.disabled}
fixed
>
<ha-button
slot="trigger"
outlined
.disabled=${this.disabled}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.actions.add"
)}
>
<ha-svg-icon .path=${mdiPlus} slot="icon"></ha-svg-icon>
</ha-button>
${this._clipboard?.action
? html` <mwc-list-item .value=${PASTE_VALUE} graphic="icon">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.paste"
)}
(${this.hass.localize(
`ui.panel.config.automation.editor.actions.type.${
getType(this._clipboard.action) || "unknown"
}.label`
)})
<ha-svg-icon slot="graphic" .path=${mdiContentPaste}></ha-svg-icon
></mwc-list-item>`
: nothing}
${this._processedTypes(this.hass.localize).map(
([opt, label, icon]) => html`
<mwc-list-item .value=${opt} graphic="icon">
${label}<ha-svg-icon slot="graphic" .path=${icon}></ha-svg-icon
></mwc-list-item>
`
.label=${this.hass.localize(
"ui.panel.config.automation.editor.actions.add"
)}
</ha-button-menu>
@click=${this._addActionDialog}
>
<ha-svg-icon .path=${mdiPlus} slot="icon"></ha-svg-icon>
</ha-button>
<ha-button
.disabled=${this.disabled}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.actions.add_building_block"
)}
@click=${this._addActionBuildingBlockDialog}
>
<ha-svg-icon .path=${mdiPlus} slot="icon"></ha-svg-icon>
</ha-button>
`;
}
@ -213,6 +166,43 @@ export default class HaAutomationAction extends LitElement {
}
}
private _addActionDialog() {
showAddAutomationElementDialog(this, {
type: "action",
add: this._addAction,
clipboardItem: getType(this._clipboard?.action),
});
}
private _addActionBuildingBlockDialog() {
showAddAutomationElementDialog(this, {
type: "action",
add: this._addAction,
clipboardItem: getType(this._clipboard?.action),
group: "building_blocks",
});
}
private _addAction = (action: string) => {
let actions: Action[];
if (action === PASTE_VALUE) {
actions = this.actions.concat(deepClone(this._clipboard!.action));
} else if (isService(action)) {
actions = this.actions.concat({
service: getService(action),
});
} else {
const elClass = customElements.get(
`ha-automation-action-${action}`
) as CustomElementConstructor & { defaultConfig: Action };
actions = this.actions.concat(
elClass ? { ...elClass.defaultConfig } : { [action]: {} }
);
}
this._focusLastActionOnChange = true;
fireEvent(this, "value-changed", { value: actions });
};
private async _enterReOrderMode(ev: CustomEvent) {
if (this.nested) return;
ev.stopPropagation();
@ -258,25 +248,6 @@ export default class HaAutomationAction extends LitElement {
return this._actionKeys.get(action)!;
}
private _addAction(ev: CustomEvent<ActionDetail>) {
const action = (ev.currentTarget as HaSelect).items[ev.detail.index].value;
let actions: Action[];
if (action === PASTE_VALUE) {
actions = this.actions.concat(deepClone(this._clipboard!.action));
} else {
const elClass = customElements.get(
`ha-automation-action-${action}`
) as CustomElementConstructor & { defaultConfig: Action };
actions = this.actions.concat(
elClass ? { ...elClass.defaultConfig } : { [action]: {} }
);
}
this._focusLastActionOnChange = true;
fireEvent(this, "value-changed", { value: actions });
}
private _moveUp(ev) {
const index = (ev.target as any).index;
const newIndex = index - 1;
@ -328,22 +299,6 @@ export default class HaAutomationAction extends LitElement {
});
}
private _processedTypes = memoizeOne(
(localize: LocalizeFunc): [string, string, string][] =>
(Object.entries(ACTION_TYPES) as Entries<typeof ACTION_TYPES>)
.map(
([action, icon]) =>
[
action,
localize(
`ui.panel.config.automation.editor.actions.type.${action}.label`
),
icon,
] as [string, string, string]
)
.sort((a, b) => stringCompare(a[1], b[1], this.hass.locale.language))
);
static get styles(): CSSResultGroup {
return [
sortableStyles,

View File

@ -7,7 +7,7 @@ import type { LocalizeFunc } from "../../../../../common/translations/localize";
import "../../../../../components/ha-select";
import type { HaSelect } from "../../../../../components/ha-select";
import type { Condition } from "../../../../../data/automation";
import { CONDITION_TYPES } from "../../../../../data/condition";
import { CONDITION_ICONS } from "../../../../../data/condition";
import { Entries, HomeAssistant } from "../../../../../types";
import "../../condition/ha-automation-condition-editor";
import type { ActionElement } from "../ha-automation-action-row";
@ -55,7 +55,7 @@ export class HaConditionAction extends LitElement implements ActionElement {
private _processedTypes = memoizeOne(
(localize: LocalizeFunc): [string, string, string][] =>
(Object.entries(CONDITION_TYPES) as Entries<typeof CONDITION_TYPES>)
(Object.entries(CONDITION_ICONS) as Entries<typeof CONDITION_ICONS>)
.map(
([condition, icon]) =>
[

View File

@ -0,0 +1,553 @@
import "@material/mwc-list/mwc-list";
import { mdiClose, mdiContentPaste, mdiPlus } from "@mdi/js";
import Fuse, { IFuseOptions } from "fuse.js";
import { CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import { domainIcon } from "../../../common/entity/domain_icon";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
import { stringCompare } from "../../../common/string/compare";
import { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-dialog";
import type { HaDialog } from "../../../components/ha-dialog";
import "../../../components/ha-header-bar";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-button-prev";
import "../../../components/ha-icon-next";
import "../../../components/ha-list-item";
import "../../../components/search-input";
import {
ACTION_GROUPS,
ACTION_ICONS,
SERVICE_PREFIX,
getService,
isService,
} from "../../../data/action";
import { AutomationElementGroup } from "../../../data/automation";
import { CONDITION_GROUPS, CONDITION_ICONS } from "../../../data/condition";
import {
IntegrationManifest,
domainToName,
fetchIntegrationManifests,
} from "../../../data/integration";
import { TRIGGER_GROUPS, TRIGGER_ICONS } from "../../../data/trigger";
import { HassDialog } from "../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import {
AddAutomationElementDialogParams,
PASTE_VALUE,
} from "./show-add-automation-element-dialog";
const TYPES = {
trigger: { groups: TRIGGER_GROUPS, icons: TRIGGER_ICONS },
condition: {
groups: CONDITION_GROUPS,
icons: CONDITION_ICONS,
},
action: {
groups: ACTION_GROUPS,
icons: ACTION_ICONS,
},
};
interface ListItem {
key: string;
name: string;
description: string;
icon: string;
group: boolean;
}
interface DomainManifestLookup {
[domain: string]: IntegrationManifest;
}
const ENTITY_DOMAINS_OTHER = new Set([
"date",
"datetime",
"device_tracker",
"text",
"time",
"tts",
"update",
"weather",
"image_processing",
]);
@customElement("add-automation-element-dialog")
class DialogAddAutomationElement extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: AddAutomationElementDialogParams;
@state() private _group?: string;
@state() private _prev?: string;
@state() private _filter = "";
@state() private _manifests?: DomainManifestLookup;
@query("ha-dialog") private _dialog?: HaDialog;
public showDialog(params): void {
this._params = params;
this._group = params.group;
if (this._params?.type === "action") {
this.hass.loadBackendTranslation("services");
this._fetchManifests();
}
}
public closeDialog(): void {
if (this._params) {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
this._params = undefined;
this._group = undefined;
this._prev = undefined;
this._filter = "";
this._manifests = undefined;
}
private _convertToItem = (
key: string,
options,
type: AddAutomationElementDialogParams["type"],
localize: LocalizeFunc
): ListItem => ({
group: Boolean(options.members),
key,
name: localize(
// @ts-ignore
`ui.panel.config.automation.editor.${type}s.${
options.members ? "groups" : "type"
}.${key}.label`
),
description: localize(
// @ts-ignore
`ui.panel.config.automation.editor.${type}s.${
options.members ? "groups" : "type"
}.${key}.description${options.members ? "" : ".picker"}`
),
icon: options.icon || TYPES[type].icons[key],
});
private _getFilteredItems = memoizeOne(
(
type: AddAutomationElementDialogParams["type"],
group: string | undefined,
filter: string,
localize: LocalizeFunc,
services: HomeAssistant["services"],
manifests?: DomainManifestLookup
): ListItem[] => {
const groups: AutomationElementGroup = group
? isService(group)
? {}
: TYPES[type].groups[group].members!
: TYPES[type].groups;
const flattenGroups = (grp: AutomationElementGroup) =>
Object.entries(grp).map(([key, options]) =>
options.members
? flattenGroups(options.members)
: this._convertToItem(key, options, type, localize)
);
const items = flattenGroups(groups).flat();
if (type === "action") {
items.push(...this._services(localize, services, manifests, group));
}
const options: IFuseOptions<ListItem> = {
keys: ["key", "name", "description"],
isCaseSensitive: false,
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2,
};
const fuse = new Fuse(items, options);
return fuse.search(filter).map((result) => result.item);
}
);
private _getGroupItems = memoizeOne(
(
type: AddAutomationElementDialogParams["type"],
group: string | undefined,
localize: LocalizeFunc,
services: HomeAssistant["services"],
manifests?: DomainManifestLookup
): ListItem[] => {
if (type === "action" && isService(group)) {
const result = this._services(localize, services, manifests, group);
if (group === "service_media_player") {
result.unshift(this._convertToItem("play_media", {}, type, localize));
}
return result;
}
const groups: AutomationElementGroup = group
? TYPES[type].groups[group].members!
: TYPES[type].groups;
const result = Object.entries(groups).map(([key, options]) =>
this._convertToItem(key, options, type, localize)
);
if (type === "action") {
if (!this._group) {
result.unshift(
...this._serviceGroups(localize, services, manifests, undefined)
);
} else if (this._group === "helpers") {
result.unshift(
...this._serviceGroups(localize, services, manifests, "helper")
);
} else if (this._group === "other") {
result.unshift(
...this._serviceGroups(localize, services, manifests, "other")
);
}
}
return result.sort((a, b) => {
if (a.group && b.group) {
return 0;
}
if (a.group && !b.group) {
return 1;
}
if (!a.group && b.group) {
return -1;
}
return stringCompare(a.name, b.name, this.hass.locale.language);
});
}
);
private _serviceGroups = memoizeOne(
(
localize: LocalizeFunc,
services: HomeAssistant["services"],
manifests: DomainManifestLookup | undefined,
type: "helper" | "other" | undefined
): ListItem[] => {
if (!services || !manifests) {
return [];
}
const result: ListItem[] = [];
Object.keys(services)
.sort()
.forEach((domain) => {
const manifest = manifests[domain];
if (
(type === undefined &&
manifest?.integration_type === "entity" &&
!ENTITY_DOMAINS_OTHER.has(domain)) ||
(type === "helper" && manifest?.integration_type === "helper") ||
(type === "other" &&
(ENTITY_DOMAINS_OTHER.has(domain) ||
!["helper", "entity"].includes(
manifest?.integration_type || ""
)))
) {
result.push({
group: true,
icon: domainIcon(domain),
key: `${SERVICE_PREFIX}${domain}`,
name: domainToName(localize, domain, manifest),
description: "",
});
}
});
return result;
}
);
private _services = memoizeOne(
(
localize: LocalizeFunc,
services: HomeAssistant["services"],
manifests: DomainManifestLookup | undefined,
group?: string
): ListItem[] => {
if (!services) {
return [];
}
const result: ListItem[] = [];
let domain: string | undefined;
if (isService(group)) {
domain = getService(group!);
}
const addDomain = (dmn: string) => {
const services_keys = Object.keys(services[dmn]);
for (const service of services_keys) {
result.push({
group: false,
icon: domainIcon(dmn),
key: `${SERVICE_PREFIX}${dmn}.${service}`,
name: `${domain ? "" : `${domainToName(localize, dmn)}: `}${
this.hass.localize(`component.${dmn}.services.${service}.name`) ||
services[dmn][service]?.name ||
service
}`,
description:
this.hass.localize(
`component.${domain}.services.${service}.description`
) || services[dmn][service]?.description,
});
}
};
if (domain) {
addDomain(domain);
return result.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
);
}
if (group && !["helpers", "other"].includes(group)) {
return [];
}
Object.keys(services)
.sort()
.forEach((dmn) => {
const manifest = manifests?.[dmn];
if (group === "helpers" && manifest?.integration_type !== "helper") {
return;
}
if (
group === "other" &&
(ENTITY_DOMAINS_OTHER.has(dmn) ||
["helper", "entity"].includes(manifest?.integration_type || ""))
) {
return;
}
addDomain(dmn);
});
return result;
}
);
private async _fetchManifests() {
const manifests = {};
const fetched = await fetchIntegrationManifests(this.hass);
for (const manifest of fetched) {
manifests[manifest.domain] = manifest;
}
this._manifests = manifests;
}
protected render() {
if (!this._params) {
return nothing;
}
const items = this._filter
? this._getFilteredItems(
this._params.type,
this._group,
this._filter,
this.hass.localize,
this.hass.services,
this._manifests
)
: this._getGroupItems(
this._params.type,
this._group,
this.hass.localize,
this.hass.services,
this._manifests
);
const groupName = isService(this._group)
? domainToName(
this.hass.localize,
getService(this._group!),
this._manifests?.[getService(this._group!)]
)
: this.hass.localize(
// @ts-ignore
`ui.panel.config.automation.editor.${this._params.type}s.groups.${this._group}.label`
);
return html`
<ha-dialog open hideActions @closed=${this.closeDialog} .heading=${true}>
<div slot="heading">
<ha-header-bar>
<span slot="title"
>${this._group
? groupName
: this.hass.localize(
`ui.panel.config.automation.editor.${this._params.type}s.add`
)}</span
>
${this._group && this._group !== this._params.group
? html`<ha-icon-button-prev
slot="navigationIcon"
@click=${this._back}
></ha-icon-button-prev>`
: html`<ha-icon-button
.path=${mdiClose}
slot="navigationIcon"
dialogAction="cancel"
></ha-icon-button>`}
</ha-header-bar>
<search-input
.hass=${this.hass}
.filter=${this._filter}
@value-changed=${this._filterChanged}
.label=${groupName
? this.hass.localize(
"ui.panel.config.automation.editor.search_in",
{ group: groupName }
)
: this.hass.localize(
`ui.panel.config.automation.editor.${this._params.type}s.search`
)}
></search-input>
</div>
<mwc-list
innerRole="listbox"
itemRoles="option"
rootTabbable
dialogInitialFocus
>
${this._params.clipboardItem &&
!this._filter &&
(!this._group ||
items.find((item) => item.key === this._params!.clipboardItem))
? html`<ha-list-item
twoline
class="paste"
.value=${PASTE_VALUE}
graphic="icon"
hasMeta
@request-selected=${this._selected}
>
${this.hass.localize(
`ui.panel.config.automation.editor.${this._params.type}s.paste`
)}
<span slot="secondary"
>${this.hass.localize(
// @ts-ignore
`ui.panel.config.automation.editor.${this._params.type}s.type.${this._params.clipboardItem}.label`
)}</span
>
<ha-svg-icon
slot="graphic"
.path=${mdiContentPaste}
></ha-svg-icon
><ha-svg-icon slot="meta" .path=${mdiPlus}></ha-svg-icon>
</ha-list-item>
<li divider role="separator"></li>`
: ""}
${repeat(
items,
(item) => item.key,
(item) => html`
<ha-list-item
.twoline=${Boolean(item.description)}
.value=${item.key}
.group=${item.group}
graphic="icon"
hasMeta
@request-selected=${this._selected}
>
${item.name}
<span slot="secondary">${item.description}</span>
<ha-svg-icon slot="graphic" .path=${item.icon}></ha-svg-icon>
${item.group
? html`<ha-icon-next slot="meta"></ha-icon-next>`
: html`<ha-svg-icon
slot="meta"
.path=${mdiPlus}
></ha-svg-icon>`}
</ha-list-item>
`
)}
</mwc-list>
</ha-dialog>
`;
}
private _back() {
if (this._filter) {
this._filter = "";
return;
}
if (this._prev) {
this._group = this._prev;
this._prev = undefined;
return;
}
this._group = undefined;
}
private _selected(ev) {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
this._dialog!.scrollToPos(0, 0);
const item = ev.currentTarget;
if (item.group) {
this._prev = this._group;
this._group = item.value;
return;
}
this._params!.add(item.value);
this.closeDialog();
}
private _filterChanged(ev) {
this._filter = ev.detail.value;
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: 0;
--mdc-dialog-max-height: 60vh;
}
@media all and (min-width: 550px) {
ha-dialog {
--mdc-dialog-min-width: 500px;
}
}
ha-header-bar {
--mdc-theme-on-primary: var(--primary-text-color);
--mdc-theme-primary: var(--mdc-theme-surface);
margin-top: 8px;
display: block;
}
ha-icon-next {
width: 24px;
}
search-input {
display: block;
margin: 0 16px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"add-automation-element-dialog": DialogAddAutomationElement;
}
}

View File

@ -29,7 +29,7 @@ import "../../../../components/ha-icon-button";
import type { AutomationClipboard } from "../../../../data/automation";
import { Condition, testCondition } from "../../../../data/automation";
import { describeCondition } from "../../../../data/automation_i18n";
import { CONDITION_TYPES } from "../../../../data/condition";
import { CONDITION_ICONS } from "../../../../data/condition";
import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import { EntityRegistryEntry } from "../../../../data/entity_registry";
@ -123,7 +123,7 @@ export default class HaAutomationConditionRow extends LitElement {
<h3 slot="header">
<ha-svg-icon
class="condition-icon"
.path=${CONDITION_TYPES[this.condition.condition]}
.path=${CONDITION_ICONS[this.condition.condition]}
></ha-svg-icon>
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)

View File

@ -1,25 +1,18 @@
import "@material/mwc-button";
import type { ActionDetail } from "@material/mwc-list";
import {
mdiArrowDown,
mdiArrowUp,
mdiContentPaste,
mdiDrag,
mdiPlus,
} from "@mdi/js";
import { mdiArrowDown, mdiArrowUp, mdiDrag, mdiPlus } from "@mdi/js";
import deepClone from "deep-clone-simple";
import {
css,
CSSResultGroup,
html,
LitElement,
nothing,
PropertyValues,
css,
html,
nothing,
} from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import type { SortableEvent } from "sortablejs";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-button-menu";
@ -28,30 +21,15 @@ import type {
AutomationClipboard,
Condition,
} from "../../../../data/automation";
import type { Entries, HomeAssistant } from "../../../../types";
import "./ha-automation-condition-row";
import type HaAutomationConditionRow from "./ha-automation-condition-row";
// Uncommenting these and this element doesn't load
// import "./types/ha-automation-condition-not";
// import "./types/ha-automation-condition-or";
import { storage } from "../../../../common/decorators/storage";
import { stringCompare } from "../../../../common/string/compare";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { HaSelect } from "../../../../components/ha-select";
import { CONDITION_TYPES } from "../../../../data/condition";
import { sortableStyles } from "../../../../resources/ha-sortable-style";
import type { SortableInstance } from "../../../../resources/sortable";
import "./types/ha-automation-condition-and";
import "./types/ha-automation-condition-device";
import "./types/ha-automation-condition-numeric_state";
import "./types/ha-automation-condition-state";
import "./types/ha-automation-condition-sun";
import "./types/ha-automation-condition-template";
import "./types/ha-automation-condition-time";
import "./types/ha-automation-condition-trigger";
import "./types/ha-automation-condition-zone";
const PASTE_VALUE = "__paste__";
import type { HomeAssistant } from "../../../../types";
import {
PASTE_VALUE,
showAddAutomationElementDialog,
} from "../show-add-automation-element-dialog";
import "./ha-automation-condition-row";
import type HaAutomationConditionRow from "./ha-automation-condition-row";
@customElement("ha-automation-condition")
export default class HaAutomationCondition extends LitElement {
@ -197,43 +175,67 @@ export default class HaAutomationCondition extends LitElement {
`
)}
</div>
<ha-button-menu
@action=${this._addCondition}
<ha-button
outlined
.disabled=${this.disabled}
fixed
>
<ha-button
slot="trigger"
outlined
.disabled=${this.disabled}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.conditions.add"
)}
>
<ha-svg-icon .path=${mdiPlus} slot="icon"></ha-svg-icon>
</ha-button>
${this._clipboard?.condition
? html` <mwc-list-item .value=${PASTE_VALUE} graphic="icon">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.paste"
)}
(${this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.${this._clipboard.condition.condition}.label`
)})
<ha-svg-icon slot="graphic" .path=${mdiContentPaste}></ha-svg-icon
></mwc-list-item>`
: nothing}
${this._processedTypes(this.hass.localize).map(
([opt, label, icon]) => html`
<mwc-list-item .value=${opt} graphic="icon">
${label}<ha-svg-icon slot="graphic" .path=${icon}></ha-svg-icon
></mwc-list-item>
`
.label=${this.hass.localize(
"ui.panel.config.automation.editor.conditions.add"
)}
</ha-button-menu>
@click=${this._addConditionDialog}
>
<ha-svg-icon .path=${mdiPlus} slot="icon"></ha-svg-icon>
</ha-button>
<ha-button
.disabled=${this.disabled}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.conditions.add_building_block"
)}
@click=${this._addConditionBuildingBlockDialog}
>
<ha-svg-icon .path=${mdiPlus} slot="icon"></ha-svg-icon>
</ha-button>
`;
}
private _addConditionDialog() {
showAddAutomationElementDialog(this, {
type: "condition",
add: this._addCondition,
clipboardItem: this._clipboard?.condition?.condition,
});
}
private _addConditionBuildingBlockDialog() {
showAddAutomationElementDialog(this, {
type: "condition",
add: this._addCondition,
clipboardItem: this._clipboard?.condition?.condition,
group: "building_blocks",
});
}
private _addCondition = (value) => {
let conditions: Condition[];
if (value === PASTE_VALUE) {
conditions = this.conditions.concat(
deepClone(this._clipboard!.condition)
);
} else {
const condition = value as Condition["condition"];
const elClass = customElements.get(
`ha-automation-condition-${condition}`
) as CustomElementConstructor & {
defaultConfig: Omit<Condition, "condition">;
};
conditions = this.conditions.concat({
condition: condition as any,
...elClass.defaultConfig,
});
}
this._focusLastConditionOnChange = true;
fireEvent(this, "value-changed", { value: conditions });
};
private async _enterReOrderMode(ev: CustomEvent) {
if (this.nested) return;
ev.stopPropagation();
@ -282,32 +284,6 @@ export default class HaAutomationCondition extends LitElement {
return this._conditionKeys.get(condition)!;
}
private _addCondition(ev: CustomEvent<ActionDetail>) {
const value = (ev.currentTarget as HaSelect).items[ev.detail.index].value;
let conditions: Condition[];
if (value === PASTE_VALUE) {
conditions = this.conditions.concat(
deepClone(this._clipboard!.condition)
);
} else {
const condition = value as Condition["condition"];
const elClass = customElements.get(
`ha-automation-condition-${condition}`
) as CustomElementConstructor & {
defaultConfig: Omit<Condition, "condition">;
};
conditions = this.conditions.concat({
condition: condition as any,
...elClass.defaultConfig,
});
}
this._focusLastConditionOnChange = true;
fireEvent(this, "value-changed", { value: conditions });
}
private _moveUp(ev) {
const index = (ev.target as any).index;
const newIndex = index - 1;
@ -361,22 +337,6 @@ export default class HaAutomationCondition extends LitElement {
});
}
private _processedTypes = memoizeOne(
(localize: LocalizeFunc): [string, string, string][] =>
(Object.entries(CONDITION_TYPES) as Entries<typeof CONDITION_TYPES>)
.map(
([condition, icon]) =>
[
condition,
localize(
`ui.panel.config.automation.editor.conditions.type.${condition}.label`
),
icon,
] as [string, string, string]
)
.sort((a, b) => stringCompare(a[1], b[1], this.hass.locale.language))
);
static get styles(): CSSResultGroup {
return [
sortableStyles,

View File

@ -0,0 +1,22 @@
import { fireEvent } from "../../../common/dom/fire_event";
export const PASTE_VALUE = "__paste__";
export interface AddAutomationElementDialogParams {
type: "trigger" | "condition" | "action";
add: (key: string) => void;
clipboardItem: string | undefined;
group?: string;
}
const loadDialog = () => import("./add-automation-element-dialog");
export const showAddAutomationElementDialog = (
element: HTMLElement,
dialogParams: AddAutomationElementDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "add-automation-element-dialog",
dialogImport: loadDialog,
dialogParams,
});
};

View File

@ -37,7 +37,7 @@ import { describeTrigger } from "../../../../data/automation_i18n";
import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
import { EntityRegistryEntry } from "../../../../data/entity_registry";
import { TRIGGER_TYPES } from "../../../../data/trigger";
import { TRIGGER_ICONS } from "../../../../data/trigger";
import {
showAlertDialog,
showConfirmationDialog,
@ -150,7 +150,7 @@ export default class HaAutomationTriggerRow extends LitElement {
<h3 slot="header">
<ha-svg-icon
class="trigger-icon"
.path=${TRIGGER_TYPES[this.trigger.platform]}
.path=${TRIGGER_ICONS[this.trigger.platform]}
></ha-svg-icon>
${describeTrigger(this.trigger, this.hass, this._entityReg)}
</h3>

View File

@ -1,59 +1,25 @@
import "@material/mwc-button";
import type { ActionDetail } from "@material/mwc-list";
import {
mdiArrowDown,
mdiArrowUp,
mdiContentPaste,
mdiDrag,
mdiPlus,
} from "@mdi/js";
import { mdiArrowDown, mdiArrowUp, mdiDrag, mdiPlus } from "@mdi/js";
import deepClone from "deep-clone-simple";
import {
CSSResultGroup,
LitElement,
PropertyValues,
css,
html,
nothing,
} from "lit";
import { CSSResultGroup, LitElement, PropertyValues, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import type { SortableEvent } from "sortablejs";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stringCompare } from "../../../../common/string/compare";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-button";
import "../../../../components/ha-button-menu";
import type { HaSelect } from "../../../../components/ha-select";
import "../../../../components/ha-svg-icon";
import { AutomationClipboard, Trigger } from "../../../../data/automation";
import { TRIGGER_TYPES } from "../../../../data/trigger";
import { sortableStyles } from "../../../../resources/ha-sortable-style";
import type { SortableInstance } from "../../../../resources/sortable";
import { Entries, HomeAssistant } from "../../../../types";
import { HomeAssistant } from "../../../../types";
import "./ha-automation-trigger-row";
import type HaAutomationTriggerRow from "./ha-automation-trigger-row";
import "./types/ha-automation-trigger-calendar";
import "./types/ha-automation-trigger-conversation";
import "./types/ha-automation-trigger-device";
import "./types/ha-automation-trigger-event";
import "./types/ha-automation-trigger-geo_location";
import "./types/ha-automation-trigger-homeassistant";
import "./types/ha-automation-trigger-mqtt";
import "./types/ha-automation-trigger-numeric_state";
import "./types/ha-automation-trigger-persistent_notification";
import "./types/ha-automation-trigger-state";
import "./types/ha-automation-trigger-sun";
import "./types/ha-automation-trigger-tag";
import "./types/ha-automation-trigger-template";
import "./types/ha-automation-trigger-time";
import "./types/ha-automation-trigger-time_pattern";
import "./types/ha-automation-trigger-webhook";
import "./types/ha-automation-trigger-zone";
const PASTE_VALUE = "__paste__";
import {
PASTE_VALUE,
showAddAutomationElementDialog,
} from "../show-add-automation-element-dialog";
@customElement("ha-automation-trigger")
export default class HaAutomationTrigger extends LitElement {
@ -147,47 +113,48 @@ export default class HaAutomationTrigger extends LitElement {
</ha-automation-trigger-row>
`
)}
<ha-button-menu
@action=${this._addTrigger}
.disabled=${this.disabled}
fixed
>
<ha-button
slot="trigger"
outlined
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.add"
)}
.disabled=${this.disabled}
>
<ha-svg-icon .path=${mdiPlus} slot="icon"></ha-svg-icon>
</ha-button>
${this._clipboard?.trigger
? html` <mwc-list-item .value=${PASTE_VALUE} graphic="icon">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.paste"
)}
(${this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.${this._clipboard.trigger.platform}.label`
)})
<ha-svg-icon
slot="graphic"
.path=${mdiContentPaste}
></ha-svg-icon
></mwc-list-item>`
: nothing}
${this._processedTypes(this.hass.localize).map(
([opt, label, icon]) => html`
<mwc-list-item .value=${opt} graphic="icon">
${label}<ha-svg-icon slot="graphic" .path=${icon}></ha-svg-icon
></mwc-list-item>
`
<ha-button
outlined
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.add"
)}
</ha-button-menu>
.disabled=${this.disabled}
@click=${this._addTriggerDialog}
>
<ha-svg-icon .path=${mdiPlus} slot="icon"></ha-svg-icon>
</ha-button>
</div>
`;
}
private _addTriggerDialog() {
showAddAutomationElementDialog(this, {
type: "trigger",
add: this._addTrigger,
clipboardItem: this._clipboard?.trigger?.platform,
});
}
private _addTrigger = (value: string) => {
let triggers: Trigger[];
if (value === PASTE_VALUE) {
triggers = this.triggers.concat(deepClone(this._clipboard!.trigger));
} else {
const platform = value as Trigger["platform"];
const elClass = customElements.get(
`ha-automation-trigger-${platform}`
) as CustomElementConstructor & {
defaultConfig: Omit<Trigger, "platform">;
};
triggers = this.triggers.concat({
platform: platform as any,
...elClass.defaultConfig,
});
}
this._focusLastTriggerOnChange = true;
fireEvent(this, "value-changed", { value: triggers });
};
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
@ -261,30 +228,6 @@ export default class HaAutomationTrigger extends LitElement {
return this._triggerKeys.get(action)!;
}
private _addTrigger(ev: CustomEvent<ActionDetail>) {
const value = (ev.currentTarget as HaSelect).items[ev.detail.index].value;
let triggers: Trigger[];
if (value === PASTE_VALUE) {
triggers = this.triggers.concat(deepClone(this._clipboard!.trigger));
} else {
const platform = value as Trigger["platform"];
const elClass = customElements.get(
`ha-automation-trigger-${platform}`
) as CustomElementConstructor & {
defaultConfig: Omit<Trigger, "platform">;
};
triggers = this.triggers.concat({
platform: platform as any,
...elClass.defaultConfig,
});
}
this._focusLastTriggerOnChange = true;
fireEvent(this, "value-changed", { value: triggers });
}
private _moveUp(ev) {
const index = (ev.target as any).index;
const newIndex = index - 1;
@ -336,22 +279,6 @@ export default class HaAutomationTrigger extends LitElement {
});
}
private _processedTypes = memoizeOne(
(localize: LocalizeFunc): [string, string, string][] =>
(Object.entries(TRIGGER_TYPES) as Entries<typeof TRIGGER_TYPES>)
.map(
([action, icon]) =>
[
action,
localize(
`ui.panel.config.automation.editor.triggers.type.${action}.label`
),
icon,
] as [string, string, string]
)
.sort((a, b) => stringCompare(a[1], b[1], this.hass.locale.language))
);
static get styles(): CSSResultGroup {
return [
sortableStyles,

View File

@ -487,7 +487,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
<h1 class="card-header">
${this._manifest?.integration_type
? this.hass.localize(
`ui.panel.config.integrations.integration_page.entries_${this._manifest?.integration_type}`
`ui.panel.config.integrations.integration_page.entries_${this._manifest.integration_type}`
)
: this.hass.localize(
`ui.panel.config.integrations.integration_page.entries`
@ -507,7 +507,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
<ha-button @click=${this._addIntegration}>
${this._manifest?.integration_type
? this.hass.localize(
`ui.panel.config.integrations.integration_page.add_${this._manifest?.integration_type}`
`ui.panel.config.integrations.integration_page.add_${this._manifest.integration_type}`
)
: this.hass.localize(
`ui.panel.config.integrations.integration_page.add_entry`

View File

@ -2441,6 +2441,7 @@
"edit_yaml": "Edit in YAML",
"edit_ui": "Edit in visual editor",
"copy_to_clipboard": "Copy to clipboard",
"search_in": "Search · {group}",
"triggers": {
"name": "Triggers",
"header": "When",
@ -2448,6 +2449,7 @@
"learn_more": "Learn more about triggers",
"triggered": "Triggered",
"add": "Add trigger",
"search": "Search trigger",
"id": "Trigger ID",
"edit_id": "Edit ID",
"duplicate": "[%key:ui::common::duplicate%]",
@ -2464,6 +2466,17 @@
"unsupported_platform": "No visual editor support for platform: {platform}",
"type_select": "Trigger type",
"unknown_trigger": "[%key:ui::panel::config::devices::automation::triggers::unknown_trigger%]",
"groups": {
"entity": {
"label": "Entity",
"description": "When something happens to an entity"
},
"time_location": {
"label": "Time and location",
"description": "When someone enters or leaves a zone, or at a specific time."
},
"other": { "label": "Other" }
},
"type": {
"calendar": {
"label": "Calendar",
@ -2482,6 +2495,9 @@
"below": "Below",
"for": "Duration (optional)",
"zone": "[%key:ui::panel::config::automation::editor::triggers::type::zone::label%]"
},
"description": {
"picker": "When something happens to a device. Great way to start."
}
},
"event": {
@ -2643,6 +2659,8 @@
"description": "This list of conditions needs to be satisfied for the automation to run. A condition can be satisfied or not at any given time, for example: ''If {user} is home''. You can use building blocks to create more complex conditions.",
"learn_more": "Learn more about conditions",
"add": "Add condition",
"search": "Search condition",
"add_building_block": "Add building block",
"test": "Test",
"testing_error": "Condition did not pass",
"testing_pass": "Condition passes",
@ -2662,6 +2680,21 @@
"unsupported_condition": "No visual editor support for condition: {condition}",
"type_select": "Condition type",
"unknown_condition": "[%key:ui::panel::config::devices::automation::conditions::unknown_condition%]",
"groups": {
"entity": {
"label": "Entity",
"description": "If an entity is in a specific state"
},
"time_location": {
"label": "Time and location",
"description": "If someone is in a zone or if the current time is before or after a specified time"
},
"other": { "label": "Other" },
"building_blocks": {
"label": "Building blocks",
"description": "Build more complex conditions"
}
},
"type": {
"and": {
"label": "And",
@ -2679,6 +2712,9 @@
"for": "Duration",
"hvac_mode": "HVAC mode",
"preset_mode": "Preset mode"
},
"description": {
"picker": "If something happens to a device. Great way to start."
}
},
"not": {
@ -2781,6 +2817,8 @@
"description": "This list of actions will be performed in sequence when the automation runs. An action usually controls one of your areas, devices, or entities, for example: 'Turn on the lights'. You can use building blocks to create more complex sequences of actions.",
"learn_more": "Learn more about actions",
"add": "Add action",
"search": "Search action",
"add_building_block": "Add building block",
"invalid_action": "Invalid action",
"run": "Run",
"run_action_error": "Error running action",
@ -2802,6 +2840,14 @@
"unsupported_action": "No visual editor support for this action",
"type_select": "Action type",
"continue_on_error": "Continue on error",
"groups": {
"helpers": { "label": "Helpers" },
"other": { "label": "Other" },
"building_blocks": {
"label": "Building blocks",
"description": "Build more complex sequences of actions"
}
},
"type": {
"service": {
"label": "Call service",
@ -2821,6 +2867,7 @@
"play_media": {
"label": "Play media",
"description": {
"picker": "Play media on a media player",
"full": "Play {hasMedia, select, \n true {{media}} \n other {media}\n } on {hasMediaPlayer, select, \n true {{mediaPlayer}} \n other {a media player}\n }"
}
},
@ -3703,6 +3750,8 @@
"entries_service": "Services",
"entries_helper": "Helpers",
"entries_hardware": "Hardware",
"entries_system": "[%key:ui::panel::config::integrations::integration_page::entries%]",
"entries_entity": "[%key:ui::panel::config::integrations::integration_page::entries%]",
"no_entries": "No entries",
"attention_entries": "Needs attention",
"add_entry": "Add entry",
@ -3710,7 +3759,9 @@
"add_hub": "Add hub",
"add_service": "Add service",
"add_helper": "Add helper",
"add_hardware": "Add hardware"
"add_hardware": "Add hardware",
"add_entity": "[%key:ui::panel::config::integrations::integration_page::add_entry%]",
"add_system": "[%key:ui::panel::config::integrations::integration_page::add_entry%]"
},
"config_entry": {
"application_credentials": {