From 34ce276222e1369dfbdca1ad45f2a1d2ab6167e4 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 17 Jul 2025 12:33:16 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20subdocs=20can=20manage=20?= =?UTF-8?q?link=20reach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subdocs can now have their own link reach properties, dissociated from the parent document. --- CHANGELOG.md | 1 + .../app-impress/doc-inherited-share.spec.ts | 46 ++++- .../doc-management/api/useUpdateDocLink.tsx | 12 ++ .../components/DocDesynchronized.tsx | 67 +++++++ .../doc-share/components/DocShareModal.tsx | 20 ++- .../components/DocShareModalFooter.tsx | 8 +- .../doc-share/components/DocVisibility.tsx | 163 +++++------------- .../docs/doc-tree/hooks/useTreeUtils.tsx | 3 +- 8 files changed, 177 insertions(+), 143 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-share/components/DocDesynchronized.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e992f2c..5fad24c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to ### Added - ✨(backend) allow masking documents from the list view #1171 +- ✨(frontend) subdocs can manage link reach #1190 - ✨(frontend) add duplicate action to doc tree #1175 ### Changed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-inherited-share.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-inherited-share.spec.ts index 838705b0..87ee6220 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-inherited-share.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-inherited-share.spec.ts @@ -31,9 +31,7 @@ test.describe('Inherited share accesses', () => { await verifyDocName(page, parentTitle); }); -}); -test.describe('Inherited share link', () => { test('it checks if the link is inherited', async ({ page, browserName }) => { await page.goto('/'); // Create root doc @@ -47,12 +45,50 @@ test.describe('Inherited share link', () => { // Create sub page await createRootSubPage(page, browserName, 'sub-page'); - // // verify share link is restricted and reader + // Verify share link is like the parent document await page.getByRole('button', { name: 'Share' }).click(); - // await expect(page.getByText('Inherited share')).toBeVisible(); const docVisibilityCard = page.getByLabel('Doc visibility card'); - await expect(docVisibilityCard).toBeVisible(); + await expect(docVisibilityCard.getByText('Connected')).toBeVisible(); await expect(docVisibilityCard.getByText('Reading')).toBeVisible(); + + // Verify inherited link + await docVisibilityCard.getByText('Connected').click(); + await expect( + page.getByRole('menuitem', { name: 'Private' }), + ).toBeDisabled(); + + // Update child link + await page.getByRole('menuitem', { name: 'Public' }).click(); + + await docVisibilityCard.getByText('Reading').click(); + await page.getByRole('menuitem', { name: 'Editing' }).click(); + + await expect(docVisibilityCard.getByText('Connected')).toBeHidden(); + await expect(docVisibilityCard.getByText('Reading')).toBeHidden(); + await expect( + docVisibilityCard.getByText('Public', { + exact: true, + }), + ).toBeVisible(); + await expect(docVisibilityCard.getByText('Editing')).toBeVisible(); + await expect( + docVisibilityCard.getByText( + 'The link sharing rules differ from the parent document', + ), + ).toBeVisible(); + + // Restore inherited link + await page.getByRole('button', { name: 'Restore' }).click(); + + await expect(docVisibilityCard.getByText('Connected')).toBeVisible(); + await expect(docVisibilityCard.getByText('Reading')).toBeVisible(); + await expect(docVisibilityCard.getByText('Public')).toBeHidden(); + await expect(docVisibilityCard.getByText('Editing')).toBeHidden(); + await expect( + docVisibilityCard.getByText( + 'The link sharing rules differ from the parent document', + ), + ).toBeHidden(); }); }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDocLink.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDocLink.tsx index e3c8e4f0..1926d311 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDocLink.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useUpdateDocLink.tsx @@ -1,4 +1,6 @@ +import { VariantType, useToastProvider } from '@openfun/cunningham-react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; import { APIError, errorCauses, fetchAPI } from '@/api'; import { Doc, KEY_DOC } from '@/docs/doc-management'; @@ -39,6 +41,8 @@ export function useUpdateDocLink({ }: UpdateDocLinkProps = {}) { const queryClient = useQueryClient(); const { broadcast } = useBroadcastStore(); + const { toast } = useToastProvider(); + const { t } = useTranslation(); return useMutation({ mutationFn: updateDocLink, @@ -52,6 +56,14 @@ export function useUpdateDocLink({ // Broadcast to every user connected to the document broadcast(`${KEY_DOC}-${variable.id}`); + toast( + t('The document visibility has been updated.'), + VariantType.SUCCESS, + { + duration: 2000, + }, + ); + onSuccess?.(data); }, }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocDesynchronized.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocDesynchronized.tsx new file mode 100644 index 00000000..4d337f06 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocDesynchronized.tsx @@ -0,0 +1,67 @@ +import { Button } from '@openfun/cunningham-react'; +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { Box, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { + Doc, + KEY_DOC, + KEY_LIST_DOC, + useUpdateDocLink, +} from '@/docs/doc-management'; + +import Desync from './../assets/desynchro.svg'; +import Undo from './../assets/undo.svg'; + +interface DocDesynchronizedProps { + doc: Doc; +} + +export const DocDesynchronized = ({ doc }: DocDesynchronizedProps) => { + const { t } = useTranslation(); + const { spacingsTokens, colorsTokens } = useCunninghamTheme(); + + const { mutate: updateDocLink } = useUpdateDocLink({ + listInvalideQueries: [KEY_LIST_DOC, KEY_DOC], + }); + + return ( + + + + + {t('The link sharing rules differ from the parent document')} + + + {doc.abilities.accesses_manage && ( + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx index 0a886b61..3d00334d 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx @@ -51,8 +51,18 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => { const { isDesktop } = useResponsiveStore(); + /** + * The modal content height is calculated based on the viewport height. + * The formula is: + * 100dvh - 2em - 12px - 34px + * - 34px is the height of the modal title in mobile + * - 2em is the padding of the modal content + * - 12px is the padding of the modal footer + * - 690px is the height of the content in desktop + * This ensures that the modal content is always visible and does not overflow. + */ const modalContentHeight = isDesktop - ? 'min(690px, calc(100dvh - 2em - 12px - 34px))' // 100dvh - 2em - 12px is the max cunningham modal height. 690px is the height of the content in desktop ad 34px is the height of the modal title in mobile + ? 'min(690px, calc(100dvh - 2em - 12px - 34px))' : `calc(100dvh - 34px)`; const [selectedUsers, setSelectedUsers] = useState([]); const [userQuery, setUserQuery] = useState(''); @@ -230,13 +240,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => { - {showFooter && ( - - )} + {showFooter && } diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalFooter.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalFooter.tsx index 587e6f45..11d2ab17 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalFooter.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModalFooter.tsx @@ -7,17 +7,15 @@ import { Doc, useCopyDocLink } from '@/docs/doc-management'; import { DocVisibility } from './DocVisibility'; -type Props = { +type DocShareModalFooterProps = { doc: Doc; onClose: () => void; - canEditVisibility?: boolean; }; export const DocShareModalFooter = ({ doc, onClose, - canEditVisibility = true, -}: Props) => { +}: DocShareModalFooterProps) => { const copyDocLink = useCopyDocLink(doc.id); const { t } = useTranslation(); return ( @@ -29,7 +27,7 @@ export const DocShareModalFooter = ({ > - + { +export const DocVisibility = ({ doc }: DocVisibilityProps) => { const { t } = useTranslation(); - const { toast } = useToastProvider(); const { isDesktop } = useResponsiveStore(); const { spacingsTokens, colorsTokens } = useCunninghamTheme(); - const canManage = doc.abilities.accesses_manage && canEdit; - const [linkReach, setLinkReach] = useState(getDocLinkReach(doc)); - const [docLinkRole, setDocLinkRole] = useState( - doc.computed_link_role ?? LinkRole.READER, - ); - const { isDesyncronized } = useTreeUtils(doc); - + const canManage = doc.abilities.accesses_manage; + const docLinkReach = getDocLinkReach(doc); + const docLinkRole = doc.computed_link_role ?? LinkRole.READER; + const { isDesynchronized } = useTreeUtils(doc); const { linkModeTranslations, linkReachChoices, linkReachTranslations } = useTranslatedShareSettings(); const description = docLinkRole === LinkRole.READER - ? linkReachChoices[linkReach].descriptionReadOnly - : linkReachChoices[linkReach].descriptionEdit; + ? linkReachChoices[docLinkReach].descriptionReadOnly + : linkReachChoices[docLinkReach].descriptionEdit; - const api = useUpdateDocLink({ - onSuccess: () => { - toast( - t('The document visibility has been updated.'), - VariantType.SUCCESS, - { - duration: 4000, - }, - ); - }, + const { mutate: updateDocLink } = useUpdateDocLink({ listInvalideQueries: [KEY_LIST_DOC, KEY_DOC], }); - const updateReach = useCallback( - (link_reach: LinkReach, link_role?: LinkRole) => { - const params: { - id: string; - link_reach: LinkReach; - link_role?: LinkRole; - } = { - id: doc.id, - link_reach, - }; - - api.mutate(params); - setLinkReach(link_reach); - if (link_role) { - params.link_role = link_role; - setDocLinkRole(link_role); - } - }, - [api, doc.id], - ); - - const updateLinkRole = useCallback( - (link_role: LinkRole) => { - api.mutate({ id: doc.id, link_role }); - setDocLinkRole(link_role); - }, - [api, doc.id], - ); - const linkReachOptions: DropdownMenuOption[] = useMemo(() => { return Object.values(LinkReach).map((key) => { - const isDisabled = - doc.abilities.link_select_options[key as LinkReach] === undefined; + const isDisabled = doc.abilities.link_select_options[key] === undefined; return { - label: linkReachTranslations[key as LinkReach], - callback: () => updateReach(key as LinkReach), - isSelected: linkReach === (key as LinkReach), + label: linkReachTranslations[key], + callback: () => + updateDocLink({ + id: doc.id, + link_reach: key, + }), + isSelected: docLinkReach === key, disabled: isDisabled, }; }); - }, [doc, linkReach, linkReachTranslations, updateReach]); + }, [ + doc.abilities.link_select_options, + doc.id, + docLinkReach, + linkReachTranslations, + updateDocLink, + ]); const haveDisabledOptions = linkReachOptions.some( (option) => option.disabled, @@ -120,41 +80,29 @@ export const DocVisibility = ({ doc, canEdit = true }: DocVisibilityProps) => { const showLinkRoleOptions = doc.computed_link_reach !== LinkReach.RESTRICTED; const linkRoleOptions: DropdownMenuOption[] = useMemo(() => { - const options = doc.abilities.link_select_options[linkReach] ?? []; + const options = doc.abilities.link_select_options[docLinkReach] ?? []; return Object.values(LinkRole).map((key) => { const isDisabled = !options.includes(key); return { label: linkModeTranslations[key], - callback: () => updateLinkRole(key), + callback: () => updateDocLink({ id: doc.id, link_role: key }), isSelected: docLinkRole === key, disabled: isDisabled, }; }); - }, [doc, docLinkRole, linkModeTranslations, updateLinkRole, linkReach]); + }, [ + doc.abilities.link_select_options, + doc.id, + docLinkReach, + docLinkRole, + linkModeTranslations, + updateDocLink, + ]); const haveDisabledLinkRoleOptions = linkRoleOptions.some( (option) => option.disabled, ); - const undoDesync = () => { - const params: { - id: string; - link_reach: LinkReach; - link_role?: LinkRole; - } = { - id: doc.id, - link_reach: doc.ancestors_link_reach, - }; - if (doc.ancestors_link_role) { - params.link_role = doc.ancestors_link_role; - } - api.mutate(params); - setLinkReach(doc.ancestors_link_reach); - if (doc.ancestors_link_role) { - setDocLinkRole(doc.ancestors_link_role); - } - }; - return ( { className="--docs--doc-visibility" > - {t('Link parameters')} + {t('Link settings')} - {isDesyncronized && ( - - - - - {t('Sharing rules differ from the parent page')} - - - {doc.abilities.accesses_manage && ( - - )} - - )} + {isDesynchronized && } { { $weight="500" $size="md" > - {linkReachChoices[linkReach].label} + {linkReachChoices[docLinkReach].label} @@ -251,7 +168,7 @@ export const DocVisibility = ({ doc, canEdit = true }: DocVisibilityProps) => { {showLinkRoleOptions && ( - {linkReach !== LinkReach.RESTRICTED && ( + {docLinkReach !== LinkReach.RESTRICTED && ( { isParent: doc.nb_accesses_ancestors <= 1, // it is a parent isChild: doc.nb_accesses_ancestors > 1, // it is a child isCurrentParent: treeContext?.root?.id === doc.id || doc.depth === 1, // it can be a child but not for the current user - isDesyncronized: !!( + isDesynchronized: !!( doc.ancestors_link_reach && - doc.ancestors_link_role && (doc.computed_link_reach !== doc.ancestors_link_reach || doc.computed_link_role !== doc.ancestors_link_role) ),