mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-27 08:04:18 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aba8ff5c3d | ||
|
|
a86e7ae955 | ||
|
|
b23fdc40fb | ||
|
|
b92e761a6c | ||
|
|
cfd05ab129 |
No files matched your search
+2
-2
@@ -40,7 +40,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "8.0.7",
|
||||
"@babel/runtime": "8.0.8",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.4",
|
||||
"@codemirror/commands": "6.11.1",
|
||||
@@ -137,7 +137,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ampproject/remapping": "2.3.0",
|
||||
"@babel/core": "8.0.7",
|
||||
"@babel/core": "8.0.9",
|
||||
"@babel/helper-compilation-targets": "8.0.7",
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-class-properties": "8.0.1",
|
||||
|
||||
Generated
+333
-333
File diff suppressed because it is too large.
Load diff
@@ -86,6 +86,8 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@property({ attribute: false }) public allowCustom = true;
|
||||
|
||||
@state() private _mode?: "composed" | "custom";
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
@@ -129,17 +131,23 @@ export class HaEntityNamePicker extends LitElement {
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-button-toggle-group
|
||||
size="s"
|
||||
.buttons=${modeButtons}
|
||||
.active=${this._mode}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._modeChanged}
|
||||
></ha-button-toggle-group>
|
||||
${
|
||||
this.allowCustom
|
||||
? html`
|
||||
<ha-button-toggle-group
|
||||
size="s"
|
||||
.buttons=${modeButtons}
|
||||
.active=${this._mode}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._modeChanged}
|
||||
></ha-button-toggle-group>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<div class="content">
|
||||
${
|
||||
this._mode === "custom"
|
||||
this.allowCustom && this._mode === "custom"
|
||||
? this._renderTextInput()
|
||||
: this._renderPicker()
|
||||
}
|
||||
@@ -211,7 +219,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
.rowRenderer=${rowRenderer}
|
||||
.value=${this._getPickerValue()}
|
||||
no-sort
|
||||
allow-custom-value
|
||||
?allow-custom-value=${this.allowCustom}
|
||||
.customValueLabel=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.custom_name"
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { computeRTL } from "../common/util/compute_rtl";
|
||||
import { internationalizationContext, statesContext } from "../data/context";
|
||||
import { NO_DEVICE_CLASS } from "../data/entity/device_class";
|
||||
import {
|
||||
computeEntityTypeNames,
|
||||
entityTypeKey,
|
||||
FIXED_ENTITY_TYPE_ICONS,
|
||||
parseEntityType,
|
||||
sortDeviceClasses,
|
||||
usedEntityTypes,
|
||||
} from "../data/entity/entity_type";
|
||||
import { domainToName } from "../data/integration";
|
||||
import "./chips/ha-chip-set";
|
||||
import "./chips/ha-input-chip";
|
||||
import "./ha-combo-box-item";
|
||||
import "./ha-domain-icon";
|
||||
import "./ha-generic-picker";
|
||||
import type {
|
||||
PickerComboBoxItem,
|
||||
PickerComboBoxSearchFn,
|
||||
} from "./ha-picker-combo-box";
|
||||
import { DEFAULT_SEARCH_KEYS } from "./ha-picker-combo-box";
|
||||
import "./ha-tree-indicator";
|
||||
|
||||
interface EntityTypeComboBoxItem extends PickerComboBoxItem {
|
||||
last?: boolean;
|
||||
}
|
||||
|
||||
const SEARCH_KEYS = [
|
||||
...DEFAULT_SEARCH_KEYS,
|
||||
{ name: "search_labels.domain", weight: 5 },
|
||||
];
|
||||
|
||||
const isDeviceClassOf = (
|
||||
item: PickerComboBoxItem | undefined,
|
||||
domain: string
|
||||
) => {
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
const type = parseEntityType(item.id);
|
||||
return type.domain === domain && type.deviceClass !== undefined;
|
||||
};
|
||||
|
||||
// Flags the last device class of each domain so its tree line ends there.
|
||||
const markLastDeviceClasses = (
|
||||
items: PickerComboBoxItem[]
|
||||
): EntityTypeComboBoxItem[] =>
|
||||
items.map((item, index) => {
|
||||
const { domain, deviceClass } = parseEntityType(item.id);
|
||||
if (!deviceClass) {
|
||||
return item;
|
||||
}
|
||||
return { ...item, last: !isDeviceClassOf(items[index + 1], domain) };
|
||||
});
|
||||
|
||||
@customElement("ha-entity-type-picker")
|
||||
export class HaEntityTypePicker extends LitElement {
|
||||
@property({ attribute: false }) public value?: string[];
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states?: ContextType<typeof statesContext>;
|
||||
|
||||
private _titlesLoaded = false;
|
||||
|
||||
protected willUpdate() {
|
||||
if (this._titlesLoaded || !this._i18n) {
|
||||
return;
|
||||
}
|
||||
this._titlesLoaded = true;
|
||||
this._i18n.loadBackendTranslation("title");
|
||||
}
|
||||
|
||||
private _chipLabels = memoizeOne(computeEntityTypeNames);
|
||||
|
||||
private _items = memoizeOne(
|
||||
(
|
||||
states: ContextType<typeof statesContext>,
|
||||
localize: LocalizeFunc,
|
||||
language: string
|
||||
): PickerComboBoxItem[] => {
|
||||
// A section keeps following its types, so a device class is offered even
|
||||
// when it is the only one of its domain today.
|
||||
const types = usedEntityTypes(states, false);
|
||||
const domains = [...types.keys()]
|
||||
.map((domain) => ({ domain, name: domainToName(localize, domain) }))
|
||||
.sort((a, b) => stringCompare(a.name, b.name, language));
|
||||
|
||||
return domains.flatMap(({ domain, name }) => [
|
||||
{ id: domain, primary: name },
|
||||
...sortDeviceClasses(
|
||||
localize,
|
||||
domain,
|
||||
types
|
||||
.get(domain)!
|
||||
.filter((deviceClass) => deviceClass !== NO_DEVICE_CLASS),
|
||||
language
|
||||
).map(({ deviceClass, name: deviceClassName }) => ({
|
||||
id: entityTypeKey(domain, deviceClass),
|
||||
primary: deviceClassName,
|
||||
search_labels: { domain: name },
|
||||
})),
|
||||
]);
|
||||
}
|
||||
);
|
||||
|
||||
private _availableItems = memoizeOne(
|
||||
(items: PickerComboBoxItem[], value: string[]) => {
|
||||
const selectedDomains = new Set(
|
||||
value.filter((key) => !parseEntityType(key).deviceClass)
|
||||
);
|
||||
return markLastDeviceClasses(
|
||||
items.filter(
|
||||
(item) =>
|
||||
!value.includes(item.id) &&
|
||||
!selectedDomains.has(parseEntityType(item.id).domain)
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _getItems = () => {
|
||||
if (!this._states || !this._i18n) {
|
||||
return [];
|
||||
}
|
||||
const items = this._items(
|
||||
this._states,
|
||||
this._i18n.localize,
|
||||
this._i18n.locale.language
|
||||
);
|
||||
return this._availableItems(items, this.value ?? []);
|
||||
};
|
||||
|
||||
// Keeps the domain above the device classes that match, so the tree stays
|
||||
// readable while searching, and lists the domains by their best match.
|
||||
// Items the search adds itself, like the "no match" placeholder, are kept
|
||||
// after the list.
|
||||
private _searchFn: PickerComboBoxSearchFn<PickerComboBoxItem> = (
|
||||
_search,
|
||||
filteredItems,
|
||||
allItems
|
||||
) => {
|
||||
const matchedIds = new Set(filteredItems.map((item) => item.id));
|
||||
const matchedDomains = new Set(
|
||||
filteredItems.map((item) => parseEntityType(item.id).domain)
|
||||
);
|
||||
const ordered = [...matchedDomains].flatMap((matchedDomain) =>
|
||||
allItems.filter((item) => {
|
||||
const { domain, deviceClass } = parseEntityType(item.id);
|
||||
return (
|
||||
domain === matchedDomain && (!deviceClass || matchedIds.has(item.id))
|
||||
);
|
||||
})
|
||||
);
|
||||
const allIds = new Set(allItems.map((item) => item.id));
|
||||
const extras = filteredItems.filter((item) => !allIds.has(item.id));
|
||||
return [...markLastDeviceClasses(ordered), ...extras];
|
||||
};
|
||||
|
||||
private _rowRenderer: RenderItemFunction<EntityTypeComboBoxItem> = (item) => {
|
||||
const { deviceClass } = parseEntityType(item.id);
|
||||
const rtl = this._i18n
|
||||
? computeRTL(
|
||||
this._i18n.language,
|
||||
this._i18n.translationMetadata.translations
|
||||
)
|
||||
: false;
|
||||
return html`
|
||||
<ha-combo-box-item
|
||||
style=${
|
||||
deviceClass
|
||||
? "--ha-combo-box-item-padding-inline-start: var(--ha-space-12);"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
${
|
||||
deviceClass
|
||||
? html`
|
||||
<ha-tree-indicator
|
||||
style=${styleMap({
|
||||
width: "var(--ha-space-12)",
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
height: "100%",
|
||||
left: rtl ? undefined : "var(--ha-space-1)",
|
||||
right: rtl ? "var(--ha-space-1)" : undefined,
|
||||
transform: rtl ? "scaleX(-1)" : "",
|
||||
})}
|
||||
.end=${item.last}
|
||||
slot="start"
|
||||
></ha-tree-indicator>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${this._renderIcon(item.id, "start")}
|
||||
<span slot="headline">${item.primary}</span>
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
};
|
||||
|
||||
private _renderIcon(key: string, slot: "start" | "icon") {
|
||||
const { domain, deviceClass } = parseEntityType(key);
|
||||
return html`
|
||||
<ha-domain-icon
|
||||
slot=${slot}
|
||||
.icon=${FIXED_ENTITY_TYPE_ICONS[key]}
|
||||
.domain=${domain}
|
||||
.deviceClass=${deviceClass}
|
||||
.state=${domain === "binary_sensor" ? "on" : undefined}
|
||||
?brand-fallback=${!deviceClass}
|
||||
></ha-domain-icon>
|
||||
`;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const localize = this._i18n?.localize;
|
||||
const value = this.value ?? [];
|
||||
const chipLabels = localize ? this._chipLabels(localize, value) : undefined;
|
||||
|
||||
return html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
${
|
||||
value.length && chipLabels
|
||||
? html`
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
value,
|
||||
(key) => key,
|
||||
(key) => {
|
||||
const label = chipLabels.get(key)!;
|
||||
return html`
|
||||
<ha-input-chip
|
||||
data-key=${key}
|
||||
.label=${label}
|
||||
.disabled=${this.disabled}
|
||||
@remove=${this._removeItem}
|
||||
selected
|
||||
>
|
||||
${this._renderIcon(key, "icon")} ${label}
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</ha-chip-set>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<ha-generic-picker
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.value=${""}
|
||||
.addButtonLabel=${localize?.("ui.components.entity-type-picker.add")}
|
||||
.getItems=${this._getItems}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.searchFn=${this._searchFn}
|
||||
.searchKeys=${SEARCH_KEYS}
|
||||
.emptyLabel=${localize?.("ui.components.entity-type-picker.no_types")}
|
||||
no-sort
|
||||
@value-changed=${this._itemAdded}
|
||||
></ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _itemAdded(ev: CustomEvent<{ value?: string }>) {
|
||||
ev.stopPropagation();
|
||||
const key = ev.detail.value;
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const { domain, deviceClass } = parseEntityType(key);
|
||||
const value = (this.value ?? []).filter(
|
||||
(selected) => deviceClass || parseEntityType(selected).domain !== domain
|
||||
);
|
||||
this._setValue([...value, key]);
|
||||
}
|
||||
|
||||
private _removeItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
const key = (ev.currentTarget as HTMLElement).dataset.key;
|
||||
this._setValue((this.value ?? []).filter((selected) => selected !== key));
|
||||
}
|
||||
|
||||
private _setValue(value: string[]) {
|
||||
this.value = value;
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-2);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
ha-generic-picker {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
ha-chip-set {
|
||||
padding: var(--ha-space-2) 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-entity-type-picker": HaEntityTypePicker;
|
||||
}
|
||||
}
|
||||
@@ -23,13 +23,12 @@ import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { internationalizationContext, statesContext } from "../data/context";
|
||||
import {
|
||||
computeDeviceClassName,
|
||||
NO_DEVICE_CLASS,
|
||||
} from "../data/entity/device_class";
|
||||
import { NO_DEVICE_CLASS } from "../data/entity/device_class";
|
||||
import {
|
||||
entityTypeKey,
|
||||
FIXED_ENTITY_TYPE_ICONS,
|
||||
parseEntityType,
|
||||
sortDeviceClasses,
|
||||
usedEntityTypes,
|
||||
} from "../data/entity/entity_type";
|
||||
import { domainToName } from "../data/integration";
|
||||
@@ -45,11 +44,6 @@ import type { HaListItemOption } from "./item/ha-list-item-option";
|
||||
import "./list/ha-list-selectable";
|
||||
import type { HaListSelectable } from "./list/ha-list-selectable";
|
||||
|
||||
// Core picks this one from the battery level, so it has no usable default.
|
||||
const FIXED_TYPE_ICONS: Record<string, string> = {
|
||||
"sensor/battery": "mdi:battery",
|
||||
};
|
||||
|
||||
interface TypeRow {
|
||||
key: string;
|
||||
domain: string;
|
||||
@@ -180,7 +174,7 @@ export class HaFilterEntityTypes extends LitElement {
|
||||
? html`<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>`
|
||||
: html`<ha-domain-icon
|
||||
slot="start"
|
||||
.icon=${FIXED_TYPE_ICONS[row.key]}
|
||||
.icon=${FIXED_ENTITY_TYPE_ICONS[row.key]}
|
||||
.domain=${row.domain}
|
||||
.deviceClass=${row.deviceClass}
|
||||
.state=${row.domain === "binary_sensor" ? "on" : undefined}
|
||||
@@ -240,21 +234,12 @@ export class HaFilterEntityTypes extends LitElement {
|
||||
const rows: TypeRow[] = [];
|
||||
|
||||
for (const { domain, name } of domains) {
|
||||
const deviceClasses = types
|
||||
.get(domain)!
|
||||
.map((deviceClass) => ({
|
||||
deviceClass,
|
||||
name: this._deviceClassName(localize, domain, deviceClass),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.deviceClass === NO_DEVICE_CLASS) {
|
||||
return 1;
|
||||
}
|
||||
if (b.deviceClass === NO_DEVICE_CLASS) {
|
||||
return -1;
|
||||
}
|
||||
return stringCompare(a.name, b.name, language);
|
||||
});
|
||||
const deviceClasses = sortDeviceClasses(
|
||||
localize,
|
||||
domain,
|
||||
types.get(domain)!,
|
||||
language
|
||||
);
|
||||
|
||||
const matchingClasses = deviceClasses.filter((entry) =>
|
||||
this._matches(filter, entry.deviceClass, entry.name)
|
||||
@@ -298,16 +283,6 @@ export class HaFilterEntityTypes extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _deviceClassName(
|
||||
localize: LocalizeFunc,
|
||||
domain: string,
|
||||
deviceClass: string
|
||||
): string {
|
||||
return deviceClass === NO_DEVICE_CLASS
|
||||
? localize("ui.components.filter-entity-types.no_device_class")
|
||||
: computeDeviceClassName(localize, domain, deviceClass);
|
||||
}
|
||||
|
||||
private _matches(
|
||||
filter: string | undefined,
|
||||
slug: string,
|
||||
|
||||
@@ -34,6 +34,7 @@ export class HaSelectorEntityName extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
.value=${this.value}
|
||||
.defaultValue=${this.selector.entity_name?.default_name}
|
||||
.allowCustom=${this.selector.entity_name?.allow_custom ?? true}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
|
||||
@@ -140,6 +140,11 @@ export class HaTargetSelector extends LitElement {
|
||||
);
|
||||
|
||||
static styles = css`
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-2);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
ha-target-picker {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { stringCompare } from "../../common/string/compare";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { DOMAIN_DEVICE_CLASSES } from "../device_classes";
|
||||
import { NO_DEVICE_CLASS } from "./device_class";
|
||||
import { domainToName } from "../integration";
|
||||
import { computeDeviceClassName, NO_DEVICE_CLASS } from "./device_class";
|
||||
|
||||
const SEPARATOR = "/";
|
||||
|
||||
@@ -26,9 +30,11 @@ export const parseEntityType = (key: string): EntityType => {
|
||||
export const entityTypesNeedStates = (types?: string[]): boolean =>
|
||||
!!types?.some((key) => key.includes(SEPARATOR));
|
||||
|
||||
// A domain worth no split maps to an empty list rather than to its lone bucket.
|
||||
// By default, a domain worth no split maps to an empty list rather than to its
|
||||
// lone bucket.
|
||||
export const usedEntityTypes = (
|
||||
states: HomeAssistant["states"]
|
||||
states: HomeAssistant["states"],
|
||||
collapseSingleClass = true
|
||||
): Map<string, string[]> => {
|
||||
const byDomain = new Map<string, Set<string>>();
|
||||
|
||||
@@ -47,7 +53,7 @@ export const usedEntityTypes = (
|
||||
return new Map(
|
||||
[...byDomain].map(([domain, classes]) => [
|
||||
domain,
|
||||
classes.size > 1 ? [...classes] : [],
|
||||
classes.size > 1 || !collapseSingleClass ? [...classes] : [],
|
||||
])
|
||||
);
|
||||
};
|
||||
@@ -90,3 +96,81 @@ export const entityTypeFilterFunc = (
|
||||
return classes.has(stateObj.attributes.device_class || NO_DEVICE_CLASS);
|
||||
};
|
||||
};
|
||||
|
||||
// Core picks this one from the battery level, so it has no usable default.
|
||||
export const FIXED_ENTITY_TYPE_ICONS: Record<string, string> = {
|
||||
"sensor/battery": "mdi:battery",
|
||||
};
|
||||
|
||||
export const computeEntityTypeDeviceClassName = (
|
||||
localize: LocalizeFunc,
|
||||
domain: string,
|
||||
deviceClass: string
|
||||
): string =>
|
||||
deviceClass === NO_DEVICE_CLASS
|
||||
? localize("ui.components.filter-entity-types.no_device_class")
|
||||
: computeDeviceClassName(localize, domain, deviceClass);
|
||||
|
||||
export const sortDeviceClasses = (
|
||||
localize: LocalizeFunc,
|
||||
domain: string,
|
||||
deviceClasses: string[],
|
||||
language?: string
|
||||
): { deviceClass: string; name: string }[] =>
|
||||
deviceClasses
|
||||
.map((deviceClass) => ({
|
||||
deviceClass,
|
||||
name: computeEntityTypeDeviceClassName(localize, domain, deviceClass),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.deviceClass === NO_DEVICE_CLASS) {
|
||||
return 1;
|
||||
}
|
||||
if (b.deviceClass === NO_DEVICE_CLASS) {
|
||||
return -1;
|
||||
}
|
||||
return stringCompare(a.name, b.name, language);
|
||||
});
|
||||
|
||||
const computeEntityTypeFullName = (
|
||||
localize: LocalizeFunc,
|
||||
key: string
|
||||
): string => {
|
||||
const { domain, deviceClass } = parseEntityType(key);
|
||||
const domainName = domainToName(localize, domain);
|
||||
if (!deviceClass) {
|
||||
return domainName;
|
||||
}
|
||||
return [
|
||||
domainName,
|
||||
computeEntityTypeDeviceClassName(localize, domain, deviceClass),
|
||||
].join(STRINGS_SEPARATOR_DOT);
|
||||
};
|
||||
|
||||
// A device class is named on its own unless another type in the list has the
|
||||
// same name, or it is the "no type" bucket, which means nothing without its
|
||||
// domain.
|
||||
export const computeEntityTypeNames = (
|
||||
localize: LocalizeFunc,
|
||||
keys: string[]
|
||||
): Map<string, string> => {
|
||||
const shortNames = keys.map((key) => {
|
||||
const { domain, deviceClass } = parseEntityType(key);
|
||||
return deviceClass
|
||||
? computeEntityTypeDeviceClassName(localize, domain, deviceClass)
|
||||
: domainToName(localize, domain);
|
||||
});
|
||||
return new Map(
|
||||
keys.map((key, index) => {
|
||||
const name = shortNames[index];
|
||||
const isDuplicate =
|
||||
shortNames.indexOf(name) !== shortNames.lastIndexOf(name);
|
||||
const needsDomain =
|
||||
isDuplicate || parseEntityType(key).deviceClass === NO_DEVICE_CLASS;
|
||||
return [
|
||||
key,
|
||||
needsDomain ? computeEntityTypeFullName(localize, key) : name,
|
||||
];
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -294,7 +294,7 @@ export const getDurationSelectorUnits = (
|
||||
enableMillisecond: !!config?.enable_millisecond,
|
||||
});
|
||||
|
||||
interface EntitySelectorFilter {
|
||||
export interface EntitySelectorFilter {
|
||||
integration?: string;
|
||||
domain?: string | readonly string[];
|
||||
device_class?: string | readonly string[];
|
||||
@@ -694,6 +694,7 @@ export interface EntityNameSelector {
|
||||
entity_name: {
|
||||
entity_id?: string;
|
||||
default_name?: EntityNameItem | EntityNameItem[] | string;
|
||||
allow_custom?: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -481,27 +481,39 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
return html`
|
||||
${
|
||||
!this.hideName && this._device
|
||||
? html`<ha-row-item>
|
||||
? html`<ha-row-item class="main-entity">
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.use_device_name"
|
||||
)}
|
||||
(${computeDeviceNameDisplay(this._device, this.hass.localize)})</span
|
||||
"ui.dialogs.entity_registry.editor.main_entity",
|
||||
{
|
||||
device: computeDeviceNameDisplay(
|
||||
this._device,
|
||||
this.hass.localize
|
||||
),
|
||||
}
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.change_device_settings",
|
||||
{
|
||||
link: html`<button
|
||||
class="link"
|
||||
@click=${this._openDeviceSettings}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.change_device_name_link"
|
||||
)}
|
||||
</button>`,
|
||||
}
|
||||
)}</span
|
||||
"ui.dialogs.entity_registry.editor.main_entity_description"
|
||||
)}
|
||||
${
|
||||
this._useDeviceName
|
||||
? this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.change_device_settings",
|
||||
{
|
||||
link: html`<button
|
||||
class="link"
|
||||
@click=${this._openDeviceSettings}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.change_device_name_link"
|
||||
)}
|
||||
</button>`,
|
||||
}
|
||||
)
|
||||
: nothing
|
||||
}</span
|
||||
>
|
||||
<ha-switch
|
||||
slot="end"
|
||||
@@ -1912,6 +1924,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
ha-row-item.main-entity::part(supporting-text),
|
||||
ha-row-item.device-area::part(supporting-text) {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { mdiAutoFix, mdiContentPaste, mdiViewGridPlusOutline } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import deepClone from "deep-clone-simple";
|
||||
import { consume } from "../../../../common/decorators/consume";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
import "../../../../components/ha-adaptive-dialog";
|
||||
import "../../../../components/ha-icon-next";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/item/ha-list-item-button";
|
||||
import "../../../../components/list/ha-list-base";
|
||||
import { internationalizationContext } from "../../../../data/context";
|
||||
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
|
||||
import { DialogMixin } from "../../../../dialogs/dialog-mixin";
|
||||
import { generateDefaultSection } from "../../views/default-section";
|
||||
import { addSection } from "../config-util";
|
||||
import type { CreateSectionDialogParams } from "./show-create-section-dialog";
|
||||
import { showSelectSectionStrategyDialog } from "./show-create-section-dialog";
|
||||
|
||||
@customElement("hui-dialog-create-section")
|
||||
export class HuiDialogCreateSection extends DialogMixin<CreateSectionDialogParams>(
|
||||
LitElement
|
||||
) {
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@storage({
|
||||
key: "dashboardSectionClipboard",
|
||||
state: true,
|
||||
subscribe: true,
|
||||
storage: "sessionStorage",
|
||||
})
|
||||
private _clipboard?: LovelaceSectionRawConfig;
|
||||
|
||||
protected render() {
|
||||
if (!this.params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const localize = this._i18n.localize;
|
||||
|
||||
return html`
|
||||
<ha-adaptive-dialog
|
||||
open
|
||||
header-title=${localize("ui.panel.lovelace.editor.create_section.header")}
|
||||
>
|
||||
<ha-list-base>
|
||||
<ha-list-item-button @click=${this._createManual}>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiViewGridPlusOutline}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline">
|
||||
${localize("ui.panel.lovelace.editor.create_section.manual")}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${localize(
|
||||
"ui.panel.lovelace.editor.create_section.manual_description"
|
||||
)}
|
||||
</span>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button @click=${this._pickStrategy}>
|
||||
<ha-svg-icon slot="start" .path=${mdiAutoFix}></ha-svg-icon>
|
||||
<span slot="headline">
|
||||
${localize("ui.panel.lovelace.editor.create_section.automatic")}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${localize(
|
||||
"ui.panel.lovelace.editor.create_section.automatic_description"
|
||||
)}
|
||||
</span>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-list-item-button>
|
||||
${
|
||||
this._clipboard
|
||||
? html`
|
||||
<ha-list-item-button @click=${this._paste}>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiContentPaste}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline">
|
||||
${localize("ui.panel.lovelace.editor.create_section.paste")}
|
||||
</span>
|
||||
<span slot="supporting-text">
|
||||
${localize(
|
||||
"ui.panel.lovelace.editor.create_section.paste_description"
|
||||
)}
|
||||
</span>
|
||||
</ha-list-item-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-list-base>
|
||||
</ha-adaptive-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _createManual() {
|
||||
const { lovelace, containerPath } = this.params!;
|
||||
this.closeDialog();
|
||||
lovelace.saveConfig(
|
||||
addSection(
|
||||
lovelace.config,
|
||||
containerPath,
|
||||
generateDefaultSection(this._i18n.localize, true)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private _pickStrategy() {
|
||||
showSelectSectionStrategyDialog(this, this.params!);
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
private async _paste() {
|
||||
if (!this._clipboard) {
|
||||
return;
|
||||
}
|
||||
const { lovelace, containerPath } = this.params!;
|
||||
const section = deepClone(this._clipboard);
|
||||
this.closeDialog();
|
||||
try {
|
||||
await lovelace.saveConfig(
|
||||
addSection(lovelace.config, containerPath, section)
|
||||
);
|
||||
} catch (_err: unknown) {
|
||||
lovelace.showToast({
|
||||
message: this._i18n.localize(
|
||||
"ui.panel.lovelace.editor.section.paste_error"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-adaptive-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
ha-list-base {
|
||||
--ha-list-padding: 0 0 var(--ha-space-3);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-dialog-create-section": HuiDialogCreateSection;
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
deleteAtPath,
|
||||
getAtPath,
|
||||
getViewPath,
|
||||
insertAtPath,
|
||||
moveAtPath,
|
||||
setAtPath,
|
||||
} from "../lovelace-path";
|
||||
@@ -139,10 +140,9 @@ export class HuiDialogEditSection
|
||||
|
||||
this.lovelace = params.lovelace;
|
||||
|
||||
this._config = getAtPath<LovelaceSectionRawConfig>(
|
||||
params.lovelaceConfig,
|
||||
params.path
|
||||
);
|
||||
this._config =
|
||||
params.newSection ??
|
||||
getAtPath<LovelaceSectionRawConfig>(params.lovelaceConfig, params.path);
|
||||
this._currTab =
|
||||
this._config && isStrategySection(this._config)
|
||||
? "tab-strategy"
|
||||
@@ -152,7 +152,12 @@ export class HuiDialogEditSection
|
||||
params.lovelaceConfig,
|
||||
viewPath
|
||||
);
|
||||
this._initDirtyTracking({ type: "deep" }, this._config);
|
||||
if (params.newSection) {
|
||||
this._initDirtyTracking({ type: "deep" }, {});
|
||||
this._updateDirtyState(params.newSection);
|
||||
} else {
|
||||
this._initDirtyTracking({ type: "deep" }, this._config);
|
||||
}
|
||||
this._defaultPreviewMode =
|
||||
window.innerWidth >= TWO_COLUMNS_MIN_WIDTH ? "desktop" : "mobile";
|
||||
}
|
||||
@@ -174,6 +179,7 @@ export class HuiDialogEditSection
|
||||
this._currTab = TABS[0];
|
||||
this._strategyError = false;
|
||||
this._yamlError = false;
|
||||
this._discardDirtyStateChanges();
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
@@ -265,24 +271,33 @@ export class HuiDialogEditSection
|
||||
@click=${this._enlarge}
|
||||
>${heading}</span
|
||||
>
|
||||
<ha-dropdown
|
||||
slot="headerActionItems"
|
||||
placement="bottom-end"
|
||||
@closed=${stopPropagation}
|
||||
@wa-select=${this._handleAction}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass!.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
<ha-dropdown-item value="move-to-view">
|
||||
<ha-svg-icon slot="icon" .path=${mdiFileMoveOutline}></ha-svg-icon>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.edit_view.move_to_view"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
${
|
||||
this._params.newSection
|
||||
? nothing
|
||||
: html`
|
||||
<ha-dropdown
|
||||
slot="headerActionItems"
|
||||
placement="bottom-end"
|
||||
@closed=${stopPropagation}
|
||||
@wa-select=${this._handleAction}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.label=${this.hass!.localize("ui.common.menu")}
|
||||
.path=${mdiDotsVertical}
|
||||
></ha-icon-button>
|
||||
<ha-dropdown-item value="move-to-view">
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiFileMoveOutline}
|
||||
></ha-svg-icon>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.edit_view.move_to_view"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
`
|
||||
}
|
||||
<div class="content">
|
||||
<div class="element-editor ha-scrollbar">
|
||||
${
|
||||
@@ -679,11 +694,13 @@ export class HuiDialogEditSection
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const newConfig = setAtPath(
|
||||
this._params.lovelaceConfig,
|
||||
this._params.path,
|
||||
this._config
|
||||
);
|
||||
const newConfig = this._params.newSection
|
||||
? insertAtPath(
|
||||
this._params.lovelaceConfig,
|
||||
this._params.path,
|
||||
this._config
|
||||
)
|
||||
: setAtPath(this._params.lovelaceConfig, this._params.path, this._config);
|
||||
|
||||
this._params.saveConfig(newConfig);
|
||||
this._markDirtyStateClean();
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-adaptive-dialog";
|
||||
import "../../../../components/ha-icon-button-arrow-prev";
|
||||
import "../../../../components/ha-ripple";
|
||||
import type { LovelaceStrategySectionConfig } from "../../../../data/lovelace/config/section";
|
||||
import { CUSTOM_TYPE_PREFIX } from "../../../../data/lovelace_custom_cards";
|
||||
import { getCustomStrategiesForType } from "../../../../data/lovelace_custom_strategies";
|
||||
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import "../../sections/hui-section";
|
||||
import { getLovelaceStrategy } from "../../strategies/get-strategy";
|
||||
import type { LovelaceSectionStrategy } from "../../strategies/types";
|
||||
import { getAtPath } from "../lovelace-path";
|
||||
import type { CreateSectionDialogParams } from "./show-create-section-dialog";
|
||||
import { showCreateSectionDialog } from "./show-create-section-dialog";
|
||||
import { showEditSectionDialog } from "./show-edit-section-dialog";
|
||||
|
||||
interface BuiltInSectionStrategy {
|
||||
type: "common-controls" | "device" | "entities";
|
||||
isAvailable?: (hass: HomeAssistant) => boolean;
|
||||
}
|
||||
|
||||
const BUILT_IN_SECTION_STRATEGIES: BuiltInSectionStrategy[] = [
|
||||
{
|
||||
type: "common-controls",
|
||||
isAvailable: (hass) => isComponentLoaded(hass.config, "usage_prediction"),
|
||||
},
|
||||
{
|
||||
type: "device",
|
||||
},
|
||||
{
|
||||
type: "entities",
|
||||
},
|
||||
];
|
||||
|
||||
interface StrategyOption {
|
||||
type: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
@customElement("hui-dialog-select-section-strategy")
|
||||
export class HuiDialogSelectSectionStrategy
|
||||
extends LitElement
|
||||
implements HassDialog<CreateSectionDialogParams>
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: CreateSectionDialogParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _previews: Record<string, LovelaceStrategySectionConfig> =
|
||||
{};
|
||||
|
||||
public showDialog(params: CreateSectionDialogParams): void {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
this._previews = {};
|
||||
this._loadPreviews(params);
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
this._open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
this._previews = {};
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
private _availableBuiltInTypes() {
|
||||
return BUILT_IN_SECTION_STRATEGIES.filter(
|
||||
(strategy) => strategy.isAvailable?.(this.hass) ?? true
|
||||
).map((strategy) => strategy.type);
|
||||
}
|
||||
|
||||
private _builtInOptions(): StrategyOption[] {
|
||||
return this._availableBuiltInTypes().map((type) => ({
|
||||
type,
|
||||
name: this.hass.localize(
|
||||
`ui.panel.lovelace.editor.section_strategy.${type}.name`
|
||||
),
|
||||
description: this.hass.localize(
|
||||
`ui.panel.lovelace.editor.section_strategy.${type}.description`
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
private _customOptions(): StrategyOption[] {
|
||||
return getCustomStrategiesForType("section").map((strategy) => ({
|
||||
type: CUSTOM_TYPE_PREFIX + strategy.type,
|
||||
name: strategy.name || strategy.type,
|
||||
description: strategy.description || "",
|
||||
}));
|
||||
}
|
||||
|
||||
private async _loadPreviews(params: CreateSectionDialogParams) {
|
||||
const types = [
|
||||
...this._availableBuiltInTypes(),
|
||||
...getCustomStrategiesForType("section").map(
|
||||
(strategy) => CUSTOM_TYPE_PREFIX + strategy.type
|
||||
),
|
||||
];
|
||||
await Promise.all(
|
||||
types.map(async (type) => {
|
||||
try {
|
||||
const strategy = (await getLovelaceStrategy(
|
||||
"section",
|
||||
type
|
||||
)) as LovelaceSectionStrategy;
|
||||
const stubConfig = await strategy.getStubConfig?.(this.hass);
|
||||
if (!stubConfig || this._params !== params) {
|
||||
return;
|
||||
}
|
||||
this._previews = {
|
||||
...this._previews,
|
||||
[type]: { strategy: stubConfig },
|
||||
};
|
||||
} catch (_err: unknown) {
|
||||
// The tile is shown without a preview
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const customOptions = this._customOptions();
|
||||
|
||||
return html`
|
||||
<ha-adaptive-dialog
|
||||
.open=${this._open}
|
||||
width="large"
|
||||
@closed=${this._dialogClosed}
|
||||
header-title=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.select_section_strategy.header"
|
||||
)}
|
||||
>
|
||||
<ha-icon-button-arrow-prev
|
||||
slot="headerNavigationIcon"
|
||||
@click=${this._back}
|
||||
></ha-icon-button-arrow-prev>
|
||||
${
|
||||
customOptions.length
|
||||
? html`
|
||||
<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.select_section_strategy.built_in"
|
||||
)}
|
||||
</h3>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div class="options">
|
||||
${this._builtInOptions().map((option) => this._renderOption(option))}
|
||||
</div>
|
||||
${
|
||||
customOptions.length
|
||||
? html`
|
||||
<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.select_section_strategy.custom"
|
||||
)}
|
||||
</h3>
|
||||
<div class="options">
|
||||
${customOptions.map((option) => this._renderOption(option))}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-adaptive-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderOption(option: StrategyOption) {
|
||||
const preview = this._previews[option.type];
|
||||
return html`
|
||||
<div
|
||||
class="option"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label=${option.name}
|
||||
data-type=${option.type}
|
||||
@click=${this._strategyPicked}
|
||||
@keydown=${this._handleKeyDown}
|
||||
>
|
||||
<div class="header">
|
||||
<h4>${option.name}</h4>
|
||||
<p>${option.description}</p>
|
||||
</div>
|
||||
${
|
||||
preview
|
||||
? html`
|
||||
<div class="preview" inert>
|
||||
<hui-section
|
||||
.hass=${this.hass}
|
||||
.config=${preview}
|
||||
preview
|
||||
></hui-section>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<ha-ripple></ha-ripple>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleKeyDown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
(ev.currentTarget as HTMLElement).click();
|
||||
}
|
||||
}
|
||||
|
||||
private _back() {
|
||||
showCreateSectionDialog(this, this._params!);
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
private _strategyPicked(ev: Event) {
|
||||
const type = (ev.currentTarget as HTMLElement).dataset.type!;
|
||||
const { lovelace, containerPath } = this._params!;
|
||||
const sectionsPath = [...containerPath, "sections"];
|
||||
const sectionCount =
|
||||
getAtPath<unknown[]>(lovelace.config, sectionsPath)?.length ?? 0;
|
||||
|
||||
showEditSectionDialog(this, {
|
||||
lovelace,
|
||||
lovelaceConfig: lovelace.config,
|
||||
saveConfig: (newConfig) => {
|
||||
lovelace.saveConfig(newConfig);
|
||||
},
|
||||
path: [...sectionsPath, sectionCount],
|
||||
newSection: this._previews[type] ?? { strategy: { type } },
|
||||
});
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
h3 {
|
||||
margin: 0 0 var(--ha-space-3) 0;
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.options + h3 {
|
||||
margin-top: var(--ha-space-6);
|
||||
}
|
||||
.options {
|
||||
display: grid;
|
||||
gap: var(--ha-space-4);
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr));
|
||||
}
|
||||
.option {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
.option:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.header {
|
||||
flex: 1;
|
||||
padding: var(--ha-space-3) var(--ha-space-4);
|
||||
}
|
||||
h4 {
|
||||
margin: 0 0 var(--ha-space-1) 0;
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: var(--ha-font-size-m);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.preview {
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
padding: var(--ha-space-3);
|
||||
background: var(--primary-background-color);
|
||||
pointer-events: none;
|
||||
mask-image: linear-gradient(to bottom, black 75%, transparent);
|
||||
}
|
||||
hui-section {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-dialog-select-section-strategy": HuiDialogSelectSectionStrategy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { Lovelace } from "../../types";
|
||||
import type { LovelacePath } from "../lovelace-path";
|
||||
|
||||
export interface CreateSectionDialogParams {
|
||||
lovelace: Lovelace;
|
||||
containerPath: LovelacePath;
|
||||
}
|
||||
|
||||
export const showCreateSectionDialog = (
|
||||
element: HTMLElement,
|
||||
params: CreateSectionDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "hui-dialog-create-section",
|
||||
dialogImport: () => import("./hui-dialog-create-section"),
|
||||
dialogParams: params,
|
||||
});
|
||||
};
|
||||
|
||||
export const showSelectSectionStrategyDialog = (
|
||||
element: HTMLElement,
|
||||
params: CreateSectionDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "hui-dialog-select-section-strategy",
|
||||
dialogImport: () => import("./hui-dialog-select-section-strategy"),
|
||||
dialogParams: params,
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
|
||||
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
|
||||
import type { Lovelace } from "../../types";
|
||||
import type { LovelacePath } from "../lovelace-path";
|
||||
@@ -8,6 +9,7 @@ export interface EditSectionDialogParams {
|
||||
lovelaceConfig: LovelaceConfig;
|
||||
saveConfig: (config: LovelaceConfig) => void;
|
||||
path: LovelacePath;
|
||||
newSection?: LovelaceSectionRawConfig;
|
||||
}
|
||||
|
||||
const importEditSectionDialog = () => import("./hui-dialog-edit-section");
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { ensureArray } from "../../../../common/array/ensure-array";
|
||||
import { getAreasFloorHierarchy } from "../../../../common/areas/areas-floor-hierarchy";
|
||||
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import { computeEntityNameDisplay } from "../../../../common/entity/compute_entity_name_display";
|
||||
import type { EntityNameItem } from "../../../../common/entity/entity_name_config";
|
||||
import { getEntityAreaId } from "../../../../common/entity/context/get_entity_context";
|
||||
import { stringCompare } from "../../../../common/string/compare";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import { NO_DEVICE_CLASS } from "../../../../data/entity/device_class";
|
||||
import {
|
||||
computeEntityTypeNames,
|
||||
entityTypeFilterFunc,
|
||||
entityTypeKey,
|
||||
} from "../../../../data/entity/entity_type";
|
||||
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
|
||||
import { resolveEntityIDs } from "../../../../data/selector";
|
||||
import type { HassServiceTargetWithExclude } from "../../../../data/target";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type {
|
||||
HeadingCardConfig,
|
||||
MarkdownCardConfig,
|
||||
TileCardConfig,
|
||||
} from "../../cards/types";
|
||||
import type {
|
||||
LovelaceStrategyDependency,
|
||||
LovelaceStrategyEditor,
|
||||
} from "../types";
|
||||
|
||||
export const ENTITIES_SECTION_GROUPS = [
|
||||
"none",
|
||||
"area",
|
||||
"floor",
|
||||
"type",
|
||||
] as const;
|
||||
|
||||
export type EntitiesSectionGroup = (typeof ENTITIES_SECTION_GROUPS)[number];
|
||||
|
||||
export interface EntitiesSectionStrategyConfig {
|
||||
type: "entities";
|
||||
title?: string;
|
||||
target?: HassServiceTargetWithExclude;
|
||||
entity_types?: string[];
|
||||
include_config_and_diagnostic?: boolean;
|
||||
group_by?: EntitiesSectionGroup;
|
||||
name?: "" | EntityNameItem | EntityNameItem[];
|
||||
}
|
||||
|
||||
interface EntityGroup {
|
||||
name?: string;
|
||||
entityIds: string[];
|
||||
}
|
||||
|
||||
const TARGET_KEYS = [
|
||||
"entity_id",
|
||||
"device_id",
|
||||
"area_id",
|
||||
"floor_id",
|
||||
"label_id",
|
||||
] as const satisfies readonly (keyof HassServiceTarget)[];
|
||||
|
||||
const hasTargets = (target: HassServiceTarget): boolean =>
|
||||
TARGET_KEYS.some((key) => ensureArray(target[key] ?? []).length > 0);
|
||||
|
||||
const computeTileConfig = (
|
||||
entityId: string,
|
||||
name?: EntitiesSectionStrategyConfig["name"]
|
||||
): TileCardConfig => ({
|
||||
type: "tile",
|
||||
entity: entityId,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
});
|
||||
|
||||
const addToGroup = <T>(groups: Map<T, string[]>, key: T, entityId: string) => {
|
||||
const entityIds = groups.get(key);
|
||||
if (entityIds) {
|
||||
entityIds.push(entityId);
|
||||
return;
|
||||
}
|
||||
groups.set(key, [entityId]);
|
||||
};
|
||||
|
||||
// Groups follow the order of the picked types, each entity going to the type
|
||||
// that picked it: its domain, or its domain and device class.
|
||||
const groupByType = (
|
||||
hass: HomeAssistant,
|
||||
localize: LocalizeFunc,
|
||||
entityIds: string[],
|
||||
types: string[]
|
||||
): EntityGroup[] => {
|
||||
const byType = new Map<string, string[]>();
|
||||
for (const entityId of entityIds) {
|
||||
const domain = computeDomain(entityId);
|
||||
if (types.includes(domain)) {
|
||||
addToGroup(byType, domain, entityId);
|
||||
continue;
|
||||
}
|
||||
const deviceClass =
|
||||
hass.states[entityId]?.attributes.device_class || NO_DEVICE_CLASS;
|
||||
addToGroup(byType, entityTypeKey(domain, deviceClass), entityId);
|
||||
}
|
||||
|
||||
const names = computeEntityTypeNames(localize, types);
|
||||
return types
|
||||
.filter((type) => byType.has(type))
|
||||
.map((type) => ({ name: names.get(type), entityIds: byType.get(type)! }));
|
||||
};
|
||||
|
||||
// Groups follow the floor and area order of the home. Entities without a
|
||||
// known area or floor go to a last group.
|
||||
const groupByPlace = (
|
||||
hass: HomeAssistant,
|
||||
entityIds: string[],
|
||||
groupBy: "area" | "floor"
|
||||
): EntityGroup[] => {
|
||||
const hierarchy = getAreasFloorHierarchy(
|
||||
Object.values(hass.floors),
|
||||
Object.values(hass.areas)
|
||||
);
|
||||
const orderedGroupIds =
|
||||
groupBy === "area"
|
||||
? [
|
||||
...hierarchy.floors.flatMap((floor) => floor.areas),
|
||||
...hierarchy.areas,
|
||||
]
|
||||
: hierarchy.floors.map((floor) => floor.id);
|
||||
const knownGroupIds = new Set(orderedGroupIds);
|
||||
|
||||
const groupIdOf = (entityId: string): string | undefined => {
|
||||
const areaId = getEntityAreaId(entityId, hass.entities, hass.devices);
|
||||
if (!areaId) {
|
||||
return undefined;
|
||||
}
|
||||
const groupId =
|
||||
groupBy === "area" ? areaId : (hass.areas[areaId]?.floor_id ?? undefined);
|
||||
return groupId && knownGroupIds.has(groupId) ? groupId : undefined;
|
||||
};
|
||||
|
||||
const byGroup = new Map<string | undefined, string[]>();
|
||||
for (const entityId of entityIds) {
|
||||
addToGroup(byGroup, groupIdOf(entityId), entityId);
|
||||
}
|
||||
|
||||
const groupName = (groupId: string) =>
|
||||
groupBy === "area" ? hass.areas[groupId]?.name : hass.floors[groupId]?.name;
|
||||
|
||||
const groups: EntityGroup[] = orderedGroupIds
|
||||
.filter((groupId) => byGroup.has(groupId))
|
||||
.map((groupId) => ({
|
||||
name: groupName(groupId),
|
||||
entityIds: byGroup.get(groupId)!,
|
||||
}));
|
||||
|
||||
const ungrouped = byGroup.get(undefined);
|
||||
if (ungrouped) {
|
||||
groups.push({
|
||||
name: hass.localize("ui.panel.lovelace.strategy.entities.other"),
|
||||
entityIds: ungrouped,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
const findAreaWithMostLights = (hass: HomeAssistant): string | undefined => {
|
||||
const lightCountByArea = new Map<string, number>();
|
||||
for (const entry of Object.values(hass.entities)) {
|
||||
if (computeDomain(entry.entity_id) !== "light" || entry.hidden) {
|
||||
continue;
|
||||
}
|
||||
const areaId = getEntityAreaId(
|
||||
entry.entity_id,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
);
|
||||
if (areaId) {
|
||||
lightCountByArea.set(areaId, (lightCountByArea.get(areaId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
let bestAreaId: string | undefined;
|
||||
let bestCount = 0;
|
||||
for (const [areaId, count] of lightCountByArea) {
|
||||
if (count > bestCount) {
|
||||
bestAreaId = areaId;
|
||||
bestCount = count;
|
||||
}
|
||||
}
|
||||
return bestAreaId;
|
||||
};
|
||||
|
||||
@customElement("entities-section-strategy")
|
||||
export class EntitiesSectionStrategy extends ReactiveElement {
|
||||
static registryDependencies: readonly LovelaceStrategyDependency[] = [
|
||||
"entities",
|
||||
"devices",
|
||||
"areas",
|
||||
"floors",
|
||||
];
|
||||
|
||||
public static async getConfigElement(): Promise<LovelaceStrategyEditor> {
|
||||
await import("./hui-entities-section-strategy-editor");
|
||||
return document.createElement("hui-entities-section-strategy-editor");
|
||||
}
|
||||
|
||||
public static getStubConfig(
|
||||
hass: HomeAssistant
|
||||
): EntitiesSectionStrategyConfig {
|
||||
const areaId = findAreaWithMostLights(hass) ?? Object.keys(hass.areas)[0];
|
||||
const config: EntitiesSectionStrategyConfig = {
|
||||
type: "entities",
|
||||
entity_types: ["light"],
|
||||
};
|
||||
if (areaId) {
|
||||
return { ...config, target: { area_id: areaId } };
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
static async generate(
|
||||
config: EntitiesSectionStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceSectionConfig> {
|
||||
const section: LovelaceSectionConfig = {
|
||||
type: "grid",
|
||||
cards: [],
|
||||
};
|
||||
|
||||
if (config.title) {
|
||||
section.cards!.push({
|
||||
type: "heading",
|
||||
heading: config.title,
|
||||
} satisfies HeadingCardConfig);
|
||||
}
|
||||
|
||||
if (
|
||||
!config.target ||
|
||||
!hasTargets(config.target) ||
|
||||
!config.entity_types?.length
|
||||
) {
|
||||
section.cards!.push({
|
||||
type: "markdown",
|
||||
content: hass.localize(
|
||||
"ui.panel.lovelace.strategy.entities.no_criteria"
|
||||
),
|
||||
} satisfies MarkdownCardConfig);
|
||||
return section;
|
||||
}
|
||||
|
||||
const explicitEntityIds = new Set(
|
||||
ensureArray(config.target?.entity_id ?? [])
|
||||
);
|
||||
const targetLabelIds = ensureArray(config.target?.label_id ?? []);
|
||||
const matchesType = entityTypeFilterFunc(config.entity_types, hass.states);
|
||||
|
||||
const isShown = (entityId: string) => {
|
||||
if (explicitEntityIds.has(entityId)) {
|
||||
return true;
|
||||
}
|
||||
const entry = hass.entities[entityId];
|
||||
if (!entry) {
|
||||
return true;
|
||||
}
|
||||
if (entry.hidden) {
|
||||
return false;
|
||||
}
|
||||
if (config.include_config_and_diagnostic) {
|
||||
return true;
|
||||
}
|
||||
const directlyLabelled = targetLabelIds.some((labelId) =>
|
||||
entry.labels.includes(labelId)
|
||||
);
|
||||
return directlyLabelled || !entry.entity_category;
|
||||
};
|
||||
|
||||
const shownEntityIds = resolveEntityIDs(
|
||||
hass,
|
||||
config.target,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas
|
||||
)
|
||||
.filter(matchesType)
|
||||
.filter(isShown);
|
||||
|
||||
// An empty name hides the name on the tiles, which are then sorted by
|
||||
// their default name.
|
||||
const names = new Map(
|
||||
shownEntityIds.map((entityId) => {
|
||||
const stateObj = hass.states[entityId];
|
||||
const name = stateObj
|
||||
? computeEntityNameDisplay(stateObj, config.name || undefined, hass)
|
||||
: entityId;
|
||||
return [entityId, name];
|
||||
})
|
||||
);
|
||||
const entityIds = shownEntityIds.sort((entityId1, entityId2) =>
|
||||
stringCompare(
|
||||
names.get(entityId1)!,
|
||||
names.get(entityId2)!,
|
||||
hass.locale.language
|
||||
)
|
||||
);
|
||||
|
||||
if (!entityIds.length) {
|
||||
section.cards!.push({
|
||||
type: "markdown",
|
||||
content: hass.localize(
|
||||
"ui.panel.lovelace.strategy.entities.no_entities"
|
||||
),
|
||||
} satisfies MarkdownCardConfig);
|
||||
return section;
|
||||
}
|
||||
|
||||
const groupBy = config.group_by ?? "none";
|
||||
let groups: EntityGroup[] = [{ entityIds }];
|
||||
if (groupBy === "type") {
|
||||
const localize = await hass.loadBackendTranslation("title");
|
||||
groups = groupByType(hass, localize, entityIds, config.entity_types);
|
||||
} else if (groupBy !== "none") {
|
||||
groups = groupByPlace(hass, entityIds, groupBy);
|
||||
}
|
||||
const showGroupNames = groups.length > 1;
|
||||
|
||||
for (const group of groups) {
|
||||
if (showGroupNames && group.name) {
|
||||
section.cards!.push({
|
||||
type: "heading",
|
||||
heading_style: "subtitle",
|
||||
heading: group.name,
|
||||
} satisfies HeadingCardConfig);
|
||||
}
|
||||
section.cards!.push(
|
||||
...group.entityIds.map((entityId) =>
|
||||
computeTileConfig(entityId, config.name)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return section;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"entities-section-strategy": EntitiesSectionStrategy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import "../../../../components/ha-entity-type-picker";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
} from "../../../../components/ha-form/types";
|
||||
import { parseEntityType } from "../../../../data/entity/entity_type";
|
||||
import type { EntitySelectorFilter } from "../../../../data/selector";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { LovelaceStrategyEditor } from "../types";
|
||||
import {
|
||||
ENTITIES_SECTION_GROUPS,
|
||||
type EntitiesSectionStrategyConfig,
|
||||
} from "./entities-section-strategy";
|
||||
|
||||
const TITLE_SCHEMA = [
|
||||
{ name: "title", selector: { text: {} } },
|
||||
] as const satisfies readonly HaFormSchema[];
|
||||
|
||||
const computeEntityFilters = (
|
||||
types: string[] | undefined
|
||||
): EntitySelectorFilter[] | undefined =>
|
||||
types?.map((key) => {
|
||||
const { domain, deviceClass } = parseEntityType(key);
|
||||
if (!deviceClass) {
|
||||
return { domain };
|
||||
}
|
||||
return { domain, device_class: deviceClass };
|
||||
});
|
||||
|
||||
@customElement("hui-entities-section-strategy-editor")
|
||||
export class HuiEntitiesSectionStrategyEditor
|
||||
extends LitElement
|
||||
implements LovelaceStrategyEditor
|
||||
{
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@state() @consumeLocalize() private _localize!: LocalizeFunc;
|
||||
|
||||
@state() private _config?: EntitiesSectionStrategyConfig;
|
||||
|
||||
public setConfig(config: EntitiesSectionStrategyConfig): void {
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
types: string[] | undefined,
|
||||
includeConfigAndDiagnostic: boolean
|
||||
) =>
|
||||
[
|
||||
{
|
||||
name: "include_config_and_diagnostic",
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "target",
|
||||
required: true,
|
||||
selector: {
|
||||
target: {
|
||||
entity: computeEntityFilters(types),
|
||||
primary_entities_only: !includeConfigAndDiagnostic,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
selector: {
|
||||
entity_name: {
|
||||
allow_custom: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "group_by",
|
||||
required: true,
|
||||
selector: {
|
||||
select: {
|
||||
mode: "dropdown",
|
||||
options: ENTITIES_SECTION_GROUPS.map((group) => ({
|
||||
value: group,
|
||||
label: localize(
|
||||
`ui.panel.lovelace.editor.strategy.entities.group_by_options.${group}`
|
||||
),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[]
|
||||
);
|
||||
|
||||
protected render() {
|
||||
if (!this._config) return nothing;
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._config}
|
||||
.schema=${TITLE_SCHEMA}
|
||||
.computeLabel=${this._computeLabel}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
<ha-entity-type-picker
|
||||
.label=${this._localize(
|
||||
"ui.panel.lovelace.editor.strategy.entities.entity_types"
|
||||
)}
|
||||
.value=${this._config.entity_types}
|
||||
@value-changed=${this._typesChanged}
|
||||
></ha-entity-type-picker>
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${{ group_by: "none", ...this._config }}
|
||||
.schema=${this._schema(
|
||||
this._localize,
|
||||
this._config.entity_types,
|
||||
Boolean(this._config.include_config_and_diagnostic)
|
||||
)}
|
||||
.computeLabel=${this._computeLabel}
|
||||
.computeHelper=${this._computeHelper}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
`;
|
||||
}
|
||||
|
||||
private _computeLabel = (
|
||||
schema:
|
||||
| SchemaUnion<typeof TITLE_SCHEMA>
|
||||
| SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) =>
|
||||
this._localize(`ui.panel.lovelace.editor.strategy.entities.${schema.name}`);
|
||||
|
||||
private _computeHelper = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) =>
|
||||
schema.name === "include_config_and_diagnostic"
|
||||
? this._localize(
|
||||
`ui.panel.lovelace.editor.strategy.entities.${schema.name}_helper`
|
||||
)
|
||||
: "";
|
||||
|
||||
private _typesChanged(ev: ValueChangedEvent<string[]>) {
|
||||
ev.stopPropagation();
|
||||
const config = { ...this._config! };
|
||||
if (ev.detail.value.length) {
|
||||
config.entity_types = ev.detail.value;
|
||||
} else {
|
||||
delete config.entity_types;
|
||||
}
|
||||
fireEvent(this, "config-changed", { config });
|
||||
}
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<EntitiesSectionStrategyConfig>) {
|
||||
ev.stopPropagation();
|
||||
const config = { ...this._config, ...ev.detail.value };
|
||||
if (config.group_by === "none") {
|
||||
delete config.group_by;
|
||||
}
|
||||
fireEvent(this, "config-changed", { config });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-entity-type-picker {
|
||||
margin: var(--ha-space-6) 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-entities-section-strategy-editor": HuiEntitiesSectionStrategyEditor;
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
|
||||
"common-controls": () =>
|
||||
import("./usage_prediction/common-controls-section-strategy"),
|
||||
device: () => import("./device/device-section-strategy"),
|
||||
entities: () => import("./entities/entities-section-strategy"),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -35,7 +35,11 @@ export type LovelaceDashboardStrategyGetCreateSuggestions = (
|
||||
|
||||
export interface LovelaceViewStrategy extends LovelaceStrategy<LovelaceViewConfig> {}
|
||||
|
||||
export interface LovelaceSectionStrategy extends LovelaceStrategy<LovelaceSectionConfig> {}
|
||||
export interface LovelaceSectionStrategy extends LovelaceStrategy<LovelaceSectionConfig> {
|
||||
getStubConfig?(
|
||||
hass: HomeAssistant
|
||||
): LovelaceStrategyConfig | Promise<LovelaceStrategyConfig>;
|
||||
}
|
||||
|
||||
export interface LovelaceStrategyEditor extends LovelaceGenericElementEditor {
|
||||
setConfig(config: LovelaceStrategyConfig): void;
|
||||
|
||||
@@ -40,6 +40,10 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
|
||||
);
|
||||
}
|
||||
|
||||
public static getStubConfig(): CommonControlsSectionStrategyConfig {
|
||||
return { type: "common-controls" };
|
||||
}
|
||||
|
||||
static async generate(
|
||||
config: CommonControlsSectionStrategyConfig,
|
||||
hass: HomeAssistant
|
||||
|
||||
@@ -30,6 +30,7 @@ import "../components/hui-section-edit-mode";
|
||||
import { addSection } from "../editor/config-util";
|
||||
import type { LovelacePath } from "../editor/lovelace-path";
|
||||
import { getAtPath, moveAtPath } from "../editor/lovelace-path";
|
||||
import { showCreateSectionDialog } from "../editor/section-editor/show-create-section-dialog";
|
||||
import type { HuiSection } from "../sections/hui-section";
|
||||
import {
|
||||
renderSectionContainer,
|
||||
@@ -462,12 +463,10 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
}
|
||||
|
||||
private _createSection(): void {
|
||||
const newConfig = addSection(
|
||||
this.lovelace!.config,
|
||||
this.path!,
|
||||
generateDefaultSection(this.hass.localize, true)
|
||||
);
|
||||
this.lovelace!.saveConfig(newConfig);
|
||||
showCreateSectionDialog(this, {
|
||||
lovelace: this.lovelace!,
|
||||
containerPath: this.path!,
|
||||
});
|
||||
}
|
||||
|
||||
private _sectionMoved(ev: CustomEvent) {
|
||||
|
||||
@@ -942,6 +942,10 @@
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse"
|
||||
},
|
||||
"entity-type-picker": {
|
||||
"add": "Add type",
|
||||
"no_types": "No types available"
|
||||
},
|
||||
"subpage-data-table": {
|
||||
"filters": "Filters",
|
||||
"show_results": "Show {number} results",
|
||||
@@ -2147,7 +2151,8 @@
|
||||
"faq": "documentation",
|
||||
"editor": {
|
||||
"name": "Name",
|
||||
"use_device_name": "Use device name",
|
||||
"main_entity": "Main entity of the device ({device})",
|
||||
"main_entity_description": "Main entities are shown with the device name only.",
|
||||
"change_device_name_link": "change the device name",
|
||||
"restore_name": "Restore default name",
|
||||
"icon": "Icon",
|
||||
@@ -2258,8 +2263,8 @@
|
||||
"note": "Note: This might not work yet with all integrations.",
|
||||
"use_device_area": "Use device area",
|
||||
"use_device_area_confirm_title": "Use the area of the device?",
|
||||
"use_device_area_confirm_text": "This entity is in {area}. Because it has no name of its own, it will use the area of its device instead.",
|
||||
"use_device_area_required": "An entity without its own name always uses the area of its device. You can {link} in the device settings.",
|
||||
"use_device_area_confirm_text": "This entity is in {area}. As a main entity, it will use the area of its device instead.",
|
||||
"use_device_area_required": "A main entity always uses the area of its device. You can {link} in the device settings.",
|
||||
"change_device_settings": "You can {link} in the device settings",
|
||||
"change_device_area_link": "change the device area",
|
||||
"configure_state": "{integration} options",
|
||||
@@ -9877,6 +9882,11 @@
|
||||
"diagnostic": "[%key:ui::panel::config::devices::entities::diagnostic%]"
|
||||
}
|
||||
},
|
||||
"entities": {
|
||||
"other": "Other",
|
||||
"no_criteria": "Pick at least one type and one target.",
|
||||
"no_entities": "No entities match this section."
|
||||
},
|
||||
"light": {
|
||||
"lights": "Lights",
|
||||
"other_lights": "Other lights",
|
||||
@@ -10474,9 +10484,32 @@
|
||||
},
|
||||
"section_strategy": {
|
||||
"common-controls": {
|
||||
"name": "Common controls"
|
||||
"name": "Common controls",
|
||||
"description": "Shows the entities you use most, based on your activity."
|
||||
},
|
||||
"device": {
|
||||
"name": "Device",
|
||||
"description": "Shows the controls and sensors of a device."
|
||||
},
|
||||
"entities": {
|
||||
"name": "Entities",
|
||||
"description": "Shows the entities of the types you pick, like all the lights in a room."
|
||||
}
|
||||
},
|
||||
"create_section": {
|
||||
"header": "Create section",
|
||||
"manual": "Manual section",
|
||||
"manual_description": "Start empty and add your own cards.",
|
||||
"automatic": "[%key:ui::panel::lovelace::editor::section::automatic%]",
|
||||
"automatic_description": "Fills itself with cards and stays up to date.",
|
||||
"paste": "Paste section",
|
||||
"paste_description": "Add the section you copied."
|
||||
},
|
||||
"select_section_strategy": {
|
||||
"header": "Choose an automatic section",
|
||||
"built_in": "Built-in sections",
|
||||
"custom": "Community sections"
|
||||
},
|
||||
"edit_section": {
|
||||
"header": "Section configuration",
|
||||
"typed_header": "{type} section configuration",
|
||||
@@ -11967,6 +12000,21 @@
|
||||
"exclude_entities": "Exclude entities",
|
||||
"exclude_entities_helper": "Not shown in this section, even if their category is selected"
|
||||
},
|
||||
"entities": {
|
||||
"title": "Title",
|
||||
"entity_types": "Types to show",
|
||||
"target": "[%key:ui::panel::config::automation::editor::targets%]",
|
||||
"group_by": "Group by",
|
||||
"name": "Display name",
|
||||
"group_by_options": {
|
||||
"none": "No grouping",
|
||||
"area": "Area",
|
||||
"floor": "Floor",
|
||||
"type": "Type"
|
||||
},
|
||||
"include_config_and_diagnostic": "Include configuration and diagnostic entities",
|
||||
"include_config_and_diagnostic_helper": "By default, these only show when you add them as entities or through a label"
|
||||
},
|
||||
"original-states": {
|
||||
"areas": "Areas to display",
|
||||
"hide_entities_without_area": "Hide entities without area",
|
||||
|
||||
Reference in new issue
Block a user