mirror of
https://github.com/suitenumerique/docs.git
synced 2026-08-17 21:25:43 +02:00
✨(frontend) unauthenticated users can search
Unauthenticated users can now search documents inside a document depend their permissions on the document.
This commit is contained in:
@@ -6,6 +6,10 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) unauthenticated users can search #2407
|
||||
|
||||
### Changed
|
||||
|
||||
- 👷(CI) remove test-e2e-other-browser job #2404
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, verifyDocName } from './utils-common';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
import { connectOtherUserToDoc, updateShareLink } from './utils-share';
|
||||
import {
|
||||
createRootSubPage,
|
||||
navigateToTopParentFromTree,
|
||||
} from './utils-sub-pages';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -96,57 +100,6 @@ test.describe('Document search', () => {
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('it check the presence of filters in search modal', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
// Doc grid filters are not visible
|
||||
const searchButton = page.getByTestId('search-docs-button');
|
||||
|
||||
const filters = page.getByTestId('doc-search-filters');
|
||||
|
||||
await searchButton.click();
|
||||
await expect(
|
||||
page.getByRole('combobox', { name: 'Search documents' }),
|
||||
).toBeVisible();
|
||||
await expect(filters).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
// Create a doc without children for the moment
|
||||
// and check that filters are not visible
|
||||
const [doc1Title] = await createDoc(page, 'My page search', browserName, 1);
|
||||
await verifyDocName(page, doc1Title);
|
||||
|
||||
await searchButton.click();
|
||||
await expect(
|
||||
page.getByRole('combobox', { name: 'Search documents' }),
|
||||
).toBeVisible();
|
||||
await expect(filters).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
// Create a sub page
|
||||
// and check that filters are visible
|
||||
await createRootSubPage(page, browserName, 'My sub page search');
|
||||
|
||||
await searchButton.click();
|
||||
|
||||
await expect(filters).toBeVisible();
|
||||
|
||||
await filters.click();
|
||||
await filters.getByRole('button', { name: 'Current doc' }).click();
|
||||
await expect(
|
||||
page.getByRole('menuitemcheckbox', { name: 'All docs' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitemcheckbox', { name: 'Current doc' }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('menuitemcheckbox', { name: 'All docs' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('it searches sub pages', async ({ page, browserName }) => {
|
||||
// First doc
|
||||
const [firstDocTitle] = await createDoc(
|
||||
@@ -171,7 +124,7 @@ test.describe('Document search', () => {
|
||||
await page.getByRole('combobox', { name: 'Search documents' }).click();
|
||||
await page
|
||||
.getByRole('combobox', { name: 'Search documents' })
|
||||
.fill('sub page search');
|
||||
.fill('sub page');
|
||||
|
||||
// Expect to find the first and second docs in the results list
|
||||
const resultsList = page.getByRole('listbox');
|
||||
@@ -188,14 +141,14 @@ test.describe('Document search', () => {
|
||||
const { name: secondChildDocTitle } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'second - Child doc',
|
||||
'My second sub page search - Child doc',
|
||||
);
|
||||
await searchButton.click();
|
||||
await page
|
||||
.getByRole('combobox', { name: 'Search documents' })
|
||||
.fill('second');
|
||||
.fill('sub page');
|
||||
|
||||
// Now there is a sub page - expect to have the focus on the current doc
|
||||
// Display only current doc results
|
||||
const updatedResultsList = page.getByRole('listbox');
|
||||
await expect(
|
||||
updatedResultsList.getByRole('option', { name: secondDocTitle }),
|
||||
@@ -206,5 +159,63 @@ test.describe('Document search', () => {
|
||||
await expect(
|
||||
updatedResultsList.getByRole('option', { name: firstDocTitle }),
|
||||
).toBeHidden();
|
||||
|
||||
// Click on the filter to show all docs
|
||||
const filters = page.getByTestId('doc-search-filters');
|
||||
await filters.click();
|
||||
await filters.getByRole('button', { name: 'Current doc' }).click();
|
||||
await page.getByRole('menuitemcheckbox', { name: 'All docs' }).click();
|
||||
|
||||
// Expect to see all docs in the results list
|
||||
await expect(
|
||||
updatedResultsList.getByRole('option', { name: secondDocTitle }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
updatedResultsList.getByRole('option', { name: secondChildDocTitle }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
updatedResultsList.getByRole('option', { name: firstDocTitle }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
// Navigate to the top parent doc and make it public
|
||||
await navigateToTopParentFromTree({ page });
|
||||
await verifyDocName(page, secondDocTitle);
|
||||
await page.locator('[data-test="share-button"]').click();
|
||||
await updateShareLink(page, 'Public');
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
const docUrl = page.url();
|
||||
|
||||
const { otherPage, cleanup } = await connectOtherUserToDoc({
|
||||
browserName,
|
||||
docUrl,
|
||||
docTitle: secondDocTitle,
|
||||
withoutSignIn: true,
|
||||
});
|
||||
|
||||
await otherPage.getByTestId('search-docs-button').click();
|
||||
await otherPage
|
||||
.getByRole('combobox', { name: 'Search documents' })
|
||||
.fill('sub page');
|
||||
|
||||
// Search only in the current doc
|
||||
const otherPageResultsList = otherPage.getByRole('listbox');
|
||||
await expect(
|
||||
otherPageResultsList.getByRole('option', { name: secondDocTitle }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
otherPageResultsList.getByRole('option', { name: secondChildDocTitle }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
otherPageResultsList.getByRole('option', { name: firstDocTitle }),
|
||||
).toBeHidden();
|
||||
|
||||
// Filter is not displayed because the user is not connected
|
||||
const otherPageFilters = otherPage.getByTestId('doc-search-filters');
|
||||
await expect(otherPageFilters).toBeHidden();
|
||||
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,13 +179,14 @@ test.describe('Doc Tree', () => {
|
||||
await expect(allSubPageItems.nth(1).getByText('second move')).toBeVisible();
|
||||
|
||||
// Will move the first sub page to the second position
|
||||
// Use the testId-based locators for bounding box to avoid stale text locators
|
||||
// Wait for elements to be stable before reading their positions — a React
|
||||
// re-render can transiently detach nodes, making boundingBox() return null.
|
||||
await allSubPageItems.nth(0).waitFor({ state: 'visible' });
|
||||
await allSubPageItems.nth(1).waitFor({ state: 'visible' });
|
||||
|
||||
const firstSubPageBoundingBox = await allSubPageItems.nth(0).boundingBox();
|
||||
const secondSubPageBoundingBox = await allSubPageItems.nth(1).boundingBox();
|
||||
|
||||
expect(firstSubPageBoundingBox).toBeDefined();
|
||||
expect(secondSubPageBoundingBox).toBeDefined();
|
||||
|
||||
if (!firstSubPageBoundingBox || !secondSubPageBoundingBox) {
|
||||
throw new Error('unable to determine the position of the elements');
|
||||
}
|
||||
@@ -205,9 +206,10 @@ test.describe('Doc Tree', () => {
|
||||
|
||||
await page.mouse.up();
|
||||
|
||||
// check that the sub pages are visible in the tree
|
||||
await expect(firstSubPageItem).toBeVisible();
|
||||
await expect(secondSubPageItem).toBeVisible();
|
||||
// Wait for the reorder to be reflected in the tree before reloading —
|
||||
// this also ensures the API call has had time to persist the new order.
|
||||
await expect(allSubPageItems.nth(0).getByText('second move')).toBeVisible();
|
||||
await expect(allSubPageItems.nth(1).getByText('first move')).toBeVisible();
|
||||
|
||||
// reload the page
|
||||
await page.reload();
|
||||
|
||||
@@ -222,7 +222,6 @@ test.describe('Doc Visibility: Public', () => {
|
||||
|
||||
await expect(cardContainer.getByText('Public ·')).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId('search-docs-button')).toBeVisible();
|
||||
await expect(page.getByTestId('new-doc-button')).toBeVisible();
|
||||
|
||||
const docUrl = page.url();
|
||||
@@ -234,7 +233,6 @@ test.describe('Doc Visibility: Public', () => {
|
||||
});
|
||||
|
||||
await expect(otherPage.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
await expect(otherPage.getByTestId('search-docs-button')).toBeHidden();
|
||||
await expect(otherPage.getByTestId('new-doc-button')).toBeHidden();
|
||||
const card = otherPage.getByLabel('It is the card information');
|
||||
await expect(card).toBeVisible();
|
||||
@@ -306,7 +304,6 @@ test.describe('Doc Visibility: Public', () => {
|
||||
docTitle,
|
||||
});
|
||||
|
||||
await expect(otherPage.getByTestId('search-docs-button')).toBeHidden();
|
||||
await expect(otherPage.getByTestId('new-doc-button')).toBeHidden();
|
||||
|
||||
const otherEditor = await getEditor({ page: otherPage });
|
||||
|
||||
@@ -5,6 +5,7 @@ import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { Doc } from '../types';
|
||||
|
||||
import { KEY_LIST_DOC } from './useDocs';
|
||||
import { KEY_LIST_SEARCH_DOC } from './useSearchDocs';
|
||||
|
||||
export type CreateChildDocParam = Pick<Doc, 'title'> & {
|
||||
parentId: string;
|
||||
@@ -40,6 +41,9 @@ export function useCreateChildDoc({ onSuccess }: UseCreateChildDocProps) {
|
||||
void queryClient.resetQueries({
|
||||
queryKey: [KEY_LIST_DOC],
|
||||
});
|
||||
void queryClient.resetQueries({
|
||||
queryKey: [KEY_LIST_SEARCH_DOC],
|
||||
});
|
||||
onSuccess(doc);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { Doc } from '../types';
|
||||
|
||||
import { KEY_LIST_DOC } from './useDocs';
|
||||
import { KEY_LIST_SEARCH_DOC } from './useSearchDocs';
|
||||
|
||||
type CreateDocParams = {
|
||||
title?: string;
|
||||
@@ -37,6 +38,9 @@ export function useCreateDoc(options?: UseCreateDocOptions) {
|
||||
void queryClient.resetQueries({
|
||||
queryKey: [KEY_LIST_DOC],
|
||||
});
|
||||
void queryClient.resetQueries({
|
||||
queryKey: [KEY_LIST_SEARCH_DOC],
|
||||
});
|
||||
options?.onSuccess?.(data, variables, onMutateResult, context);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -94,6 +94,7 @@ export interface Doc {
|
||||
partial_update: boolean;
|
||||
restore: boolean;
|
||||
retrieve: boolean;
|
||||
search: boolean;
|
||||
update: boolean;
|
||||
versions_destroy: boolean;
|
||||
versions_list: boolean;
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { t } from 'i18next';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import SearchSVG from '@/assets/icons/ui-kit/zoom-rounded.svg';
|
||||
import { useDocStore } from '@/docs/doc-management';
|
||||
import { useAuth } from '@/features/auth';
|
||||
import { useCmdK } from '@/hooks/useCmdK';
|
||||
|
||||
const DocSearchModal = dynamic(
|
||||
() =>
|
||||
import('./DocSearchModal').then((mod) => ({
|
||||
default: mod.DocSearchModal,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export const DocSearchButtonModal = () => {
|
||||
const { currentDoc } = useDocStore();
|
||||
const { authenticated } = useAuth();
|
||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
||||
const canSearch = authenticated || currentDoc?.abilities.search;
|
||||
|
||||
const openSearchModal = useCallback(() => {
|
||||
const isEditorToolbarOpen =
|
||||
document.getElementsByClassName('bn-formatting-toolbar').length > 0;
|
||||
if (isEditorToolbarOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearchModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeSearchModal = useCallback(() => {
|
||||
setIsSearchModalOpen(false);
|
||||
}, []);
|
||||
|
||||
useCmdK(openSearchModal);
|
||||
|
||||
if (!canSearch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
data-testid="search-docs-button"
|
||||
onClick={openSearchModal}
|
||||
size="medium"
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
aria-label={t('Search docs')}
|
||||
icon={<SearchSVG aria-hidden="true" width={24} height={24} />}
|
||||
/>
|
||||
{isSearchModalOpen && (
|
||||
<DocSearchModal
|
||||
onClose={closeSearchModal}
|
||||
isOpen={isSearchModalOpen}
|
||||
doc={currentDoc}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+8
-6
@@ -1,5 +1,5 @@
|
||||
import { Modal, ModalSize } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { TreeContextType, useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
DocSearchFiltersValues,
|
||||
DocSearchTarget,
|
||||
} from '@/docs/doc-search';
|
||||
import { useAuth } from '@/features/auth/hooks/useAuth';
|
||||
import { useFocusStore, useResponsiveStore } from '@/stores';
|
||||
|
||||
import EmptySearchIcon from '../assets/illustration-docs-empty.png';
|
||||
@@ -32,13 +33,13 @@ type DocSearchModalGlobalProps = {
|
||||
isOpen: boolean;
|
||||
showFilters?: boolean;
|
||||
defaultFilters?: DocSearchFiltersValues;
|
||||
treeContext?: TreeContextType<Doc> | null;
|
||||
parentPath?: string; // If defined, the search will be limited to the children of the document with the given path
|
||||
};
|
||||
|
||||
const DocSearchModalGlobal = ({
|
||||
showFilters = false,
|
||||
defaultFilters,
|
||||
treeContext,
|
||||
parentPath,
|
||||
...modalProps
|
||||
}: DocSearchModalGlobalProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -141,7 +142,7 @@ const DocSearchModalGlobal = ({
|
||||
}
|
||||
parentPath={
|
||||
filters.target === DocSearchTarget.CURRENT
|
||||
? treeContext?.root?.path
|
||||
? parentPath
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -164,12 +165,13 @@ const DocSearchModalDetail = ({
|
||||
const { hasChildren, isChild } = useDocUtils(doc);
|
||||
const isWithChildren = isChild || hasChildren;
|
||||
const treeContext = useTreeContext<Doc>();
|
||||
const { authenticated } = useAuth();
|
||||
|
||||
let defaultFilters = DocSearchTarget.ALL;
|
||||
let showFilters = false;
|
||||
if (isWithChildren) {
|
||||
defaultFilters = DocSearchTarget.CURRENT;
|
||||
showFilters = true;
|
||||
showFilters = authenticated;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -177,7 +179,7 @@ const DocSearchModalDetail = ({
|
||||
{...modalProps}
|
||||
showFilters={showFilters}
|
||||
defaultFilters={{ target: defaultFilters }}
|
||||
treeContext={treeContext}
|
||||
parentPath={treeContext?.root?.path}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,49 +1,21 @@
|
||||
import { Button } from '@gouvfr-lasuite/cunningham-react';
|
||||
import { t } from 'i18next';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRouter } from 'next/router';
|
||||
import { PropsWithChildren, useCallback, useState } from 'react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import HomeSVG from '@/assets/icons/ui-kit/house-rounded.svg';
|
||||
import SearchSVG from '@/assets/icons/ui-kit/zoom-rounded.svg';
|
||||
import { Box, SeparatedSection } from '@/components';
|
||||
import { useDocStore } from '@/docs/doc-management';
|
||||
import { useAuth } from '@/features/auth';
|
||||
import { useCmdK } from '@/hooks/useCmdK';
|
||||
import { DocSearchButtonModal } from '@/features/docs/doc-search/components/DocSearchButtonModal';
|
||||
|
||||
import { useLeftPanelStore } from '../stores';
|
||||
|
||||
import { LeftPanelHeaderNewDoc } from './LeftPanelHeaderNewDoc';
|
||||
|
||||
const DocSearchModal = dynamic(
|
||||
() =>
|
||||
import('@/docs/doc-search/components/DocSearchModal').then((mod) => ({
|
||||
default: mod.DocSearchModal,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
||||
const { currentDoc } = useDocStore();
|
||||
const router = useRouter();
|
||||
const { authenticated } = useAuth();
|
||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
||||
|
||||
const openSearchModal = useCallback(() => {
|
||||
const isEditorToolbarOpen =
|
||||
document.getElementsByClassName('bn-formatting-toolbar').length > 0;
|
||||
if (isEditorToolbarOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearchModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeSearchModal = useCallback(() => {
|
||||
setIsSearchModalOpen(false);
|
||||
}, []);
|
||||
|
||||
useCmdK(openSearchModal);
|
||||
const { togglePanel } = useLeftPanelStore();
|
||||
|
||||
const goToHome = () => {
|
||||
@@ -52,56 +24,33 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box $width="100%" className="--docs--left-panel-header">
|
||||
<SeparatedSection>
|
||||
<Box
|
||||
$padding={{ horizontal: 'sm' }}
|
||||
$width="100%"
|
||||
$direction="row"
|
||||
$justify="space-between"
|
||||
$align="center"
|
||||
>
|
||||
{authenticated && <LeftPanelHeaderNewDoc />}
|
||||
{(router.pathname !== '/' || authenticated) && (
|
||||
<Box $direction="row" $gap="2px">
|
||||
{router.pathname !== '/' && (
|
||||
<Button
|
||||
data-testid="home-button"
|
||||
onClick={goToHome}
|
||||
aria-label={t('Back to homepage')}
|
||||
size="medium"
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
icon={<HomeSVG aria-hidden="true" width={24} height={24} />}
|
||||
/>
|
||||
)}
|
||||
{authenticated && (
|
||||
<Button
|
||||
data-testid="search-docs-button"
|
||||
onClick={openSearchModal}
|
||||
size="medium"
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
aria-label={t('Search docs')}
|
||||
icon={
|
||||
<SearchSVG aria-hidden="true" width={24} height={24} />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box $width="100%" className="--docs--left-panel-header">
|
||||
<SeparatedSection>
|
||||
<Box
|
||||
$padding={{ horizontal: 'sm' }}
|
||||
$width="100%"
|
||||
$direction="row"
|
||||
$justify="space-between"
|
||||
$align="center"
|
||||
>
|
||||
{authenticated && <LeftPanelHeaderNewDoc />}
|
||||
<Box $direction="row" $gap="2px" $margin={{ left: 'auto' }}>
|
||||
{router.pathname !== '/' && (
|
||||
<Button
|
||||
data-testid="home-button"
|
||||
onClick={goToHome}
|
||||
aria-label={t('Back to homepage')}
|
||||
size="medium"
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
icon={<HomeSVG aria-hidden="true" width={24} height={24} />}
|
||||
/>
|
||||
)}
|
||||
<DocSearchButtonModal />
|
||||
</Box>
|
||||
</SeparatedSection>
|
||||
{children}
|
||||
</Box>
|
||||
{isSearchModalOpen && (
|
||||
<DocSearchModal
|
||||
onClose={closeSearchModal}
|
||||
isOpen={isSearchModalOpen}
|
||||
doc={currentDoc}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</Box>
|
||||
</SeparatedSection>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -283,6 +283,7 @@ export class ApiPlugin implements WorkboxPlugin {
|
||||
partial_update: true,
|
||||
restore: true,
|
||||
retrieve: true,
|
||||
search: true,
|
||||
update: true,
|
||||
versions_destroy: true,
|
||||
versions_list: true,
|
||||
|
||||
Reference in New Issue
Block a user