mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-23 11:37:30 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| df9cfeab58 | |||
| a31713a5ea | |||
| d5005ba951 | |||
| fa29f86b9d | |||
| 8775d3f4c5 | |||
| 2771a565b9 | |||
| d3505cece0 | |||
| 8579f77c12 | |||
| 6249e037ed | |||
| 8beb4ee36e | |||
| 7844938ecd | |||
| f188f5de07 | |||
| ad0ad20cbe | |||
| 25ed411198 | |||
| 988cd5ac82 | |||
| ca074a1c03 | |||
| b8be36c5bb |
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)})`;
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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! });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)[],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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`
|
||||
|
||||
+4
-4
@@ -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);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
+16
-29
@@ -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: {} },
|
||||
|
||||
+13
-15
@@ -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
|
||||
|
||||
+2
-3
@@ -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
|
||||
|
||||
+14
-16
@@ -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
|
||||
|
||||
+14
-16
@@ -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
|
||||
|
||||
+18
-18
@@ -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
|
||||
|
||||
+17
-25
@@ -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
|
||||
|
||||
+18
-18
@@ -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: {},
|
||||
},
|
||||
|
||||
+13
-15
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -90,6 +90,7 @@ const MEDIA_BROWSER_ROOT = {
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__assistRun?: unknown;
|
||||
__mockHass: MockHomeAssistant;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user