diff --git a/app/client/lib/MultiUserManager.ts b/app/client/lib/MultiUserManager.ts new file mode 100644 index 00000000..b3979e49 --- /dev/null +++ b/app/client/lib/MultiUserManager.ts @@ -0,0 +1,176 @@ +import {computed, Computed, dom, DomElementArg, IDisposableOwner, Observable, styled} from "grainjs"; +import {cssModalBody, cssModalButtons, cssModalTitle, IModalControl, modal, cssAnimatedModal} from 'app/client/ui2018/modals'; +import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons'; +import {mediaXSmall, testId, theme, vars} from 'app/client/ui2018/cssVars'; +import {UserManagerModel, IOrgMemberSelectOption} from 'app/client/models/UserManagerModel'; +import {icon} from 'app/client/ui2018/icons'; +import {textarea} from "app/client/ui/inputs"; +import {BasicRole, VIEWER, NonGuestRole, isBasicRole} from "app/common/roles"; +import {menu, menuItem} from 'app/client/ui2018/menus'; + +function parseEmailList(emailListRaw: string): Array { + return emailListRaw + .split('\n') + .map(email => email.trim().toLowerCase()) + .filter(email => email !== ""); +} + +function validateEmail(email: string): boolean { + const mailformat = /\S+@\S+\.\S+/; + return mailformat.test(email); +} + +export function buildMultiUserManagerModal( + owner: IDisposableOwner, + model: UserManagerModel, + onAdd: (email: string, role: NonGuestRole) => void, +) { + const emailListObs = Observable.create(owner, ""); + const rolesObs = Observable.create(owner, VIEWER); + const isValidObs = Observable.create(owner, true); + + const enableAdd: Computed = computed((use) => Boolean(use(emailListObs) && use(rolesObs) && use(isValidObs))); + + const save = (ctl: IModalControl) => { + const emailList = parseEmailList(emailListObs.get()); + const role = rolesObs.get(); + if (emailList.some(email => !validateEmail(email))) { + isValidObs.set(false); + } else { + emailList.forEach(email => onAdd(email, role)); + ctl.close(); + } + } + + return modal(ctl => [ + { style: 'padding: 0;' }, + dom.cls(cssAnimatedModal.className), + cssTitle( + 'Invite Users', + testId('um-header'), + ), + cssModalBody( + cssUserManagerBody( + buildEmailsTextarea(emailListObs, isValidObs), + dom.maybe(use => !use(isValidObs), () => cssErrorMessage('At least one email is invalid')), + cssInheritRoles( + dom('span', 'Access: '), + buildRolesSelect(rolesObs, model) + ) + ), + ), + cssModalButtons( + { style: 'margin: 32px 64px; display: flex;' }, + bigPrimaryButton('Confirm', + dom.boolAttr('disabled', (use) => !use(enableAdd)), + dom.on('click', () => {save(ctl)}), + testId('um-confirm') + ), + bigBasicButton( + 'Cancel', + dom.on('click', () => ctl.close()), + testId('um-cancel') + ), + ) + ]); +} + +function buildRolesSelect( + roleSelectedObs: Observable, + model: UserManagerModel, +) { + const allRoles = (model.isOrg ? model.orgUserSelectOptions : model.userSelectOptions) + .filter((x): x is {value: BasicRole, label: string} => isBasicRole(x.value)); + return cssOptionBtn( + menu(() => [ + dom.forEach(allRoles, (_role) => + menuItem(() => roleSelectedObs.set(_role.value), _role.label, + testId(`um-role-option`) + ) + ) + ]), + dom.text((use) => { + // Get the label of the active role. + const activeRole = allRoles.find((_role: IOrgMemberSelectOption) => use(roleSelectedObs) === _role.value); + return activeRole ? activeRole.label : ""; + }), + cssCollapseIcon('Collapse'), + testId('um-role-select') + ); +} + + +function buildEmailsTextarea( + emailListObs: Observable, + isValidObs: Observable, + ...args: DomElementArg[] +) { + return cssTextarea(emailListObs, + {onInput: true, isValid: isValidObs}, + {placeholder: "Enter one email address per line"}, + dom.on('change', (_ev) => isValidObs.set(true)), + ...args, + ); +} + + +const cssTitle = styled(cssModalTitle, ` + margin: 40px 64px 0 64px; + + @media ${mediaXSmall} { + & { + margin: 16px; + } + } +`); + +const cssInheritRoles = styled('span', ` + margin: 13px 63px 42px; +`); + +const cssErrorMessage = styled('span', ` + margin: 0 63px; + color: ${theme.errorText}; +`); + +const cssOptionBtn = styled('span', ` + display: inline-flex; + font-size: ${vars.mediumFontSize}; + color: ${theme.controlFg}; + cursor: pointer; +`); + +const cssCollapseIcon = styled(icon, ` + margin-top: 1px; + background-color: ${theme.controlFg}; +`); + +const cssAccessDetailsBody = styled('div', ` + display: flex; + flex-direction: column; + width: 600px; + font-size: ${vars.mediumFontSize}; +`); + +const cssUserManagerBody = styled(cssAccessDetailsBody, ` + height: 374px; + border-bottom: 1px solid ${theme.modalBorderDark}; +`); + +const cssTextarea = styled(textarea, ` + margin: 16px 63px; + padding: 12px 10px; + border-radius: 3px; + resize: none; + border: 1px solid ${theme.inputBorder}; + color: ${theme.inputFg}; + background-color: ${theme.inputBg}; + flex: 1 1 0; + font-size: ${vars.mediumFontSize}; + font-family: ${vars.fontFamily}; + outline: none; + + &::placeholder { + color: ${theme.inputPlaceholderFg}; + } +`); diff --git a/app/client/ui/UserManager.ts b/app/client/ui/UserManager.ts index 90ab396c..57b1d2c6 100644 --- a/app/client/ui/UserManager.ts +++ b/app/client/ui/UserManager.ts @@ -10,12 +10,13 @@ import {capitalizeFirstWord, 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, Disposable, dom, DomElementArg, keyframes, Observable, observable, styled} from 'grainjs'; +import {Computed, Disposable, dom, DomElementArg, Observable, observable, styled} from 'grainjs'; import pick = require('lodash/pick'); import {ACIndexImpl, normalizeText} from 'app/client/lib/ACIndex'; import {copyToClipboard} from 'app/client/lib/copyToClipboard'; import {setTestState} from 'app/client/lib/testState'; +import {buildMultiUserManagerModal} from 'app/client/lib/MultiUserManager'; import {ACUserItem, buildACMemberEmail} from 'app/client/lib/ACUserManager'; import {AppModel} from 'app/client/models/AppModel'; import {DocPageModel} from 'app/client/models/DocPageModel'; @@ -39,7 +40,7 @@ import {cssLink} from 'app/client/ui2018/links'; import {loadingSpinner} from 'app/client/ui2018/loaders'; import {menu, menuItem, menuText} from 'app/client/ui2018/menus'; import {confirmModal, cssModalBody, cssModalButtons, cssModalTitle, IModalControl, - modal} from 'app/client/ui2018/modals'; + modal, cssAnimatedModal} from 'app/client/ui2018/modals'; export interface IUserManagerOptions { permissionData: Promise; @@ -148,7 +149,8 @@ function buildUserManagerModal( cssModalBody( cssBody( new UserManager( - model, pick(options, 'linkToCopy', 'docPageModel', 'appModel', 'prompt', 'resource') + model, + pick(options, 'linkToCopy', 'docPageModel', 'appModel', 'prompt', 'resource') ).buildDom() ), ), @@ -198,22 +200,20 @@ function buildUserManagerModal( */ export class UserManager extends Disposable { private _dom: HTMLDivElement; - constructor(private _model: UserManagerModel, private _options: { - linkToCopy?: string, - docPageModel?: DocPageModel, - appModel?: AppModel, - prompt?: {email: string}, - resource?: Resource, + + constructor( + private _model: UserManagerModel, + private _options: { + linkToCopy?: string, + docPageModel?: DocPageModel, + appModel?: AppModel, + prompt?: {email: string}, + resource?: Resource, }) { super(); } public buildDom() { - const acMemberEmail = this.autoDispose(new ACMemberEmail( - this._onAdd.bind(this), - this._model.membersEdited.get(), - this._options.prompt, - )); if (this._model.isPublicMember) { return this._buildSelfPublicAccessDom(); } @@ -222,6 +222,12 @@ export class UserManager extends Disposable { return this._buildSelfAccessDom(); } + const acMemberEmail = this.autoDispose(new ACMemberEmail( + this._onAdd.bind(this), + this._model.membersEdited.get(), + this._options.prompt, + )); + return [ acMemberEmail.buildDom(), this._buildOptionsDom(), @@ -233,6 +239,16 @@ export class UserManager extends Disposable { ]; } + private _onAddOrEdit(email: string, role: roles.NonGuestRole) { + const members = this._model.membersEdited.get(); + const maybeMember = members.find(m => m.email === email); + if (maybeMember) { + maybeMember.access.set(role); + } else { + this._onAdd(email, role); + } + } + private _onAdd(email: string, role: roles.NonGuestRole) { this._model.add(email, role); // Make sure the entry we have just added is actually visible - confusing if not. @@ -244,41 +260,54 @@ export class UserManager extends Disposable { private _buildOptionsDom(): Element { const publicMember = this._model.publicMember; let tooltipControl: ITooltipControl | undefined; - return cssOptionRow( - // TODO: Consider adding a tooltip explaining inheritance. A brief text caption may - // be used to fill whitespace in org UserManager. - this._model.isOrg ? null : dom('span', { style: `float: left;` }, - dom('span', 'Inherit access: '), - this._inheritRoleSelector() + return dom('div', + cssOptionRowMultiple( + icon('AddUser'), + cssLabel('Invite multiple'), + dom.on('click', (_ev) => buildMultiUserManagerModal( + this, + this._model, + (email, role) => { + this._onAddOrEdit(email, role); + }, + )) ), - publicMember ? dom('span', { style: `float: right;` }, - cssSmallPublicMemberIcon('PublicFilled'), - dom('span', 'Public access: '), - cssOptionBtn( - menu(() => { - tooltipControl?.close(); - return [ - menuItem(() => publicMember.access.set(roles.VIEWER), 'On', testId(`um-public-option`)), - menuItem(() => publicMember.access.set(null), 'Off', - // Disable null access if anonymous access is inherited. - dom.cls('disabled', (use) => use(publicMember.inheritedAccess) !== null), - testId(`um-public-option`) - ), - // If the 'Off' setting is disabled, show an explanation. - dom.maybe((use) => use(publicMember.inheritedAccess) !== null, () => menuText( - `Public access inherited from ${getResourceParent(this._model.resourceType)}. ` + - `To remove, set 'Inherit access' option to 'None'.`)) - ]; - }), - dom.text((use) => use(publicMember.effectiveAccess) ? 'On' : 'Off'), - cssCollapseIcon('Collapse'), - testId('um-public-access') + cssOptionRow( + // TODO: Consider adding a tooltip explaining inheritance. A brief text caption may + // be used to fill whitespace in org UserManager. + this._model.isOrg ? null : dom('span', { style: `float: left;` }, + dom('span', 'Inherit access: '), + this._inheritRoleSelector() ), - hoverTooltip((ctl) => { - tooltipControl = ctl; - return 'Allow anyone with the link to open.'; - }), - ) : null + publicMember ? dom('span', { style: `float: right;` }, + cssSmallPublicMemberIcon('PublicFilled'), + dom('span', 'Public access: '), + cssOptionBtn( + menu(() => { + tooltipControl?.close(); + return [ + menuItem(() => publicMember.access.set(roles.VIEWER), 'On', testId(`um-public-option`)), + menuItem(() => publicMember.access.set(null), 'Off', + // Disable null access if anonymous access is inherited. + dom.cls('disabled', (use) => use(publicMember.inheritedAccess) !== null), + testId(`um-public-option`) + ), + // If the 'Off' setting is disabled, show an explanation. + dom.maybe((use) => use(publicMember.inheritedAccess) !== null, () => menuText( + `Public access inherited from ${getResourceParent(this._model.resourceType)}. ` + + `To remove, set 'Inherit access' option to 'None'.`)) + ]; + }), + dom.text((use) => use(publicMember.effectiveAccess) ? 'On' : 'Off'), + cssCollapseIcon('Collapse'), + testId('um-public-access') + ), + hoverTooltip((ctl) => { + tooltipControl = ctl; + return 'Allow anyone with the link to open.'; + }), + ) : null, + ), ); } @@ -674,6 +703,24 @@ const cssOptionRow = styled('div', ` margin: 0 63px 23px 63px; `); +const cssOptionRowMultiple = styled('div', ` + margin: 0 63px 12px 63px; + font-size: ${vars.mediumFontSize}; + display: flex; + cursor: pointer; + color: ${theme.controlFg}; + --icon-color: ${theme.controlFg}; + + &:hover { + color: ${theme.controlHoverFg}; + --icon-color: ${theme.controlHoverFg}; + } +`); + +const cssLabel = styled('span', ` + margin-left: 4px; +`); + const cssOptionBtn = styled('span', ` display: inline-flex; font-size: ${vars.mediumFontSize}; @@ -735,17 +782,6 @@ const cssOrgDomain = styled('span', ` color: ${theme.accentText}; `); -const cssFadeInFromTop = keyframes(` - from {top: -250px; opacity: 0} - to {top: 0; opacity: 1} -`); - -const cssAnimatedModal = styled('div', ` - animation-name: ${cssFadeInFromTop}; - animation-duration: 0.4s; - position: relative; -`); - const cssTitle = styled(cssModalTitle, ` margin: 40px 64px 0 64px; diff --git a/app/client/ui/inputs.ts b/app/client/ui/inputs.ts index 776cc476..6dfd0e0f 100644 --- a/app/client/ui/inputs.ts +++ b/app/client/ui/inputs.ts @@ -1,5 +1,5 @@ import {theme, vars} from 'app/client/ui2018/cssVars'; -import {dom, DomElementArg, Observable, styled} from 'grainjs'; +import {dom, IDomArgs, DomElementArg, IInputOptions, Observable, styled, subscribe} from 'grainjs'; export const cssInput = styled('input', ` font-size: ${vars.mediumFontSize}; @@ -46,3 +46,25 @@ export function textInput(obs: Observable, ...args: DomElementArg[]): HT ...args, ); } + +export function textarea( + obs: Observable, options: IInputOptions, ...args: IDomArgs +): HTMLTextAreaElement { + + const isValid = options.isValid; + + function setValue(elem: HTMLTextAreaElement) { + obs.set(elem.value); + if (isValid) { isValid.set(elem.validity.valid); } + } + + return dom('textarea', ...args, + dom.prop('value', obs), + (isValid ? + (elem) => dom.autoDisposeElem(elem, + subscribe(obs, (use) => isValid.set(elem.checkValidity()))) : + null), + options.onInput ? dom.on('input', (e, elem) => setValue(elem)) : null, + dom.on('change', (e, elem) => setValue(elem)), + ); +} \ No newline at end of file diff --git a/app/client/ui2018/modals.ts b/app/client/ui2018/modals.ts index 186931cf..28ab788b 100644 --- a/app/client/ui2018/modals.ts +++ b/app/client/ui2018/modals.ts @@ -602,3 +602,14 @@ const cssModalSpinner = styled('div', ` display: flex; flex-direction: column; `); + +const cssFadeInFromTop = keyframes(` + from {top: -250px; opacity: 0} + to {top: 0; opacity: 1} +`); + +export const cssAnimatedModal = styled('div', ` + animation-name: ${cssFadeInFromTop}; + animation-duration: 0.4s; + position: relative; +`);