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 { mdiClose } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { mdiClose, mdiDrag } from "@mdi/js";
import { LitElement, PropertyValues, css, html, nothing } from "lit";
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 { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
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 "../ha-checkbox";
import "../ha-chip";
@ -38,6 +42,68 @@ export class HaSelectSelector extends LitElement {
@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 = "";
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) {
return html`
<div>
@ -124,23 +194,39 @@ export class HaSelectSelector extends LitElement {
return html`
${value?.length
? html`<ha-chip-set>
${value.map(
(item, idx) => html`
<ha-chip hasTrailingIcon>
${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>`
: ""}
? html`
<ha-chip-set>
${repeat(
value,
(item) => item,
(item, idx) => html`
<ha-chip
hasTrailingIcon
.hasIcon=${this.selector.select?.reorder}
>
${this.selector.select?.reorder
? html`
<ha-svg-icon
slot="icon"
.path=${mdiDrag}
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
item-value-path="value"
@ -331,19 +417,22 @@ export class HaSelectSelector extends LitElement {
this.comboBox.filteredItems = filteredItems;
}
static styles = css`
:host {
position: relative;
}
ha-select,
mwc-formfield,
ha-formfield {
display: block;
}
mwc-list-item[disabled] {
--mdc-theme-text-primary-on-background: var(--disabled-text-color);
}
`;
static styles = [
sortableStyles,
css`
:host {
position: relative;
}
ha-select,
mwc-formfield,
ha-formfield {
display: block;
}
mwc-list-item[disabled] {
--mdc-theme-text-primary-on-background: var(--disabled-text-color);
}
`,
];
}
declare global {

View File

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

View File

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

View File

@ -21,6 +21,7 @@ import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../../common/array/ensure-array";
import { computeCssColor } from "../../../common/color/compute-color";
import { hsv2rgb, rgb2hex, rgb2hsv } from "../../../common/color/convert-color";
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-info";
import { cameraUrlWithWidthHeight } from "../../../data/camera";
import {
CoverEntity,
computeCoverPositionStateDisplay,
} from "../../../data/cover";
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 { SENSOR_DEVICE_CLASS_TIMESTAMP } from "../../../data/sensor";
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 active = stateActive(stateObj);
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>
`;
if (domain === "light" && active) {
return this._renderStateContent(stateObj, ["brightness"]);
}
if (domain === "light" && stateActive(stateObj)) {
const brightness = (stateObj as LightEntity).attributes.brightness;
if (brightness) {
return this.hass!.formatEntityAttributeValue(stateObj, "brightness");
}
if (domain === "fan" && active) {
return this._renderStateContent(stateObj, ["percentage"]);
}
if (domain === "fan") {
const speedStateDisplay = computeFanSpeedStateDisplay(
stateObj as FanEntity,
this.hass!
);
if (speedStateDisplay) {
return speedStateDisplay;
}
if (domain === "cover" && active) {
return this._renderStateContent(stateObj, ["state", "current_position"]);
}
const stateDisplay = this.hass!.formatEntityState(stateObj);
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 === "humidifier") {
return this._renderStateContent(stateObj, ["state", "current_humidity"]);
}
if (domain === "climate") {
const current_temperature = (stateObj as ClimateEntity).attributes
.current_temperature;
if (current_temperature) {
const formattedCurrentTemperature =
this.hass!.formatEntityAttributeValue(
stateObj,
"current_temperature",
current_temperature
);
return `${stateDisplay}${formattedCurrentTemperature}`;
}
return this._renderStateContent(stateObj, [
"state",
"current_temperature",
]);
}
return stateDisplay;
return this._renderStateContent(stateObj, "state");
}
@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 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 color = this._computeStateColor(stateObj, this._config.color);

View File

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

View File

@ -1,6 +1,6 @@
import { mdiGestureTap, mdiPalette } from "@mdi/js";
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 memoizeOne from "memoize-one";
import {
@ -12,11 +12,17 @@ import {
object,
optional,
string,
union,
} 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 "../../../../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 { TileCardConfig } from "../../cards/types";
import {
@ -31,12 +37,65 @@ import { EditSubElementEvent, SubElementEditorConfig } from "../types";
import { configElementStyle } from "./config-elements-style";
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(
baseLovelaceCardConfig,
object({
entity: optional(string()),
name: optional(string()),
icon: optional(string()),
hide_state: optional(boolean()),
state_content: optional(union([string(), array(string())])),
color: optional(string()),
show_entity_picture: optional(boolean()),
vertical: optional(boolean()),
@ -63,7 +122,12 @@ export class HuiTileCardEditor
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
(
localize: LocalizeFunc,
formatEntityAttributeName: formatEntityAttributeNameFunc,
stateObj: HassEntity | undefined,
hideState: boolean
) =>
[
{ name: "entity", selector: { entity: {} } },
{
@ -102,9 +166,49 @@ export class HuiTileCardEditor
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: "",
@ -124,9 +228,9 @@ export class HuiTileCardEditor
ui_action: {},
},
},
] as const,
],
},
] as const
] as const satisfies readonly HaFormSchema[]
);
private _context = memoizeOne(
@ -142,7 +246,12 @@ export class HuiTileCardEditor
| HassEntity
| 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) {
return html`
@ -157,10 +266,15 @@ export class HuiTileCardEditor
`;
}
const data = {
...this._config,
state_content: ensureArray(this._config.state_content),
};
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
@ -181,10 +295,25 @@ export class HuiTileCardEditor
return;
}
const newConfig = ev.detail.value as TileCardConfig;
const config: TileCardConfig = {
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 });
}
@ -252,6 +381,8 @@ export class HuiTileCardEditor
case "icon_tap_action":
case "show_entity_picture":
case "vertical":
case "hide_state":
case "state_content":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.tile.${schema.name}`
);

View File

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