From c080ebbf468a095c4ef9871dd88a20f46e865533 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 31 Oct 2023 11:01:07 +0100 Subject: [PATCH] Add user selector with multiple and system option --- src/components/entity/ha-entities-picker.ts | 3 +- .../ha-selector/ha-selector-user.ts | 65 ++++++ src/components/ha-selector/ha-selector.ts | 1 + src/components/user/ha-user-picker.ts | 215 +++++++++++++----- src/components/user/ha-users-picker.ts | 142 ++++++------ src/data/selector.ts | 10 +- src/translations/en.json | 4 +- 7 files changed, 307 insertions(+), 133 deletions(-) create mode 100644 src/components/ha-selector/ha-selector-user.ts diff --git a/src/components/entity/ha-entities-picker.ts b/src/components/entity/ha-entities-picker.ts index 21aebdfd2c..3aa7cd586b 100644 --- a/src/components/entity/ha-entities-picker.ts +++ b/src/components/entity/ha-entities-picker.ts @@ -71,7 +71,8 @@ class HaEntitiesPickerLight extends LitElement { @property({ attribute: "picked-entity-label" }) public pickedEntityLabel?: string; - @property({ attribute: "pick-entity-label" }) public pickEntityLabel?: string; + @property({ attribute: "pick-entity-label" }) + public pickEntityLabel?: string; @property() public entityFilter?: HaEntityPickerEntityFilterFunc; diff --git a/src/components/ha-selector/ha-selector-user.ts b/src/components/ha-selector/ha-selector-user.ts new file mode 100644 index 0000000000..345b8d6c28 --- /dev/null +++ b/src/components/ha-selector/ha-selector-user.ts @@ -0,0 +1,65 @@ +import { LitElement, css, html } from "lit"; +import { customElement, property } from "lit/decorators"; +import type { UserSelector } from "../../data/selector"; +import { HomeAssistant } from "../../types"; +import "../user/ha-user-picker"; +import "../user/ha-users-picker"; + +@customElement("ha-selector-user") +export class HaUserSelector extends LitElement { + @property() public hass!: HomeAssistant; + + @property() public selector!: UserSelector; + + @property() public value?: any; + + @property() public label?: string; + + @property() public helper?: string; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = true; + + protected render() { + if (this.selector.user?.multiple) { + return html` + + `; + } + + return html` + + `; + } + + static get styles() { + return css` + ha-user-picker { + width: 100%; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-user": HaUserSelector; + } +} diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index a69fab2157..93a31cdef2 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -50,6 +50,7 @@ const LOAD_ELEMENTS = { color_temp: () => import("./ha-selector-color-temp"), ui_action: () => import("./ha-selector-ui-action"), ui_color: () => import("./ha-selector-ui-color"), + user: () => import("./ha-selector-user"), }; const LEGACY_UI_SELECTORS = new Set(["ui-action", "ui-color"]); diff --git a/src/components/user/ha-user-picker.ts b/src/components/user/ha-user-picker.ts index 50ec6b4611..fa77ec1251 100644 --- a/src/components/user/ha-user-picker.ts +++ b/src/components/user/ha-user-picker.ts @@ -1,68 +1,84 @@ -import "@material/mwc-list/mwc-list-item"; -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { property } from "lit/decorators"; +import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} from "lit"; +import { property, query, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../common/dom/fire_event"; import { stringCompare } from "../../common/string/compare"; +import { + fuzzyFilterSort, + ScorableTextItem, +} from "../../common/string/filter/sequence-matching"; import { fetchUsers, User } from "../../data/user"; -import { HomeAssistant } from "../../types"; +import { HomeAssistant, ValueChangedEvent } from "../../types"; +import "../ha-combo-box"; +import type { HaComboBox } from "../ha-combo-box"; +import "../ha-list-item"; import "../ha-select"; import "./ha-user-badge"; -import "../ha-list-item"; + +type ScorableUser = ScorableTextItem & User; class HaUserPicker extends LitElement { - public hass?: HomeAssistant; + @property({ attribute: false }) public hass!: HomeAssistant; @property() public label?: string; - @property() public noUserLabel?: string; + @property() public value?: string; - @property() public value = ""; + @property() public helper?: string; - @property() public users?: User[]; + @property({ attribute: false }) public users?: User[]; - @property({ type: Boolean }) public disabled = false; + @property({ type: Boolean }) public disabled?: boolean; - private _sortedUsers = memoizeOne((users?: User[]) => { - if (!users) { - return []; + @property({ type: Boolean }) public required?: boolean; + + @state() private _opened?: boolean; + + @query("ha-combo-box", true) public comboBox!: HaComboBox; + + @property({ type: Boolean, attribute: "include-system" }) + public includeSystem?: boolean; + + private _init = false; + + private _getUserItems = memoizeOne( + (users: User[] | undefined): ScorableUser[] => { + if (!users) { + return []; + } + + return users + .sort((a, b) => + stringCompare(a.name, b.name, this.hass!.locale.language) + ) + .map((user) => ({ + ...user, + strings: [user.name, ...(user.username ? [user.username] : [])], + })); } + ); - return users - .filter((user) => !user.system_generated) - .sort((a, b) => - stringCompare(a.name, b.name, this.hass!.locale.language) - ); - }); + private _filteredUsers = memoizeOne( + (users: User[], includeSystem?: boolean) => + users.filter((user) => includeSystem || !user.system_generated) + ); - protected render(): TemplateResult { - return html` - - ${this.users?.length === 0 - ? html` - ${this.noUserLabel || - this.hass?.localize("ui.components.user-picker.no_user")} - ` - : ""} - ${this._sortedUsers(this.users).map( - (user) => html` - - - ${user.name} - - ` - )} - - `; + public async open() { + await this.updateComplete; + await this.comboBox?.open(); + } + + public async focus() { + await this.updateComplete; + await this.comboBox?.focus(); } protected firstUpdated(changedProps) { @@ -74,25 +90,104 @@ class HaUserPicker extends LitElement { } } - private _userChanged(ev) { - const newValue = ev.target.value; + protected updated(changedProps: PropertyValues) { + super.updated(changedProps); + if ( + (!this._init && this.users) || + (this._init && changedProps.has("_opened") && this._opened) + ) { + this._init = true; + const filteredUsers = this._filteredUsers( + this.users ?? [], + this.includeSystem + ); + const items = this._getUserItems(filteredUsers); - if (newValue !== this.value) { - this.value = newValue; - setTimeout(() => { - fireEvent(this, "value-changed", { value: newValue }); - fireEvent(this, "change"); - }, 0); + this.comboBox.items = items; + this.comboBox.filteredItems = items; } } + private _rowRenderer: ComboBoxLitRenderer = (item) => html` + + + ${item.name} + ${item.username} + + `; + + protected render(): TemplateResult { + return html` + + + `; + } + + private _filterChanged(ev: CustomEvent): void { + const target = ev.target as HaComboBox; + const filterString = ev.detail.value.toLowerCase(); + target.filteredItems = filterString.length + ? fuzzyFilterSort(filterString, target.items || []) + : target.items; + } + + private _valueChanged(ev: ValueChangedEvent) { + ev.stopPropagation(); + let newValue = ev.detail.value; + + if (newValue === "no_users") { + newValue = ""; + } + + if (newValue !== this._value) { + this._setValue(newValue); + } + } + + private _openedChanged(ev: ValueChangedEvent) { + this._opened = ev.detail.value; + } + + private get _value() { + return this.value || ""; + } + + private _setValue(value: string) { + this.value = value; + setTimeout(() => { + fireEvent(this, "value-changed", { value }); + fireEvent(this, "change"); + }, 0); + } + static get styles(): CSSResultGroup { return css` - :host { - display: inline-block; - } - mwc-list { - display: block; + ha-select { + width: 100%; } `; } diff --git a/src/components/user/ha-users-picker.ts b/src/components/user/ha-users-picker.ts index aeda071a88..e06c199626 100644 --- a/src/components/user/ha-users-picker.ts +++ b/src/components/user/ha-users-picker.ts @@ -1,5 +1,4 @@ -import { mdiClose } from "@mdi/js"; -import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { css, html, LitElement, nothing } from "lit"; import { customElement, property } from "lit/decorators"; import { guard } from "lit/directives/guard"; import memoizeOne from "memoize-one"; @@ -15,6 +14,10 @@ class HaUsersPickerLight extends LitElement { @property() public value?: string[]; + @property({ type: Boolean }) public disabled?: boolean; + + @property({ type: Boolean }) public required?: boolean; + @property({ attribute: "picked-user-label" }) public pickedUserLabel?: string; @@ -24,6 +27,9 @@ class HaUsersPickerLight extends LitElement { @property({ attribute: false }) public users?: User[]; + @property({ type: Boolean, attribute: "include-system" }) + public includeSystem?: boolean; + protected firstUpdated(changedProps) { super.firstUpdated(changedProps); if (this.users === undefined) { @@ -38,69 +44,80 @@ class HaUsersPickerLight extends LitElement { return nothing; } - const notSelectedUsers = this._notSelectedUsers(this.users, this.value); + const filteredUsers = this._filteredUsers(this.users, this.includeSystem); + const selectedUsers = this._selectedUsers(filteredUsers, this.value); + const notSelectedUsers = this._notSelectedUsers(filteredUsers, this.value); + return html` - ${guard( - [notSelectedUsers], - () => - this.value?.map( - (user_id, idx) => html` -
- - - > -
- ` - ) + ${guard([notSelectedUsers], () => + selectedUsers.map( + (user, idx) => html` +
+ +
+ ` + ) )} +
${this._renderPicker(notSelectedUsers)}
+ `; + } + + private _renderPicker(users?: User[]) { + return html` `; } - private _notSelectedUsers = memoizeOne( - (users?: User[], currentUsers?: string[]) => - currentUsers - ? users?.filter( - (user) => !user.system_generated && !currentUsers.includes(user.id) - ) - : users?.filter((user) => !user.system_generated) + private _filteredUsers = memoizeOne( + (users: User[], includeSystem?: boolean) => + users.filter((user) => includeSystem || !user.system_generated) ); - private _notSelectedUsersAndSelected = ( - userId: string, + private _selectedUsers = memoizeOne( + (users: User[], selectedUserIds?: string[]) => { + if (!selectedUserIds) { + return []; + } + return users.filter((user) => selectedUserIds.includes(user.id)); + } + ); + + private _notSelectedUsers = memoizeOne( + (users: User[], selectedUserIds?: string[]) => { + if (!selectedUserIds) { + return users; + } + return users.filter((user) => !selectedUserIds.includes(user.id)); + } + ); + + private _notSelectedUsersAndCurrent = ( + currentUser: User, users?: User[], notSelected?: User[] ) => { - const selectedUser = users?.find((user) => user.id === userId); + const selectedUser = users?.find((user) => user.id === currentUser.id); if (selectedUser) { return notSelected ? [...notSelected, selectedUser] : [selectedUser]; } @@ -123,7 +140,7 @@ class HaUsersPickerLight extends LitElement { const index = (event.currentTarget as any).index; const newValue = event.detail.value; const newUsers = [...this._currentUsers]; - if (newValue === "") { + if (newValue === undefined) { newUsers.splice(index, 1); } else { newUsers.splice(index, 1, newValue); @@ -146,22 +163,11 @@ class HaUsersPickerLight extends LitElement { this._updateUsers([...currentUsers, toAdd]); } - private _removeUser(event) { - const userId = (event.currentTarget as any).userId; - this._updateUsers(this._currentUsers.filter((user) => user !== userId)); - } - - static get styles(): CSSResultGroup { - return css` - :host { - display: block; - } - div { - display: flex; - align-items: center; - } - `; - } + static override styles = css` + div { + margin-top: 8px; + } + `; } declare global { diff --git a/src/data/selector.ts b/src/data/selector.ts index aaa0a23c0f..618691c271 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -52,7 +52,8 @@ export type Selector = | TTSSelector | TTSVoiceSelector | UiActionSelector - | UiColorSelector; + | UiColorSelector + | UserSelector; export interface ActionSelector { action: { @@ -392,6 +393,13 @@ export interface UiColorSelector { ui_color: {} | null; } +export interface UserSelector { + user: { + multiple?: boolean; + include_system?: boolean; + } | null; +} + export const expandAreaTarget = ( hass: HomeAssistant, areaId: string, diff --git a/src/translations/en.json b/src/translations/en.json index e3047241c3..3ecee3faca 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -444,9 +444,7 @@ "none": "None" }, "user-picker": { - "no_user": "No user", - "add_user": "Add user", - "remove_user": "Remove user" + "user": "User" }, "blueprint-picker": { "select_blueprint": "Select a blueprint"