Add support for state content customization in tile card (#18180)

* Add support for state content customization

* Add reorder option

* Do not display null attributes

* Always return a value

* Add hide state option

* Add missing attribute unit

* Fix sortable create and destroy
This commit is contained in:
Paul Bottein 2023-10-24 12:08:11 +02:00 committed by GitHub
parent c9f5d16745
commit 6ffc0625d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 349 additions and 114 deletions

View File

@ -1,12 +1,16 @@
import "@material/mwc-list/mwc-list-item"; import "@material/mwc-list/mwc-list-item";
import { mdiClose } from "@mdi/js"; import { mdiClose, mdiDrag } from "@mdi/js";
import { css, html, LitElement } from "lit"; import { LitElement, PropertyValues, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators"; import { customElement, property, query } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { SortableEvent } from "sortablejs";
import { ensureArray } from "../../common/array/ensure-array"; import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation"; import { stopPropagation } from "../../common/dom/stop_propagation";
import { caseInsensitiveStringCompare } from "../../common/string/compare"; import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { SelectOption, SelectSelector } from "../../data/selector"; import type { SelectOption, SelectSelector } from "../../data/selector";
import { sortableStyles } from "../../resources/ha-sortable-style";
import { SortableInstance } from "../../resources/sortable";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../ha-checkbox"; import "../ha-checkbox";
import "../ha-chip"; import "../ha-chip";
@ -38,6 +42,68 @@ export class HaSelectSelector extends LitElement {
@query("ha-combo-box", true) private comboBox!: HaComboBox; @query("ha-combo-box", true) private comboBox!: HaComboBox;
private _sortable?: SortableInstance;
protected updated(changedProps: PropertyValues): void {
if (changedProps.has("value") || changedProps.has("selector")) {
const sortableNeeded =
this.selector.select?.multiple &&
this.selector.select.reorder &&
this.value?.length;
if (!this._sortable && sortableNeeded) {
this._createSortable();
} else if (this._sortable && !sortableNeeded) {
this._destroySortable();
}
}
}
private async _createSortable() {
const Sortable = (await import("../../resources/sortable")).default;
this._sortable = new Sortable(
this.shadowRoot!.querySelector("ha-chip-set")!,
{
animation: 150,
fallbackClass: "sortable-fallback",
draggable: "ha-chip",
onChoose: (evt: SortableEvent) => {
(evt.item as any).placeholder =
document.createComment("sort-placeholder");
evt.item.after((evt.item as any).placeholder);
},
onEnd: (evt: SortableEvent) => {
// put back in original location
if ((evt.item as any).placeholder) {
(evt.item as any).placeholder.replaceWith(evt.item);
delete (evt.item as any).placeholder;
}
this._dragged(evt);
},
}
);
}
private _dragged(ev: SortableEvent): void {
if (ev.oldIndex === ev.newIndex) return;
this._move(ev.oldIndex!, ev.newIndex!);
}
private _move(index: number, newIndex: number) {
const value = this.value as string[];
const newValue = value.concat();
const element = newValue.splice(index, 1)[0];
newValue.splice(newIndex, 0, element);
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
});
}
private _destroySortable() {
this._sortable?.destroy();
this._sortable = undefined;
}
private _filter = ""; private _filter = "";
protected render() { protected render() {
@ -71,7 +137,11 @@ export class HaSelectSelector extends LitElement {
); );
} }
if (!this.selector.select?.custom_value && this._mode === "list") { if (
!this.selector.select?.custom_value &&
!this.selector.select?.reorder &&
this._mode === "list"
) {
if (!this.selector.select?.multiple) { if (!this.selector.select?.multiple) {
return html` return html`
<div> <div>
@ -124,23 +194,39 @@ export class HaSelectSelector extends LitElement {
return html` return html`
${value?.length ${value?.length
? html`<ha-chip-set> ? html`
${value.map( <ha-chip-set>
(item, idx) => html` ${repeat(
<ha-chip hasTrailingIcon> value,
${options.find((option) => option.value === item)?.label || (item) => item,
item} (item, idx) => html`
<ha-svg-icon <ha-chip
slot="trailing-icon" hasTrailingIcon
.path=${mdiClose} .hasIcon=${this.selector.select?.reorder}
.idx=${idx} >
@click=${this._removeItem} ${this.selector.select?.reorder
></ha-svg-icon> ? html`
</ha-chip> <ha-svg-icon
` slot="icon"
)} .path=${mdiDrag}
</ha-chip-set>` data-handle
: ""} ></ha-svg-icon>
`
: nothing}
${options.find((option) => option.value === item)
?.label || item}
<ha-svg-icon
slot="trailing-icon"
.path=${mdiClose}
.idx=${idx}
@click=${this._removeItem}
></ha-svg-icon>
</ha-chip>
`
)}
</ha-chip-set>
`
: nothing}
<ha-combo-box <ha-combo-box
item-value-path="value" item-value-path="value"
@ -331,19 +417,22 @@ export class HaSelectSelector extends LitElement {
this.comboBox.filteredItems = filteredItems; this.comboBox.filteredItems = filteredItems;
} }
static styles = css` static styles = [
:host { sortableStyles,
position: relative; css`
} :host {
ha-select, position: relative;
mwc-formfield, }
ha-formfield { ha-select,
display: block; mwc-formfield,
} ha-formfield {
mwc-list-item[disabled] { display: block;
--mdc-theme-text-primary-on-background: var(--disabled-text-color); }
} mwc-list-item[disabled] {
`; --mdc-theme-text-primary-on-background: var(--disabled-text-color);
}
`,
];
} }
declare global { declare global {

View File

@ -28,6 +28,7 @@ export const TEMPERATURE_ATTRIBUTES = new Set([
"target_temperature", "target_temperature",
"target_temp_temp", "target_temp_temp",
"target_temp_high", "target_temp_high",
"target_temp_low",
"target_temp_step", "target_temp_step",
"min_temp", "min_temp",
"max_temp", "max_temp",
@ -70,4 +71,7 @@ export const DOMAIN_ATTRIBUTES_UNITS: Record<string, Record<string, string>> = {
vacuum: { vacuum: {
battery_level: "%", battery_level: "%",
}, },
sensor: {
battery_level: "%",
},
}; };

View File

@ -309,6 +309,7 @@ export interface SelectSelector {
options: readonly string[] | readonly SelectOption[]; options: readonly string[] | readonly SelectOption[];
translation_key?: string; translation_key?: string;
sort?: boolean; sort?: boolean;
reorder?: boolean;
} | null; } | null;
} }

View File

@ -21,6 +21,7 @@ import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined"; import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { ensureArray } from "../../../common/array/ensure-array";
import { computeCssColor } from "../../../common/color/compute-color"; import { computeCssColor } from "../../../common/color/compute-color";
import { hsv2rgb, rgb2hex, rgb2hsv } from "../../../common/color/convert-color"; import { hsv2rgb, rgb2hex, rgb2hsv } from "../../../common/color/convert-color";
import { DOMAINS_TOGGLE } from "../../../common/const"; import { DOMAINS_TOGGLE } from "../../../common/const";
@ -34,15 +35,7 @@ import "../../../components/tile/ha-tile-icon";
import "../../../components/tile/ha-tile-image"; import "../../../components/tile/ha-tile-image";
import "../../../components/tile/ha-tile-info"; import "../../../components/tile/ha-tile-info";
import { cameraUrlWithWidthHeight } from "../../../data/camera"; import { cameraUrlWithWidthHeight } from "../../../data/camera";
import {
CoverEntity,
computeCoverPositionStateDisplay,
} from "../../../data/cover";
import { isUnavailableState } from "../../../data/entity"; import { isUnavailableState } from "../../../data/entity";
import { FanEntity, computeFanSpeedStateDisplay } from "../../../data/fan";
import type { HumidifierEntity } from "../../../data/humidifier";
import type { ClimateEntity } from "../../../data/climate";
import type { LightEntity } from "../../../data/light";
import type { ActionHandlerEvent } from "../../../data/lovelace"; import type { ActionHandlerEvent } from "../../../data/lovelace";
import { SENSOR_DEVICE_CLASS_TIMESTAMP } from "../../../data/sensor"; import { SENSOR_DEVICE_CLASS_TIMESTAMP } from "../../../data/sensor";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
@ -181,80 +174,89 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
} }
); );
private _formatState(stateObj: HassEntity): TemplateResult | string { private _renderStateContent(
stateObj: HassEntity,
stateContent: string | string[]
) {
const contents = ensureArray(stateContent);
const values = contents
.map((content) => {
if (content === "state") {
const domain = computeDomain(stateObj.entity_id);
if (
(stateObj.attributes.device_class ===
SENSOR_DEVICE_CLASS_TIMESTAMP ||
TIMESTAMP_STATE_DOMAINS.includes(domain)) &&
!isUnavailableState(stateObj.state)
) {
return html`
<hui-timestamp-display
.hass=${this.hass}
.ts=${new Date(stateObj.state)}
format="relative"
capitalize
></hui-timestamp-display>
`;
}
return this.hass!.formatEntityState(stateObj);
}
if (content === "last-changed") {
return html`
<ha-relative-time
.hass=${this.hass}
.datetime=${stateObj.last_changed}
></ha-relative-time>
`;
}
if (stateObj.attributes[content] == null) {
return undefined;
}
return this.hass!.formatEntityAttributeValue(stateObj, content);
})
.filter(Boolean);
if (!values.length) {
return html`${this.hass!.formatEntityState(stateObj)}`;
}
return html`
${values.map(
(value, index, array) =>
html`${value}${index < array.length - 1 ? " ⸱ " : nothing}`
)}
`;
}
private _renderState(stateObj: HassEntity): TemplateResult | typeof nothing {
const domain = computeDomain(stateObj.entity_id); const domain = computeDomain(stateObj.entity_id);
const active = stateActive(stateObj);
if ( if (domain === "light" && active) {
(stateObj.attributes.device_class === SENSOR_DEVICE_CLASS_TIMESTAMP || return this._renderStateContent(stateObj, ["brightness"]);
TIMESTAMP_STATE_DOMAINS.includes(domain)) &&
!isUnavailableState(stateObj.state)
) {
return html`
<hui-timestamp-display
.hass=${this.hass}
.ts=${new Date(stateObj.state)}
format="relative"
capitalize
></hui-timestamp-display>
`;
} }
if (domain === "light" && stateActive(stateObj)) { if (domain === "fan" && active) {
const brightness = (stateObj as LightEntity).attributes.brightness; return this._renderStateContent(stateObj, ["percentage"]);
if (brightness) {
return this.hass!.formatEntityAttributeValue(stateObj, "brightness");
}
} }
if (domain === "fan") { if (domain === "cover" && active) {
const speedStateDisplay = computeFanSpeedStateDisplay( return this._renderStateContent(stateObj, ["state", "current_position"]);
stateObj as FanEntity,
this.hass!
);
if (speedStateDisplay) {
return speedStateDisplay;
}
} }
const stateDisplay = this.hass!.formatEntityState(stateObj); if (domain === "humidifier") {
return this._renderStateContent(stateObj, ["state", "current_humidity"]);
if (domain === "cover") {
const positionStateDisplay = computeCoverPositionStateDisplay(
stateObj as CoverEntity,
this.hass!
);
if (positionStateDisplay) {
return `${stateDisplay}${positionStateDisplay}`;
}
}
if (domain === "humidifier" && stateActive(stateObj)) {
const humidity = (stateObj as HumidifierEntity).attributes.humidity;
if (humidity) {
const formattedHumidity = this.hass!.formatEntityAttributeValue(
stateObj,
"humidity",
Math.round(humidity)
);
return `${stateDisplay}${formattedHumidity}`;
}
} }
if (domain === "climate") { if (domain === "climate") {
const current_temperature = (stateObj as ClimateEntity).attributes return this._renderStateContent(stateObj, [
.current_temperature; "state",
if (current_temperature) { "current_temperature",
const formattedCurrentTemperature = ]);
this.hass!.formatEntityAttributeValue(
stateObj,
"current_temperature",
current_temperature
);
return `${stateDisplay}${formattedCurrentTemperature}`;
}
} }
return stateDisplay; return this._renderStateContent(stateObj, "state");
} }
@queryAsync("mwc-ripple") private _ripple!: Promise<Ripple | null>; @queryAsync("mwc-ripple") private _ripple!: Promise<Ripple | null>;
@ -323,7 +325,11 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
const name = this._config.name || stateObj.attributes.friendly_name; const name = this._config.name || stateObj.attributes.friendly_name;
const localizedState = this._formatState(stateObj); const localizedState = this._config.hide_state
? nothing
: this._config.state_content
? this._renderStateContent(stateObj, this._config.state_content)
: this._renderState(stateObj);
const active = stateActive(stateObj); const active = stateActive(stateObj);
const color = this._computeStateColor(stateObj, this._config.color); const color = this._computeStateColor(stateObj, this._config.color);

View File

@ -520,6 +520,8 @@ export interface EnergyFlowCardConfig extends LovelaceCardConfig {
export interface TileCardConfig extends LovelaceCardConfig { export interface TileCardConfig extends LovelaceCardConfig {
entity: string; entity: string;
name?: string; name?: string;
hide_state?: boolean;
state_content?: string | string[];
icon?: string; icon?: string;
color?: string; color?: string;
show_entity_picture?: string; show_entity_picture?: string;

View File

@ -1,6 +1,6 @@
import { mdiGestureTap, mdiPalette } from "@mdi/js"; import { mdiGestureTap, mdiPalette } from "@mdi/js";
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit"; import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { import {
@ -12,11 +12,17 @@ import {
object, object,
optional, optional,
string, string,
union,
} from "superstruct"; } from "superstruct";
import { fireEvent, HASSDomEvent } from "../../../../common/dom/fire_event"; import { ensureArray } from "../../../../common/array/ensure-array";
import { HASSDomEvent, fireEvent } from "../../../../common/dom/fire_event";
import { formatEntityAttributeNameFunc } from "../../../../common/translations/entity-state";
import { LocalizeFunc } from "../../../../common/translations/localize"; import { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form"; import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types"; import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import type { TileCardConfig } from "../../cards/types"; import type { TileCardConfig } from "../../cards/types";
import { import {
@ -31,12 +37,65 @@ import { EditSubElementEvent, SubElementEditorConfig } from "../types";
import { configElementStyle } from "./config-elements-style"; import { configElementStyle } from "./config-elements-style";
import "./hui-tile-card-features-editor"; import "./hui-tile-card-features-editor";
const HIDDEN_ATTRIBUTES = [
"access_token",
"available_modes",
"code_arm_required",
"code_format",
"color_modes",
"device_class",
"editable",
"effect_list",
"entity_id",
"entity_picture",
"event_types",
"fan_modes",
"fan_speed_list",
"friendly_name",
"frontend_stream_type",
"has_date",
"has_time",
"hvac_modes",
"icon",
"id",
"max_color_temp_kelvin",
"max_mireds",
"max_temp",
"max",
"min_color_temp_kelvin",
"min_mireds",
"min_temp",
"min",
"mode",
"operation_list",
"options",
"percentage_step",
"precipitation_unit",
"preset_modes",
"pressure_unit",
"sound_mode_list",
"source_list",
"state_class",
"step",
"supported_color_modes",
"supported_features",
"swing_modes",
"target_temp_step",
"temperature_unit",
"token",
"unit_of_measurement",
"visibility_unit",
"wind_speed_unit",
];
const cardConfigStruct = assign( const cardConfigStruct = assign(
baseLovelaceCardConfig, baseLovelaceCardConfig,
object({ object({
entity: optional(string()), entity: optional(string()),
name: optional(string()), name: optional(string()),
icon: optional(string()), icon: optional(string()),
hide_state: optional(boolean()),
state_content: optional(union([string(), array(string())])),
color: optional(string()), color: optional(string()),
show_entity_picture: optional(boolean()), show_entity_picture: optional(boolean()),
vertical: optional(boolean()), vertical: optional(boolean()),
@ -63,7 +122,12 @@ export class HuiTileCardEditor
} }
private _schema = memoizeOne( private _schema = memoizeOne(
(localize: LocalizeFunc) => (
localize: LocalizeFunc,
formatEntityAttributeName: formatEntityAttributeNameFunc,
stateObj: HassEntity | undefined,
hideState: boolean
) =>
[ [
{ name: "entity", selector: { entity: {} } }, { name: "entity", selector: { entity: {} } },
{ {
@ -102,9 +166,49 @@ export class HuiTileCardEditor
boolean: {}, boolean: {},
}, },
}, },
] as const, {
name: "hide_state",
selector: {
boolean: {},
},
},
],
}, },
] as const, ...(!hideState
? ([
{
name: "state_content",
selector: {
select: {
mode: "dropdown",
reorder: true,
custom_value: true,
multiple: true,
options: [
{
label: "State",
value: "state",
},
{
label: "Last changed",
value: "last-changed",
},
...Object.keys(stateObj?.attributes ?? {})
.filter((a) => !HIDDEN_ATTRIBUTES.includes(a))
.map((attribute) => ({
value: attribute,
label: formatEntityAttributeName(
stateObj!,
attribute
),
})),
],
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
],
}, },
{ {
name: "", name: "",
@ -124,9 +228,9 @@ export class HuiTileCardEditor
ui_action: {}, ui_action: {},
}, },
}, },
] as const, ],
}, },
] as const ] as const satisfies readonly HaFormSchema[]
); );
private _context = memoizeOne( private _context = memoizeOne(
@ -142,7 +246,12 @@ export class HuiTileCardEditor
| HassEntity | HassEntity
| undefined; | undefined;
const schema = this._schema(this.hass!.localize); const schema = this._schema(
this.hass!.localize,
this.hass.formatEntityAttributeName,
stateObj,
this._config.hide_state ?? false
);
if (this._subElementEditorConfig) { if (this._subElementEditorConfig) {
return html` return html`
@ -157,10 +266,15 @@ export class HuiTileCardEditor
`; `;
} }
const data = {
...this._config,
state_content: ensureArray(this._config.state_content),
};
return html` return html`
<ha-form <ha-form
.hass=${this.hass} .hass=${this.hass}
.data=${this._config} .data=${data}
.schema=${schema} .schema=${schema}
.computeLabel=${this._computeLabelCallback} .computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
@ -181,10 +295,25 @@ export class HuiTileCardEditor
return; return;
} }
const newConfig = ev.detail.value as TileCardConfig;
const config: TileCardConfig = { const config: TileCardConfig = {
features: this._config.features, features: this._config.features,
...ev.detail.value, ...newConfig,
}; };
if (config.hide_state) {
delete config.state_content;
}
if (config.state_content) {
if (config.state_content.length === 0) {
delete config.state_content;
} else if (config.state_content.length === 1) {
config.state_content = config.state_content[0];
}
}
fireEvent(this, "config-changed", { config }); fireEvent(this, "config-changed", { config });
} }
@ -252,6 +381,8 @@ export class HuiTileCardEditor
case "icon_tap_action": case "icon_tap_action":
case "show_entity_picture": case "show_entity_picture":
case "vertical": case "vertical":
case "hide_state":
case "state_content":
return this.hass!.localize( return this.hass!.localize(
`ui.panel.lovelace.editor.card.tile.${schema.name}` `ui.panel.lovelace.editor.card.tile.${schema.name}`
); );

View File

@ -5063,6 +5063,8 @@
"default_color": "Default color (state)", "default_color": "Default color (state)",
"show_entity_picture": "Show entity picture", "show_entity_picture": "Show entity picture",
"vertical": "Vertical", "vertical": "Vertical",
"hide_state": "Hide state",
"state_content": "State content",
"features": { "features": {
"name": "Features", "name": "Features",
"not_compatible": "Not compatible", "not_compatible": "Not compatible",