Compare commits

..

17 Commits

Author SHA1 Message Date
Simon Lamon df9cfeab58 Update .github/workflows/demo_deployment.yaml 2026-07-22 18:57:20 +02:00
Simon Lamon a31713a5ea Clean up and add some more documentation 2026-07-22 15:44:26 +00:00
Simon Lamon d5005ba951 Merge branch 'dev' into pass-browser-list 2026-07-22 17:41:03 +02:00
Maarten Lakerveld fa29f86b9d Fix pass-browser-list branch (#53243)
Add failsafe for empty browserlist. Add comment to describe prefix issue in literals. Use default browserlistEnv naming convention. SW uses more efficient browserlist only targeting browsers that support sw.
2026-07-22 17:38:42 +02:00
Paul Bottein 8775d3f4c5 Use ha-form conditional field visibility in dashboard editors (#53239)
* Use ha-form conditional field visibility in dashboard editors

* Keep view and section editors on localize context
2026-07-22 16:09:17 +01:00
Paul Bottein 2771a565b9 Rename ha-form conditional field to visible and skip it in config flow submission (#53168)
* Skip hidden fields in config flow submission

* Resolve hidden fields until stable so they hold no value

* Rename ha-form conditional field from `hidden` to `visible`
2026-07-22 16:06:03 +02:00
Petar Petrov d3505cece0 Fix missing app update button on app info page (#53238)
Fix missing app update button by resolving update entity reactively
2026-07-22 15:17:34 +02:00
Aidan Timson 8579f77c12 Add demo theme persistence tests (#53237)
* Add demo theme persistence tests

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Use shared demo theme storage key in e2e tests

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-22 15:50:08 +03:00
Aidan Timson 6249e037ed Add ask assist command action to quick search (#53234)
* Add ask assist action to quick search and support params in dialog

* Support native android

* Group Ask Assist with quick search commands

* Hide Ask Assist when native Assist is active

* Test Ask Assist from quick search

* Fix

* Use new test structure
2026-07-22 15:18:04 +03:00
Paul Bottein 8beb4ee36e Migrate entities and glance card state_color to color (#53151)
* Migrate entities and glance card state_color to color

* Improve tests
2026-07-22 15:08:10 +03:00
Simon Lamon 7844938ecd It's going to be green now 2026-07-22 09:54:48 +00:00
Simon Lamon f188f5de07 Getting closer 2026-07-22 09:43:26 +00:00
Simon Lamon ad0ad20cbe Should have read the documentation twice 2026-07-22 09:37:39 +00:00
Simon Lamon 25ed411198 Console logs to debug 2026-07-22 09:28:03 +00:00
Simon Lamon 988cd5ac82 Pass browserlist environment (since it's no longer a babel plugin and no longer set) 2026-07-22 09:02:20 +00:00
Simon Lamon ca074a1c03 Push deployment 2026-07-17 15:41:49 +00:00
Simon Lamon b8be36c5bb Pass browserlist targets to lightningcss 2026-07-14 17:18:12 +00:00
62 changed files with 1412 additions and 771 deletions
-34
View File
@@ -1,34 +0,0 @@
name: Copilot Setup Steps
on:
workflow_dispatch:
push:
paths:
- .github/workflows/copilot-setup-steps.yml
pull_request:
paths:
- .github/workflows/copilot-setup-steps.yml
env:
NODE_OPTIONS: --max_old_space_size=6144
jobs:
copilot-setup-steps:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Setup Node and install
uses: ./.github/actions/setup
with:
node-modules-cache: true
- name: Skip fetching nightly translations
run: echo "SKIP_FETCH_NIGHTLY_TRANSLATIONS=1" >> "$GITHUB_ENV"
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
@@ -12,18 +12,42 @@
const remapping = require("@ampproject/remapping");
// minify-literals is ESM-only, so load it via dynamic import from this CJS loader.
let minifyPromise;
const getMinifier = () => {
if (!minifyPromise) {
minifyPromise = import("minify-literals").then((m) => m.minifyHTMLLiterals);
// Also map to cache loader promises per environment (e.g., 'modern', 'legacy')
const loaderInitPromises = new Map();
const initLoader = (browserslistEnv) => {
if (!loaderInitPromises.has(browserslistEnv)) {
loaderInitPromises.set(
browserslistEnv,
Promise.all([
import("minify-literals"),
import("browserslist"),
import("lightningcss"),
]).then(([minifyModule, browserslistModule, lightningcssModule]) => {
const browserslist = browserslistModule.default;
const { browserslistToTargets } = lightningcssModule;
const rawTargets = browserslist(null, { env: browserslistEnv });
if (!rawTargets.length) {
throw new Error(
`No browsers resolved for browserslist environment "${browserslistEnv}"`
);
}
const lightningcssTargets = browserslistToTargets(rawTargets);
return {
minifyHTMLLiterals: minifyModule.minifyHTMLLiterals,
lightningcssTargets,
};
})
);
}
return minifyPromise;
return loaderInitPromises.get(browserslistEnv);
};
// HTML options mirror the previous babel-plugin-template-html-minifier config
// (html-minifier-next is option-compatible with html-minifier-terser). CSS in
// css`` templates and inline <style> is handled by minify-literals' lightningcss
// default.
// css`` templates and inline <style> is handled by minify-literals'
// lightningcss. We pass in the targets from browserslist so lightningcss
// can minify CSS appropriately for the build environment.
//
// `keepClosingSlash` is required for `svg`` templates: SVG elements such as
// `<path />` and `<circle />` are not void elements in HTML, so dropping the
@@ -40,11 +64,16 @@ const htmlOptions = {
module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
const callback = this.async();
getMinifier()
.then((minifyHTMLLiterals) =>
const { browserslistEnv } = this.getOptions();
initLoader(browserslistEnv)
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
minifyHTMLLiterals(source, {
fileName: this.resourcePath,
html: htmlOptions,
css: {
targets: lightningcssTargets,
},
})
)
.then((result) => {
+5
View File
@@ -96,6 +96,11 @@ const createRspackConfig = ({
__dirname,
"minify-template-literals-loader.cjs"
),
options: {
browserslistEnv: latestBuild
? "modern"
: `legacy${info.issuerLayer === "sw" ? "-sw" : ""}`,
},
},
!latestBuild &&
info.resource.startsWith(
+24 -11
View File
@@ -6,8 +6,10 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../common/color/compute-color";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { stateActive } from "../../common/entity/state_active";
import {
stateColorBrightness,
stateColorCss,
@@ -27,10 +29,15 @@ export class StateBadge extends LitElement {
@property({ attribute: false }) public overrideImage?: string;
// Cannot be a boolean attribute because undefined is treated different than
// false. When it is undefined, state is still colored for light entities.
/**
* Cannot be a boolean attribute because undefined is treated different than
* false. When it is undefined, state is still colored for light entities.
* @deprecated use `color` instead
*/
@property({ attribute: false }) public stateColor?: boolean;
// "state", "none" or a color (theme color name or CSS color). Custom colors
// apply when the entity is active. Takes precedence over stateColor.
@property() public color?: string;
// @todo Consider reworking to eliminate need for attribute since it is manipulated internally
@@ -67,11 +74,17 @@ export class StateBadge extends LitElement {
}
}
private get _stateColor() {
private get _color(): string | undefined {
if (this.color) {
return this.color;
}
if (this.stateColor !== undefined) {
return this.stateColor ? "state" : "none";
}
const domain = this.stateObj
? computeStateDomain(this.stateObj)
: undefined;
return this.stateColor ?? domain === "light";
return domain === "light" ? "state" : undefined;
}
protected render() {
@@ -131,6 +144,7 @@ export class StateBadge extends LitElement {
if (stateObj) {
const domain = computeDomain(stateObj.entity_id);
const color = this._color;
if (this.overrideImage === undefined) {
// hide icon if we have entity picture
if (
@@ -147,13 +161,10 @@ export class StateBadge extends LitElement {
}
backgroundImage = `url(${imageUrl})`;
this.icon = false;
} else if (this.color) {
// Externally provided overriding color wins over state color
iconStyle.color = this.color;
} else if (this._stateColor) {
const color = stateColorCss(stateObj);
if (color) {
iconStyle.color = color;
} else if (color === "state") {
const stateColor = stateColorCss(stateObj);
if (stateColor) {
iconStyle.color = stateColor;
}
if (stateObj.attributes.rgb_color) {
iconStyle.color = `rgb(${stateObj.attributes.rgb_color.join(",")})`;
@@ -180,6 +191,8 @@ export class StateBadge extends LitElement {
delete iconStyle.color;
}
}
} else if (color && color !== "none" && stateActive(stateObj)) {
iconStyle.color = computeCssColor(color);
}
} else if (this.overrideImage) {
backgroundImage = `url(${this._resolveImageUrl(this.overrideImage)})`;
+4 -2
View File
@@ -1,6 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import type { HomeAssistant } from "../../types";
import "../ha-relative-time";
import "../ha-tooltip";
@@ -23,10 +24,11 @@ class StateInfo extends LitElement {
const name = this.hass.formatEntityName(this.stateObj, { type: "entity" });
// Inline style because the state-badge color API only colors active entities
return html`<state-badge
.stateObj=${this.stateObj}
.stateColor=${true}
.color=${this.color}
.stateColor=${!this.color}
style=${styleMap({ color: this.color })}
></state-badge>
<div class="info">
<div class="name ${this.inDialog ? "in-dialog" : ""}" .title=${name}>
+37 -1
View File
@@ -57,6 +57,16 @@ interface AssistMessage {
error?: boolean;
}
export const initialPromptToSubmit = (
prompt: string | undefined,
submit: boolean
): string | undefined => (submit ? prompt?.trim() || undefined : undefined);
export const assistPipelineChanged = (
previous: AssistPipeline | undefined,
current: AssistPipeline | undefined
): boolean => previous?.id !== current?.id;
@customElement("ha-assist-chat")
export class HaAssistChat extends LitElement {
@property({ attribute: false }) public pipeline?: AssistPipeline;
@@ -67,6 +77,12 @@ export class HaAssistChat extends LitElement {
@property({ attribute: false })
public startListening?: boolean;
@property({ attribute: false })
public initialPrompt?: string;
@property({ attribute: false })
public submitInitialPrompt = false;
@query("#message-input") private _messageInput!: HaInput;
@query(".message:last-child")
@@ -99,6 +115,8 @@ export class HaAssistChat extends LitElement {
private _conversationId: string | null = null;
private _initialPromptSubmitted = false;
private _audioRecorder?: AudioRecorder;
private _audioBuffer?: Int16Array[];
@@ -108,7 +126,11 @@ export class HaAssistChat extends LitElement {
private _stt_binary_handler_id?: number | null;
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (!this.hasUpdated || changedProperties.has("pipeline")) {
if (
!this.hasUpdated ||
(changedProperties.has("pipeline") &&
assistPipelineChanged(changedProperties.get("pipeline"), this.pipeline))
) {
this._conversation = [
{
who: "hass",
@@ -138,6 +160,20 @@ export class HaAssistChat extends LitElement {
if (changedProps.has("_conversation")) {
this._scrollMessagesBottom();
}
if (
!this._initialPromptSubmitted &&
(changedProps.has("initialPrompt") ||
changedProps.has("submitInitialPrompt"))
) {
const prompt = initialPromptToSubmit(
this.initialPrompt,
this.submitInitialPrompt
);
if (prompt) {
this._initialPromptSubmitted = true;
this._processText(prompt);
}
}
}
public disconnectedCallback() {
+30 -7
View File
@@ -57,17 +57,40 @@ export const evaluateCondition = (
return matchFieldCondition(condition, data);
};
export const isFieldHidden = (
export const isFieldVisible = (
schema: HaFormSchema,
data: HaFormDataContainer | undefined
): boolean => {
const { hidden } = schema as HaFormBaseSchema;
if (!hidden) {
return false;
}
if (hidden === true) {
const { visible } = schema as HaFormBaseSchema;
if (visible === undefined || visible === true) {
return true;
}
const conditions = Array.isArray(hidden) ? hidden : [hidden];
if (visible === false) {
return false;
}
const conditions = Array.isArray(visible) ? visible : [visible];
return conditions.every((condition) => evaluateCondition(condition, data));
};
// Hiding a field drops its value, which can flip another field's condition, so
// resolve the set to a fixpoint.
export const getHiddenFields = (
schema: readonly HaFormSchema[],
data: HaFormDataContainer | undefined
): Set<string> => {
const hidden = new Set<string>();
const evalData: HaFormDataContainer = { ...(data ?? {}) };
let changed = true;
while (changed) {
changed = false;
for (const field of schema) {
if (hidden.has(field.name) || isFieldVisible(field, evalData)) {
continue;
}
hidden.add(field.name);
delete evalData[field.name];
changed = true;
}
}
return hidden;
};
+4 -2
View File
@@ -2,7 +2,7 @@ import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, queryAll } from "lit/decorators";
import type { HomeAssistant } from "../../types";
import { isFieldHidden } from "./conditions";
import { getHiddenFields } from "./conditions";
import "./ha-form";
import type { HaForm } from "./ha-form";
import type {
@@ -68,9 +68,11 @@ export class HaFormGrid extends LitElement implements HaFormElement {
}
protected render(): TemplateResult {
const hiddenFields = getHiddenFields(this.schema.schema, this.data);
return html`
${this.schema.schema
.filter((item) => !isFieldHidden(item, this.data))
.filter((item) => !hiddenFields.has(item.name))
.map(
(item) => html`
<ha-form
+6 -3
View File
@@ -6,7 +6,7 @@ import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant } from "../../types";
import "../ha-alert";
import "../ha-selector/ha-selector";
import { isFieldHidden } from "./conditions";
import { getHiddenFields } from "./conditions";
import type { HaFormDataContainer, HaFormElement, HaFormSchema } from "./types";
const LOAD_ELEMENTS = {
@@ -99,8 +99,9 @@ export class HaForm extends LitElement implements HaFormElement {
let isValid = true;
let firstInvalidElement: HTMLElement | undefined;
const hiddenFields = getHiddenFields(this.schema, this.data);
const visibleSchema = this.schema.filter(
(item) => !isFieldHidden(item, this.data)
(item) => !hiddenFields.has(item.name)
);
visibleSchema.forEach((item, index) => {
@@ -157,6 +158,8 @@ export class HaForm extends LitElement implements HaFormElement {
}
protected render(): TemplateResult {
const renderHiddenFields = getHiddenFields(this.schema, this.data);
return html`
<div class="root" part="root">
${
@@ -169,7 +172,7 @@ export class HaForm extends LitElement implements HaFormElement {
: ""
}
${this.schema.map((item) => {
if (isFieldHidden(item, this.data)) {
if (renderHiddenFields.has(item.name)) {
return nothing;
}
+3 -3
View File
@@ -22,9 +22,9 @@ export interface HaFormBaseSchema {
default?: HaFormData;
required?: boolean;
disabled?: boolean;
// Field is hidden while the condition holds. Serializable so it can be
// shared with the backend and other renderers.
hidden?: boolean | HaFormCondition | HaFormCondition[];
// Field is visible while the condition holds (visible by default).
// Serializable so it can be shared with the backend and other renderers.
visible?: boolean | HaFormCondition | HaFormCondition[];
description?: {
suffix?: string;
// This value will be set initially when form is loaded
+9 -8
View File
@@ -1,6 +1,7 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import type { HomeAssistant } from "../types";
import { subscribeLabFeature } from "../data/labs";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
@@ -81,14 +82,14 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) {
class="snowflake ${
this.narrow && flake.id >= 30 ? "hide-narrow" : ""
}"
style="
left: ${flake.left}%;
width: ${flake.size}px;
height: ${flake.size}px;
animation-duration: ${flake.duration}s;
animation-delay: ${flake.delay}s;
--rotation: ${flake.rotation}deg;
"
style=${styleMap({
left: `${flake.left}%`,
width: `${flake.size}px`,
height: `${flake.size}px`,
"animation-duration": `${flake.duration}s`,
"animation-delay": `${flake.delay}s`,
"--rotation": `${flake.rotation}deg`,
})}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
+16 -6
View File
@@ -9,6 +9,7 @@ import { fireEvent } from "../../common/dom/fire_event";
import { isNavigationClick } from "../../common/dom/is-navigation-click";
import "../../components/ha-alert";
import { computeInitialHaFormData } from "../../components/ha-form/compute-initial-ha-form-data";
import { getHiddenFields } from "../../components/ha-form/conditions";
import "../../components/ha-form/ha-form";
import type {
HaFormSchema,
@@ -226,14 +227,17 @@ class StepFlowForm extends LitElement {
const checkAllRequiredFields = (
schema: readonly HaFormSchema[],
data: Record<string, any>
) =>
schema.every(
) => {
const hidden = getHiddenFields(schema, data);
return schema.every(
(field) =>
(!field.required || !["", undefined].includes(data[field.name])) &&
(field.type !== "expandable" ||
(!field.required && data[field.name] === undefined) ||
checkAllRequiredFields(field.schema, data[field.name]))
hidden.has(field.name) ||
((!field.required || !["", undefined].includes(data[field.name])) &&
(field.type !== "expandable" ||
(!field.required && data[field.name] === undefined) ||
checkAllRequiredFields(field.schema, data[field.name])))
);
};
const allRequiredInfoFilledIn =
stepData === undefined
@@ -255,8 +259,14 @@ class StepFlowForm extends LitElement {
const flowId = this.step.flow_id;
const hiddenFields = getHiddenFields(this.step.data_schema, stepData);
const toSendData: Record<string, unknown> = {};
Object.keys(stepData).forEach((key) => {
if (hiddenFields.has(key)) {
// Hidden fields are not part of the submitted config
return;
}
const value = stepData[key];
const isEmpty = [undefined, ""].includes(value);
const field = this.step.data_schema?.find((f) => f.name === key);
+56 -17
View File
@@ -1,4 +1,4 @@
import { mdiDevices } from "@mdi/js";
import { mdiCommentProcessingOutline, mdiDevices } from "@mdi/js";
import { consume } from "@lit/context";
import Fuse from "fuse.js";
import type { CSSResultGroup, PropertyValues } from "lit";
@@ -61,6 +61,7 @@ import { isIosApp } from "../../util/is_ios";
import { isMac } from "../../util/is_mac";
import { showConfirmationDialog } from "../generic/show-dialog-box";
import { showShortcutsDialog } from "../shortcuts/show-shortcuts-dialog";
import { showVoiceCommandDialog } from "../voice-command-dialog/show-ha-voice-command-dialog";
import {
effectiveQuickBarMode,
type QuickBarParams,
@@ -69,6 +70,18 @@ import {
const SEPARATOR = "________";
interface AssistComboBoxItem extends PickerComboBoxItem {
action: "assist";
assistPrompt: string;
}
type QuickBarComboBoxItem =
| NavigationComboBoxItem
| ActionCommandComboBoxItem
| EntityComboBoxItem
| DevicePickerItem
| AssistComboBoxItem;
@customElement("ha-quick-bar")
export class QuickBar extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -290,13 +303,7 @@ export class QuickBar extends LitElement {
`;
}
private _renderRow = (
item:
| NavigationComboBoxItem
| ActionCommandComboBoxItem
| EntityComboBoxItem
| DevicePickerItem
) => {
private _renderRow = (item: QuickBarComboBoxItem) => {
if (!item) {
return nothing;
}
@@ -412,8 +419,8 @@ export class QuickBar extends LitElement {
}: {
firstIndex: number;
lastIndex: number;
firstItem: PickerComboBoxItem | string;
secondItem: PickerComboBoxItem | string;
firstItem: QuickBarComboBoxItem | string;
secondItem: QuickBarComboBoxItem | string;
itemsCount: number;
}) => {
if (
@@ -461,7 +468,23 @@ export class QuickBar extends LitElement {
filter?: string,
section?: QuickBarSection
) => {
const items: (string | PickerComboBoxItem)[] = [];
const items: (string | QuickBarComboBoxItem)[] = [];
const prompt = filter?.trim();
const assistItem =
prompt &&
(!section || section === "command") &&
isComponentLoaded(this.hass.config, "conversation") &&
!this.hass.auth.external?.config.hasAssist
? ({
id: "ask-assist",
action: "assist",
primary: this.hass.localize("ui.dialogs.quick-bar.ask_assist", {
query: prompt,
}),
icon_path: mdiCommentProcessingOutline,
assistPrompt: prompt,
} satisfies AssistComboBoxItem)
: undefined;
if (!section || section === "navigate") {
let navigateItems = this._generateNavigationCommandsMemoized(
@@ -486,10 +509,12 @@ export class QuickBar extends LitElement {
items.push(...navigateItems);
}
if (this.hass.user?.is_admin && (!section || section === "command")) {
let commandItems = this._generateActionCommandsMemoized(this.hass).sort(
this._sortBySortingLabel
);
if (!section || section === "command") {
let commandItems = this.hass.user?.is_admin
? this._generateActionCommandsMemoized(this.hass).sort(
this._sortBySortingLabel
)
: [];
if (filter) {
commandItems = this._filterGroup(
@@ -499,12 +524,15 @@ export class QuickBar extends LitElement {
) as ActionCommandComboBoxItem[];
}
if (!section && commandItems.length) {
if (!section && (commandItems.length || assistItem)) {
// show group title
items.push(this.hass.localize("ui.dialogs.quick-bar.commands_title"));
}
items.push(...commandItems);
if (assistItem) {
items.push(assistItem);
}
}
if (!section || section === "entity") {
@@ -723,10 +751,21 @@ export class QuickBar extends LitElement {
const { index, newTab } = ev.detail;
const item = this._comboBox.virtualizerElement.items[
index
] as PickerComboBoxItem;
] as QuickBarComboBoxItem;
this._itemSelected = true;
if (item && "assistPrompt" in item) {
this.closeDialog();
showVoiceCommandDialog(this, this.hass, {
pipeline_id: "last_used",
start_listening: false,
prompt: item.assistPrompt,
submit: true,
});
return;
}
// entity selected
if (item && "stateObj" in item) {
this.closeDialog();
@@ -58,9 +58,11 @@ export class HaVoiceCommandDialog extends LitElement {
private _startListening = false;
public async showDialog(
params: Required<VoiceCommandDialogParams>
): Promise<void> {
private _prompt?: string;
private _submitPrompt = false;
public async showDialog(params: VoiceCommandDialogParams): Promise<void> {
await this._loadPipelines();
const pipelinesIds = this._pipelines?.map((pipeline) => pipeline.id) || [];
if (
@@ -77,7 +79,9 @@ export class HaVoiceCommandDialog extends LitElement {
this._pipelineId = this._preferredPipeline;
}
this._startListening = params.start_listening;
this._startListening = params.start_listening ?? false;
this._prompt = params.prompt;
this._submitPrompt = params.submit ?? false;
this._dialogOpen = true;
this._open = true;
}
@@ -189,6 +193,8 @@ export class HaVoiceCommandDialog extends LitElement {
.hass=${this.hass}
.pipeline=${this._pipeline}
.startListening=${this._startListening}
.initialPrompt=${this._prompt}
.submitInitialPrompt=${this._submitPrompt}
>
</ha-assist-chat>
`
@@ -6,6 +6,8 @@ const loadVoiceCommandDialog = () => import("./ha-voice-command-dialog");
export interface VoiceCommandDialogParams {
pipeline_id: "last_used" | "preferred" | string;
start_listening?: boolean;
prompt?: string;
submit?: boolean;
}
export const showVoiceCommandDialog = (
@@ -31,6 +33,8 @@ export const showVoiceCommandDialog = (
pipeline_id: dialogParams.pipeline_id,
// Don't start listening by default for web
start_listening: dialogParams.start_listening ?? false,
prompt: dialogParams.prompt,
submit: dialogParams.submit ?? false,
},
});
};
@@ -95,7 +95,7 @@ import { showMoreInfoDialog } from "../../../../../dialogs/more-info/show-ha-mor
import { MobileAwareMixin } from "../../../../../mixins/mobile-aware-mixin";
import { mdiHomeAssistant } from "../../../../../resources/home-assistant-logo-svg";
import { haStyle } from "../../../../../resources/styles";
import type { Route } from "../../../../../types";
import type { HomeAssistantRegistries, Route } from "../../../../../types";
import { bytesToString } from "../../../../../util/bytes-to-string";
import { getAppDisplayName } from "../../common/app";
import "../../components/supervisor-apps-state";
@@ -172,10 +172,20 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
public connectedCallback() {
super.connectedCallback();
this._computeUpdateEntityId();
this._startPolling();
}
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Registries load asynchronously and change over time; resolve the update
// entity reactively instead of only once on connect.
this._updateEntityId = this._findUpdateEntityId(
this.registries.devices,
this.registries.entities,
(this._currentAddon as HassioAddonDetails).slug
);
}
public disconnectedCallback() {
super.disconnectedCallback();
this._stopPolling();
@@ -1500,26 +1510,24 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
"system_managed" in addon && addon.system_managed
);
private _computeUpdateEntityId() {
const addon = this._currentAddon as HassioAddonDetails;
const device = Object.values(this.registries.devices).find((d) =>
d.identifiers.some(
([domain, id]) => domain === "hassio" && id === addon.slug
)
);
if (!device) {
return;
private _findUpdateEntityId = memoizeOne(
(
devices: HomeAssistantRegistries["devices"],
entities: HomeAssistantRegistries["entities"],
slug: string
): string | undefined => {
const device = Object.values(devices).find((d) =>
d.identifiers.some(([domain, id]) => domain === "hassio" && id === slug)
);
if (!device) {
return undefined;
}
return Object.values(entities).find(
(e) =>
e.device_id === device.id && computeDomain(e.entity_id) === "update"
)?.entity_id;
}
const updateEntity = Object.values(this.registries.entities).find(
(e) =>
e.device_id === device.id && computeDomain(e.entity_id) === "update"
);
if (!updateEntity) {
return;
}
this._updateEntityId = updateEntity.entity_id;
}
);
private _openUpdate() {
showMoreInfoDialog(this, { entityId: this._updateEntityId! });
+14 -13
View File
@@ -8,6 +8,7 @@ import "../../../components/ha-card";
import type { HomeAssistant } from "../../../types";
import { computeCardSize } from "../common/compute-card-size";
import { findEntities } from "../common/find-entities";
import { applyDefaultColor } from "../common/entity-color-config";
import { processConfigEntities } from "../common/process-config-entities";
import "../components/hui-entities-toggle";
import { createHeaderFooterElement } from "../create-element/create-header-footer-element";
@@ -24,7 +25,7 @@ import type {
LovelaceHeaderFooter,
} from "../types";
import { migrateEntitiesCardConfig } from "./migrate-card-config";
import type { EntitiesCardConfig } from "./types";
import type { EntitiesCardConfig, EntitiesCardEntityConfig } from "./types";
import { haStyleScrollbar } from "../../../resources/styles";
export const computeShowHeaderToggle = <
@@ -159,7 +160,7 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
const migratedConfig = migrateEntitiesCardConfig(config);
const entities = processConfigEntities(migratedConfig.entities);
this._config = migratedConfig;
this._config = { color: "state", ...migratedConfig };
this._configEntities = entities;
this._showHeaderToggle = computeShowHeaderToggle(migratedConfig, entities);
if (this._config.header) {
@@ -343,18 +344,18 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
`,
];
private _renderEntity(entityConf: LovelaceRowConfig): TemplateResult {
const element = createRowElement(
(!("type" in entityConf) || entityConf.type === "conditional") &&
"state_color" in this._config!
? ({
state_color: this._config.state_color,
...(entityConf as EntityConfig),
} as EntityConfig)
: entityConf.type === "perform-action"
? { ...entityConf, type: "call-service" }
: entityConf
private _rowConfig(entityConf: LovelaceRowConfig): LovelaceRowConfig {
if (entityConf.type === "perform-action") {
return { ...entityConf, type: "call-service" };
}
return applyDefaultColor(
entityConf as EntitiesCardEntityConfig,
this._config!.color
);
}
private _renderEntity(entityConf: LovelaceRowConfig): TemplateResult {
const element = createRowElement(this._rowConfig(entityConf));
if (this._hass) {
element.hass = this._hass;
}
+12 -8
View File
@@ -26,6 +26,7 @@ import { findEntities } from "../common/find-entities";
import { handleAction } from "../common/handle-action";
import { hasAction, hasAnyAction } from "../common/has-action";
import { hasConfigOrEntitiesChanged } from "../common/has-changed";
import { applyDefaultColor } from "../common/entity-color-config";
import { processConfigEntities } from "../common/process-config-entities";
import "../components/hui-timestamp-display";
import { createEntityNotFoundWarning } from "../components/hui-warning";
@@ -87,14 +88,19 @@ export class HuiGlanceCard extends LitElement implements LovelaceCard {
show_name: true,
show_state: true,
show_icon: true,
state_color: true,
color: "state",
...migratedConfig,
};
const cardColor = this._config.color;
const entities = processConfigEntities(migratedConfig.entities).map(
(entityConf) => ({
hold_action: { action: "more-info" } as MoreInfoActionConfig,
...entityConf,
})
(entityConf) =>
applyDefaultColor(
{
hold_action: { action: "more-info" } as MoreInfoActionConfig,
...entityConf,
},
cardColor
)
);
for (const entity of entities) {
@@ -294,9 +300,7 @@ export class HuiGlanceCard extends LitElement implements LovelaceCard {
.stateObj=${stateObj}
.overrideIcon=${entityConf.icon}
.overrideImage=${entityConf.image}
.stateColor=${
entityConf.state_color ?? this._config!.state_color
}
.color=${entityConf.color}
></state-badge>
`
: ""
@@ -1,40 +1,56 @@
import type { EntityConfig, LovelaceRowConfig } from "../entity-rows/types";
import { migrateStateColorConfig } from "../common/entity-color-config";
import { migrateTimeFormatConfig } from "../common/entity-time-format-config";
import type {
ConditionalRowConfig,
LovelaceRowConfig,
} from "../entity-rows/types";
import type {
EntitiesCardConfig,
EntitiesCardEntityConfig,
GlanceCardConfig,
GlanceConfigEntity,
} from "./types";
const migrateEntitiesRowConfig = (
rowConf: LovelaceRowConfig | string
): LovelaceRowConfig | string => {
if (typeof rowConf !== "object") {
return rowConf;
}
let newConf: LovelaceRowConfig = rowConf;
newConf = migrateTimeFormatConfig(newConf as EntitiesCardEntityConfig);
newConf = migrateStateColorConfig(newConf as EntitiesCardEntityConfig);
if (newConf.type === "conditional") {
const row = (newConf as ConditionalRowConfig).row;
if (row && typeof row === "object") {
let newRow = migrateTimeFormatConfig(row as EntitiesCardEntityConfig);
newRow = migrateStateColorConfig(newRow);
if (newRow !== row) {
newConf = { ...newConf, row: newRow } as ConditionalRowConfig;
}
}
}
return newConf;
};
export const migrateEntitiesCardConfig = (
config: EntitiesCardConfig
): EntitiesCardConfig => {
let changed = false;
const newEntities = config.entities?.map((e) => {
if (typeof e !== "object") {
return e;
const newConf = migrateEntitiesRowConfig(e);
if (newConf !== e) {
changed = true;
}
// Custom rows own their config schema and may use `format` with a
// different meaning (e.g. custom:multiple-entity-row), so leave it
// untouched.
if (e.type?.startsWith("custom:")) {
return e;
}
if (!("format" in e)) {
return e;
}
changed = true;
const { format, ...rest } = e;
return {
...rest,
time_format: (rest as EntityConfig).time_format ?? format,
};
return newConf;
});
const newConfig = migrateStateColorConfig(config);
if (!changed) {
return config;
return newConfig;
}
return {
...config,
entities: newEntities as (LovelaceRowConfig | string)[],
...newConfig,
entities: newEntities,
};
};
@@ -46,21 +62,20 @@ export const migrateGlanceCardConfig = (
if (typeof e !== "object") {
return e;
}
if (!("format" in e)) {
return e;
let newConf = e;
newConf = migrateTimeFormatConfig(newConf);
newConf = migrateStateColorConfig(newConf);
if (newConf !== e) {
changed = true;
}
changed = true;
const { format, ...rest } = e;
return {
...rest,
time_format: rest.time_format ?? format,
};
return newConf;
});
const newConfig = migrateStateColorConfig(config);
if (!changed) {
return config;
return newConfig;
}
return {
...config,
...newConfig,
entities: newEntities as (GlanceConfigEntity | string)[],
};
};
+8
View File
@@ -112,7 +112,9 @@ export interface EntitiesCardEntityConfig extends EntityConfig {
tap_action?: ActionConfig;
hold_action?: ActionConfig;
double_tap_action?: ActionConfig;
/** @deprecated use `color` instead */
state_color?: boolean;
color?: string;
show_name?: boolean;
show_icon?: boolean;
}
@@ -126,7 +128,9 @@ export interface EntitiesCardConfig extends LovelaceCardConfig {
icon?: string;
header?: LovelaceHeaderFooterConfig;
footer?: LovelaceHeaderFooterConfig;
/** @deprecated use `color` instead */
state_color?: boolean;
color?: string;
}
export type AreaCardDisplayType = "compact" | "icon" | "picture" | "camera";
@@ -330,7 +334,9 @@ export interface GlanceConfigEntity extends ConfigEntity {
show_last_changed?: boolean;
image?: string;
show_state?: boolean;
/** @deprecated use `color` instead */
state_color?: boolean;
color?: string;
time_format?: TimestampRenderingFormat;
}
@@ -342,7 +348,9 @@ export interface GlanceCardConfig extends LovelaceCardConfig {
theme?: string;
entities: (string | GlanceConfigEntity)[];
columns?: number;
/** @deprecated use `color` instead */
state_color?: boolean;
color?: string;
}
export interface HumidifierCardConfig extends LovelaceCardConfig {
@@ -0,0 +1,34 @@
import { isCustomType } from "../../../data/lovelace_custom_cards";
interface LegacyStateColorConfig {
type?: string;
color?: string;
state_color?: boolean;
}
export const migrateStateColorConfig = <T extends LegacyStateColorConfig>(
config: T
): T => {
// Custom elements own their config schema, leave them untouched
if (config.type !== undefined && isCustomType(config.type)) {
return config;
}
if (config.state_color === undefined) {
return config;
}
const { state_color, ...rest } = config;
return {
color: state_color ? "state" : "none",
...rest,
} as T;
};
export const applyDefaultColor = <T extends { type?: string; color?: string }>(
config: T,
color: string | undefined
): T =>
color === undefined ||
config.color !== undefined ||
(config.type !== undefined && isCustomType(config.type))
? config
: { ...config, color };
@@ -0,0 +1,27 @@
import { isCustomType } from "../../../data/lovelace_custom_cards";
import type { TimestampRenderingFormat } from "../components/types";
interface LegacyTimeFormatConfig {
type?: string;
time_format?: TimestampRenderingFormat;
/** @deprecated use `time_format` instead */
format?: TimestampRenderingFormat;
}
export const migrateTimeFormatConfig = <T extends LegacyTimeFormatConfig>(
config: T
): T => {
// Custom elements own their config schema and may use the same option with
// a different meaning (e.g. custom:multiple-entity-row), leave them untouched
if (config.type !== undefined && isCustomType(config.type)) {
return config;
}
if (config.format === undefined) {
return config;
}
const { format, ...rest } = config;
return {
time_format: format,
...rest,
} as T;
};
@@ -86,6 +86,7 @@ export class HuiGenericEntityRow extends LitElement {
.overrideIcon=${this.config.icon}
.overrideImage=${this.config.image}
.stateColor=${this.config.state_color}
.color=${this.config.color}
></state-badge>
${
!this.hideName
@@ -2,13 +2,11 @@ import type { HaFormSchema } from "../../../../components/ha-form/types";
interface CustomizableListSchemaParams {
field: string;
customize: boolean;
options: { value: string; label: string }[];
}
export const customizableListSchema = ({
field,
customize,
options,
}: CustomizableListSchemaParams) =>
[
@@ -16,21 +14,18 @@ export const customizableListSchema = ({
name: "customize",
selector: { boolean: {} },
},
...(customize
? ([
{
name: field,
selector: {
select: {
mode: "list",
reorder: true,
multiple: true,
options,
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
{
name: field,
visible: { field: "customize", value: true },
selector: {
select: {
mode: "list",
reorder: true,
multiple: true,
options,
},
},
},
] as const satisfies readonly HaFormSchema[];
// `customize` is form-only and never stored in the config.
@@ -37,11 +37,7 @@ export class HuiAlarmModesCardFeatureEditor
}
private _schema = memoizeOne(
(
localize: LocalizeFunc,
stateObj: HassEntity | undefined,
customizeModes: boolean
) =>
(localize: LocalizeFunc, stateObj: HassEntity | undefined) =>
[
{
name: "customize_modes",
@@ -49,27 +45,24 @@ export class HuiAlarmModesCardFeatureEditor
boolean: {},
},
},
...(customizeModes
? ([
{
name: "modes",
selector: {
select: {
multiple: true,
reorder: true,
options: stateObj
? supportedAlarmModes(stateObj).map((mode) => ({
value: mode,
label: `${localize(
`ui.panel.lovelace.editor.features.types.alarm-modes.modes_list.${mode}`
)}`,
}))
: [],
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
{
name: "modes",
visible: { field: "customize_modes", value: true },
selector: {
select: {
multiple: true,
reorder: true,
options: stateObj
? supportedAlarmModes(stateObj).map((mode) => ({
value: mode,
label: `${localize(
`ui.panel.lovelace.editor.features.types.alarm-modes.modes_list.${mode}`
)}`,
}))
: [],
},
},
},
] as const satisfies readonly HaFormSchema[]
);
@@ -87,11 +80,7 @@ export class HuiAlarmModesCardFeatureEditor
? this.hass.states[this.context?.entity_id]
: undefined;
const schema = this._schema(
this.hass.localize,
stateObj,
data.customize_modes
);
const schema = this._schema(this.hass.localize, stateObj);
return html`
<ha-form
@@ -113,9 +113,8 @@ export class HuiClockCardEditor
},
{
name: "time_format",
hidden: {
visible: {
field: "clock_style",
operator: "not_eq",
value: "digital",
},
selector: {
@@ -132,7 +131,7 @@ export class HuiClockCardEditor
},
{
name: "border",
hidden: { field: "clock_style", operator: "not_eq", value: "analog" },
visible: { field: "clock_style", value: "analog" },
description: {
suffix: localize(
`ui.panel.lovelace.editor.card.clock.border.description`
@@ -145,7 +144,7 @@ export class HuiClockCardEditor
},
{
name: "ticks",
hidden: { field: "clock_style", operator: "not_eq", value: "analog" },
visible: { field: "clock_style", value: "analog" },
description: {
suffix: localize(
`ui.panel.lovelace.editor.card.clock.ticks.description`
@@ -169,13 +168,10 @@ export class HuiClockCardEditor
},
{
name: "seconds_motion",
hidden: {
condition: "or",
conditions: [
{ field: "clock_style", operator: "not_eq", value: "analog" },
{ field: "show_seconds", operator: "not_eq", value: true },
],
},
visible: [
{ field: "clock_style", value: "analog" },
{ field: "show_seconds", value: true },
],
description: {
suffix: localize(
`ui.panel.lovelace.editor.card.clock.seconds_motion.description`
@@ -199,13 +195,10 @@ export class HuiClockCardEditor
},
{
name: "face_style",
hidden: {
condition: "or",
conditions: [
{ field: "clock_style", operator: "not_eq", value: "analog" },
{ field: "ticks", value: "none" },
],
},
visible: [
{ field: "clock_style", value: "analog" },
{ field: "ticks", operator: "not_eq", value: "none" },
],
description: {
suffix: localize(
`ui.panel.lovelace.editor.card.clock.face_style.description`
@@ -2,6 +2,7 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import "../../../../components/ha-form/ha-form";
import type { HomeAssistant } from "../../../../types";
@@ -32,13 +33,12 @@ export class HuiCounterActionsCardFeatureEditor
this._config = config;
}
private _schema = memoizeOne((customize: boolean) =>
private _schema = memoizeOne((localize: LocalizeFunc) =>
customizableListSchema({
field: "actions",
customize,
options: COUNTER_ACTIONS.map((action) => ({
value: action,
label: this.hass!.localize(
label: localize(
`ui.panel.lovelace.editor.features.types.counter-actions.actions_list.${action}`
),
})),
@@ -51,7 +51,7 @@ export class HuiCounterActionsCardFeatureEditor
}
const data = customizableListData(this._config, "actions");
const schema = this._schema(data.customize);
const schema = this._schema(this.hass.localize);
return html`
<ha-form
@@ -61,38 +61,36 @@ export class HuiEnergyDevicesCardEditor
this._config = config;
}
private _schema = memoizeOne((localize: LocalizeFunc, type: string) => {
private _schema = memoizeOne((localize: LocalizeFunc) => {
const schema: HaFormSchema[] = [
{ name: "title", selector: { text: {} } },
{
name: "",
type: "grid",
schema: [
...(type === "energy-devices-graph"
? ([
{
name: "modes",
required: false,
selector: {
select: {
multiple: true,
mode: "list",
options: chartModeOpts.map((mode) => ({
value: mode,
label: localize(
`ui.panel.lovelace.editor.card.energy-devices-graph.mode_options.${mode}`
),
})),
},
},
},
{
name: "hide_compound_stats",
required: false,
selector: { boolean: {} },
},
] as HaFormSchema[])
: []),
{
name: "modes",
required: false,
visible: { field: "type", value: "energy-devices-graph" },
selector: {
select: {
multiple: true,
mode: "list",
options: chartModeOpts.map((mode) => ({
value: mode,
label: localize(
`ui.panel.lovelace.editor.card.energy-devices-graph.mode_options.${mode}`
),
})),
},
},
},
{
name: "hide_compound_stats",
required: false,
visible: { field: "type", value: "energy-devices-graph" },
selector: { boolean: {} },
},
{
name: "max_devices",
required: false,
@@ -114,7 +112,7 @@ export class HuiEnergyDevicesCardEditor
return nothing;
}
const schema = this._schema(this.hass.localize, this._config.type);
const schema = this._schema(this.hass.localize);
const data = {
...this._config,
@@ -10,7 +10,6 @@ import {
string,
union,
} from "superstruct";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type { HaFormSchema } from "../../../../components/ha-form/types";
@@ -24,6 +23,36 @@ import type {
import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
const SCHEMA: HaFormSchema[] = [
{
name: "title",
visible: { field: "type", operator: "not_eq", value: "energy-compare" },
selector: { text: {} },
},
{
name: "show_legend",
visible: {
field: "type",
operator: "in",
value: ["power-sources-graph", "energy-usage-graph"],
},
default: true,
required: false,
selector: { boolean: {} },
},
{
name: "link_dashboard",
visible: { field: "type", value: "energy-distribution" },
required: false,
selector: { boolean: {} },
},
{
type: "string",
name: "collection_key",
required: false,
},
];
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
@@ -72,46 +101,11 @@ export class HuiEnergyGraphCardEditor
this._config = config;
}
private _schema = memoizeOne((type: string) => {
const schema: HaFormSchema[] = [
...(type !== "energy-compare"
? [{ name: "title", selector: { text: {} } }]
: []),
...(type === "power-sources-graph" || type === "energy-usage-graph"
? [
{
name: "show_legend",
default: true,
required: false,
selector: { boolean: {} },
},
]
: []),
...(type === "energy-distribution"
? [
{
name: "link_dashboard",
required: false,
selector: { boolean: {} },
},
]
: []),
{
type: "string",
name: "collection_key",
required: false,
},
];
return schema;
});
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const schema = this._schema(this._config.type);
const data = {
...this._config,
};
@@ -119,7 +113,7 @@ export class HuiEnergyGraphCardEditor
return html` <ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.schema=${SCHEMA}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@value-changed=${this._valueChanged}
@@ -22,11 +22,9 @@ import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { customType } from "../../../../common/structs/is-custom-type";
import "../../../../components/ha-card";
import "../../../../components/ha-formfield";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import "../../../../components/ha-icon";
import "../../../../components/ha-switch";
import "../../../../components/ha-theme-picker";
import "../../../../components/input/ha-input";
import { isCustomType } from "../../../../data/lovelace_custom_cards";
import type { HomeAssistant } from "../../../../types";
import {
@@ -90,6 +88,7 @@ const conditionalEntitiesRowConfigStruct = object({
type: literal("conditional"),
row: any(),
conditions: array(any()),
color: optional(string()),
});
const dividerEntitiesRowConfigStruct = object({
@@ -181,6 +180,22 @@ const entitiesRowConfigStruct = dynamic<any>((value) => {
return entitiesConfigStruct;
});
const SCHEMA = [
{ name: "title", selector: { text: {} } },
{ name: "theme", selector: { theme: {} } },
{
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
include_none: true,
},
},
},
{ name: "show_header_toggle", selector: { boolean: {} } },
] as const;
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
@@ -189,7 +204,7 @@ const cardConfigStruct = assign(
theme: optional(string()),
icon: optional(string()),
show_header_toggle: optional(boolean()),
state_color: optional(boolean()),
color: optional(string()),
entities: array(entitiesRowConfigStruct),
header: optional(headerFooterConfigStructs),
footer: optional(headerFooterConfigStructs),
@@ -226,14 +241,6 @@ export class HuiEntitiesCardEditor
);
});
get _title(): string {
return this._config!.title || "";
}
get _theme(): string {
return this._config!.theme || "";
}
protected render() {
if (!this.hass || !this._config) {
return nothing;
@@ -251,52 +258,20 @@ export class HuiEntitiesCardEditor
`;
}
const data = {
...this._config,
show_header_toggle: this._showHeaderToggle(this._config),
};
return html`
<div class="card-config">
<ha-input
.label="${this.hass.localize(
"ui.panel.lovelace.editor.card.generic.title"
)} (${this.hass.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})"
.value=${this._title}
.configValue=${"title"}
@input=${this._valueChanged}
></ha-input>
<ha-theme-picker
.value=${this._theme}
.label=${`${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.theme"
)} (${this.hass!.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})`}
.configValue=${"theme"}
@value-changed=${this._valueChanged}
></ha-theme-picker>
<div class="side-by-side">
<ha-formfield
.label=${this.hass.localize(
"ui.panel.lovelace.editor.card.entities.show_header_toggle"
)}
>
<ha-switch
.checked=${this._showHeaderToggle(this._config)}
.configValue=${"show_header_toggle"}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize(
"ui.panel.lovelace.editor.card.generic.state_color"
)}
>
<ha-switch
.checked=${this._config!.state_color}
.configValue=${"state_color"}
@change=${this._valueChanged}
></ha-switch>
</ha-formfield>
</div>
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${SCHEMA}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._formChanged}
></ha-form>
<hui-header-footer-editor
.hass=${this.hass}
.configValue=${"header"}
@@ -321,6 +296,43 @@ export class HuiEntitiesCardEditor
`;
}
private _formChanged(ev: CustomEvent): void {
ev.stopPropagation();
if (!this._config) {
return;
}
const config = { ...ev.detail.value } as EntitiesCardConfig;
if (
this._config.show_header_toggle === undefined &&
config.show_header_toggle === this._showHeaderToggle(this._config)
) {
delete config.show_header_toggle;
}
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
switch (schema.name) {
case "title":
case "theme":
return `${this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
)} (${this.hass!.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})`;
case "show_header_toggle":
return this.hass!.localize(
"ui.panel.lovelace.editor.card.entities.show_header_toggle"
);
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
};
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
if (!this._config || !this.hass) {
@@ -330,17 +342,7 @@ export class HuiEntitiesCardEditor
const target = ev.target! as EditorTarget;
const configValue =
target.configValue || this._subElementEditorConfig?.type;
const value =
target.checked !== undefined
? target.checked
: target.value || ev.detail.config || ev.detail.value;
if (
(configValue === "title" && target.value === this._title) ||
(configValue === "theme" && target.value === this._theme)
) {
return;
}
const value = ev.detail.config ?? ev.detail.value;
if (configValue === "row" || (ev.detail && ev.detail.entities)) {
const newConfigEntities =
@@ -359,7 +361,7 @@ export class HuiEntitiesCardEditor
this._config = { ...this._config!, entities: newConfigEntities };
this._configEntities = processEditorEntities(this._config!.entities);
} else if (configValue) {
if (value === "") {
if (value === "" || value === undefined) {
this._config = { ...this._config };
delete this._config[configValue!];
} else {
@@ -435,10 +437,6 @@ export class HuiEntitiesCardEditor
hui-header-footer-editor {
padding-top: var(--ha-space-1);
}
ha-input {
--ha-input-padding-bottom: var(--ha-space-4);
}
`,
];
}
@@ -74,11 +74,7 @@ export abstract class HuiEntityModesCardFeatureEditorBase<
}
private _schema = memoizeOne(
(
localize: LocalizeFunc,
stateObj: HassEntity | undefined,
customizeModes: boolean
) => {
(localize: LocalizeFunc, stateObj: HassEntity | undefined) => {
const d = this.descriptor;
const styleListId = d.styleListI18nFeatureId ?? d.i18nFeatureId;
return [
@@ -103,25 +99,20 @@ export abstract class HuiEntityModesCardFeatureEditorBase<
boolean: {},
},
},
...(customizeModes
? ([
{
name: d.modeField,
selector: {
select: {
reorder: true,
multiple: true,
options: d
.getAvailableModesOrdered(stateObj)
.map((mode) => ({
value: mode,
label: d.getModeLabel(this.hass!, stateObj, mode),
})),
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
{
name: d.modeField,
visible: { field: "customize_modes", value: true },
selector: {
select: {
reorder: true,
multiple: true,
options: d.getAvailableModesOrdered(stateObj).map((mode) => ({
value: mode,
label: d.getModeLabel(this.hass!, stateObj, mode),
})),
},
},
},
] as const satisfies readonly HaFormSchema[];
}
);
@@ -143,11 +134,7 @@ export abstract class HuiEntityModesCardFeatureEditorBase<
customize_modes: this._config[modeField as keyof TConfig] !== undefined,
};
const schema = this._schema(
this.hass.localize,
stateObj,
data.customize_modes
);
const schema = this._schema(this.hass.localize, stateObj);
return html`
<ha-form
@@ -80,7 +80,7 @@ export class HuiGaugeCardEditor
}
private _schema = memoizeOne(
(showSeverity: boolean, entityId?: string) =>
(entityId?: string) =>
[
{
name: "entity",
@@ -132,28 +132,25 @@ export class HuiGaugeCardEditor
{ name: "show_severity", selector: { boolean: {} } },
],
},
...(showSeverity
? ([
{
name: "severity",
type: "grid",
schema: [
{
name: "green",
selector: { number: { mode: "box", step: "any" } },
},
{
name: "yellow",
selector: { number: { mode: "box", step: "any" } },
},
{
name: "red",
selector: { number: { mode: "box", step: "any" } },
},
],
},
] as const)
: []),
{
name: "severity",
type: "grid",
visible: { field: "show_severity", value: true },
schema: [
{
name: "green",
selector: { number: { mode: "box", step: "any" } },
},
{
name: "yellow",
selector: { number: { mode: "box", step: "any" } },
},
{
name: "red",
selector: { number: { mode: "box", step: "any" } },
},
],
},
{
name: "interactions",
type: "expandable",
@@ -197,10 +194,7 @@ export class HuiGaugeCardEditor
return nothing;
}
const schema = this._schema(
this._config!.severity !== undefined,
this._config!.entity
);
const schema = this._schema(this._config!.entity);
const data = {
show_severity: this._config!.severity !== undefined,
...this._config,
@@ -59,13 +59,28 @@ export class HuiGenericEntityRowEditor
context: { entity: "entity" },
},
{
name: "icon",
selector: {
icon: {},
},
context: {
icon_entity: "entity",
},
name: "",
type: "grid",
schema: [
{
name: "icon",
selector: {
icon: {},
},
context: {
icon_entity: "entity",
},
},
{
name: "color",
selector: {
ui_color: {
include_state: true,
include_none: true,
},
},
},
],
},
{
name: "secondary_info",
@@ -44,7 +44,7 @@ const cardConfigStruct = assign(
show_name: optional(boolean()),
show_state: optional(boolean()),
show_icon: optional(boolean()),
state_color: optional(boolean()),
color: optional(string()),
entities: array(entitiesConfigStruct),
})
);
@@ -69,7 +69,16 @@ const SCHEMA = [
{ name: "show_state", selector: { boolean: {} } },
],
},
{ name: "state_color", selector: { boolean: {} } },
{
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
include_none: true,
},
},
},
] as const;
@customElement("hui-glance-card-editor")
@@ -115,6 +124,15 @@ export class HuiGlanceCardEditor
icon_entity: "entity",
},
},
{
name: "color",
selector: {
ui_color: {
include_state: true,
include_none: true,
},
},
},
{ name: "show_last_changed", selector: { boolean: {} } },
{ name: "show_state", selector: { boolean: {} }, default: true },
],
@@ -102,11 +102,11 @@ const SCHEMA = [
{
name: "fit_y_data",
required: false,
hidden: {
condition: "and",
visible: {
condition: "or",
conditions: [
{ field: "min_y_axis", operator: "not_exists" },
{ field: "max_y_axis", operator: "not_exists" },
{ field: "min_y_axis", operator: "exists" },
{ field: "max_y_axis", operator: "exists" },
],
},
selector: { boolean: {} },
@@ -34,20 +34,18 @@ export class HuiLawnMowerCommandsCardFeatureEditor
this._config = config;
}
private _schema = memoizeOne(
(stateObj: HassEntity | undefined, customize: boolean) =>
customizableListSchema({
field: "commands",
customize,
options: LAWN_MOWER_COMMANDS.filter(
(command) => stateObj && supportsLawnMowerCommand(stateObj, command)
).map((command) => ({
value: command,
label: this.hass!.localize(
`ui.panel.lovelace.editor.features.types.lawn-mower-commands.commands_list.${command}`
),
})),
})
private _schema = memoizeOne((stateObj: HassEntity | undefined) =>
customizableListSchema({
field: "commands",
options: LAWN_MOWER_COMMANDS.filter(
(command) => stateObj && supportsLawnMowerCommand(stateObj, command)
).map((command) => ({
value: command,
label: this.hass!.localize(
`ui.panel.lovelace.editor.features.types.lawn-mower-commands.commands_list.${command}`
),
})),
})
);
protected render() {
@@ -60,7 +58,7 @@ export class HuiLawnMowerCommandsCardFeatureEditor
: undefined;
const data = customizableListData(this._config, "commands");
const schema = this._schema(stateObj, data.customize);
const schema = this._schema(stateObj);
return html`
<ha-form
@@ -51,7 +51,7 @@ export class HuiMarkdownCardEditor
}
private _schema = memoizeOne(
(localize: LocalizeFunc, text_only: boolean) =>
(localize: LocalizeFunc) =>
[
{
name: "style",
@@ -73,9 +73,11 @@ export class HuiMarkdownCardEditor
},
},
},
...(!text_only
? ([{ name: "title", selector: { text: {} } }] as const)
: []),
{
name: "title",
visible: { field: "style", operator: "not_eq", value: "text-only" },
selector: { text: {} },
},
{
name: "content",
required: true,
@@ -131,10 +133,7 @@ export class HuiMarkdownCardEditor
style: this._config.text_only ? "text-only" : "card",
};
const schema = this._schema(
this.hass.localize,
this._config.text_only || false
);
const schema = this._schema(this.hass.localize);
return html`
<ha-form
@@ -38,11 +38,10 @@ export class HuiMediaPlayerPlaybackCardFeatureEditor
}
private _schema = memoizeOne(
(stateObj: MediaPlayerEntity | undefined, customize: boolean) =>
(stateObj: MediaPlayerEntity | undefined) =>
[
...customizableListSchema({
field: "controls",
customize,
options: MEDIA_PLAYER_PLAYBACK_CONTROLS.filter(
(control) =>
stateObj && supportsMediaPlayerPlaybackControl(stateObj, control)
@@ -69,7 +68,7 @@ export class HuiMediaPlayerPlaybackCardFeatureEditor
: undefined;
const data = customizableListData(this._config, "controls");
const schema = this._schema(stateObj, data.customize);
const schema = this._schema(stateObj);
return html`
<ha-form
@@ -32,21 +32,19 @@ export class HuiMediaPlayerSoundModeCardFeatureEditor
this._config = config;
}
private _schema = memoizeOne(
(stateObj: MediaPlayerEntity | undefined, customize: boolean) =>
customizableListSchema({
field: "sound_modes",
customize,
options:
stateObj?.attributes.sound_mode_list?.map((mode) => ({
value: mode,
label: this.hass!.formatEntityAttributeValue(
stateObj,
"sound_mode",
mode
),
})) ?? [],
})
private _schema = memoizeOne((stateObj: MediaPlayerEntity | undefined) =>
customizableListSchema({
field: "sound_modes",
options:
stateObj?.attributes.sound_mode_list?.map((mode) => ({
value: mode,
label: this.hass!.formatEntityAttributeValue(
stateObj,
"sound_mode",
mode
),
})) ?? [],
})
);
protected render() {
@@ -60,7 +58,7 @@ export class HuiMediaPlayerSoundModeCardFeatureEditor
: undefined;
const data = customizableListData(this._config, "sound_modes");
const schema = this._schema(stateObj, data.customize);
const schema = this._schema(stateObj);
return html`
<ha-form
@@ -32,21 +32,19 @@ export class HuiMediaPlayerSourceCardFeatureEditor
this._config = config;
}
private _schema = memoizeOne(
(stateObj: MediaPlayerEntity | undefined, customize: boolean) =>
customizableListSchema({
field: "sources",
customize,
options:
stateObj?.attributes.source_list?.map((source) => ({
value: source,
label: this.hass!.formatEntityAttributeValue(
stateObj,
"source",
source
),
})) ?? [],
})
private _schema = memoizeOne((stateObj: MediaPlayerEntity | undefined) =>
customizableListSchema({
field: "sources",
options:
stateObj?.attributes.source_list?.map((source) => ({
value: source,
label: this.hass!.formatEntityAttributeValue(
stateObj,
"source",
source
),
})) ?? [],
})
);
protected render() {
@@ -60,7 +58,7 @@ export class HuiMediaPlayerSourceCardFeatureEditor
: undefined;
const data = customizableListData(this._config, "sources");
const schema = this._schema(stateObj, data.customize);
const schema = this._schema(stateObj);
return html`
<ha-form
@@ -38,15 +38,10 @@ export class HuiPrecipitationForecastCardFeatureEditor
}
private _schema = memoizeOne(
(
stateObj: HassEntity | undefined,
forecastType: ForecastResolution,
localize: HomeAssistant["localize"]
) => {
(stateObj: HassEntity | undefined, localize: HomeAssistant["localize"]) => {
const supportedTypes = stateObj
? getSupportedForecastTypes(stateObj)
: [];
const isHourly = forecastType === "hourly";
return [
{
name: "forecast_type",
@@ -67,17 +62,22 @@ export class HuiPrecipitationForecastCardFeatureEditor
},
},
},
isHourly
? {
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
}
: {
name: "days_to_show",
default: DEFAULT_DAYS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
},
{
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
visible: { field: "forecast_type", value: "hourly" },
selector: { number: { min: 1, mode: "box" } },
},
{
name: "days_to_show",
default: DEFAULT_DAYS_TO_SHOW,
visible: {
field: "forecast_type",
operator: "not_eq",
value: "hourly",
},
selector: { number: { min: 1, mode: "box" } },
},
{
name: "precipitation_type",
required: true,
@@ -141,7 +141,7 @@ export class HuiPrecipitationForecastCardFeatureEditor
: { days_to_show: this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW }),
};
const schema = this._schema(stateObj, resolvedType, this.hass.localize);
const schema = this._schema(stateObj, this.hass.localize);
return html`
<ha-form
@@ -38,8 +38,7 @@ export class HuiSelectOptionsCardFeatureEditor
private _schema = memoizeOne(
(
formatEntityState: FormatEntityStateFunc,
stateObj: HassEntity | undefined,
customizeOptions: boolean
stateObj: HassEntity | undefined
) =>
[
{
@@ -48,24 +47,21 @@ export class HuiSelectOptionsCardFeatureEditor
boolean: {},
},
},
...(customizeOptions
? ([
{
name: "options",
selector: {
select: {
multiple: true,
reorder: true,
options:
stateObj?.attributes.options?.map((option) => ({
value: option,
label: formatEntityState(stateObj, option),
})) || [],
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
{
name: "options",
visible: { field: "customize_options", value: true },
selector: {
select: {
multiple: true,
reorder: true,
options:
stateObj?.attributes.options?.map((option) => ({
value: option,
label: formatEntityState(stateObj, option),
})) || [],
},
},
},
] as const satisfies readonly HaFormSchema[]
);
@@ -83,11 +79,7 @@ export class HuiSelectOptionsCardFeatureEditor
customize_options: this._config.options !== undefined,
};
const schema = this._schema(
this.hass.formatEntityState,
stateObj,
data.customize_options
);
const schema = this._schema(this.hass.formatEntityState, stateObj);
return html`
<ha-form
@@ -157,8 +157,6 @@ export class HuiStatisticsGraphCardEditor
localize: LocalizeFunc,
statisticIds: string[] | undefined,
metaDatas: StatisticsMetaData[] | undefined,
showFitOption: boolean,
hiddenLegend: boolean,
enableDateSelect: boolean
) => {
const units = new Set<string>();
@@ -291,15 +289,18 @@ export class HuiStatisticsGraphCardEditor
required: false,
selector: { number: { mode: "box", step: "any" } },
},
...(showFitOption
? [
{
name: "fit_y_data",
required: false,
selector: { boolean: {} },
},
]
: []),
{
name: "fit_y_data",
required: false,
visible: {
condition: "or",
conditions: [
{ field: "min_y_axis", operator: "exists" },
{ field: "max_y_axis", operator: "exists" },
],
},
selector: { boolean: {} },
},
{
name: "logarithmic_scale",
required: false,
@@ -310,15 +311,16 @@ export class HuiStatisticsGraphCardEditor
required: false,
selector: { boolean: {} },
},
...(!hiddenLegend
? [
{
name: "expand_legend",
required: false,
selector: { boolean: {} },
},
]
: []),
{
name: "expand_legend",
required: false,
visible: {
field: "hide_legend",
operator: "not_eq",
value: true,
},
selector: { boolean: {} },
},
],
},
],
@@ -397,9 +399,6 @@ export class HuiStatisticsGraphCardEditor
this.hass.localize,
this._configEntities,
this._metaDatas,
this._config!.min_y_axis !== undefined ||
this._config!.max_y_axis !== undefined,
!!this._config!.hide_legend,
!!this._config!.energy_date_selection
);
const configured_stat_types = this._config!.stat_types
@@ -38,15 +38,10 @@ export class HuiTemperatureForecastCardFeatureEditor
}
private _schema = memoizeOne(
(
stateObj: HassEntity | undefined,
forecastType: ForecastResolution,
localize: HomeAssistant["localize"]
) => {
(stateObj: HassEntity | undefined, localize: HomeAssistant["localize"]) => {
const supportedTypes = stateObj
? getSupportedForecastTypes(stateObj)
: [];
const isHourly = forecastType === "hourly";
return [
{
name: "forecast_type",
@@ -67,17 +62,22 @@ export class HuiTemperatureForecastCardFeatureEditor
},
},
},
isHourly
? {
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
}
: {
name: "days_to_show",
default: DEFAULT_DAYS_TO_SHOW,
selector: { number: { min: 1, mode: "box" } },
},
{
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
visible: { field: "forecast_type", value: "hourly" },
selector: { number: { min: 1, mode: "box" } },
},
{
name: "days_to_show",
default: DEFAULT_DAYS_TO_SHOW,
visible: {
field: "forecast_type",
operator: "not_eq",
value: "hourly",
},
selector: { number: { min: 1, mode: "box" } },
},
{
name: "color",
selector: {
@@ -117,7 +117,7 @@ export class HuiTemperatureForecastCardFeatureEditor
: { days_to_show: this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW }),
};
const schema = this._schema(stateObj, resolvedType, this.hass.localize);
const schema = this._schema(stateObj, this.hass.localize);
return html`
<ha-form
@@ -145,7 +145,7 @@ export class HuiTileCardEditor
},
{
name: "state_content",
hidden: { field: "hide_state", value: true },
visible: { field: "hide_state", operator: "not_eq", value: true },
selector: {
ui_state_content: {
allow_context: true,
@@ -157,7 +157,7 @@ export class HuiTileCardEditor
},
{
name: "time_format",
hidden: !showTimeFormat,
visible: showTimeFormat,
selector: {
ui_time_format: {},
},
@@ -37,20 +37,18 @@ export class HuiVacuumCommandsCardFeatureEditor
this._config = config;
}
private _schema = memoizeOne(
(stateObj: HassEntity | undefined, customize: boolean) =>
customizableListSchema({
field: "commands",
customize,
options: VACUUM_COMMANDS.filter(
(command) => stateObj && supportsVacuumCommand(stateObj, command)
).map((command) => ({
value: command,
label: this.hass!.localize(
`ui.panel.lovelace.editor.features.types.vacuum-commands.commands_list.${command}`
),
})),
})
private _schema = memoizeOne((stateObj: HassEntity | undefined) =>
customizableListSchema({
field: "commands",
options: VACUUM_COMMANDS.filter(
(command) => stateObj && supportsVacuumCommand(stateObj, command)
).map((command) => ({
value: command,
label: this.hass!.localize(
`ui.panel.lovelace.editor.features.types.vacuum-commands.commands_list.${command}`
),
})),
})
);
protected render() {
@@ -63,7 +61,7 @@ export class HuiVacuumCommandsCardFeatureEditor
: undefined;
const data = customizableListData(this._config, "commands");
const schema = this._schema(stateObj, data.customize);
const schema = this._schema(stateObj);
return html`
<ha-form
@@ -56,7 +56,7 @@ export class HuiDialogEditSection extends LitElement {
type: "expandable",
flatten: true,
expanded: true,
hidden: { field: "background_enabled", value: false },
visible: { field: "background_enabled", value: true },
iconPath: mdiPalette,
schema: [
{
@@ -14,7 +14,7 @@ export const entitiesConfigStruct = union([
image: optional(string()),
secondary_info: optional(string()),
time_format: optional(timeFormatConfigStruct),
state_color: optional(boolean()),
color: optional(string()),
tap_action: optional(actionConfigStruct),
hold_action: optional(actionConfigStruct),
double_tap_action: optional(actionConfigStruct),
@@ -93,9 +93,8 @@ export class HuiViewEditor extends LitElement {
type: "expandable",
flatten: true,
expanded: true,
hidden: {
visible: {
field: "type",
operator: "not_eq",
value: SECTIONS_VIEW_LAYOUT,
},
schema: [
@@ -1,12 +1,9 @@
import { customElement } from "lit/decorators";
import type { EntityCardConfig } from "../cards/types";
import type { EntitiesCardEntityConfig } from "../cards/types";
import { applyDefaultColor } from "../common/entity-color-config";
import { HuiConditionalBase } from "../components/hui-conditional-base";
import { createRowElement } from "../create-element/create-row-element";
import type {
ConditionalRowConfig,
EntityConfig,
LovelaceRow,
} from "../entity-rows/types";
import type { ConditionalRowConfig, LovelaceRow } from "../entity-rows/types";
import { fireEvent } from "../../../common/dom/fire_event";
declare global {
@@ -23,13 +20,10 @@ class HuiConditionalRow extends HuiConditionalBase implements LovelaceRow {
throw new Error("No row configured");
}
const { color } = config as EntitiesCardEntityConfig;
this._element = createRowElement(
(config as EntityCardConfig).state_color
? ({
state_color: true,
...(config.row as EntityConfig),
} as EntityConfig)
: config.row
applyDefaultColor(config.row as EntitiesCardEntityConfig, color)
) as LovelaceRow;
}
+1
View File
@@ -1495,6 +1495,7 @@
"revert_error": "Failed to revert the configuration: {error}"
},
"quick-bar": {
"ask_assist": "Ask Assist: {query}",
"commands_title": "Commands",
"navigate_title": "Navigate",
"commands": {
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import type { AssistPipeline } from "../../src/data/assist_pipeline";
import {
assistPipelineChanged,
initialPromptToSubmit,
} from "../../src/components/ha-assist-chat";
describe("initialPromptToSubmit", () => {
it("returns a trimmed prompt when submission is requested", () => {
expect(initialPromptToSubmit(" Turn on the lights ", true)).toBe(
"Turn on the lights"
);
});
it("does not return a prompt when submission is not requested", () => {
expect(initialPromptToSubmit("Turn on the lights", false)).toBeUndefined();
});
it("does not return an empty prompt", () => {
expect(initialPromptToSubmit(" ", true)).toBeUndefined();
});
});
describe("ha-assist-chat pipeline updates", () => {
it("preserves the conversation when the same pipeline is reloaded", () => {
const pipeline = { id: "pipeline-id" } as AssistPipeline;
expect(assistPipelineChanged(pipeline, { ...pipeline })).toBe(false);
});
it("resets the conversation when the pipeline changes", () => {
expect(
assistPipelineChanged(
{ id: "first" } as AssistPipeline,
{ id: "second" } as AssistPipeline
)
).toBe(true);
});
});
@@ -0,0 +1,169 @@
import { describe, expect, it } from "vitest";
import {
getHiddenFields,
isFieldVisible,
} from "../../../src/components/ha-form/conditions";
import type { HaFormSchema } from "../../../src/components/ha-form/types";
const field = (visible: HaFormSchema["visible"]): HaFormSchema =>
({ name: "field", selector: { text: {} }, visible }) as HaFormSchema;
describe("isFieldVisible", () => {
it("shows a field without a visible condition", () => {
expect(isFieldVisible(field(undefined), { a: 1 })).toBe(true);
});
it("honors a boolean visible", () => {
expect(isFieldVisible(field(true), {})).toBe(true);
expect(isFieldVisible(field(false), {})).toBe(false);
});
describe("operators", () => {
it("eq (default) matches equal values", () => {
expect(isFieldVisible(field({ field: "a", value: 1 }), { a: 1 })).toBe(
true
);
expect(isFieldVisible(field({ field: "a", value: 1 }), { a: 2 })).toBe(
false
);
});
it("not_eq matches different values", () => {
const schema = field({ field: "a", operator: "not_eq", value: 1 });
expect(isFieldVisible(schema, { a: 2 })).toBe(true);
expect(isFieldVisible(schema, { a: 1 })).toBe(false);
});
it("in matches membership", () => {
const schema = field({ field: "a", operator: "in", value: ["x", "y"] });
expect(isFieldVisible(schema, { a: "y" })).toBe(true);
expect(isFieldVisible(schema, { a: "z" })).toBe(false);
});
it("not_in matches non-membership", () => {
const schema = field({
field: "a",
operator: "not_in",
value: ["x", "y"],
});
expect(isFieldVisible(schema, { a: "z" })).toBe(true);
expect(isFieldVisible(schema, { a: "x" })).toBe(false);
});
it("exists matches a defined non-empty value", () => {
const schema = field({ field: "a", operator: "exists" });
expect(isFieldVisible(schema, { a: "x" })).toBe(true);
expect(isFieldVisible(schema, { a: "" })).toBe(false);
expect(isFieldVisible(schema, {})).toBe(false);
});
it("not_exists matches a missing or empty value", () => {
const schema = field({ field: "a", operator: "not_exists" });
expect(isFieldVisible(schema, {})).toBe(true);
expect(isFieldVisible(schema, { a: null } as any)).toBe(true);
expect(isFieldVisible(schema, { a: "x" })).toBe(false);
});
});
describe("combinators", () => {
it("and requires every condition", () => {
const schema = field({
condition: "and",
conditions: [
{ field: "a", value: 1 },
{ field: "b", value: 2 },
],
});
expect(isFieldVisible(schema, { a: 1, b: 2 })).toBe(true);
expect(isFieldVisible(schema, { a: 1, b: 9 })).toBe(false);
});
it("or requires any condition", () => {
const schema = field({
condition: "or",
conditions: [
{ field: "a", value: 1 },
{ field: "b", value: 2 },
],
});
expect(isFieldVisible(schema, { a: 9, b: 2 })).toBe(true);
expect(isFieldVisible(schema, { a: 9, b: 9 })).toBe(false);
});
it("not negates its conditions", () => {
const schema = field({
condition: "not",
conditions: [{ field: "a", value: 1 }],
});
expect(isFieldVisible(schema, { a: 2 })).toBe(true);
expect(isFieldVisible(schema, { a: 1 })).toBe(false);
});
it("nests combinators", () => {
const schema = field({
condition: "and",
conditions: [
{ field: "a", value: 1 },
{
condition: "or",
conditions: [
{ field: "b", value: 2 },
{ field: "c", value: 3 },
],
},
],
});
expect(isFieldVisible(schema, { a: 1, b: 9, c: 3 })).toBe(true);
expect(isFieldVisible(schema, { a: 1, b: 9, c: 9 })).toBe(false);
expect(isFieldVisible(schema, { a: 9, b: 2, c: 3 })).toBe(false);
});
});
it("treats an array of conditions as AND", () => {
const schema = field([
{ field: "a", value: 1 },
{ field: "b", value: 2 },
]);
expect(isFieldVisible(schema, { a: 1, b: 2 })).toBe(true);
expect(isFieldVisible(schema, { a: 1, b: 9 })).toBe(false);
});
it("handles missing data", () => {
expect(isFieldVisible(field({ field: "a", value: 1 }), undefined)).toBe(
false
);
});
});
describe("getHiddenFields", () => {
const named = (
name: string,
visible?: HaFormSchema["visible"]
): HaFormSchema =>
({ name, selector: { text: {} }, visible }) as HaFormSchema;
it("collects the fields that are not visible", () => {
const schema = [
named("mode"),
named("token", { field: "mode", value: "advanced" }),
];
expect([...getHiddenFields(schema, { mode: "simple" })]).toEqual(["token"]);
expect([...getHiddenFields(schema, { mode: "advanced" })]).toEqual([]);
});
it("treats a hidden field as absent to the other conditions", () => {
// "token" is hidden, so its value must not satisfy the condition on "extra"
const schema = [
named("mode"),
named("token", { field: "mode", value: "advanced" }),
named("extra", { field: "token", operator: "exists" }),
];
expect([
...getHiddenFields(schema, { mode: "simple", token: "stale" }),
]).toEqual(["token", "extra"]);
expect([
...getHiddenFields(schema, { mode: "advanced", token: "kept" }),
]).toEqual([]);
});
});
@@ -1,134 +0,0 @@
import { describe, expect, it } from "vitest";
import { isFieldHidden } from "../../../src/components/ha-form/conditions";
import type { HaFormSchema } from "../../../src/components/ha-form/types";
const field = (hidden: HaFormSchema["hidden"]): HaFormSchema =>
({ name: "field", selector: { text: {} }, hidden }) as HaFormSchema;
describe("isFieldHidden", () => {
it("shows a field without a hidden condition", () => {
expect(isFieldHidden(field(undefined), { a: 1 })).toBe(false);
});
it("honors a boolean hidden", () => {
expect(isFieldHidden(field(true), {})).toBe(true);
expect(isFieldHidden(field(false), {})).toBe(false);
});
describe("operators", () => {
it("eq (default) matches equal values", () => {
expect(isFieldHidden(field({ field: "a", value: 1 }), { a: 1 })).toBe(
true
);
expect(isFieldHidden(field({ field: "a", value: 1 }), { a: 2 })).toBe(
false
);
});
it("not_eq matches different values", () => {
const schema = field({ field: "a", operator: "not_eq", value: 1 });
expect(isFieldHidden(schema, { a: 2 })).toBe(true);
expect(isFieldHidden(schema, { a: 1 })).toBe(false);
});
it("in matches membership", () => {
const schema = field({ field: "a", operator: "in", value: ["x", "y"] });
expect(isFieldHidden(schema, { a: "y" })).toBe(true);
expect(isFieldHidden(schema, { a: "z" })).toBe(false);
});
it("not_in matches non-membership", () => {
const schema = field({
field: "a",
operator: "not_in",
value: ["x", "y"],
});
expect(isFieldHidden(schema, { a: "z" })).toBe(true);
expect(isFieldHidden(schema, { a: "x" })).toBe(false);
});
it("exists matches a defined non-empty value", () => {
const schema = field({ field: "a", operator: "exists" });
expect(isFieldHidden(schema, { a: "x" })).toBe(true);
expect(isFieldHidden(schema, { a: "" })).toBe(false);
expect(isFieldHidden(schema, {})).toBe(false);
});
it("not_exists matches a missing or empty value", () => {
const schema = field({ field: "a", operator: "not_exists" });
expect(isFieldHidden(schema, {})).toBe(true);
expect(isFieldHidden(schema, { a: null } as any)).toBe(true);
expect(isFieldHidden(schema, { a: "x" })).toBe(false);
});
});
describe("combinators", () => {
it("and requires every condition", () => {
const schema = field({
condition: "and",
conditions: [
{ field: "a", value: 1 },
{ field: "b", value: 2 },
],
});
expect(isFieldHidden(schema, { a: 1, b: 2 })).toBe(true);
expect(isFieldHidden(schema, { a: 1, b: 9 })).toBe(false);
});
it("or requires any condition", () => {
const schema = field({
condition: "or",
conditions: [
{ field: "a", value: 1 },
{ field: "b", value: 2 },
],
});
expect(isFieldHidden(schema, { a: 9, b: 2 })).toBe(true);
expect(isFieldHidden(schema, { a: 9, b: 9 })).toBe(false);
});
it("not negates its conditions", () => {
const schema = field({
condition: "not",
conditions: [{ field: "a", value: 1 }],
});
expect(isFieldHidden(schema, { a: 2 })).toBe(true);
expect(isFieldHidden(schema, { a: 1 })).toBe(false);
});
it("nests combinators", () => {
const schema = field({
condition: "and",
conditions: [
{ field: "a", value: 1 },
{
condition: "or",
conditions: [
{ field: "b", value: 2 },
{ field: "c", value: 3 },
],
},
],
});
expect(isFieldHidden(schema, { a: 1, b: 9, c: 3 })).toBe(true);
expect(isFieldHidden(schema, { a: 1, b: 9, c: 9 })).toBe(false);
expect(isFieldHidden(schema, { a: 9, b: 2, c: 3 })).toBe(false);
});
});
it("treats an array of conditions as AND", () => {
const schema = field([
{ field: "a", value: 1 },
{ field: "b", value: 2 },
]);
expect(isFieldHidden(schema, { a: 1, b: 2 })).toBe(true);
expect(isFieldHidden(schema, { a: 1, b: 9 })).toBe(false);
});
it("handles missing data", () => {
expect(isFieldHidden(field({ field: "a", value: 1 }), undefined)).toBe(
false
);
});
});
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { showVoiceCommandDialog } from "../../../src/dialogs/voice-command-dialog/show-ha-voice-command-dialog";
import type { HomeAssistant } from "../../../src/types";
describe("showVoiceCommandDialog", () => {
let element: HTMLElement;
beforeEach(() => {
element = document.createElement("div");
});
it("passes an initial prompt to the web dialog", () => {
const hass = {
auth: { external: undefined },
} as HomeAssistant;
const listener = vi.fn();
element.addEventListener("show-dialog", listener);
showVoiceCommandDialog(element, hass, {
pipeline_id: "last_used",
prompt: "Turn on the lights",
submit: true,
});
expect(listener).toHaveBeenCalledOnce();
expect(listener.mock.calls[0][0].detail.dialogParams).toEqual({
pipeline_id: "last_used",
start_listening: false,
prompt: "Turn on the lights",
submit: true,
});
});
it("keeps the native Assist payload compatible", () => {
const fireMessage = vi.fn();
const hass = {
auth: {
external: {
config: { hasAssist: true },
fireMessage,
},
},
} as unknown as HomeAssistant;
showVoiceCommandDialog(element, hass, {
pipeline_id: "last_used",
prompt: "Turn on the lights",
submit: true,
});
expect(fireMessage).toHaveBeenCalledWith({
type: "assist/show",
payload: {
pipeline_id: "last_used",
start_listening: true,
},
});
});
});
+39
View File
@@ -120,6 +120,45 @@ test.describe("App shell", () => {
});
});
test.describe("Quick search", () => {
test("starts an Assist conversation with the search query", async ({
page,
}) => {
await goToPanel(page, "/?scenario=quick-search-assist#/lovelace");
await page.keyboard.press("Control+K");
const quickBar = page.locator("ha-quick-bar");
await expect(quickBar).toBeAttached({ timeout: QUICK_TIMEOUT });
await quickBar
.locator("ha-input-search >> input")
.fill("Turn on the lights");
const assistItem = quickBar
.locator(".combo-box-row")
.filter({ hasText: "Ask Assist: Turn on the lights" });
await expect(assistItem).toBeVisible({ timeout: QUICK_TIMEOUT });
await assistItem.click();
const voiceCommandDialog = page.locator("ha-voice-command-dialog");
await expect(voiceCommandDialog).toBeAttached({ timeout: QUICK_TIMEOUT });
await expect(voiceCommandDialog.locator("ha-assist-chat")).toBeAttached({
timeout: QUICK_TIMEOUT,
});
await expect
.poll(() => page.evaluate(() => window.__assistRun))
.toMatchObject({
type: "assist_pipeline/run",
start_stage: "intent",
input: { text: "Turn on the lights" },
end_stage: "intent",
pipeline: "test-pipeline",
conversation_id: null,
});
});
});
defineRouteSmokeTests(appRouteSmokeGroups);
// ---------------------------------------------------------------------------
+1
View File
@@ -90,6 +90,7 @@ const MEDIA_BROWSER_ROOT = {
declare global {
interface Window {
__assistRun?: unknown;
__mockHass: MockHomeAssistant;
}
}
+36
View File
@@ -1,4 +1,5 @@
import type { ExtEntityRegistryEntry } from "../../../../../src/data/entity/entity_registry";
import type { AssistPipeline } from "../../../../../src/data/assist_pipeline";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
export type Scenario = (hass: MockHomeAssistant) => Promise<void> | void;
@@ -89,6 +90,40 @@ const lightMoreInfoScenario: Scenario = async (hass) => {
hass.mockWS("config/entity_registry/get", () => registryEntry);
};
const quickSearchAssistScenario: Scenario = async (hass) => {
const pipeline: AssistPipeline = {
id: "test-pipeline",
name: "Test Assist",
language: "en",
conversation_engine: "conversation.home_assistant",
conversation_language: "en",
stt_engine: null,
stt_language: null,
tts_engine: null,
tts_language: null,
tts_voice: null,
wake_word_entity: null,
wake_word_id: null,
};
hass.updateHass({
config: {
...hass.config,
components: [...hass.config.components, "conversation"],
},
enableShortcuts: true,
});
hass.mockWS("assist_pipeline/pipeline/list", () => ({
pipelines: [pipeline],
preferred_pipeline: pipeline.id,
}));
hass.mockWS("assist_pipeline/pipeline/get", () => pipeline);
hass.mockWS("assist_pipeline/run", (message) => {
window.__assistRun = message;
return () => undefined;
});
};
// ── Registry ──────────────────────────────────────────────────────────────
export const scenarios: Record<string, Scenario> = {
@@ -97,4 +132,5 @@ export const scenarios: Record<string, Scenario> = {
"dark-theme": darkThemeScenario,
"custom-theme": customThemeScenario,
"light-more-info": lightMoreInfoScenario,
"quick-search-assist": quickSearchAssistScenario,
};
+70 -6
View File
@@ -8,11 +8,15 @@ import {
trackPageErrors,
} from "./helpers";
import {
DEMO_THEME_STORAGE_KEY,
activateDemoSidebarPanel,
demoCardSelector,
expectDemoDarkMode,
expectStoredDemoTheme,
loadDemo,
moreInfoCardSelector,
openDemoSidebar,
waitForDemoReady,
reloadDemo,
} from "./demo/helpers";
test.describe("Home Assistant Demo", () => {
@@ -20,17 +24,16 @@ test.describe("Home Assistant Demo", () => {
test.beforeEach(async ({ page }) => {
pageErrors = trackPageErrors(page);
await page.goto("/");
});
test("page loads and ha-demo mounts without JS errors", async ({ page }) => {
await waitForDemoReady(page);
await loadDemo(page);
expectNoPageErrors(pageErrors);
});
test("dashboard renders Lovelace cards", async ({ page }) => {
await waitForDemoReady(page);
await loadDemo(page);
await expect(page.locator(demoCardSelector).first()).toBeVisible({
timeout: PANEL_TIMEOUT,
@@ -38,7 +41,7 @@ test.describe("Home Assistant Demo", () => {
});
test("sidebar navigation changes the active panel", async ({ page }) => {
await waitForDemoReady(page);
await loadDemo(page);
await openDemoSidebar(page);
await activateDemoSidebarPanel(page, "map");
@@ -48,7 +51,7 @@ test.describe("Home Assistant Demo", () => {
test("clicking an entity card opens the more-info dialog", async ({
page,
}) => {
await waitForDemoReady(page);
await loadDemo(page);
// Tile cards are the most common card type in the demo; fall back to other
// clickable card types in case this platform renders a different layout.
@@ -65,4 +68,65 @@ test.describe("Home Assistant Demo", () => {
expectNoPageErrors(pageErrors);
});
for (const colorScheme of ["light", "dark"] as const) {
test(`unset theme remains light with ${colorScheme} system color scheme`, async ({
page,
}) => {
await page.emulateMedia({ colorScheme });
await page.addInitScript((storageKey) => {
localStorage.removeItem(storageKey);
localStorage.removeItem("selectedTheme");
}, DEMO_THEME_STORAGE_KEY);
await loadDemo(page);
await expectDemoDarkMode(page, false);
expectNoPageErrors(pageErrors);
});
}
test("theme selection persists without offering migration", async ({
page,
}) => {
await page.addInitScript((storageKey) => {
if (sessionStorage.getItem("theme_test_seeded")) {
return;
}
sessionStorage.setItem("theme_test_seeded", "true");
localStorage.removeItem(storageKey);
localStorage.setItem(
"selectedTheme",
JSON.stringify({ theme: "default", dark: false })
);
}, DEMO_THEME_STORAGE_KEY);
await loadDemo(page, "/#/profile/general");
const themeRow = page.locator("ha-pick-theme-row");
await expect(themeRow).toBeVisible({ timeout: PANEL_TIMEOUT });
await expect(themeRow.locator(":scope > ha-settings-row")).toHaveCount(0);
await themeRow.locator('ha-radio-option[value="dark"]').click();
await expectStoredDemoTheme(page, { theme: "default", dark: true });
await expect
.poll(() => page.evaluate(() => localStorage.getItem("selectedTheme")), {
timeout: SHELL_TIMEOUT,
})
.toBeNull();
await expectDemoDarkMode(page, true);
await reloadDemo(page);
await expectStoredDemoTheme(page, { theme: "default", dark: true });
await expectDemoDarkMode(page, true);
await loadDemo(page, "/#/profile/general");
await expect(themeRow).toBeVisible({ timeout: PANEL_TIMEOUT });
await expect(
themeRow.locator('ha-radio-option[value="dark"]')
).toHaveAttribute("aria-checked", "true");
expectNoPageErrors(pageErrors);
});
});
+44
View File
@@ -1,6 +1,9 @@
import { expect, type Page } from "@playwright/test";
import type { ThemeSettings } from "../../../src/types";
import { NAVIGATION_TIMEOUT, SHELL_TIMEOUT } from "../helpers";
export const DEMO_THEME_STORAGE_KEY = "demo_theme";
export const demoCardSelector = [
"hui-tile-card",
"hui-entity-card",
@@ -21,6 +24,47 @@ export async function waitForDemoReady(page: Page) {
});
}
export async function loadDemo(page: Page, path = "/") {
await page.goto(path);
await waitForDemoReady(page);
}
export async function reloadDemo(page: Page) {
await page.reload();
await waitForDemoReady(page);
}
export async function expectDemoDarkMode(page: Page, darkMode: boolean) {
await expect
.poll(
() =>
page.locator("ha-demo").evaluate((element) => {
const demo = element as HTMLElement & {
hass?: { themes?: { darkMode?: boolean } };
};
return demo.hass?.themes?.darkMode;
}),
{ timeout: SHELL_TIMEOUT }
)
.toBe(darkMode);
}
export async function expectStoredDemoTheme(
page: Page,
expected: ThemeSettings
) {
await expect
.poll(
() =>
page.evaluate((storageKey) => {
const storedTheme = localStorage.getItem(storageKey);
return storedTheme ? (JSON.parse(storedTheme) as unknown) : null;
}, DEMO_THEME_STORAGE_KEY),
{ timeout: SHELL_TIMEOUT }
)
.toEqual(expected);
}
export async function openDemoSidebar(page: Page) {
const menuButton = page.locator("ha-menu-button");
if (await menuButton.isVisible()) {
@@ -83,6 +83,87 @@ describe("migrateEntitiesCardConfig", () => {
time_format: "datetime",
});
});
it("migrates card and entity level state_color to color", () => {
expect(
migrateEntitiesCardConfig({
type: "entities",
state_color: true,
entities: [
"light.bed_light",
{ entity: "switch.ac", state_color: false },
{
entity: "sensor.humidity",
type: "simple-entity",
state_color: true,
},
],
} as unknown as EntitiesCardConfig)
).toEqual({
type: "entities",
color: "state",
entities: [
"light.bed_light",
{ entity: "switch.ac", color: "none" },
{ entity: "sensor.humidity", type: "simple-entity", color: "state" },
],
});
});
it("keeps an explicit color over the legacy state_color", () => {
expect(
migrateEntitiesCardConfig({
type: "entities",
entities: [{ entity: "switch.ac", state_color: true, color: "red" }],
} as unknown as EntitiesCardConfig)
).toEqual({
type: "entities",
entities: [{ entity: "switch.ac", color: "red" }],
});
});
it("keeps state_color on custom rows untouched", () => {
const customRow = {
entity: "sensor.power",
type: "custom:multiple-entity-row",
state_color: true,
};
const config = {
type: "entities",
entities: [customRow],
} as unknown as EntitiesCardConfig;
const result = migrateEntitiesCardConfig(config);
expect(result).toBe(config);
expect(result.entities[0]).toEqual(customRow);
});
it("migrates conditional rows and their inner rows", () => {
expect(
migrateEntitiesCardConfig({
type: "entities",
entities: [
{
type: "conditional",
state_color: false,
conditions: [],
row: { entity: "light.bed_light", state_color: true },
},
],
} as unknown as EntitiesCardConfig)
).toEqual({
type: "entities",
entities: [
{
type: "conditional",
color: "none",
conditions: [],
row: { entity: "light.bed_light", color: "state" },
},
],
});
});
});
describe("migrateGlanceCardConfig", () => {
@@ -124,4 +205,21 @@ describe("migrateGlanceCardConfig", () => {
time_format: "datetime",
});
});
it("migrates card and entity level state_color to color", () => {
expect(
migrateGlanceCardConfig({
type: "glance",
state_color: false,
entities: [
"light.bed_light",
{ entity: "switch.ac", state_color: true },
],
} as unknown as GlanceCardConfig)
).toEqual({
type: "glance",
color: "none",
entities: ["light.bed_light", { entity: "switch.ac", color: "state" }],
});
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { applyDefaultColor } from "../../../../src/panels/lovelace/common/entity-color-config";
describe("applyDefaultColor", () => {
interface TestEntityConfig {
entity: string;
type?: string;
color?: string;
}
it("applies the default color when none is set", () => {
const config: TestEntityConfig = { entity: "light.a" };
expect(applyDefaultColor(config, "state")).toEqual({
entity: "light.a",
color: "state",
});
});
it("keeps an explicit color", () => {
const config: TestEntityConfig = { entity: "light.a", color: "red" };
expect(applyDefaultColor(config, "state")).toBe(config);
});
it("returns the same object without a default color", () => {
const config: TestEntityConfig = { entity: "light.a" };
expect(applyDefaultColor(config, undefined)).toBe(config);
});
it("keeps custom elements untouched", () => {
const config: TestEntityConfig = { entity: "light.a", type: "custom:foo" };
expect(applyDefaultColor(config, "state")).toBe(config);
});
});