This commit is contained in:
Nathan Panchout
2024-11-28 20:30:18 +01:00
parent dc80cd3a02
commit 5d92c481eb
18 changed files with 664 additions and 312 deletions

View File

@@ -67,7 +67,9 @@ export const addNewMember = async (
response.status() === 200,
);
const inputSearch = page.getByLabel(/Find a member to add to the document/);
const inputSearch = page.getByRole('combobox', {
name: 'Find a member to add to the document',
});
// Select a new user
await inputSearch.fill(fillText);
@@ -79,17 +81,19 @@ export const addNewMember = async (
}[];
// Choose user
await page.getByRole('option', { name: users[index].email }).click();
await page.getByTestId(`search-user-row-${users[index].email}`).click();
// Choose a role
await page.getByRole('combobox', { name: /Choose a role/ }).click();
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('option', { name: role }).click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`User ${users[index].email} added to the document.`),
).toBeVisible();
await page.getByRole('button', { name: 'Invite' }).click();
const list = page.getByTestId('doc-share-quick-search');
const newUser = list.getByTestId(
`doc-share-member-row-${users[index].email}`,
);
await expect(newUser).toBeVisible();
return users[index].email;
};

View File

@@ -0,0 +1,238 @@
import { expect, test } from '@playwright/test';
import { createDoc, randomName } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Document create member', () => {
test('it selects 2 users and 1 invitation', async ({ page, browserName }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/users/?q=user') && response.status() === 200,
);
await createDoc(page, 'select-multi-users', browserName, 1);
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Find a member to add to the document',
});
await expect(inputSearch).toBeVisible();
// Select user 1 and verify tag
await inputSearch.fill('user');
const response = await responsePromise;
const users = (await response.json()).results as {
email: string;
full_name: string;
}[];
const list = page.getByTestId('doc-share-add-member-list');
await expect(list).toBeHidden();
const quickSearchContent = page.getByTestId('doc-share-quick-search');
await quickSearchContent
.getByTestId(`search-user-row-${users[0].email}`)
.click();
await expect(list).toBeVisible();
await expect(
list.getByTestId(`doc-share-add-member-${users[0].email}`),
).toBeVisible();
await expect(list.getByText(`${users[0].full_name}`)).toBeVisible();
// Select user 2 and verify tag
await inputSearch.fill('user');
await quickSearchContent
.getByTestId(`search-user-row-${users[1].email}`)
.click();
await expect(
list.getByTestId(`doc-share-add-member-${users[1].email}`),
).toBeVisible();
await expect(list.getByText(`${users[1].full_name}`)).toBeVisible();
// Select email and verify tag
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
await quickSearchContent.getByText(email).click();
await expect(list.getByText(email)).toBeVisible();
// Check roles are displayed
await list.getByLabel('doc-role-dropdown').click();
await expect(page.getByRole('option', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Owner' })).toBeVisible();
await expect(
page.getByRole('option', { name: 'Administrator' }),
).toBeVisible();
// Validate
await page.getByRole('option', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation added
await expect(
quickSearchContent.getByText('Pending invitations'),
).toBeVisible();
await expect(quickSearchContent.getByText(email).first()).toBeVisible();
// Check user added
await expect(page.getByText('Share with 3 users')).toBeVisible();
await expect(
quickSearchContent.getByText(users[0].full_name).first(),
).toBeVisible();
await expect(
quickSearchContent.getByText(users[0].email).first(),
).toBeVisible();
await expect(
quickSearchContent.getByText(users[1].email).first(),
).toBeVisible();
await expect(
quickSearchContent.getByText(users[1].full_name).first(),
).toBeVisible();
});
test('it try to add twice the same invitation', async ({
page,
browserName,
}) => {
await createDoc(page, 'invitation-twice', browserName, 1);
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Find a member to add to the document',
});
const [email] = randomName('test@test.fr', browserName, 1);
await inputSearch.fill(email);
await page.getByTestId(`search-user-row-${email}`).click();
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('option', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation sent
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
await inputSearch.fill(email);
await page.getByTestId(`search-user-row-${email}`).click();
// Choose a role
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('option', { name: 'Owner' }).click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 400,
);
await page.getByRole('button', { name: 'Invite' }).click();
await expect(
page.getByText(`"${email}" is already invited to the document.`),
).toBeVisible();
const responseCreateInvitationFail =
await responsePromiseCreateInvitationFail;
expect(responseCreateInvitationFail.ok()).toBeFalsy();
});
test('The invitation endpoint get the language of the website', async ({
page,
browserName,
}) => {
await createDoc(page, 'user-invitation', browserName, 1);
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('EN').click();
await header.getByRole('option', { name: 'FR' }).click();
await page.getByRole('button', { name: 'Partager' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Trouver un membre à ajouter au document',
});
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
await page.getByTestId(`search-user-row-${email}`).click();
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('option', { name: 'Administrateur' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation sent
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
expect(
responseCreateInvitation.request().headers()['content-language'],
).toBe('fr-fr');
});
test('it manages invitation', async ({ page, browserName }) => {
await createDoc(page, 'user-invitation', browserName, 1);
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Find a member to add to the document',
});
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
await page.getByTestId(`search-user-row-${email}`).click();
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('option', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation sent
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
const listInvitation = page.getByTestId('doc-share-quick-search');
const userInvitation = listInvitation.getByTestId(
`doc-share-invitation-row-${email}`,
);
await expect(userInvitation).toBeVisible();
await userInvitation.getByLabel('doc-role-dropdown').click();
await page.getByRole('option', { name: 'Reader' }).click();
const moreActions = userInvitation.getByRole('button', {
name: 'more_vert',
});
await moreActions.click();
await page.getByRole('option', { name: 'delete Delete' }).click();
await expect(userInvitation).toBeHidden();
});
});

View File

@@ -1,7 +1,5 @@
import { expect, test } from '@playwright/test';
import { waitForElementCount } from '../helpers';
import { addNewMember, createDoc, goToGridDoc, verifyDocName } from './common';
test.beforeEach(async ({ page }) => {
@@ -15,10 +13,11 @@ test.describe('Document list members', () => {
async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page');
const pageId = url.searchParams.get('page') ?? '1';
const accesses = {
count: 100,
next: 'http://anything/?page=2',
count: 40,
next: +pageId < 2 ? 'http://anything/?page=2' : undefined,
previous: null,
results: Array.from({ length: 20 }, (_, i) => ({
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
@@ -48,25 +47,20 @@ test.describe('Document list members', () => {
);
await goToGridDoc(page);
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List members card').locator('ul');
await expect(list.locator('li')).toHaveCount(20);
await list.getByText(`impress@impress.world-page-${1}-18`).hover();
await page.mouse.wheel(0, 10);
const prefix = 'doc-share-member-row';
const elements = page.locator(`[data-testid^="${prefix}"]`);
const loadMore = page.getByTestId('load-more-members');
await waitForElementCount(list.locator('li'), 21, 10000);
await expect(elements).toHaveCount(20);
await expect(page.getByText(`Impress World Page 1-16`)).toBeVisible();
expect(await list.locator('li').count()).toBeGreaterThan(20);
await expect(list.getByText(`Impress World Page 1-16`)).toBeVisible();
await expect(
list.getByText(`impress@impress.world-page-1-16`),
).toBeVisible();
await expect(list.getByText(`Impress World Page 2-15`)).toBeVisible();
await expect(
list.getByText(`impress@impress.world-page-2-15`),
).toBeVisible();
await loadMore.click();
await expect(elements).toHaveCount(40);
await expect(page.getByText(`Impress World Page 2-15`)).toBeVisible();
await expect(loadMore).toBeHidden();
});
test('it checks a big list of invitations', async ({ page }) => {
@@ -75,10 +69,10 @@ test.describe('Document list members', () => {
async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page');
const pageId = url.searchParams.get('page') ?? '1';
const accesses = {
count: 100,
next: 'http://anything/?page=2',
count: 40,
next: +pageId < 2 ? 'http://anything/?page=2' : null,
previous: null,
results: Array.from({ length: 20 }, (_, i) => ({
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
@@ -105,23 +99,24 @@ test.describe('Document list members', () => {
);
await goToGridDoc(page);
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List invitation card').locator('ul');
await expect(list.locator('li')).toHaveCount(20);
await list.getByText(`impress@impress.world-page-${1}-18`).hover();
await page.mouse.wheel(0, 10);
const prefix = 'doc-share-invitation';
const elements = page.locator(`[data-testid^="${prefix}"]`);
const loadMore = page.getByTestId('load-more-invitations');
await waitForElementCount(list.locator('li'), 21, 10000);
await expect(elements).toHaveCount(20);
await expect(
page.getByText(`impress@impress.world-page-1-16`).first(),
).toBeVisible();
expect(await list.locator('li').count()).toBeGreaterThan(20);
await loadMore.click();
await expect(elements).toHaveCount(40);
await expect(
list.getByText(`impress@impress.world-page-1-16`),
).toBeVisible();
await expect(
list.getByText(`impress@impress.world-page-2-15`),
page.getByText(`impress@impress.world-page-2-16`).first(),
).toBeVisible();
await expect(loadMore).toBeHidden();
});
test('it checks the role rules', async ({ page, browserName }) => {
@@ -130,59 +125,47 @@ test.describe('Document list members', () => {
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List members card').locator('ul');
await expect(list.getByText(`user@${browserName}.e2e`)).toBeVisible();
const soleOwner = list.getByText(
const list = page.getByTestId('doc-share-quick-search');
await expect(list).toBeVisible();
const currentUser = list.getByTestId(
`doc-share-member-row-user@chromium.e2e`,
);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
await expect(currentUser).toBeVisible();
await expect(currentUserRole).toBeVisible();
await currentUserRole.click();
const soloOwner = page.getByText(
`You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.`,
);
await expect(soloOwner).toBeVisible();
await list.click();
const newUserEmail = await addNewMember(page, 0, 'Owner');
const newUser = list.getByTestId(`doc-share-member-row-${newUserEmail}`);
const newUserRoles = newUser.getByLabel('doc-role-dropdown');
await expect(soleOwner).toBeVisible();
await expect(newUser).toBeVisible();
const username = await addNewMember(page, 0, 'Owner');
await currentUserRole.click();
await expect(soloOwner).toBeHidden();
await list.click();
await expect(list.getByText(username)).toBeVisible();
await expect(soleOwner).toBeHidden();
const otherOwner = list.getByText(
const otherOwner = page.getByText(
`You cannot update the role or remove other owner.`,
);
await newUserRoles.click();
await expect(otherOwner).toBeVisible();
await list.click();
const SelectRoleCurrentUser = list
.locator('li')
.filter({
hasText: `user@${browserName}.e2e`,
})
.getByRole('combobox', { name: 'Role' });
await SelectRoleCurrentUser.click();
await currentUserRole.click();
await page.getByRole('option', { name: 'Administrator' }).click();
await expect(page.getByText('The role has been updated')).toBeVisible();
await list.click();
await expect(currentUserRole).toBeVisible();
const shareModal = page.getByLabel('Share modal');
// Admin still have the right to share
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).not.toHaveAttribute('disabled');
await SelectRoleCurrentUser.click();
await currentUserRole.click();
await page.getByRole('option', { name: 'Reader' }).click();
await expect(page.getByText('The role has been updated')).toBeVisible();
// Reader does not have the right to share
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await list.click();
await expect(currentUserRole).toBeHidden();
});
test('it checks the delete members', async ({ page, browserName }) => {
@@ -192,43 +175,44 @@ test.describe('Document list members', () => {
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List members card').locator('ul');
const list = page.getByTestId('doc-share-quick-search');
const nameMyself = `user@${browserName}.e2e`;
await expect(list.getByText(nameMyself)).toBeVisible();
const emailMyself = `user@${browserName}.e2e`;
const mySelf = list.getByTestId(`doc-share-member-row-${emailMyself}`);
const mySelfMoreActions = mySelf.getByRole('button', { name: 'more_vert' });
const userOwner = await addNewMember(page, 0, 'Owner');
await expect(list.getByText(userOwner)).toBeVisible();
const userOwnerEmail = await addNewMember(page, 0, 'Owner');
const userOwner = list.getByTestId(
`doc-share-member-row-${userOwnerEmail}`,
);
const userOwnerMoreActions = userOwner.getByRole('button', {
name: 'more_vert',
});
const userReader = await addNewMember(page, 0, 'Reader');
await expect(list.getByText(userReader)).toBeVisible();
const userReaderEmail = await addNewMember(page, 0, 'Reader');
const userReader = list.getByTestId(
`doc-share-member-row-${userReaderEmail}`,
);
const userReaderMoreActions = userReader.getByRole('button', {
name: 'more_vert',
});
await list
.locator('li')
.filter({
hasText: userReader,
})
.getByText('delete')
.click();
await expect(mySelf).toBeVisible();
await expect(userOwner).toBeVisible();
await expect(userReader).toBeVisible();
await expect(list.getByText(userReader)).toBeHidden();
await expect(userOwnerMoreActions).toBeVisible();
await expect(userReaderMoreActions).toBeVisible();
await expect(mySelfMoreActions).toBeVisible();
await list
.locator('li')
.filter({
hasText: nameMyself,
})
.getByText('delete')
.click();
await expect(list.getByText(nameMyself)).toBeHidden();
await userReaderMoreActions.click();
await page.getByRole('option', { name: 'Delete' }).click();
await expect(userReader).toBeHidden();
await mySelfMoreActions.click();
await page.getByRole('option', { name: 'Delete' }).click();
await expect(
page.getByText('The member has been removed from the document').first(),
page.getByText('You do not have permission to perform this action.'),
).toBeVisible();
await expect(
page.getByRole('heading', { name: 'Share', level: 3 }),
).toBeHidden();
});
});

View File

@@ -143,22 +143,22 @@ test.describe('Doc Visibility: Restricted', () => {
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
const inputSearch = page.getByRole('combobox', {
name: 'Find a member to add to the document',
});
const otherBrowser = browsersName.find((b) => b !== browserName);
const username = `user@${otherBrowser}.e2e`;
await inputSearch.fill(username);
await page.getByRole('option', { name: username }).click();
await page.getByTestId(`search-user-row-${username}`).click();
// Choose a role
await page.getByRole('combobox', { name: /Choose a role/ }).click();
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('option', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`User ${username} added to the document.`),
).toBeVisible();
await page.getByRole('button', { name: 'Invite' }).click();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
@@ -434,15 +434,17 @@ test.describe('Doc Visibility: Authenticated', () => {
page.getByText('Read only, you cannot edit this document'),
).toBeVisible();
const shareModal = page.getByLabel('Share modal');
await expect(
shareModal.getByRole('combobox', {
page.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
await expect(shareModal.getByLabel('List members card')).toBeHidden();
const inputSearch = page.getByRole('combobox', {
name: 'Find a member to add to the document',
});
await expect(inputSearch).toBeHidden();
});
test('It checks a authenticated doc in editable mode', async ({
@@ -512,14 +514,15 @@ test.describe('Doc Visibility: Authenticated', () => {
page.getByText('Read only, you cannot edit this document'),
).toBeHidden();
const shareModal = page.getByLabel('Share modal');
await expect(
shareModal.getByRole('combobox', {
page.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
await expect(shareModal.getByLabel('List members card')).toBeHidden();
const inputSearch = page.getByRole('combobox', {
name: 'Find a member to add to the document',
});
await expect(inputSearch).toBeHidden();
});
});

View File

@@ -27,6 +27,7 @@ const StyledButton = styled(Button)`
export interface DropButtonProps {
button: ReactNode;
isOpen?: boolean;
label?: string;
onOpenChange?: (isOpen: boolean) => void;
}
@@ -34,6 +35,7 @@ export const DropButton = ({
button,
isOpen = false,
onOpenChange,
label,
children,
}: PropsWithChildren<DropButtonProps>) => {
const [opacity, setOpacity] = useState(false);
@@ -53,7 +55,7 @@ export const DropButton = ({
return (
<DialogTrigger onOpenChange={onOpenChangeHandler} isOpen={isLocalOpen}>
<StyledButton>{button}</StyledButton>
<StyledButton aria-label={label}>{button}</StyledButton>
<StyledPopover
style={{ opacity: opacity ? 1 : 0 }}
isOpen={isLocalOpen}

View File

@@ -1,7 +1,7 @@
import { PropsWithChildren, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon } from '@/components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
export type DropdownMenuOption = {
@@ -10,13 +10,16 @@ export type DropdownMenuOption = {
testId?: string;
callback?: () => void | Promise<unknown>;
danger?: boolean;
isSelected?: boolean;
disabled?: boolean;
};
export type DropdownMenuProps = {
options: DropdownMenuOption[];
showArrow?: boolean;
label?: string;
arrowCss?: BoxProps['$css'];
topMessage?: string;
};
export const DropdownMenu = ({
@@ -24,6 +27,8 @@ export const DropdownMenu = ({
children,
showArrow = false,
arrowCss,
label,
topMessage,
}: PropsWithChildren<DropdownMenuProps>) => {
const theme = useCunninghamTheme();
const spacings = theme.spacingsTokens();
@@ -34,10 +39,12 @@ export const DropdownMenu = ({
setIsOpen(isOpen);
};
console.log('topMessage', topMessage);
return (
<DropButton
isOpen={isOpen}
onOpenChange={onOpenChange}
label={label}
button={
showArrow ? (
<Box $direction="row" $align="center">
@@ -57,11 +64,23 @@ export const DropdownMenu = ({
)
}
>
<Box>
{options.map((option, index) => {
<Box $maxWidth="320px">
{topMessage && (
<Text
$variation="1000"
$wrap="wrap"
$size="xs"
$weight="bold"
$padding={{ vertical: 'xs', horizontal: 'base' }}
>
{topMessage}
</Text>
)}
{options.map((option) => {
const isDisabled = option.disabled !== undefined && option.disabled;
return (
<BoxButton
role="option"
data-testid={option.testId}
$direction="row"
disabled={isDisabled}
@@ -73,6 +92,7 @@ export const DropdownMenu = ({
}}
key={option.label}
$align="center"
$justify="space-between"
$background={colors['greyscale-000']}
$color={colors['primary-600']}
$padding={{ vertical: 'xs', horizontal: 'base' }}
@@ -81,28 +101,35 @@ export const DropdownMenu = ({
$css={css`
border: none;
font-size: var(--c--theme--font--sizes--sm);
color: var(--c--theme--colors--primary-600);
color: var(--c--theme--colors--greyscale-1000);
font-weight: 500;
cursor: ${isDisabled ? 'not-allowed' : 'pointer'};
user-select: none;
border-bottom: ${index !== options.length - 1
? `1px solid var(--c--theme--colors--greyscale-200)`
: 'none'};
&:hover {
background-color: var(--c--theme--colors--greyscale-050);
}
`}
>
{option.icon && (
<Icon
$size="20px"
$theme={!isDisabled ? 'primary' : 'greyscale'}
$variation={!isDisabled ? '600' : '400'}
iconName={option.icon}
/>
<Box $direction="row" $align="center" $gap={spacings['base']}>
{option.icon && (
<Icon
$size="20px"
$theme="greyscale"
$variation={isDisabled ? '400' : '1000'}
iconName={option.icon}
/>
)}
<Text
$margin={{ top: '-3px' }}
$variation={isDisabled ? '400' : '1000'}
>
{option.label}
</Text>
</Box>
{option.isSelected && (
<Icon iconName="check" $size="20px" $theme="greyscale" />
)}
{option.label}
</BoxButton>
);
})}

View File

@@ -4,11 +4,18 @@ import { Box } from './Box';
import { Icon } from './Icon';
import { Text } from './Text';
export const LoadMoreText = () => {
type LoadMoreTextProps = {
['data-testid']?: string;
};
export const LoadMoreText = ({
'data-testid': dataTestId,
}: LoadMoreTextProps) => {
const { t } = useTranslation();
return (
<Box
data-testid={dataTestId}
$direction="row"
$align="center"
$gap="0.4rem"

View File

@@ -53,7 +53,9 @@ export const QuickSearchInput = ({
<Command.Input
/* eslint-disable-next-line jsx-a11y/no-autofocus */
autoFocus={true}
aria-label={t('Find a member to add to the document')}
value={inputValue}
role="combobox"
placeholder={placeholder ?? t('Search')}
onValueChange={onFilter}
/>

View File

@@ -7,13 +7,8 @@ export const QuickSearchStyle = createGlobalStyle`
background: #ffffff;
border-radius: 12px;
overflow: hidden;
transition: transform 100ms ease;
outline: none;
.dark & {
background: rgba(22, 22, 22, 0.7);
}
}
[cmdk-input] {
@@ -23,9 +18,7 @@ export const QuickSearchStyle = createGlobalStyle`
padding: 8px;
background: white;
outline: none;
color: var(--c--theme--colors--greyscale-1000);
border-radius: 0;
&::placeholder {
@@ -50,17 +43,12 @@ export const QuickSearchStyle = createGlobalStyle`
[cmdk-item] {
content-visibility: auto;
cursor: pointer;
cursor: pointer;
border-radius: var(--c--theme--spacings--xs);
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
/* padding: var(--c--theme--spacings--2xs) ; */
user-select: none;
will-change: background, color;
transition: all 150ms ease;
@@ -89,11 +77,11 @@ export const QuickSearchStyle = createGlobalStyle`
}
[cmdk-list] {
height: 500px;
padding: 0 var(--c--theme--spacings--sm) var(--c--theme--spacings--sm)
var(--c--theme--spacings--sm);
max-height: 700px;
flex:1;
overflow-y: auto;
overscroll-behavior: contain;
transition: 100ms ease;
@@ -161,7 +149,7 @@ export const QuickSearchStyle = createGlobalStyle`
.c__modal__title {
font-size: var(--c--theme--font--sizes--xs);
padding: var(--c--theme--spacings--200W);
padding: var(--c--theme--spacings--base);
margin-bottom: 0;
}
}

View File

@@ -21,11 +21,7 @@ import {
useEditorStore,
usePanelEditorStore,
} from '@/features/docs/doc-editor/';
import {
Doc,
ModalRemoveDoc,
ModalShare,
} from '@/features/docs/doc-management';
import { Doc, ModalRemoveDoc } from '@/features/docs/doc-management';
import { ModalVersion, Versions } from '@/features/docs/doc-versioning';
import { useResponsiveStore } from '@/stores';
@@ -204,9 +200,9 @@ export const DocToolBox = ({ doc, versionId }: DocToolBoxProps) => {
/>
</DropdownMenu>
</Box>
{isModalShareOpen && (
{/* {isModalShareOpen && (
<ModalShare onClose={() => setIsModalShareOpen(false)} doc={doc} />
)}
)} */}
{modalShare.isOpen && (
<DocShareModal doc={doc} onClose={modalShare.onClose} />
)}

View File

@@ -1,3 +1,4 @@
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { DropdownMenu, DropdownMenuOption, Text } from '@/components';
@@ -9,19 +10,26 @@ type Props = {
currentRole: Role;
onSelectRole?: (role: Role) => void;
canUpdate?: boolean;
isLastOwner?: boolean;
isOtherOwner?: boolean;
};
export const DocRoleDropdown = ({
canUpdate = true,
currentRole,
onSelectRole,
isLastOwner,
isOtherOwner,
}: Props) => {
const { transRole, translatedRoles } = useTrans();
const { t } = useTranslation();
const { transRole, translatedRoles, getNotAllowedMessage } = useTrans();
const roles: DropdownMenuOption[] = Object.keys(translatedRoles).map(
(key) => {
return {
label: transRole(key as Role),
callback: () => onSelectRole?.(key as Role),
disabled: isLastOwner || isOtherOwner,
isSelected: currentRole === (key as Role),
};
},
);
@@ -40,7 +48,16 @@ export const DocRoleDropdown = ({
}
return (
<DropdownMenu showArrow={true} options={roles}>
<DropdownMenu
topMessage={getNotAllowedMessage(
canUpdate,
!!isLastOwner,
!!isOtherOwner,
)}
label="doc-role-dropdown"
showArrow={true}
options={roles}
>
<Text
$variation="600"
$css={css`

View File

@@ -117,6 +117,7 @@ export const DocShareAddMemberList = ({
return (
<Box
data-testid="doc-share-add-member-list"
$direction="row"
$padding={spacing.sm}
$align="center"

View File

@@ -17,6 +17,7 @@ export const DocShareAddMemberListItem = ({ user, onRemoveUser }: Props) => {
const fontSize = fontSizesTokens();
return (
<Box
data-testid={`doc-share-add-member-${user.email}`}
$radius={spacing['3xs']}
$direction="row"
$height="fit-content"

View File

@@ -32,7 +32,8 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
};
const { toast } = useToastProvider();
const canUpdate = invitation.abilities.partial_update;
const canUpdate = doc.abilities.accesses_manage;
const { mutate: updateDocInvitation } = useUpdateDocInvitation({
onError: (error) => {
toast(
@@ -78,22 +79,30 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
},
];
return (
<SearchUserRow
showRightOnHover={false}
user={fakeUser}
right={
<Box $direction="row" $align="center">
<DocRoleDropdown
currentRole={invitation.role}
onSelectRole={onUpdate}
canUpdate={canUpdate}
/>
<Box
$width="100%"
data-testid={`doc-share-invitation-row-${invitation.email}`}
>
<SearchUserRow
alwaysShowRight={true}
user={fakeUser}
right={
<Box $direction="row" $align="center">
<DocRoleDropdown
currentRole={invitation.role}
onSelectRole={onUpdate}
canUpdate={canUpdate}
/>
<DropdownMenu options={moreActions}>
<IconOptions $variation="600" />
</DropdownMenu>
</Box>
}
/>
<DropdownMenu
data-testid="doc-share-invitation-more-actions"
options={moreActions}
>
<IconOptions $variation="600" />
</DropdownMenu>
</Box>
}
/>
</Box>
);
};

View File

@@ -28,7 +28,7 @@ export const DocShareMemberItem = ({ doc, access }: Props) => {
const { toast } = useToastProvider();
const { isDesktop } = useResponsiveStore();
const isNotAllowed =
isOtherOwner || isLastOwner || !doc.abilities.accesses_manage;
isOtherOwner || !!isLastOwner || !doc.abilities.accesses_manage;
const { mutate: updateDocAccess } = useUpdateDocAccess({
onError: () => {
@@ -68,24 +68,34 @@ export const DocShareMemberItem = ({ doc, access }: Props) => {
];
return (
<SearchUserRow
alwaysShowRight={true}
user={access.user}
right={
<Box $direction="row" $align="center">
<DocRoleDropdown
currentRole={access.role}
onSelectRole={onUpdate}
canUpdate={!isNotAllowed}
/>
<Box
$width="100%"
data-testid={`doc-share-member-row-${access.user.email}`}
>
<SearchUserRow
alwaysShowRight={true}
user={access.user}
right={
<Box $direction="row" $align="center">
<DocRoleDropdown
currentRole={access.role}
onSelectRole={onUpdate}
canUpdate={doc.abilities.accesses_manage}
isLastOwner={isLastOwner}
isOtherOwner={!!isOtherOwner}
/>
{isDesktop && (
<DropdownMenu options={moreActions}>
<IconOptions $variation="600" />
</DropdownMenu>
)}
</Box>
}
/>
{isDesktop && doc.abilities.accesses_manage && (
<DropdownMenu options={moreActions}>
<IconOptions
data-testid="doc-share-member-more-actions"
$variation="600"
/>
</DropdownMenu>
)}
</Box>
}
/>
</Box>
);
};

View File

@@ -7,6 +7,7 @@ import {
} from '@openfun/cunningham-react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { useDebouncedCallback } from 'use-debounce';
import { Box } from '@/components';
@@ -84,7 +85,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
endActions: membersQuery.hasNextPage
? [
{
content: <LoadMoreText />,
content: <LoadMoreText data-testid="load-more-members" />,
onSelect: () => void membersQuery.fetchNextPage(),
},
]
@@ -102,7 +103,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
endActions: invitationQuery.hasNextPage
? [
{
content: <LoadMoreText />,
content: <LoadMoreText data-testid="load-more-invitations" />,
onSelect: () => void invitationQuery.fetchNextPage(),
},
]
@@ -121,7 +122,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
};
return {
groupName: t('Share with {{count}} users', { count: users.length }),
groupName: t('Search user result', { count: users.length }),
elements: users,
endActions:
isEmail && users.length === 0
@@ -154,6 +155,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
return (
<Modal
isOpen
closeOnClickOutside
size={isDesktop ? ModalSize.LARGE : ModalSize.FULL}
onClose={onClose}
title={
@@ -162,89 +164,125 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
</Box>
}
>
{canShare && selectedUsers.length > 0 && (
<Box $padding={{ horizontal: 'base' }} $margin={{ vertical: '11px' }}>
<DocShareAddMemberList
doc={doc}
selectedUsers={selectedUsers}
onRemoveUser={onRemoveUser}
afterInvite={() => {
setUserQuery('');
setInputValue('');
setSelectedUsers([]);
}}
/>
</Box>
)}
<QuickSearch
data={[]}
onFilter={(str) => {
setInputValue(str);
onFilter(str);
}}
inputValue={inputValue}
showInput={canShare}
loading={searchUsersQuery.isLoading}
renderElement={(user) => <DocShareModalInviteUserRow user={user} />}
onSelect={onSelect}
placeholder={t('Type a name or email')}
<Box
aria-label={t('List members card')}
$direction="column"
$height={isDesktop ? undefined : 'calc(100vh - 50px)'}
$justify="space-between"
>
{!showMemberSection && inputValue !== '' && (
<QuickSearchGroup
group={searchUserData}
onSelect={onSelect}
renderElement={(user) => <DocShareModalInviteUserRow user={user} />}
/>
)}
{showMemberSection && (
<>
{invitationsData.elements.length > 0 && (
<QuickSearchGroup
group={invitationsData}
renderElement={(invitation) => (
<DocShareInvitationItem doc={doc} invitation={invitation} />
)}
/>
)}
<QuickSearchGroup
group={membersData}
renderElement={(access) => (
<DocShareMemberItem doc={doc} access={access} />
)}
/>
</>
)}
</QuickSearch>
<HorizontalSeparator />
<DocVisibility doc={doc} />
<HorizontalSeparator />
<Box $direction="row" $justify="space-between" $padding="base">
<Button
fullWidth={false}
onClick={() => {
navigator.clipboard
.writeText(window.location.href)
.then(() => {
toast(t('Link Copied !'), VariantType.SUCCESS, {
duration: 3000,
});
})
.catch(() => {
toast(t('Failed to copy link'), VariantType.ERROR, {
duration: 3000,
});
});
}}
color="tertiary"
icon={<span className="material-icons">add_link</span>}
<Box
$flex={1}
$css={css`
overflow-y: auto;
[cmdk-list] {
overflow-y: auto;
max-height: ${isDesktop ? '400px' : '100%'};
}
`}
>
{t('Copy link')}
</Button>
<Button onClick={onClose} color="primary">
{t('Ok')}
</Button>
{canShare && selectedUsers.length > 0 && (
<Box
$padding={{ horizontal: 'base' }}
$margin={{ vertical: '11px' }}
>
<DocShareAddMemberList
doc={doc}
selectedUsers={selectedUsers}
onRemoveUser={onRemoveUser}
afterInvite={() => {
setUserQuery('');
setInputValue('');
setSelectedUsers([]);
}}
/>
</Box>
)}
<Box data-testid="doc-share-quick-search">
<QuickSearch
data={[]}
onFilter={(str) => {
setInputValue(str);
onFilter(str);
}}
inputValue={inputValue}
showInput={canShare}
loading={searchUsersQuery.isLoading}
renderElement={(user) => (
<DocShareModalInviteUserRow user={user} />
)}
onSelect={onSelect}
placeholder={t('Type a name or email')}
>
{!showMemberSection && inputValue !== '' && (
<QuickSearchGroup
group={searchUserData}
onSelect={onSelect}
renderElement={(user) => (
<DocShareModalInviteUserRow user={user} />
)}
/>
)}
{showMemberSection && (
<>
{invitationsData.elements.length > 0 && (
<QuickSearchGroup
group={invitationsData}
renderElement={(invitation) => (
<DocShareInvitationItem
doc={doc}
invitation={invitation}
/>
)}
/>
)}
<QuickSearchGroup
group={membersData}
renderElement={(access) => (
<DocShareMemberItem doc={doc} access={access} />
)}
/>
</>
)}
</QuickSearch>
</Box>
</Box>
<Box
$css={css`
flex-shrink: 0;
`}
>
<HorizontalSeparator />
<DocVisibility doc={doc} />
<HorizontalSeparator />
<Box $direction="row" $justify="space-between" $padding="base">
<Button
fullWidth={false}
onClick={() => {
navigator.clipboard
.writeText(window.location.href)
.then(() => {
toast(t('Link Copied !'), VariantType.SUCCESS, {
duration: 3000,
});
})
.catch(() => {
toast(t('Failed to copy link'), VariantType.ERROR, {
duration: 3000,
});
});
}}
color="tertiary"
icon={<span className="material-icons">add_link</span>}
>
{t('Copy link')}
</Button>
<Button onClick={onClose} color="primary">
{t('Ok')}
</Button>
</Box>
</Box>
</Box>
</Modal>
);

View File

@@ -11,25 +11,27 @@ type Props = {
export const DocShareModalInviteUserRow = ({ user }: Props) => {
const { t } = useTranslation();
return (
<SearchUserRow
user={user}
right={
<Box
className="right-hover"
$direction="row"
$align="center"
$css={css`
font-family: Arial, Helvetica, sans-serif;
font-size: var(--c--theme--font--sizes--sm);
color: var(--c--theme--colors--greyscale-400);
`}
>
<Text $theme="primary" $variation="600">
{t('Add')}
</Text>
<Icon $theme="primary" $variation="600" iconName="add" />
</Box>
}
/>
<Box $width="100%" data-testid={`search-user-row-${user.email}`}>
<SearchUserRow
user={user}
right={
<Box
className="right-hover"
$direction="row"
$align="center"
$css={css`
font-family: Arial, Helvetica, sans-serif;
font-size: var(--c--theme--font--sizes--sm);
color: var(--c--theme--colors--greyscale-400);
`}
>
<Text $theme="primary" $variation="600">
{t('Add')}
</Text>
<Icon $theme="primary" $variation="600" iconName="add" />
</Box>
}
/>
</Box>
);
};

View File

@@ -12,11 +12,34 @@ export const useTrans = () => {
[Role.EDITOR]: t('Editor'),
};
const getNotAllowedMessage = (
canUpdate: boolean,
isLastOwner: boolean,
isOtherOwner: boolean,
) => {
if (!canUpdate) {
return undefined;
}
if (isLastOwner) {
return t(
'You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.',
);
}
if (isOtherOwner) {
return t('You cannot update the role or remove other owner.');
}
return undefined;
};
return {
transRole: (role: Role) => {
return translatedRoles[role];
},
untitledDocument: t('Untitled document'),
translatedRoles,
getNotAllowedMessage,
};
};