(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:
Anthony LC
2026-06-10 11:44:13 +02:00
parent 021f53092e
commit 6dc9955d64
11 changed files with 191 additions and 151 deletions
+4
View File
@@ -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;
@@ -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}
/>
)}
</>
);
};
@@ -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,