(frontend) add top parent on sub docs search

In the search modal, under the sub docs,
we now display the top parent of the doc, to give
more context to the user about the doc they are
looking for.
We refactorize the filters to get more flexibility
and avoid too much props drilling.
This commit is contained in:
Anthony LC
2026-06-10 16:44:49 +02:00
parent da6e65b204
commit 359fb42605
15 changed files with 255 additions and 174 deletions
+1
View File
@@ -8,6 +8,7 @@ and this project adheres to
### Added
- ✨(frontend) add top parent on sub docs search #1952
- ✨(frontend) unauthenticated users can search #2407
### Changed
@@ -14,7 +14,7 @@ export type QuickSearchAction = {
};
export type QuickSearchData<T> = {
groupName: string;
groupName?: string;
groupKey?: string;
elements: T[];
emptyString?: string;
@@ -16,6 +16,7 @@ export const QuickSearchStyle = createGlobalStyle`
}
[cmdk-input] {
font-family: var(--c--globals--font--families--base);
border: none;
width: 100%;
font-size: 16px;
@@ -10,4 +10,3 @@ export * from './useDuplicateDoc';
export * from './useMoveDoc';
export * from './useRestoreDoc';
export * from './useUpdateDoc';
export * from './useSearchDocs';
@@ -1,10 +1,10 @@
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import ArrowSVG from '@/assets/icons/ui-kit/subdirectory_arrow_right.svg';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useDate } from '@/hooks/useDate';
import { useResponsiveStore } from '@/stores';
import ChildDocument from '../assets/child-document.svg';
import PinnedDocumentIcon from '../assets/pinned-document.svg';
@@ -20,22 +20,24 @@ const ItemTextCss = css`
line-clamp: 1;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
justify-content: center;
`;
type SimpleDocItemProps = {
doc: Doc;
breadcrumb?: string;
isPinned?: boolean;
showAccesses?: boolean;
showDate?: boolean;
};
export const SimpleDocItem = ({
doc,
isPinned = false,
showAccesses = false,
showDate = false,
breadcrumb,
}: SimpleDocItemProps) => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const { untitledDocument } = useTrans();
const { isChild } = useDocUtils(doc);
const { relativeDate, formatDate } = useDate();
@@ -90,7 +92,7 @@ export const SimpleDocItem = ({
/>
)}
</Box>
<Box $justify="center" $overflow="auto">
<Box $justify="center" $overflow="auto" $gap="3xs">
<Text
$size="sm"
$weight="500"
@@ -99,17 +101,41 @@ export const SimpleDocItem = ({
>
{docTitle}
</Text>
{(!isDesktop || showAccesses) && (
<Box
$direction="row"
$align="center"
$gap={spacingsTokens['3xs']}
$margin={{ top: '-2px' }}
aria-hidden="true"
>
<Text $size="xs" $variation="tertiary">
{docRelativeUpdate}
</Text>
{(showDate || breadcrumb) && (
<Box $direction="row" $align="center" aria-hidden="true">
{breadcrumb && (
<Box
$direction="row"
$align="center"
$gap="3xs"
$css={css`
& > svg {
margin-top: -2px;
}
`}
>
<ArrowSVG
width="16px"
height="16px"
aria-hidden="true"
color="var(--c--contextuals--content--semantic--neutral--tertiary)"
/>
<Text $size="xs" $variation="tertiary" $css={ItemTextCss}>
{breadcrumb}
</Text>
</Box>
)}
{breadcrumb && showDate && (
<Text $size="xs" $variation="tertiary">
&nbsp;·&nbsp;
</Text>
)}
{showDate && (
<Text $size="xs" $variation="tertiary">
{docRelativeUpdate}
</Text>
)}
</Box>
)}
</Box>
@@ -8,26 +8,27 @@ import {
useAPIInfiniteQuery,
} from '@/api';
import { Doc } from '@/docs/doc-management';
import { DocSearchTarget } from '@/docs/doc-search';
import { DocSearchFilterTypes } from '../types';
export type SearchDocsParams = {
page: number;
q: string;
target?: DocSearchTarget;
filter?: DocSearchFilterTypes;
parentPath?: string;
};
const constructParams = ({
q,
page,
target,
filter,
parentPath,
}: SearchDocsParams): URLSearchParams => {
const searchParams = new URLSearchParams();
searchParams.set('q', q);
if (target === DocSearchTarget.CURRENT && parentPath) {
if (filter === 'current' && parentPath) {
searchParams.set('path', parentPath);
}
if (page) {
@@ -37,13 +38,19 @@ const constructParams = ({
return searchParams;
};
export type DocSearch = Doc & {
parent: Doc | null;
};
type SearchDocsResponse = APIList<DocSearch>;
const searchDocs = async ({
q,
page,
target,
filter,
parentPath,
}: SearchDocsParams): Promise<APIList<Doc>> => {
const searchParams = constructParams({ q, page, target, parentPath });
}: SearchDocsParams): Promise<SearchDocsResponse> => {
const searchParams = constructParams({ q, page, filter, parentPath });
const response = await fetchAPI(
`documents/search/?${searchParams.toString()}`,
);
@@ -52,18 +59,18 @@ const searchDocs = async ({
throw new APIError('Failed to get the docs', await errorCauses(response));
}
return response.json() as Promise<APIList<Doc>>;
return response.json() as Promise<SearchDocsResponse>;
};
export const KEY_LIST_SEARCH_DOC = 'search-docs';
export const useSearchDocs = (
{ q, page, target, parentPath }: SearchDocsParams,
param: SearchDocsParams,
queryConfig?: { enabled?: boolean },
) => {
return useQuery<APIList<Doc>, APIError, APIList<Doc>>({
queryKey: [KEY_LIST_SEARCH_DOC, 'search', { q, page, target, parentPath }],
queryFn: () => searchDocs({ q, page, target, parentPath }),
return useQuery<SearchDocsResponse, APIError, SearchDocsResponse>({
queryKey: [KEY_LIST_SEARCH_DOC, param],
queryFn: () => searchDocs(param),
...queryConfig,
});
};
@@ -58,6 +58,7 @@ export const DocSearchButtonModal = () => {
onClose={closeSearchModal}
isOpen={isSearchModalOpen}
doc={currentDoc}
defaultFilters="all"
/>
)}
</>
@@ -5,24 +5,22 @@ import { InView } from 'react-intersection-observer';
import { Box } from '@/components/';
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
import { useInfiniteSearchDocs } from '@/docs/doc-management/api/useSearchDocs';
import { DocSearchTarget } from '@/docs/doc-search';
import { Doc } from '../../doc-management';
import { DocSearch, useInfiniteSearchDocs } from '../api/useSearchDocs';
import { useDocSearchFilterStore } from '../stores/useDocSearchFilterStore';
import { DocSearchItem } from './DocSearchItem';
type DocSearchContentProps = {
groupName: string;
groupName?: string;
search: string;
filterResults?: (doc: Doc) => boolean;
filterResults?: (doc: DocSearch) => boolean;
isSearchNotMandatory?: boolean;
onResults?: (results: Doc[]) => void;
onSelect: (doc: Doc) => void;
onResults?: (results: DocSearch[]) => void;
onSelect: (doc: DocSearch) => void;
onLoadingChange?: (loading: boolean) => void;
target?: DocSearchTarget;
parentPath?: string;
renderSearchElement?: (doc: Doc) => React.ReactNode;
renderSearchElement?: (doc: DocSearch) => React.ReactNode;
};
export const DocSearchContent = ({
@@ -33,10 +31,10 @@ export const DocSearchContent = ({
onSelect,
onLoadingChange,
renderSearchElement,
target,
parentPath,
isSearchNotMandatory,
}: DocSearchContentProps) => {
const { filter } = useDocSearchFilterStore();
const {
data,
isFetching,
@@ -48,16 +46,16 @@ export const DocSearchContent = ({
{
q: search,
page: 1,
target,
filter,
parentPath,
},
{
enabled: target !== DocSearchTarget.CURRENT || !!parentPath,
enabled: filter !== 'current' || !!parentPath,
},
);
const loading = isFetching || isRefetching || isLoading;
const [docsData, setDocsData] = useState<QuickSearchData<Doc>>({
const [docsData, setDocsData] = useState<QuickSearchData<DocSearch>>({
groupName: '',
groupKey: 'docs',
elements: [],
@@ -84,7 +82,6 @@ export const DocSearchContent = ({
groupName: groupName,
groupKey: 'docs',
elements,
emptyString: t('No document found'),
endActions: hasNextPage
? [
{
@@ -1,73 +1,52 @@
import { Button } from '@gouvfr-lasuite/cunningham-react';
import { Switch } from '@gouvfr-lasuite/cunningham-react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box } from '@/components';
import { FilterDropdown } from '@/components/filter/FilterDropdown';
export enum DocSearchTarget {
ALL = 'all',
CURRENT = 'current',
}
import { useDocSearchFilterStore } from '../stores/useDocSearchFilterStore';
export type DocSearchFiltersValues = {
target?: DocSearchTarget;
};
export type DocSearchFiltersProps = {
values?: DocSearchFiltersValues;
onValuesChange?: (values: DocSearchFiltersValues) => void;
onReset?: () => void;
};
export const DocSearchFilters = ({
values,
onValuesChange,
onReset,
}: DocSearchFiltersProps) => {
export const DocSearchFilters = () => {
const { t } = useTranslation();
const hasFilters = Object.keys(values ?? {}).length > 0;
const handleTargetChange = (target: DocSearchTarget) => {
onValuesChange?.({ ...values, target });
};
const { setFilter, filter } = useDocSearchFilterStore();
return (
/**
* The switch is not focusable, so we wrap it in a div that can be focused
* and handle the keydown event to toggle the switch with
* space key for accessibility reasons
*/
<Box
$direction="row"
$align="center"
$height="35px"
$justify="space-between"
$gap="10px"
data-testid="doc-search-filters"
$margin={{ vertical: 'base' }}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === ' ') {
e.preventDefault();
setFilter(filter === 'all' ? 'current' : 'all');
}
}}
$css={css`
&:focus-visible .c__switch__rail {
outline: none;
box-shadow: 0 0 0 2px
var(--c--contextuals--border--semantic--brand--primary);
}
// Remove the default focus style of the switch component
.c__checkbox:focus-within {
border: none;
box-shadow: none;
outline: 0;
}
`}
>
<Box $direction="row" $align="center" $gap="10px">
<FilterDropdown
selectedValue={values?.target}
options={[
{
label: t('All docs'),
value: DocSearchTarget.ALL,
callback: () => handleTargetChange(DocSearchTarget.ALL),
},
{
label: t('Current doc'),
value: DocSearchTarget.CURRENT,
callback: () => handleTargetChange(DocSearchTarget.CURRENT),
},
]}
/>
</Box>
{hasFilters && (
<Button
color="brand"
variant="tertiary"
size="small"
onClick={onReset}
aria-label={t('Reset search filters')}
>
{t('Reset')}
</Button>
)}
<Switch
labelSide="right"
label={t('All docs')}
checked={filter === 'all'}
onChange={() => setFilter(filter === 'all' ? 'current' : 'all')}
aria-label={t(
'Toggle to search in all documents or only in current document',
)}
/>
</Box>
);
};
@@ -1,14 +1,20 @@
import ArrowIcon from '@/assets/icons/ui-kit/enter.svg';
import { Box, Icon } from '@/components';
import { QuickSearchItemContent } from '@/components/quick-search/';
import { Doc, SimpleDocItem } from '@/docs/doc-management';
import { SimpleDocItem } from '@/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { DocSearch } from '../api/useSearchDocs';
import { useDocSearchFilterStore } from '../stores/useDocSearchFilterStore';
type DocSearchItemProps = {
doc: Doc;
doc: DocSearch;
};
export const DocSearchItem = ({ doc }: DocSearchItemProps) => {
const { isDesktop } = useResponsiveStore();
const { filter } = useDocSearchFilterStore();
return (
<Box
data-testid={`doc-search-item-${doc.id}`}
@@ -19,15 +25,21 @@ export const DocSearchItem = ({ doc }: DocSearchItemProps) => {
left={
<Box $direction="row" $align="center" $gap="10px" $width="100%">
<Box $flex={isDesktop ? 9 : 1}>
<SimpleDocItem doc={doc} showAccesses />
<SimpleDocItem
doc={doc}
showDate
isPinned={doc.is_favorite}
breadcrumb={filter === 'all' ? doc.parent?.title : undefined}
/>
</Box>
</Box>
}
right={
<Icon
iconName="keyboard_return"
$padding={{ horizontal: '3xs' }}
$theme="brand"
$variation="secondary"
icon={<ArrowIcon width={16} height={16} aria-hidden="true" />}
/>
}
/>
@@ -2,7 +2,7 @@ import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { useDebouncedCallback } from 'use-debounce';
@@ -10,21 +10,29 @@ import { useDebouncedCallback } from 'use-debounce';
import { Box, ButtonCloseModal, Text } from '@/components';
import { QuickSearch } from '@/components/quick-search';
import { Doc, useDocUtils } from '@/docs/doc-management';
import {
DocSearchFilters,
DocSearchFiltersValues,
DocSearchTarget,
} from '@/docs/doc-search';
import { useAuth } from '@/features/auth/hooks/useAuth';
import { useFocusStore, useResponsiveStore } from '@/stores';
import { useResponsiveStore } from '@/stores';
import { DocSearch } from '../api/useSearchDocs';
import EmptySearchIcon from '../assets/illustration-docs-empty.png';
import { useDocSearchFilterStore } from '../stores/useDocSearchFilterStore';
import { DocSearchFilterTypes } from '../types';
import { DocSearchContent } from './DocSearchContent';
import { DocSearchFilters } from './DocSearchFilters';
const ModalStyle = createGlobalStyle`
.c__modal__scroller {
overflow: inherit ;
&:has(.quick-search-container) > div.c__modal__title {
padding-top: var(--c--globals--spacings--sm);
padding-bottom: var(--c--globals--spacings--xs);
padding-inline: var(--c--globals--spacings--base);
}
.quick-search-input {
padding: var(--c--globals--spacings--xxs) var(--c--globals--spacings--base);
}
}
`;
@@ -32,7 +40,7 @@ type DocSearchModalGlobalProps = {
onClose: () => void;
isOpen: boolean;
showFilters?: boolean;
defaultFilters?: DocSearchFiltersValues;
defaultFilters: DocSearchFilterTypes;
parentPath?: string; // If defined, the search will be limited to the children of the document with the given path
};
@@ -45,24 +53,36 @@ const DocSearchModalGlobal = ({
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [results, setResults] = useState<Doc[]>([]);
const restoreFocus = useFocusStore((state) => state.restoreFocus);
const router = useRouter();
const [search, setSearch] = useState('');
const [filters, setFilters] = useState<DocSearchFiltersValues>(
defaultFilters ?? {},
);
const { isLargeScreen } = useResponsiveStore();
const handleInputSearch = useDebouncedCallback(setSearch, 300);
const { filter, setFilter } = useDocSearchFilterStore();
useEffect(() => {
if (!search) {
setResults([]);
}
}, [search]);
useEffect(() => {
setFilter(defaultFilters);
}, [defaultFilters, setFilter]);
const handleSelect = (doc: Doc) => {
void router.push(`/docs/${doc.id}`);
modalProps.onClose?.();
};
const handleResetFilters = () => {
setFilters({});
restoreFocus();
};
/**
* When searching within the current document, we only want to show sub-documents
* Otherwise, we show all documents in the search results
*/
const filterResults = useCallback(
(doc: DocSearch) =>
(filter === 'current' && !!doc.parent) || filter === 'all' || !filter,
[filter],
);
return (
<Modal
@@ -71,51 +91,55 @@ const DocSearchModalGlobal = ({
size={isLargeScreen ? ModalSize.LARGE : ModalSize.FULL}
hideCloseButton
aria-describedby="doc-search-modal-title"
title={
<>
<Text as="h2" $margin="0" $size="s" $align="flex-start">
{t('Search for a document')}
</Text>
<Box $position="absolute" $css="top: 4px; right: 4px;">
<ButtonCloseModal
aria-label={t('Close the search modal')}
onClick={modalProps.onClose}
/>
</Box>
</>
}
>
<ModalStyle />
<Box
aria-label={t('Search modal')}
$direction="column"
$justify="space-between"
className="--docs--doc-search-modal"
$padding={{ vertical: 'base' }}
$padding={{ bottom: 'base' }}
aria-label={t('Search modal')}
>
<Text
as="h1"
$margin="0"
id="doc-search-modal-title"
className="sr-only"
>
{t('Search docs')}
</Text>
<Box $position="absolute" $css="top: 4px; right: 4px;">
<ButtonCloseModal
aria-label={t('Close the search modal')}
onClick={modalProps.onClose}
size="small"
color="brand"
variant="tertiary"
/>
</Box>
<QuickSearch
label={t('Search documents')}
placeholder={t('Type the name of a document')}
loading={loading}
onFilter={handleInputSearch}
beforeList={
showFilters ? (
<Box $padding={{ horizontal: '10px' }}>
<DocSearchFilters
values={filters}
onValuesChange={setFilters}
onReset={handleResetFilters}
<Box
$margin={{ top: 'sm', horizontal: 'base' }}
$justify="space-between"
$direction="row"
$align="center"
role="group"
aria-label={t('Search results controls')}
>
<Text $color="textSecondary" $weight="700">
<DocSearchStateText
hasResults={results.length > 0}
filter={filter}
isSearching={!!search}
/>
</Box>
) : undefined
</Text>
{showFilters && <DocSearchFilters />}
</Box>
}
>
<Box
$padding={{ horizontal: '10px', vertical: 'base' }}
$padding={{ horizontal: 'sm', bottom: 'base' }}
$height={isLargeScreen ? '500px' : 'calc(100vh - 68px - 1rem)'}
>
{search.length === 0 && (
@@ -130,21 +154,12 @@ const DocSearchModalGlobal = ({
)}
{search && (
<DocSearchContent
groupName={results.length ? t('Select a document') : ''}
filterResults={filterResults}
search={search}
onSelect={handleSelect}
onResults={setResults}
onLoadingChange={setLoading}
target={
filters.target === DocSearchTarget.CURRENT
? DocSearchTarget.CURRENT
: DocSearchTarget.ALL
}
parentPath={
filters.target === DocSearchTarget.CURRENT
? parentPath
: undefined
}
parentPath={filter === 'current' ? parentPath : undefined}
/>
)}
</Box>
@@ -167,18 +182,11 @@ const DocSearchModalDetail = ({
const treeContext = useTreeContext<Doc>();
const { authenticated } = useAuth();
let defaultFilters = DocSearchTarget.ALL;
let showFilters = false;
if (isWithChildren) {
defaultFilters = DocSearchTarget.CURRENT;
showFilters = authenticated;
}
return (
<DocSearchModalGlobal
{...modalProps}
showFilters={showFilters}
defaultFilters={{ target: defaultFilters }}
showFilters={isWithChildren && authenticated}
defaultFilters={isWithChildren ? 'current' : 'all'}
parentPath={treeContext?.root?.path}
/>
);
@@ -195,3 +203,31 @@ export const DocSearchModal = ({ doc, ...modalProps }: DocSearchModalProps) => {
return <DocSearchModalGlobal {...modalProps} />;
};
interface DocSearchStateTextProps {
hasResults: boolean;
filter: DocSearchFilterTypes;
isSearching: boolean;
}
const DocSearchStateText = ({
hasResults,
filter,
isSearching,
}: DocSearchStateTextProps) => {
const { t } = useTranslation();
if (hasResults && filter === 'all') {
return t('Select a document');
}
if (hasResults && filter === 'current') {
return t('Select a sub-document');
}
if (isSearching && !hasResults) {
return t('No documents found');
}
return null;
};
@@ -0,0 +1,21 @@
import { create } from 'zustand';
import { DocSearchFilterTypes } from '../types';
export interface UseDocSearchFilterStore {
filter: DocSearchFilterTypes;
setFilter: (filter: DocSearchFilterTypes) => void;
}
const defaultState: Pick<UseDocSearchFilterStore, 'filter'> = {
filter: 'all',
};
export const useDocSearchFilterStore = create<UseDocSearchFilterStore>(
(set) => ({
filter: defaultState.filter,
setFilter: (filter) => {
set({ filter });
},
}),
);
@@ -0,0 +1 @@
export type DocSearchFilterTypes = 'all' | 'current';
@@ -380,7 +380,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
tabIndex={-1} // avoid double tabstop
>
<Box $direction="row" $align="center" $width="100%">
<SimpleDocItem doc={treeContext.root} showAccesses={true} />
<SimpleDocItem doc={treeContext.root} showDate={true} />
<DocTreeItemActions
doc={treeContext.root}
onCreateSuccess={(createdDoc) => {
@@ -55,7 +55,7 @@ export const LeftPanelFavoriteItem = ({ doc }: LeftPanelFavoriteItemProps) => {
`}
aria-label={`${doc.title}, ${t('Updated')} ${DateTime.fromISO(doc.updated_at).toRelative()}`}
>
<SimpleDocItem showAccesses doc={doc} />
<SimpleDocItem showDate doc={doc} />
</StyledLink>
<Box className="pinned-actions" $align="center">
<DocsGridActions doc={doc} />