Compare commits

...

10 Commits

Author SHA1 Message Date
Simon Lamon b743f83db0 Fix formatting in dialog-move-datadisk.ts 2026-07-19 09:46:21 +02:00
dependabot[bot] 35193447bf Bump github/codeql-action/init from 4.36.3 to 4.37.0 (#53186)
* Bump github/codeql-action/init from 4.36.3 to 4.37.0

Bumps [github/codeql-action/init](https://github.com/github/codeql-action) from 4.36.3 to 4.37.0.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/54f647b7e1bb85c95cddabcd46b0c578ec92bc1a...99df26d4f13ea111d4ec1a7dddef6063f76b97e9)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump github/codeql-action/analyze from 4.36.3 to 4.37.0

Bumps [github/codeql-action/analyze](https://github.com/github/codeql-action) from 4.36.3 to 4.37.0.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/54f647b7e1bb85c95cddabcd46b0c578ec92bc1a...99df26d4f13ea111d4ec1a7dddef6063f76b97e9)

---
updated-dependencies:
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-19 09:42:43 +02:00
renovate[bot] 650444e92d Update dependency @lokalise/node-api to v16.2.0 (#53177)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-19 09:13:40 +02:00
dependabot[bot] da9a78f752 Bump actions/stale from 10.3.0 to 10.4.0 (#53188)
Bumps [actions/stale](https://github.com/actions/stale) from 10.3.0 to 10.4.0.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899...1e223db275d687790206a7acac4d1a11bd6fe629)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: 10.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-19 09:13:19 +02:00
dependabot[bot] 9077290b39 Bump actions/labeler from 6.1.0 to 6.2.0 (#53187)
Bumps [actions/labeler](https://github.com/actions/labeler) from 6.1.0 to 6.2.0.
- [Release notes](https://github.com/actions/labeler/releases)
- [Commits](https://github.com/actions/labeler/compare/f27b608878404679385c85cfa523b85ccb86e213...b8dd2d9be0f68b860e7dae5dae7d772984eacd6d)

---
updated-dependencies:
- dependency-name: actions/labeler
  dependency-version: 6.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-19 09:12:59 +02:00
renovate[bot] e4c6ea29b3 Lock file maintenance (#53179)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-19 09:12:40 +02:00
renovate[bot] aa57c27739 Update dependency @rsdoctor/rspack-plugin to v1.6.0 (#53174)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-18 19:12:42 +02:00
Niklas Wagner 420137478e Add manufacturer, model, and model_id filtering to entity filter selector (#29636)
* Add manufacturer, model, and model_id filtering to entity selector

* Implement suggestion from review

* fix: simplify device data type

* Nest device filtering under `device` key in entity selector

* Fix race condition when fetching data

* Avoid config entries refetch loop on fetch failure

The catch handler reset _fetchedConfigEntries to false while also
assigning _configEntries, which re-rendered the component and re-armed
the fetch guard in updated() — an unbounded, no-backoff retry loop on
persistent failure. Keep the flag set so a failed fetch is not retried
on every re-render; the connection-change handler still retries on
reconnect.

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-18 09:51:15 +02:00
renovate[bot] 25bc81cf1f Update dependency @rspack/core to v2.1.4 (#53170)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-17 17:38:02 +02:00
Paul Bottein 6b0f543fdb Add conditional field visibility to ha-form (#53165) 2026-07-17 14:06:04 +01:00
21 changed files with 1210 additions and 667 deletions
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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",
+73
View File
@@ -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));
};
+16 -13
View File
@@ -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>
`
)}
`;
}
+11 -2
View File
@@ -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);
+33
View File
@@ -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
)
);
};
+7 -1
View File
@@ -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
View File
@@ -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
);
});
});
+134
View File
@@ -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);
});
});
+544 -517
View File
File diff suppressed because it is too large Load Diff