diff --git a/CHANGELOG.md b/CHANGELOG.md index 414d2afe..8811023a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ and this project adheres to ## Fixed - 🐛(frontend) update doc editor height #481 +- 💄(frontend) add doc search #485 ## [1.9.0] - 2024-12-11 diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts new file mode 100644 index 00000000..89540f93 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts @@ -0,0 +1,114 @@ +import { expect, test } from '@playwright/test'; +import { DateTime } from 'luxon'; + +import { createDoc, verifyDocName } from './common'; + +type SmallDoc = { + id: string; + title: string; + updated_at: string; +}; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); +}); + +test.describe('Document search', () => { + test('it checks all elements are visible', async ({ page }) => { + await page.getByRole('button', { name: 'search' }).click(); + await expect( + page.getByRole('img', { name: 'No active search' }), + ).toBeVisible(); + + await expect( + page.getByLabel('Search modal').getByText('search'), + ).toBeVisible(); + + await expect( + page.getByPlaceholder('Type the name of a document'), + ).toBeVisible(); + }); + + test('it checks search for a document', async ({ page, browserName }) => { + const id = Math.random().toString(36).substring(7); + + const doc1 = await createDoc(page, `My super ${id} doc`, browserName, 1); + await verifyDocName(page, doc1[0]); + await page.goto('/'); + const doc2 = await createDoc( + page, + `My super ${id} very doc`, + browserName, + 1, + ); + await verifyDocName(page, doc2[0]); + await page.goto('/'); + await page.getByRole('button', { name: 'search' }).click(); + await page.getByPlaceholder('Type the name of a document').click(); + await page + .getByPlaceholder('Type the name of a document') + .fill(`My super ${id}`); + + let responsePromisePage = page.waitForResponse( + (response) => + response.url().includes(`/documents/?page=1&title=My+super+${id}`) && + response.status() === 200, + ); + let response = await responsePromisePage; + let result = (await response.json()) as { results: SmallDoc[] }; + let docs = result.results; + expect(docs.length).toEqual(2); + + await Promise.all( + docs.map(async (doc: SmallDoc) => { + await expect( + page.getByTestId(`doc-search-item-${doc.id}`), + ).toBeVisible(); + const updatedAt = DateTime.fromISO(doc.updated_at ?? DateTime.now()) + .setLocale('en') + .toRelative(); + await expect( + page.getByTestId(`doc-search-item-${doc.id}`).getByText(updatedAt!), + ).toBeVisible(); + }), + ); + + const firstDoc = docs[0]; + + await expect( + page + .getByTestId(`doc-search-item-${firstDoc.id}`) + .getByText('keyboard_return'), + ).toBeVisible(); + + await page + .getByPlaceholder('Type the name of a document') + .press('ArrowDown'); + + const secondDoc = docs[1]; + await expect( + page + .getByTestId(`doc-search-item-${secondDoc.id}`) + .getByText('keyboard_return'), + ).toBeVisible(); + + await page.getByPlaceholder('Type the name of a document').click(); + await page + .getByPlaceholder('Type the name of a document') + .fill(`My super ${id} doc`); + + responsePromisePage = page.waitForResponse( + (response) => + response + .url() + .includes(`/documents/?page=1&title=My+super+${id}+doc`) && + response.status() === 200, + ); + + response = await responsePromisePage; + result = (await response.json()) as { results: SmallDoc[] }; + docs = result.results; + + expect(docs.length).toEqual(1); + }); +}); diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearchGroup.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearchGroup.tsx index e735e949..0cb44820 100644 --- a/src/frontend/apps/impress/src/components/quick-search/QuickSearchGroup.tsx +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearchGroup.tsx @@ -16,6 +16,7 @@ export const QuickSearchGroup = ({ onSelect, renderElement, }: Props) => { + console.log('group', group, group.emptyString && group.elements.length === 0); return ( ({ {group.elements.map((groupElement, index) => { return ( { onSelect?.(groupElement); diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearchItem.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearchItem.tsx index 3ede8311..b3d7e07c 100644 --- a/src/frontend/apps/impress/src/components/quick-search/QuickSearchItem.tsx +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearchItem.tsx @@ -3,10 +3,16 @@ import { PropsWithChildren } from 'react'; type Props = { onSelect?: (value: string) => void; + id?: string; }; export const QuickSearchItem = ({ children, onSelect, + id, }: PropsWithChildren) => { - return {children}; + return ( + + {children} + + ); }; diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearchItemContent.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearchItemContent.tsx index 702a11ca..fd167d1d 100644 --- a/src/frontend/apps/impress/src/components/quick-search/QuickSearchItemContent.tsx +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearchItemContent.tsx @@ -1,6 +1,7 @@ import { ReactNode } from 'react'; import { useCunninghamTheme } from '@/cunningham'; +import { useResponsiveStore } from '@/stores'; import { Box } from '../Box'; @@ -18,6 +19,8 @@ export const QuickSearchItemContent = ({ const { spacingsTokens } = useCunninghamTheme(); const spacings = spacingsTokens(); + const { isDesktop } = useResponsiveStore(); + return ( - {right && ( + {isDesktop && right && ( ; @@ -45,6 +46,10 @@ export const getDocs = async (params: DocsParams): Promise => { searchParams.set('is_creator_me', params.is_creator_me.toString()); } + if (params.title && params.title.length > 0) { + searchParams.set('title', params.title); + } + const response = await fetchAPI(`documents/?${searchParams.toString()}`); if (!response.ok) { diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/assets/illustration-docs-empty.png b/src/frontend/apps/impress/src/features/docs/doc-search/assets/illustration-docs-empty.png new file mode 100644 index 00000000..fa6a726c Binary files /dev/null and b/src/frontend/apps/impress/src/features/docs/doc-search/assets/illustration-docs-empty.png differ diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchItem.tsx new file mode 100644 index 00000000..c931878f --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchItem.tsx @@ -0,0 +1,38 @@ +import { DateTime } from 'luxon'; + +import { Box, Icon, Text } from '@/components'; +import { QuickSearchItemContent } from '@/components/quick-search/QuickSearchItemContent'; +import { Doc } from '@/features/docs/doc-management'; +import { SimpleDocItem } from '@/features/docs/docs-grid/components/SimpleDocItem'; +import { useResponsiveStore } from '@/stores'; + +type DocSearchItemProps = { + doc: Doc; +}; + +export const DocSearchItem = ({ doc }: DocSearchItemProps) => { + const { isDesktop } = useResponsiveStore(); + return ( +
+ + + + + {isDesktop && ( + + + {DateTime.fromISO(doc.updated_at).toRelative()} + + + )} + + } + right={ + + } + /> +
+ ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx new file mode 100644 index 00000000..977ea7a5 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx @@ -0,0 +1,105 @@ +import { Modal, ModalProps, ModalSize } from '@openfun/cunningham-react'; +import Image from 'next/image'; +import { useRouter } from 'next/navigation'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { InView } from 'react-intersection-observer'; +import { useDebouncedCallback } from 'use-debounce'; + +import { Box } from '@/components'; +import { + QuickSearch, + QuickSearchData, + QuickSearchGroup, +} from '@/components/quick-search'; +import EmptySearchIcon from '@/features/docs/doc-search/assets/illustration-docs-empty.png'; +import { useResponsiveStore } from '@/stores'; + +import { Doc, useInfiniteDocs } from '../../doc-management'; + +import { DocSearchItem } from './DocSearchItem'; + +type DocSearchModalProps = ModalProps & {}; + +export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => { + const { t } = useTranslation(); + const router = useRouter(); + const [search, setSearch] = useState(''); + const { isDesktop } = useResponsiveStore(); + const { + data, + isFetching, + isRefetching, + isLoading, + fetchNextPage, + hasNextPage, + } = useInfiniteDocs({ + page: 1, + title: search, + }); + const loading = isFetching || isRefetching || isLoading; + const handleInputSearch = useDebouncedCallback(setSearch, 300); + + const handleSelect = (doc: Doc) => { + router.push(`/docs/${doc.id}`); + modalProps.onClose?.(); + }; + + const docsData: QuickSearchData = useMemo(() => { + const docs = data?.pages.flatMap((page) => page.results) || []; + + return { + groupName: docs.length > 0 ? t('Select a document') : '', + elements: search ? docs : [], + emptyString: t('No document found'), + endActions: hasNextPage + ? [{ content: void fetchNextPage()} /> }] + : [], + }; + }, [data, hasNextPage, fetchNextPage, t, search]); + + return ( + + + + + {search.length === 0 && ( + + {t('No + + )} + {search && ( + } + /> + )} + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx index 1e442905..f13286a3 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx @@ -21,12 +21,14 @@ type SimpleDocItemProps = { doc: Doc; isPinned?: boolean; subText?: string; + showAccesses?: boolean; }; export const SimpleDocItem = ({ doc, isPinned = false, subText, + showAccesses = false, }: SimpleDocItemProps) => { const { spacingsTokens } = useCunninghamTheme(); const { isDesktop } = useResponsiveStore(); @@ -61,7 +63,7 @@ export const SimpleDocItem = ({ {doc.title} - {!isDesktop && ( + {(!isDesktop || showAccesses) && ( <> {isPublic && } {isShared && } diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx index 37ce4520..2a7c7bd8 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx @@ -1,15 +1,19 @@ -import { Button } from '@openfun/cunningham-react'; +import { Button, ModalSize, useModal } from '@openfun/cunningham-react'; import { t } from 'i18next'; import { useRouter } from 'next/navigation'; import { PropsWithChildren } from 'react'; import { Box, Icon, SeparatedSection } from '@/components'; import { useCreateDoc } from '@/features/docs'; +import { DocSearchModal } from '@/features/docs/doc-search/components/DocSearchModal'; +import { useCmdK } from '@/hook/useCmdK'; import { useLeftPanelStore } from '../stores'; export const LeftPanelHeader = ({ children }: PropsWithChildren) => { const router = useRouter(); + const searchModal = useModal(); + useCmdK(searchModal.open); const { togglePanel } = useLeftPanelStore(); const { mutate: createDoc } = useCreateDoc({ @@ -29,27 +33,42 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => { }; return ( - - - - - - - - - {children} - + + {children} + + {searchModal.isOpen && ( + + )} + ); };