mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-20 10:06:51 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1a15ae674 | |||
| b421a96eb0 | |||
| 7dbfb2f9d2 | |||
| c68cd67cbd | |||
| 6de9841b6e | |||
| 35193447bf | |||
| 650444e92d | |||
| da9a78f752 | |||
| 9077290b39 | |||
| e4c6ea29b3 | |||
| aa57c27739 | |||
| 420137478e | |||
| 25bc81cf1f | |||
| 6b0f543fdb |
@@ -13,7 +13,7 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: ${{ inputs.cache == 'true' && 'yarn' || '' }}
|
||||
|
||||
@@ -15,6 +15,10 @@ updates:
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
codeql-action:
|
||||
patterns:
|
||||
- "github/codeql-action/*"
|
||||
labels:
|
||||
- Dependencies
|
||||
- GitHub Actions
|
||||
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -10,6 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply labels
|
||||
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||
uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
|
||||
with:
|
||||
sync-labels: true
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 90 days stale policy
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 90
|
||||
|
||||
+11
-11
@@ -52,15 +52,15 @@
|
||||
"@codemirror/view": "6.43.6",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.5.0",
|
||||
"@formatjs/intl-displaynames": "7.3.12",
|
||||
"@formatjs/intl-durationformat": "0.10.17",
|
||||
"@formatjs/intl-datetimeformat": "7.5.1",
|
||||
"@formatjs/intl-displaynames": "7.3.13",
|
||||
"@formatjs/intl-durationformat": "0.10.18",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.11",
|
||||
"@formatjs/intl-listformat": "8.3.12",
|
||||
"@formatjs/intl-listformat": "8.3.13",
|
||||
"@formatjs/intl-locale": "5.3.10",
|
||||
"@formatjs/intl-numberformat": "9.3.13",
|
||||
"@formatjs/intl-pluralrules": "6.3.12",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.12",
|
||||
"@formatjs/intl-numberformat": "9.3.14",
|
||||
"@formatjs/intl-pluralrules": "6.3.13",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.13",
|
||||
"@fullcalendar/core": "6.1.21",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"@fullcalendar/interaction": "6.1.21",
|
||||
@@ -107,7 +107,7 @@
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.11",
|
||||
"intl-messageformat": "11.2.12",
|
||||
"js-yaml": "5.2.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
@@ -146,13 +146,13 @@
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@html-eslint/eslint-plugin": "0.64.0",
|
||||
"@lokalise/node-api": "16.1.0",
|
||||
"@lokalise/node-api": "16.2.0",
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.18",
|
||||
"@rspack/core": "2.1.3",
|
||||
"@rsdoctor/rspack-plugin": "1.6.0",
|
||||
"@rspack/core": "2.1.4",
|
||||
"@rspack/dev-server": "2.1.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
|
||||
@@ -86,6 +86,11 @@
|
||||
"description": "Group date-fns with dependent timezone package",
|
||||
"groupName": "date-fns",
|
||||
"matchPackageNames": ["date-fns", "date-fns-tz"]
|
||||
},
|
||||
{
|
||||
"description": "Group formatjs monorepo package",
|
||||
"groupName": "formatjs",
|
||||
"matchPackageNames": ["@formatjs/**"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type {
|
||||
HaFormBaseSchema,
|
||||
HaFormCondition,
|
||||
HaFormDataContainer,
|
||||
HaFormFieldCondition,
|
||||
HaFormSchema,
|
||||
} from "./types";
|
||||
|
||||
const isEmpty = (value: unknown): boolean =>
|
||||
value === undefined || value === null || value === "";
|
||||
|
||||
const matchFieldCondition = (
|
||||
condition: HaFormFieldCondition,
|
||||
data: HaFormDataContainer | undefined
|
||||
): boolean => {
|
||||
const actual = data?.[condition.field];
|
||||
switch (condition.operator ?? "eq") {
|
||||
case "eq":
|
||||
return actual === condition.value;
|
||||
case "not_eq":
|
||||
return actual !== condition.value;
|
||||
case "in":
|
||||
return (
|
||||
Array.isArray(condition.value) &&
|
||||
condition.value.includes(actual as any)
|
||||
);
|
||||
case "not_in":
|
||||
return (
|
||||
Array.isArray(condition.value) &&
|
||||
!condition.value.includes(actual as any)
|
||||
);
|
||||
case "exists":
|
||||
return !isEmpty(actual);
|
||||
case "not_exists":
|
||||
return isEmpty(actual);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const evaluateCondition = (
|
||||
condition: HaFormCondition,
|
||||
data: HaFormDataContainer | undefined
|
||||
): boolean => {
|
||||
if ("condition" in condition) {
|
||||
switch (condition.condition) {
|
||||
case "and":
|
||||
return condition.conditions.every((c) => evaluateCondition(c, data));
|
||||
case "or":
|
||||
return condition.conditions.some((c) => evaluateCondition(c, data));
|
||||
case "not":
|
||||
return !condition.conditions.some((c) => evaluateCondition(c, data));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return matchFieldCondition(condition, data);
|
||||
};
|
||||
|
||||
export const isFieldHidden = (
|
||||
schema: HaFormSchema,
|
||||
data: HaFormDataContainer | undefined
|
||||
): boolean => {
|
||||
const { hidden } = schema as HaFormBaseSchema;
|
||||
if (!hidden) {
|
||||
return false;
|
||||
}
|
||||
if (hidden === true) {
|
||||
return true;
|
||||
}
|
||||
const conditions = Array.isArray(hidden) ? hidden : [hidden];
|
||||
return conditions.every((condition) => evaluateCondition(condition, data));
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, queryAll } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { isFieldHidden } from "./conditions";
|
||||
import "./ha-form";
|
||||
import type { HaForm } from "./ha-form";
|
||||
import type {
|
||||
@@ -68,19 +69,21 @@ export class HaFormGrid extends LitElement implements HaFormElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${this.schema.schema.map(
|
||||
(item) => html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this.data}
|
||||
.schema=${[item]}
|
||||
.disabled=${this.disabled}
|
||||
.computeLabel=${this.computeLabel}
|
||||
.computeHelper=${this.computeHelper}
|
||||
.localizeValue=${this.localizeValue}
|
||||
></ha-form>
|
||||
`
|
||||
)}
|
||||
${this.schema.schema
|
||||
.filter((item) => !isFieldHidden(item, this.data))
|
||||
.map(
|
||||
(item) => html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this.data}
|
||||
.schema=${[item]}
|
||||
.disabled=${this.disabled}
|
||||
.computeLabel=${this.computeLabel}
|
||||
.computeHelper=${this.computeHelper}
|
||||
.localizeValue=${this.localizeValue}
|
||||
></ha-form>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-alert";
|
||||
import "../ha-selector/ha-selector";
|
||||
import { isFieldHidden } from "./conditions";
|
||||
import type { HaFormDataContainer, HaFormElement, HaFormSchema } from "./types";
|
||||
|
||||
const LOAD_ELEMENTS = {
|
||||
@@ -98,7 +99,11 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
let isValid = true;
|
||||
let firstInvalidElement: HTMLElement | undefined;
|
||||
|
||||
this.schema.forEach((item, index) => {
|
||||
const visibleSchema = this.schema.filter(
|
||||
(item) => !isFieldHidden(item, this.data)
|
||||
);
|
||||
|
||||
visibleSchema.forEach((item, index) => {
|
||||
const element = elements[index];
|
||||
if (!element) {
|
||||
return;
|
||||
@@ -164,6 +169,10 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
: ""
|
||||
}
|
||||
${this.schema.map((item) => {
|
||||
if (isFieldHidden(item, this.data)) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const error = getError(this.error, item);
|
||||
const warning = getWarning(this.warning, item);
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ export interface HaFormBaseSchema {
|
||||
default?: HaFormData;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
// Field is hidden while the condition holds. Serializable so it can be
|
||||
// shared with the backend and other renderers.
|
||||
hidden?: boolean | HaFormCondition | HaFormCondition[];
|
||||
description?: {
|
||||
suffix?: string;
|
||||
// This value will be set initially when form is loaded
|
||||
@@ -30,6 +33,36 @@ export interface HaFormBaseSchema {
|
||||
context?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type HaFormConditionOperator =
|
||||
"eq" | "not_eq" | "in" | "not_in" | "exists" | "not_exists";
|
||||
|
||||
export interface HaFormFieldCondition {
|
||||
field: string;
|
||||
operator?: HaFormConditionOperator;
|
||||
value?: HaFormData | readonly HaFormData[];
|
||||
}
|
||||
|
||||
export interface HaFormAndCondition {
|
||||
condition: "and";
|
||||
conditions: readonly HaFormCondition[];
|
||||
}
|
||||
|
||||
export interface HaFormOrCondition {
|
||||
condition: "or";
|
||||
conditions: readonly HaFormCondition[];
|
||||
}
|
||||
|
||||
export interface HaFormNotCondition {
|
||||
condition: "not";
|
||||
conditions: readonly HaFormCondition[];
|
||||
}
|
||||
|
||||
export type HaFormCondition =
|
||||
| HaFormFieldCondition
|
||||
| HaFormAndCondition
|
||||
| HaFormOrCondition
|
||||
| HaFormNotCondition;
|
||||
|
||||
export interface HaFormGridSchema extends HaFormBaseSchema {
|
||||
type: "grid";
|
||||
flatten?: boolean;
|
||||
|
||||
@@ -133,7 +133,13 @@ export class HaAreaSelector extends LitElement {
|
||||
}
|
||||
|
||||
return ensureArray(this.selector.area.entity).some((filter) =>
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -147,7 +147,13 @@ export class HaDeviceSelector extends LitElement {
|
||||
|
||||
private _filterEntities = (entity: HassEntity): boolean =>
|
||||
ensureArray(this.selector.device!.entity).some((filter) =>
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,12 @@ import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { ConfigEntry } from "../../data/config_entries";
|
||||
import { getConfigEntries } from "../../data/config_entries";
|
||||
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
|
||||
import type { EntitySources } from "../../data/entity/entity_sources";
|
||||
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
|
||||
import type { EntitySelector } from "../../data/selector";
|
||||
@@ -23,6 +27,8 @@ export class HaEntitySelector extends LitElement {
|
||||
|
||||
@state() private _entitySources?: EntitySources;
|
||||
|
||||
@state() private _configEntries?: ConfigEntry[];
|
||||
|
||||
@property() public value?: any;
|
||||
|
||||
@property() public label?: string;
|
||||
@@ -37,12 +43,38 @@ export class HaEntitySelector extends LitElement {
|
||||
|
||||
@state() private _createDomains: string[] | undefined;
|
||||
|
||||
private _hasIntegration(selector: EntitySelector) {
|
||||
return (
|
||||
selector.entity?.filter &&
|
||||
ensureArray(selector.entity.filter).some((filter) => filter.integration)
|
||||
);
|
||||
}
|
||||
private _deviceIntegrationLookup = memoizeOne(
|
||||
(
|
||||
entitySources: EntitySources,
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
configEntries?: ConfigEntry[]
|
||||
) =>
|
||||
getDeviceIntegrationLookup(
|
||||
entitySources,
|
||||
Object.values(entities),
|
||||
Object.values(devices),
|
||||
configEntries
|
||||
)
|
||||
);
|
||||
|
||||
// Which async data the current filter needs to be evaluated: a top-level or
|
||||
// device `integration` filter needs entity sources, and a `device.integration`
|
||||
// filter additionally needs config entries (the device integration lookup is
|
||||
// built from both).
|
||||
private _dataNeeds = memoizeOne((selector: EntitySelector) => {
|
||||
const filters = selector.entity?.filter
|
||||
? ensureArray(selector.entity.filter)
|
||||
: [];
|
||||
return {
|
||||
entitySources: filters.some(
|
||||
(f) => f.integration || f.device?.integration
|
||||
),
|
||||
configEntries: filters.some((f) => f.device?.integration),
|
||||
};
|
||||
});
|
||||
|
||||
private _fetchedConfigEntries = false;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
if (changedProperties.get("selector") && this.value !== undefined) {
|
||||
@@ -57,7 +89,11 @@ export class HaEntitySelector extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (this._hasIntegration(this.selector) && !this._entitySources) {
|
||||
const needs = this._dataNeeds(this.selector);
|
||||
if (
|
||||
(needs.entitySources && !this._entitySources) ||
|
||||
(needs.configEntries && !this._configEntries)
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
@@ -96,15 +132,37 @@ export class HaEntitySelector extends LitElement {
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>): void {
|
||||
super.updated(changedProps);
|
||||
if (
|
||||
changedProps.has("selector") &&
|
||||
this._hasIntegration(this.selector) &&
|
||||
!this._entitySources
|
||||
) {
|
||||
|
||||
// The connection changed (e.g. reconnect); refetch config entries.
|
||||
const oldHass = changedProps.get("hass");
|
||||
if (oldHass && oldHass.connection !== this.hass.connection) {
|
||||
this._fetchedConfigEntries = false;
|
||||
this._configEntries = undefined;
|
||||
}
|
||||
|
||||
const needs = this._dataNeeds(this.selector);
|
||||
|
||||
if (needs.entitySources && !this._entitySources) {
|
||||
fetchEntitySourcesWithCache(this.hass).then((sources) => {
|
||||
this._entitySources = sources;
|
||||
});
|
||||
}
|
||||
|
||||
if (needs.configEntries && !this._fetchedConfigEntries) {
|
||||
this._fetchedConfigEntries = true;
|
||||
getConfigEntries(this.hass)
|
||||
.then((entries) => {
|
||||
this._configEntries = entries;
|
||||
})
|
||||
.catch(() => {
|
||||
// Fall back to no entries so the picker still renders. We keep
|
||||
// `_fetchedConfigEntries` set so the failed fetch is not retried on
|
||||
// every re-render; the connection-change handler above retries on
|
||||
// reconnect.
|
||||
this._configEntries = [];
|
||||
});
|
||||
}
|
||||
|
||||
if (changedProps.has("selector")) {
|
||||
this._createDomains = computeCreateDomains(this.selector);
|
||||
}
|
||||
@@ -114,8 +172,25 @@ export class HaEntitySelector extends LitElement {
|
||||
if (!this.selector?.entity?.filter) {
|
||||
return true;
|
||||
}
|
||||
const deviceIntegrationLookup =
|
||||
this._entitySources && this._dataNeeds(this.selector).configEntries
|
||||
? this._deviceIntegrationLookup(
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this._configEntries
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return ensureArray(this.selector.entity.filter).some((filter) =>
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
deviceIntegrationLookup
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,7 +133,13 @@ export class HaFloorSelector extends LitElement {
|
||||
}
|
||||
|
||||
return ensureArray(this.selector.floor.entity).some((filter) =>
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -93,7 +93,13 @@ export class HaTargetSelector extends LitElement {
|
||||
}
|
||||
|
||||
return ensureArray(this.selector.target.entity).some((filter) =>
|
||||
filterSelectorEntities(filter, entity, this._entitySources)
|
||||
filterSelectorEntities(
|
||||
filter,
|
||||
entity,
|
||||
this._entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -895,7 +895,13 @@ export class HaServiceControl extends LitElement {
|
||||
}
|
||||
if (targetEntities.length) {
|
||||
targetEntities = targetEntities.filter((entity) =>
|
||||
entityMeetsTargetSelector(this.hass.states[entity], targetSelector)
|
||||
entityMeetsTargetSelector(
|
||||
this.hass.states[entity],
|
||||
targetSelector,
|
||||
undefined,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
)
|
||||
);
|
||||
}
|
||||
target = {
|
||||
|
||||
+53
-10
@@ -266,6 +266,10 @@ interface EntitySelectorFilter {
|
||||
unit_of_measurement?: string | readonly string[];
|
||||
}
|
||||
|
||||
interface EntitySelectorEntityFilter extends EntitySelectorFilter {
|
||||
device?: DeviceSelectorFilter;
|
||||
}
|
||||
|
||||
export interface EntitySelectorExtraOption {
|
||||
id: string;
|
||||
primary: string;
|
||||
@@ -281,7 +285,7 @@ export interface EntitySelector {
|
||||
multiple?: boolean;
|
||||
include_entities?: string[];
|
||||
exclude_entities?: string[];
|
||||
filter?: EntitySelectorFilter | readonly EntitySelectorFilter[];
|
||||
filter?: EntitySelectorEntityFilter | readonly EntitySelectorEntityFilter[];
|
||||
reorder?: boolean;
|
||||
extra_options?: EntitySelectorExtraOption[];
|
||||
} | null;
|
||||
@@ -671,7 +675,9 @@ export const expandLabelTarget = (
|
||||
entityMeetsTargetSelector(
|
||||
hass.states[entity.entity_id],
|
||||
targetSelector,
|
||||
entitySources
|
||||
entitySources,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
@@ -737,7 +743,9 @@ export const expandAreaTarget = (
|
||||
entityMeetsTargetSelector(
|
||||
hass.states[entity.entity_id],
|
||||
targetSelector,
|
||||
entitySources
|
||||
entitySources,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
@@ -760,7 +768,9 @@ export const expandDeviceTarget = (
|
||||
entityMeetsTargetSelector(
|
||||
hass.states[entity.entity_id],
|
||||
targetSelector,
|
||||
entitySources
|
||||
entitySources,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
@@ -801,7 +811,9 @@ export const areaMeetsTargetSelector = (
|
||||
entityMeetsTargetSelector(
|
||||
hass.states[entity.entity_id],
|
||||
targetSelector,
|
||||
entitySources
|
||||
entitySources,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
@@ -849,14 +861,22 @@ export const deviceMeetsTargetSelector = (
|
||||
export const entityMeetsTargetSelector = (
|
||||
entity: HassEntity | undefined,
|
||||
targetSelector: TargetSelector,
|
||||
entitySources?: EntitySources
|
||||
entitySources?: EntitySources,
|
||||
entities?: HomeAssistant["entities"],
|
||||
devices?: HomeAssistant["devices"]
|
||||
): boolean => {
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
if (targetSelector.target?.entity) {
|
||||
return ensureArray(targetSelector.target!.entity).some((filterEntity) =>
|
||||
filterSelectorEntities(filterEntity, entity, entitySources)
|
||||
filterSelectorEntities(
|
||||
filterEntity,
|
||||
entity,
|
||||
entitySources,
|
||||
entities,
|
||||
devices
|
||||
)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
@@ -895,9 +915,12 @@ export const filterSelectorDevices = (
|
||||
};
|
||||
|
||||
export const filterSelectorEntities = (
|
||||
filterEntity: EntitySelectorFilter,
|
||||
filterEntity: EntitySelectorEntityFilter,
|
||||
entity: HassEntity,
|
||||
entitySources?: EntitySources
|
||||
entitySources?: EntitySources,
|
||||
entityRegistry?: HomeAssistant["entities"],
|
||||
devices?: HomeAssistant["devices"],
|
||||
deviceIntegrationLookup?: Record<string, Set<string>>
|
||||
): boolean => {
|
||||
const {
|
||||
domain: filterDomain,
|
||||
@@ -905,6 +928,7 @@ export const filterSelectorEntities = (
|
||||
supported_features: filterSupportedFeature,
|
||||
unit_of_measurement: filterUnitOfMeasurement,
|
||||
integration: filterIntegration,
|
||||
device: filterDevice,
|
||||
} = filterEntity;
|
||||
|
||||
if (filterDomain) {
|
||||
@@ -951,6 +975,24 @@ export const filterSelectorEntities = (
|
||||
}
|
||||
}
|
||||
|
||||
if (filterDevice) {
|
||||
if (!entityRegistry || !devices) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const deviceId = entityRegistry[entity.entity_id]?.device_id;
|
||||
if (!deviceId) {
|
||||
return false;
|
||||
}
|
||||
const device = devices[deviceId];
|
||||
if (!device) {
|
||||
return false;
|
||||
}
|
||||
if (!filterSelectorDevices(filterDevice, device, deviceIntegrationLookup)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
filterIntegration &&
|
||||
entitySources?.[entity.entity_id]?.domain !== filterIntegration
|
||||
@@ -1020,7 +1062,7 @@ export const handleLegacyDeviceSelector = (
|
||||
export const computeCreateDomains = (
|
||||
selector: EntitySelector | TargetSelector
|
||||
): undefined | string[] => {
|
||||
let entityFilters: EntitySelectorFilter[] | undefined;
|
||||
let entityFilters: EntitySelectorEntityFilter[] | undefined;
|
||||
|
||||
if ("target" in selector) {
|
||||
entityFilters = ensureArray(selector.target?.entity);
|
||||
@@ -1038,6 +1080,7 @@ export const computeCreateDomains = (
|
||||
!entityFilter.integration &&
|
||||
!entityFilter.device_class &&
|
||||
!entityFilter.supported_features &&
|
||||
!entityFilter.device &&
|
||||
entityFilter.domain
|
||||
? ensureArray(entityFilter.domain).filter((domain) =>
|
||||
isHelperDomain(domain)
|
||||
|
||||
@@ -156,6 +156,7 @@ class MoveDatadiskDialog extends DirtyStateProviderMixin<MoveDatadiskFormState>(
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.storage.datadisk.select_device"
|
||||
)}
|
||||
.value=${this._selectedDevice}
|
||||
@selected=${this._selectDevice}
|
||||
.options=${this._disks.map((disk) => ({
|
||||
value: disk.id,
|
||||
|
||||
@@ -54,6 +54,65 @@ const cardConfigStruct = assign(
|
||||
})
|
||||
);
|
||||
|
||||
const SCHEMA = [
|
||||
{ name: "title", selector: { text: {} } },
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "hours_to_show",
|
||||
default: DEFAULT_HOURS_TO_SHOW,
|
||||
selector: { number: { min: 0, step: "any", mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "show_names",
|
||||
default: true,
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "logarithmic_scale",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "expand_legend",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "min_y_axis",
|
||||
required: false,
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
{
|
||||
name: "max_y_axis",
|
||||
required: false,
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "fit_y_data",
|
||||
required: false,
|
||||
hidden: {
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ field: "min_y_axis", operator: "not_exists" },
|
||||
{ field: "max_y_axis", operator: "not_exists" },
|
||||
],
|
||||
},
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[];
|
||||
|
||||
@customElement("hui-history-graph-card-editor")
|
||||
export class HuiHistoryGraphCardEditor
|
||||
extends LitElement
|
||||
@@ -70,65 +129,6 @@ export class HuiHistoryGraphCardEditor
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(showFitOption: boolean) =>
|
||||
[
|
||||
{ name: "title", selector: { text: {} } },
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "hours_to_show",
|
||||
default: DEFAULT_HOURS_TO_SHOW,
|
||||
selector: { number: { min: 0, step: "any", mode: "box" } },
|
||||
},
|
||||
{
|
||||
name: "show_names",
|
||||
default: true,
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "logarithmic_scale",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
{
|
||||
name: "expand_legend",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "",
|
||||
type: "grid",
|
||||
schema: [
|
||||
{
|
||||
name: "min_y_axis",
|
||||
required: false,
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
{
|
||||
name: "max_y_axis",
|
||||
required: false,
|
||||
selector: { number: { mode: "box", step: "any" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
...(showFitOption
|
||||
? [
|
||||
{
|
||||
name: "fit_y_data",
|
||||
required: false,
|
||||
selector: { boolean: {} },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
] as const
|
||||
);
|
||||
|
||||
private _subForm = memoizeOne((localize: LocalizeFunc, entityId: string) => ({
|
||||
schema: [
|
||||
{ name: "entity", selector: { entity: {} }, required: true },
|
||||
@@ -176,11 +176,6 @@ export class HuiHistoryGraphCardEditor
|
||||
`;
|
||||
}
|
||||
|
||||
const schema = this._schema(
|
||||
this._config!.min_y_axis !== undefined ||
|
||||
this._config!.max_y_axis !== undefined
|
||||
);
|
||||
|
||||
const configEntities = this._config.entities
|
||||
? (processEditorEntities(this._config.entities) as GraphEntityConfig[])
|
||||
: [];
|
||||
@@ -188,7 +183,7 @@ export class HuiHistoryGraphCardEditor
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._config}
|
||||
.schema=${schema}
|
||||
.schema=${SCHEMA}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
@@ -283,9 +278,7 @@ export class HuiHistoryGraphCardEditor
|
||||
) as HistoryGraphCardConfig;
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
) => {
|
||||
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
|
||||
switch (schema.name) {
|
||||
case "show_names":
|
||||
case "logarithmic_scale":
|
||||
|
||||
@@ -92,7 +92,6 @@ export class HuiTileCardEditor
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
entityId: string | undefined,
|
||||
hideState: boolean,
|
||||
showTimeFormat: boolean
|
||||
) =>
|
||||
[
|
||||
@@ -144,31 +143,25 @@ export class HuiTileCardEditor
|
||||
},
|
||||
],
|
||||
},
|
||||
...(!hideState
|
||||
? ([
|
||||
{
|
||||
name: "state_content",
|
||||
selector: {
|
||||
ui_state_content: {
|
||||
allow_context: true,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
filter_entity: "entity",
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
...(showTimeFormat
|
||||
? ([
|
||||
{
|
||||
name: "time_format",
|
||||
selector: {
|
||||
ui_time_format: {},
|
||||
},
|
||||
},
|
||||
] as const satisfies readonly HaFormSchema[])
|
||||
: []),
|
||||
{
|
||||
name: "state_content",
|
||||
hidden: { field: "hide_state", value: true },
|
||||
selector: {
|
||||
ui_state_content: {
|
||||
allow_context: true,
|
||||
},
|
||||
},
|
||||
context: {
|
||||
filter_entity: "entity",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "time_format",
|
||||
hidden: !showTimeFormat,
|
||||
selector: {
|
||||
ui_time_format: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content_layout",
|
||||
required: true,
|
||||
@@ -293,12 +286,7 @@ export class HuiTileCardEditor
|
||||
this._config.state_content
|
||||
);
|
||||
|
||||
const schema = this._schema(
|
||||
this.hass.localize,
|
||||
entityId,
|
||||
this._config.hide_state ?? false,
|
||||
showTimeFormat
|
||||
);
|
||||
const schema = this._schema(this.hass.localize, entityId, showTimeFormat);
|
||||
|
||||
const vertical = this._config.vertical ?? false;
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isFieldHidden } from "../../../src/components/ha-form/conditions";
|
||||
import type { HaFormSchema } from "../../../src/components/ha-form/types";
|
||||
|
||||
const field = (hidden: HaFormSchema["hidden"]): HaFormSchema =>
|
||||
({ name: "field", selector: { text: {} }, hidden }) as HaFormSchema;
|
||||
|
||||
describe("isFieldHidden", () => {
|
||||
it("shows a field without a hidden condition", () => {
|
||||
expect(isFieldHidden(field(undefined), { a: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("honors a boolean hidden", () => {
|
||||
expect(isFieldHidden(field(true), {})).toBe(true);
|
||||
expect(isFieldHidden(field(false), {})).toBe(false);
|
||||
});
|
||||
|
||||
describe("operators", () => {
|
||||
it("eq (default) matches equal values", () => {
|
||||
expect(isFieldHidden(field({ field: "a", value: 1 }), { a: 1 })).toBe(
|
||||
true
|
||||
);
|
||||
expect(isFieldHidden(field({ field: "a", value: 1 }), { a: 2 })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("not_eq matches different values", () => {
|
||||
const schema = field({ field: "a", operator: "not_eq", value: 1 });
|
||||
expect(isFieldHidden(schema, { a: 2 })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("in matches membership", () => {
|
||||
const schema = field({ field: "a", operator: "in", value: ["x", "y"] });
|
||||
expect(isFieldHidden(schema, { a: "y" })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: "z" })).toBe(false);
|
||||
});
|
||||
|
||||
it("not_in matches non-membership", () => {
|
||||
const schema = field({
|
||||
field: "a",
|
||||
operator: "not_in",
|
||||
value: ["x", "y"],
|
||||
});
|
||||
expect(isFieldHidden(schema, { a: "z" })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: "x" })).toBe(false);
|
||||
});
|
||||
|
||||
it("exists matches a defined non-empty value", () => {
|
||||
const schema = field({ field: "a", operator: "exists" });
|
||||
expect(isFieldHidden(schema, { a: "x" })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: "" })).toBe(false);
|
||||
expect(isFieldHidden(schema, {})).toBe(false);
|
||||
});
|
||||
|
||||
it("not_exists matches a missing or empty value", () => {
|
||||
const schema = field({ field: "a", operator: "not_exists" });
|
||||
expect(isFieldHidden(schema, {})).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: null } as any)).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: "x" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combinators", () => {
|
||||
it("and requires every condition", () => {
|
||||
const schema = field({
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ field: "a", value: 1 },
|
||||
{ field: "b", value: 2 },
|
||||
],
|
||||
});
|
||||
expect(isFieldHidden(schema, { a: 1, b: 2 })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: 1, b: 9 })).toBe(false);
|
||||
});
|
||||
|
||||
it("or requires any condition", () => {
|
||||
const schema = field({
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ field: "a", value: 1 },
|
||||
{ field: "b", value: 2 },
|
||||
],
|
||||
});
|
||||
expect(isFieldHidden(schema, { a: 9, b: 2 })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: 9, b: 9 })).toBe(false);
|
||||
});
|
||||
|
||||
it("not negates its conditions", () => {
|
||||
const schema = field({
|
||||
condition: "not",
|
||||
conditions: [{ field: "a", value: 1 }],
|
||||
});
|
||||
expect(isFieldHidden(schema, { a: 2 })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: 1 })).toBe(false);
|
||||
});
|
||||
|
||||
it("nests combinators", () => {
|
||||
const schema = field({
|
||||
condition: "and",
|
||||
conditions: [
|
||||
{ field: "a", value: 1 },
|
||||
{
|
||||
condition: "or",
|
||||
conditions: [
|
||||
{ field: "b", value: 2 },
|
||||
{ field: "c", value: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(isFieldHidden(schema, { a: 1, b: 9, c: 3 })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: 1, b: 9, c: 9 })).toBe(false);
|
||||
expect(isFieldHidden(schema, { a: 9, b: 2, c: 3 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an array of conditions as AND", () => {
|
||||
const schema = field([
|
||||
{ field: "a", value: 1 },
|
||||
{ field: "b", value: 2 },
|
||||
]);
|
||||
expect(isFieldHidden(schema, { a: 1, b: 2 })).toBe(true);
|
||||
expect(isFieldHidden(schema, { a: 1, b: 9 })).toBe(false);
|
||||
});
|
||||
|
||||
it("handles missing data", () => {
|
||||
expect(isFieldHidden(field({ field: "a", value: 1 }), undefined)).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
|
||||
import { filterSelectorEntities } from "../../src/data/selector";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
|
||||
const entity = {
|
||||
entity_id: "light.living_room",
|
||||
state: "on",
|
||||
attributes: {},
|
||||
} as HassEntity;
|
||||
|
||||
const entityRegistry = {
|
||||
"light.living_room": { device_id: "device_1" },
|
||||
} as unknown as HomeAssistant["entities"];
|
||||
|
||||
const devices = {
|
||||
device_1: {
|
||||
id: "device_1",
|
||||
manufacturer: "Signify",
|
||||
model: "Hue Bulb",
|
||||
model_id: "LCT015",
|
||||
} as DeviceRegistryEntry,
|
||||
} as unknown as HomeAssistant["devices"];
|
||||
|
||||
describe("filterSelectorEntities device filter", () => {
|
||||
it("matches when the nested device manufacturer matches", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { manufacturer: "Signify" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match when the nested device manufacturer differs", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { manufacturer: "Sonos" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("matches when model and model_id both match", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { model: "Hue Bulb", model_id: "LCT015" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match when one of model or model_id differs", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { model: "Hue Bulb", model_id: "OTHER" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("matches the device integration via the lookup", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { integration: "hue" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices,
|
||||
{ device_1: new Set(["hue"]) }
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match a device integration that is absent from the lookup", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { integration: "zha" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices,
|
||||
{ device_1: new Set(["hue"]) }
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match when the entity has no underlying device", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ device: { manufacturer: "Signify" } },
|
||||
entity,
|
||||
undefined,
|
||||
{} as HomeAssistant["entities"],
|
||||
devices
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("combines device conditions with other entity conditions (AND)", () => {
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ domain: "light", device: { manufacturer: "Signify" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
filterSelectorEntities(
|
||||
{ domain: "switch", device: { manufacturer: "Signify" } },
|
||||
entity,
|
||||
undefined,
|
||||
entityRegistry,
|
||||
devices
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user