🛂(frontend) readers and editors can access share modal

Readers and editors of a document can access the share
modal and see the list of members and their roles.
This commit is contained in:
Anthony LC
2024-10-03 17:21:04 +02:00
committed by Anthony LC
parent eee20033ae
commit 0b15ebba71
16 changed files with 360 additions and 104 deletions

View File

@@ -21,6 +21,7 @@ and this project adheres to
- 💄(frontend) error alert closeable on editor #284
- ♻️(backend) Change email content #283
- 🛂(frontend) viewers and editors can access share modal #302
## Fixed

View File

@@ -140,7 +140,13 @@ export const goToGridDoc = async (
export const mockedDocument = async (page: Page, json: object) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (request.method().includes('GET') && !request.url().includes('page=')) {
if (
request.method().includes('GET') &&
!request.url().includes('page=') &&
!request.url().includes('versions') &&
!request.url().includes('accesses') &&
!request.url().includes('invitations')
) {
await route.fulfill({
json: {
id: 'mocked-document-id',
@@ -168,3 +174,82 @@ export const mockedDocument = async (page: Page, json: object) => {
}
});
};
export const mockedInvitations = async (page: Page, json?: object) => {
await page.route('**/invitations/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
request.url().includes('invitations') &&
request.url().includes('page=')
) {
await route.fulfill({
json: {
count: 1,
next: null,
previous: null,
results: [
{
id: '120ec765-43af-4602-83eb-7f4e1224548a',
abilities: {
destroy: true,
update: true,
partial_update: true,
retrieve: true,
},
created_at: '2024-10-03T12:19:26.107687Z',
email: 'test@invitation.test',
document: '4888c328-8406-4412-9b0b-c0ba5b9e5fb6',
role: 'editor',
issuer: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
is_expired: false,
...json,
},
],
},
});
} else {
await route.continue();
}
});
};
export const mockedAccesses = async (page: Page, json?: object) => {
await page.route('**/accesses/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
request.url().includes('accesses') &&
request.url().includes('page=')
) {
await route.fulfill({
json: {
count: 1,
next: null,
previous: null,
results: [
{
id: 'bc8bbbc5-a635-4f65-9817-fd1e9ec8ef87',
user: {
id: 'b4a21bb3-722e-426c-9f78-9d190eda641c',
email: 'test@accesses.test',
},
team: '',
role: 'reader',
abilities: {
destroy: true,
update: true,
partial_update: true,
retrieve: true,
set_role_to: ['administrator', 'editor'],
},
...json,
},
],
},
});
} else {
await route.continue();
}
});
};

View File

@@ -1,6 +1,12 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc, mockedDocument } from './common';
import {
createDoc,
goToGridDoc,
mockedAccesses,
mockedDocument,
mockedInvitations,
} from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -182,20 +188,55 @@ test.describe('Doc Header', () => {
},
});
await mockedInvitations(page);
await mockedAccesses(page);
await goToGridDoc(page);
await expect(
page.locator('h2').getByText('Mocked document'),
).toHaveAttribute('contenteditable');
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share modal');
await expect(shareModal.getByLabel('Doc private')).toBeEnabled();
await expect(shareModal.getByText('Search by email')).toBeVisible();
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(
invitationCard.getByText('test@invitation.test'),
).toBeVisible();
await expect(
invitationCard.getByRole('combobox', { name: 'Role' }),
).toBeEnabled();
await expect(
invitationCard.getByRole('button', {
name: 'delete',
}),
).toBeEnabled();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(
memberCard.getByRole('combobox', { name: 'Role' }),
).toBeEnabled();
await expect(
memberCard.getByRole('button', {
name: 'delete',
}),
).toBeEnabled();
});
test('it checks the options available if editor', async ({ page }) => {
@@ -213,20 +254,62 @@ test.describe('Doc Header', () => {
},
});
await mockedInvitations(page, {
abilities: {
destroy: false,
update: false,
partial_update: false,
retrieve: true,
},
});
await mockedAccesses(page);
await goToGridDoc(page);
await expect(
page.locator('h2').getByText('Mocked document'),
).toHaveAttribute('contenteditable');
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share modal');
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
await expect(shareModal.getByText('Search by email')).toBeHidden();
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(
invitationCard.getByText('test@invitation.test'),
).toBeVisible();
await expect(
invitationCard.getByRole('combobox', { name: 'Role' }),
).toHaveAttribute('disabled');
await expect(
invitationCard.getByRole('button', {
name: 'delete',
}),
).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(
memberCard.getByRole('combobox', { name: 'Role' }),
).toHaveAttribute('disabled');
await expect(
memberCard.getByRole('button', {
name: 'delete',
}),
).toBeHidden();
});
test('it checks the options available if reader', async ({ page }) => {
@@ -244,20 +327,61 @@ test.describe('Doc Header', () => {
},
});
await mockedInvitations(page, {
abilities: {
destroy: false,
update: false,
partial_update: false,
retrieve: true,
},
});
await mockedAccesses(page);
await goToGridDoc(page);
await expect(
page.locator('h2').getByText('Mocked document'),
).not.toHaveAttribute('contenteditable');
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share modal');
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
await expect(shareModal.getByText('Search by email')).toBeHidden();
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(
invitationCard.getByText('test@invitation.test'),
).toBeVisible();
await expect(
invitationCard.getByRole('combobox', { name: 'Role' }),
).toHaveAttribute('disabled');
await expect(
invitationCard.getByRole('button', {
name: 'delete',
}),
).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(
memberCard.getByRole('combobox', { name: 'Role' }),
).toHaveAttribute('disabled');
await expect(
memberCard.getByRole('button', {
name: 'delete',
}),
).toBeHidden();
});
});

View File

@@ -106,15 +106,17 @@ test.describe('Document list members', () => {
await page.getByRole('option', { name: 'Administrator' }).click();
await expect(page.getByText('The role has been updated')).toBeVisible();
const shareModal = page.getByLabel('Share modal');
// Admin still have the right to share
await expect(page.locator('h3').getByText('Share')).toBeVisible();
await expect(shareModal.getByLabel('Doc private')).toBeEnabled();
await SelectRoleCurrentUser.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(page.locator('h3').getByText('Share')).toBeHidden();
await expect(shareModal.getByLabel('Doc private')).toBeDisabled();
});
test('it checks the delete members', async ({ page, browserName }) => {

View File

@@ -33,6 +33,7 @@ export interface BoxProps {
$padding?: MarginPadding;
$position?: CSSProperties['position'];
$radius?: CSSProperties['borderRadius'];
$shrink?: CSSProperties['flexShrink'];
$transition?: CSSProperties['transition'];
$width?: CSSProperties['width'];
$wrap?: CSSProperties['flexWrap'];
@@ -68,6 +69,7 @@ export const Box = styled('div')<BoxProps>`
${({ $padding }) => $padding && stylesPadding($padding)}
${({ $position }) => $position && `position: ${$position};`}
${({ $radius }) => $radius && `border-radius: ${$radius};`}
${({ $shrink }) => $shrink && `flex-shrink: ${$shrink};`}
${({ $transition }) => $transition && `transition: ${$transition};`}
${({ $width }) => $width && `width: ${$width};`}
${({ $wrap }) => $wrap && `flex-wrap: ${$wrap};`}

View File

@@ -150,6 +150,12 @@ input:-webkit-autofill:focus {
border-color: var(--c--components--forms-select--border-color-disabled-hover);
}
.c__select--disabled .c__select__wrapper label,
.c__select--disabled .c__select__wrapper input,
.c__select--disabled .c__select__wrapper {
cursor: not-allowed;
}
.c__select__menu__item {
transition: all var(--c--theme--transitions--duration)
var(--c--theme--transitions--ease-out);
@@ -313,6 +319,14 @@ input:-webkit-autofill:focus {
font-size: var(--c--components--forms-checkbox--text--size);
}
.c__checkbox.c__checkbox--disabled .c__field__text {
color: var(--c--theme--colors--greyscale-600);
}
.c__switch.c__checkbox--disabled .c__switch__rail {
cursor: not-allowed;
}
/**
* Button
*/

View File

@@ -31,15 +31,13 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
$align="center"
$gap="1rem"
>
{doc.abilities.manage_accesses && (
<Button
onClick={() => {
setIsModalShareOpen(true);
}}
>
{t('Share')}
</Button>
)}
<Button
onClick={() => {
setIsModalShareOpen(true);
}}
>
{t('Share')}
</Button>
<DropButton
button={
<IconOptions

View File

@@ -44,7 +44,7 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
$align="center"
$justify="space-between"
>
<Box $direction="row" $gap="1rem">
<Box $direction="row" $gap="1rem" $align="center">
<IconBG iconName="public" $margin="none" />
<Switch
label={t(docPublic ? 'Doc public' : 'Doc private')}

View File

@@ -1,5 +1,4 @@
import { t } from 'i18next';
import { useEffect } from 'react';
import { createGlobalStyle } from 'styled-components';
import { Box, Card, SideModal, Text } from '@/components';
@@ -30,12 +29,6 @@ interface ModalShareProps {
}
export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
useEffect(() => {
if (!doc.abilities.manage_accesses) {
onClose();
}
}, [doc.abilities.manage_accesses, onClose]);
return (
<>
<ModalShareStyle />
@@ -47,29 +40,40 @@ export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
width="70vw"
$css="min-width: 320px;max-width: 777px;"
>
<Card
$direction="row"
$align="center"
$margin={{ horizontal: 'tiny', top: 'none', bottom: 'big' }}
$padding="tiny"
$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 aria-label={t('Share modal')}>
<Box $shrink="0">
<Card
$direction="row"
$align="center"
$margin={{ horizontal: 'tiny', top: 'none', bottom: 'big' }}
$padding="tiny"
$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>
<DocVisibility doc={doc} />
{doc.abilities.manage_accesses && (
<AddMembers
doc={doc}
currentRole={currentDocRole(doc.abilities)}
/>
)}
</Box>
</Card>
<DocVisibility doc={doc} />
<AddMembers doc={doc} currentRole={currentDocRole(doc.abilities)} />
<InvitationList doc={doc} />
<MemberList doc={doc} />
<Box $minHeight="0">
<InvitationList doc={doc} />
<MemberList doc={doc} />
</Box>
</Box>
</SideModal>
</>
);

View File

@@ -93,6 +93,7 @@ export const TableContent = ({ doc, headings }: TableContentProps) => {
block: 'start',
});
}}
$align="start"
>
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
{t('Back to top')}
@@ -110,6 +111,7 @@ export const TableContent = ({ doc, headings }: TableContentProps) => {
block: 'start',
});
}}
$align="start"
>
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
{t('Go to bottom')}

View File

@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next';
import { Box, IconBG, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Role } from '@/features/docs/doc-management';
import { Doc, Role } from '@/features/docs/doc-management';
import { ChooseRole } from '@/features/docs/members/members-add/';
import { useDeleteDocInvitation, useUpdateDocInvitation } from '../api';
@@ -19,11 +19,11 @@ interface InvitationItemProps {
role: Role;
currentRole: Role;
invitation: Invitation;
docId: string;
doc: Doc;
}
export const InvitationItem = ({
docId,
doc,
role,
invitation,
currentRole,
@@ -95,29 +95,34 @@ export const InvitationItem = ({
setRole={(role) => {
setLocalRole(role);
updateDocInvitation({
docId,
docId: doc.id,
invitationId: invitation.id,
role,
});
}}
/>
</Box>
<Button
color="tertiary-text"
icon={
<Text
$isMaterialIcon
$theme={!canDelete ? 'greyscale' : 'primary'}
$variation={!canDelete ? '500' : 'text'}
>
delete
</Text>
}
disabled={!canDelete}
onClick={() =>
removeDocInvitation({ docId, invitationId: invitation.id })
}
/>
{doc.abilities.manage_accesses && (
<Button
color="tertiary-text"
icon={
<Text
$isMaterialIcon
$theme={!canDelete ? 'greyscale' : 'primary'}
$variation={!canDelete ? '500' : 'text'}
>
delete
</Text>
}
disabled={!canDelete}
onClick={() =>
removeDocInvitation({
docId: doc.id,
invitationId: invitation.id,
})
}
/>
)}
</Box>
</Box>
</Box>

View File

@@ -58,7 +58,7 @@ const InvitationListState = ({
<InvitationItem
invitation={invitation}
role={invitation.role}
docId={doc.id}
doc={doc}
currentRole={currentDocRole(doc.abilities)}
/>
</Box>
@@ -97,9 +97,9 @@ export const InvitationList = ({ doc }: InvitationListProps) => {
return (
<Card
$margin="tiny"
$padding="tiny"
$maxHeight="40%"
$overflow="auto"
$maxHeight="50vh"
$padding="tiny"
aria-label={t('List invitation card')}
>
<Box ref={containerRef} $overflow="auto">
@@ -111,8 +111,9 @@ export const InvitationList = ({ doc }: InvitationListProps) => {
}}
scrollContainer={containerRef.current}
as="ul"
className="p-0 mt-0"
role="listbox"
$padding="none"
$margin="none"
>
<InvitationListState
isLoading={isLoading}

View File

@@ -153,14 +153,14 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
doc={doc}
setSelectedUsers={setSelectedUsers}
selectedUsers={selectedUsers}
disabled={isPending}
disabled={isPending || !doc.abilities.manage_accesses}
/>
</Box>
<Box $css="flex: auto;">
<ChooseRole
key={resetKey}
currentRole={currentRole}
disabled={isPending}
disabled={isPending || !doc.abilities.manage_accesses}
setRole={setSelectedRole}
/>
</Box>
@@ -168,7 +168,12 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
<Box $align="center" $justify="center" $css="flex: auto;">
<Button
color="primary"
disabled={!selectedUsers.length || isPending || !selectedRole}
disabled={
!selectedUsers.length ||
isPending ||
!selectedRole ||
!doc.abilities.manage_accesses
}
onClick={() => void handleValidate()}
style={{ height: '100%', maxHeight: '55px' }}
>

View File

@@ -120,12 +120,17 @@ export const SearchUsers = ({
placeholder: (base) => ({
...base,
fontSize: '14px',
color: colorsTokens()['primary-600'],
color: disabled
? colorsTokens()['greyscale-300']
: colorsTokens()['primary-600'],
}),
control: (base) => ({
...base,
minHeight: '45px',
borderColor: colorsTokens()['primary-600'],
borderColor: disabled
? colorsTokens()['greyscale-300']
: colorsTokens()['primary-600'],
backgroundColor: 'white',
}),
input: (base) => ({
...base,

View File

@@ -10,7 +10,7 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, IconBG, Text, TextErrors } from '@/components';
import { Access, Role } from '@/features/docs/doc-management';
import { Access, Doc, Role } from '@/features/docs/doc-management';
import { ChooseRole } from '@/features/docs/members/members-add/';
import { useDeleteDocAccess, useUpdateDocAccess } from '../api';
@@ -20,11 +20,11 @@ interface MemberItemProps {
role: Role;
currentRole: Role;
access: Access;
docId: string;
doc: Doc;
}
export const MemberItem = ({
docId,
doc,
role,
access,
currentRole,
@@ -58,7 +58,8 @@ export const MemberItem = ({
},
});
const isNotAllowed = isOtherOwner || isLastOwner;
const isNotAllowed =
isOtherOwner || isLastOwner || !doc.abilities.manage_accesses;
if (!access.user) {
return (
@@ -91,34 +92,40 @@ export const MemberItem = ({
setRole={(role) => {
setLocalRole(role);
updateDocAccess({
docId,
docId: doc.id,
accessId: access.id,
role,
});
}}
/>
</Box>
<Button
color="tertiary-text"
icon={
<Text
$isMaterialIcon
$theme={isNotAllowed ? 'greyscale' : 'primary'}
$variation={isNotAllowed ? '500' : 'text'}
>
delete
</Text>
}
disabled={isNotAllowed}
onClick={() => removeDocAccess({ docId, accessId: access.id })}
/>
{doc.abilities.manage_accesses && (
<Button
color="tertiary-text"
icon={
<Text
$isMaterialIcon
$theme={isNotAllowed ? 'greyscale' : 'primary'}
$variation={isNotAllowed ? '500' : 'text'}
>
delete
</Text>
}
disabled={isNotAllowed}
onClick={() =>
removeDocAccess({ docId: doc.id, accessId: access.id })
}
/>
)}
</Box>
</Box>
</Box>
{(errorUpdate || errorDelete) && (
<TextErrors causes={errorUpdate?.cause || errorDelete?.cause} />
<Box $margin={{ top: 'tiny' }}>
<TextErrors causes={errorUpdate?.cause || errorDelete?.cause} />
</Box>
)}
{(isLastOwner || isOtherOwner) && (
{(isLastOwner || isOtherOwner) && doc.abilities.manage_accesses && (
<Box $margin={{ top: 'tiny' }}>
<Alert
canClose={false}

View File

@@ -57,7 +57,7 @@ const MemberListState = ({
<MemberItem
access={access}
role={access.role}
docId={doc.id}
doc={doc}
currentRole={currentDocRole(doc.abilities)}
/>
</Box>
@@ -92,9 +92,9 @@ export const MemberList = ({ doc }: MemberListProps) => {
return (
<Card
$margin="tiny"
$padding="tiny"
$maxHeight="69%"
$overflow="auto"
$maxHeight="80vh"
$padding="tiny"
aria-label={t('List members card')}
>
<Box ref={containerRef} $overflow="auto">
@@ -106,7 +106,8 @@ export const MemberList = ({ doc }: MemberListProps) => {
}}
scrollContainer={containerRef.current}
as="ul"
className="p-0 mt-0"
$padding="none"
$margin="none"
role="listbox"
>
<MemberListState