mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-18 15:50:21 +02:00
Integrate Find (#1834)
## Purpose integrate Find to Docs ## Proposal - [x] ✨ add a `useSeachDocs` hook in charged of calling the search endpoint. - [x] ✨ add a optional `path` param to the `search` route. This param represents the parent document path in case of a sub-documents (descendants) search. - [x] ⚡️return Indexer results directly without DB calls to retrieve the Document objects. All informations necessary for display are indexed in Find. We can skip the DB calls and improve performance. - [x] ♻️ refactor react `DocSearchContent` components. `DocSearchContent` and `DocSearchSubContent` are now merged a unique component handling all search scenarios and relying on the unique `search` route. - [x] 🔥remove pagination logic in the Indexer. Removing the DB calls also removes the DRF queryset object which handles the pagination. Also we consider pagination not to be necessary for search v1. - [x] 🔥remove the `document/<document_id>/descendants` route. This route is not used anymore. The logic of finding the descendants are moved to the internal `_list_descendants` method. This method is based on the parent `path` instead of the parent `id` which has some consequence about the user access management. Relying on the path prevents the use of the `self.get_object()` method which used to handle the user access logic. - [x] ✨handle fallback logic on DRF based title search in case of non-configured, badly configured or failing at run time indexer. - [x] ✨handle language extension in `title` field. Find returns titles with a language extension (ex: `{ title.fr: "rapport d'activité" }` instead of `{ "title": "rapport d'activité" }`. - [x] 🔧 add a `common.test` file to allow running the tests without docker - [x] ♻️ rename `SearchIndexer` -> `FindDocumentIndexer`. This class has to do with Find in particular and the convention is more coherent with `BaseDocumentIndexer` - [x] ♻️ rename `SEARCH_INDEXER_URL` -> `INDEXING_URL` and `SEARCH_INDEXER_QUERY_URL` -> `SEARCH_URL`. I found the original names very confusing. - [x] 🔧 update the environment variables to activate the FindDocumentIndexer. - [x] ✨automate the generation of encryption key during bootstrap. OIDC_STORE_REFRESH_TOKEN_KEY is a mandatory secret key. We can not push it on Github and we want any contributor to be able to run the app by only running the `make bootstrap`. We chose to generate and wright it into the `common.local` during bootstrap. ## External contributions Thank you for your contribution! 🎉 Please ensure the following items are checked before submitting your pull request: - [x] I have read and followed the [contributing guidelines](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md) - [x] I have read and agreed to the [Code of Conduct](https://github.com/suitenumerique/docs/blob/main/CODE_OF_CONDUCT.md) - [x] I have signed off my commits with `git commit --signoff` (DCO compliance) - [x] I have signed my commits with my SSH or GPG key (`git commit -S`) - [x] My commit messages follow the required format: `<gitmoji>(type) title description` - [x] I have added a changelog entry under `## [Unreleased]` section (if noticeable change) - [x] I have added corresponding tests for new features or bug fixes (if applicable) --------- Signed-off-by: charles <charles.englebert@protonmail.com>
This commit is contained in:
+9
-5
@@ -3,6 +3,7 @@ import {
|
||||
StyleSchema,
|
||||
} from '@blocknote/core';
|
||||
import { useBlockNoteEditor } from '@blocknote/react';
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -26,12 +27,13 @@ import {
|
||||
import FoundPageIcon from '@/docs/doc-editor/assets/doc-found.svg';
|
||||
import AddPageIcon from '@/docs/doc-editor/assets/doc-plus.svg';
|
||||
import {
|
||||
Doc,
|
||||
getEmojiAndTitle,
|
||||
useCreateChildDocTree,
|
||||
useDocStore,
|
||||
useTrans,
|
||||
} from '@/docs/doc-management';
|
||||
import { DocSearchSubPageContent, DocSearchTarget } from '@/docs/doc-search';
|
||||
import { DocSearchContent, DocSearchTarget } from '@/docs/doc-search';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
const inputStyle = css`
|
||||
@@ -87,7 +89,7 @@ export const SearchPage = ({
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { untitledDocument } = useTrans();
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
/**
|
||||
* createReactInlineContentSpec add automatically the focus after
|
||||
* the inline content, so we need to set the focus on the input
|
||||
@@ -226,9 +228,11 @@ export const SearchPage = ({
|
||||
`}
|
||||
$margin={{ top: '0.5rem' }}
|
||||
>
|
||||
<DocSearchSubPageContent
|
||||
<DocSearchContent
|
||||
groupName={t('Select a document')}
|
||||
search={search}
|
||||
filters={{ target: DocSearchTarget.CURRENT }}
|
||||
target={DocSearchTarget.CURRENT}
|
||||
parentPath={treeContext?.root?.path}
|
||||
onSelect={(doc) => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
@@ -256,7 +260,7 @@ export const SearchPage = ({
|
||||
|
||||
editor.focus();
|
||||
}}
|
||||
renderElement={(doc) => {
|
||||
renderSearchElement={(doc) => {
|
||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(
|
||||
doc.title || untitledDocument,
|
||||
);
|
||||
|
||||
@@ -9,5 +9,5 @@ export * from './useDocsFavorite';
|
||||
export * from './useDuplicateDoc';
|
||||
export * from './useMoveDoc';
|
||||
export * from './useRestoreDoc';
|
||||
export * from './useSubDocs';
|
||||
export * from './useUpdateDoc';
|
||||
export * from './useSearchDocs';
|
||||
|
||||
@@ -15,7 +15,6 @@ export type DocsParams = {
|
||||
page: number;
|
||||
ordering?: DocsOrdering;
|
||||
is_creator_me?: boolean;
|
||||
title?: string;
|
||||
is_favorite?: boolean;
|
||||
};
|
||||
|
||||
@@ -31,9 +30,6 @@ export const constructParams = (params: DocsParams): URLSearchParams => {
|
||||
if (params.is_creator_me !== undefined) {
|
||||
searchParams.set('is_creator_me', params.is_creator_me.toString());
|
||||
}
|
||||
if (params.title && params.title.length > 0) {
|
||||
searchParams.set('title', params.title);
|
||||
}
|
||||
if (params.is_favorite !== undefined) {
|
||||
searchParams.set('is_favorite', params.is_favorite.toString());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
APIError,
|
||||
APIList,
|
||||
errorCauses,
|
||||
fetchAPI,
|
||||
useAPIInfiniteQuery,
|
||||
} from '@/api';
|
||||
import { Doc } from '@/docs/doc-management';
|
||||
import { DocSearchTarget } from '@/docs/doc-search';
|
||||
|
||||
export type SearchDocsParams = {
|
||||
page: number;
|
||||
q: string;
|
||||
target?: DocSearchTarget;
|
||||
parentPath?: string;
|
||||
};
|
||||
|
||||
const constructParams = ({
|
||||
q,
|
||||
page,
|
||||
target,
|
||||
parentPath,
|
||||
}: SearchDocsParams): URLSearchParams => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
searchParams.set('q', q);
|
||||
|
||||
if (target === DocSearchTarget.CURRENT && parentPath) {
|
||||
searchParams.set('path', parentPath);
|
||||
}
|
||||
if (page) {
|
||||
searchParams.set('page', page.toString());
|
||||
}
|
||||
|
||||
return searchParams;
|
||||
};
|
||||
|
||||
const searchDocs = async ({
|
||||
q,
|
||||
page,
|
||||
target,
|
||||
parentPath,
|
||||
}: SearchDocsParams): Promise<APIList<Doc>> => {
|
||||
const searchParams = constructParams({ q, page, target, parentPath });
|
||||
const response = await fetchAPI(
|
||||
`documents/search/?${searchParams.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to get the docs', await errorCauses(response));
|
||||
}
|
||||
|
||||
return response.json() as Promise<APIList<Doc>>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_SEARCH_DOC = 'search-docs';
|
||||
|
||||
export const useSearchDocs = (
|
||||
{ q, page, target, parentPath }: 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 }),
|
||||
...queryConfig,
|
||||
});
|
||||
};
|
||||
|
||||
export const useInfiniteSearchDocs = (
|
||||
params: SearchDocsParams,
|
||||
queryConfig?: { enabled?: boolean },
|
||||
) => {
|
||||
return useAPIInfiniteQuery(
|
||||
KEY_LIST_SEARCH_DOC,
|
||||
searchDocs,
|
||||
params,
|
||||
queryConfig,
|
||||
);
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
APIError,
|
||||
InfiniteQueryConfig,
|
||||
errorCauses,
|
||||
fetchAPI,
|
||||
useAPIInfiniteQuery,
|
||||
} from '@/api';
|
||||
|
||||
import { DocsOrdering } from '../types';
|
||||
|
||||
import { DocsResponse, constructParams } from './useDocs';
|
||||
|
||||
export type SubDocsParams = {
|
||||
page: number;
|
||||
ordering?: DocsOrdering;
|
||||
is_creator_me?: boolean;
|
||||
title?: string;
|
||||
is_favorite?: boolean;
|
||||
parent_id: string;
|
||||
};
|
||||
|
||||
export const getSubDocs = async (
|
||||
params: SubDocsParams,
|
||||
): Promise<DocsResponse> => {
|
||||
const searchParams = constructParams(params);
|
||||
searchParams.set('parent_id', params.parent_id);
|
||||
|
||||
const response: Response = await fetchAPI(
|
||||
`documents/${params.parent_id}/descendants/?${searchParams.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to get the sub docs',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<DocsResponse>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_SUB_DOC = 'sub-docs';
|
||||
|
||||
export function useSubDocs(
|
||||
params: SubDocsParams,
|
||||
queryConfig?: UseQueryOptions<DocsResponse, APIError, DocsResponse>,
|
||||
) {
|
||||
return useQuery<DocsResponse, APIError, DocsResponse>({
|
||||
queryKey: [KEY_LIST_SUB_DOC, params],
|
||||
queryFn: () => getSubDocs(params),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
|
||||
export const useInfiniteSubDocs = (
|
||||
params: SubDocsParams,
|
||||
queryConfig?: InfiniteQueryConfig<DocsResponse>,
|
||||
) => {
|
||||
return useAPIInfiniteQuery(KEY_LIST_SUB_DOC, getSubDocs, params, queryConfig);
|
||||
};
|
||||
+21
-7
@@ -4,7 +4,10 @@ import { InView } from 'react-intersection-observer';
|
||||
|
||||
import { Box } from '@/components/';
|
||||
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
|
||||
import { Doc, useInfiniteDocs } from '@/docs/doc-management';
|
||||
import { useInfiniteSearchDocs } from '@/docs/doc-management/api/useSearchDocs';
|
||||
import { DocSearchTarget } from '@/docs/doc-search';
|
||||
|
||||
import { Doc } from '../../doc-management';
|
||||
|
||||
import { DocSearchItem } from './DocSearchItem';
|
||||
|
||||
@@ -15,6 +18,8 @@ type DocSearchContentProps = {
|
||||
isSearchNotMandatory?: boolean;
|
||||
onSelect: (doc: Doc) => void;
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
target?: DocSearchTarget;
|
||||
parentPath?: string;
|
||||
renderSearchElement?: (doc: Doc) => React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -25,6 +30,8 @@ export const DocSearchContent = ({
|
||||
onSelect,
|
||||
onLoadingChange,
|
||||
renderSearchElement,
|
||||
target,
|
||||
parentPath,
|
||||
isSearchNotMandatory,
|
||||
}: DocSearchContentProps) => {
|
||||
const {
|
||||
@@ -34,10 +41,17 @@ export const DocSearchContent = ({
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
} = useInfiniteDocs({
|
||||
page: 1,
|
||||
...(search ? { title: search } : {}),
|
||||
});
|
||||
} = useInfiniteSearchDocs(
|
||||
{
|
||||
q: search,
|
||||
page: 1,
|
||||
target,
|
||||
parentPath,
|
||||
},
|
||||
{
|
||||
enabled: target !== DocSearchTarget.CURRENT || !!parentPath,
|
||||
},
|
||||
);
|
||||
|
||||
const loading = isFetching || isRefetching || isLoading;
|
||||
const [docsData, setDocsData] = useState<QuickSearchData<Doc>>({
|
||||
@@ -79,12 +93,12 @@ export const DocSearchContent = ({
|
||||
}, [
|
||||
search,
|
||||
data?.pages,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
filterResults,
|
||||
groupName,
|
||||
isSearchNotMandatory,
|
||||
loading,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+26
-32
@@ -1,4 +1,5 @@
|
||||
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { TreeContextType, useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
@@ -8,45 +9,39 @@ 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 { useResponsiveStore } from '@/stores';
|
||||
|
||||
import EmptySearchIcon from '../assets/illustration-docs-empty.png';
|
||||
|
||||
import { DocSearchContent } from './DocSearchContent';
|
||||
import {
|
||||
DocSearchFilters,
|
||||
DocSearchFiltersValues,
|
||||
DocSearchTarget,
|
||||
} from './DocSearchFilters';
|
||||
import { DocSearchItem } from './DocSearchItem';
|
||||
import { DocSearchSubPageContent } from './DocSearchSubPageContent';
|
||||
|
||||
type DocSearchModalGlobalProps = {
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
showFilters?: boolean;
|
||||
defaultFilters?: DocSearchFiltersValues;
|
||||
treeContext?: TreeContextType<Doc> | null;
|
||||
};
|
||||
|
||||
const DocSearchModalGlobal = ({
|
||||
showFilters = false,
|
||||
defaultFilters,
|
||||
treeContext,
|
||||
...modalProps
|
||||
}: DocSearchModalGlobalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const isDocPage = router.pathname === '/docs/[id]';
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [filters, setFilters] = useState<DocSearchFiltersValues>(
|
||||
defaultFilters ?? {},
|
||||
);
|
||||
|
||||
const target = filters.target ?? DocSearchTarget.ALL;
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
const handleInputSearch = useDebouncedCallback(setSearch, 300);
|
||||
|
||||
const handleSelect = (doc: Doc) => {
|
||||
@@ -121,25 +116,22 @@ const DocSearchModalGlobal = ({
|
||||
</Box>
|
||||
)}
|
||||
{search && (
|
||||
<>
|
||||
{target === DocSearchTarget.ALL && (
|
||||
<DocSearchContent
|
||||
groupName={t('Select a document')}
|
||||
search={search}
|
||||
onSelect={handleSelect}
|
||||
onLoadingChange={setLoading}
|
||||
/>
|
||||
)}
|
||||
{isDocPage && target === DocSearchTarget.CURRENT && (
|
||||
<DocSearchSubPageContent
|
||||
search={search}
|
||||
filters={filters}
|
||||
onSelect={handleSelect}
|
||||
onLoadingChange={setLoading}
|
||||
renderElement={(doc) => <DocSearchItem doc={doc} />}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<DocSearchContent
|
||||
groupName={t('Select a document')}
|
||||
search={search}
|
||||
onSelect={handleSelect}
|
||||
onLoadingChange={setLoading}
|
||||
target={
|
||||
filters.target === DocSearchTarget.CURRENT
|
||||
? DocSearchTarget.CURRENT
|
||||
: DocSearchTarget.ALL
|
||||
}
|
||||
parentPath={
|
||||
filters.target === DocSearchTarget.CURRENT
|
||||
? treeContext?.root?.path
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</QuickSearch>
|
||||
@@ -158,6 +150,7 @@ const DocSearchModalDetail = ({
|
||||
}: DocSearchModalDetailProps) => {
|
||||
const { hasChildren, isChild } = useDocUtils(doc);
|
||||
const isWithChildren = isChild || hasChildren;
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
|
||||
let defaultFilters = DocSearchTarget.ALL;
|
||||
let showFilters = false;
|
||||
@@ -171,6 +164,7 @@ const DocSearchModalDetail = ({
|
||||
{...modalProps}
|
||||
showFilters={showFilters}
|
||||
defaultFilters={{ target: defaultFilters }}
|
||||
treeContext={treeContext}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import { t } from 'i18next';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
|
||||
import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search';
|
||||
import { Doc, useInfiniteSubDocs } from '@/docs/doc-management';
|
||||
|
||||
import { DocSearchFiltersValues } from './DocSearchFilters';
|
||||
|
||||
type DocSearchSubPageContentProps = {
|
||||
search: string;
|
||||
filters: DocSearchFiltersValues;
|
||||
onSelect: (doc: Doc) => void;
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
renderElement: (doc: Doc) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const DocSearchSubPageContent = ({
|
||||
search,
|
||||
filters,
|
||||
onSelect,
|
||||
onLoadingChange,
|
||||
renderElement,
|
||||
}: DocSearchSubPageContentProps) => {
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
|
||||
const {
|
||||
data: subDocsData,
|
||||
isFetching,
|
||||
isRefetching,
|
||||
isLoading,
|
||||
fetchNextPage: subDocsFetchNextPage,
|
||||
hasNextPage: subDocsHasNextPage,
|
||||
} = useInfiniteSubDocs(
|
||||
{
|
||||
page: 1,
|
||||
title: search,
|
||||
...filters,
|
||||
parent_id: treeContext?.root?.id ?? '',
|
||||
},
|
||||
{
|
||||
enabled: !!treeContext?.root?.id,
|
||||
},
|
||||
);
|
||||
const [docsData, setDocsData] = useState<QuickSearchData<Doc>>({
|
||||
groupName: '',
|
||||
elements: [],
|
||||
emptyString: '',
|
||||
});
|
||||
|
||||
const loading = isFetching || isRefetching || isLoading;
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subDocs = subDocsData?.pages.flatMap((page) => page.results) || [];
|
||||
|
||||
if (treeContext?.root) {
|
||||
const isRootTitleIncludeSearch = treeContext.root?.title
|
||||
?.toLowerCase()
|
||||
.includes(search.toLowerCase());
|
||||
|
||||
if (isRootTitleIncludeSearch) {
|
||||
subDocs.unshift(treeContext.root);
|
||||
}
|
||||
}
|
||||
|
||||
setDocsData({
|
||||
groupName: subDocs.length > 0 ? t('Select a doc') : '',
|
||||
elements: search ? subDocs : [],
|
||||
emptyString: search ? t('No document found') : t('Search by title'),
|
||||
endActions: subDocsHasNextPage
|
||||
? [
|
||||
{
|
||||
content: <InView onChange={() => void subDocsFetchNextPage()} />,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
});
|
||||
}, [
|
||||
loading,
|
||||
search,
|
||||
subDocsData?.pages,
|
||||
subDocsFetchNextPage,
|
||||
subDocsHasNextPage,
|
||||
treeContext?.root,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
onLoadingChange?.(loading);
|
||||
}, [loading, onLoadingChange]);
|
||||
|
||||
return (
|
||||
<QuickSearchGroup
|
||||
onSelect={onSelect}
|
||||
group={docsData}
|
||||
renderElement={renderElement}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './DocSearchContent';
|
||||
export * from './DocSearchModal';
|
||||
export * from './DocSearchFilters';
|
||||
export * from './DocSearchSubPageContent';
|
||||
|
||||
Reference in New Issue
Block a user