✨(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:
committed by
Anthony LC
parent
157f6200f2
commit
44784b2236
@@ -50,6 +50,7 @@ and this project adheres to
|
|||||||
|
|
||||||
## Fixed
|
## Fixed
|
||||||
- 🐛(frontend) update doc editor height #481
|
- 🐛(frontend) update doc editor height #481
|
||||||
|
- 💄(frontend) add doc search #485
|
||||||
|
|
||||||
|
|
||||||
## [1.9.0] - 2024-12-11
|
## [1.9.0] - 2024-12-11
|
||||||
|
|||||||
114
src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts
Normal file
114
src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,7 @@ export const QuickSearchGroup = <T,>({
|
|||||||
onSelect,
|
onSelect,
|
||||||
renderElement,
|
renderElement,
|
||||||
}: Props<T>) => {
|
}: Props<T>) => {
|
||||||
|
console.log('group', group, group.emptyString && group.elements.length === 0);
|
||||||
return (
|
return (
|
||||||
<Box $margin={{ top: 'base' }}>
|
<Box $margin={{ top: 'base' }}>
|
||||||
<Command.Group
|
<Command.Group
|
||||||
@@ -36,6 +37,7 @@ export const QuickSearchGroup = <T,>({
|
|||||||
{group.elements.map((groupElement, index) => {
|
{group.elements.map((groupElement, index) => {
|
||||||
return (
|
return (
|
||||||
<QuickSearchItem
|
<QuickSearchItem
|
||||||
|
id={`${group.groupName}-element-${index}`}
|
||||||
key={`${group.groupName}-element-${index}`}
|
key={`${group.groupName}-element-${index}`}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
onSelect?.(groupElement);
|
onSelect?.(groupElement);
|
||||||
|
|||||||
@@ -3,10 +3,16 @@ import { PropsWithChildren } from 'react';
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onSelect?: (value: string) => void;
|
onSelect?: (value: string) => void;
|
||||||
|
id?: string;
|
||||||
};
|
};
|
||||||
export const QuickSearchItem = ({
|
export const QuickSearchItem = ({
|
||||||
children,
|
children,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
id,
|
||||||
}: PropsWithChildren<Props>) => {
|
}: PropsWithChildren<Props>) => {
|
||||||
return <Command.Item onSelect={onSelect}>{children}</Command.Item>;
|
return (
|
||||||
|
<Command.Item value={id} onSelect={onSelect}>
|
||||||
|
{children}
|
||||||
|
</Command.Item>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
import { useCunninghamTheme } from '@/cunningham';
|
import { useCunninghamTheme } from '@/cunningham';
|
||||||
|
import { useResponsiveStore } from '@/stores';
|
||||||
|
|
||||||
import { Box } from '../Box';
|
import { Box } from '../Box';
|
||||||
|
|
||||||
@@ -18,6 +19,8 @@ export const QuickSearchItemContent = ({
|
|||||||
const { spacingsTokens } = useCunninghamTheme();
|
const { spacingsTokens } = useCunninghamTheme();
|
||||||
const spacings = spacingsTokens();
|
const spacings = spacingsTokens();
|
||||||
|
|
||||||
|
const { isDesktop } = useResponsiveStore();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
$direction="row"
|
$direction="row"
|
||||||
@@ -30,7 +33,7 @@ export const QuickSearchItemContent = ({
|
|||||||
{left}
|
{left}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{right && (
|
{isDesktop && right && (
|
||||||
<Box
|
<Box
|
||||||
className={!alwaysShowRight ? 'show-right-on-focus' : ''}
|
className={!alwaysShowRight ? 'show-right-on-focus' : ''}
|
||||||
$direction="row"
|
$direction="row"
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './QuickSearch';
|
||||||
|
export * from './QuickSearchGroup';
|
||||||
|
export * from './QuickSearchItem';
|
||||||
|
export * from './QuickSearchItemContent';
|
||||||
@@ -29,6 +29,7 @@ export type DocsParams = {
|
|||||||
page: number;
|
page: number;
|
||||||
ordering?: DocsOrdering;
|
ordering?: DocsOrdering;
|
||||||
is_creator_me?: boolean;
|
is_creator_me?: boolean;
|
||||||
|
title?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DocsResponse = APIList<Doc>;
|
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());
|
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()}`);
|
const response = await fetchAPI(`documents/?${searchParams.toString()}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 280 KiB |
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -21,12 +21,14 @@ type SimpleDocItemProps = {
|
|||||||
doc: Doc;
|
doc: Doc;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
subText?: string;
|
subText?: string;
|
||||||
|
showAccesses?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SimpleDocItem = ({
|
export const SimpleDocItem = ({
|
||||||
doc,
|
doc,
|
||||||
isPinned = false,
|
isPinned = false,
|
||||||
subText,
|
subText,
|
||||||
|
showAccesses = false,
|
||||||
}: SimpleDocItemProps) => {
|
}: SimpleDocItemProps) => {
|
||||||
const { spacingsTokens } = useCunninghamTheme();
|
const { spacingsTokens } = useCunninghamTheme();
|
||||||
const { isDesktop } = useResponsiveStore();
|
const { isDesktop } = useResponsiveStore();
|
||||||
@@ -61,7 +63,7 @@ export const SimpleDocItem = ({
|
|||||||
{doc.title}
|
{doc.title}
|
||||||
</Text>
|
</Text>
|
||||||
<Box $direction="row" $align="center" $gap={spacings['3xs']}>
|
<Box $direction="row" $align="center" $gap={spacings['3xs']}>
|
||||||
{!isDesktop && (
|
{(!isDesktop || showAccesses) && (
|
||||||
<>
|
<>
|
||||||
{isPublic && <Icon iconName="public" $size="16px" />}
|
{isPublic && <Icon iconName="public" $size="16px" />}
|
||||||
{isShared && <Icon iconName="group" $size="16px" />}
|
{isShared && <Icon iconName="group" $size="16px" />}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { Button } from '@openfun/cunningham-react';
|
import { Button, ModalSize, useModal } from '@openfun/cunningham-react';
|
||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import { Box, Icon, SeparatedSection } from '@/components';
|
import { Box, Icon, SeparatedSection } from '@/components';
|
||||||
import { useCreateDoc } from '@/features/docs';
|
import { useCreateDoc } from '@/features/docs';
|
||||||
|
import { DocSearchModal } from '@/features/docs/doc-search/components/DocSearchModal';
|
||||||
|
import { useCmdK } from '@/hook/useCmdK';
|
||||||
|
|
||||||
import { useLeftPanelStore } from '../stores';
|
import { useLeftPanelStore } from '../stores';
|
||||||
|
|
||||||
export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchModal = useModal();
|
||||||
|
useCmdK(searchModal.open);
|
||||||
const { togglePanel } = useLeftPanelStore();
|
const { togglePanel } = useLeftPanelStore();
|
||||||
|
|
||||||
const { mutate: createDoc } = useCreateDoc({
|
const { mutate: createDoc } = useCreateDoc({
|
||||||
@@ -29,27 +33,42 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box $width="100%">
|
<>
|
||||||
<SeparatedSection>
|
<Box $width="100%">
|
||||||
<Box
|
<SeparatedSection>
|
||||||
$padding={{ horizontal: 'sm' }}
|
<Box
|
||||||
$width="100%"
|
$padding={{ horizontal: 'sm' }}
|
||||||
$direction="row"
|
$width="100%"
|
||||||
$justify="space-between"
|
$direction="row"
|
||||||
$align="center"
|
$justify="space-between"
|
||||||
>
|
$align="center"
|
||||||
<Box $direction="row" $gap="2px">
|
>
|
||||||
<Button
|
<Box $direction="row" $gap="2px">
|
||||||
onClick={goToHome}
|
<Button
|
||||||
size="medium"
|
onClick={goToHome}
|
||||||
color="primary-text"
|
size="medium"
|
||||||
icon={<Icon iconName="house" />}
|
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>
|
</Box>
|
||||||
<Button onClick={createNewDoc}>{t('New doc')}</Button>
|
</SeparatedSection>
|
||||||
</Box>
|
{children}
|
||||||
</SeparatedSection>
|
</Box>
|
||||||
{children}
|
{searchModal.isOpen && (
|
||||||
</Box>
|
<DocSearchModal {...searchModal} size={ModalSize.LARGE} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user