mirror of
https://github.com/home-assistant/frontend.git
synced 2025-09-05 03:06:40 +00:00
Compare commits
17 Commits
move-defau
...
20250528.0
Author | SHA1 | Date | |
---|---|---|---|
![]() |
46e05f10d1 | ||
![]() |
a1819d6189 | ||
![]() |
48a3e1fd63 | ||
![]() |
139c8b3702 | ||
![]() |
9458946dcc | ||
![]() |
b907dbefad | ||
![]() |
5371fd649c | ||
![]() |
61d9b0d2a3 | ||
![]() |
06270c771f | ||
![]() |
ae49de8e71 | ||
![]() |
6abdeeae20 | ||
![]() |
116716c51d | ||
![]() |
77ee69b64d | ||
![]() |
1a57eeddde | ||
![]() |
9131bf6dfd | ||
![]() |
1611423ca5 | ||
![]() |
de56c3376e |
@@ -1,7 +1,30 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
let changeFunction;
|
||||
|
||||
export const mockFrontend = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("frontend/get_user_data", () => ({
|
||||
value: null,
|
||||
}));
|
||||
hass.mockWS("frontend/set_user_data", ({ key, value }) => {
|
||||
if (key === "sidebar") {
|
||||
changeFunction?.({
|
||||
value: {
|
||||
panelOrder: value.panelOrder || [],
|
||||
hiddenPanels: value.hiddenPanels || [],
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
hass.mockWS("frontend/subscribe_user_data", (_msg, _hass, onChange) => {
|
||||
changeFunction = onChange;
|
||||
onChange?.({
|
||||
value: {
|
||||
panelOrder: [],
|
||||
hiddenPanels: [],
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return () => {};
|
||||
});
|
||||
};
|
||||
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20250430.0"
|
||||
version = "20250528.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
4
src/common/entity/valid_service_id.ts
Normal file
4
src/common/entity/valid_service_id.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
const validServiceId = /^(\w+)\.(\w+)$/;
|
||||
|
||||
export const isValidServiceId = (actionId: string) =>
|
||||
validServiceId.test(actionId);
|
@@ -220,11 +220,11 @@ export class HaChartBase extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
const datasets = ensureArray(this.data);
|
||||
const items = (legend.data ||
|
||||
datasets
|
||||
const items: LegendComponentOption["data"] =
|
||||
legend.data ||
|
||||
((datasets
|
||||
.filter((d) => (d.data as any[])?.length && (d.id || d.name))
|
||||
.map((d) => d.name ?? d.id) ||
|
||||
[]) as string[];
|
||||
.map((d) => d.name ?? d.id) || []) as string[]);
|
||||
|
||||
const isMobile = window.matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
@@ -239,20 +239,32 @@ export class HaChartBase extends LitElement {
|
||||
})}
|
||||
>
|
||||
<ul>
|
||||
${items.map((item: string, index: number) => {
|
||||
${items.map((item, index) => {
|
||||
if (!this.expandLegend && index >= overflowLimit) {
|
||||
return nothing;
|
||||
}
|
||||
const dataset = datasets.find(
|
||||
(d) => d.id === item || d.name === item
|
||||
);
|
||||
const color = dataset?.color as string;
|
||||
const borderColor = dataset?.itemStyle?.borderColor as string;
|
||||
let itemStyle: Record<string, any> = {};
|
||||
let name = "";
|
||||
if (typeof item === "string") {
|
||||
name = item;
|
||||
const dataset = datasets.find(
|
||||
(d) => d.id === item || d.name === item
|
||||
);
|
||||
itemStyle = {
|
||||
color: dataset?.color as string,
|
||||
...(dataset?.itemStyle as { borderColor?: string }),
|
||||
};
|
||||
} else {
|
||||
name = item.name ?? "";
|
||||
itemStyle = item.itemStyle ?? {};
|
||||
}
|
||||
const color = itemStyle?.color as string;
|
||||
const borderColor = itemStyle?.borderColor as string;
|
||||
return html`<li
|
||||
.name=${item}
|
||||
.name=${name}
|
||||
@click=${this._legendClick}
|
||||
class=${classMap({ hidden: this._hiddenDatasets.has(item) })}
|
||||
.title=${item}
|
||||
class=${classMap({ hidden: this._hiddenDatasets.has(name) })}
|
||||
.title=${name}
|
||||
>
|
||||
<div
|
||||
class="bullet"
|
||||
@@ -261,7 +273,7 @@ export class HaChartBase extends LitElement {
|
||||
borderColor: borderColor || color,
|
||||
})}
|
||||
></div>
|
||||
<div class="label">${item}</div>
|
||||
<div class="label">${name}</div>
|
||||
</li>`;
|
||||
})}
|
||||
${items.length > overflowLimit
|
||||
|
@@ -82,6 +82,8 @@ export class StateHistoryChartLine extends LitElement {
|
||||
|
||||
private _chartTime: Date = new Date();
|
||||
|
||||
private _previousYAxisLabelValue = 0;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-chart-base
|
||||
@@ -258,32 +260,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
},
|
||||
axisLabel: {
|
||||
margin: 5,
|
||||
formatter: (value: number) => {
|
||||
const formatOptions =
|
||||
value >= 1 || value <= -1
|
||||
? undefined
|
||||
: {
|
||||
// show the first significant digit for tiny values
|
||||
maximumFractionDigits: Math.max(
|
||||
2,
|
||||
-Math.floor(Math.log10(Math.abs(value % 1 || 1)))
|
||||
),
|
||||
};
|
||||
const label = formatNumber(
|
||||
value,
|
||||
this.hass.locale,
|
||||
formatOptions
|
||||
);
|
||||
const width = measureTextWidth(label, 12) + 5;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
return label;
|
||||
},
|
||||
formatter: this._formatYAxisLabel,
|
||||
},
|
||||
} as YAXisOption,
|
||||
legend: {
|
||||
@@ -745,6 +722,33 @@ export class StateHistoryChartLine extends LitElement {
|
||||
this._visualMap = visualMap.length > 0 ? visualMap : undefined;
|
||||
}
|
||||
|
||||
private _formatYAxisLabel = (value: number) => {
|
||||
const formatOptions =
|
||||
value >= 1 || value <= -1
|
||||
? undefined
|
||||
: {
|
||||
// show the first significant digit for tiny values
|
||||
maximumFractionDigits: Math.max(
|
||||
2,
|
||||
// use the difference to the previous value to determine the number of significant digits #25526
|
||||
-Math.floor(
|
||||
Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1))
|
||||
)
|
||||
),
|
||||
};
|
||||
const label = formatNumber(value, this.hass.locale, formatOptions);
|
||||
const width = measureTextWidth(label, 12) + 5;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
this._previousYAxisLabelValue = value;
|
||||
return label;
|
||||
};
|
||||
|
||||
private _clampYAxis(value?: number | ((values: any) => number)) {
|
||||
if (this.logarithmicScale) {
|
||||
// log(0) is -Infinity, so we need to set a minimum value
|
||||
|
@@ -23,7 +23,10 @@ import type { HomeAssistant } from "../../types";
|
||||
import "../ha-combo-box-item";
|
||||
import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
|
||||
import type {
|
||||
PickerComboBoxItem,
|
||||
PickerComboBoxSearchFn,
|
||||
} from "../ha-picker-combo-box";
|
||||
import type { PickerValueRenderer } from "../ha-picker-field";
|
||||
import "../ha-svg-icon";
|
||||
import "./state-badge";
|
||||
@@ -51,6 +54,9 @@ export class HaEntityPicker extends LitElement {
|
||||
@property({ type: Boolean, attribute: "allow-custom-entity" })
|
||||
public allowCustomEntity;
|
||||
|
||||
@property({ type: Boolean, attribute: "show-entity-id" })
|
||||
public showEntityId = false;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public value?: string;
|
||||
@@ -166,11 +172,15 @@ export class HaEntityPicker extends LitElement {
|
||||
`;
|
||||
};
|
||||
|
||||
private get _showEntityId() {
|
||||
return this.showEntityId || this.hass.userData?.showEntityIdPicker;
|
||||
}
|
||||
|
||||
private _rowRenderer: ComboBoxLitRenderer<EntityComboBoxItem> = (
|
||||
item,
|
||||
{ index }
|
||||
) => {
|
||||
const showEntityId = this.hass.userData?.showEntityIdPicker;
|
||||
const showEntityId = this._showEntityId;
|
||||
|
||||
return html`
|
||||
<ha-combo-box-item type="button" compact .borderTop=${index !== 0}>
|
||||
@@ -390,6 +400,7 @@ export class HaEntityPicker extends LitElement {
|
||||
.autofocus=${this.autofocus}
|
||||
.allowCustomValue=${this.allowCustomEntity}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.searchLabel=${this.searchLabel}
|
||||
.notFoundLabel=${notFoundLabel}
|
||||
.placeholder=${placeholder}
|
||||
@@ -398,6 +409,7 @@ export class HaEntityPicker extends LitElement {
|
||||
.getItems=${this._getItems}
|
||||
.getAdditionalItems=${this._getAdditionalItems}
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.searchFn=${this._searchFn}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
@@ -405,6 +417,23 @@ export class HaEntityPicker extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _searchFn: PickerComboBoxSearchFn<EntityComboBoxItem> = (
|
||||
search,
|
||||
filteredItems
|
||||
) => {
|
||||
// If there is exact match for entity id, put it first
|
||||
const index = filteredItems.findIndex(
|
||||
(item) => item.stateObj?.entity_id === search
|
||||
);
|
||||
if (index === -1) {
|
||||
return filteredItems;
|
||||
}
|
||||
|
||||
const [exactMatch] = filteredItems.splice(index, 1);
|
||||
filteredItems.unshift(exactMatch);
|
||||
return filteredItems;
|
||||
};
|
||||
|
||||
public async open() {
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
|
@@ -25,7 +25,10 @@ import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-input-helper-text";
|
||||
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
|
||||
import type {
|
||||
PickerComboBoxItem,
|
||||
PickerComboBoxSearchFn,
|
||||
} from "../ha-picker-combo-box";
|
||||
import type { PickerValueRenderer } from "../ha-picker-field";
|
||||
import "../ha-svg-icon";
|
||||
import "./state-badge";
|
||||
@@ -470,6 +473,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
.getItems=${this._getItems}
|
||||
.getAdditionalItems=${this._getAdditionalItems}
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.searchFn=${this._searchFn}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
@@ -477,6 +481,24 @@ export class HaStatisticPicker extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _searchFn: PickerComboBoxSearchFn<StatisticComboBoxItem> = (
|
||||
search,
|
||||
filteredItems
|
||||
) => {
|
||||
// If there is exact match for entity id or statistic id, put it first
|
||||
const index = filteredItems.findIndex(
|
||||
(item) =>
|
||||
item.stateObj?.entity_id === search || item.statistic_id === search
|
||||
);
|
||||
if (index === -1) {
|
||||
return filteredItems;
|
||||
}
|
||||
|
||||
const [exactMatch] = filteredItems.splice(index, 1);
|
||||
filteredItems.unshift(exactMatch);
|
||||
return filteredItems;
|
||||
};
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
@@ -34,6 +34,8 @@ const getWarning = (obj, item) => (obj && item.name ? obj[item.name] : null);
|
||||
export class HaForm extends LitElement implements HaFormElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: false }) public data!: HaFormDataContainer;
|
||||
|
||||
@property({ attribute: false }) public schema!: readonly HaFormSchema[];
|
||||
@@ -135,6 +137,7 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
? html`<ha-selector
|
||||
.schema=${item}
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.name=${item.name}
|
||||
.selector=${item.selector}
|
||||
.value=${getValue(this.data, item)}
|
||||
|
@@ -12,6 +12,7 @@ import "./ha-picker-combo-box";
|
||||
import type {
|
||||
HaPickerComboBox,
|
||||
PickerComboBoxItem,
|
||||
PickerComboBoxSearchFn,
|
||||
} from "./ha-picker-combo-box";
|
||||
import "./ha-picker-field";
|
||||
import type { HaPickerField, PickerValueRenderer } from "./ha-picker-field";
|
||||
@@ -57,6 +58,9 @@ export class HaGenericPicker extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public valueRenderer?: PickerValueRenderer;
|
||||
|
||||
@property({ attribute: false })
|
||||
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
|
||||
|
||||
@property({ attribute: "not-found-label", type: String })
|
||||
public notFoundLabel?: string;
|
||||
|
||||
@@ -102,10 +106,11 @@ export class HaGenericPicker extends LitElement {
|
||||
.notFoundLabel=${this.notFoundLabel}
|
||||
.getItems=${this.getItems}
|
||||
.getAdditionalItems=${this.getAdditionalItems}
|
||||
.searchFn=${this.searchFn}
|
||||
></ha-picker-combo-box>
|
||||
`}
|
||||
${this._renderHelper()}
|
||||
</div>
|
||||
${this._renderHelper()}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -164,6 +169,10 @@ export class HaGenericPicker extends LitElement {
|
||||
display: block;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
ha-input-helper-text {
|
||||
display: block;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
@@ -262,7 +262,7 @@ export class HaItemDisplayEditor extends LitElement {
|
||||
];
|
||||
}
|
||||
|
||||
return items.sort((a, b) =>
|
||||
return visibleItems.sort((a, b) =>
|
||||
a.disableSorting && !b.disableSorting ? -1 : compare(a.value, b.value)
|
||||
);
|
||||
}
|
||||
|
@@ -77,7 +77,7 @@ export class HaMarkdown extends LitElement {
|
||||
pre {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
line-height: 1.45;
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
font-family: var(--ha-font-family-code);
|
||||
}
|
||||
h1,
|
||||
|
@@ -49,6 +49,12 @@ const DEFAULT_ROW_RENDERER: ComboBoxLitRenderer<PickerComboBoxItem> = (
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
export type PickerComboBoxSearchFn<T extends PickerComboBoxItem> = (
|
||||
search: string,
|
||||
filteredItems: T[],
|
||||
allItems: T[]
|
||||
) => T[];
|
||||
|
||||
@customElement("ha-picker-combo-box")
|
||||
export class HaPickerComboBox extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -84,6 +90,9 @@ export class HaPickerComboBox extends LitElement {
|
||||
@property({ attribute: "not-found-label", type: String })
|
||||
public notFoundLabel?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
|
||||
|
||||
@state() private _opened = false;
|
||||
|
||||
@query("ha-combo-box", true) public comboBox!: HaComboBox;
|
||||
@@ -237,6 +246,7 @@ export class HaPickerComboBox extends LitElement {
|
||||
const fuse = new HaFuse(this._items, { shouldSort: false }, index);
|
||||
|
||||
const results = fuse.multiTermsSearch(searchString);
|
||||
let filteredItems = this._items as PickerComboBoxItem[];
|
||||
if (results) {
|
||||
const items = results.map((result) => result.item);
|
||||
if (items.length === 0) {
|
||||
@@ -246,10 +256,14 @@ export class HaPickerComboBox extends LitElement {
|
||||
}
|
||||
const additionalItems = this._getAdditionalItems(searchString);
|
||||
items.push(...additionalItems);
|
||||
target.filteredItems = items;
|
||||
} else {
|
||||
target.filteredItems = this._items;
|
||||
filteredItems = items;
|
||||
}
|
||||
|
||||
if (this.searchFn) {
|
||||
filteredItems = this.searchFn(searchString, filteredItems, this._items);
|
||||
}
|
||||
|
||||
target.filteredItems = filteredItems;
|
||||
}
|
||||
|
||||
private _setValue(value: string | undefined) {
|
||||
|
@@ -95,8 +95,8 @@ export class HaPickerField extends LitElement {
|
||||
border-end-start-radius: 0;
|
||||
--md-list-item-one-line-container-height: 56px;
|
||||
--md-list-item-two-line-container-height: 56px;
|
||||
--md-list-item-top-space: 8px;
|
||||
--md-list-item-bottom-space: 8px;
|
||||
--md-list-item-top-space: 0px;
|
||||
--md-list-item-bottom-space: 0px;
|
||||
--md-list-item-leading-space: 8px;
|
||||
--md-list-item-trailing-space: 8px;
|
||||
--ha-md-list-item-gap: 8px;
|
||||
|
@@ -19,6 +19,8 @@ import { SubscribeMixin } from "../../mixins/subscribe-mixin";
|
||||
export class HaActionSelector extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: false }) public selector!: ActionSelector;
|
||||
|
||||
@property({ attribute: false }) public value?: Action;
|
||||
@@ -66,6 +68,7 @@ export class HaActionSelector extends SubscribeMixin(LitElement) {
|
||||
.disabled=${this.disabled}
|
||||
.actions=${this._actions(this.value)}
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
></ha-automation-action>
|
||||
`;
|
||||
}
|
||||
|
@@ -9,6 +9,8 @@ import type { HomeAssistant } from "../../types";
|
||||
export class HaConditionSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: false }) public selector!: ConditionSelector;
|
||||
|
||||
@property({ attribute: false }) public value?: Condition;
|
||||
@@ -24,6 +26,7 @@ export class HaConditionSelector extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
.conditions=${this.value || []}
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
></ha-automation-condition>
|
||||
`;
|
||||
}
|
||||
|
@@ -11,6 +11,8 @@ import type { HomeAssistant } from "../../types";
|
||||
export class HaTriggerSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: false }) public selector!: TriggerSelector;
|
||||
|
||||
@property({ attribute: false }) public value?: Trigger;
|
||||
@@ -33,6 +35,7 @@ export class HaTriggerSelector extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
.triggers=${this._triggers(this.value)}
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
></ha-automation-trigger>
|
||||
`;
|
||||
}
|
||||
|
@@ -69,6 +69,8 @@ const LEGACY_UI_SELECTORS = new Set(["ui-action", "ui-color"]);
|
||||
export class HaSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property() public name?: string;
|
||||
|
||||
@property({ attribute: false }) public selector!: Selector;
|
||||
@@ -127,6 +129,7 @@ export class HaSelector extends LitElement {
|
||||
return html`
|
||||
${dynamicElement(`ha-selector-${this._type}`, {
|
||||
hass: this.hass,
|
||||
narrow: this.narrow,
|
||||
name: this.name,
|
||||
selector: this._handleLegacySelector(this.selector),
|
||||
value: this.value,
|
||||
|
@@ -85,8 +85,11 @@ export class HaServiceControl extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: "show-advanced", type: Boolean }) public showAdvanced =
|
||||
false;
|
||||
@property({ attribute: "show-advanced", type: Boolean })
|
||||
public showAdvanced = false;
|
||||
|
||||
@property({ attribute: "show-service-id", type: Boolean })
|
||||
public showServiceId = false;
|
||||
|
||||
@property({ attribute: "hide-picker", type: Boolean, reflect: true })
|
||||
public hidePicker = false;
|
||||
@@ -435,6 +438,7 @@ export class HaServiceControl extends LitElement {
|
||||
.value=${this._value?.action}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._serviceChanged}
|
||||
.showServiceId=${this.showServiceId}
|
||||
></ha-service-picker>`}
|
||||
${this.hideDescription
|
||||
? nothing
|
||||
|
@@ -1,15 +1,25 @@
|
||||
import { mdiRoomService } from "@mdi/js";
|
||||
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { html, LitElement, nothing, type TemplateResult } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { isValidServiceId } from "../common/entity/valid_service_id";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { domainToName } from "../data/integration";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-combo-box";
|
||||
import "./ha-combo-box-item";
|
||||
import "./ha-service-icon";
|
||||
import { getServiceIcons } from "../data/icons";
|
||||
import { domainToName } from "../data/integration";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import "./ha-combo-box-item";
|
||||
import "./ha-generic-picker";
|
||||
import type { HaGenericPicker } from "./ha-generic-picker";
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
import type { PickerValueRenderer } from "./ha-picker-field";
|
||||
import "./ha-service-icon";
|
||||
|
||||
interface ServiceComboBoxItem extends PickerComboBoxItem {
|
||||
domain_name?: string;
|
||||
service_id?: string;
|
||||
}
|
||||
|
||||
@customElement("ha-service-picker")
|
||||
class HaServicePicker extends LitElement {
|
||||
@@ -17,66 +27,121 @@ class HaServicePicker extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public placeholder?: string;
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@state() private _filter?: string;
|
||||
@property({ attribute: "show-service-id", type: Boolean })
|
||||
public showServiceId = false;
|
||||
|
||||
protected willUpdate() {
|
||||
if (!this.hasUpdated) {
|
||||
this.hass.loadBackendTranslation("services");
|
||||
getServiceIcons(this.hass);
|
||||
}
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
public async open() {
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private _rowRenderer: ComboBoxLitRenderer<{ service: string; name: string }> =
|
||||
(item) => html`
|
||||
<ha-combo-box-item type="button">
|
||||
<ha-service-icon
|
||||
slot="start"
|
||||
.hass=${this.hass}
|
||||
.service=${item.service}
|
||||
></ha-service-icon>
|
||||
<span slot="headline">${item.name}</span>
|
||||
<span slot="supporting-text"
|
||||
>${item.name === item.service ? "" : item.service}</span
|
||||
>
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
protected firstUpdated(props) {
|
||||
super.firstUpdated(props);
|
||||
this.hass.loadBackendTranslation("services");
|
||||
getServiceIcons(this.hass);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-combo-box
|
||||
private _rowRenderer: ComboBoxLitRenderer<ServiceComboBoxItem> = (
|
||||
item,
|
||||
{ index }
|
||||
) => html`
|
||||
<ha-combo-box-item type="button" border-top .borderTop=${index !== 0}>
|
||||
<ha-service-icon
|
||||
slot="start"
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize("ui.components.service-picker.action")}
|
||||
.filteredItems=${this._filteredServices(
|
||||
this.hass.localize,
|
||||
this.hass.services,
|
||||
this._filter
|
||||
)}
|
||||
.value=${this.value}
|
||||
.disabled=${this.disabled}
|
||||
.renderer=${this._rowRenderer}
|
||||
item-value-path="service"
|
||||
item-label-path="name"
|
||||
.service=${item.id}
|
||||
></ha-service-icon>
|
||||
<span slot="headline">${item.primary}</span>
|
||||
<span slot="supporting-text">${item.secondary}</span>
|
||||
${item.service_id && this.showServiceId
|
||||
? html`<span slot="supporting-text" class="code">
|
||||
${item.service_id}
|
||||
</span>`
|
||||
: nothing}
|
||||
${item.domain_name
|
||||
? html`
|
||||
<div slot="trailing-supporting-text" class="domain">
|
||||
${item.domain_name}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
private _valueRenderer: PickerValueRenderer = (value) => {
|
||||
const serviceId = value;
|
||||
const [domain, service] = serviceId.split(".");
|
||||
|
||||
if (!this.hass.services[domain]?.[service]) {
|
||||
return html`
|
||||
<ha-svg-icon slot="start" .path=${mdiRoomService}></ha-svg-icon>
|
||||
<span slot="headline">${value}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
const serviceName =
|
||||
this.hass.localize(`component.${domain}.services.${service}.name`) ||
|
||||
this.hass.services[domain][service].name ||
|
||||
service;
|
||||
|
||||
return html`
|
||||
<ha-service-icon
|
||||
slot="start"
|
||||
.hass=${this.hass}
|
||||
.service=${serviceId}
|
||||
></ha-service-icon>
|
||||
<span slot="headline">${serviceName}</span>
|
||||
${this.showServiceId
|
||||
? html`<span slot="supporting-text" class="code">${serviceId}</span>`
|
||||
: nothing}
|
||||
`;
|
||||
};
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const placeholder =
|
||||
this.placeholder ??
|
||||
this.hass.localize("ui.components.service-picker.action");
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.autofocus=${this.autofocus}
|
||||
allow-custom-value
|
||||
@filter-changed=${this._filterChanged}
|
||||
.notFoundLabel=${this.hass.localize(
|
||||
"ui.components.service-picker.no_match"
|
||||
)}
|
||||
.label=${this.label}
|
||||
.placeholder=${placeholder}
|
||||
.value=${this.value}
|
||||
.getItems=${this._getItems}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-combo-box>
|
||||
>
|
||||
</ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _getItems = () =>
|
||||
this._services(this.hass.localize, this.hass.services);
|
||||
|
||||
private _services = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
services: HomeAssistant["services"]
|
||||
): {
|
||||
service: string;
|
||||
name: string;
|
||||
}[] => {
|
||||
): ServiceComboBoxItem[] => {
|
||||
if (!services) {
|
||||
return [];
|
||||
}
|
||||
const result: { service: string; name: string }[] = [];
|
||||
const items: ServiceComboBoxItem[] = [];
|
||||
|
||||
Object.keys(services)
|
||||
.sort()
|
||||
@@ -84,56 +149,60 @@ class HaServicePicker extends LitElement {
|
||||
const services_keys = Object.keys(services[domain]).sort();
|
||||
|
||||
for (const service of services_keys) {
|
||||
result.push({
|
||||
service: `${domain}.${service}`,
|
||||
name: `${domainToName(localize, domain)}: ${
|
||||
this.hass.localize(
|
||||
`component.${domain}.services.${service}.name`
|
||||
) ||
|
||||
services[domain][service].name ||
|
||||
service
|
||||
}`,
|
||||
const serviceId = `${domain}.${service}`;
|
||||
const domainName = domainToName(localize, domain);
|
||||
|
||||
const name =
|
||||
this.hass.localize(
|
||||
`component.${domain}.services.${service}.name`
|
||||
) ||
|
||||
services[domain][service].name ||
|
||||
service;
|
||||
|
||||
const description =
|
||||
this.hass.localize(
|
||||
`component.${domain}.services.${service}.description`
|
||||
) || services[domain][service].description;
|
||||
|
||||
items.push({
|
||||
id: serviceId,
|
||||
primary: name,
|
||||
secondary: description,
|
||||
domain_name: domainName,
|
||||
service_id: serviceId,
|
||||
search_labels: [serviceId, domainName, name, description].filter(
|
||||
Boolean
|
||||
),
|
||||
sorting_label: serviceId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
return items;
|
||||
}
|
||||
);
|
||||
|
||||
private _filteredServices = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
services: HomeAssistant["services"],
|
||||
filter?: string
|
||||
) => {
|
||||
if (!services) {
|
||||
return [];
|
||||
}
|
||||
const processedServices = this._services(localize, services);
|
||||
private _valueChanged(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (!filter) {
|
||||
return processedServices;
|
||||
}
|
||||
const split_filter = filter.split(" ");
|
||||
return processedServices.filter((service) => {
|
||||
const lower_service_name = service.name.toLowerCase();
|
||||
const lower_service = service.service.toLowerCase();
|
||||
return split_filter.every(
|
||||
(f) => lower_service_name.includes(f) || lower_service.includes(f)
|
||||
);
|
||||
});
|
||||
if (!value) {
|
||||
this._setValue(undefined);
|
||||
return;
|
||||
}
|
||||
);
|
||||
|
||||
private _filterChanged(ev: CustomEvent): void {
|
||||
this._filter = ev.detail.value.toLowerCase();
|
||||
if (!isValidServiceId(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._setValue(value);
|
||||
}
|
||||
|
||||
private _valueChanged(ev) {
|
||||
this.value = ev.detail.value;
|
||||
private _setValue(value: string | undefined) {
|
||||
this.value = value;
|
||||
|
||||
fireEvent(this, "value-changed", { value });
|
||||
fireEvent(this, "change");
|
||||
fireEvent(this, "value-changed", { value: this.value });
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -368,7 +368,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
|
||||
if (!this._panelOrder || !this._hiddenPanels) {
|
||||
return html`
|
||||
<ha-fade-in .delay=${500}
|
||||
><ha-spinner size="large"></ha-spinner
|
||||
><ha-spinner size="small"></ha-spinner
|
||||
></ha-fade-in>
|
||||
`;
|
||||
}
|
||||
|
@@ -640,6 +640,12 @@ export const mergeHistoryResults = (
|
||||
}
|
||||
|
||||
for (const item of ltsResult.line) {
|
||||
if (item.unit === BLANK_UNIT) {
|
||||
// disabled entities have no unit, so we need to find the unit from the history result
|
||||
item.unit =
|
||||
historyResult.line.find((line) => line.identifier === item.identifier)
|
||||
?.unit ?? BLANK_UNIT;
|
||||
}
|
||||
const key = computeGroupKey(
|
||||
item.unit,
|
||||
item.device_class,
|
||||
|
@@ -349,6 +349,7 @@ class DataEntryFlowDialog extends LitElement {
|
||||
${this._step.type === "form"
|
||||
? html`
|
||||
<step-flow-form
|
||||
narrow
|
||||
.flowConfig=${this._params.flowConfig}
|
||||
.step=${this._step}
|
||||
.hass=${this.hass}
|
||||
|
@@ -27,6 +27,8 @@ import { configFlowContentStyles } from "./styles";
|
||||
class StepFlowForm extends LitElement {
|
||||
@property({ attribute: false }) public flowConfig!: FlowConfig;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: false }) public step!: DataEntryFlowStepForm;
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -63,6 +65,7 @@ class StepFlowForm extends LitElement {
|
||||
: ""}
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.data=${stepData}
|
||||
.disabled=${this._loading}
|
||||
@value-changed=${this._stepDataChanged}
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import { mdiClose, mdiContentPaste, mdiPlus } from "@mdi/js";
|
||||
import type { IFuseOptions } from "fuse.js";
|
||||
import Fuse from "fuse.js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
@@ -46,6 +45,7 @@ import { haStyle, haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { AddAutomationElementDialogParams } from "./show-add-automation-element-dialog";
|
||||
import { PASTE_VALUE } from "./show-add-automation-element-dialog";
|
||||
import { HaFuse } from "../../../resources/fuse";
|
||||
|
||||
const TYPES = {
|
||||
trigger: { groups: TRIGGER_GROUPS, icons: TRIGGER_ICONS },
|
||||
@@ -175,6 +175,40 @@ class DialogAddAutomationElement extends LitElement implements HassDialog {
|
||||
type: AddAutomationElementDialogParams["type"],
|
||||
group: string | undefined,
|
||||
filter: string,
|
||||
domains: Set<string> | undefined,
|
||||
localize: LocalizeFunc,
|
||||
services: HomeAssistant["services"],
|
||||
manifests?: DomainManifestLookup
|
||||
): ListItem[] => {
|
||||
const items = this._items(type, group, localize, services, manifests);
|
||||
|
||||
const index = this._fuseIndex(items);
|
||||
|
||||
const fuse = new HaFuse(
|
||||
items,
|
||||
{ ignoreLocation: true, includeScore: true },
|
||||
index
|
||||
);
|
||||
|
||||
const results = fuse.multiTermsSearch(filter);
|
||||
if (results) {
|
||||
return results.map((result) => result.item);
|
||||
}
|
||||
return this._getGroupItems(
|
||||
type,
|
||||
group,
|
||||
domains,
|
||||
localize,
|
||||
services,
|
||||
manifests
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _items = memoizeOne(
|
||||
(
|
||||
type: AddAutomationElementDialogParams["type"],
|
||||
group: string | undefined,
|
||||
localize: LocalizeFunc,
|
||||
services: HomeAssistant["services"],
|
||||
manifests?: DomainManifestLookup
|
||||
@@ -189,24 +223,17 @@ class DialogAddAutomationElement extends LitElement implements HassDialog {
|
||||
);
|
||||
|
||||
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,
|
||||
ignoreLocation: true,
|
||||
minMatchCharLength: Math.min(filter.length, 2),
|
||||
threshold: 0.2,
|
||||
ignoreDiacritics: true,
|
||||
};
|
||||
const fuse = new Fuse(items, options);
|
||||
return fuse.search(filter).map((result) => result.item);
|
||||
return items;
|
||||
}
|
||||
);
|
||||
|
||||
private _fuseIndex = memoizeOne((items: ListItem[]) =>
|
||||
Fuse.createIndex(["key", "name", "description"], items)
|
||||
);
|
||||
|
||||
private _getGroupItems = memoizeOne(
|
||||
(
|
||||
type: AddAutomationElementDialogParams["type"],
|
||||
@@ -449,6 +476,7 @@ class DialogAddAutomationElement extends LitElement implements HassDialog {
|
||||
this._params.type,
|
||||
this._group,
|
||||
this._filter,
|
||||
this._domains,
|
||||
this.hass.localize,
|
||||
this.hass.services,
|
||||
this._manifests
|
||||
|
@@ -142,6 +142,7 @@ class HaPanelDevAction extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.value=${this._serviceData?.action}
|
||||
@value-changed=${this._serviceChanged}
|
||||
show-service-id
|
||||
></ha-service-picker>
|
||||
<ha-yaml-editor
|
||||
id="yaml-editor"
|
||||
@@ -156,6 +157,7 @@ class HaPanelDevAction extends LitElement {
|
||||
.value=${this._serviceData}
|
||||
.narrow=${this.narrow}
|
||||
show-advanced
|
||||
show-service-id
|
||||
@value-changed=${this._serviceDataChanged}
|
||||
class="card-content"
|
||||
></ha-service-control>
|
||||
|
@@ -130,6 +130,7 @@ class HaPanelDevState extends LitElement {
|
||||
.value=${this._entityId}
|
||||
@value-changed=${this._entityIdChanged}
|
||||
allow-custom-entity
|
||||
show-entity-id
|
||||
></ha-entity-picker>
|
||||
${this._entityId
|
||||
? html`
|
||||
|
@@ -6,6 +6,7 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { BarSeriesOption } from "echarts/charts";
|
||||
import type { LegendComponentOption } from "echarts/components";
|
||||
import { getGraphColorByIndex } from "../../../../common/color/colors";
|
||||
import { getEnergyColor } from "./common/color";
|
||||
import "../../../../components/ha-card";
|
||||
@@ -54,6 +55,8 @@ export class HuiEnergyDevicesDetailGraphCard
|
||||
|
||||
@state() private _data?: EnergyData;
|
||||
|
||||
@state() private _legendData?: LegendComponentOption["data"];
|
||||
|
||||
@state() private _start = startOfToday();
|
||||
|
||||
@state() private _end = endOfToday();
|
||||
@@ -185,6 +188,7 @@ export class HuiEnergyDevicesDetailGraphCard
|
||||
legend: {
|
||||
show: true,
|
||||
type: "custom",
|
||||
data: this._legendData,
|
||||
selected: this._hiddenStats.reduce((acc, stat) => {
|
||||
acc[stat] = false;
|
||||
return acc;
|
||||
@@ -310,6 +314,13 @@ export class HuiEnergyDevicesDetailGraphCard
|
||||
);
|
||||
|
||||
datasets.push(...processedData);
|
||||
this._legendData = processedData.map((d) => ({
|
||||
name: d.name as string,
|
||||
itemStyle: {
|
||||
color: d.color as string,
|
||||
borderColor: d.itemStyle?.borderColor as string,
|
||||
},
|
||||
}));
|
||||
|
||||
if (showUntracked) {
|
||||
const untrackedData = this._processUntracked(
|
||||
@@ -319,6 +330,13 @@ export class HuiEnergyDevicesDetailGraphCard
|
||||
false
|
||||
);
|
||||
datasets.push(untrackedData);
|
||||
this._legendData.push({
|
||||
name: untrackedData.name as string,
|
||||
itemStyle: {
|
||||
color: untrackedData.color as string,
|
||||
borderColor: untrackedData.itemStyle?.borderColor as string,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
fillDataGapsAndRoundCaps(datasets);
|
||||
|
@@ -224,19 +224,19 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
|
||||
filter: colored ? stateColorBrightness(stateObj) : undefined,
|
||||
height: this._config.icon_height
|
||||
? this._config.icon_height
|
||||
: "",
|
||||
: undefined,
|
||||
})}
|
||||
></ha-state-icon>
|
||||
`
|
||||
: ""}
|
||||
: nothing}
|
||||
${this._config.show_name
|
||||
? html`<span tabindex="-1" .title=${name}>${name}</span>`
|
||||
: ""}
|
||||
: nothing}
|
||||
${this._config.show_state && stateObj
|
||||
? html`<span class="state">
|
||||
${this.hass.formatEntityState(stateObj)}
|
||||
</span>`
|
||||
: ""}
|
||||
: nothing}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
@@ -282,7 +282,8 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 4% 0;
|
||||
font-size: 16.8px;
|
||||
font-size: var(--ha-font-size-l);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
|
@@ -873,7 +873,8 @@
|
||||
}
|
||||
},
|
||||
"service-picker": {
|
||||
"action": "Action"
|
||||
"action": "Action",
|
||||
"no_match": "No matching actions found"
|
||||
},
|
||||
"service-control": {
|
||||
"required": "This field is required",
|
||||
@@ -5131,7 +5132,7 @@
|
||||
"restore_entity_id_selected": {
|
||||
"button": "Recreate entity IDs of selected",
|
||||
"confirm_title": "Recreate entity IDs?",
|
||||
"confirm_text": "Are you sure you want to change the entity IDs of these entities? You will have to change you dashboards, automations and scripts to use the new entity IDs.",
|
||||
"confirm_text": "Are you sure you want to change the entity IDs of these entities? You will have to change your dashboards, automations and scripts to use the new entity IDs.",
|
||||
"changes": "The following entity IDs will be updated:"
|
||||
},
|
||||
"delete_selected": {
|
||||
|
Reference in New Issue
Block a user