♻️(frontend) add user from side modal

We move the add user functionality to a side modal.
The side modal is opened from the share button.
This commit is contained in:
Anthony LC
2024-07-11 10:32:18 +02:00
committed by Anthony LC
parent ff832239d3
commit c2d6e60ae8
17 changed files with 241 additions and 152 deletions

View File

@@ -80,8 +80,7 @@ export const addNewMember = async (
response.status() === 200,
);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
@@ -98,8 +97,8 @@ export const addNewMember = async (
await page.getByRole('option', { name: users[index].email }).click();
// Choose a role
await page.getByRole('radio', { name: role }).click();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: role }).click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(

View File

@@ -76,5 +76,6 @@ test.describe('Doc Header', () => {
card.getByText('Owners: super@owner.com / super2@owner.com'),
).toBeVisible();
await expect(card.getByText('Your role: Owner')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
});

View File

@@ -6,7 +6,7 @@ test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Document add users', () => {
test.describe('Document create member', () => {
test('it selects 2 users and 1 invitation', async ({ page, browserName }) => {
const responsePromise = page.waitForResponse(
(response) =>
@@ -14,8 +14,7 @@ test.describe('Document add users', () => {
);
await createDoc(page, 'select-multi-users', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
await expect(inputSearch).toBeVisible();
@@ -56,12 +55,14 @@ test.describe('Document add users', () => {
await expect(page.getByLabel(`Remove ${email}`)).toBeVisible();
// Check roles are displayed
await expect(page.getByText(/Choose a role/)).toBeVisible();
await expect(page.getByRole('radio', { name: 'Reader' })).toBeChecked();
await expect(page.getByRole('radio', { name: 'Owner' })).toBeVisible();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await expect(page.getByRole('option', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Editor' })).toBeVisible();
await expect(
page.getByRole('radio', { name: 'Administrator' }),
page.getByRole('option', { name: 'Administrator' }),
).toBeVisible();
await expect(page.getByRole('option', { name: 'Owner' })).toBeVisible();
});
test('it sends a new invitation and adds a new user', async ({
@@ -75,8 +76,7 @@ test.describe('Document add users', () => {
await createDoc(page, 'user-invitation', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
@@ -93,7 +93,8 @@ test.describe('Document add users', () => {
await page.getByRole('option', { name: user.email }).click();
// Choose a role
await page.getByRole('radio', { name: 'Administrator' }).click();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -127,8 +128,7 @@ test.describe('Document add users', () => {
await createDoc(page, 'user-twice', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
await inputSearch.fill('user');
@@ -139,7 +139,8 @@ test.describe('Document add users', () => {
await page.getByRole('option', { name: user.email }).click();
// Choose a role
await page.getByRole('radio', { name: 'Owner' }).click();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Owner' }).click();
const responsePromiseAddMember = page.waitForResponse(
(response) =>
@@ -154,10 +155,8 @@ test.describe('Document add users', () => {
const responseAddMember = await responsePromiseAddMember;
expect(responseAddMember.ok()).toBeTruthy();
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await inputSearch.fill('user');
await expect(page.getByText('Loading...')).toBeHidden();
await expect(page.getByRole('option', { name: user.email })).toBeHidden();
});
@@ -167,8 +166,7 @@ test.describe('Document add users', () => {
}) => {
await createDoc(page, 'invitation-twice', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
@@ -177,7 +175,8 @@ test.describe('Document add users', () => {
await page.getByRole('option', { name: email }).click();
// Choose a role
await page.getByRole('radio', { name: 'Owner' }).click();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -191,10 +190,8 @@ test.describe('Document add users', () => {
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await inputSearch.fill(email);
await expect(page.getByText('Loading...')).toBeHidden();
await expect(page.getByRole('option', { name: email })).toBeHidden();
});
});

View File

@@ -216,11 +216,10 @@ test.describe('Doc Tools', () => {
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Add members' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Generate PDF' }),
).toBeVisible();
@@ -267,11 +266,10 @@ test.describe('Doc Tools', () => {
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Add members' }),
).toBeHidden();
await expect(
page.getByRole('button', { name: 'Generate PDF' }),
).toBeVisible();
@@ -318,11 +316,11 @@ test.describe('Doc Tools', () => {
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Add members' }),
).toBeHidden();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(
page.getByRole('button', { name: 'Generate PDF' }),
).toBeVisible();

View File

@@ -5,10 +5,23 @@ import { createGlobalStyle } from 'styled-components';
interface SideModalStyleProps {
side: 'left' | 'right';
width: string;
$css?: string;
}
const SideModalStyle = createGlobalStyle<SideModalStyleProps>`
@keyframes slidein {
from {
transform: translateX(100%);
}
to {
transform: translateX(0%);
}
}
& .c__modal{
animation: slidein 0.7s;
width: ${({ width }) => width};
${({ side }) => side === 'right' && 'left: auto;'};
@@ -17,6 +30,8 @@ const SideModalStyle = createGlobalStyle<SideModalStyleProps>`
display: flex;
flex-direction: column;
}
${({ $css }) => $css}
}
`;
@@ -28,11 +43,12 @@ export const SideModal = ({
children,
side = 'right',
width = '35vw',
$css,
...modalProps
}: PropsWithChildren<SideModalProps>) => {
return (
<>
<SideModalStyle width={width} side={side} />
<SideModalStyle width={width} side={side} $css={$css} />
<Modal {...modalProps} size={ModalSize.FULL}>
{children}
</Modal>

View File

@@ -14,6 +14,7 @@ export interface TextProps extends BoxProps {
'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
>;
$elipsis?: boolean;
$isMaterialIcon?: boolean;
$weight?: CSSProperties['fontWeight'];
$textAlign?: CSSProperties['textAlign'];
// eslint-disable-next-line @typescript-eslint/ban-types
@@ -56,9 +57,17 @@ export const TextStyled = styled(Box)<TextProps>`
`;
export const Text = ({
className,
$isMaterialIcon,
...props
}: ComponentPropsWithRef<typeof TextStyled>) => {
return (
<TextStyled as="span" $theme="greyscale" $variation="text" {...props} />
<TextStyled
as="span"
$theme="greyscale"
$variation="text"
className={`${className || ''}${$isMaterialIcon ? ' material-icons' : ''}`}
{...props}
/>
);
};

View File

@@ -477,6 +477,10 @@ input:-webkit-autofill:focus {
padding: 1.5rem 1rem;
}
.c__modal--full .c__modal__content {
overflow-y: auto;
}
/**
* Toast
*/

View File

@@ -31,7 +31,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
<Box $padding="small" $direction="row" $align="center">
<StyledLink href="/">
<Text
className="material-icons"
$isMaterialIcon
$theme="primary"
$size="2rem"
$css={`&:hover {background-color: ${colorsTokens()['primary-100']}; };`}
@@ -82,7 +82,9 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
{t('Owners:')}{' '}
<strong>
{doc.accesses
.filter((access) => access.role === Role.OWNER)
.filter(
(access) => access.role === Role.OWNER && access.user.email,
)
.map((access, index, accesses) => (
<Fragment key={`access-${index}`}>
{access.user.email}{' '}

View File

@@ -6,10 +6,9 @@ import { Box, DropButton, IconOptions, Text } from '@/components';
import {
Doc,
ModalRemoveDoc,
ModalShare,
ModalUpdateDoc,
currentDocRole,
} from '@/features/docs/doc-management';
import { ModalAddMembers } from '@/features/docs/members/members-add';
import { ModalGridMembers } from '@/features/docs/members/members-grid/';
import { ModalPDF } from './ModalPDF';
@@ -20,15 +19,29 @@ interface DocToolBoxProps {
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation();
const [isModalAddMembersOpen, setIsModalAddMembersOpen] = useState(false);
const [isModalGridMembersOpen, setIsModalGridMembersOpen] = useState(false);
const [isModalShareOpen, setIsModalShareOpen] = useState(false);
const [isModalUpdateOpen, setIsModalUpdateOpen] = useState(false);
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isModalPDFOpen, setIsModalPDFOpen] = useState(false);
const [isDropOpen, setIsDropOpen] = useState(false);
return (
<Box $margin={{ left: 'auto' }}>
<Box
$margin={{ left: 'auto' }}
$direction="row"
$align="center"
$gap="1rem"
>
{doc.abilities.manage_accesses && (
<Button
onClick={() => {
setIsModalShareOpen(true);
}}
>
{t('Share')}
</Button>
)}
<DropButton
button={
<IconOptions
@@ -42,17 +55,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
<Box>
{doc.abilities.manage_accesses && (
<>
<Button
onClick={() => {
setIsModalAddMembersOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">person_add</span>}
size="small"
>
<Text $theme="primary">{t('Add members')}</Text>
</Button>
<Button
onClick={() => {
setIsModalGridMembersOpen(true);
@@ -105,19 +107,15 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
</Button>
</Box>
</DropButton>
{isModalShareOpen && (
<ModalShare onClose={() => setIsModalShareOpen(false)} doc={doc} />
)}
{isModalGridMembersOpen && (
<ModalGridMembers
onClose={() => setIsModalGridMembersOpen(false)}
doc={doc}
/>
)}
{isModalAddMembersOpen && (
<ModalAddMembers
onClose={() => setIsModalAddMembersOpen(false)}
doc={doc}
currentRole={currentDocRole(doc.abilities)}
/>
)}
{isModalPDFOpen && (
<ModalPDF onClose={() => setIsModalPDFOpen(false)} doc={doc} />
)}

View File

@@ -0,0 +1,61 @@
import { t } from 'i18next';
import { useEffect } from 'react';
import { createGlobalStyle } from 'styled-components';
import { Box, Card, Text } from '@/components';
import { SideModal } from '@/components/SideModal';
import { AddMembers } from '../../members/members-add';
import { Doc } from '../types';
import { currentDocRole } from '../utils';
const ModalShareStyle = createGlobalStyle`
& .c__modal__scroller{
background: #FAFAFA;
padding: 1.5rem .5rem;
}
`;
interface ModalShareProps {
onClose: () => void;
doc: Doc;
}
export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
useEffect(() => {
if (!doc.abilities.manage_accesses) {
onClose();
}
}, [doc.abilities.manage_accesses, onClose]);
return (
<>
<ModalShareStyle />
<SideModal
isOpen
closeOnClickOutside
hideCloseButton
onClose={onClose}
width="45vw"
$css="min-width: 320px;"
title={
<Card $direction="row" $align="center" $padding="0.7rem" $gap="1rem">
<Text $isMaterialIcon $size="48px" $theme="primary">
share
</Text>
<Box $align="flex-start">
<Text as="h3" $size="26px" $margin="none">
{t('Share')}
</Text>
<Text $size="small" $weight="normal" $textAlign="left">
{doc.title}
</Text>
</Box>
</Card>
}
>
<AddMembers doc={doc} currentRole={currentDocRole(doc.abilities)} />
</SideModal>
</>
);
};

View File

@@ -1,2 +1,3 @@
export * from './ModalRemoveDoc';
export * from './ModalCreateUpdateDoc';
export * from './ModalRemoveDoc';
export * from './ModalShare';

View File

@@ -27,6 +27,7 @@ const DocsGridStyle = createGlobalStyle`
position: sticky;
top: 0;
background: #fff;
z-index: 1;
}
& .c__pagination__goto{
display:none;

View File

@@ -1,21 +1,17 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { APIError } from '@/api';
import { Box, Text } from '@/components';
import { Box, Card, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, Role } from '@/features/docs/doc-management';
import { useCreateDocAccess, useCreateInvitation } from '../api';
import IconAddUser from '../assets/add-user.svg';
import {
OptionInvitation,
OptionNewMember,
@@ -27,12 +23,6 @@ import {
import { ChooseRole } from './ChooseRole';
import { OptionsSelect, SearchUsers } from './SearchUsers';
const GlobalStyle = createGlobalStyle`
.c__modal {
overflow: visible;
}
`;
type APIErrorUser = APIError<{
value: string;
type: OptionType;
@@ -40,26 +30,22 @@ type APIErrorUser = APIError<{
interface ModalAddMembersProps {
currentRole: Role;
onClose: () => void;
doc: Doc;
}
export const ModalAddMembers = ({
currentRole,
onClose,
doc,
}: ModalAddMembersProps) => {
const { colorsTokens } = useCunninghamTheme();
export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [selectedUsers, setSelectedUsers] = useState<OptionsSelect>([]);
const [selectedRole, setSelectedRole] = useState<Role>(Role.READER);
const [selectedRole, setSelectedRole] = useState<Role>();
const { toast } = useToastProvider();
const { mutateAsync: createInvitation } = useCreateInvitation();
const { mutateAsync: createDocAccess } = useCreateDocAccess();
const [resetKey, setResetKey] = useState(1);
const [isPending, setIsPending] = useState<boolean>(false);
const switchActions = (selectedUsers: OptionsSelect) =>
const switchActions = (selectedUsers: OptionsSelect, selectedRole: Role) =>
selectedUsers.map(async (selectedUser) => {
switch (selectedUser.type) {
case OptionType.INVITATION:
@@ -112,12 +98,16 @@ export const ModalAddMembers = ({
const handleValidate = async () => {
setIsPending(true);
if (!selectedRole) {
return;
}
const settledPromises = await Promise.allSettled<
OptionInvitation | OptionNewMember
>(switchActions(selectedUsers));
>(switchActions(selectedUsers, selectedRole));
onClose();
setIsPending(false);
setResetKey(resetKey + 1);
settledPromises.forEach((settledPromise) => {
switch (settledPromise.status) {
@@ -133,63 +123,57 @@ export const ModalAddMembers = ({
};
return (
<Modal
isOpen
leftActions={
<Button
color="secondary"
fullWidth
onClick={onClose}
disabled={isPending}
>
{t('Cancel')}
</Button>
}
onClose={onClose}
closeOnClickOutside
hideCloseButton
rightActions={
<Button
color="primary"
fullWidth
disabled={!selectedUsers.length || isPending}
onClick={() => void handleValidate()}
>
{t('Validate')}
</Button>
}
size={ModalSize.MEDIUM}
title={
<Box $align="center" $gap="1rem">
<IconAddUser width={48} color={colorsTokens()['primary-text']} />
<Text $size="h3" $margin="none">
{t('Add members to the document')}
</Text>
</Box>
}
<Card
$gap="1rem"
$padding="1rem"
$margin="1rem 0.7rem"
$direction="row"
$align="center"
$wrap="wrap"
>
<GlobalStyle />
<Box $margin={{ bottom: 'xl', top: 'large' }}>
<SearchUsers
doc={doc}
setSelectedUsers={setSelectedUsers}
selectedUsers={selectedUsers}
disabled={isPending}
/>
{selectedUsers.length >= 0 && (
<Box $margin={{ top: 'small' }}>
<Text as="h4" $textAlign="left" $margin={{ bottom: 'tiny' }}>
{t('Choose a role')}
</Text>
<Text
$isMaterialIcon
$size="44px"
$theme="primary"
$background={colorsTokens()['primary-bg']}
$css={`border: 1px solid ${colorsTokens()['primary-200']}`}
$radius="12px"
$padding="4px"
$margin="auto"
>
group_add
</Text>
<Box $gap="0.7rem" $direction="row" $wrap="wrap" $css="flex: 70%;">
<Box $gap="0.7rem" $direction="row" $wrap="wrap" $css="flex: 80%;">
<Box $css="flex: auto;">
<SearchUsers
key={resetKey + 1}
doc={doc}
setSelectedUsers={setSelectedUsers}
selectedUsers={selectedUsers}
disabled={isPending}
/>
</Box>
<Box $css="flex: auto;">
<ChooseRole
key={resetKey}
currentRole={currentRole}
disabled={isPending}
defaultRole={Role.READER}
setRole={setSelectedRole}
/>
</Box>
)}
</Box>
<Box $align="center" $justify="center" $css="flex: auto;">
<Button
color="primary"
disabled={!selectedUsers.length || isPending || !selectedRole}
onClick={() => void handleValidate()}
style={{ height: '100%', maxHeight: '55px' }}
>
{t('Validate')}
</Button>
</Box>
</Box>
</Modal>
</Card>
);
};

View File

@@ -1,11 +1,12 @@
import { Radio, RadioGroup } from '@openfun/cunningham-react';
import { Select } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Role, useTransRole } from '@/features/docs/doc-management';
interface ChooseRoleProps {
currentRole: Role;
disabled: boolean;
defaultRole: Role;
defaultRole?: Role;
setRole: (role: Role) => void;
}
@@ -15,23 +16,20 @@ export const ChooseRole = ({
currentRole,
setRole,
}: ChooseRoleProps) => {
const { t } = useTranslation();
const transRole = useTransRole();
return (
<RadioGroup>
{Object.values(Role).map((role) => (
<Radio
key={role}
label={transRole(role)}
value={role}
name="role"
onChange={(evt) => setRole(evt.target.value as Role)}
defaultChecked={defaultRole === role}
disabled={
disabled || (currentRole !== Role.OWNER && role === Role.OWNER)
}
/>
))}
</RadioGroup>
<Select
label={t('Choose a role')}
options={Object.values(Role).map((role) => ({
label: transRole(role),
value: role,
disabled: currentRole !== Role.OWNER && role === Role.OWNER,
}))}
onChange={(evt) => setRole(evt.target.value as Role)}
disabled={disabled}
value={defaultRole}
/>
);
};

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Options } from 'react-select';
import AsyncSelect from 'react-select/async';
import { useCunninghamTheme } from '@/cunningham';
import { Doc } from '@/features/docs/doc-management';
import { isValidEmail } from '@/utils';
@@ -24,6 +25,7 @@ export const SearchUsers = ({
setSelectedUsers,
disabled,
}: SearchUsersProps) => {
const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
const [input, setInput] = useState('');
const [userQuery, setUserQuery] = useState('');
@@ -102,6 +104,23 @@ export const SearchUsers = ({
return (
<AsyncSelect
styles={{
placeholder: (base) => ({
...base,
fontSize: '14px',
color: colorsTokens()['primary-600'],
}),
control: (base) => ({
...base,
minHeight: '45px',
borderColor: colorsTokens()['primary-600'],
}),
input: (base) => ({
...base,
minHeight: '45px',
fontSize: '14px',
}),
}}
isDisabled={disabled}
aria-label={t('Find a member to add to the document')}
isMulti

View File

@@ -0,0 +1 @@
export * from './AddMembers';

View File

@@ -1 +1 @@
export * from './components/ModalAddMembers';
export * from './components';