🐛(frontend) hide the sharing method when you don't have the rights

- Added a new hook `useCopyDocLink` to handle copying document links to
the clipboard with success/error notifications.
- Updated the `DocToolBox`, `DocsGridActions`, and `DocShareModal`
components to utilize the new copy link feature.
- Enhanced tests to verify the functionality of the copy link button in
various scenarios.
- Adjusted visibility checks for sharing options based on user access
rights.
This commit is contained in:
Nathan Panchout
2025-01-15 16:03:34 +01:00
committed by Anthony LC
parent de8dea20d5
commit 282200ac3d
15 changed files with 297 additions and 130 deletions

View File

@@ -9,6 +9,10 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
## Fixed
- 🐛(frontend) share modal is shown when you don't have the abilities #557
## [2.0.0] - 2025-01-13 ## [2.0.0] - 2025-01-13
## Added ## Added

View File

@@ -47,6 +47,7 @@ test.describe('Doc Header', () => {
versions_list: true, versions_list: true,
versions_retrieve: true, versions_retrieve: true,
accesses_manage: true, accesses_manage: true,
accesses_view: true,
update: true, update: true,
partial_update: true, partial_update: true,
retrieve: true, retrieve: true,
@@ -396,6 +397,28 @@ test.describe('Doc Header', () => {
const clipboardContent = await handle.jsonValue(); const clipboardContent = await handle.jsonValue();
expect(clipboardContent.trim()).toBe(`<h1>Hello World</h1><p></p>`); expect(clipboardContent.trim()).toBe(`<h1>Hello World</h1><p></p>`);
}); });
test('it checks the copy link button', async ({ page }) => {
await mockedDocument(page, {
abilities: {
destroy: false, // Means owner
link_configuration: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
accesses_manage: false,
accesses_view: false,
update: true,
partial_update: true,
retrieve: true,
},
});
await goToGridDoc(page);
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
});
}); });
test.describe('Documents Header mobile', () => { test.describe('Documents Header mobile', () => {
@@ -405,6 +428,45 @@ test.describe('Documents Header mobile', () => {
await page.goto('/'); await page.goto('/');
}); });
test('it checks the copy link button', async ({ page, browserName }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(
browserName === 'webkit',
'navigator.clipboard is not working with webkit and playwright',
);
await mockedDocument(page, {
abilities: {
destroy: false,
link_configuration: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
accesses_manage: false,
accesses_view: false,
update: true,
partial_update: true,
retrieve: true,
},
});
await goToGridDoc(page);
await expect(page.getByRole('button', { name: 'Copy link' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
// Test that clipboard is in HTML format
const handle = await page.evaluateHandle(() =>
navigator.clipboard.readText(),
);
const clipboardContent = await handle.jsonValue();
const origin = await page.evaluate(() => window.location.origin);
expect(clipboardContent.trim()).toMatch(
`${origin}/docs/mocked-document-id/`,
);
});
test('it checks the close button on Share modal', async ({ page }) => { test('it checks the close button on Share modal', async ({ page }) => {
await mockedDocument(page, { await mockedDocument(page, {
abilities: { abilities: {
@@ -414,6 +476,7 @@ test.describe('Documents Header mobile', () => {
versions_list: true, versions_list: true,
versions_retrieve: true, versions_retrieve: true,
accesses_manage: true, accesses_manage: true,
accesses_view: true,
update: true, update: true,
partial_update: true, partial_update: true,
retrieve: true, retrieve: true,

View File

@@ -79,7 +79,7 @@ test.describe('Document create member', () => {
await expect(quickSearchContent.getByText(email).first()).toBeVisible(); await expect(quickSearchContent.getByText(email).first()).toBeVisible();
// Check user added // Check user added
await expect(page.getByText('Share with 3 users')).toBeVisible(); await expect(page.getByText('Share with 2 users')).toBeVisible();
await expect( await expect(
quickSearchContent.getByText(users[0].full_name).first(), quickSearchContent.getByText(users[0].full_name).first(),
).toBeVisible(); ).toBeVisible();

View File

@@ -413,14 +413,8 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.goto(urlDoc); await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible(); await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click(); await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
await expect(selectVisibility).toBeHidden();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
await expect(inputSearch).toBeHidden();
}); });
test('It checks a authenticated doc in editable mode', async ({ test('It checks a authenticated doc in editable mode', async ({
@@ -474,13 +468,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.goto(urlDoc); await page.goto(urlDoc);
await verifyDocName(page, docTitle); await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click(); await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
await expect(selectVisibility).toBeHidden();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
await expect(inputSearch).toBeHidden();
}); });
}); });

View File

@@ -16,10 +16,13 @@ import {
Icon, Icon,
IconOptions, IconOptions,
} from '@/components'; } from '@/components';
import { useAuthStore } from '@/core';
import { useCunninghamTheme } from '@/cunningham'; import { useCunninghamTheme } from '@/cunningham';
import { useEditorStore } from '@/features/docs/doc-editor/'; import { useEditorStore } from '@/features/docs/doc-editor/';
import { Doc, ModalRemoveDoc } from '@/features/docs/doc-management'; import {
Doc,
ModalRemoveDoc,
useCopyDocLink,
} from '@/features/docs/doc-management';
import { DocShareModal } from '@/features/docs/doc-share'; import { DocShareModal } from '@/features/docs/doc-share';
import { import {
KEY_LIST_DOC_VERSIONS, KEY_LIST_DOC_VERSIONS,
@@ -37,6 +40,9 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const hasAccesses = doc.nb_accesses > 1; const hasAccesses = doc.nb_accesses > 1;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const copyDocLink = useCopyDocLink(doc.id);
const { spacingsTokens, colorsTokens } = useCunninghamTheme(); const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const spacings = spacingsTokens(); const spacings = spacingsTokens();
@@ -48,18 +54,24 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const modalShare = useModal(); const modalShare = useModal();
const { isSmallMobile, isDesktop } = useResponsiveStore(); const { isSmallMobile, isDesktop } = useResponsiveStore();
const { authenticated } = useAuthStore();
const { editor } = useEditorStore(); const { editor } = useEditorStore();
const { toast } = useToastProvider(); const { toast } = useToastProvider();
const canViewAccesses = doc.abilities.accesses_view;
const options: DropdownMenuOption[] = [ const options: DropdownMenuOption[] = [
...(isSmallMobile ...(isSmallMobile
? [ ? [
{ {
label: t('Share'), label: canViewAccesses ? t('Share') : t('Copy link'),
icon: 'upload', icon: canViewAccesses ? 'group' : 'link',
callback: () => { callback: () => {
modalShare.open(); if (canViewAccesses) {
modalShare.open();
return;
}
copyDocLink();
}, },
}, },
{ {
@@ -153,7 +165,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
$margin={{ left: 'auto' }} $margin={{ left: 'auto' }}
$gap={spacings['2xs']} $gap={spacings['2xs']}
> >
{authenticated && !isSmallMobile && ( {canViewAccesses && !isSmallMobile && (
<> <>
{!hasAccesses && ( {!hasAccesses && (
<Button <Button
@@ -187,12 +199,23 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
}} }}
size={isSmallMobile ? 'small' : 'medium'} size={isSmallMobile ? 'small' : 'medium'}
> >
{doc.nb_accesses} {doc.nb_accesses - 1}
</Button> </Button>
</Box> </Box>
)} )}
</> </>
)} )}
{!canViewAccesses && !isSmallMobile && (
<Button
color="tertiary-text"
onClick={() => {
copyDocLink();
}}
size={isSmallMobile ? 'small' : 'medium'}
>
{t('Copy link')}
</Button>
)}
{!isSmallMobile && ( {!isSmallMobile && (
<Button <Button
color="tertiary-text" color="tertiary-text"

View File

@@ -1,2 +1,3 @@
export * from './useCollaboration'; export * from './useCollaboration';
export * from './useTrans'; export * from './useTrans';
export * from './useCopyDocLink';

View File

@@ -0,0 +1,19 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useClipboard } from '@/hook';
import { Doc } from '../types';
export const useCopyDocLink = (docId: Doc['id']) => {
const { t } = useTranslation();
const copyToClipboard = useClipboard();
return useCallback(() => {
copyToClipboard(
`${window.location.origin}/docs/${docId}/`,
t('Link Copied !'),
t('Failed to copy link'),
);
}, [copyToClipboard, docId, t]);
};

View File

@@ -56,8 +56,9 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
const [userQuery, setUserQuery] = useState(''); const [userQuery, setUserQuery] = useState('');
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [listHeight, setListHeight] = useState<string>('400px'); const [listHeight, setListHeight] = useState<string>('0px');
const canShare = doc.abilities.accesses_manage; const canShare = doc.abilities.accesses_manage;
const canViewAccesses = doc.abilities.accesses_view;
const showMemberSection = inputValue === '' && selectedUsers.length === 0; const showMemberSection = inputValue === '' && selectedUsers.length === 0;
const showFooter = selectedUsers.length === 0 && !inputValue; const showFooter = selectedUsers.length === 0 && !inputValue;
@@ -94,7 +95,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
count === 1 count === 1
? t('Document owner') ? t('Document owner')
: t('Share with {{count}} users', { : t('Share with {{count}} users', {
count, count: count - 1,
}), }),
elements: members, elements: members,
endActions: membersQuery.hasNextPage endActions: membersQuery.hasNextPage
@@ -168,6 +169,10 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
}; };
const handleRef = (node: HTMLDivElement) => { const handleRef = (node: HTMLDivElement) => {
if (!canViewAccesses) {
setListHeight('0px');
return;
}
const inputHeight = canShare ? 70 : 0; const inputHeight = canShare ? 70 : 0;
const marginTop = 11; const marginTop = 11;
const footerHeight = node?.clientHeight ?? 0; const footerHeight = node?.clientHeight ?? 0;
@@ -191,7 +196,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
<ShareModalStyle /> <ShareModalStyle />
<Box <Box
aria-label={t('Share modal')} aria-label={t('Share modal')}
$height={modalContentHeight} $height={canViewAccesses ? modalContentHeight : 'auto'}
$overflow="hidden" $overflow="hidden"
$justify="space-between" $justify="space-between"
> >
@@ -235,39 +240,43 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
loading={searchUsersQuery.isLoading} loading={searchUsersQuery.isLoading}
placeholder={t('Type a name or email')} placeholder={t('Type a name or email')}
> >
{!showMemberSection && inputValue !== '' && ( {canViewAccesses && (
<QuickSearchGroup
group={searchUserData}
onSelect={onSelect}
renderElement={(user) => (
<DocShareModalInviteUserRow user={user} />
)}
/>
)}
{showMemberSection && (
<> <>
{invitationsData.elements.length > 0 && ( {!showMemberSection && inputValue !== '' && (
<Box aria-label={t('List invitation card')}>
<QuickSearchGroup
group={invitationsData}
renderElement={(invitation) => (
<DocShareInvitationItem
doc={doc}
invitation={invitation}
/>
)}
/>
</Box>
)}
<Box aria-label={t('List members card')}>
<QuickSearchGroup <QuickSearchGroup
group={membersData} group={searchUserData}
renderElement={(access) => ( onSelect={onSelect}
<DocShareMemberItem doc={doc} access={access} /> renderElement={(user) => (
<DocShareModalInviteUserRow user={user} />
)} )}
/> />
</Box> )}
{showMemberSection && (
<>
{invitationsData.elements.length > 0 && (
<Box aria-label={t('List invitation card')}>
<QuickSearchGroup
group={invitationsData}
renderElement={(invitation) => (
<DocShareInvitationItem
doc={doc}
invitation={invitation}
/>
)}
/>
</Box>
)}
<Box aria-label={t('List members card')}>
<QuickSearchGroup
group={membersData}
renderElement={(access) => (
<DocShareMemberItem doc={doc} access={access} />
)}
/>
</Box>
</>
)}
</> </>
)} )}
</QuickSearch> </QuickSearch>

View File

@@ -1,13 +1,9 @@
import { import { Button } from '@openfun/cunningham-react';
Button,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { css } from 'styled-components'; import { css } from 'styled-components';
import { Box, HorizontalSeparator } from '@/components'; import { Box, HorizontalSeparator } from '@/components';
import { Doc } from '@/features/docs'; import { Doc, useCopyDocLink } from '@/features/docs';
import { DocVisibility } from './DocVisibility'; import { DocVisibility } from './DocVisibility';
@@ -18,7 +14,8 @@ type Props = {
export const DocShareModalFooter = ({ doc, onClose }: Props) => { export const DocShareModalFooter = ({ doc, onClose }: Props) => {
const canShare = doc.abilities.accesses_manage; const canShare = doc.abilities.accesses_manage;
const { toast } = useToastProvider();
const copyDocLink = useCopyDocLink(doc.id);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Box <Box
@@ -41,18 +38,7 @@ export const DocShareModalFooter = ({ doc, onClose }: Props) => {
<Button <Button
fullWidth={false} fullWidth={false}
onClick={() => { onClick={() => {
navigator.clipboard copyDocLink();
.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" color="tertiary"
icon={<span className="material-icons">add_link</span>} icon={<span className="material-icons">add_link</span>}

View File

@@ -8,7 +8,7 @@ import { useResponsiveStore } from '@/stores';
const leftPaddingMap: { [key: number]: string } = { const leftPaddingMap: { [key: number]: string } = {
3: '1.5rem', 3: '1.5rem',
2: '0.9rem', 2: '0.9rem',
1: '0.3', 1: '0.3rem',
}; };
export type HeadingsHighlight = { export type HeadingsHighlight = {

View File

@@ -6,6 +6,7 @@ import {
Doc, Doc,
KEY_LIST_DOC, KEY_LIST_DOC,
ModalRemoveDoc, ModalRemoveDoc,
useCopyDocLink,
useCreateFavoriteDoc, useCreateFavoriteDoc,
useDeleteFavoriteDoc, useDeleteFavoriteDoc,
} from '@/features/docs/doc-management'; } from '@/features/docs/doc-management';
@@ -20,6 +21,11 @@ export const DocsGridActions = ({
openShareModal, openShareModal,
}: DocsGridActionsProps) => { }: DocsGridActionsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const copyDocLink = useCopyDocLink(doc.id);
const canViewAccesses = doc.abilities.accesses_view;
const deleteModal = useModal(); const deleteModal = useModal();
const removeFavoriteDoc = useDeleteFavoriteDoc({ const removeFavoriteDoc = useDeleteFavoriteDoc({
@@ -43,9 +49,16 @@ export const DocsGridActions = ({
testId: `docs-grid-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`, testId: `docs-grid-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
}, },
{ {
label: t('Share'), label: canViewAccesses ? t('Share') : t('Copy link'),
icon: 'group', icon: canViewAccesses ? 'group' : 'link',
callback: () => openShareModal?.(), callback: () => {
if (canViewAccesses) {
openShareModal?.();
return;
}
copyDocLink();
},
testId: `docs-grid-actions-share-${doc.id}`, testId: `docs-grid-actions-share-${doc.id}`,
}, },

View File

@@ -1,13 +1,14 @@
import { Button, useModal } from '@openfun/cunningham-react'; import { useModal } from '@openfun/cunningham-react';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { css } from 'styled-components'; import { css } from 'styled-components';
import { Box, Icon, StyledLink, Text } from '@/components'; import { Box, StyledLink, Text } from '@/components';
import { Doc, LinkReach } from '@/features/docs/doc-management'; import { Doc } from '@/features/docs/doc-management';
import { DocShareModal } from '@/features/docs/doc-share'; import { DocShareModal } from '@/features/docs/doc-share';
import { useResponsiveStore } from '@/stores'; import { useResponsiveStore } from '@/stores';
import { DocsGridActions } from './DocsGridActions'; import { DocsGridActions } from './DocsGridActions';
import { DocsGridItemSharedButton } from './DocsGridItemSharedButton';
import { SimpleDocItem } from './SimpleDocItem'; import { SimpleDocItem } from './SimpleDocItem';
type DocsGridItemProps = { type DocsGridItemProps = {
@@ -17,11 +18,6 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
const { isDesktop } = useResponsiveStore(); const { isDesktop } = useResponsiveStore();
const shareModal = useModal(); const shareModal = useModal();
const isPublic = doc.link_reach === LinkReach.PUBLIC;
const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED;
const isRestricted = doc.link_reach === LinkReach.RESTRICTED;
const sharedCount = doc.nb_accesses - 1;
const isShared = sharedCount > 0;
const handleShareClick = () => { const handleShareClick = () => {
shareModal.open(); shareModal.open();
@@ -70,49 +66,13 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
$justify="flex-end" $justify="flex-end"
$gap="32px" $gap="32px"
> >
{isDesktop && isPublic && ( {isDesktop && (
<Button <DocsGridItemSharedButton
onClick={(event) => { doc={doc}
event.preventDefault(); handleClick={handleShareClick}
event.stopPropagation(); />
handleShareClick();
}}
size="nano"
fullWidth
icon={<Icon $variation="000" iconName="public" />}
>
{isShared ? sharedCount : undefined}
</Button>
)}
{isDesktop && !isPublic && isRestricted && isShared && (
<Button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleShareClick();
}}
fullWidth
color="tertiary"
size="nano"
icon={<Icon $variation="800" $theme="primary" iconName="group" />}
>
{sharedCount}
</Button>
)}
{isDesktop && !isPublic && isAuthenticated && (
<Button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleShareClick();
}}
fullWidth
size="nano"
icon={<Icon $variation="000" iconName="corporate_fare" />}
>
{sharedCount}
</Button>
)} )}
<DocsGridActions doc={doc} openShareModal={handleShareClick} /> <DocsGridActions doc={doc} openShareModal={handleShareClick} />
</Box> </Box>
</Box> </Box>

View File

@@ -0,0 +1,66 @@
import { Button } from '@openfun/cunningham-react';
import { useMemo } from 'react';
import { Box, Icon } from '@/components';
import { Doc, LinkReach } from '../../doc-management';
type Props = {
doc: Doc;
handleClick: () => void;
};
export const DocsGridItemSharedButton = ({ doc, handleClick }: Props) => {
const isPublic = doc.link_reach === LinkReach.PUBLIC;
const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED;
const isRestricted = doc.link_reach === LinkReach.RESTRICTED;
const sharedCount = doc.nb_accesses - 1;
const isShared = sharedCount > 0;
const icon = useMemo(() => {
if (isPublic) {
return 'public';
}
if (isAuthenticated) {
return 'corporate_fare';
}
if (isRestricted) {
return 'group';
}
return undefined;
}, [isPublic, isAuthenticated, isRestricted]);
if (!icon) {
return null;
}
if (!doc.abilities.accesses_view) {
return (
<Box $align="center" $width="100%">
<Icon $variation="800" $theme="primary" iconName={icon} />
</Box>
);
}
return (
<Button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleClick();
}}
fullWidth
color={isRestricted ? 'tertiary' : 'primary'}
size="nano"
icon={
<Icon
$variation={isRestricted ? '800' : '000'}
$theme={isRestricted ? 'primary' : 'greyscale'}
iconName={icon}
/>
}
>
{isShared ? sharedCount : undefined}
</Button>
);
};

View File

@@ -1 +1,2 @@
export * from './useDate'; export * from './useDate';
export * from './useClipboard';

View File

@@ -0,0 +1,34 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
export const useClipboard = () => {
const { toast } = useToastProvider();
const { t } = useTranslation();
return useCallback(
(text: string, successMessage?: string, errorMessage?: string) => {
navigator.clipboard
.writeText(text)
.then(() => {
toast(
successMessage ?? t('Copied to clipboard'),
VariantType.SUCCESS,
{
duration: 3000,
},
);
})
.catch(() => {
toast(
errorMessage ?? t('Failed to copy to clipboard'),
VariantType.ERROR,
{
duration: 3000,
},
);
});
},
[t, toast],
);
};