Add context providers and transform decorator (#15902)

This commit is contained in:
Bram Kragten 2023-03-23 18:31:01 +01:00 committed by GitHub
parent 4ba7e5cf0f
commit 74cfccaac7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 417 additions and 46 deletions

View File

@ -45,6 +45,7 @@
"@fullcalendar/list": "6.1.5",
"@fullcalendar/timegrid": "6.1.5",
"@lezer/highlight": "1.1.3",
"@lit-labs/context": "0.2.0",
"@lit-labs/motion": "1.0.3",
"@lit-labs/virtualizer": "1.0.1",
"@material/chips": "=14.0.0-canary.53b3cad2f.0",

View File

@ -0,0 +1,111 @@
import { PropertyDeclaration, PropertyValues, ReactiveElement } from "lit";
import { ClassElement } from "../../types";
import { shallowEqual } from "../util/shallow-equal";
/**
* Transform function type.
*/
export interface Transformer<T = any, V = any> {
(value: V): T;
}
type ReactiveTransformElement = ReactiveElement & {
_transformers: Map<PropertyKey, Transformer>;
_watching: Map<PropertyKey, Set<PropertyKey>>;
};
type ReactiveElementClassWithTransformers = typeof ReactiveElement & {
prototype: ReactiveTransformElement;
};
/**
* Specifies an tranformer callback that is run when the value of the decorated property, or any of the properties in the watching array, changes.
* The result of the tranformer is assigned to the decorated property.
* The tranformer receives the current as arguments.
*/
export const transform =
<T, V>(config: {
transformer: Transformer<T, V>;
watch?: PropertyKey[];
propertyOptions?: PropertyDeclaration;
}): any =>
(clsElement: ClassElement) => {
const key = String(clsElement.key);
return {
...clsElement,
kind: "method",
descriptor: {
set(this: ReactiveTransformElement, value: V) {
const oldValue = this[`__transform_${key}`];
const trnsformr: Transformer<T, V> | undefined =
this._transformers.get(key);
if (trnsformr) {
this[`__transform_${key}`] = trnsformr.call(this, value);
} else {
this[`__transform_${key}`] = value;
}
this[`__original_${key}`] = value;
this.requestUpdate(key, oldValue);
},
get(): T {
return this[`__transform_${key}`];
},
enumerable: true,
configurable: true,
},
finisher(cls: ReactiveElementClassWithTransformers) {
// if we haven't wrapped `willUpdate` in this class, do so
if (!cls.prototype._transformers) {
cls.prototype._transformers = new Map<PropertyKey, Transformer>();
cls.prototype._watching = new Map<PropertyKey, Set<PropertyKey>>();
// @ts-ignore
const userWillUpdate = cls.prototype.willUpdate;
// @ts-ignore
cls.prototype.willUpdate = function (
this: ReactiveTransformElement,
changedProperties: PropertyValues
) {
userWillUpdate.call(this, changedProperties);
const keys = new Set<PropertyKey>();
changedProperties.forEach((_v, k) => {
const watchers = this._watching;
const ks: Set<PropertyKey> | undefined = watchers.get(k);
if (ks !== undefined) {
ks.forEach((wk) => keys.add(wk));
}
});
keys.forEach((k) => {
// trigger setter
this[k] = this[`__original_${String(k)}`];
});
};
// clone any existing observers (superclasses)
// eslint-disable-next-line no-prototype-builtins
} else if (!cls.prototype.hasOwnProperty("_transformers")) {
const tranformers = cls.prototype._transformers;
cls.prototype._transformers = new Map();
tranformers.forEach((v: any, k: PropertyKey) =>
cls.prototype._transformers.set(k, v)
);
}
// set this method
cls.prototype._transformers.set(clsElement.key, config.transformer);
if (config.watch) {
// store watchers
config.watch.forEach((k) => {
let curWatch = cls.prototype._watching.get(k);
if (!curWatch) {
curWatch = new Set();
cls.prototype._watching.set(k, curWatch);
}
curWatch.add(clsElement.key);
});
}
cls.createProperty(clsElement.key, {
noAccessor: true,
hasChanged: (v: any, o: any) => !shallowEqual(v, o),
...config.propertyOptions,
});
},
};
};

View File

@ -0,0 +1,108 @@
/**
* Compares two values for shallow equality, only 1 level deep.
*/
export const shallowEqual = (a: any, b: any): boolean => {
if (a === b) {
return true;
}
if (a && b && typeof a === "object" && typeof b === "object") {
if (a.constructor !== b.constructor) {
return false;
}
let i: number | [any, any];
let length: number;
if (Array.isArray(a)) {
length = a.length;
if (length !== b.length) {
return false;
}
for (i = length; i-- !== 0; ) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
if (a instanceof Map && b instanceof Map) {
if (a.size !== b.size) {
return false;
}
for (i of a.entries()) {
if (!b.has(i[0])) {
return false;
}
}
for (i of a.entries()) {
if (i[1] !== b.get(i[0])) {
return false;
}
}
return true;
}
if (a instanceof Set && b instanceof Set) {
if (a.size !== b.size) {
return false;
}
for (i of a.entries()) {
if (!b.has(i[0])) {
return false;
}
}
return true;
}
if (ArrayBuffer.isView(a) && ArrayBuffer.isView(b)) {
// @ts-ignore
length = a.length;
// @ts-ignore
if (length !== b.length) {
return false;
}
for (i = length; i-- !== 0; ) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
if (a.constructor === RegExp) {
return a.source === b.source && a.flags === b.flags;
}
if (a.valueOf !== Object.prototype.valueOf) {
return a.valueOf() === b.valueOf();
}
if (a.toString !== Object.prototype.toString) {
return a.toString() === b.toString();
}
const keys = Object.keys(a);
length = keys.length;
if (length !== Object.keys(b).length) {
return false;
}
for (i = length; i-- !== 0; ) {
if (!Object.prototype.hasOwnProperty.call(b, keys[i])) {
return false;
}
}
for (i = length; i-- !== 0; ) {
const key = keys[i];
if (a[key] !== b[key]) {
return false;
}
}
return true;
}
// true if both NaN, false otherwise
// eslint-disable-next-line no-self-compare
return a !== a && b !== b;
};

24
src/data/context.ts Normal file
View File

@ -0,0 +1,24 @@
import { createContext } from "@lit-labs/context";
import { HomeAssistant } from "../types";
import { EntityRegistryEntry } from "./entity_registry";
export const statesContext = createContext<HomeAssistant["states"]>("states");
export const entitiesContext =
createContext<HomeAssistant["entities"]>("entities");
export const devicesContext =
createContext<HomeAssistant["devices"]>("devices");
export const areasContext = createContext<HomeAssistant["areas"]>("areas");
export const localizeContext =
createContext<HomeAssistant["localize"]>("localize");
export const localeContext = createContext<HomeAssistant["locale"]>("locale");
export const configContext = createContext<HomeAssistant["config"]>("config");
export const themesContext = createContext<HomeAssistant["themes"]>("themes");
export const selectedThemeContext =
createContext<HomeAssistant["selectedTheme"]>("selectedTheme");
export const userContext = createContext<HomeAssistant["user"]>("user");
export const userDataContext =
createContext<HomeAssistant["userData"]>("userData");
export const panelsContext = createContext<HomeAssistant["panels"]>("panels");
export const extendedEntitiesContext =
createContext<EntityRegistryEntry[]>("extendedEntities");

View File

@ -181,6 +181,10 @@ export class HomeAssistantMain extends LitElement {
this.drawer.close();
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
// Make app-drawer adjust to a potential LTR/RTL change

View File

@ -1,25 +1,21 @@
import { consume } from "@lit-labs/context";
import "@material/mwc-ripple";
import type { Ripple } from "@material/mwc-ripple";
import { RippleHandlers } from "@material/mwc-ripple/ripple-handlers";
import { HassEntity } from "home-assistant-js-websocket";
import { HassEntities, HassEntity } from "home-assistant-js-websocket";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
css,
html,
nothing,
} from "lit";
import {
customElement,
eventOptions,
property,
queryAsync,
state,
} from "lit/decorators";
import { customElement, eventOptions, queryAsync, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { DOMAINS_TOGGLE } from "../../../common/const";
import { transform } from "../../../common/decorators/transform";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateDisplay } from "../../../common/entity/compute_state_display";
@ -30,6 +26,13 @@ import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import { iconColorCSS } from "../../../common/style/icon_color_css";
import "../../../components/ha-card";
import { HVAC_ACTION_TO_MODE } from "../../../data/climate";
import {
entitiesContext,
localeContext,
localizeContext,
statesContext,
themesContext,
} from "../../../data/context";
import { LightEntity } from "../../../data/light";
import { ActionHandlerEvent } from "../../../data/lovelace";
import { HomeAssistant } from "../../../types";
@ -40,6 +43,9 @@ import { hasAction } from "../common/has-action";
import { createEntityNotFoundWarning } from "../components/hui-warning";
import { LovelaceCard, LovelaceCardEditor } from "../types";
import { ButtonCardConfig } from "./types";
import { LocalizeFunc } from "../../../common/translations/localize";
import { FrontendLocaleData } from "../../../data/translation";
import { Themes } from "../../../data/ws-themes";
@customElement("hui-button-card")
export class HuiButtonCard extends LitElement implements LovelaceCard {
@ -71,10 +77,39 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
};
}
@property({ attribute: false }) public hass?: HomeAssistant;
public hass!: HomeAssistant;
@state() private _config?: ButtonCardConfig;
@consume<any>({ context: statesContext, subscribe: true })
@transform({
transformer: function (this: HuiButtonCard, value: HassEntities) {
return this._config?.entity ? value[this._config?.entity] : undefined;
},
watch: ["_config"],
})
_stateObj?: HassEntity;
@consume({ context: themesContext, subscribe: true })
_themes!: Themes;
@consume({ context: localizeContext, subscribe: true })
_localize!: LocalizeFunc;
@consume({ context: localeContext, subscribe: true })
_locale!: FrontendLocaleData;
@consume({ context: entitiesContext, subscribe: true })
@transform<HomeAssistant["entities"], HomeAssistant["entities"]>({
transformer: function (this: HuiButtonCard, value) {
return this._config?.entity
? { [this._config?.entity]: value[this._config?.entity] }
: {};
},
watch: ["_config"],
})
_entities!: HomeAssistant["entities"];
@queryAsync("mwc-ripple") private _ripple!: Promise<Ripple | null>;
@state() private _shouldRenderRipple = false;
@ -114,35 +149,11 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
};
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
if (changedProps.has("_config")) {
return true;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (
!oldHass ||
oldHass.themes !== this.hass!.themes ||
oldHass.locale !== this.hass!.locale
) {
return true;
}
return (
Boolean(this._config!.entity) &&
oldHass.states[this._config!.entity!] !==
this.hass!.states[this._config!.entity!]
);
}
protected render() {
if (!this._config || !this.hass) {
if (!this._config) {
return nothing;
}
const stateObj = this._config.entity
? this.hass.states[this._config.entity]
: undefined;
const stateObj = this._stateObj;
if (this._config.entity && !stateObj) {
return html`
@ -207,10 +218,10 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
${this._config.show_state && stateObj
? html`<span class="state">
${computeStateDisplay(
this.hass.localize,
this._localize,
stateObj,
this.hass.locale,
this.hass.entities
this._locale,
this._entities
)}
</span>`
: ""}
@ -221,21 +232,23 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (!this._config || !this.hass) {
if (!this._config) {
return;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
const oldThemes = changedProps.get("_themes") as
| HomeAssistant["themes"]
| undefined;
const oldConfig = changedProps.get("_config") as
| ButtonCardConfig
| undefined;
if (
!oldHass ||
!oldThemes ||
!oldConfig ||
oldHass.themes !== this.hass.themes ||
oldThemes !== this._themes ||
oldConfig.theme !== this._config.theme
) {
applyThemesOnElement(this, this.hass.themes, this._config.theme);
applyThemesOnElement(this, this._themes, this._config.theme);
}
}

View File

@ -0,0 +1,97 @@
import { ContextProvider } from "@lit-labs/context";
import {
areasContext,
configContext,
devicesContext,
entitiesContext,
localeContext,
localizeContext,
panelsContext,
selectedThemeContext,
statesContext,
themesContext,
userContext,
userDataContext,
} from "../data/context";
import { Constructor, HomeAssistant } from "../types";
import { HassBaseEl } from "./hass-base-mixin";
export const contextMixin = <T extends Constructor<HassBaseEl>>(
superClass: T
) =>
class extends superClass {
private __contextProviders: Record<
string,
ContextProvider<any> | undefined
> = {
states: new ContextProvider(
this,
statesContext,
this.hass ? this.hass.states : this._pendingHass.states
),
entities: new ContextProvider(
this,
entitiesContext,
this.hass ? this.hass.entities : this._pendingHass.entities
),
devices: new ContextProvider(
this,
devicesContext,
this.hass ? this.hass.devices : this._pendingHass.devices
),
areas: new ContextProvider(
this,
areasContext,
this.hass ? this.hass.areas : this._pendingHass.areas
),
localize: new ContextProvider(
this,
localizeContext,
this.hass ? this.hass.localize : this._pendingHass.localize
),
locale: new ContextProvider(
this,
localeContext,
this.hass ? this.hass.locale : this._pendingHass.locale
),
config: new ContextProvider(
this,
configContext,
this.hass ? this.hass.config : this._pendingHass.config
),
themes: new ContextProvider(
this,
themesContext,
this.hass ? this.hass.themes : this._pendingHass.themes
),
selectedTheme: new ContextProvider(
this,
selectedThemeContext,
this.hass ? this.hass.selectedTheme : this._pendingHass.selectedTheme
),
user: new ContextProvider(
this,
userContext,
this.hass ? this.hass.user : this._pendingHass.user
),
userData: new ContextProvider(
this,
userDataContext,
this.hass ? this.hass.userData : this._pendingHass.userData
),
panels: new ContextProvider(
this,
panelsContext,
this.hass ? this.hass.panels : this._pendingHass.panels
),
};
protected _updateHass(obj: Partial<HomeAssistant>) {
super._updateHass(obj);
for (const [key, value] of Object.entries(obj)) {
if (key in this.__contextProviders) {
this.__contextProviders[key]!.setValue(value);
}
}
}
};

View File

@ -6,6 +6,7 @@ import DisconnectToastMixin from "./disconnect-toast-mixin";
import { hapticMixin } from "./haptic-mixin";
import { HassBaseEl } from "./hass-base-mixin";
import { loggingMixin } from "./logging-mixin";
import { contextMixin } from "./context-mixin";
import MoreInfoMixin from "./more-info-mixin";
import NotificationMixin from "./notification-mixin";
import { panelTitleMixin } from "./panel-title-mixin";
@ -31,4 +32,5 @@ export class HassElement extends ext(HassBaseEl, [
hapticMixin,
panelTitleMixin,
loggingMixin,
contextMixin,
]) {}

View File

@ -1955,6 +1955,16 @@ __metadata:
languageName: node
linkType: hard
"@lit-labs/context@npm:0.2.0":
version: 0.2.0
resolution: "@lit-labs/context@npm:0.2.0"
dependencies:
"@lit/reactive-element": ^1.5.0
lit: ^2.5.0
checksum: 0b3d803ba81683d9650ba384e5f138656ecd52d6a54448535e867e0a0ba0cb23e4526ec52e82ed657e9c3598a103c0e8b164bfe927222467e349fb070c770af3
languageName: node
linkType: hard
"@lit-labs/motion@npm:1.0.3":
version: 1.0.3
resolution: "@lit-labs/motion@npm:1.0.3"
@ -1982,7 +1992,7 @@ __metadata:
languageName: node
linkType: hard
"@lit/reactive-element@npm:^1.3.0, @lit/reactive-element@npm:^1.6.0":
"@lit/reactive-element@npm:^1.3.0, @lit/reactive-element@npm:^1.5.0, @lit/reactive-element@npm:^1.6.0":
version: 1.6.1
resolution: "@lit/reactive-element@npm:1.6.1"
dependencies:
@ -9416,6 +9426,7 @@ __metadata:
"@fullcalendar/timegrid": 6.1.5
"@koa/cors": 4.0.0
"@lezer/highlight": 1.1.3
"@lit-labs/context": 0.2.0
"@lit-labs/motion": 1.0.3
"@lit-labs/virtualizer": 1.0.1
"@material/chips": =14.0.0-canary.53b3cad2f.0