diff --git a/CHANGELOG.md b/CHANGELOG.md index b27dabe12a..5d3723ff32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to - ✨(frontend) Can print a doc #1832 - ✨(backend) manage reconciliation requests for user accounts #1878 - 👷(CI) add GHCR workflow for forked repo testing #1851 +- ✨(frontend) Move doc modal #1886 - ⚡️(backend) remove content from Document serializer when asked #1910 - ✨(backend) allow the duplication of subpages #1893 - ✨(backend) Onboarding docs for new users #1891 diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts similarity index 58% rename from src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts rename to src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts index 17cfa81562..ae8c6fe419 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-move.spec.ts @@ -1,10 +1,26 @@ import { expect, test } from '@playwright/test'; -import { createDoc, mockedListDocs, toggleHeaderMenu } from './utils-common'; +import { + createDoc, + getGridRow, + getOtherBrowserName, + mockedListDocs, + toggleHeaderMenu, + verifyDocName, +} from './utils-common'; +import { writeInEditor } from './utils-editor'; +import { + addNewMember, + connectOtherUserToDoc, + updateShareLink, +} from './utils-share'; import { createRootSubPage } from './utils-sub-pages'; -test.describe('Doc grid dnd', () => { - test('it creates a doc', async ({ page, browserName }) => { +test.describe('Doc grid move', () => { + test('it checks drag and drop functionality', async ({ + page, + browserName, + }) => { await page.goto('/'); const header = page.locator('header').first(); await createDoc(page, 'Draggable doc', browserName, 1); @@ -29,7 +45,7 @@ test.describe('Doc grid dnd', () => { await expect(draggableElement).toBeVisible(); await expect(dropZone).toBeVisible(); - // Obtenir les positions des éléments + // Get the position of the elements const draggableBoundingBox = await draggableElement.boundingBox(); const dropZoneBoundingBox = await dropZone.boundingBox(); @@ -46,7 +62,7 @@ test.describe('Doc grid dnd', () => { ); await page.mouse.down(); - // Déplacer vers la zone cible + // Move to the target zone await page.mouse.move( dropZoneBoundingBox.x + dropZoneBoundingBox.width / 2, dropZoneBoundingBox.y + dropZoneBoundingBox.height / 2, @@ -161,6 +177,207 @@ test.describe('Doc grid dnd', () => { await page.mouse.up(); }); + + test('it moves a doc from the doc search modal', async ({ + page, + browserName, + }) => { + await page.goto('/'); + + const [titleDoc1] = await createDoc(page, 'Draggable doc', browserName, 1); + + const otherBrowserName = getOtherBrowserName(browserName); + await page.getByRole('button', { name: 'Share' }).click(); + await addNewMember(page, 0, 'Administrator', otherBrowserName); + await page + .getByRole('dialog') + .getByRole('button', { name: 'close' }) + .click(); + + await page.getByRole('button', { name: 'Back to homepage' }).click(); + + const [titleDoc2] = await createDoc(page, 'Droppable doc', browserName, 1); + await page.getByRole('button', { name: 'Back to homepage' }).click(); + + const docsGrid = page.getByTestId('docs-grid'); + await expect(docsGrid.getByText(titleDoc1)).toBeVisible(); + await expect(docsGrid.getByText(titleDoc2)).toBeVisible(); + + const row = await getGridRow(page, titleDoc1); + await row.getByText(`more_horiz`).click(); + + await page.getByRole('menuitem', { name: 'Move into a doc' }).click(); + + await expect( + page.getByRole('dialog').getByRole('heading', { name: 'Move' }), + ).toBeVisible(); + + const input = page.getByRole('combobox', { name: 'Quick search input' }); + await input.click(); + await input.fill(titleDoc2); + + await expect(page.getByRole('option').getByText(titleDoc2)).toBeVisible(); + + // Select the first result + await page.keyboard.press('Enter'); + // The CTA should get the focus + await page.keyboard.press('Tab'); + // Validate the move action + await page.keyboard.press('Enter'); + + await expect( + page + .getByRole('dialog') + .getByText('it will lose its current access rights'), + ).toBeVisible(); + + await page + .getByRole('dialog') + .getByRole('button', { name: 'Move', exact: true }) + .first() + .click(); + + await expect(docsGrid.getByText(titleDoc1)).toBeHidden(); + await docsGrid + .getByRole('link', { name: `Open document ${titleDoc2}` }) + .click(); + + await verifyDocName(page, titleDoc2); + + const docTree = page.getByTestId('doc-tree'); + await expect(docTree.getByText(titleDoc1)).toBeVisible(); + }); + + test('it proposes an access request when moving a doc without sufficient permissions', async ({ + page, + browserName, + }) => { + test.slow(); + await page.goto('/'); + + const [titleDoc1] = await createDoc(page, 'Move doc', browserName, 1); + + const { otherPage, cleanup } = await connectOtherUserToDoc({ + docUrl: '/', + browserName, + }); + + // Another user creates a doc + const [titleDoc2] = await createDoc(otherPage, 'Drop doc', browserName, 1); + await writeInEditor({ + page: otherPage, + text: 'Hello world', + }); + // Make it public + await otherPage.getByRole('button', { name: 'Share' }).click(); + await updateShareLink(otherPage, 'Public'); + await otherPage + .getByRole('dialog') + .getByRole('button', { name: 'close' }) + .click(); + const otherPageUrl = otherPage.url(); + + // The first user visit the doc to have it in his grid list + await page.goto(otherPageUrl); + await expect(page.getByText('Hello world')).toBeVisible(); + + await page.waitForTimeout(1000); + + await page.getByRole('button', { name: 'Back to homepage' }).click(); + + const docsGrid = page.getByTestId('docs-grid'); + await expect(docsGrid.getByText(titleDoc1)).toBeVisible(); + await expect(docsGrid.getByText(titleDoc2)).toBeVisible(); + + const row = await getGridRow(page, titleDoc1); + await row.getByText(`more_horiz`).click(); + + await page.getByRole('menuitem', { name: 'Move into a doc' }).click(); + + await expect( + page.getByRole('dialog').getByRole('heading', { name: 'Move' }), + ).toBeVisible(); + + const input = page.getByRole('combobox', { name: 'Quick search input' }); + await input.click(); + await input.fill(titleDoc2); + + await expect(page.getByRole('option').getByText(titleDoc2)).toBeVisible(); + + // Select the first result + await page.keyboard.press('Enter'); + // The CTA should get the focus + await page.keyboard.press('Tab'); + // Validate the move action + await page.keyboard.press('Enter'); + + // Request access modal should be visible + await expect( + page + .getByRole('dialog') + .getByText( + 'You need edit access to the destination. Request access, then try again.', + ), + ).toBeVisible(); + + await page + .getByRole('dialog') + .getByRole('button', { name: 'Request access', exact: true }) + .first() + .click(); + + // The other user should receive the access request and be able to approve it + await otherPage.getByRole('button', { name: 'Share' }).click(); + await expect(otherPage.getByText('Access Requests')).toBeVisible(); + await expect(otherPage.getByText(`E2E ${browserName}`)).toBeVisible(); + + const emailRequest = `user.test@${browserName}.test`; + await expect(otherPage.getByText(emailRequest)).toBeVisible(); + const container = otherPage.getByTestId( + `doc-share-access-request-row-${emailRequest}`, + ); + await container.getByTestId('doc-role-dropdown').click(); + await otherPage.getByRole('menuitem', { name: 'Administrator' }).click(); + await container.getByRole('button', { name: 'Approve' }).click(); + + await expect(otherPage.getByText('Access Requests')).toBeHidden(); + await expect(otherPage.getByText('Share with 2 users')).toBeVisible(); + await expect(otherPage.getByText(`E2E ${browserName}`)).toBeVisible(); + + // The first user should now be able to move the doc + await page.reload(); + await row.getByText(`more_horiz`).click(); + + await page.getByRole('menuitem', { name: 'Move into a doc' }).click(); + + await expect( + page.getByRole('dialog').getByRole('heading', { name: 'Move' }), + ).toBeVisible(); + + await input.click(); + await input.fill(titleDoc2); + + await expect(page.getByRole('option').getByText(titleDoc2)).toBeVisible(); + + // Select the first result + await page.keyboard.press('Enter'); + // The CTA should get the focus + await page.keyboard.press('Tab'); + // Validate the move action + await page.keyboard.press('Enter'); + + await expect(docsGrid.getByText(titleDoc1)).toBeHidden(); + await docsGrid + .getByRole('link', { name: `Open document ${titleDoc2}` }) + .click(); + + await verifyDocName(page, titleDoc2); + + const docTree = page.getByTestId('doc-tree'); + await expect(docTree.getByText(titleDoc1)).toBeVisible(); + + await cleanup(); + }); }); test.describe('Doc grid dnd mobile', () => { diff --git a/src/frontend/apps/impress/src/components/modal/AlertModal.tsx b/src/frontend/apps/impress/src/components/modal/AlertModal.tsx index 778c8ff8f2..fb919ca23a 100644 --- a/src/frontend/apps/impress/src/components/modal/AlertModal.tsx +++ b/src/frontend/apps/impress/src/components/modal/AlertModal.tsx @@ -1,5 +1,11 @@ -import { Button, Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react'; -import { ReactNode } from 'react'; +import { + Button, + ButtonProps, + Modal, + ModalProps, + ModalSize, +} from '@gouvfr-lasuite/cunningham-react'; +import { ReactNode, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { Box } from '../Box'; @@ -10,10 +16,11 @@ export type AlertModalProps = { isOpen: boolean; onClose: () => void; onConfirm: () => void; + themeCTA?: ButtonProps['color']; title: string; cancelLabel?: string; confirmLabel?: string; -}; +} & Partial; export const AlertModal = ({ cancelLabel, @@ -23,8 +30,26 @@ export const AlertModal = ({ onClose, onConfirm, title, + themeCTA, + ...props }: AlertModalProps) => { const { t } = useTranslation(); + + /** + * TODO: + * Remove this effect when Cunningham will have this patch released: + * https://github.com/suitenumerique/cunningham/pull/377 + */ + useEffect(() => { + const timeout = setTimeout(() => { + const contents = document.querySelectorAll('.c__modal__content'); + contents.forEach((content) => { + content.setAttribute('tabindex', '-1'); + }); + }, 100); + return () => clearTimeout(timeout); + }, []); + return ( } rightActions={ - <> + - + } + {...props} > diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearch.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearch.tsx index 16cbb826a1..441e3e44af 100644 --- a/src/frontend/apps/impress/src/components/quick-search/QuickSearch.tsx +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearch.tsx @@ -15,6 +15,7 @@ export type QuickSearchAction = { export type QuickSearchData = { groupName: string; + groupKey?: string; elements: T[]; emptyString?: string; startActions?: QuickSearchAction[]; @@ -30,13 +31,13 @@ export type QuickSearchProps = { loading?: boolean; label?: string; placeholder?: string; + groupKey?: string; }; export const QuickSearch = ({ onFilter, inputContent, inputValue, - loading, showInput = true, label, placeholder, @@ -72,10 +73,10 @@ export const QuickSearch = ({ tabIndex={-1} value={selectedValue} onValueChange={handleValueChange} + disablePointerSelection > {showInput && ( ({ {group.startActions?.map((action, index) => { return ( {action.content} @@ -38,8 +38,8 @@ export const QuickSearchGroup = ({ {group.elements.map((groupElement, index) => { return ( { onSelect?.(groupElement); }} @@ -51,7 +51,7 @@ export const QuickSearchGroup = ({ {group.endActions?.map((action, index) => { return ( {action.content} diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx index e1fcdcf0d2..803ab4c43d 100644 --- a/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx @@ -1,6 +1,5 @@ -import { Loader } from '@gouvfr-lasuite/cunningham-react'; import { Command } from 'cmdk'; -import { ReactNode } from 'react'; +import { PropsWithChildren } from 'react'; import { useTranslation } from 'react-i18next'; import { HorizontalSeparator } from '@/components'; @@ -9,19 +8,16 @@ import { useCunninghamTheme } from '@/cunningham'; import { Box } from '../Box'; import { Icon } from '../Icon'; -type Props = { - loading?: boolean; +type QuickSearchInputProps = { inputValue?: string; onFilter?: (str: string) => void; placeholder?: string; - children?: ReactNode; withSeparator?: boolean; listId?: string; onUserInteract?: () => void; isExpanded?: boolean; }; export const QuickSearchInput = ({ - loading, inputValue, onFilter, placeholder, @@ -30,7 +26,7 @@ export const QuickSearchInput = ({ listId, onUserInteract, isExpanded, -}: Props) => { +}: PropsWithChildren) => { const { t } = useTranslation(); const { spacingsTokens } = useCunninghamTheme(); @@ -52,14 +48,7 @@ export const QuickSearchInput = ({ $gap={spacingsTokens['2xs']} $padding={{ horizontal: 'base', vertical: 'sm' }} > - {!loading && ( - => { + const response = await fetchAPI(`documents/${sourceDocumentId}/move/`, { + method: 'POST', + body: JSON.stringify({ + target_document_id: targetDocumentId, + position, + }), + }); + + if (!response.ok) { + throw new APIError('Failed to move the doc', await errorCauses(response)); + } + + return response.json() as Promise; +}; + +export function useMoveDoc(deleteAccessOnMove = false) { + const queryClient = useQueryClient(); + const { mutate: handleDeleteInvitation } = useDeleteDocInvitation(); + const { mutate: handleDeleteAccess } = useDeleteDocAccess(); + + return useMutation({ + mutationFn: moveDoc, + async onSuccess(_data, variables, _onMutateResult, _context) { + if (!deleteAccessOnMove) { + return; + } + + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_DOC], + }); + const accesses = await getDocAccesses({ + docId: variables.sourceDocumentId, + }); + + const invitationsResponse = await getDocInvitations({ + docId: variables.sourceDocumentId, + page: 1, + }); + + const invitations = invitationsResponse.results; + + await Promise.all([ + ...invitations.map((invitation) => + handleDeleteInvitation({ + docId: variables.sourceDocumentId, + invitationId: invitation.id, + }), + ), + ...accesses.map((access) => + handleDeleteAccess({ + docId: variables.sourceDocumentId, + accessId: access.id, + }), + ), + ]); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchContent.tsx index fc14d8bc63..0536714e2e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchContent.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchContent.tsx @@ -12,15 +12,19 @@ import { DocSearchItem } from './DocSearchItem'; type DocSearchContentProps = { search: string; filters: DocSearchFiltersValues; + filterResults?: (doc: Doc) => boolean; onSelect: (doc: Doc) => void; onLoadingChange?: (loading: boolean) => void; + renderSearchElement?: (doc: Doc) => React.ReactNode; }; export const DocSearchContent = ({ search, filters, + filterResults, onSelect, onLoadingChange, + renderSearchElement, }: DocSearchContentProps) => { const { data, @@ -38,10 +42,15 @@ export const DocSearchContent = ({ const loading = isFetching || isRefetching || isLoading; const docsData: QuickSearchData = useMemo(() => { - const docs = data?.pages.flatMap((page) => page.results) || []; + let docs = data?.pages.flatMap((page) => page.results) || []; + + if (filterResults) { + docs = docs.filter(filterResults); + } return { groupName: docs.length > 0 ? t('Select a document') : '', + groupKey: 'docs', elements: search ? docs : [], emptyString: t('No document found'), endActions: hasNextPage @@ -52,7 +61,7 @@ export const DocSearchContent = ({ ] : [], }; - }, [search, data?.pages, fetchNextPage, hasNextPage]); + }, [search, data?.pages, fetchNextPage, hasNextPage, filterResults]); useEffect(() => { onLoadingChange?.(loading); @@ -62,7 +71,9 @@ export const DocSearchContent = ({ } + renderElement={ + renderSearchElement ?? ((doc) => ) + } /> ); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts b/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts index d5d0e0c444..2952e6ad95 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts @@ -1,3 +1,4 @@ +export * from './DocSearchContent'; export * from './DocSearchModal'; export * from './DocSearchFilters'; export * from './DocSearchSubPageContent'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/AlertModalRequestAccess.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/AlertModalRequestAccess.tsx new file mode 100644 index 0000000000..6060b0a6b4 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/AlertModalRequestAccess.tsx @@ -0,0 +1,89 @@ +import { Button } from '@gouvfr-lasuite/cunningham-react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { AlertModal, Box, Icon, Text } from '@/components'; + +import { useDocAccessRequests } from '../api/useDocAccessRequest'; + +import { ButtonAccessRequest } from './DocShareAccessRequest'; + +interface AlertModalRequestAccessProps { + docId: string; + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + targetDocumentTitle: string; + title: string; +} + +export const AlertModalRequestAccess = ({ + docId, + isOpen, + onClose, + onConfirm, + targetDocumentTitle, + title, +}: AlertModalRequestAccessProps) => { + const { t } = useTranslation(); + const { data: requests } = useDocAccessRequests({ + docId, + page: 1, + }); + + const hasRequested = !!( + requests && requests?.results.find((request) => request.document === docId) + ); + + return ( + + + }} + /> + + {hasRequested && ( + + + {t('You have already requested access to this document.')} + + )} + + } + confirmLabel={t('Request access')} + onConfirm={onConfirm} + rightActions={ + + + + + } + /> + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAccessRequest.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAccessRequest.tsx index 898c99c29e..c0ab9c466c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAccessRequest.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAccessRequest.tsx @@ -4,7 +4,7 @@ import { VariantType, useToastProvider, } from '@gouvfr-lasuite/cunningham-react'; -import { useMemo, useState } from 'react'; +import { MouseEventHandler, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { createGlobalStyle } from 'styled-components'; @@ -167,10 +167,13 @@ export const QuickSearchGroupAccessRequest = ({ type ButtonAccessRequestProps = { docId: Doc['id']; -} & ButtonProps; +} & Omit & { + onClick?: MouseEventHandler; + }; export const ButtonAccessRequest = ({ docId, + onClick, ...buttonProps }: ButtonAccessRequestProps) => { const { authenticated } = useAuth(); @@ -216,7 +219,10 @@ export const ButtonAccessRequest = ({ return ( + + + } + title={ + + + + {t('Move')} + + onClose()} + /> + + + + + Choose the new location for {docTitle}. + + + + + } + > + { + // Close modal on Escape + if (e.key === 'Escape') { + onClose(); + return; + } + // Prevent keyboard events from bubbling to parent components (e.g., drag and drop) + e.stopPropagation(); + }} + > + + + {search.length === 0 && ( + + {t('No + + )} + {search && ( + + docResults.id !== doc.id} + onSelect={handleSelect} + onLoadingChange={setLoading} + renderSearchElement={(docSearch) => { + const isSelected = docSelected?.id === docSearch.id; + + return ( + + + + + ); + }} + /> + + )} + + + + + {modalConfirmation.isOpen && ( + + )} + {modalRequest.isOpen && docSelected?.id && ( + { + modalRequest.onClose(); + onClose(); + }} + targetDocumentTitle={docTargetTitle} + title={t('Move document')} + /> + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx index bee4eb4c81..52def24051 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx @@ -16,10 +16,7 @@ import { useInfiniteDocsTrashbin } from '../api'; import { useImport } from '../hooks/useImport'; import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid'; -import { - DocGridContentList, - DraggableDocGridContentList, -} from './DocGridContentList'; +import { DocGridContentList } from './DocGridContentList'; import { DocsGridLoader } from './DocsGridLoader'; const Tooltip = styled(TooltipBase)` @@ -166,11 +163,7 @@ export const DocsGrid = ({ - {isDesktop ? ( - - ) : ( - - )} + {hasNextPage && !loading && ( diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx index 2758cd3b19..61d69fa5d7 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridActions.tsx @@ -11,20 +11,24 @@ import { useCreateFavoriteDoc, useDeleteFavoriteDoc, useDuplicateDoc, + useTrans, } from '@/docs/doc-management'; +import { DocShareModal } from '@/docs/doc-share'; + +import { DocMoveModal } from './DocMoveModal'; interface DocsGridActionsProps { doc: Doc; - openShareModal?: () => void; } -export const DocsGridActions = ({ - doc, - openShareModal, -}: DocsGridActionsProps) => { +export const DocsGridActions = ({ doc }: DocsGridActionsProps) => { const { t } = useTranslation(); const deleteModal = useModal(); + const shareModal = useModal(); + const importModal = useModal(); + const { untitledDocument } = useTrans(); + const { mutate: duplicateDoc } = useDuplicateDoc(); const removeFavoriteDoc = useDeleteFavoriteDoc({ @@ -52,11 +56,20 @@ export const DocsGridActions = ({ label: t('Share'), icon: 'group', callback: () => { - openShareModal?.(); + shareModal.open(); }, testId: `docs-grid-actions-share-${doc.id}`, }, + { + label: t('Move into a doc'), + icon: 'copy_all', + callback: () => { + importModal.open(); + }, + testId: `docs-grid-actions-import-${doc.id}`, + show: doc.abilities.move, + }, { label: t('Duplicate'), icon: 'content_copy', @@ -79,7 +92,7 @@ export const DocsGridActions = ({ }, ]; - const documentTitle = doc.title || t('Untitled document'); + const documentTitle = doc.title || untitledDocument; const menuLabel = t('Open the menu of actions for the document: {{title}}', { title: documentTitle, }); @@ -114,6 +127,16 @@ export const DocsGridActions = ({ {deleteModal.isOpen && ( )} + {shareModal.isOpen && ( + + )} + {importModal.isOpen && ( + + )} ); }; 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 b01058365e..41f1a70685 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,4 @@ -import { Tooltip, useModal } from '@gouvfr-lasuite/cunningham-react'; +import { Tooltip } from '@gouvfr-lasuite/cunningham-react'; import { useSearchParams } from 'next/navigation'; import { KeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; @@ -7,8 +7,7 @@ import { css } from 'styled-components'; import { Box, Icon, StyledLink, Text } from '@/components'; import { useConfig } from '@/core'; import { useCunninghamTheme } from '@/cunningham'; -import { Doc, LinkReach, SimpleDocItem } from '@/docs/doc-management'; -import { DocShareModal } from '@/docs/doc-share'; +import { Doc, LinkReach, SimpleDocItem, useTrans } from '@/docs/doc-management'; import { useDate } from '@/hooks'; import { useResponsiveStore } from '@/stores'; @@ -27,19 +26,12 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => { const searchParams = useSearchParams(); const target = searchParams.get('target'); const isInTrashbin = target === 'trashbin'; + const { untitledDocument } = useTrans(); const { t } = useTranslation(); const { isDesktop } = useResponsiveStore(); const { flexLeft, flexRight } = useResponsiveDocGrid(); const { spacingsTokens } = useCunninghamTheme(); - const shareModal = useModal(); - const isPublic = doc.link_reach === LinkReach.PUBLIC; - const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED; - const isShared = isPublic || isAuthenticated; - - const handleShareClick = () => { - shareModal.open(); - }; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { @@ -68,7 +60,7 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => { `} className="--docs--doc-grid-item" aria-label={t('Open document: {{title}}', { - title: doc.title || t('Untitled document'), + title: doc.title || untitledDocument, })} > { href={`/docs/${doc.id}`} onKeyDown={handleKeyDown} > - - - {isShared && ( - - {dragMode && ( - <> - - - {isPublic - ? t('Accessible to anyone') - : t('Accessible to authenticated users')} - - - )} - {!dragMode && ( - - {isPublic - ? t('Accessible to anyone') - : t('Accessible to authenticated users')} - - } - placement="top" - > -
- - - {isPublic - ? t('Accessible to anyone') - : t('Accessible to authenticated users')} - -
-
- )} -
- )} -
+
@@ -165,31 +92,106 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => { $gap="32px" role="gridcell" > - + + + {isDesktop && ( - + )} {isInTrashbin ? ( ) : ( - + )} - {shareModal.isOpen && ( - + + ); +}; + +export const DocsGridItemTitle = ({ + doc, + withTooltip, +}: { + doc: Doc; + withTooltip: boolean; +}) => { + const { t } = useTranslation(); + const { isDesktop } = useResponsiveStore(); + const { spacingsTokens } = useCunninghamTheme(); + const isPublic = doc.link_reach === LinkReach.PUBLIC; + const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED; + const isShared = isPublic || isAuthenticated; + + return ( + + + {isShared && ( + + {withTooltip ? ( + + {isPublic + ? t('Accessible to anyone') + : t('Accessible to authenticated users')} + + } + placement="top" + > + + + + + ) : ( + + )} + )} + + ); +}; + +const IconPublic = ({ isPublic }: { isPublic: boolean }) => { + const { t } = useTranslation(); + + return ( + <> + + + {isPublic + ? t('Accessible to anyone') + : t('Accessible to authenticated users')} + ); }; @@ -223,15 +225,8 @@ export const DocsGridItemDate = ({ } return ( - - - {dateToDisplay} - - + + {dateToDisplay} + ); }; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItemSharedButton.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItemSharedButton.tsx index 0fd0c75ad5..9270d2056b 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItemSharedButton.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridItemSharedButton.tsx @@ -1,65 +1,67 @@ -import { Button, Tooltip } from '@gouvfr-lasuite/cunningham-react'; +import { Button, Tooltip, useModal } from '@gouvfr-lasuite/cunningham-react'; import { useTranslation } from 'react-i18next'; import { Box, Icon, Text } from '@/components'; import { Doc } from '@/docs/doc-management'; +import { DocShareModal } from '@/docs/doc-share'; type Props = { doc: Doc; - handleClick: () => void; disabled: boolean; }; -export const DocsGridItemSharedButton = ({ - doc, - handleClick, - disabled, -}: Props) => { +export const DocsGridItemSharedButton = ({ doc, disabled }: Props) => { const { t } = useTranslation(); const sharedCount = doc.nb_accesses_direct; const isShared = sharedCount - 1 > 0; + const shareModal = useModal(); if (!isShared) { return  ; } return ( - - {t('Shared with {{count}} users', { count: sharedCount })} - - } - placement="top" - className="--docs--doc-tooltip-grid-item-shared-button" - > - - + + + {shareModal.isOpen && ( + + )} + ); }; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridTrashbinActions.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridTrashbinActions.tsx index 3ea404defa..14f68e3578 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridTrashbinActions.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGridTrashbinActions.tsx @@ -6,7 +6,12 @@ import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; import { DropdownMenu, DropdownMenuOption, Icon } from '@/components'; -import { Doc, KEY_LIST_DOC, useRestoreDoc } from '@/docs/doc-management'; +import { + Doc, + KEY_LIST_DOC, + useRestoreDoc, + useTrans, +} from '@/docs/doc-management'; import { KEY_LIST_DOC_TRASHBIN } from '../api'; @@ -18,6 +23,7 @@ export const DocsGridTrashbinActions = ({ doc, }: DocsGridTrashbinActionsProps) => { const { t } = useTranslation(); + const { untitledDocument } = useTrans(); const { toast } = useToastProvider(); const { mutate: restoreDoc, error } = useRestoreDoc({ listInvalidQueries: [KEY_LIST_DOC, KEY_LIST_DOC_TRASHBIN], @@ -61,7 +67,7 @@ export const DocsGridTrashbinActions = ({ }, ]; - const documentTitle = doc.title || t('Untitled document'); + const documentTitle = doc.title || untitledDocument; const menuLabel = t('Open the menu of actions for the document: {{title}}', { title: documentTitle, }); diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/Draggable.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/Draggable.tsx index a38cf59c0c..bd72a1248c 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/Draggable.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/Draggable.tsx @@ -1,15 +1,17 @@ import { Data, useDraggable } from '@dnd-kit/core'; +import { PropsWithChildren } from 'react'; type DraggableProps = { id: string; data?: Data; - children: React.ReactNode; + disabled?: boolean; }; -export const Draggable = (props: DraggableProps) => { +export const Draggable = (props: PropsWithChildren>) => { const { attributes, listeners, setNodeRef } = useDraggable({ id: props.id, data: props.data, + disabled: props.disabled, }); return ( diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/ModalConfimationMoveDoc.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/ModalConfimationMoveDoc.tsx new file mode 100644 index 0000000000..aaf310c061 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/ModalConfimationMoveDoc.tsx @@ -0,0 +1,41 @@ +import { Trans, useTranslation } from 'react-i18next'; + +import { AlertModal, Text } from '@/components'; + +interface ModalConfirmationMoveDocProps { + targetDocumentTitle: string; + onConfirm: () => void; + onClose: () => void; + isOpen: boolean; +} + +export const ModalConfirmationMoveDoc = ({ + targetDocumentTitle, + onClose, + onConfirm, + isOpen, +}: ModalConfirmationMoveDocProps) => { + const { t } = useTranslation(); + + return ( + + }} + /> + + } + confirmLabel={t('Move')} + onConfirm={onConfirm} + /> + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/__tests__/DocsGridItemDate.test.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/__tests__/DocsGridItemDate.test.tsx index 1652e1e88a..22efaae726 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/__tests__/DocsGridItemDate.test.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/__tests__/DocsGridItemDate.test.tsx @@ -18,7 +18,9 @@ describe('DocsGridItemDate', () => { it('should not render date when not on desktop', () => { render( , @@ -27,7 +29,7 @@ describe('DocsGridItemDate', () => { }, ); - expect(screen.queryByRole('link')).not.toBeInTheDocument(); + expect(screen.queryByText('1 minute ago')).not.toBeInTheDocument(); }); [ @@ -66,7 +68,6 @@ describe('DocsGridItemDate', () => { { wrapper: AppWrapper }, ); - expect(screen.getByRole('link')).toBeInTheDocument(); expect(screen.getByText(rendered)).toBeInTheDocument(); }); }); @@ -87,7 +88,6 @@ describe('DocsGridItemDate', () => { { wrapper: AppWrapper }, ); - expect(screen.getByRole('link')).toBeInTheDocument(); expect(screen.getByText('il y a 5 jours')).toBeInTheDocument(); await i18next.changeLanguage('en'); @@ -134,7 +134,6 @@ describe('DocsGridItemDate', () => { { wrapper: AppWrapper }, ); - expect(screen.getByRole('link')).toBeInTheDocument(); await waitFor( () => { expect(screen.getByText(rendered)).toBeInTheDocument(); diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFavoriteItem.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFavoriteItem.tsx index 21f0c974d7..b2d3cc7d7c 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFavoriteItem.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFavoriteItem.tsx @@ -1,4 +1,3 @@ -import { useModal } from '@gouvfr-lasuite/cunningham-react'; import { t } from 'i18next'; import { DateTime } from 'luxon'; import { css } from 'styled-components'; @@ -6,7 +5,6 @@ import { css } from 'styled-components'; import { Box, StyledLink } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; import { Doc, SimpleDocItem } from '@/docs/doc-management'; -import { DocShareModal } from '@/docs/doc-share'; import { DocsGridActions } from '@/docs/docs-grid'; import { useResponsiveStore } from '@/stores'; @@ -15,7 +13,6 @@ type LeftPanelFavoriteItemProps = { }; export const LeftPanelFavoriteItem = ({ doc }: LeftPanelFavoriteItemProps) => { - const shareModal = useModal(); const { colorsTokens, spacingsTokens } = useCunninghamTheme(); const { isDesktop } = useResponsiveStore(); @@ -61,11 +58,8 @@ export const LeftPanelFavoriteItem = ({ doc }: LeftPanelFavoriteItemProps) => { - + - {shareModal.isOpen && ( - - )} ); };