Add multiple users (#350)

add modal to invite multiple users
This commit is contained in:
Louis Delbosc 2022-11-28 15:02:32 +01:00 committed by GitHub
parent 94a7b750a8
commit ae76b25311
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 304 additions and 59 deletions

View File

@ -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<string> {
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<BasicRole>(owner, VIEWER);
const isValidObs = Observable.create(owner, true);
const enableAdd: Computed<boolean> = 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<BasicRole>,
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<string>,
isValidObs: Observable<boolean>,
...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};
}
`);

View File

@ -10,12 +10,13 @@ import {capitalizeFirstWord, isLongerThan} from 'app/common/gutil';
import {FullUser} from 'app/common/LoginSessionAPI'; import {FullUser} from 'app/common/LoginSessionAPI';
import * as roles from 'app/common/roles'; import * as roles from 'app/common/roles';
import {Organization, PermissionData, UserAPI} from 'app/common/UserAPI'; 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 pick = require('lodash/pick');
import {ACIndexImpl, normalizeText} from 'app/client/lib/ACIndex'; import {ACIndexImpl, normalizeText} from 'app/client/lib/ACIndex';
import {copyToClipboard} from 'app/client/lib/copyToClipboard'; import {copyToClipboard} from 'app/client/lib/copyToClipboard';
import {setTestState} from 'app/client/lib/testState'; import {setTestState} from 'app/client/lib/testState';
import {buildMultiUserManagerModal} from 'app/client/lib/MultiUserManager';
import {ACUserItem, buildACMemberEmail} from 'app/client/lib/ACUserManager'; import {ACUserItem, buildACMemberEmail} from 'app/client/lib/ACUserManager';
import {AppModel} from 'app/client/models/AppModel'; import {AppModel} from 'app/client/models/AppModel';
import {DocPageModel} from 'app/client/models/DocPageModel'; 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 {loadingSpinner} from 'app/client/ui2018/loaders';
import {menu, menuItem, menuText} from 'app/client/ui2018/menus'; import {menu, menuItem, menuText} from 'app/client/ui2018/menus';
import {confirmModal, cssModalBody, cssModalButtons, cssModalTitle, IModalControl, import {confirmModal, cssModalBody, cssModalButtons, cssModalTitle, IModalControl,
modal} from 'app/client/ui2018/modals'; modal, cssAnimatedModal} from 'app/client/ui2018/modals';
export interface IUserManagerOptions { export interface IUserManagerOptions {
permissionData: Promise<PermissionData>; permissionData: Promise<PermissionData>;
@ -148,7 +149,8 @@ function buildUserManagerModal(
cssModalBody( cssModalBody(
cssBody( cssBody(
new UserManager( new UserManager(
model, pick(options, 'linkToCopy', 'docPageModel', 'appModel', 'prompt', 'resource') model,
pick(options, 'linkToCopy', 'docPageModel', 'appModel', 'prompt', 'resource')
).buildDom() ).buildDom()
), ),
), ),
@ -198,7 +200,10 @@ function buildUserManagerModal(
*/ */
export class UserManager extends Disposable { export class UserManager extends Disposable {
private _dom: HTMLDivElement; private _dom: HTMLDivElement;
constructor(private _model: UserManagerModel, private _options: {
constructor(
private _model: UserManagerModel,
private _options: {
linkToCopy?: string, linkToCopy?: string,
docPageModel?: DocPageModel, docPageModel?: DocPageModel,
appModel?: AppModel, appModel?: AppModel,
@ -209,11 +214,6 @@ export class UserManager extends Disposable {
} }
public buildDom() { public buildDom() {
const acMemberEmail = this.autoDispose(new ACMemberEmail(
this._onAdd.bind(this),
this._model.membersEdited.get(),
this._options.prompt,
));
if (this._model.isPublicMember) { if (this._model.isPublicMember) {
return this._buildSelfPublicAccessDom(); return this._buildSelfPublicAccessDom();
} }
@ -222,6 +222,12 @@ export class UserManager extends Disposable {
return this._buildSelfAccessDom(); return this._buildSelfAccessDom();
} }
const acMemberEmail = this.autoDispose(new ACMemberEmail(
this._onAdd.bind(this),
this._model.membersEdited.get(),
this._options.prompt,
));
return [ return [
acMemberEmail.buildDom(), acMemberEmail.buildDom(),
this._buildOptionsDom(), 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) { private _onAdd(email: string, role: roles.NonGuestRole) {
this._model.add(email, role); this._model.add(email, role);
// Make sure the entry we have just added is actually visible - confusing if not. // Make sure the entry we have just added is actually visible - confusing if not.
@ -244,7 +260,19 @@ export class UserManager extends Disposable {
private _buildOptionsDom(): Element { private _buildOptionsDom(): Element {
const publicMember = this._model.publicMember; const publicMember = this._model.publicMember;
let tooltipControl: ITooltipControl | undefined; let tooltipControl: ITooltipControl | undefined;
return cssOptionRow( return dom('div',
cssOptionRowMultiple(
icon('AddUser'),
cssLabel('Invite multiple'),
dom.on('click', (_ev) => buildMultiUserManagerModal(
this,
this._model,
(email, role) => {
this._onAddOrEdit(email, role);
},
))
),
cssOptionRow(
// TODO: Consider adding a tooltip explaining inheritance. A brief text caption may // TODO: Consider adding a tooltip explaining inheritance. A brief text caption may
// be used to fill whitespace in org UserManager. // be used to fill whitespace in org UserManager.
this._model.isOrg ? null : dom('span', { style: `float: left;` }, this._model.isOrg ? null : dom('span', { style: `float: left;` },
@ -278,7 +306,8 @@ export class UserManager extends Disposable {
tooltipControl = ctl; tooltipControl = ctl;
return 'Allow anyone with the link to open.'; return 'Allow anyone with the link to open.';
}), }),
) : null ) : null,
),
); );
} }
@ -674,6 +703,24 @@ const cssOptionRow = styled('div', `
margin: 0 63px 23px 63px; 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', ` const cssOptionBtn = styled('span', `
display: inline-flex; display: inline-flex;
font-size: ${vars.mediumFontSize}; font-size: ${vars.mediumFontSize};
@ -735,17 +782,6 @@ const cssOrgDomain = styled('span', `
color: ${theme.accentText}; 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, ` const cssTitle = styled(cssModalTitle, `
margin: 40px 64px 0 64px; margin: 40px 64px 0 64px;

View File

@ -1,5 +1,5 @@
import {theme, vars} from 'app/client/ui2018/cssVars'; 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', ` export const cssInput = styled('input', `
font-size: ${vars.mediumFontSize}; font-size: ${vars.mediumFontSize};
@ -46,3 +46,25 @@ export function textInput(obs: Observable<string>, ...args: DomElementArg[]): HT
...args, ...args,
); );
} }
export function textarea(
obs: Observable<string>, options: IInputOptions, ...args: IDomArgs<HTMLTextAreaElement>
): 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)),
);
}

View File

@ -602,3 +602,14 @@ const cssModalSpinner = styled('div', `
display: flex; display: flex;
flex-direction: column; 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;
`);