(frontend) implement document search functionality

- Added a new DocSearchModal component for searching documents.
- Introduced DocSearchItem component to display individua
 document results.
- Enhanced the useDocs API to support title-based searching.
- Implemented e2e tests for document search visibility and
functionality.
- Included an empty state illustration for no search results.
- Updated the LeftPanelHeader to open the document search modal.
This commit is contained in:
Nathan Panchout
2024-12-09 18:00:53 +01:00
committed by Anthony LC
parent 157f6200f2
commit 44784b2236
12 changed files with 324 additions and 25 deletions

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -16,6 +16,7 @@ export const QuickSearchGroup = <T,>({
onSelect,
renderElement,
}: Props<T>) => {
console.log('group', group, group.emptyString && group.elements.length === 0);
return (
<Box $margin={{ top: 'base' }}>
<Command.Group
@@ -36,6 +37,7 @@ export const QuickSearchGroup = <T,>({
{group.elements.map((groupElement, index) => {
return (
<QuickSearchItem
id={`${group.groupName}-element-${index}`}
key={`${group.groupName}-element-${index}`}
onSelect={() => {
onSelect?.(groupElement);

View File

@@ -3,10 +3,16 @@ import { PropsWithChildren } from 'react';
type Props = {
onSelect?: (value: string) => void;
id?: string;
};
export const QuickSearchItem = ({
children,
onSelect,
id,
}: PropsWithChildren<Props>) => {
return <Command.Item onSelect={onSelect}>{children}</Command.Item>;
return (
<Command.Item value={id} onSelect={onSelect}>
{children}
</Command.Item>
);
};

View File

@@ -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 (
<Box
$direction="row"
@@ -30,7 +33,7 @@ export const QuickSearchItemContent = ({
{left}
</Box>
{right && (
{isDesktop && right && (
<Box
className={!alwaysShowRight ? 'show-right-on-focus' : ''}
$direction="row"

View File

@@ -0,0 +1,4 @@
export * from './QuickSearch';
export * from './QuickSearchGroup';
export * from './QuickSearchItem';
export * from './QuickSearchItemContent';

View File

@@ -29,6 +29,7 @@ export type DocsParams = {
page: number;
ordering?: DocsOrdering;
is_creator_me?: boolean;
title?: string;
};
export type DocsResponse = APIList<Doc>;
@@ -45,6 +46,10 @@ export const getDocs = async (params: DocsParams): Promise<DocsResponse> => {
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) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

View File

@@ -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 (
<div data-testid={`doc-search-item-${doc.id}`}>
<QuickSearchItemContent
left={
<Box $direction="row" $align="center" $gap="10px">
<Box $flex={isDesktop ? 9 : 1}>
<SimpleDocItem doc={doc} showAccesses />
</Box>
{isDesktop && (
<Box $flex={2} $justify="center" $align="center">
<Text $variation="500" $align="right" $size="xs">
{DateTime.fromISO(doc.updated_at).toRelative()}
</Text>
</Box>
)}
</Box>
}
right={
<Icon iconName="keyboard_return" $theme="primary" $variation="800" />
}
/>
</div>
);
};

View File

@@ -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<Doc> = 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: <InView onChange={() => void fetchNextPage()} /> }]
: [],
};
}, [data, hasNextPage, fetchNextPage, t, search]);
return (
<Modal
{...modalProps}
closeOnClickOutside
size={isDesktop ? ModalSize.LARGE : ModalSize.FULL}
>
<Box
aria-label={t('Search modal')}
$direction="column"
$justify="space-between"
>
<QuickSearch
placeholder={t('Type the name of a document')}
loading={loading}
data={[]}
onFilter={handleInputSearch}
>
<Box $height={isDesktop ? '500px' : 'calc(100vh - 68px - 1rem)'}>
{search.length === 0 && (
<Box
$direction="column"
$height="100%"
$align="center"
$justify="center"
>
<Image
width={320}
src={EmptySearchIcon}
alt={t('No active search')}
/>
</Box>
)}
{search && (
<QuickSearchGroup
onSelect={handleSelect}
group={docsData}
renderElement={(doc) => <DocSearchItem doc={doc} />}
/>
)}
</Box>
</QuickSearch>
</Box>
</Modal>
);
};

View File

@@ -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}
</Text>
<Box $direction="row" $align="center" $gap={spacings['3xs']}>
{!isDesktop && (
{(!isDesktop || showAccesses) && (
<>
{isPublic && <Icon iconName="public" $size="16px" />}
{isShared && <Icon iconName="group" $size="16px" />}

View File

@@ -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 (
<Box $width="100%">
<SeparatedSection>
<Box
$padding={{ horizontal: 'sm' }}
$width="100%"
$direction="row"
$justify="space-between"
$align="center"
>
<Box $direction="row" $gap="2px">
<Button
onClick={goToHome}
size="medium"
color="primary-text"
icon={<Icon iconName="house" />}
/>
<>
<Box $width="100%">
<SeparatedSection>
<Box
$padding={{ horizontal: 'sm' }}
$width="100%"
$direction="row"
$justify="space-between"
$align="center"
>
<Box $direction="row" $gap="2px">
<Button
onClick={goToHome}
size="medium"
color="primary-text"
icon={
<Icon $variation="800" $theme="primary" iconName="house" />
}
/>
<Button
onClick={searchModal.open}
size="medium"
color="primary-text"
icon={
<Icon $variation="800" $theme="primary" iconName="search" />
}
/>
</Box>
<Button onClick={createNewDoc}>{t('New doc')}</Button>
</Box>
<Button onClick={createNewDoc}>{t('New doc')}</Button>
</Box>
</SeparatedSection>
{children}
</Box>
</SeparatedSection>
{children}
</Box>
{searchModal.isOpen && (
<DocSearchModal {...searchModal} size={ModalSize.LARGE} />
)}
</>
);
};