diff --git a/package.json b/package.json index d704eb4b9b..111db689f4 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "lit-html": "^1.0.0", "marked": "^0.6.0", "mdn-polyfills": "^5.12.0", + "memoize-one": "^5.0.0", "moment": "^2.22.2", "preact": "^8.3.1", "preact-compat": "^3.18.4", @@ -105,6 +106,7 @@ "@gfx/zopfli": "^1.0.9", "@types/chai": "^4.1.7", "@types/codemirror": "^0.0.71", + "@types/memoize-one": "^4.1.0", "@types/mocha": "^5.2.5", "babel-eslint": "^10", "babel-loader": "^8.0.4", diff --git a/src/components/entity/ha-entities-picker.ts b/src/components/entity/ha-entities-picker.ts new file mode 100644 index 0000000000..5fe0720c95 --- /dev/null +++ b/src/components/entity/ha-entities-picker.ts @@ -0,0 +1,120 @@ +import { + LitElement, + TemplateResult, + property, + html, + customElement, +} from "lit-element"; +import "@polymer/paper-icon-button/paper-icon-button-light"; + +import { HomeAssistant } from "../../types"; +import { PolymerChangedEvent } from "../../polymer-types"; +import { fireEvent } from "../../common/dom/fire_event"; +import isValidEntityId from "../../common/entity/valid_entity_id"; + +import "./ha-entity-picker"; +// Not a duplicate, type import +// tslint:disable-next-line +import { HaEntityPickerEntityFilterFunc } from "./ha-entity-picker"; +import { HassEntity } from "home-assistant-js-websocket"; + +@customElement("ha-entities-picker") +class HaEntitiesPickerLight extends LitElement { + @property() public hass?: HomeAssistant; + @property() public value?: string[]; + @property() public domainFilter?: string; + @property() public pickedEntityLabel?: string; + @property() public pickEntityLabel?: string; + + protected render(): TemplateResult | void { + if (!this.hass) { + return; + } + const currentEntities = this._currentEntities; + return html` + ${currentEntities.map( + (entityId) => html` +
+ +
+ ` + )} +
+ +
+ `; + } + + private _entityFilter: HaEntityPickerEntityFilterFunc = ( + stateObj: HassEntity + ) => !this.value || !this.value.includes(stateObj.entity_id); + + private get _currentEntities() { + return this.value || []; + } + + private async _updateEntities(entities) { + fireEvent(this, "value-changed", { + value: entities, + }); + + this.value = entities; + } + + private _entityChanged(event: PolymerChangedEvent) { + event.stopPropagation(); + const curValue = (event.currentTarget as any).curValue; + const newValue = event.detail.value; + if ( + newValue === curValue || + (newValue !== "" && !isValidEntityId(newValue)) + ) { + return; + } + if (newValue === "") { + this._updateEntities( + this._currentEntities.filter((ent) => ent !== curValue) + ); + } else { + this._updateEntities( + this._currentEntities.map((ent) => (ent === curValue ? newValue : ent)) + ); + } + } + + private async _addEntity(event: PolymerChangedEvent) { + event.stopPropagation(); + const toAdd = event.detail.value; + (event.currentTarget as any).value = ""; + if (!toAdd) { + return; + } + const currentEntities = this._currentEntities; + if (currentEntities.includes(toAdd)) { + return; + } + + this._updateEntities([...currentEntities, toAdd]); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-entities-picker": HaEntitiesPickerLight; + } +} diff --git a/src/components/entity/ha-entity-picker.js b/src/components/entity/ha-entity-picker.js deleted file mode 100644 index bc4765d8d7..0000000000 --- a/src/components/entity/ha-entity-picker.js +++ /dev/null @@ -1,179 +0,0 @@ -import "@polymer/paper-icon-button/paper-icon-button"; -import "@polymer/paper-input/paper-input"; -import "@polymer/paper-item/paper-icon-item"; -import "@polymer/paper-item/paper-item-body"; -import { html } from "@polymer/polymer/lib/utils/html-tag"; -import { PolymerElement } from "@polymer/polymer/polymer-element"; -import "@vaadin/vaadin-combo-box/vaadin-combo-box-light"; - -import "./state-badge"; - -import computeStateName from "../../common/entity/compute_state_name"; -import LocalizeMixin from "../../mixins/localize-mixin"; -import EventsMixin from "../../mixins/events-mixin"; - -/* - * @appliesMixin LocalizeMixin - */ -class HaEntityPicker extends EventsMixin(LocalizeMixin(PolymerElement)) { - static get template() { - return html` - - - - Clear - - - - - `; - } - - static get properties() { - return { - allowCustomEntity: { - type: Boolean, - value: false, - }, - hass: { - type: Object, - observer: "_hassChanged", - }, - _hass: Object, - _states: { - type: Array, - computed: "_computeStates(_hass, domainFilter, entityFilter)", - }, - autofocus: Boolean, - label: { - type: String, - }, - value: { - type: String, - notify: true, - }, - opened: { - type: Boolean, - value: false, - observer: "_openedChanged", - }, - domainFilter: { - type: String, - value: null, - }, - entityFilter: { - type: Function, - value: null, - }, - disabled: Boolean, - }; - } - - _computeLabel(label, localize) { - return label === undefined - ? localize("ui.components.entity.entity-picker.entity") - : label; - } - - _computeStates(hass, domainFilter, entityFilter) { - if (!hass) return []; - - let entityIds = Object.keys(hass.states); - - if (domainFilter) { - entityIds = entityIds.filter( - (eid) => eid.substr(0, eid.indexOf(".")) === domainFilter - ); - } - - let entities = entityIds.sort().map((key) => hass.states[key]); - - if (entityFilter) { - entities = entities.filter(entityFilter); - } - - return entities; - } - - _computeStateName(state) { - return computeStateName(state); - } - - _openedChanged(newVal) { - if (!newVal) { - this._hass = this.hass; - } - } - - _hassChanged(newVal) { - if (!this.opened) { - this._hass = newVal; - } - } - - _computeToggleIcon(opened) { - return opened ? "hass:menu-up" : "hass:menu-down"; - } - - _fireChanged(ev) { - ev.stopPropagation(); - this.fire("change"); - } -} - -customElements.define("ha-entity-picker", HaEntityPicker); diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts new file mode 100644 index 0000000000..3b5bf7ed88 --- /dev/null +++ b/src/components/entity/ha-entity-picker.ts @@ -0,0 +1,200 @@ +import "@polymer/paper-icon-button/paper-icon-button"; +import "@polymer/paper-input/paper-input"; +import "@polymer/paper-item/paper-icon-item"; +import "@polymer/paper-item/paper-item-body"; +import "@vaadin/vaadin-combo-box/vaadin-combo-box-light"; +import memoizeOne from "memoize-one"; + +import "./state-badge"; + +import computeStateName from "../../common/entity/compute_state_name"; +import { + LitElement, + TemplateResult, + html, + css, + CSSResult, + property, + PropertyValues, +} from "lit-element"; +import { HomeAssistant } from "../../types"; +import { HassEntity } from "home-assistant-js-websocket"; +import { PolymerChangedEvent } from "../../polymer-types"; +import { fireEvent } from "../../common/dom/fire_event"; + +export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean; + +const rowRenderer = ( + root: HTMLElement, + _owner, + model: { item: HassEntity } +) => { + if (!root.firstElementChild) { + root.innerHTML = ` + + + + +
[[_computeStateName(item)]]
+
[[item.entity_id]]
+
+
+ `; + } + + root.querySelector("state-badge")!.stateObj = model.item; + root.querySelector(".name")!.textContent = computeStateName(model.item); + root.querySelector("[secondary]")!.textContent = model.item.entity_id; +}; + +class HaEntityPicker extends LitElement { + @property({ type: Boolean }) public autofocus?: boolean; + @property({ type: Boolean }) public disabled?: boolean; + @property({ type: Boolean }) public allowCustomEntity; + @property() public hass?: HomeAssistant; + @property() public label?: string; + @property() public value?: string; + @property() public domainFilter?: string; + @property() public entityFilter?: HaEntityPickerEntityFilterFunc; + @property({ type: Boolean }) private _opened?: boolean; + @property() private _hass?: HomeAssistant; + + private _getStates = memoizeOne( + ( + hass: this["hass"], + domainFilter: this["domainFilter"], + entityFilter: this["entityFilter"] + ) => { + let states: HassEntity[] = []; + + if (!hass) { + return []; + } + let entityIds = Object.keys(hass.states); + + if (domainFilter) { + entityIds = entityIds.filter( + (eid) => eid.substr(0, eid.indexOf(".")) === domainFilter + ); + } + + states = entityIds.sort().map((key) => hass!.states[key]); + + if (entityFilter) { + states = states.filter( + (stateObj) => + // We always want to include the entity of the current value + stateObj.entity_id === this.value || entityFilter!(stateObj) + ); + } + return states; + } + ); + + protected updated(changedProps: PropertyValues) { + super.updated(changedProps); + + if (changedProps.has("hass") && !this._opened) { + this._hass = this.hass; + } + } + + protected render(): TemplateResult | void { + const states = this._getStates( + this._hass, + this.domainFilter, + this.entityFilter + ); + + return html` + + + ${this.value + ? html` + + Clear + + ` + : ""} + ${states.length > 0 + ? html` + + Toggle + + ` + : ""} + + + `; + } + + private get _value() { + return this.value || ""; + } + + private _openedChanged(ev: PolymerChangedEvent) { + this._opened = ev.detail.value; + } + + private _valueChanged(ev: PolymerChangedEvent) { + const newValue = ev.detail.value; + if (newValue !== this._value) { + this.value = ev.detail.value; + setTimeout(() => { + fireEvent(this, "value-changed", { value: this.value }); + fireEvent(this, "change"); + }, 0); + } + } + + static get styles(): CSSResult { + return css` + paper-input > paper-icon-button { + width: 24px; + height: 24px; + padding: 2px; + color: var(--secondary-text-color); + } + [hidden] { + display: none; + } + `; + } +} + +customElements.define("ha-entity-picker", HaEntityPicker); diff --git a/src/panels/config/person/dialog-person-detail.ts b/src/panels/config/person/dialog-person-detail.ts index 2e863e7c80..4497f6204d 100644 --- a/src/panels/config/person/dialog-person-detail.ts +++ b/src/panels/config/person/dialog-person-detail.ts @@ -2,14 +2,15 @@ import { LitElement, html, css, - PropertyDeclarations, CSSResult, TemplateResult, + property, } from "lit-element"; import "@polymer/paper-dialog/paper-dialog"; import "@polymer/paper-dialog-scrollable/paper-dialog-scrollable"; import "@polymer/paper-input/paper-input"; +import "../../../components/entity/ha-entities-picker"; import { PersonDetailDialogParams } from "./show-dialog-person-detail"; import { PolymerChangedEvent } from "../../../polymer-types"; import { haStyleDialog } from "../../../resources/ha-style"; @@ -17,24 +18,20 @@ import { HomeAssistant } from "../../../types"; import { PersonMutableParams } from "../../../data/person"; class DialogPersonDetail extends LitElement { - public hass!: HomeAssistant; - private _name!: string; - private _error?: string; - private _params?: PersonDetailDialogParams; - private _submitting?: boolean; - - static get properties(): PropertyDeclarations { - return { - _error: {}, - _name: {}, - _params: {}, - }; - } + @property() public hass!: HomeAssistant; + @property() private _name!: string; + @property() private _deviceTrackers!: string[]; + @property() private _error?: string; + @property() private _params?: PersonDetailDialogParams; + @property() private _submitting?: boolean; public async showDialog(params: PersonDetailDialogParams): Promise { this._params = params; this._error = undefined; this._name = this._params.entry ? this._params.entry.name : ""; + this._deviceTrackers = this._params.entry + ? this._params.entry.device_trackers || [] + : []; await this.updateComplete; } @@ -64,6 +61,23 @@ class DialogPersonDetail extends LitElement { error-message="Name is required" .invalid=${nameInvalid} > +

+ ${this.hass.localize( + "ui.panel.config.person.detail.device_tracker_intro" + )} +

+
@@ -94,14 +108,19 @@ class DialogPersonDetail extends LitElement { this._name = ev.detail.value; } + private _deviceTrackersChanged(ev: PolymerChangedEvent) { + this._error = undefined; + this._deviceTrackers = ev.detail.value; + } + private async _updateEntry() { this._submitting = true; try { const values: PersonMutableParams = { name: this._name.trim(), + device_trackers: this._deviceTrackers, // Temp, we will add this in a future PR. user_id: null, - device_trackers: [], }; if (this._params!.entry) { await this._params!.updateEntry(values); diff --git a/src/translations/en.json b/src/translations/en.json index 01aca9f1e3..d20f31f8cf 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -783,8 +783,14 @@ } }, "person": { - "caption": "People", - "description": "Manage the people in Home Assistant." + "caption": "Persons", + "description": "Manage the persons that Home Assistant tracks.", + "detail": { + "name": "Name", + "device_tracker_intro": "Select the devices that belong to this person.", + "device_tracker_picked": "Track Device", + "device_tracker_pick": "Pick device to track" + } }, "integrations": { "caption": "Integrations", diff --git a/src/types.ts b/src/types.ts index e72f40b124..50964a2640 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,13 @@ declare global { getComputedStyleValue(element, propertyName); }; } + // for fire event + interface HASSDomEvents { + "value-changed": { + value: unknown; + }; + change: undefined; + } } export interface WebhookError { diff --git a/yarn.lock b/yarn.lock index 800cf3275f..17185f5b93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1642,6 +1642,11 @@ resolved "https://registry.yarnpkg.com/@types/launchpad/-/launchpad-0.6.0.tgz#37296109b7f277f6e6c5fd7e0c0706bc918fbb51" integrity sha1-NylhCbfyd/bmxf1+DAcGvJGPu1E= +"@types/memoize-one@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@types/memoize-one/-/memoize-one-4.1.0.tgz#62119f26055b3193ae43ca1882c5b29b88b71ece" + integrity sha512-cmSgi6JMX/yBwgpVm4GooNWIH+vEeJoa8FAa6ExOhpJbC0Juq32/uYKiKb3VPSqrEA0aOnjvwZanla3O1WZMbw== + "@types/merge-stream@^1.0.28": version "1.1.2" resolved "https://registry.yarnpkg.com/@types/merge-stream/-/merge-stream-1.1.2.tgz#a880ff66b1fbbb5eef4958d015c5947a9334dbb1" @@ -9466,6 +9471,11 @@ mem@^4.0.0: mimic-fn "^1.0.0" p-is-promise "^1.1.0" +memoize-one@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.0.0.tgz#d55007dffefb8de7546659a1722a5d42e128286e" + integrity sha512-7g0+ejkOaI9w5x6LvQwmj68kUj6rxROywPSCqmclG/HBacmFnZqhVscQ8kovkn9FBCNJmOz6SY42+jnvZzDWdw== + memory-fs@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.2.0.tgz#f2bb25368bc121e391c2520de92969caee0a0290"