From 1bff046a3b9764dff76c0e965c13a42c3b069d3c Mon Sep 17 00:00:00 2001 From: Louis Delbosc Date: Wed, 21 Sep 2022 16:30:54 +0200 Subject: [PATCH] Improve input team member (#268) * Autocomplete for email * Remove old MemberEmail input and styled correctly the new autocomplete one * Add validation on autocomplete input * fix selected item styling * Add prompt feature on ACUserManager * Add sort for result in autocomplete * Add attach option to autocomplete Co-authored-by: Ronan Amicel --- app/client/lib/ACSelect.ts | 2 +- app/client/lib/ACUserManager.ts | 204 ++++++++++++++++++++++++++++++++ app/client/lib/autocomplete.ts | 19 ++- app/client/ui/UserItem.ts | 1 - app/client/ui/UserManager.ts | 145 ++++++++--------------- 5 files changed, 272 insertions(+), 99 deletions(-) create mode 100644 app/client/lib/ACUserManager.ts diff --git a/app/client/lib/ACSelect.ts b/app/client/lib/ACSelect.ts index 37cf0cc6..b57b7930 100644 --- a/app/client/lib/ACSelect.ts +++ b/app/client/lib/ACSelect.ts @@ -100,7 +100,7 @@ const cssSelectBtn = styled('div', ` --icon-color: ${theme.selectButtonFg}; `); -const cssSelectItem = styled('li', ` +export const cssSelectItem = styled('li', ` color: ${theme.menuItemFg}; display: block; white-space: pre; diff --git a/app/client/lib/ACUserManager.ts b/app/client/lib/ACUserManager.ts new file mode 100644 index 00000000..f97a1628 --- /dev/null +++ b/app/client/lib/ACUserManager.ts @@ -0,0 +1,204 @@ +import {ACResults, ACIndex, ACItem, buildHighlightedDom, normalizeText} from "app/client/lib/ACIndex"; +import {cssSelectItem} from "app/client/lib/ACSelect"; +import {Autocomplete, IAutocompleteOptions} from "app/client/lib/autocomplete"; +import {cssMenuItem} from "popweasel"; + +import {testId, colors, theme} from "app/client/ui2018/cssVars"; +import {menuCssClass} from "app/client/ui2018/menus"; +import {dom, DomElementArg, Holder, IDisposableOwner, Observable, styled, computed, Computed} from "grainjs"; +import { + cssEmailInput, + cssEmailInputContainer, + cssMailIcon, + cssMemberImage, + cssMemberListItem, + cssMemberPrimary, + cssMemberSecondary, + cssMemberText, +} from "app/client/ui/UserItem"; +import {createUserImage, cssUserImage} from "app/client/ui/UserImage"; + +export interface ACUserItem extends ACItem { + value: string; + label: string; + name: string; + email: string; + id: number; + picture?: string | null; // when present, a url to a public image of unspecified dimensions. + isNew?: boolean; +} + +export function buildACMemberEmail( + owner: IDisposableOwner, + options: { + acIndex: ACIndex; + emailObs: Observable; + save: (value: string) => Promise | void; + isInputValid: Observable; + prompt?: {email: string}, + }, + ...args: DomElementArg[] +) { + const { acIndex, emailObs, save, isInputValid, prompt } = options; + const acHolder = Holder.create>(owner); + let emailInput: HTMLInputElement; + + const isOpen = () => !acHolder.isEmpty(); + const acOpen = () => acHolder.isEmpty() && Autocomplete.create(acHolder, emailInput, acOptions); + const acClose = () => acHolder.clear(); + const finish = () => { + acClose(); + emailObs.set(""); + emailInput.value = emailObs.get(); + emailInput.focus(); + }; + const openOrCommit = () => { + isOpen() ? commitIfValid() : acOpen(); + }; + + const commitIfValid = () => { + const item = acHolder.get()?.getSelectedItem(); + if (item) { + emailObs.set(item.value); + } + emailInput.setCustomValidity(""); + const selectedEmail = item?.value || emailObs.get(); + try { + if (selectedEmail && isInputValid.get()) { + save(emailObs.get()); + finish(); + } + } catch (e) { + emailInput.setCustomValidity(e.message); + } finally { + emailInput.reportValidity(); + } + }; + + const maybeShowAddNew = async (results: ACResults, text: string): Promise> => { + const cleanText = normalizeText(text); + const items = results.items + .filter(item => item.cleanText.includes(cleanText)) + .sort((a,b) => a.cleanText.localeCompare(b.cleanText)); + results.items = items; + if (!results.items.length) { + const newObject = { + value: text, + cleanText, + name: "", + email: "", + isNew: true, + label: text, + id: 0, + }; + results.items.push(newObject); + } + return results; + }; + + const renderSearchItem = (item: ACUserItem, highlightFunc: any): HTMLLIElement => (item?.isNew ? cssSelectItem( + cssMemberListItem( + cssUserImagePlus( + "+", + cssUserImage.cls("-large"), + cssUserImagePlus.cls('-invalid', (use) => !use(enableAdd), + )), + cssMemberText( + cssMemberPrimaryPlus("Invite new member"), + cssMemberSecondaryPlus( + dom.text(use => `We'll email an invite to ${use(emailObs)}`) + ) + ), + testId("um-add-email") + ) + ) : cssSelectItem( + cssMemberListItem( + cssMemberImage(createUserImage(item, "large")), + cssMemberText( + cssMemberPrimaryPlus(item.name, testId("um-member-name")), + cssMemberSecondaryPlus(buildHighlightedDom(item.label, highlightFunc, cssMatchText)) + ) + ) + )); + + const enableAdd: Computed = computed((use) => Boolean(use(emailObs) && use(isInputValid))); + + const acOptions: IAutocompleteOptions = { + attach: null, + menuCssClass: `${menuCssClass} test-acselect-dropdown`, + search: (term) => maybeShowAddNew(acIndex.search(term), term), + renderItem: renderSearchItem, + getItemText: (item) => item.value, + onClick: commitIfValid, + }; + + const result = cssEmailInputContainer( + cssMailIcon("Mail"), + (emailInput = cssEmailInput( + emailObs, + {onInput: true, isValid: isInputValid}, + {type: "email", placeholder: "Enter email address"}, + dom.on("input", acOpen), + dom.on("focus", acOpen), + dom.on("click", acOpen), + dom.on("blur", acClose), + dom.onKeyDown({ + Escape: finish, + Enter: openOrCommit, + ArrowDown: acOpen, + Tab: commitIfValid, + }), + ...args + )), + cssEmailInputContainer.cls('-green', enableAdd), + ); + + if (prompt) { setTimeout(() => emailInput.focus(), 0); } + + return result; +} + +const cssMemberPrimaryPlus = styled( + cssMemberPrimary, + ` + .${cssSelectItem.className}.selected & { + color: ${theme.menuItemSelectedFg}; + } +` +); + +const cssMemberSecondaryPlus = styled( + cssMemberSecondary, + ` + .${cssSelectItem.className}.selected & { + color: ${theme.menuItemSelectedFg}; + } +` +); + +const cssMatchText = styled( + "span", + ` + color: ${theme.autocompleteMatchText}; + .${cssSelectItem.className}.selected & { + color: ${theme.autocompleteSelectedMatchText}; + } +` +); + +const cssUserImagePlus = styled( + cssUserImage, + ` + background-color: ${colors.lightGreen}; + margin: auto 0; + + &-invalid { + background-color: ${colors.mediumGrey}; + } + + .${cssMenuItem.className}-sel & { + background-color: ${theme.menuItemIconSelectedFg}; + color: ${theme.menuItemSelectedBg}; + } +` +); diff --git a/app/client/lib/autocomplete.ts b/app/client/lib/autocomplete.ts index 4f07441a..d9d1c078 100644 --- a/app/client/lib/autocomplete.ts +++ b/app/client/lib/autocomplete.ts @@ -32,6 +32,10 @@ export interface IAutocompleteOptions { // A callback triggered when user clicks one of the choices. onClick?(): void; + + // To which element to append the popup content. Null means triggerElem.parentNode and is the + // default; string is a selector for the closest matching ancestor of triggerElem, e.g. 'body'. + attach?: Element|string|null; } /** @@ -86,8 +90,10 @@ export class Autocomplete extends Disposable { this.search(); this.autoDispose(dom.onElem(_triggerElem, 'input', () => this.search())); - // Attach the content to the page. - document.body.appendChild(content); + const attachElem = _options.attach === undefined ? document.body : _options.attach; + const containerElem = getContainer(_triggerElem, attachElem) ?? document.body; + containerElem.appendChild(content); + this.onDispose(() => { dom.domDispose(content); content.remove(); }); // Prepare and create the Popper instance, which places the content according to the options. @@ -198,6 +204,15 @@ export const defaultPopperOptions: Partial = { }; +/** + * Helper that finds the container according to attachElem. Null means + * elem.parentNode; string is a selector for the closest matching ancestor, e.g. 'body'. + */ + function getContainer(elem: Element, attachElem: Element|string|null): Node|null { + return (typeof attachElem === 'string') ? elem.closest(attachElem) : + (attachElem || elem.parentNode); +} + /** * Helper function which returns the direct child of ancestor which is an ancestor of elem, or * null if elem is not a descendant of ancestor. diff --git a/app/client/ui/UserItem.ts b/app/client/ui/UserItem.ts index 8602f224..a163fdf7 100644 --- a/app/client/ui/UserItem.ts +++ b/app/client/ui/UserItem.ts @@ -137,7 +137,6 @@ export const cssEmailInput = styled(input, ` color: ${theme.inputFg}; background-color: ${theme.inputBg}; flex: 1 1 0; - line-height: 42px; font-size: ${vars.mediumFontSize}; font-family: ${vars.fontFamily}; outline: none; diff --git a/app/client/ui/UserManager.ts b/app/client/ui/UserManager.ts index f93e385b..3773343c 100644 --- a/app/client/ui/UserManager.ts +++ b/app/client/ui/UserManager.ts @@ -10,13 +10,13 @@ import {isLongerThan} from 'app/common/gutil'; import {FullUser} from 'app/common/LoginSessionAPI'; import * as roles from 'app/common/roles'; import {Organization, PermissionData, UserAPI} from 'app/common/UserAPI'; -import {computed, Computed, Disposable, keyframes, observable, Observable} from 'grainjs'; -import {dom, DomElementArg, styled} from 'grainjs'; +import {Computed, Disposable, keyframes, observable, Observable, dom, DomElementArg, styled} from 'grainjs'; import pick = require('lodash/pick'); -import {cssMenuItem} from 'popweasel'; +import {ACIndexImpl, normalizeText} from 'app/client/lib/ACIndex'; import {copyToClipboard} from 'app/client/lib/copyToClipboard'; import {setTestState} from 'app/client/lib/testState'; +import {ACUserItem, buildACMemberEmail} from 'app/client/lib/ACUserManager'; import {AppModel} from 'app/client/models/AppModel'; import {DocPageModel} from 'app/client/models/DocPageModel'; import {reportError} from 'app/client/models/errors'; @@ -27,16 +27,16 @@ import {UserManagerModel, UserManagerModelImpl} from 'app/client/models/UserMana import {getResourceParent, ResourceType} from 'app/client/models/UserManagerModel'; import {shadowScroll} from 'app/client/ui/shadowScroll'; import {showTransientTooltip} from 'app/client/ui/tooltips'; -import {createUserImage, cssUserImage} from 'app/client/ui/UserImage'; -import {cssEmailInput, cssEmailInputContainer, cssMailIcon, cssMemberBtn, cssMemberImage, cssMemberListItem, +import {createUserImage} from 'app/client/ui/UserImage'; +import {cssMemberBtn, cssMemberImage, cssMemberListItem, cssMemberPrimary, cssMemberSecondary, cssMemberText, cssMemberType, cssMemberTypeProblem, cssRemoveIcon} from 'app/client/ui/UserItem'; import {basicButton, bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons'; -import {colors, mediaXSmall, testId, theme, vars} from 'app/client/ui2018/cssVars'; +import {mediaXSmall, testId, theme, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; import {loadingSpinner} from 'app/client/ui2018/loaders'; -import {inputMenu, menu, menuItem, menuText} from 'app/client/ui2018/menus'; +import {menu, menuItem, menuText} from 'app/client/ui2018/menus'; import {confirmModal, cssModalBody, cssModalButtons, cssModalTitle, IModalControl, modal} from 'app/client/ui2018/modals'; @@ -204,8 +204,12 @@ export class UserManager extends Disposable { } public buildDom() { - const memberEmail = this.autoDispose(new MemberEmail(this._onAdd.bind(this), - this._options.prompt)); + const acmemberEmail = this.autoDispose(new ACMemberEmail( + this._onAdd.bind(this), + (member) => this._model.isActiveUser(member), + this._model.membersEdited.get(), + this._options.prompt, + )); if (this._model.isPublicMember) { return this._buildSelfPublicAccessDom(); } @@ -215,7 +219,7 @@ export class UserManager extends Disposable { } return [ - memberEmail.buildDom(), + acmemberEmail.buildDom(), this._buildOptionsDom(), this._dom = shadowScroll( testId('um-members'), @@ -518,90 +522,60 @@ export class UserManager extends Disposable { } } +function getUserItem(member: IEditableMember): ACUserItem { + return { + value: member.email, + label: member.email, + cleanText: normalizeText(member.email), + email: member.email, + name: member.name, + picture: member?.picture, + id: member.id, + } +} + /** * Represents the widget that allows typing in an email and adding it. - * The border of the input turns green when the email is considered valid. */ -export class MemberEmail extends Disposable { - public email = this.autoDispose(observable("")); - public isEmpty = this.autoDispose(computed((use) => !use(this.email))); - +export class ACMemberEmail extends Disposable { + private _email = this.autoDispose(observable("")); private _isValid = this.autoDispose(observable(false)); - private _emailElem: HTMLInputElement; constructor( private _onAdd: (email: string, role: roles.NonGuestRole) => void, - private _prompt?: {email: string}, + private _isActiveUser: (member: IEditableMember) => boolean, + private _members: Array, + private _prompt?: {email: string} ) { super(); if (_prompt) { - this.email.set(_prompt.email); + this._email.set(_prompt.email); } - // Reset custom validity that we sometimes set. - this.email.addListener(() => this._emailElem.setCustomValidity("")); } - public buildDom(): Element { - const enableAdd: Computed = computed((use) => Boolean(use(this.email) && use(this._isValid))); - const result = cssEmailInputContainer( - dom.autoDispose(enableAdd), - cssMailIcon('Mail'), - this._emailElem = cssEmailInput(this.email, {onInput: true, isValid: this._isValid}, - {type: "email", placeholder: "Enter email address"}, - dom.onKeyPress({Enter: () => this._commit()}), - inputMenu(() => [ - cssInputMenuItem(() => this._commit(), - cssUserImagePlus('+', - cssUserImage.cls('-large'), - cssUserImagePlus.cls('-invalid', (use) => !use(enableAdd)) - ), - cssMemberText( - cssMemberPrimary('Invite new member'), - cssMemberSecondary( - dom.text((use) => `We'll email an invite to ${use(this.email)}`) - ) - ), - testId('um-add-email') - ) - ], { - // NOTE: An offset of -40px is used to center the input button across the - // input container (including an envelope icon) rather than the input inside. - modifiers: { - offset: { enabled: true, offset: -40 } - }, - stretchToSelector: `.${cssEmailInputContainer.className}`, - attach: null, - boundaries: 'document' as any, // TODO: Update weasel.js types to allow 'document'. - }) - ), - cssEmailInputContainer.cls('-green', enableAdd), + public buildDom() { + const acUserItem = this._members.map((member: IEditableMember) => getUserItem(member)); + const acIndex = new ACIndexImpl(acUserItem); + + return buildACMemberEmail(this, + { + acIndex, + emailObs: this._email, + save: this._handleSave.bind(this), + isInputValid: this._isValid, + prompt: this._prompt, + }, testId('um-member-new') ); - if (this._prompt) { - this._emailElem.dispatchEvent(new Event('input', { bubbles: true })); - } - return result; } - // Add the currently entered email if valid, or trigger a validation message if not. - private _commit() { - this._emailElem.setCustomValidity(""); - this._isValid.set(this._emailElem.checkValidity()); - if (this.email.get() && this._isValid.get()) { - try { - this._onAdd(this.email.get(), roles.VIEWER); - this._reset(); - } catch (e) { - this._emailElem.setCustomValidity(e.message); - } + private _handleSave(selectedEmail: string) { + const member = this._members.find(member => member.email === selectedEmail); + if (!member) { + this._onAdd(selectedEmail, roles.VIEWER); + } else if (!this._isActiveUser(member)) { + member?.effectiveAccess.set(roles.VIEWER); } - this._emailElem.reportValidity(); - } - - // Reset the widget. - private _reset() { - this.email.set(""); - this._emailElem.focus(); } } @@ -734,25 +708,6 @@ const cssCollapseIcon = styled(icon, ` background-color: ${theme.controlFg}; `); -const cssInputMenuItem = styled(menuItem, ` - height: 64px; - padding: 8px 15px; -`); - -const cssUserImagePlus = styled(cssUserImage, ` - background-color: ${colors.lightGreen}; - margin: auto 0; - - &-invalid { - background-color: ${colors.mediumGrey}; - } - - .${cssMenuItem.className}-sel & { - background-color: ${theme.menuItemIconSelectedFg}; - color: ${theme.menuItemSelectedBg}; - } -`); - const cssAccessLink = styled(cssLink, ` align-self: center; margin-left: auto;