From 34ce276222e1369dfbdca1ad45f2a1d2ab6167e4 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 17 Jul 2025 12:33:16 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8(frontend)=20subdocs=20can=20manag?= =?UTF-8?q?e=20link=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 2e992f2c34..5fad24c4ac 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 838705b0d4..87ee6220e5 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 e3c8e4f080..1926d311ef 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 0000000000..4d337f0630 --- /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 0a886b611f..3d00334d59 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 587e6f45f4..11d2ab173a 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) ), From 3ff6d2541cb169b5524a84a31c96629b91da09ba Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Tue, 22 Jul 2025 11:38:32 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F(frontend)=20use=20more?= =?UTF-8?q?=20reliable=20properties=20in=20useTreeUtils?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Using the treeContext was causing issues with the current parent detection, in many places the context is not available. "depth" property is more reliable than "nb_accesses_ancestors". --- .../features/docs/doc-tree/components/DocTree.tsx | 1 + .../docs/doc-tree/components/DocTreeItemActions.tsx | 13 ++++++------- .../features/docs/doc-tree/hooks/useTreeUtils.tsx | 9 ++------- .../docs/docs-grid/components/DocsGridItem.tsx | 5 +---- 4 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx index ec6817b58c..083301387e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx @@ -223,6 +223,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => { treeContext?.treeData.addChild(null, newDoc); }} isOpen={rootActionsOpen} + isRoot={true} onOpenChange={setRootActionsOpen} /> diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx index 0589f9de30..c0ad2447f3 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx @@ -20,29 +20,28 @@ import { import { useCreateChildrenDoc } from '../api/useCreateChildren'; import { useDetachDoc } from '../api/useDetach'; import MoveDocIcon from '../assets/doc-extract-bold.svg'; -import { useTreeUtils } from '../hooks'; type DocTreeItemActionsProps = { doc: Doc; isOpen?: boolean; - parentId?: string | null; + isRoot?: boolean; onCreateSuccess?: (newDoc: Doc) => void; onOpenChange?: (isOpen: boolean) => void; + parentId?: string | null; }; export const DocTreeItemActions = ({ doc, - parentId, - onCreateSuccess, isOpen, + isRoot = false, + onCreateSuccess, onOpenChange, + parentId, }: DocTreeItemActionsProps) => { const router = useRouter(); const { t } = useTranslation(); const deleteModal = useModal(); - const copyLink = useCopyDocLink(doc.id); - const { isCurrentParent } = useTreeUtils(doc); const { mutate: detachDoc } = useDetachDoc(); const treeContext = useTreeContext(); const { mutate: duplicateDoc } = useDuplicateDoc({ @@ -81,7 +80,7 @@ export const DocTreeItemActions = ({ icon: , callback: copyLink, }, - ...(!isCurrentParent + ...(!isRoot ? [ { label: t('Move to my docs'), diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx index 562a84aa5e..34f4d02cef 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx @@ -1,14 +1,9 @@ -import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; - import { Doc } from '@/docs/doc-management'; export const useTreeUtils = (doc: Doc) => { - const treeContext = useTreeContext(); - return { - 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 + isTopRoot: doc.depth === 1, + isChild: doc.depth > 1, isDesynchronized: !!( doc.ancestors_link_reach && (doc.computed_link_reach !== doc.ancestors_link_reach || diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx index dc6d96a4fa..722d5e7af9 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItem.tsx @@ -1,4 +1,3 @@ -import { TreeProvider } from '@gouvfr-lasuite/ui-kit'; import { Tooltip, useModal } from '@openfun/cunningham-react'; import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; @@ -142,9 +141,7 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => { {shareModal.isOpen && ( - - - + )} );