mirror of
https://github.com/home-assistant/frontend.git
synced 2026-05-18 23:27:09 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 008e291133 | |||
| 6e88bf5fd9 | |||
| 5f614dd603 | |||
| 63a4cfd435 | |||
| 40525d5a3f | |||
| 046208652c | |||
| 8417569951 | |||
| c129c5fcad | |||
| 6d0b956fd8 | |||
| 875f9ed101 | |||
| 2927891848 | |||
| 584fc1ae19 | |||
| aa0b9112e5 | |||
| d1ad20289a | |||
| ccc5a04629 | |||
| 5c6d74ca28 | |||
| 1b808ae92a |
@@ -0,0 +1,165 @@
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { supportsAlarmModesCardFeature } from "./hui-alarm-modes-card-feature";
|
||||
import { supportsAreaControlsCardFeature } from "./hui-area-controls-card-feature";
|
||||
import { supportsBarGaugeCardFeature } from "./hui-bar-gauge-card-feature";
|
||||
import { supportsButtonCardFeature } from "./hui-button-card-feature";
|
||||
import { supportsClimateFanModesCardFeature } from "./hui-climate-fan-modes-card-feature";
|
||||
import { supportsClimateHvacModesCardFeature } from "./hui-climate-hvac-modes-card-feature";
|
||||
import { supportsClimatePresetModesCardFeature } from "./hui-climate-preset-modes-card-feature";
|
||||
import { supportsClimateSwingHorizontalModesCardFeature } from "./hui-climate-swing-horizontal-modes-card-feature";
|
||||
import { supportsClimateSwingModesCardFeature } from "./hui-climate-swing-modes-card-feature";
|
||||
import { supportsCounterActionsCardFeature } from "./hui-counter-actions-card-feature";
|
||||
import { supportsCoverOpenCloseCardFeature } from "./hui-cover-open-close-card-feature";
|
||||
import { supportsCoverPositionFavoriteCardFeature } from "./hui-cover-position-favorite-card-feature";
|
||||
import { supportsCoverPositionCardFeature } from "./hui-cover-position-card-feature";
|
||||
import { supportsCoverTiltCardFeature } from "./hui-cover-tilt-card-feature";
|
||||
import { supportsCoverTiltFavoriteCardFeature } from "./hui-cover-tilt-favorite-card-feature";
|
||||
import { supportsCoverTiltPositionCardFeature } from "./hui-cover-tilt-position-card-feature";
|
||||
import { supportsDateSetCardFeature } from "./hui-date-set-card-feature";
|
||||
import { supportsFanDirectionCardFeature } from "./hui-fan-direction-card-feature";
|
||||
import { supportsFanOscilatteCardFeature } from "./hui-fan-oscillate-card-feature";
|
||||
import { supportsFanPresetModesCardFeature } from "./hui-fan-preset-modes-card-feature";
|
||||
import { supportsFanSpeedCardFeature } from "./hui-fan-speed-card-feature";
|
||||
import { supportsHumidifierModesCardFeature } from "./hui-humidifier-modes-card-feature";
|
||||
import { supportsHumidifierToggleCardFeature } from "./hui-humidifier-toggle-card-feature";
|
||||
import { supportsLawnMowerCommandCardFeature } from "./hui-lawn-mower-commands-card-feature";
|
||||
import { supportsLightBrightnessCardFeature } from "./hui-light-brightness-card-feature";
|
||||
import { supportsLightColorFavoritesCardFeature } from "./hui-light-color-favorites-card-feature";
|
||||
import { supportsLightColorTempCardFeature } from "./hui-light-color-temp-card-feature";
|
||||
import { supportsLockCommandsCardFeature } from "./hui-lock-commands-card-feature";
|
||||
import { supportsLockOpenDoorCardFeature } from "./hui-lock-open-door-card-feature";
|
||||
import { supportsMediaPlayerPlaybackCardFeature } from "./hui-media-player-playback-card-feature";
|
||||
import { supportsMediaPlayerSoundModeCardFeature } from "./hui-media-player-sound-mode-card-feature";
|
||||
import { supportsMediaPlayerSourceCardFeature } from "./hui-media-player-source-card-feature";
|
||||
import { supportsMediaPlayerVolumeButtonsCardFeature } from "./hui-media-player-volume-buttons-card-feature";
|
||||
import { supportsMediaPlayerVolumeSliderCardFeature } from "./hui-media-player-volume-slider-card-feature";
|
||||
import { supportsNumericInputCardFeature } from "./hui-numeric-input-card-feature";
|
||||
import { supportsSelectOptionsCardFeature } from "./hui-select-options-card-feature";
|
||||
import { supportsTargetHumidityCardFeature } from "./hui-target-humidity-card-feature";
|
||||
import { supportsTargetTemperatureCardFeature } from "./hui-target-temperature-card-feature";
|
||||
import { supportsToggleCardFeature } from "./hui-toggle-card-feature";
|
||||
import { supportsTrendGraphCardFeature } from "./hui-trend-graph-card-feature";
|
||||
import { supportsUpdateActionsCardFeature } from "./hui-update-actions-card-feature";
|
||||
import { supportsVacuumCommandsCardFeature } from "./hui-vacuum-commands-card-feature";
|
||||
import { supportsValveOpenCloseCardFeature } from "./hui-valve-open-close-card-feature";
|
||||
import { supportsValvePositionFavoriteCardFeature } from "./hui-valve-position-favorite-card-feature";
|
||||
import { supportsValvePositionCardFeature } from "./hui-valve-position-card-feature";
|
||||
import { supportsWaterHeaterOperationModesCardFeature } from "./hui-water-heater-operation-modes-card-feature";
|
||||
import type {
|
||||
LovelaceCardFeatureConfig,
|
||||
LovelaceCardFeatureContext,
|
||||
} from "./types";
|
||||
|
||||
export type FeatureType = LovelaceCardFeatureConfig["type"];
|
||||
|
||||
export type SupportsFeature = (
|
||||
hass: HomeAssistant,
|
||||
context: LovelaceCardFeatureContext
|
||||
) => boolean;
|
||||
|
||||
export const UI_FEATURE_TYPES = [
|
||||
"alarm-modes",
|
||||
"area-controls",
|
||||
"bar-gauge",
|
||||
"button",
|
||||
"climate-fan-modes",
|
||||
"climate-hvac-modes",
|
||||
"climate-preset-modes",
|
||||
"climate-swing-modes",
|
||||
"climate-swing-horizontal-modes",
|
||||
"counter-actions",
|
||||
"cover-open-close",
|
||||
"cover-position-favorite",
|
||||
"cover-position",
|
||||
"cover-tilt-favorite",
|
||||
"cover-tilt-position",
|
||||
"cover-tilt",
|
||||
"date-set",
|
||||
"fan-direction",
|
||||
"fan-oscillate",
|
||||
"fan-preset-modes",
|
||||
"fan-speed",
|
||||
"humidifier-modes",
|
||||
"humidifier-toggle",
|
||||
"lawn-mower-commands",
|
||||
"light-brightness",
|
||||
"light-color-temp",
|
||||
"light-color-favorites",
|
||||
"lock-commands",
|
||||
"lock-open-door",
|
||||
"media-player-playback",
|
||||
"media-player-sound-mode",
|
||||
"media-player-source",
|
||||
"media-player-volume-buttons",
|
||||
"media-player-volume-slider",
|
||||
"numeric-input",
|
||||
"select-options",
|
||||
"trend-graph",
|
||||
"target-humidity",
|
||||
"target-temperature",
|
||||
"toggle",
|
||||
"update-actions",
|
||||
"vacuum-commands",
|
||||
"valve-open-close",
|
||||
"valve-position-favorite",
|
||||
"valve-position",
|
||||
"water-heater-operation-modes",
|
||||
] as const satisfies readonly FeatureType[];
|
||||
|
||||
export type UiFeatureType = (typeof UI_FEATURE_TYPES)[number];
|
||||
|
||||
export const SUPPORTS_FEATURE_TYPES: Record<UiFeatureType, SupportsFeature> = {
|
||||
"alarm-modes": supportsAlarmModesCardFeature,
|
||||
"area-controls": supportsAreaControlsCardFeature,
|
||||
"bar-gauge": supportsBarGaugeCardFeature,
|
||||
button: supportsButtonCardFeature,
|
||||
"climate-fan-modes": supportsClimateFanModesCardFeature,
|
||||
"climate-swing-modes": supportsClimateSwingModesCardFeature,
|
||||
"climate-swing-horizontal-modes":
|
||||
supportsClimateSwingHorizontalModesCardFeature,
|
||||
"climate-hvac-modes": supportsClimateHvacModesCardFeature,
|
||||
"climate-preset-modes": supportsClimatePresetModesCardFeature,
|
||||
"counter-actions": supportsCounterActionsCardFeature,
|
||||
"cover-open-close": supportsCoverOpenCloseCardFeature,
|
||||
"cover-position-favorite": supportsCoverPositionFavoriteCardFeature,
|
||||
"cover-position": supportsCoverPositionCardFeature,
|
||||
"cover-tilt-favorite": supportsCoverTiltFavoriteCardFeature,
|
||||
"cover-tilt-position": supportsCoverTiltPositionCardFeature,
|
||||
"cover-tilt": supportsCoverTiltCardFeature,
|
||||
"date-set": supportsDateSetCardFeature,
|
||||
"fan-direction": supportsFanDirectionCardFeature,
|
||||
"fan-oscillate": supportsFanOscilatteCardFeature,
|
||||
"fan-preset-modes": supportsFanPresetModesCardFeature,
|
||||
"fan-speed": supportsFanSpeedCardFeature,
|
||||
"humidifier-modes": supportsHumidifierModesCardFeature,
|
||||
"humidifier-toggle": supportsHumidifierToggleCardFeature,
|
||||
"lawn-mower-commands": supportsLawnMowerCommandCardFeature,
|
||||
"light-brightness": supportsLightBrightnessCardFeature,
|
||||
"light-color-temp": supportsLightColorTempCardFeature,
|
||||
"light-color-favorites": supportsLightColorFavoritesCardFeature,
|
||||
"lock-commands": supportsLockCommandsCardFeature,
|
||||
"lock-open-door": supportsLockOpenDoorCardFeature,
|
||||
"media-player-playback": supportsMediaPlayerPlaybackCardFeature,
|
||||
"media-player-sound-mode": supportsMediaPlayerSoundModeCardFeature,
|
||||
"media-player-source": supportsMediaPlayerSourceCardFeature,
|
||||
"media-player-volume-buttons": supportsMediaPlayerVolumeButtonsCardFeature,
|
||||
"media-player-volume-slider": supportsMediaPlayerVolumeSliderCardFeature,
|
||||
"numeric-input": supportsNumericInputCardFeature,
|
||||
"select-options": supportsSelectOptionsCardFeature,
|
||||
"trend-graph": supportsTrendGraphCardFeature,
|
||||
"target-humidity": supportsTargetHumidityCardFeature,
|
||||
"target-temperature": supportsTargetTemperatureCardFeature,
|
||||
toggle: supportsToggleCardFeature,
|
||||
"update-actions": supportsUpdateActionsCardFeature,
|
||||
"vacuum-commands": supportsVacuumCommandsCardFeature,
|
||||
"valve-open-close": supportsValveOpenCloseCardFeature,
|
||||
"valve-position-favorite": supportsValvePositionFavoriteCardFeature,
|
||||
"valve-position": supportsValvePositionCardFeature,
|
||||
"water-heater-operation-modes": supportsWaterHeaterOperationModesCardFeature,
|
||||
};
|
||||
|
||||
export const supportsFeatureType = (
|
||||
hass: HomeAssistant,
|
||||
context: LovelaceCardFeatureContext,
|
||||
type: UiFeatureType
|
||||
): boolean => SUPPORTS_FEATURE_TYPES[type](hass, context);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import type { CalendarCardConfig } from "../cards/types";
|
||||
import type { CardSuggestionProvider } from "./types";
|
||||
|
||||
export const calendarCardSuggestions: CardSuggestionProvider<CalendarCardConfig> =
|
||||
{
|
||||
getEntitySuggestion(hass, entityId) {
|
||||
if (computeDomain(entityId) !== "calendar") return null;
|
||||
return {
|
||||
id: "calendar",
|
||||
label: hass.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.suggestions.calendar"
|
||||
),
|
||||
config: { type: "calendar", entities: [entityId] },
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import {
|
||||
SUPPORTS_FEATURE_TYPES,
|
||||
type UiFeatureType,
|
||||
} from "../card-features/registry";
|
||||
import type { LovelaceCardFeatureConfig } from "../card-features/types";
|
||||
import type { TileCardConfig } from "../cards/types";
|
||||
import type { CardSuggestion, CardSuggestionProvider } from "./types";
|
||||
|
||||
interface TileVariant {
|
||||
id: string;
|
||||
features: UiFeatureType[];
|
||||
}
|
||||
|
||||
const LABEL_PREFIX = "ui.panel.lovelace.editor.cardpicker.suggestions.";
|
||||
|
||||
const TILE_VARIANT: TileVariant = { id: "tile", features: [] };
|
||||
const TILE_TOGGLE_VARIANT: TileVariant = {
|
||||
id: "tile_toggle",
|
||||
features: ["toggle"],
|
||||
};
|
||||
|
||||
const SELECT_VARIANTS: TileVariant[] = [
|
||||
TILE_VARIANT,
|
||||
{ id: "tile_options", features: ["select-options"] },
|
||||
];
|
||||
|
||||
const NUMERIC_INPUT_VARIANTS: TileVariant[] = [
|
||||
TILE_VARIANT,
|
||||
{ id: "tile_numeric_input", features: ["numeric-input"] },
|
||||
];
|
||||
|
||||
const DATE_VARIANTS: TileVariant[] = [
|
||||
TILE_VARIANT,
|
||||
{ id: "tile_date_picker", features: ["date-set"] },
|
||||
];
|
||||
|
||||
const DOMAIN_VARIANTS: Record<string, TileVariant[]> = {
|
||||
light: [
|
||||
TILE_VARIANT,
|
||||
{ id: "tile_brightness", features: ["light-brightness"] },
|
||||
TILE_TOGGLE_VARIANT,
|
||||
{ id: "tile_color_temperature", features: ["light-color-temp"] },
|
||||
{ id: "tile_favorite_colors", features: ["light-color-favorites"] },
|
||||
],
|
||||
cover: [
|
||||
TILE_VARIANT,
|
||||
{ id: "tile_open_close", features: ["cover-open-close"] },
|
||||
{ id: "tile_position", features: ["cover-position"] },
|
||||
{ id: "tile_tilt", features: ["cover-tilt"] },
|
||||
],
|
||||
climate: [
|
||||
TILE_VARIANT,
|
||||
{ id: "tile_hvac_modes", features: ["climate-hvac-modes"] },
|
||||
],
|
||||
media_player: [
|
||||
TILE_VARIANT,
|
||||
{ id: "tile_playback_controls", features: ["media-player-playback"] },
|
||||
{ id: "tile_volume_slider", features: ["media-player-volume-slider"] },
|
||||
],
|
||||
fan: [
|
||||
TILE_VARIANT,
|
||||
{ id: "tile_speed", features: ["fan-speed"] },
|
||||
{ id: "tile_preset_modes", features: ["fan-preset-modes"] },
|
||||
],
|
||||
switch: [TILE_VARIANT, TILE_TOGGLE_VARIANT],
|
||||
input_boolean: [TILE_VARIANT, TILE_TOGGLE_VARIANT],
|
||||
lock: [
|
||||
TILE_VARIANT,
|
||||
{ id: "tile_lock_commands", features: ["lock-commands"] },
|
||||
],
|
||||
humidifier: [
|
||||
TILE_VARIANT,
|
||||
{ id: "tile_humidifier_toggle", features: ["humidifier-toggle"] },
|
||||
{ id: "tile_humidifier_modes", features: ["humidifier-modes"] },
|
||||
],
|
||||
vacuum: [
|
||||
TILE_VARIANT,
|
||||
{ id: "tile_vacuum_commands", features: ["vacuum-commands"] },
|
||||
],
|
||||
lawn_mower: [
|
||||
TILE_VARIANT,
|
||||
{ id: "tile_mower_commands", features: ["lawn-mower-commands"] },
|
||||
],
|
||||
valve: [
|
||||
TILE_VARIANT,
|
||||
{ id: "tile_open_close", features: ["valve-open-close"] },
|
||||
{ id: "tile_position", features: ["valve-position"] },
|
||||
],
|
||||
alarm_control_panel: [
|
||||
TILE_VARIANT,
|
||||
{ id: "tile_alarm_modes", features: ["alarm-modes"] },
|
||||
],
|
||||
counter: [
|
||||
TILE_VARIANT,
|
||||
{ id: "tile_counter_actions", features: ["counter-actions"] },
|
||||
],
|
||||
input_select: SELECT_VARIANTS,
|
||||
select: SELECT_VARIANTS,
|
||||
input_number: NUMERIC_INPUT_VARIANTS,
|
||||
number: NUMERIC_INPUT_VARIANTS,
|
||||
input_datetime: DATE_VARIANTS,
|
||||
date: DATE_VARIANTS,
|
||||
update: [
|
||||
TILE_VARIANT,
|
||||
{ id: "tile_update_actions", features: ["update-actions"] },
|
||||
],
|
||||
water_heater: [
|
||||
TILE_VARIANT,
|
||||
{ id: "tile_operation_modes", features: ["water-heater-operation-modes"] },
|
||||
],
|
||||
};
|
||||
|
||||
const DEFAULT_VARIANT: TileVariant = TILE_VARIANT;
|
||||
|
||||
const SENSOR_TREND_DEVICE_CLASSES = new Set<string>([
|
||||
"battery",
|
||||
"carbon_dioxide",
|
||||
"carbon_monoxide",
|
||||
"humidity",
|
||||
"illuminance",
|
||||
"pm1",
|
||||
"pm10",
|
||||
"pm25",
|
||||
"power",
|
||||
"pressure",
|
||||
"temperature",
|
||||
"volatile_organic_compounds",
|
||||
"wind_speed",
|
||||
]);
|
||||
|
||||
const SENSOR_TREND_VARIANTS: TileVariant[] = [
|
||||
TILE_VARIANT,
|
||||
{ id: "tile_trend_graph", features: ["trend-graph"] },
|
||||
];
|
||||
|
||||
// Domains with a dedicated card-suggestions provider; skip the tile
|
||||
// fallback so the dedicated card wins.
|
||||
const EXCLUDED_DOMAINS = new Set(["calendar", "todo"]);
|
||||
|
||||
const getVariants = (
|
||||
hass: HomeAssistant,
|
||||
entityId: string
|
||||
): TileVariant[] | undefined => {
|
||||
const domain = computeDomain(entityId);
|
||||
if (domain === "sensor") {
|
||||
const deviceClass = hass.states[entityId]?.attributes.device_class;
|
||||
if (deviceClass && SENSOR_TREND_DEVICE_CLASSES.has(deviceClass)) {
|
||||
return SENSOR_TREND_VARIANTS;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return DOMAIN_VARIANTS[domain];
|
||||
};
|
||||
|
||||
const buildTileConfig = (
|
||||
entityId: string,
|
||||
features: UiFeatureType[]
|
||||
): TileCardConfig => {
|
||||
const config: TileCardConfig = { type: "tile", entity: entityId };
|
||||
if (features.length) {
|
||||
config.features = features.map(
|
||||
(type) => ({ type }) as LovelaceCardFeatureConfig
|
||||
);
|
||||
}
|
||||
return config;
|
||||
};
|
||||
|
||||
// A throwing supportsX would invalidate the variant; treat it as unsupported
|
||||
// rather than tearing down the whole suggestion list.
|
||||
const allFeaturesSupported = (
|
||||
hass: HomeAssistant,
|
||||
entityId: string,
|
||||
features: UiFeatureType[]
|
||||
): boolean =>
|
||||
features.every((type) => {
|
||||
try {
|
||||
return SUPPORTS_FEATURE_TYPES[type](hass, { entity_id: entityId });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
export const tileCardSuggestions: CardSuggestionProvider<TileCardConfig> = {
|
||||
getEntitySuggestion(hass, entityId) {
|
||||
if (EXCLUDED_DOMAINS.has(computeDomain(entityId))) return null;
|
||||
const variants = getVariants(hass, entityId) ?? [DEFAULT_VARIANT];
|
||||
const suggestions: CardSuggestion<TileCardConfig>[] = [];
|
||||
for (const variant of variants) {
|
||||
if (!allFeaturesSupported(hass, entityId, variant.features)) continue;
|
||||
suggestions.push({
|
||||
id: variant.id,
|
||||
label: hass.localize(`${LABEL_PREFIX}${variant.id}`),
|
||||
config: buildTileConfig(entityId, variant.features),
|
||||
});
|
||||
}
|
||||
return suggestions.length ? suggestions : null;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import type { TodoListCardConfig } from "../cards/types";
|
||||
import type { CardSuggestionProvider } from "./types";
|
||||
|
||||
export const todoListCardSuggestions: CardSuggestionProvider<TodoListCardConfig> =
|
||||
{
|
||||
getEntitySuggestion(hass, entityId) {
|
||||
if (computeDomain(entityId) !== "todo") return null;
|
||||
return {
|
||||
id: "todo-list",
|
||||
label: hass.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.suggestions.todo_list"
|
||||
),
|
||||
config: { type: "todo-list", entity: entityId },
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { CARD_SUGGESTION_PROVIDERS } from "./registry";
|
||||
import type { CardSuggestion } from "./types";
|
||||
|
||||
export type { CardSuggestion, CardSuggestionProvider } from "./types";
|
||||
export { CARD_SUGGESTION_PROVIDERS } from "./registry";
|
||||
|
||||
export const generateCardSuggestions = (
|
||||
hass: HomeAssistant,
|
||||
entityId: string | undefined
|
||||
): CardSuggestion[] => {
|
||||
if (!entityId || hass.states[entityId] === undefined) return [];
|
||||
return Object.values(CARD_SUGGESTION_PROVIDERS).flatMap((provider) => {
|
||||
try {
|
||||
const result = provider.getEntitySuggestion(hass, entityId);
|
||||
if (!result) return [];
|
||||
return Array.isArray(result) ? result : [result];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { calendarCardSuggestions } from "./hui-calendar-card-suggestions";
|
||||
import { tileCardSuggestions } from "./hui-tile-card-suggestions";
|
||||
import { todoListCardSuggestions } from "./hui-todo-list-card-suggestions";
|
||||
import type { CardSuggestionProvider } from "./types";
|
||||
|
||||
export const CARD_SUGGESTION_PROVIDERS: Record<string, CardSuggestionProvider> =
|
||||
{
|
||||
tile: tileCardSuggestions,
|
||||
calendar: calendarCardSuggestions,
|
||||
"todo-list": todoListCardSuggestions,
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
export interface CardSuggestion<
|
||||
T extends LovelaceCardConfig = LovelaceCardConfig,
|
||||
> {
|
||||
id: string;
|
||||
label: string;
|
||||
config: T;
|
||||
}
|
||||
|
||||
export interface CardSuggestionProvider<
|
||||
T extends LovelaceCardConfig = LovelaceCardConfig,
|
||||
> {
|
||||
getEntitySuggestion(
|
||||
hass: HomeAssistant,
|
||||
entityId: string
|
||||
): CardSuggestion<T> | CardSuggestion<T>[] | null;
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import {
|
||||
mdiHomeAssistant,
|
||||
mdiPuzzle,
|
||||
mdiShape,
|
||||
mdiToggleSwitch,
|
||||
} from "@mdi/js";
|
||||
import type { FuseIndex } from "fuse.js";
|
||||
import Fuse from "fuse.js";
|
||||
import { computeAreaName } from "../../../../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import { computeEntityName } from "../../../../common/entity/compute_entity_name";
|
||||
import { computeStateName } from "../../../../common/entity/compute_state_name";
|
||||
import { stringCompare } from "../../../../common/string/compare";
|
||||
import { entityComboBoxKeys } from "../../../../data/entity/entity_picker";
|
||||
import { isUnavailableState } from "../../../../data/entity/entity";
|
||||
import { getFloorAreaLookup } from "../../../../data/floor_registry";
|
||||
import { domainToName } from "../../../../data/integration";
|
||||
import { multiTermSortedSearch } from "../../../../resources/fuseMultiTerm";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { isHelperDomain } from "../../../config/helpers/const";
|
||||
|
||||
export interface DeviceNode {
|
||||
id: string;
|
||||
name: string;
|
||||
entityIds: string[];
|
||||
}
|
||||
|
||||
export interface AreaNode {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string;
|
||||
directEntityIds: string[];
|
||||
devices: DeviceNode[];
|
||||
}
|
||||
|
||||
export interface FloorNode {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
level: number | null;
|
||||
areas: AreaNode[];
|
||||
}
|
||||
|
||||
export interface DomainGroup {
|
||||
domain: string;
|
||||
name: string;
|
||||
entityIds: string[];
|
||||
}
|
||||
|
||||
// Display fields are flat; search_labels match ha-entity-picker so the
|
||||
// fuzzy weights and behavior stay consistent across pickers.
|
||||
export interface SearchableEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
area: string;
|
||||
device: string;
|
||||
domain: string;
|
||||
search_labels: {
|
||||
entityName: string | null;
|
||||
friendlyName: string | null;
|
||||
deviceName: string | null;
|
||||
areaName: string | null;
|
||||
domainName: string | null;
|
||||
entityId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UnassignedSection {
|
||||
id: "entities" | "helpers" | "devices" | "services";
|
||||
label: string;
|
||||
iconPath: string;
|
||||
domains?: DomainGroup[];
|
||||
devices?: DeviceNode[];
|
||||
}
|
||||
|
||||
export interface EntityTree {
|
||||
floors: FloorNode[];
|
||||
otherAreas: AreaNode[];
|
||||
unassignedSections: UnassignedSection[];
|
||||
searchableEntities: SearchableEntity[];
|
||||
}
|
||||
|
||||
export type EntityFuseIndex = FuseIndex<SearchableEntity>;
|
||||
|
||||
// Pre-built so the first keystroke in the search input doesn't trigger
|
||||
// Fuse.createIndex (50-100ms on large registries).
|
||||
export const buildSearchIndex = (
|
||||
entities: SearchableEntity[]
|
||||
): EntityFuseIndex => Fuse.createIndex(FUSE_KEY_NAMES, entities);
|
||||
|
||||
export const OTHER_AREAS_ID = "__other_areas__";
|
||||
const SEP = "~";
|
||||
|
||||
export const floorKey = (id: string) => `f|${id}`;
|
||||
export const unassignedKey = (id: string) => `u|${id}`;
|
||||
export const areaKey = (parent: string, id: string) => `${parent}${SEP}a|${id}`;
|
||||
export const deviceKey = (parent: string, id: string) =>
|
||||
`${parent}${SEP}d|${id}`;
|
||||
export const domainKey = (parent: string, domain: string) =>
|
||||
`${parent}${SEP}dom|${domain}`;
|
||||
export const childKeyPrefix = (key: string) => `${key}${SEP}`;
|
||||
|
||||
const FUSE_KEY_NAMES = entityComboBoxKeys.map((k) => k.name as string);
|
||||
|
||||
export function buildEntityTree(
|
||||
hass: HomeAssistant,
|
||||
// Override for callers that loaded translations themselves and need to
|
||||
// bypass the parent's possibly-stale hass.localize.
|
||||
localize: HomeAssistant["localize"] = hass.localize
|
||||
): EntityTree {
|
||||
const {
|
||||
states,
|
||||
entities: entityReg,
|
||||
devices: deviceReg,
|
||||
areas: areaReg,
|
||||
floors: floorReg,
|
||||
} = hass;
|
||||
const language = hass.locale?.language;
|
||||
|
||||
const areaDirectEntities = new Map<string, string[]>();
|
||||
const areaDeviceEntities = new Map<string, Map<string, string[]>>();
|
||||
const unassignedDeviceEntities = new Map<string, string[]>();
|
||||
const unassignedServiceEntities = new Map<string, string[]>();
|
||||
const unassignedHelperByDomain = new Map<string, string[]>();
|
||||
const unassignedEntityByDomain = new Map<string, string[]>();
|
||||
const searchableEntities: SearchableEntity[] = [];
|
||||
|
||||
for (const entityId of Object.keys(states)) {
|
||||
const stateObj = states[entityId];
|
||||
if (!stateObj || isUnavailableState(stateObj.state)) continue;
|
||||
|
||||
const entry = entityReg[entityId];
|
||||
if (entry?.hidden) continue;
|
||||
|
||||
const device = entry?.device_id ? deviceReg[entry.device_id] : undefined;
|
||||
const areaId = entry?.area_id ?? device?.area_id;
|
||||
const area = areaId ? areaReg[areaId] : undefined;
|
||||
const domain = computeDomain(entityId);
|
||||
|
||||
const entityName = computeEntityName(stateObj, entityReg, deviceReg);
|
||||
const friendlyName = computeStateName(stateObj);
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
const domainName = domainToName(localize, domain);
|
||||
|
||||
searchableEntities.push({
|
||||
id: entityId,
|
||||
name: entityName || friendlyName || entityId,
|
||||
area: areaName ?? "",
|
||||
device: deviceName ?? "",
|
||||
domain: domainName,
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
friendlyName: friendlyName || null,
|
||||
deviceName: deviceName || null,
|
||||
areaName: areaName || null,
|
||||
domainName: domainName || null,
|
||||
entityId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!areaId || !areaReg[areaId]) {
|
||||
if (device) {
|
||||
const isService = device.entry_type === "service";
|
||||
const target = isService
|
||||
? unassignedServiceEntities
|
||||
: unassignedDeviceEntities;
|
||||
const list = target.get(device.id) ?? [];
|
||||
list.push(entityId);
|
||||
target.set(device.id, list);
|
||||
} else if (isHelperDomain(domain)) {
|
||||
const list = unassignedHelperByDomain.get(domain) ?? [];
|
||||
list.push(entityId);
|
||||
unassignedHelperByDomain.set(domain, list);
|
||||
} else {
|
||||
const list = unassignedEntityByDomain.get(domain) ?? [];
|
||||
list.push(entityId);
|
||||
unassignedEntityByDomain.set(domain, list);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const groupUnderDevice = device && !entry?.area_id;
|
||||
if (groupUnderDevice) {
|
||||
const byDevice = areaDeviceEntities.get(areaId) ?? new Map();
|
||||
const list = byDevice.get(device!.id) ?? [];
|
||||
list.push(entityId);
|
||||
byDevice.set(device!.id, list);
|
||||
areaDeviceEntities.set(areaId, byDevice);
|
||||
} else {
|
||||
const list = areaDirectEntities.get(areaId) ?? [];
|
||||
list.push(entityId);
|
||||
areaDirectEntities.set(areaId, list);
|
||||
}
|
||||
}
|
||||
|
||||
const sortByName = (a: string, b: string) => {
|
||||
const an = computeStateName(states[a]) || a;
|
||||
const bn = computeStateName(states[b]) || b;
|
||||
return stringCompare(an, bn, language);
|
||||
};
|
||||
|
||||
const buildAreaNode = (areaId: string): AreaNode | undefined => {
|
||||
const area = areaReg[areaId];
|
||||
if (!area) return undefined;
|
||||
const directIds = (areaDirectEntities.get(areaId) ?? []).sort(sortByName);
|
||||
const byDevice = areaDeviceEntities.get(areaId);
|
||||
const devices: DeviceNode[] = byDevice
|
||||
? [...byDevice.entries()]
|
||||
.map(([id, ids]) => {
|
||||
const device = deviceReg[id];
|
||||
return {
|
||||
id,
|
||||
name: (device ? computeDeviceName(device) : undefined) ?? id,
|
||||
entityIds: ids.sort(sortByName),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => stringCompare(a.name, b.name, language))
|
||||
: [];
|
||||
if (!directIds.length && !devices.length) return undefined;
|
||||
return {
|
||||
id: area.area_id,
|
||||
name: computeAreaName(area) ?? area.area_id,
|
||||
icon: area.icon ?? undefined,
|
||||
directEntityIds: directIds,
|
||||
devices,
|
||||
};
|
||||
};
|
||||
|
||||
const areas = Object.values(areaReg);
|
||||
const floors = Object.values(floorReg);
|
||||
const floorAreaLookup = getFloorAreaLookup(areas);
|
||||
|
||||
const floorNodes: FloorNode[] = floors
|
||||
.map((floor) => {
|
||||
const areaList = (floorAreaLookup[floor.floor_id] ?? [])
|
||||
.map((a) => buildAreaNode(a.area_id))
|
||||
.filter((a): a is AreaNode => !!a)
|
||||
.sort((a, b) => stringCompare(a.name, b.name, language));
|
||||
if (!areaList.length) return undefined;
|
||||
return {
|
||||
id: floor.floor_id,
|
||||
name: floor.name,
|
||||
icon: floor.icon,
|
||||
level: floor.level,
|
||||
areas: areaList,
|
||||
};
|
||||
})
|
||||
.filter((f): f is FloorNode => !!f)
|
||||
.sort((a, b) => stringCompare(a.name, b.name, language));
|
||||
|
||||
const otherAreas = areas
|
||||
.filter((a) => !a.floor_id || !floorReg[a.floor_id])
|
||||
.map((a) => buildAreaNode(a.area_id))
|
||||
.filter((a): a is AreaNode => !!a)
|
||||
.sort((a, b) => stringCompare(a.name, b.name, language));
|
||||
|
||||
const buildDeviceNodes = (source: Map<string, string[]>): DeviceNode[] =>
|
||||
[...source.entries()]
|
||||
.map(([id, ids]) => {
|
||||
const device = deviceReg[id];
|
||||
return {
|
||||
id,
|
||||
name: (device ? computeDeviceName(device) : undefined) ?? id,
|
||||
entityIds: ids.sort(sortByName),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => stringCompare(a.name, b.name, language));
|
||||
|
||||
const buildDomainGroups = (source: Map<string, string[]>): DomainGroup[] =>
|
||||
[...source.entries()]
|
||||
.map(([domain, ids]) => ({
|
||||
domain,
|
||||
name: domainToName(localize, domain),
|
||||
entityIds: ids.sort(sortByName),
|
||||
}))
|
||||
.sort((a, b) => stringCompare(a.name, b.name, language));
|
||||
|
||||
const unassignedSections: UnassignedSection[] = [];
|
||||
const entityDomains = buildDomainGroups(unassignedEntityByDomain);
|
||||
if (entityDomains.length) {
|
||||
unassignedSections.push({
|
||||
id: "entities",
|
||||
iconPath: mdiShape,
|
||||
label: localize("ui.panel.lovelace.editor.cardpicker.entities"),
|
||||
domains: entityDomains,
|
||||
});
|
||||
}
|
||||
const helperDomains = buildDomainGroups(unassignedHelperByDomain);
|
||||
if (helperDomains.length) {
|
||||
unassignedSections.push({
|
||||
id: "helpers",
|
||||
iconPath: mdiToggleSwitch,
|
||||
label: localize("ui.panel.lovelace.editor.cardpicker.helpers"),
|
||||
domains: helperDomains,
|
||||
});
|
||||
}
|
||||
const orphanDevices = buildDeviceNodes(unassignedDeviceEntities);
|
||||
if (orphanDevices.length) {
|
||||
unassignedSections.push({
|
||||
id: "devices",
|
||||
iconPath: mdiPuzzle,
|
||||
label: localize("ui.panel.lovelace.editor.cardpicker.devices"),
|
||||
devices: orphanDevices,
|
||||
});
|
||||
}
|
||||
const orphanServices = buildDeviceNodes(unassignedServiceEntities);
|
||||
if (orphanServices.length) {
|
||||
unassignedSections.push({
|
||||
id: "services",
|
||||
iconPath: mdiHomeAssistant,
|
||||
label: localize("ui.panel.lovelace.editor.cardpicker.services"),
|
||||
devices: orphanServices,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
floors: floorNodes,
|
||||
otherAreas,
|
||||
unassignedSections,
|
||||
searchableEntities,
|
||||
};
|
||||
}
|
||||
|
||||
export function pathToEntity(tree: EntityTree, entityId: string): string[] {
|
||||
for (const floor of tree.floors) {
|
||||
const fKey = floorKey(floor.id);
|
||||
for (const area of floor.areas) {
|
||||
const aKey = areaKey(fKey, area.id);
|
||||
if (area.directEntityIds.includes(entityId)) return [fKey, aKey];
|
||||
for (const device of area.devices) {
|
||||
if (device.entityIds.includes(entityId)) {
|
||||
return [fKey, aKey, deviceKey(aKey, device.id)];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const otherAreasFloor = floorKey(OTHER_AREAS_ID);
|
||||
for (const area of tree.otherAreas) {
|
||||
const aKey = areaKey(otherAreasFloor, area.id);
|
||||
if (area.directEntityIds.includes(entityId)) {
|
||||
return [otherAreasFloor, aKey];
|
||||
}
|
||||
for (const device of area.devices) {
|
||||
if (device.entityIds.includes(entityId)) {
|
||||
return [otherAreasFloor, aKey, deviceKey(aKey, device.id)];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const section of tree.unassignedSections) {
|
||||
const sKey = unassignedKey(section.id);
|
||||
if (section.devices) {
|
||||
for (const device of section.devices) {
|
||||
if (device.entityIds.includes(entityId)) {
|
||||
return [sKey, deviceKey(sKey, device.id)];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (section.domains) {
|
||||
for (const group of section.domains) {
|
||||
if (group.entityIds.includes(entityId)) {
|
||||
return [sKey, domainKey(sKey, group.domain)];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function searchEntities(
|
||||
entities: SearchableEntity[],
|
||||
index: EntityFuseIndex,
|
||||
filter: string,
|
||||
limit = 100
|
||||
): SearchableEntity[] {
|
||||
if (!filter) return [];
|
||||
return multiTermSortedSearch(
|
||||
entities,
|
||||
filter,
|
||||
entityComboBoxKeys,
|
||||
(item) => item.id,
|
||||
index
|
||||
).slice(0, limit);
|
||||
}
|
||||
@@ -516,7 +516,7 @@ export class HuiCardPicker extends LitElement {
|
||||
}
|
||||
|
||||
ha-input-search {
|
||||
padding: var(--ha-space-3) var(--ha-space-4) 0;
|
||||
padding: var(--ha-space-3) var(--ha-space-3) 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { cache } from "lit/directives/cache";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog-header";
|
||||
@@ -11,36 +10,18 @@ import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-tab-group";
|
||||
import "../../../../components/ha-tab-group-tab";
|
||||
import "../../../../components/ha-dialog";
|
||||
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
|
||||
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
|
||||
import { isStrategySection } from "../../../../data/lovelace/config/section";
|
||||
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
|
||||
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
|
||||
import { haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
computeCards,
|
||||
computeSection,
|
||||
} from "../../common/generate-lovelace-config";
|
||||
import { addCard } from "../config-util";
|
||||
import {
|
||||
findLovelaceContainer,
|
||||
parseLovelaceContainerPath,
|
||||
} from "../lovelace-path";
|
||||
import { findLovelaceContainer } from "../lovelace-path";
|
||||
import "./hui-card-picker";
|
||||
import "./hui-entity-picker-table";
|
||||
import "./hui-suggestion-picker";
|
||||
import type { CreateCardDialogParams } from "./show-create-card-dialog";
|
||||
import { showEditCardDialog } from "./show-edit-card-dialog";
|
||||
import { showSuggestCardDialog } from "./show-suggest-card-dialog";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"selected-changed": SelectedChangedEvent;
|
||||
}
|
||||
}
|
||||
|
||||
interface SelectedChangedEvent {
|
||||
selectedEntities: string[];
|
||||
}
|
||||
|
||||
@customElement("hui-dialog-create-card")
|
||||
export class HuiCreateDialogCard
|
||||
@@ -57,9 +38,7 @@ export class HuiCreateDialogCard
|
||||
| LovelaceViewConfig
|
||||
| LovelaceSectionConfig;
|
||||
|
||||
@state() private _selectedEntities: string[] = [];
|
||||
|
||||
@state() private _currTab: "card" | "entity" = "card";
|
||||
@state() private _currTab: "card" | "entity" = "entity";
|
||||
|
||||
@state() private _narrow = false;
|
||||
|
||||
@@ -91,8 +70,7 @@ export class HuiCreateDialogCard
|
||||
private _dialogClosed(): void {
|
||||
this._open = false;
|
||||
this._params = undefined;
|
||||
this._currTab = "card";
|
||||
this._selectedEntities = [];
|
||||
this._currTab = "entity";
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
@@ -115,7 +93,6 @@ export class HuiCreateDialogCard
|
||||
width="large"
|
||||
@keydown=${this._ignoreKeydown}
|
||||
@closed=${this._dialogClosed}
|
||||
class=${classMap({ table: this._currTab === "entity" })}
|
||||
>
|
||||
<ha-dialog-header show-border slot="header">
|
||||
<ha-icon-button
|
||||
@@ -129,31 +106,39 @@ export class HuiCreateDialogCard
|
||||
${!this._params.saveCard
|
||||
? html`
|
||||
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.active=${this._currTab === "entity"}
|
||||
panel="entity"
|
||||
?autofocus=${this._narrow}
|
||||
>${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.by_entity"
|
||||
)}</ha-tab-group-tab
|
||||
>
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.active=${this._currTab === "card"}
|
||||
panel="card"
|
||||
?autofocus=${this._narrow}
|
||||
>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.by_card"
|
||||
)}
|
||||
</ha-tab-group-tab>
|
||||
<ha-tab-group-tab
|
||||
slot="nav"
|
||||
.active=${this._currTab === "entity"}
|
||||
panel="entity"
|
||||
>${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.by_entity"
|
||||
)}</ha-tab-group-tab
|
||||
>
|
||||
</ha-tab-group>
|
||||
`
|
||||
: nothing}
|
||||
</ha-dialog-header>
|
||||
${cache(
|
||||
this._currTab === "card"
|
||||
this._currTab === "entity"
|
||||
? html`
|
||||
<hui-suggestion-picker
|
||||
.hass=${this.hass}
|
||||
.prioritizedCardTypes=${this._params.suggestedCards}
|
||||
@suggestion-picked=${this._handleSuggestionPicked}
|
||||
@browse-cards=${this._handleBrowseCards}
|
||||
></hui-suggestion-picker>
|
||||
`
|
||||
: html`
|
||||
<hui-card-picker
|
||||
?autofocus=${!this._narrow}
|
||||
.suggestedCards=${this._params.suggestedCards}
|
||||
@@ -162,13 +147,6 @@ export class HuiCreateDialogCard
|
||||
@config-changed=${this._handleCardPicked}
|
||||
></hui-card-picker>
|
||||
`
|
||||
: html`
|
||||
<hui-entity-picker-table
|
||||
.hass=${this.hass}
|
||||
narrow
|
||||
@selected-changed=${this._handleSelectedChanged}
|
||||
></hui-entity-picker-table>
|
||||
`
|
||||
)}
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
@@ -179,13 +157,6 @@ export class HuiCreateDialogCard
|
||||
>
|
||||
${this.hass!.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
${this._selectedEntities.length
|
||||
? html`
|
||||
<ha-button slot="primaryAction" @click=${this._suggestCards}>
|
||||
${this.hass!.localize("ui.common.continue")}
|
||||
</ha-button>
|
||||
`
|
||||
: ""}
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
@@ -204,13 +175,12 @@ export class HuiCreateDialogCard
|
||||
--dialog-z-index: 6;
|
||||
}
|
||||
|
||||
ha-dialog.table {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
|
||||
ha-dialog::part(body) {
|
||||
overflow: hidden;
|
||||
}
|
||||
ha-dialog-footer {
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
ha-tab-group-tab {
|
||||
flex: 1;
|
||||
@@ -226,18 +196,13 @@ export class HuiCreateDialogCard
|
||||
}
|
||||
|
||||
hui-card-picker,
|
||||
hui-entity-picker-table {
|
||||
hui-suggestion-picker {
|
||||
height: calc(100vh - 198px);
|
||||
}
|
||||
|
||||
hui-entity-picker-table {
|
||||
display: block;
|
||||
--mdc-shape-small: 0;
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
hui-card-picker,
|
||||
hui-entity-picker-table {
|
||||
hui-suggestion-picker {
|
||||
height: calc(100vh - 158px);
|
||||
}
|
||||
}
|
||||
@@ -245,6 +210,28 @@ export class HuiCreateDialogCard
|
||||
];
|
||||
}
|
||||
|
||||
private _handleBrowseCards(): void {
|
||||
this._currTab = "card";
|
||||
}
|
||||
|
||||
private async _handleSuggestionPicked(
|
||||
ev: CustomEvent<{ config: LovelaceCardConfig }>
|
||||
): Promise<void> {
|
||||
const config = ev.detail.config;
|
||||
if (this._params!.saveCard) {
|
||||
await this._params!.saveCard(config);
|
||||
this.closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
const lovelaceConfig = this._params!.lovelaceConfig;
|
||||
const containerPath = this._params!.path;
|
||||
const saveConfig = this._params!.saveConfig;
|
||||
const newConfig = addCard(lovelaceConfig, containerPath, config);
|
||||
await saveConfig(newConfig);
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
private _handleCardPicked(ev) {
|
||||
const config = ev.detail.config;
|
||||
if (this._params!.entities && this._params!.entities.length) {
|
||||
@@ -294,13 +281,7 @@ export class HuiCreateDialogCard
|
||||
if (newTab === this._currTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._currTab = newTab;
|
||||
this._selectedEntities = [];
|
||||
}
|
||||
|
||||
private _handleSelectedChanged(ev: CustomEvent): void {
|
||||
this._selectedEntities = ev.detail.selectedEntities;
|
||||
}
|
||||
|
||||
private _cancel(ev?: Event) {
|
||||
@@ -309,45 +290,6 @@ export class HuiCreateDialogCard
|
||||
}
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
private _suggestCards(): void {
|
||||
const cardConfig = computeCards(this.hass, this._selectedEntities, {});
|
||||
|
||||
let sectionOptions: Partial<LovelaceSectionConfig> = {};
|
||||
|
||||
const { viewIndex, sectionIndex } = parseLovelaceContainerPath(
|
||||
this._params!.path
|
||||
);
|
||||
const isSection = sectionIndex !== undefined;
|
||||
|
||||
// If we are in a section, we want to keep the section options for the preview
|
||||
if (isSection) {
|
||||
const containerConfig = findLovelaceContainer(
|
||||
this._params!.lovelaceConfig!,
|
||||
[viewIndex, sectionIndex]
|
||||
);
|
||||
if (!isStrategySection(containerConfig)) {
|
||||
const { cards, title, ...rest } = containerConfig;
|
||||
sectionOptions = rest;
|
||||
}
|
||||
}
|
||||
|
||||
const sectionConfig = computeSection(
|
||||
this._selectedEntities,
|
||||
sectionOptions
|
||||
);
|
||||
|
||||
showSuggestCardDialog(this, {
|
||||
lovelaceConfig: this._params!.lovelaceConfig,
|
||||
saveConfig: this._params!.saveConfig,
|
||||
path: this._params!.path as [number],
|
||||
entities: this._selectedEntities,
|
||||
cardConfig,
|
||||
sectionConfig,
|
||||
});
|
||||
|
||||
this.closeDialog();
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-ripple";
|
||||
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import "../../cards/hui-card";
|
||||
import type { CardSuggestion } from "../../card-suggestions/types";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"pick-suggestion": { suggestion: CardSuggestion };
|
||||
}
|
||||
}
|
||||
|
||||
const PREVIEW_ITEM_CAP = 6;
|
||||
|
||||
interface PreviewState {
|
||||
config: LovelaceCardConfig;
|
||||
hiddenCount: number;
|
||||
}
|
||||
|
||||
const buildPreview = (config: LovelaceCardConfig): PreviewState => {
|
||||
const c = config as LovelaceCardConfig & {
|
||||
cards?: LovelaceCardConfig[];
|
||||
entities?: unknown[];
|
||||
};
|
||||
const items = c.cards ?? c.entities;
|
||||
if (!items || items.length <= PREVIEW_ITEM_CAP) {
|
||||
return { config, hiddenCount: 0 };
|
||||
}
|
||||
const slice = items.slice(0, PREVIEW_ITEM_CAP);
|
||||
const truncated = c.cards
|
||||
? { ...config, cards: slice }
|
||||
: { ...config, entities: slice };
|
||||
return { config: truncated, hiddenCount: items.length - PREVIEW_ITEM_CAP };
|
||||
};
|
||||
|
||||
@customElement("hui-suggestion-card")
|
||||
export class HuiSuggestionCard extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public suggestion!: CardSuggestion;
|
||||
|
||||
@state() private _preview?: PreviewState;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (!changedProps.has("suggestion")) {
|
||||
return;
|
||||
}
|
||||
this._preview = this.suggestion?.config
|
||||
? buildPreview(this.suggestion.config)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const { suggestion } = this;
|
||||
const hiddenCount = this._preview?.hiddenCount ?? 0;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="card"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label=${suggestion.label}
|
||||
@click=${this._handleClick}
|
||||
@keydown=${this._handleKeyDown}
|
||||
>
|
||||
<div class="card-header">${suggestion.label}</div>
|
||||
<div class="preview">
|
||||
${this._preview
|
||||
? html`
|
||||
<hui-card
|
||||
.hass=${this.hass}
|
||||
.config=${this._preview.config}
|
||||
preview
|
||||
></hui-card>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
${hiddenCount > 0
|
||||
? html`
|
||||
<div class="more-badge">
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.more_cards",
|
||||
{ count: hiddenCount }
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
<ha-ripple></ha-ripple>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleClick(): void {
|
||||
fireEvent(this, "pick-suggestion", { suggestion: this.suggestion });
|
||||
}
|
||||
|
||||
private _handleKeyDown(ev: KeyboardEvent): void {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
this._handleClick();
|
||||
}
|
||||
}
|
||||
|
||||
static readonly styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
.card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
|
||||
background: var(--primary-background-color, #fafafa);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: var(--ha-card-border-width, 1px) solid
|
||||
var(--ha-card-border-color, var(--divider-color));
|
||||
}
|
||||
.card:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.card-header {
|
||||
color: var(--ha-card-header-color, var(--primary-text-color));
|
||||
font-family: var(--ha-card-header-font-family, inherit);
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
padding: var(--ha-space-3) var(--ha-space-4);
|
||||
text-align: center;
|
||||
}
|
||||
.preview {
|
||||
pointer-events: none;
|
||||
margin: var(--ha-space-4);
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.preview > :first-child {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.more-badge {
|
||||
margin: 0 var(--ha-space-4) var(--ha-space-3);
|
||||
padding: var(--ha-space-1) var(--ha-space-2);
|
||||
align-self: flex-start;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
background: var(--ha-color-fill-neutral-quiet-resting);
|
||||
color: var(--ha-color-text-secondary);
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-suggestion-card": HuiSuggestionCard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
import {
|
||||
mdiChevronDown,
|
||||
mdiChevronRight,
|
||||
mdiMagnify,
|
||||
mdiTextureBox,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { computeEntityName } from "../../../../common/entity/compute_entity_name";
|
||||
import { computeStateName } from "../../../../common/entity/compute_state_name";
|
||||
import { computeRTL } from "../../../../common/util/compute_rtl";
|
||||
import { debounce } from "../../../../common/util/debounce";
|
||||
import "../../../../components/entity/state-badge";
|
||||
import "../../../../components/ha-combo-box-item";
|
||||
import "../../../../components/ha-domain-icon";
|
||||
import "../../../../components/ha-floor-icon";
|
||||
import "../../../../components/ha-icon";
|
||||
import "../../../../components/ha-section-title";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/input/ha-input-search";
|
||||
import type { HaInputSearch } from "../../../../components/input/ha-input-search";
|
||||
import type { ConfigEntry } from "../../../../data/config_entries";
|
||||
import { getConfigEntries } from "../../../../data/config_entries";
|
||||
import { haStyleScrollbar } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type {
|
||||
AreaNode,
|
||||
DeviceNode,
|
||||
DomainGroup,
|
||||
EntityFuseIndex,
|
||||
EntityTree,
|
||||
FloorNode,
|
||||
SearchableEntity,
|
||||
UnassignedSection,
|
||||
} from "./entity-tree-builder";
|
||||
import {
|
||||
areaKey,
|
||||
buildEntityTree,
|
||||
buildSearchIndex,
|
||||
childKeyPrefix,
|
||||
deviceKey,
|
||||
domainKey,
|
||||
floorKey,
|
||||
OTHER_AREAS_ID,
|
||||
pathToEntity,
|
||||
searchEntities,
|
||||
unassignedKey,
|
||||
} from "./entity-tree-builder";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"entity-picked": { entityId: string };
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("hui-suggestion-entity-tree")
|
||||
export class HuiSuggestionEntityTree extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selectedEntityId?: string;
|
||||
|
||||
@state() private _filter = "";
|
||||
|
||||
@state() private _expanded: Record<string, boolean> = {};
|
||||
|
||||
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
|
||||
|
||||
// Captured from the load promise to avoid racing parent hass propagation.
|
||||
@state() private _domainLocalize?: HomeAssistant["localize"];
|
||||
|
||||
// Built once; rebuilding the structure and Fuse index on each hass tick
|
||||
// would freeze the picker on large registries.
|
||||
@state() private _tree?: EntityTree;
|
||||
|
||||
@state() private _fuseIndex?: EntityFuseIndex;
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this.hass && !Object.keys(this._configEntryLookup).length) {
|
||||
this._loadConfigEntries();
|
||||
}
|
||||
this._loadDomainTranslations();
|
||||
}
|
||||
|
||||
private async _loadDomainTranslations() {
|
||||
if (!this.hass) return;
|
||||
this._domainLocalize = await this.hass.loadBackendTranslation("title");
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._setFilter.cancel();
|
||||
}
|
||||
|
||||
private async _loadConfigEntries() {
|
||||
if (!this.hass) return;
|
||||
try {
|
||||
const entries = await getConfigEntries(this.hass);
|
||||
const lookup: Record<string, ConfigEntry> = {};
|
||||
for (const entry of entries) {
|
||||
lookup[entry.entry_id] = entry;
|
||||
}
|
||||
this._configEntryLookup = lookup;
|
||||
} catch (_err) {
|
||||
// Device rows will fall back to a generic icon
|
||||
}
|
||||
}
|
||||
|
||||
private _deviceDomain(deviceId: string): string | undefined {
|
||||
const device = this.hass?.devices[deviceId];
|
||||
if (!device?.primary_config_entry) return undefined;
|
||||
return this._configEntryLookup[device.primary_config_entry]?.domain;
|
||||
}
|
||||
|
||||
private _searchMemo = memoizeOne(searchEntities);
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (!this._tree && this.hass && this._domainLocalize) {
|
||||
this._tree = buildEntityTree(this.hass, this._domainLocalize);
|
||||
this._fuseIndex = buildSearchIndex(this._tree.searchableEntities);
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this._tree) return nothing;
|
||||
|
||||
return html`
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
.placeholder=${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.search_entities"
|
||||
)}
|
||||
@input=${this._handleFilterChange}
|
||||
></ha-input-search>
|
||||
<div class="tree ha-scrollbar">
|
||||
${this._filter
|
||||
? this._renderSearchResults()
|
||||
: this._renderTree(this._tree)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _isExpanded(key: string): boolean {
|
||||
return this._expanded[key] ?? false;
|
||||
}
|
||||
|
||||
private _renderTree(tree: EntityTree): TemplateResult {
|
||||
return html`
|
||||
${tree.floors.length || tree.otherAreas.length
|
||||
? html`
|
||||
<ha-section-title>
|
||||
${this.hass.localize("ui.panel.lovelace.editor.cardpicker.home")}
|
||||
</ha-section-title>
|
||||
${repeat(
|
||||
tree.floors,
|
||||
(floor: FloorNode) => floor.id,
|
||||
(floor: FloorNode) => this._renderFloor(floor, false)
|
||||
)}
|
||||
${tree.otherAreas.length
|
||||
? this._renderFloor(
|
||||
{
|
||||
id: OTHER_AREAS_ID,
|
||||
name: this.hass.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.other_areas"
|
||||
),
|
||||
icon: null,
|
||||
level: null,
|
||||
areas: tree.otherAreas,
|
||||
},
|
||||
true
|
||||
)
|
||||
: nothing}
|
||||
`
|
||||
: nothing}
|
||||
${tree.unassignedSections.length
|
||||
? html`
|
||||
<ha-section-title>
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.unassigned"
|
||||
)}
|
||||
</ha-section-title>
|
||||
${repeat(
|
||||
tree.unassignedSections,
|
||||
(section: UnassignedSection) => section.id,
|
||||
(section: UnassignedSection) =>
|
||||
this._renderUnassignedSection(section)
|
||||
)}
|
||||
`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSearchResults(): TemplateResult {
|
||||
const results = this._searchMemo(
|
||||
this._tree!.searchableEntities,
|
||||
this._fuseIndex!,
|
||||
this._filter
|
||||
);
|
||||
if (!results.length) {
|
||||
return html`
|
||||
<div class="empty">
|
||||
<ha-svg-icon .path=${mdiMagnify}></ha-svg-icon>
|
||||
<h2>
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.no_search_results_title"
|
||||
)}
|
||||
</h2>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.no_search_results_description"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
const separator = rtl ? " ◂ " : " ▸ ";
|
||||
return html`
|
||||
${repeat(
|
||||
results,
|
||||
(item: SearchableEntity) => item.id,
|
||||
(item: SearchableEntity, index: number) =>
|
||||
this._renderSearchRow(item, index, separator)
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSearchRow(
|
||||
item: SearchableEntity,
|
||||
index: number,
|
||||
separator: string
|
||||
): TemplateResult {
|
||||
const stateObj = this.hass.states[item.id];
|
||||
const selected = this.selectedEntityId === item.id;
|
||||
const secondary = [item.area, item.device].filter(Boolean).join(separator);
|
||||
return html`
|
||||
<ha-combo-box-item
|
||||
type="button"
|
||||
compact
|
||||
.borderTop=${index !== 0}
|
||||
class="entity-item ${selected ? "selected" : ""}"
|
||||
aria-current=${selected ? "true" : "false"}
|
||||
data-entity-id=${item.id}
|
||||
@click=${this._pickEntity}
|
||||
>
|
||||
${stateObj
|
||||
? html`<state-badge
|
||||
slot="start"
|
||||
.hass=${this.hass}
|
||||
.stateObj=${stateObj}
|
||||
></state-badge>`
|
||||
: nothing}
|
||||
<span slot="headline">${item.name}</span>
|
||||
${secondary
|
||||
? html`<span slot="supporting-text">${secondary}</span>`
|
||||
: nothing}
|
||||
${item.domain
|
||||
? html`<div slot="trailing-supporting-text" class="domain">
|
||||
${item.domain}
|
||||
</div>`
|
||||
: nothing}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderChevron(expanded: boolean): TemplateResult {
|
||||
return html`<ha-svg-icon
|
||||
class="chevron"
|
||||
.path=${expanded ? mdiChevronDown : mdiChevronRight}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
|
||||
private _renderFloor(
|
||||
floor: FloorNode,
|
||||
isUnassigned: boolean
|
||||
): TemplateResult {
|
||||
const key = floorKey(floor.id);
|
||||
const expanded = this._isExpanded(key);
|
||||
return html`
|
||||
<ha-combo-box-item
|
||||
type="button"
|
||||
class="branch depth-root floor-item"
|
||||
aria-expanded=${expanded}
|
||||
data-node-key=${key}
|
||||
@click=${this._toggleNode}
|
||||
>
|
||||
<div slot="start" class="leading">
|
||||
${this._renderChevron(expanded)}
|
||||
${isUnassigned
|
||||
? html`<ha-svg-icon .path=${mdiTextureBox}></ha-svg-icon>`
|
||||
: html`<ha-floor-icon
|
||||
.floor=${{ icon: floor.icon, level: floor.level }}
|
||||
></ha-floor-icon>`}
|
||||
</div>
|
||||
<span slot="headline">${floor.name}</span>
|
||||
</ha-combo-box-item>
|
||||
${expanded
|
||||
? repeat(
|
||||
floor.areas,
|
||||
(area: AreaNode) => area.id,
|
||||
(area: AreaNode) => this._renderArea(area, key)
|
||||
)
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderArea(area: AreaNode, parentKey: string): TemplateResult {
|
||||
const key = areaKey(parentKey, area.id);
|
||||
const expanded = this._isExpanded(key);
|
||||
return html`
|
||||
<ha-combo-box-item
|
||||
type="button"
|
||||
class="branch depth-area area-item"
|
||||
aria-expanded=${expanded}
|
||||
data-node-key=${key}
|
||||
@click=${this._toggleNode}
|
||||
>
|
||||
<div slot="start" class="leading">
|
||||
${this._renderChevron(expanded)}
|
||||
${area.icon
|
||||
? html`<ha-icon .icon=${area.icon}></ha-icon>`
|
||||
: html`<ha-svg-icon .path=${mdiTextureBox}></ha-svg-icon>`}
|
||||
</div>
|
||||
<span slot="headline">${area.name}</span>
|
||||
</ha-combo-box-item>
|
||||
${expanded
|
||||
? html`
|
||||
${repeat(
|
||||
area.devices,
|
||||
(device: DeviceNode) => device.id,
|
||||
(device: DeviceNode) => this._renderDevice(device, key)
|
||||
)}
|
||||
${repeat(
|
||||
area.directEntityIds,
|
||||
(id: string) => id,
|
||||
(id: string) => this._renderEntity(id, "depth-entity-area")
|
||||
)}
|
||||
`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderDevice(device: DeviceNode, parentKey: string): TemplateResult {
|
||||
const key = deviceKey(parentKey, device.id);
|
||||
const expanded = this._isExpanded(key);
|
||||
const domain = this._deviceDomain(device.id);
|
||||
return html`
|
||||
<ha-combo-box-item
|
||||
type="button"
|
||||
class="branch depth-device device-item"
|
||||
aria-expanded=${expanded}
|
||||
data-node-key=${key}
|
||||
@click=${this._toggleNode}
|
||||
>
|
||||
<div slot="start" class="leading">
|
||||
${this._renderChevron(expanded)}
|
||||
${domain
|
||||
? html`<ha-domain-icon
|
||||
.hass=${this.hass}
|
||||
.domain=${domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>`
|
||||
: html`<ha-svg-icon .path=${mdiTextureBox}></ha-svg-icon>`}
|
||||
</div>
|
||||
<span slot="headline">${device.name}</span>
|
||||
</ha-combo-box-item>
|
||||
${expanded
|
||||
? repeat(
|
||||
device.entityIds,
|
||||
(id: string) => id,
|
||||
(id: string) => this._renderEntity(id, "depth-entity-device")
|
||||
)
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderUnassignedSection(section: UnassignedSection): TemplateResult {
|
||||
const key = unassignedKey(section.id);
|
||||
const expanded = this._isExpanded(key);
|
||||
return html`
|
||||
<ha-combo-box-item
|
||||
type="button"
|
||||
class="branch depth-root floor-item"
|
||||
aria-expanded=${expanded}
|
||||
data-node-key=${key}
|
||||
@click=${this._toggleNode}
|
||||
>
|
||||
<div slot="start" class="leading">
|
||||
${this._renderChevron(expanded)}
|
||||
<ha-svg-icon .path=${section.iconPath}></ha-svg-icon>
|
||||
</div>
|
||||
<span slot="headline">${section.label}</span>
|
||||
</ha-combo-box-item>
|
||||
${expanded
|
||||
? html`
|
||||
${section.devices
|
||||
? repeat(
|
||||
section.devices,
|
||||
(device: DeviceNode) => device.id,
|
||||
(device: DeviceNode) => this._renderDevice(device, key)
|
||||
)
|
||||
: nothing}
|
||||
${section.domains
|
||||
? repeat(
|
||||
section.domains,
|
||||
(g: DomainGroup) => g.domain,
|
||||
(g: DomainGroup) => {
|
||||
const dKey = domainKey(key, g.domain);
|
||||
const dExpanded = this._isExpanded(dKey);
|
||||
return html`
|
||||
<ha-combo-box-item
|
||||
type="button"
|
||||
class="branch depth-area area-item"
|
||||
aria-expanded=${dExpanded}
|
||||
data-node-key=${dKey}
|
||||
@click=${this._toggleNode}
|
||||
>
|
||||
<div slot="start" class="leading">
|
||||
${this._renderChevron(dExpanded)}
|
||||
<ha-domain-icon
|
||||
.hass=${this.hass}
|
||||
.domain=${g.domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
</div>
|
||||
<span slot="headline">${g.name}</span>
|
||||
</ha-combo-box-item>
|
||||
${dExpanded
|
||||
? repeat(
|
||||
g.entityIds,
|
||||
(id: string) => id,
|
||||
(id: string) =>
|
||||
this._renderEntity(id, "depth-entity-area")
|
||||
)
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
)
|
||||
: nothing}
|
||||
`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderEntity(entityId: string, depthClass: string): TemplateResult {
|
||||
const stateObj = this.hass.states[entityId];
|
||||
const selected = this.selectedEntityId === entityId;
|
||||
const entityName = stateObj
|
||||
? computeEntityName(stateObj, this.hass.entities, this.hass.devices)
|
||||
: undefined;
|
||||
const name =
|
||||
entityName ||
|
||||
(stateObj ? computeStateName(stateObj) : undefined) ||
|
||||
entityId;
|
||||
return html`
|
||||
<ha-combo-box-item
|
||||
type="button"
|
||||
class="leaf ${depthClass} entity-item ${selected ? "selected" : ""}"
|
||||
aria-current=${selected ? "true" : "false"}
|
||||
data-entity-id=${entityId}
|
||||
@click=${this._pickEntity}
|
||||
>
|
||||
<div slot="start" class="leading">
|
||||
<span class="chevron-spacer"></span>
|
||||
${stateObj
|
||||
? html`<state-badge
|
||||
.hass=${this.hass}
|
||||
.stateObj=${stateObj}
|
||||
></state-badge>`
|
||||
: nothing}
|
||||
</div>
|
||||
<span slot="headline">${name}</span>
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggleNode(ev: Event) {
|
||||
const target = ev.currentTarget as HTMLElement;
|
||||
const key = target.dataset.nodeKey;
|
||||
if (!key) return;
|
||||
if (this._expanded[key]) {
|
||||
const next = { ...this._expanded };
|
||||
delete next[key];
|
||||
const prefix = childKeyPrefix(key);
|
||||
for (const k of Object.keys(next)) {
|
||||
if (k.startsWith(prefix)) delete next[k];
|
||||
}
|
||||
this._expanded = next;
|
||||
} else {
|
||||
this._expanded = { ...this._expanded, [key]: true };
|
||||
}
|
||||
}
|
||||
|
||||
private _pickEntity(ev: Event) {
|
||||
const target = ev.currentTarget as HTMLElement;
|
||||
const entityId = target.dataset.entityId;
|
||||
if (!entityId) return;
|
||||
this._expandToEntity(entityId);
|
||||
fireEvent(this, "entity-picked", { entityId });
|
||||
}
|
||||
|
||||
private _expandToEntity(entityId: string) {
|
||||
if (!this._tree) return;
|
||||
const path = pathToEntity(this._tree, entityId);
|
||||
if (!path.length) return;
|
||||
const next = { ...this._expanded };
|
||||
let changed = false;
|
||||
for (const key of path) {
|
||||
if (!next[key]) {
|
||||
next[key] = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) this._expanded = next;
|
||||
}
|
||||
|
||||
private _handleFilterChange(ev: Event) {
|
||||
this._setFilter((ev.target as HaInputSearch).value ?? "");
|
||||
}
|
||||
|
||||
private _setFilter = debounce((value: string) => {
|
||||
this._filter = value;
|
||||
}, 150);
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
ha-input-search {
|
||||
padding: var(--ha-space-3);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
.tree {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-bottom: var(--ha-space-3);
|
||||
}
|
||||
ha-combo-box-item {
|
||||
--md-list-item-one-line-container-height: 40px;
|
||||
--md-list-item-two-line-container-height: 48px;
|
||||
--md-list-item-leading-space: var(--ha-space-3);
|
||||
--md-list-item-trailing-space: var(--ha-space-3);
|
||||
--md-list-item-leading-element-leading-space: 0;
|
||||
width: 100%;
|
||||
}
|
||||
ha-combo-box-item.depth-area {
|
||||
--md-list-item-leading-space: var(--ha-space-8);
|
||||
}
|
||||
ha-combo-box-item.depth-device {
|
||||
--md-list-item-leading-space: var(--ha-space-12);
|
||||
}
|
||||
ha-combo-box-item.depth-entity-area {
|
||||
--md-list-item-leading-space: var(--ha-space-12);
|
||||
}
|
||||
ha-combo-box-item.depth-entity-device {
|
||||
--md-list-item-leading-space: var(--ha-space-16);
|
||||
}
|
||||
.leading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.leading ha-svg-icon,
|
||||
.leading ha-icon,
|
||||
.leading ha-floor-icon,
|
||||
.leading ha-domain-icon {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.leading state-badge {
|
||||
--state-icon-color: var(--secondary-text-color);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
.chevron {
|
||||
color: var(--secondary-text-color);
|
||||
flex: 0 0 24px;
|
||||
}
|
||||
.chevron-spacer {
|
||||
width: 24px;
|
||||
flex: 0 0 24px;
|
||||
}
|
||||
.floor-item {
|
||||
--md-list-item-label-text-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.entity-item.selected {
|
||||
background-color: var(
|
||||
--ha-color-fill-primary-quiet-resting,
|
||||
rgba(var(--rgb-primary-color, 33, 150, 243), 0.12)
|
||||
);
|
||||
--md-list-item-label-text-color: var(--primary-color);
|
||||
--md-list-item-label-text-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.entity-item.selected .leading state-badge {
|
||||
--state-icon-color: var(--primary-color);
|
||||
}
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--ha-space-2);
|
||||
padding: var(--ha-space-8) var(--ha-space-4);
|
||||
text-align: center;
|
||||
}
|
||||
.empty ha-svg-icon {
|
||||
--mdc-icon-size: 32px;
|
||||
color: var(--ha-color-text-secondary);
|
||||
}
|
||||
.empty h2 {
|
||||
margin: 0;
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.empty p {
|
||||
margin: 0;
|
||||
color: var(--ha-color-text-secondary);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-suggestion-entity-tree": HuiSuggestionEntityTree;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import { mdiClose, mdiViewGridPlus } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { computeEntityPickerDisplay } from "../../../../common/entity/compute_entity_name_display";
|
||||
import "../../../../components/entity/state-badge";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-combo-box-item";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-ripple";
|
||||
import "../../../../components/ha-section-title";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
|
||||
import { haStyleScrollbar } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { generateCardSuggestions } from "../../card-suggestions";
|
||||
import type { CardSuggestion } from "../../card-suggestions/types";
|
||||
import "./hui-suggestion-card";
|
||||
import "./hui-suggestion-entity-tree";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"browse-cards": undefined;
|
||||
"suggestion-picked": { config: LovelaceCardConfig };
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("hui-suggestion-picker")
|
||||
export class HuiSuggestionPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ type: Array, attribute: false })
|
||||
public prioritizedCardTypes?: string[];
|
||||
|
||||
@state() private _entityId?: string;
|
||||
|
||||
@state() private _narrow = false;
|
||||
|
||||
private _narrowMql?: MediaQueryList;
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._narrowMql = matchMedia("(max-width: 600px)");
|
||||
this._narrow = this._narrowMql.matches;
|
||||
this._narrowMql.addEventListener("change", this._handleNarrowChange);
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._narrowMql?.removeEventListener("change", this._handleNarrowChange);
|
||||
this._narrowMql = undefined;
|
||||
}
|
||||
|
||||
private _handleNarrowChange = (ev: MediaQueryListEvent) => {
|
||||
this._narrow = ev.matches;
|
||||
};
|
||||
|
||||
// Memoize on scalars so the result stays stable when only hass changes.
|
||||
// Keeps hui-card previews from re-rendering on every state tick.
|
||||
private _computeSuggestions = memoizeOne(
|
||||
(
|
||||
entityId: string | undefined,
|
||||
priorityTypesKey: string
|
||||
): CardSuggestion[] => {
|
||||
if (!this.hass) return [];
|
||||
const suggestions = generateCardSuggestions(this.hass, entityId);
|
||||
const priorityTypes = priorityTypesKey
|
||||
? priorityTypesKey.split("|")
|
||||
: undefined;
|
||||
if (!priorityTypes?.length) return suggestions;
|
||||
const isPrioritized = (s: CardSuggestion) =>
|
||||
priorityTypes.includes(s.config.type);
|
||||
return [
|
||||
...suggestions.filter(isPrioritized),
|
||||
...suggestions.filter((s) => !isPrioritized(s)),
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) return nothing;
|
||||
const hasEntity = !!this._entityId;
|
||||
// Tree is rendered unconditionally so its state (filter, expanded
|
||||
// branches, fuse index) survives the desktop/mobile and tree/suggestions
|
||||
// switches.
|
||||
const showTree = !this._narrow || !hasEntity;
|
||||
const showMain = !this._narrow || hasEntity;
|
||||
return html`
|
||||
<div class=${classMap({ sidebar: true, hidden: !showTree })}>
|
||||
<hui-suggestion-entity-tree
|
||||
class="tree"
|
||||
.hass=${this.hass}
|
||||
.selectedEntityId=${this._entityId}
|
||||
@entity-picked=${this._handleEntityPicked}
|
||||
></hui-suggestion-entity-tree>
|
||||
</div>
|
||||
<div class=${classMap({ main: true, hidden: !showMain })}>
|
||||
<div class="content ha-scrollbar">
|
||||
${this._renderMainContent(hasEntity)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderMainContent(
|
||||
hasEntity: boolean
|
||||
): TemplateResult | typeof nothing {
|
||||
if (!hasEntity) return this._renderEmptyState();
|
||||
if (this._narrow) {
|
||||
return html`
|
||||
${this._renderSelectedEntity()}
|
||||
<ha-section-title>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.suggestions_title"
|
||||
)}
|
||||
</ha-section-title>
|
||||
${this._renderSuggestionsGrid(this._suggestions())}
|
||||
`;
|
||||
}
|
||||
return this._renderSuggestionsGrid(this._suggestions());
|
||||
}
|
||||
|
||||
private _renderSelectedEntity(): TemplateResult {
|
||||
const stateObj = this.hass!.states[this._entityId!];
|
||||
const { primary, secondary } = stateObj
|
||||
? computeEntityPickerDisplay(this.hass!, stateObj)
|
||||
: { primary: this._entityId!, secondary: undefined };
|
||||
return html`
|
||||
<ha-section-title>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.selected_entity"
|
||||
)}
|
||||
</ha-section-title>
|
||||
<ha-combo-box-item compact class="selected-entity">
|
||||
${stateObj
|
||||
? html`<state-badge
|
||||
slot="start"
|
||||
.hass=${this.hass}
|
||||
.stateObj=${stateObj}
|
||||
></state-badge>`
|
||||
: nothing}
|
||||
<span slot="headline">${primary}</span>
|
||||
${secondary
|
||||
? html`<span slot="supporting-text">${secondary}</span>`
|
||||
: nothing}
|
||||
<ha-icon-button
|
||||
slot="end"
|
||||
.label=${this.hass!.localize("ui.common.clear")}
|
||||
.path=${mdiClose}
|
||||
@click=${this._clearEntity}
|
||||
></ha-icon-button>
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderEmptyState(): TemplateResult {
|
||||
return html`
|
||||
<div class="content-empty">
|
||||
<h2>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.content_empty_title"
|
||||
)}
|
||||
</h2>
|
||||
<p>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.content_empty_description"
|
||||
)}
|
||||
</p>
|
||||
<ha-button appearance="plain" @click=${this._browseCards}>
|
||||
<ha-svg-icon slot="start" .path=${mdiViewGridPlus}></ha-svg-icon>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.browse_cards"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSuggestionsGrid(
|
||||
suggestions: CardSuggestion[]
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div class="suggestions" @pick-suggestion=${this._pickSuggestion}>
|
||||
${repeat(
|
||||
suggestions,
|
||||
(s: CardSuggestion) => s.id,
|
||||
(s: CardSuggestion) => html`
|
||||
<hui-suggestion-card
|
||||
.hass=${this.hass}
|
||||
.suggestion=${s}
|
||||
></hui-suggestion-card>
|
||||
`
|
||||
)}
|
||||
<div
|
||||
class="browse-card"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label=${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.browse_cards"
|
||||
)}
|
||||
@click=${this._browseCards}
|
||||
@keydown=${this._browseCardsKeydown}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiViewGridPlus}></ha-svg-icon>
|
||||
<span class="browse-card-title">
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.browse_cards"
|
||||
)}
|
||||
</span>
|
||||
<p>
|
||||
${this.hass!.localize(
|
||||
"ui.panel.lovelace.editor.cardpicker.not_found"
|
||||
)}
|
||||
</p>
|
||||
<ha-ripple></ha-ripple>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _suggestions(): CardSuggestion[] {
|
||||
return this._computeSuggestions(
|
||||
this._entityId,
|
||||
(this.prioritizedCardTypes ?? []).join("|")
|
||||
);
|
||||
}
|
||||
|
||||
private _browseCards(): void {
|
||||
fireEvent(this, "browse-cards", undefined);
|
||||
}
|
||||
|
||||
private _browseCardsKeydown(ev: KeyboardEvent): void {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
this._browseCards();
|
||||
}
|
||||
}
|
||||
|
||||
private _handleEntityPicked(ev: CustomEvent<{ entityId: string }>): void {
|
||||
this._entityId = ev.detail.entityId;
|
||||
}
|
||||
|
||||
private _clearEntity(): void {
|
||||
this._entityId = undefined;
|
||||
}
|
||||
|
||||
private _pickSuggestion(
|
||||
ev: CustomEvent<{ suggestion: CardSuggestion }>
|
||||
): void {
|
||||
fireEvent(this, "suggestion-picked", {
|
||||
config: ev.detail.suggestion.config,
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex: 0 0 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-inline-end: 1px solid var(--divider-color);
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tree {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
.suggestions {
|
||||
display: grid;
|
||||
gap: var(--ha-space-3);
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
.content-empty {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--ha-space-3);
|
||||
padding: var(--ha-space-8) var(--ha-space-4);
|
||||
text-align: center;
|
||||
}
|
||||
.content-empty h2 {
|
||||
margin: 0;
|
||||
font-size: var(--ha-font-size-xl);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.content-empty p {
|
||||
margin: 0;
|
||||
max-width: 480px;
|
||||
color: var(--ha-color-text-secondary);
|
||||
line-height: var(--ha-line-height-expanded);
|
||||
}
|
||||
.browse-card {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--ha-space-2);
|
||||
padding: var(--ha-space-6);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
border-radius: var(
|
||||
--ha-card-border-radius,
|
||||
var(--ha-border-radius-lg)
|
||||
);
|
||||
border: var(--ha-card-border-width, 1px) dashed
|
||||
var(--ha-card-border-color, var(--divider-color));
|
||||
background: var(--primary-background-color, #fafafa);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.browse-card:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.browse-card ha-svg-icon {
|
||||
color: var(--ha-color-text-secondary);
|
||||
}
|
||||
.browse-card-title {
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.browse-card p {
|
||||
margin: 0;
|
||||
color: var(--ha-color-text-secondary);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
|
||||
/* Mobile master/detail: sidebar OR main is visible, never both. */
|
||||
@media (max-width: 600px) {
|
||||
:host {
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar {
|
||||
flex: 1;
|
||||
border-inline-end: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-suggestion-picker": HuiSuggestionPicker;
|
||||
}
|
||||
}
|
||||
@@ -24,117 +24,20 @@ import {
|
||||
stripCustomPrefix,
|
||||
} from "../../../../data/lovelace_custom_cards";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { supportsAlarmModesCardFeature } from "../../card-features/hui-alarm-modes-card-feature";
|
||||
import { supportsAreaControlsCardFeature } from "../../card-features/hui-area-controls-card-feature";
|
||||
import { supportsBarGaugeCardFeature } from "../../card-features/hui-bar-gauge-card-feature";
|
||||
import { supportsButtonCardFeature } from "../../card-features/hui-button-card-feature";
|
||||
import { supportsClimateFanModesCardFeature } from "../../card-features/hui-climate-fan-modes-card-feature";
|
||||
import { supportsClimateHvacModesCardFeature } from "../../card-features/hui-climate-hvac-modes-card-feature";
|
||||
import { supportsClimatePresetModesCardFeature } from "../../card-features/hui-climate-preset-modes-card-feature";
|
||||
import { supportsClimateSwingHorizontalModesCardFeature } from "../../card-features/hui-climate-swing-horizontal-modes-card-feature";
|
||||
import { supportsClimateSwingModesCardFeature } from "../../card-features/hui-climate-swing-modes-card-feature";
|
||||
import { supportsCounterActionsCardFeature } from "../../card-features/hui-counter-actions-card-feature";
|
||||
import { supportsCoverOpenCloseCardFeature } from "../../card-features/hui-cover-open-close-card-feature";
|
||||
import { supportsCoverPositionFavoriteCardFeature } from "../../card-features/hui-cover-position-favorite-card-feature";
|
||||
import { supportsCoverPositionCardFeature } from "../../card-features/hui-cover-position-card-feature";
|
||||
import { supportsCoverTiltCardFeature } from "../../card-features/hui-cover-tilt-card-feature";
|
||||
import { supportsCoverTiltFavoriteCardFeature } from "../../card-features/hui-cover-tilt-favorite-card-feature";
|
||||
import { supportsCoverTiltPositionCardFeature } from "../../card-features/hui-cover-tilt-position-card-feature";
|
||||
import { supportsDateSetCardFeature } from "../../card-features/hui-date-set-card-feature";
|
||||
import { supportsFanDirectionCardFeature } from "../../card-features/hui-fan-direction-card-feature";
|
||||
import { supportsFanOscilatteCardFeature } from "../../card-features/hui-fan-oscillate-card-feature";
|
||||
import { supportsFanPresetModesCardFeature } from "../../card-features/hui-fan-preset-modes-card-feature";
|
||||
import { supportsFanSpeedCardFeature } from "../../card-features/hui-fan-speed-card-feature";
|
||||
import { supportsHumidifierModesCardFeature } from "../../card-features/hui-humidifier-modes-card-feature";
|
||||
import { supportsHumidifierToggleCardFeature } from "../../card-features/hui-humidifier-toggle-card-feature";
|
||||
import { supportsLawnMowerCommandCardFeature } from "../../card-features/hui-lawn-mower-commands-card-feature";
|
||||
import { supportsLightBrightnessCardFeature } from "../../card-features/hui-light-brightness-card-feature";
|
||||
import { supportsLightColorTempCardFeature } from "../../card-features/hui-light-color-temp-card-feature";
|
||||
import { supportsLockCommandsCardFeature } from "../../card-features/hui-lock-commands-card-feature";
|
||||
import { supportsLockOpenDoorCardFeature } from "../../card-features/hui-lock-open-door-card-feature";
|
||||
import { supportsMediaPlayerPlaybackCardFeature } from "../../card-features/hui-media-player-playback-card-feature";
|
||||
import { supportsMediaPlayerSoundModeCardFeature } from "../../card-features/hui-media-player-sound-mode-card-feature";
|
||||
import { supportsMediaPlayerSourceCardFeature } from "../../card-features/hui-media-player-source-card-feature";
|
||||
import { supportsMediaPlayerVolumeButtonsCardFeature } from "../../card-features/hui-media-player-volume-buttons-card-feature";
|
||||
import { supportsMediaPlayerVolumeSliderCardFeature } from "../../card-features/hui-media-player-volume-slider-card-feature";
|
||||
import { supportsNumericInputCardFeature } from "../../card-features/hui-numeric-input-card-feature";
|
||||
import { supportsSelectOptionsCardFeature } from "../../card-features/hui-select-options-card-feature";
|
||||
import { supportsTargetHumidityCardFeature } from "../../card-features/hui-target-humidity-card-feature";
|
||||
import { supportsTargetTemperatureCardFeature } from "../../card-features/hui-target-temperature-card-feature";
|
||||
import { supportsToggleCardFeature } from "../../card-features/hui-toggle-card-feature";
|
||||
import { supportsTrendGraphCardFeature } from "../../card-features/hui-trend-graph-card-feature";
|
||||
import { supportsUpdateActionsCardFeature } from "../../card-features/hui-update-actions-card-feature";
|
||||
import { supportsVacuumCommandsCardFeature } from "../../card-features/hui-vacuum-commands-card-feature";
|
||||
import { supportsValveOpenCloseCardFeature } from "../../card-features/hui-valve-open-close-card-feature";
|
||||
import { supportsValvePositionFavoriteCardFeature } from "../../card-features/hui-valve-position-favorite-card-feature";
|
||||
import { supportsValvePositionCardFeature } from "../../card-features/hui-valve-position-card-feature";
|
||||
import { supportsWaterHeaterOperationModesCardFeature } from "../../card-features/hui-water-heater-operation-modes-card-feature";
|
||||
import type {
|
||||
LovelaceCardFeatureConfig,
|
||||
LovelaceCardFeatureContext,
|
||||
} from "../../card-features/types";
|
||||
import type { UiFeatureType } from "../../card-features/registry";
|
||||
import {
|
||||
SUPPORTS_FEATURE_TYPES,
|
||||
UI_FEATURE_TYPES,
|
||||
} from "../../card-features/registry";
|
||||
import { getCardFeatureElementClass } from "../../create-element/create-card-feature-element";
|
||||
import { supportsLightColorFavoritesCardFeature } from "../../card-features/hui-light-color-favorites-card-feature";
|
||||
|
||||
export type FeatureType = LovelaceCardFeatureConfig["type"];
|
||||
|
||||
type SupportsFeature = (
|
||||
hass: HomeAssistant,
|
||||
context: LovelaceCardFeatureContext
|
||||
) => boolean;
|
||||
|
||||
const UI_FEATURE_TYPES = [
|
||||
"alarm-modes",
|
||||
"area-controls",
|
||||
"bar-gauge",
|
||||
"button",
|
||||
"climate-fan-modes",
|
||||
"climate-hvac-modes",
|
||||
"climate-preset-modes",
|
||||
"climate-swing-modes",
|
||||
"climate-swing-horizontal-modes",
|
||||
"counter-actions",
|
||||
"cover-open-close",
|
||||
"cover-position-favorite",
|
||||
"cover-position",
|
||||
"cover-tilt-favorite",
|
||||
"cover-tilt-position",
|
||||
"cover-tilt",
|
||||
"date-set",
|
||||
"fan-direction",
|
||||
"fan-oscillate",
|
||||
"fan-preset-modes",
|
||||
"fan-speed",
|
||||
"humidifier-modes",
|
||||
"humidifier-toggle",
|
||||
"lawn-mower-commands",
|
||||
"light-brightness",
|
||||
"light-color-temp",
|
||||
"light-color-favorites",
|
||||
"lock-commands",
|
||||
"lock-open-door",
|
||||
"media-player-playback",
|
||||
"media-player-sound-mode",
|
||||
"media-player-source",
|
||||
"media-player-volume-buttons",
|
||||
"media-player-volume-slider",
|
||||
"numeric-input",
|
||||
"select-options",
|
||||
"trend-graph",
|
||||
"target-humidity",
|
||||
"target-temperature",
|
||||
"toggle",
|
||||
"update-actions",
|
||||
"vacuum-commands",
|
||||
"valve-open-close",
|
||||
"valve-position-favorite",
|
||||
"valve-position",
|
||||
"water-heater-operation-modes",
|
||||
] as const satisfies readonly FeatureType[];
|
||||
|
||||
type UiFeatureTypes = (typeof UI_FEATURE_TYPES)[number];
|
||||
|
||||
const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
|
||||
const EDITABLES_FEATURE_TYPES = new Set<UiFeatureType>([
|
||||
"alarm-modes",
|
||||
"area-controls",
|
||||
"bar-gauge",
|
||||
@@ -162,59 +65,6 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
|
||||
"water-heater-operation-modes",
|
||||
]);
|
||||
|
||||
const SUPPORTS_FEATURE_TYPES: Record<
|
||||
UiFeatureTypes,
|
||||
SupportsFeature | undefined
|
||||
> = {
|
||||
"alarm-modes": supportsAlarmModesCardFeature,
|
||||
"area-controls": supportsAreaControlsCardFeature,
|
||||
"bar-gauge": supportsBarGaugeCardFeature,
|
||||
button: supportsButtonCardFeature,
|
||||
"climate-fan-modes": supportsClimateFanModesCardFeature,
|
||||
"climate-swing-modes": supportsClimateSwingModesCardFeature,
|
||||
"climate-swing-horizontal-modes":
|
||||
supportsClimateSwingHorizontalModesCardFeature,
|
||||
"climate-hvac-modes": supportsClimateHvacModesCardFeature,
|
||||
"climate-preset-modes": supportsClimatePresetModesCardFeature,
|
||||
"counter-actions": supportsCounterActionsCardFeature,
|
||||
"cover-open-close": supportsCoverOpenCloseCardFeature,
|
||||
"cover-position-favorite": supportsCoverPositionFavoriteCardFeature,
|
||||
"cover-position": supportsCoverPositionCardFeature,
|
||||
"cover-tilt-favorite": supportsCoverTiltFavoriteCardFeature,
|
||||
"cover-tilt-position": supportsCoverTiltPositionCardFeature,
|
||||
"cover-tilt": supportsCoverTiltCardFeature,
|
||||
"date-set": supportsDateSetCardFeature,
|
||||
"fan-direction": supportsFanDirectionCardFeature,
|
||||
"fan-oscillate": supportsFanOscilatteCardFeature,
|
||||
"fan-preset-modes": supportsFanPresetModesCardFeature,
|
||||
"fan-speed": supportsFanSpeedCardFeature,
|
||||
"humidifier-modes": supportsHumidifierModesCardFeature,
|
||||
"humidifier-toggle": supportsHumidifierToggleCardFeature,
|
||||
"lawn-mower-commands": supportsLawnMowerCommandCardFeature,
|
||||
"light-brightness": supportsLightBrightnessCardFeature,
|
||||
"light-color-temp": supportsLightColorTempCardFeature,
|
||||
"light-color-favorites": supportsLightColorFavoritesCardFeature,
|
||||
"lock-commands": supportsLockCommandsCardFeature,
|
||||
"lock-open-door": supportsLockOpenDoorCardFeature,
|
||||
"media-player-playback": supportsMediaPlayerPlaybackCardFeature,
|
||||
"media-player-sound-mode": supportsMediaPlayerSoundModeCardFeature,
|
||||
"media-player-source": supportsMediaPlayerSourceCardFeature,
|
||||
"media-player-volume-buttons": supportsMediaPlayerVolumeButtonsCardFeature,
|
||||
"media-player-volume-slider": supportsMediaPlayerVolumeSliderCardFeature,
|
||||
"numeric-input": supportsNumericInputCardFeature,
|
||||
"select-options": supportsSelectOptionsCardFeature,
|
||||
"trend-graph": supportsTrendGraphCardFeature,
|
||||
"target-humidity": supportsTargetHumidityCardFeature,
|
||||
"target-temperature": supportsTargetTemperatureCardFeature,
|
||||
toggle: supportsToggleCardFeature,
|
||||
"update-actions": supportsUpdateActionsCardFeature,
|
||||
"vacuum-commands": supportsVacuumCommandsCardFeature,
|
||||
"valve-open-close": supportsValveOpenCloseCardFeature,
|
||||
"valve-position-favorite": supportsValvePositionFavoriteCardFeature,
|
||||
"valve-position": supportsValvePositionCardFeature,
|
||||
"water-heater-operation-modes": supportsWaterHeaterOperationModesCardFeature,
|
||||
};
|
||||
|
||||
const customCardFeatures = getCustomCardFeatures();
|
||||
|
||||
const CUSTOM_FEATURE_ENTRIES: Record<
|
||||
@@ -272,7 +122,7 @@ export const supportsFeaturesType = (
|
||||
}
|
||||
}
|
||||
|
||||
const supportsFeature = SUPPORTS_FEATURE_TYPES[type];
|
||||
const supportsFeature = SUPPORTS_FEATURE_TYPES[type as UiFeatureType];
|
||||
return !supportsFeature || supportsFeature(hass, context);
|
||||
};
|
||||
|
||||
|
||||
@@ -10214,7 +10214,54 @@
|
||||
"domain": "Domain",
|
||||
"entity": "Entity",
|
||||
"by_entity": "By entity",
|
||||
"by_card": "By card"
|
||||
"by_card": "By card",
|
||||
"search_entities": "Search entities",
|
||||
"home": "[%key:ui::panel::config::automation::editor::home%]",
|
||||
"unassigned": "[%key:ui::panel::config::automation::editor::unassigned%]",
|
||||
"other_areas": "[%key:ui::panel::config::automation::editor::other_areas%]",
|
||||
"entities": "Entities",
|
||||
"helpers": "[%key:ui::panel::config::automation::editor::helpers%]",
|
||||
"devices": "Devices",
|
||||
"services": "[%key:ui::panel::config::automation::editor::services%]",
|
||||
"browse_cards": "Browse all cards",
|
||||
"more_cards": "+{count} more",
|
||||
"content_empty_title": "Pick an entity to see suggestions",
|
||||
"content_empty_description": "Or browse all card types.",
|
||||
"no_search_results_title": "No entity matches your search",
|
||||
"no_search_results_description": "Try a different keyword.",
|
||||
"selected_entity": "Selected entity",
|
||||
"suggestions_title": "Suggestions",
|
||||
"not_found": "Can't find the card you want?",
|
||||
"suggestions": {
|
||||
"tile": "Tile",
|
||||
"tile_toggle": "Tile (toggle)",
|
||||
"tile_brightness": "Tile (brightness)",
|
||||
"tile_color_temperature": "Tile (color temperature)",
|
||||
"tile_favorite_colors": "Tile (favorite colors)",
|
||||
"tile_open_close": "Tile (open/close)",
|
||||
"tile_position": "Tile (position)",
|
||||
"tile_tilt": "Tile (tilt)",
|
||||
"tile_hvac_modes": "Tile (HVAC modes)",
|
||||
"tile_playback_controls": "Tile (playback controls)",
|
||||
"tile_volume_slider": "Tile (volume slider)",
|
||||
"tile_speed": "Tile (speed)",
|
||||
"tile_preset_modes": "Tile (preset modes)",
|
||||
"tile_options": "Tile (options)",
|
||||
"tile_numeric_input": "Tile (numeric input)",
|
||||
"tile_date_picker": "Tile (date picker)",
|
||||
"tile_trend_graph": "Tile (trend graph)",
|
||||
"tile_lock_commands": "Tile (lock commands)",
|
||||
"tile_humidifier_toggle": "Tile (humidifier toggle)",
|
||||
"tile_humidifier_modes": "Tile (humidifier modes)",
|
||||
"tile_vacuum_commands": "Tile (vacuum commands)",
|
||||
"tile_mower_commands": "Tile (mower commands)",
|
||||
"tile_alarm_modes": "Tile (alarm modes)",
|
||||
"tile_counter_actions": "Tile (counter actions)",
|
||||
"tile_update_actions": "Tile (update actions)",
|
||||
"tile_operation_modes": "Tile (operation modes)",
|
||||
"calendar": "Calendar",
|
||||
"todo_list": "To-do list"
|
||||
}
|
||||
},
|
||||
"badge_picker": {
|
||||
"no_description": "No description available.",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
CARD_SUGGESTION_PROVIDERS,
|
||||
generateCardSuggestions,
|
||||
} from "../../../../src/panels/lovelace/card-suggestions";
|
||||
import type { CardSuggestionProvider } from "../../../../src/panels/lovelace/card-suggestions/types";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
const makeState = (
|
||||
entityId: string,
|
||||
state = "on",
|
||||
attributes: Record<string, unknown> = {}
|
||||
): HassEntity => ({
|
||||
entity_id: entityId,
|
||||
state,
|
||||
attributes,
|
||||
last_changed: "",
|
||||
last_updated: "",
|
||||
context: { id: "", parent_id: null, user_id: null },
|
||||
});
|
||||
|
||||
const makeHass = (states: HassEntity[]): HomeAssistant =>
|
||||
({
|
||||
states: Object.fromEntries(states.map((s) => [s.entity_id, s])),
|
||||
localize: (key: string) => key,
|
||||
}) as unknown as HomeAssistant;
|
||||
|
||||
const registerTestProviders = (
|
||||
providers: Record<string, CardSuggestionProvider>
|
||||
): (() => void) => {
|
||||
const keys = Object.keys(providers);
|
||||
for (const key of keys) {
|
||||
CARD_SUGGESTION_PROVIDERS[key] = providers[key];
|
||||
}
|
||||
return () => {
|
||||
for (const key of keys) {
|
||||
delete CARD_SUGGESTION_PROVIDERS[key];
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
describe("generateCardSuggestions", () => {
|
||||
let cleanupProviders: (() => void) | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
cleanupProviders?.();
|
||||
cleanupProviders = undefined;
|
||||
});
|
||||
|
||||
it("suggests nothing when no entity is picked", () => {
|
||||
expect(generateCardSuggestions(makeHass([]), undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("suggests nothing when the picked entity doesn't exist", () => {
|
||||
expect(generateCardSuggestions(makeHass([]), "light.ghost")).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns the entity-specific suggestions for a known entity", () => {
|
||||
const hass = makeHass([
|
||||
makeState("light.a", "on", { supported_color_modes: ["onoff"] }),
|
||||
]);
|
||||
const suggestions = generateCardSuggestions(hass, "light.a");
|
||||
expect(suggestions.some((s) => s.id === "tile")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts null, a single suggestion, or a list from each provider", () => {
|
||||
cleanupProviders = registerTestProviders({
|
||||
"test-null": { getEntitySuggestion: () => null },
|
||||
"test-single": {
|
||||
getEntitySuggestion: (_hass, entityId) => ({
|
||||
id: "single",
|
||||
label: "Single",
|
||||
config: { type: "custom:test-single", entity: entityId },
|
||||
}),
|
||||
},
|
||||
"test-array": {
|
||||
getEntitySuggestion: (_hass, entityId) => [
|
||||
{
|
||||
id: "array-a",
|
||||
label: "Array A",
|
||||
config: { type: "custom:test-array", entity: entityId },
|
||||
},
|
||||
{
|
||||
id: "array-b",
|
||||
label: "Array B",
|
||||
config: { type: "custom:test-array", entity: entityId },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const hass = makeHass([makeState("sensor.a", "1")]);
|
||||
const ids = generateCardSuggestions(hass, "sensor.a").map((s) => s.id);
|
||||
|
||||
expect(ids).toContain("single");
|
||||
expect(ids).toContain("array-a");
|
||||
expect(ids).toContain("array-b");
|
||||
});
|
||||
|
||||
it("keeps working when a provider throws", () => {
|
||||
cleanupProviders = registerTestProviders({
|
||||
"test-throws": {
|
||||
getEntitySuggestion: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hass = makeHass([makeState("sensor.a", "1")]);
|
||||
const ids = generateCardSuggestions(hass, "sensor.a").map((s) => s.id);
|
||||
expect(ids).toContain("tile");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,369 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildEntityTree,
|
||||
buildSearchIndex,
|
||||
pathToEntity,
|
||||
searchEntities,
|
||||
areaKey,
|
||||
deviceKey,
|
||||
domainKey,
|
||||
floorKey,
|
||||
OTHER_AREAS_ID,
|
||||
unassignedKey,
|
||||
} from "../../../../../src/panels/lovelace/editor/card-editor/entity-tree-builder";
|
||||
import type { HomeAssistant } from "../../../../../src/types";
|
||||
|
||||
const state = (
|
||||
entityId: string,
|
||||
attributes: Record<string, unknown> = {}
|
||||
): HassEntity => ({
|
||||
entity_id: entityId,
|
||||
state: "on",
|
||||
attributes: { friendly_name: entityId, ...attributes },
|
||||
last_changed: "",
|
||||
last_updated: "",
|
||||
context: { id: "", parent_id: null, user_id: null },
|
||||
});
|
||||
|
||||
const entity = (overrides: Record<string, unknown>) =>
|
||||
({
|
||||
name: null,
|
||||
icon: null,
|
||||
platform: "test",
|
||||
translation_key: null,
|
||||
device_id: null,
|
||||
area_id: null,
|
||||
hidden: false,
|
||||
entity_category: null,
|
||||
has_entity_name: false,
|
||||
options: null,
|
||||
labels: [],
|
||||
categories: {},
|
||||
display_precision: null,
|
||||
...overrides,
|
||||
}) as any;
|
||||
|
||||
const device = (id: string, overrides: Record<string, unknown> = {}) =>
|
||||
({
|
||||
id,
|
||||
name: id,
|
||||
name_by_user: null,
|
||||
area_id: null,
|
||||
entry_type: null,
|
||||
primary_config_entry: null,
|
||||
config_entries: [],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
identifiers: [],
|
||||
manufacturer: null,
|
||||
model: null,
|
||||
model_id: null,
|
||||
labels: [],
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
serial_number: null,
|
||||
via_device_id: null,
|
||||
disabled_by: null,
|
||||
configuration_url: null,
|
||||
...overrides,
|
||||
}) as any;
|
||||
|
||||
const area = (id: string, overrides: Record<string, unknown> = {}) =>
|
||||
({
|
||||
area_id: id,
|
||||
name: id,
|
||||
icon: null,
|
||||
floor_id: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
picture: null,
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
...overrides,
|
||||
}) as any;
|
||||
|
||||
const floor = (id: string, overrides: Record<string, unknown> = {}) =>
|
||||
({
|
||||
floor_id: id,
|
||||
name: id,
|
||||
icon: null,
|
||||
level: 0,
|
||||
aliases: [],
|
||||
...overrides,
|
||||
}) as any;
|
||||
|
||||
const makeHass = (overrides: Partial<HomeAssistant>): HomeAssistant =>
|
||||
({
|
||||
states: {},
|
||||
entities: {},
|
||||
devices: {},
|
||||
areas: {},
|
||||
floors: {},
|
||||
locale: { language: "en" } as any,
|
||||
localize: (key: string) => key,
|
||||
...overrides,
|
||||
}) as unknown as HomeAssistant;
|
||||
|
||||
describe("buildEntityTree", () => {
|
||||
it("groups entities by floor → area", () => {
|
||||
const hass = makeHass({
|
||||
states: {
|
||||
"light.living": state("light.living"),
|
||||
"light.kitchen": state("light.kitchen"),
|
||||
},
|
||||
entities: {
|
||||
"light.living": entity({ area_id: "living" }),
|
||||
"light.kitchen": entity({ area_id: "kitchen" }),
|
||||
},
|
||||
areas: {
|
||||
living: area("living", { floor_id: "ground" }),
|
||||
kitchen: area("kitchen", { floor_id: "ground" }),
|
||||
},
|
||||
floors: { ground: floor("ground") },
|
||||
});
|
||||
|
||||
const tree = buildEntityTree(hass);
|
||||
expect(tree.floors).toHaveLength(1);
|
||||
expect(tree.floors[0].id).toBe("ground");
|
||||
const areas = tree.floors[0].areas;
|
||||
expect(areas.map((a) => a.id).sort()).toEqual(["kitchen", "living"]);
|
||||
});
|
||||
|
||||
it("puts areas without a floor in otherAreas", () => {
|
||||
const hass = makeHass({
|
||||
states: { "light.a": state("light.a") },
|
||||
entities: { "light.a": entity({ area_id: "orphan" }) },
|
||||
areas: { orphan: area("orphan") },
|
||||
});
|
||||
|
||||
const tree = buildEntityTree(hass);
|
||||
expect(tree.floors).toHaveLength(0);
|
||||
expect(tree.otherAreas.map((a) => a.id)).toEqual(["orphan"]);
|
||||
});
|
||||
|
||||
it("nests device entities under their device when the entry has no area override", () => {
|
||||
const hass = makeHass({
|
||||
states: {
|
||||
"sensor.temp": state("sensor.temp"),
|
||||
"binary_sensor.motion": state("binary_sensor.motion"),
|
||||
},
|
||||
entities: {
|
||||
"sensor.temp": entity({ device_id: "dev1" }),
|
||||
"binary_sensor.motion": entity({ device_id: "dev1" }),
|
||||
},
|
||||
devices: { dev1: device("dev1", { area_id: "living" }) },
|
||||
areas: { living: area("living") },
|
||||
});
|
||||
|
||||
const tree = buildEntityTree(hass);
|
||||
const livingArea = tree.otherAreas[0];
|
||||
expect(livingArea.devices).toHaveLength(1);
|
||||
expect(livingArea.devices[0].entityIds.sort()).toEqual([
|
||||
"binary_sensor.motion",
|
||||
"sensor.temp",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats entities with their own area_id as direct area entities (not under device)", () => {
|
||||
const hass = makeHass({
|
||||
states: { "sensor.temp": state("sensor.temp") },
|
||||
entities: {
|
||||
"sensor.temp": entity({ device_id: "dev1", area_id: "living" }),
|
||||
},
|
||||
devices: { dev1: device("dev1", { area_id: "kitchen" }) },
|
||||
areas: { living: area("living"), kitchen: area("kitchen") },
|
||||
});
|
||||
|
||||
const tree = buildEntityTree(hass);
|
||||
const living = tree.otherAreas.find((a) => a.id === "living")!;
|
||||
expect(living.directEntityIds).toEqual(["sensor.temp"]);
|
||||
expect(living.devices).toEqual([]);
|
||||
});
|
||||
|
||||
it("buckets unassigned entities by device/helper/domain", () => {
|
||||
const hass = makeHass({
|
||||
states: {
|
||||
"light.unowned": state("light.unowned"),
|
||||
"input_boolean.helper": state("input_boolean.helper"),
|
||||
"sensor.from_device": state("sensor.from_device"),
|
||||
"sensor.service_one": state("sensor.service_one"),
|
||||
},
|
||||
entities: {
|
||||
"light.unowned": entity({}),
|
||||
"input_boolean.helper": entity({}),
|
||||
"sensor.from_device": entity({ device_id: "dev_orphan" }),
|
||||
"sensor.service_one": entity({ device_id: "dev_service" }),
|
||||
},
|
||||
devices: {
|
||||
dev_orphan: device("dev_orphan"),
|
||||
dev_service: device("dev_service", { entry_type: "service" }),
|
||||
},
|
||||
});
|
||||
|
||||
const tree = buildEntityTree(hass);
|
||||
const ids = tree.unassignedSections.map((s) => s.id);
|
||||
expect(ids).toContain("entities");
|
||||
expect(ids).toContain("helpers");
|
||||
expect(ids).toContain("devices");
|
||||
expect(ids).toContain("services");
|
||||
});
|
||||
|
||||
it("excludes hidden and unavailable entities", () => {
|
||||
const hass = makeHass({
|
||||
states: {
|
||||
"light.visible": state("light.visible"),
|
||||
"light.hidden": state("light.hidden"),
|
||||
"light.unavailable": {
|
||||
...state("light.unavailable"),
|
||||
state: "unavailable",
|
||||
},
|
||||
},
|
||||
entities: {
|
||||
"light.visible": entity({ area_id: "living" }),
|
||||
"light.hidden": entity({ area_id: "living", hidden: true }),
|
||||
"light.unavailable": entity({ area_id: "living" }),
|
||||
},
|
||||
areas: { living: area("living") },
|
||||
});
|
||||
|
||||
const tree = buildEntityTree(hass);
|
||||
const entities = tree.otherAreas[0].directEntityIds;
|
||||
expect(entities).toEqual(["light.visible"]);
|
||||
});
|
||||
|
||||
it("indexes searchable entities for fuse", () => {
|
||||
const hass = makeHass({
|
||||
states: { "light.kitchen": state("light.kitchen") },
|
||||
entities: { "light.kitchen": entity({ area_id: "kitchen" }) },
|
||||
areas: { kitchen: area("kitchen", { name: "Kitchen" }) },
|
||||
});
|
||||
|
||||
const tree = buildEntityTree(hass);
|
||||
expect(tree.searchableEntities).toHaveLength(1);
|
||||
expect(tree.searchableEntities[0]).toMatchObject({
|
||||
id: "light.kitchen",
|
||||
area: "Kitchen",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("searchEntities", () => {
|
||||
const buildSample = () => {
|
||||
const hass = makeHass({
|
||||
states: {
|
||||
"light.kitchen_ceiling": state("light.kitchen_ceiling"),
|
||||
"light.living_lamp": state("light.living_lamp"),
|
||||
"sensor.orphan": state("sensor.orphan"),
|
||||
},
|
||||
entities: {
|
||||
"light.kitchen_ceiling": entity({ area_id: "kitchen" }),
|
||||
"light.living_lamp": entity({ area_id: "living" }),
|
||||
"sensor.orphan": entity({}),
|
||||
},
|
||||
areas: {
|
||||
kitchen: area("kitchen", { floor_id: "ground", name: "Kitchen" }),
|
||||
living: area("living", { floor_id: "ground", name: "Living" }),
|
||||
},
|
||||
floors: { ground: floor("ground") },
|
||||
});
|
||||
const tree = buildEntityTree(hass);
|
||||
return {
|
||||
entities: tree.searchableEntities,
|
||||
index: buildSearchIndex(tree.searchableEntities),
|
||||
};
|
||||
};
|
||||
|
||||
it("returns nothing for an empty filter", () => {
|
||||
const { entities, index } = buildSample();
|
||||
expect(searchEntities(entities, index, "")).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches on entity name", () => {
|
||||
const { entities, index } = buildSample();
|
||||
const ids = searchEntities(entities, index, "kitchen").map((s) => s.id);
|
||||
expect(ids).toContain("light.kitchen_ceiling");
|
||||
});
|
||||
|
||||
it("matches on area name", () => {
|
||||
const { entities, index } = buildSample();
|
||||
const ids = searchEntities(entities, index, "Living").map((s) => s.id);
|
||||
expect(ids).toContain("light.living_lamp");
|
||||
});
|
||||
|
||||
it("respects the result limit", () => {
|
||||
const { entities, index } = buildSample();
|
||||
expect(searchEntities(entities, index, "light", 1)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pathToEntity", () => {
|
||||
it("returns the floor+area keys for a directly attached entity", () => {
|
||||
const hass = makeHass({
|
||||
states: { "light.lamp": state("light.lamp") },
|
||||
entities: { "light.lamp": entity({ area_id: "kitchen" }) },
|
||||
areas: { kitchen: area("kitchen", { floor_id: "ground" }) },
|
||||
floors: { ground: floor("ground") },
|
||||
});
|
||||
const tree = buildEntityTree(hass);
|
||||
const path = pathToEntity(tree, "light.lamp");
|
||||
expect(path).toEqual([
|
||||
floorKey("ground"),
|
||||
areaKey(floorKey("ground"), "kitchen"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("includes the device key when the entity is nested under a device", () => {
|
||||
const hass = makeHass({
|
||||
states: { "sensor.temp": state("sensor.temp") },
|
||||
entities: { "sensor.temp": entity({ device_id: "dev1" }) },
|
||||
devices: { dev1: device("dev1", { area_id: "kitchen" }) },
|
||||
areas: { kitchen: area("kitchen") },
|
||||
});
|
||||
const tree = buildEntityTree(hass);
|
||||
const otherAreasFloor = floorKey(OTHER_AREAS_ID);
|
||||
const path = pathToEntity(tree, "sensor.temp");
|
||||
expect(path).toEqual([
|
||||
otherAreasFloor,
|
||||
areaKey(otherAreasFloor, "kitchen"),
|
||||
deviceKey(areaKey(otherAreasFloor, "kitchen"), "dev1"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the unassigned section + domain path for orphan entities", () => {
|
||||
const hass = makeHass({
|
||||
states: { "light.unowned": state("light.unowned") },
|
||||
entities: { "light.unowned": entity({}) },
|
||||
});
|
||||
const tree = buildEntityTree(hass);
|
||||
const sKey = unassignedKey("entities");
|
||||
expect(pathToEntity(tree, "light.unowned")).toEqual([
|
||||
sKey,
|
||||
domainKey(sKey, "light"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty path for an unknown entity", () => {
|
||||
const hass = makeHass({});
|
||||
expect(pathToEntity(buildEntityTree(hass), "light.ghost")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("key helpers", () => {
|
||||
it("produces nested keys whose prefixes match their ancestor key", () => {
|
||||
const f = floorKey("ground");
|
||||
const a = areaKey(f, "kitchen");
|
||||
const d = deviceKey(a, "dev1");
|
||||
expect(a.startsWith(`${f}~`)).toBe(true);
|
||||
expect(d.startsWith(`${a}~`)).toBe(true);
|
||||
});
|
||||
|
||||
it("separates unassigned and floor namespaces", () => {
|
||||
expect(floorKey(OTHER_AREAS_ID)).not.toBe(unassignedKey("entities"));
|
||||
expect(domainKey(unassignedKey("entities"), "light").startsWith("u|")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
+4
-8
@@ -1,9 +1,5 @@
|
||||
import { beforeAll } from "vitest";
|
||||
global.window = (global.window ?? {}) as any;
|
||||
global.navigator = (global.navigator ?? {}) as any;
|
||||
|
||||
beforeAll(() => {
|
||||
global.window = {} as any;
|
||||
global.navigator = {} as any;
|
||||
|
||||
global.__DEMO__ = false;
|
||||
global.__DEV__ = false;
|
||||
});
|
||||
global.__DEMO__ = false;
|
||||
global.__DEV__ = false;
|
||||
|
||||
Reference in New Issue
Block a user