Compare commits

...
Author SHA1 Message Date
Petar Petrov affaeb1bb5 Drop hass from the object selector
formatSelectorValue takes the values it reads, and the object selector consumes them from contexts.
2026-10-26 18:46:45 +02:00
4 changed files with 158 additions and 62 deletions

No files matched your search

@@ -5,15 +5,31 @@ import {
mdiPencil,
} from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { HassEntities } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { consume } from "../../common/decorators/consume";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import {
formattersContext,
internationalizationContext,
registriesContext,
statesContext,
} from "../../data/context";
import type { ObjectSelector } from "../../data/selector";
import { formatSelectorValue } from "../../data/selector/format_selector_value";
import {
formatSelectorValue,
type FormatSelectorValueOptions,
} from "../../data/selector/format_selector_value";
import { showFormDialog } from "../../dialogs/form/show-form-dialog";
import type { HomeAssistant } from "../../types";
import type {
HomeAssistantFormatters,
HomeAssistantInternationalization,
HomeAssistantRegistries,
} from "../../types";
import { computeInitialHaFormData } from "../ha-form/compute-initial-ha-form-data";
import type { HaFormSchema } from "../ha-form/types";
import "../ha-input-helper-text";
@@ -26,8 +42,6 @@ import { deepEqual } from "../../common/util/deep-equal";
@customElement("ha-selector-object")
export class HaObjectSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: ObjectSelector;
@property() public value?: any;
@@ -47,6 +61,28 @@ export class HaObjectSelector extends LitElement {
values?: Record<string, string>
) => string;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: HomeAssistantRegistries;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: HassEntities;
@state()
@consume({ context: formattersContext, subscribe: true })
@transform<
HomeAssistantFormatters,
HomeAssistantFormatters["formatEntityName"]
>({
transformer: (formatters) => formatters?.formatEntityName,
})
private _formatEntityName!: HomeAssistantFormatters["formatEntityName"];
@query("ha-yaml-editor", true) private _yamlEditor?: HaYamlEditor;
private _valueChangedFromChild = false;
@@ -83,7 +119,12 @@ export class HaObjectSelector extends LitElement {
private _formatField(item: any, field: string): string {
const fields = this.selector.object!.fields!;
const value = formatSelectorValue(
this.hass,
this._formatOptions(
this._i18n,
this._registries,
this._states,
this._formatEntityName
),
item[field],
fields[field]?.selector
);
@@ -148,14 +189,14 @@ export class HaObjectSelector extends LitElement {
slot="end"
.item=${item}
.index=${index}
.label=${this.hass.localize("ui.common.edit")}
.label=${this._i18n.localize("ui.common.edit")}
.path=${mdiPencil}
@click=${this._editItem}
></ha-icon-button>
<ha-icon-button
slot="end"
.index=${index}
.label=${this.hass.localize("ui.common.delete")}
.label=${this._i18n.localize("ui.common.delete")}
.path=${multiple ? mdiDelete : mdiClose}
@click=${this._deleteItem}
></ha-icon-button>
@@ -180,7 +221,7 @@ export class HaObjectSelector extends LitElement {
</ha-list-base>
</ha-sortable>
<ha-button appearance="filled" @click=${this._addItem}>
${this.hass.localize("ui.common.add")}
${this._i18n.localize("ui.common.add")}
</ha-button>
</div>
`;
@@ -196,7 +237,7 @@ export class HaObjectSelector extends LitElement {
</ha-list-base>`
: html`
<ha-button appearance="filled" @click=${this._addItem}>
${this.hass.localize("ui.common.add")}
${this._i18n.localize("ui.common.add")}
</ha-button>
`
}
@@ -219,6 +260,23 @@ export class HaObjectSelector extends LitElement {
} `;
}
private _formatOptions = memoizeOne(
(
i18n: HomeAssistantInternationalization,
registries: HomeAssistantRegistries,
states: HassEntities,
formatEntityName: HomeAssistantFormatters["formatEntityName"]
): FormatSelectorValueOptions => ({
locale: i18n.locale,
localize: i18n.localize,
states,
devices: registries.devices,
areas: registries.areas,
floors: registries.floors,
formatEntityName,
})
);
private _schema = memoizeOne((selector: ObjectSelector) => {
if (!selector.object || !selector.object.fields) {
return [];
@@ -262,12 +320,12 @@ export class HaObjectSelector extends LitElement {
};
const newItem = await showFormDialog(this, {
title: this.hass.localize("ui.common.add"),
title: this._i18n.localize("ui.common.add"),
schema,
data,
computeLabel: this._computeLabel,
computeHelper: this._computeHelper,
submitText: this.hass.localize("ui.common.add"),
submitText: this._i18n.localize("ui.common.add"),
});
if (newItem === null) {
@@ -290,12 +348,12 @@ export class HaObjectSelector extends LitElement {
const index = ev.currentTarget.index;
const updatedItem = await showFormDialog(this, {
title: this.hass.localize("ui.common.edit"),
title: this._i18n.localize("ui.common.edit"),
schema: this._schema(this.selector),
data: item,
computeLabel: this._computeLabel,
computeHelper: this._computeHelper,
submitText: this.hass.localize("ui.common.save"),
submitText: this._i18n.localize("ui.common.save"),
});
if (updatedItem === null) {
+27 -11
View File
@@ -1,3 +1,4 @@
import type { HassEntities } from "home-assistant-js-websocket";
import { ensureArray } from "../../common/array/ensure-array";
import { createDurationData } from "../../common/datetime/create_duration_data";
import { formatDurationLong } from "../../common/datetime/format_duration";
@@ -5,12 +6,27 @@ import { normalizeDuration } from "../../common/datetime/normalize_duration";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { DEFAULT_ENTITY_NAME } from "../../common/entity/compute_entity_name_display";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import type { HomeAssistant } from "../../types";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { HomeAssistantFormatters } from "../../types";
import type { AreaRegistryEntry } from "../area/area_registry";
import type { DeviceRegistryEntry } from "../device/device_registry";
import type { FloorRegistryEntry } from "../floor_registry";
import type { Selector } from "../selector";
import { getDurationSelectorMode, getDurationSelectorUnits } from "../selector";
import type { FrontendLocaleData } from "../translation";
export interface FormatSelectorValueOptions {
locale: FrontendLocaleData;
localize: LocalizeFunc;
states: HassEntities;
devices: Record<string, DeviceRegistryEntry>;
areas: Record<string, AreaRegistryEntry>;
floors: Record<string, FloorRegistryEntry>;
formatEntityName: HomeAssistantFormatters["formatEntityName"];
}
export const formatSelectorValue = (
hass: HomeAssistant,
options: FormatSelectorValueOptions,
value: any,
selector?: Selector
) => {
@@ -47,7 +63,7 @@ export const formatSelectorValue = (
return number;
}
return unit_of_measurement
? `${num}${blankBeforeUnit(unit_of_measurement, hass.locale)}${unit_of_measurement}`
? `${num}${blankBeforeUnit(unit_of_measurement, options.locale)}${unit_of_measurement}`
: num.toString();
})
.join(", ");
@@ -57,7 +73,7 @@ export const formatSelectorValue = (
const floors = ensureArray(value);
return floors
.map((floorId) => {
const floor = hass.floors[floorId];
const floor = options.floors[floorId];
if (!floor) {
return floorId;
}
@@ -70,7 +86,7 @@ export const formatSelectorValue = (
const areas = ensureArray(value);
return areas
.map((areaId) => {
const area = hass.areas[areaId];
const area = options.areas[areaId];
if (!area) {
return areaId;
}
@@ -83,11 +99,11 @@ export const formatSelectorValue = (
const entities = ensureArray(value);
return entities
.map((entityId) => {
const stateObj = hass.states[entityId];
const stateObj = options.states[entityId];
if (!stateObj) {
return entityId;
}
const name = hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
const name = options.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
return name || entityId;
})
.join(", ");
@@ -97,7 +113,7 @@ export const formatSelectorValue = (
const devices = ensureArray(value);
return devices
.map((deviceId) => {
const device = hass.devices[deviceId];
const device = options.devices[deviceId];
if (!device) {
return deviceId;
}
@@ -125,7 +141,7 @@ export const formatSelectorValue = (
return Object.entries(fields)
.filter(([key]) => key in item && item[key] != null)
.map(([key, field]) =>
formatSelectorValue(hass, item[key], field.selector)
formatSelectorValue(options, item[key], field.selector)
)
.join(" = ");
}
@@ -143,13 +159,13 @@ export const formatSelectorValue = (
data,
getDurationSelectorUnits(selector.duration)
);
const formatted = formatDurationLong(hass.locale, duration);
const formatted = formatDurationLong(options.locale, duration);
const mode = getDurationSelectorMode(selector.duration);
if (!formatted || mode === "positive") {
return formatted;
}
const sign = negative ? "negative" : "positive";
return hass.localize(
return options.localize(
`ui.components.selectors.duration.summary.${mode}_${sign}`,
{ duration: formatted }
);
@@ -1,6 +1,16 @@
import { ContextProvider } from "@lit/context";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
formattersContext,
internationalizationContext,
registriesContext,
statesContext,
} from "../../../src/data/context";
import type { ObjectSelector } from "../../../src/data/selector";
import type { HomeAssistant } from "../../../src/types";
import type {
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../../../src/types";
import type { HaObjectSelector } from "../../../src/components/ha-selector/ha-selector-object";
import type {
FormDialogData,
@@ -33,16 +43,6 @@ vi.mock("../../../src/components/ha-yaml-editor", () => {
return {};
});
const hass = {
localize: (key: string) => key,
locale: "en-US",
floors: {},
areas: {},
devices: {},
states: {},
formatEntityName: () => "",
} as unknown as HomeAssistant;
const selectorConfig = {
object: {
multiple: true,
@@ -67,13 +67,32 @@ const mountSelector = async (
value: FormDialogData | FormDialogData[] | "",
config: ObjectSelector = selectorConfig
) => {
const host = document.createElement("div");
new ContextProvider(host, {
context: internationalizationContext,
initialValue: {
localize: (key: string) => key,
locale: { language: "en" },
} as unknown as HomeAssistantInternationalization,
});
new ContextProvider(host, {
context: registriesContext,
initialValue: { entities: {}, devices: {}, areas: {}, floors: {} },
});
new ContextProvider(host, { context: statesContext, initialValue: {} });
new ContextProvider(host, {
context: formattersContext,
initialValue: {
formatEntityName: () => "",
} as unknown as HomeAssistantFormatters,
});
const selector = document.createElement(
"ha-selector-object"
) as HaObjectSelector;
selector.hass = hass;
selector.selector = config;
selector.value = value;
document.body.append(selector);
host.append(selector);
document.body.append(host);
await selector.updateComplete;
return selector;
};
+27 -24
View File
@@ -1,33 +1,34 @@
import { describe, it, expect } from "vitest";
import { formatSelectorValue } from "../../src/data/selector/format_selector_value";
import type { HomeAssistant } from "../../src/types";
import {
formatSelectorValue,
type FormatSelectorValueOptions,
} from "../../src/data/selector/format_selector_value";
// formatSelectorValue only touches hass for floor/area/entity/device
// selectors, none of which these tests exercise.
const hass = {} as HomeAssistant;
// These selectors read none of the options.
const options = {} as FormatSelectorValueOptions;
describe("formatSelectorValue", () => {
it("returns an empty string for nullish values", () => {
expect(formatSelectorValue(hass, null)).toBe("");
expect(formatSelectorValue(hass, undefined)).toBe("");
expect(formatSelectorValue(options, null)).toBe("");
expect(formatSelectorValue(options, undefined)).toBe("");
});
it("renders a plain text value", () => {
expect(formatSelectorValue(hass, "hello", { text: { type: "text" } })).toBe(
"hello"
);
expect(
formatSelectorValue(options, "hello", { text: { type: "text" } })
).toBe("hello");
});
it("applies prefix and suffix for text selectors", () => {
expect(
formatSelectorValue(hass, "5", {
formatSelectorValue(options, "5", {
text: { type: "text", prefix: "$", suffix: " each" },
})
).toBe("$5 each");
});
it("masks a password text value instead of revealing it", () => {
const result = formatSelectorValue(hass, "hunter2", {
const result = formatSelectorValue(options, "hunter2", {
text: { type: "password" },
});
expect(result).not.toContain("hunter2");
@@ -35,7 +36,7 @@ describe("formatSelectorValue", () => {
});
it("masks every value of a multiple password selector", () => {
const result = formatSelectorValue(hass, ["one", "two"], {
const result = formatSelectorValue(options, ["one", "two"], {
text: { type: "password" },
});
expect(result).not.toContain("one");
@@ -45,7 +46,7 @@ describe("formatSelectorValue", () => {
it("masks a nested password field in an object selector preview", () => {
const result = formatSelectorValue(
hass,
options,
{ username: "admin", password: "hunter2" },
{
object: {
@@ -63,7 +64,7 @@ describe("formatSelectorValue", () => {
});
describe("formatSelectorValue duration selector", () => {
const localizedHass = {
const localizedOptions = {
locale: { language: "en" },
localize: (key: string, values?: Record<string, unknown>) => {
const suffix = key.split("ui.components.selectors.duration.summary.")[1];
@@ -74,12 +75,12 @@ describe("formatSelectorValue duration selector", () => {
signed_positive: `+${values?.duration}`,
}[suffix]!;
},
} as unknown as HomeAssistant;
} as unknown as FormatSelectorValueOptions;
it("formats a positive duration without a sign", () => {
expect(
formatSelectorValue(
localizedHass,
localizedOptions,
{ hours: 1, minutes: 30 },
{ duration: {} }
)
@@ -89,13 +90,13 @@ describe("formatSelectorValue duration selector", () => {
it("formats signed durations with an explicit sign", () => {
expect(
formatSelectorValue(
localizedHass,
localizedOptions,
{ minutes: -30 },
{ duration: { mode: "signed" } }
)
).toBe("-30 minutes");
expect(
formatSelectorValue(localizedHass, "00:30:00", {
formatSelectorValue(localizedOptions, "00:30:00", {
duration: { allow_negative: true },
})
).toBe("+30 minutes");
@@ -104,22 +105,24 @@ describe("formatSelectorValue duration selector", () => {
it("formats offsets as before or after", () => {
expect(
formatSelectorValue(
localizedHass,
localizedOptions,
{ hours: -1, minutes: -30 },
{ duration: { mode: "offset" } }
)
).toBe("1 hour, 30 minutes before");
expect(
formatSelectorValue(localizedHass, "-00:10:00", {
formatSelectorValue(localizedOptions, "-00:10:00", {
duration: { mode: "offset" },
})
).toBe("10 minutes before");
expect(
formatSelectorValue(localizedHass, 45, { duration: { mode: "offset" } })
formatSelectorValue(localizedOptions, 45, {
duration: { mode: "offset" },
})
).toBe("45 seconds after");
expect(
formatSelectorValue(
localizedHass,
localizedOptions,
{ minutes: -5 },
{ duration: { mode: "offset" } }
)
@@ -129,7 +132,7 @@ describe("formatSelectorValue duration selector", () => {
it("returns an empty string for a zero offset", () => {
expect(
formatSelectorValue(
localizedHass,
localizedOptions,
{ hours: 0, minutes: 0, seconds: 0 },
{ duration: { mode: "offset" } }
)