♻️(frontend) Add default data to move modal

By default the move modal has the last updated doc
in the list, when typing it starts filtering.
In order to have this behaviour possible, we had to
refactorise DocSearchContent and the QuickSearch
components to be able to set a default value in
the input and a default list of items.
This commit is contained in:
Anthony LC
2026-03-04 16:00:23 +01:00
parent 6ce847d6e1
commit 1944f6177e
9 changed files with 148 additions and 158 deletions
@@ -302,7 +302,9 @@ test.describe('Doc grid move', () => {
await input.click();
await input.fill(titleDoc2);
await expect(page.getByRole('option').getByText(titleDoc2)).toBeVisible();
await expect(
page.getByRole('option').first().getByText(titleDoc2),
).toBeVisible();
// Select the first result
await page.keyboard.press('Enter');
@@ -32,7 +32,10 @@ test.describe('Doc Header', () => {
1,
);
const collapseButton = page.getByTestId('floating-bar-toggle-left-panel');
const cardCollapse = page.locator('.--docs--left-panel-collapse-button');
const collapseButton = cardCollapse.getByTestId(
'floating-bar-toggle-left-panel',
);
await expect(collapseButton).toBeVisible();
// Panel open
@@ -42,7 +45,7 @@ test.describe('Doc Header', () => {
// Collapse panel
await collapseButton.click();
await expect(collapseButton).toHaveAttribute('aria-expanded', 'false');
await expect(collapseButton.getByText(docTitle)).toBeHidden();
await expect(cardCollapse.getByText(docTitle)).toBeHidden();
// When the title is not visible in the viewport, the button should show the title
const editor = await writeInEditor({ page, text: 'Lorem ipsum' });
@@ -50,12 +53,12 @@ test.describe('Doc Header', () => {
await editor.press('Enter');
}
await writeInEditor({ page, text: 'Lorem ipsum 2' });
await expect(collapseButton.getByText(docTitle)).toBeVisible();
await expect(cardCollapse.getByText(docTitle)).toBeVisible();
// Expand panel and check the title is hidden again
await collapseButton.click();
await expect(collapseButton).toHaveAttribute('aria-expanded', 'true');
await expect(collapseButton.getByText(docTitle)).toBeHidden();
await expect(cardCollapse.getByText(docTitle)).toBeHidden();
});
test('it checks the element are correctly displayed', async ({
@@ -45,22 +45,13 @@ export const QuickSearch = ({
}: PropsWithChildren<QuickSearchProps>) => {
const ref = useRef<HTMLDivElement | null>(null);
const listId = useId();
const NO_SELECTION_VALUE = '__none__';
const [userInteracted, setUserInteracted] = useState(false);
const [selectedValue, setSelectedValue] = useState(NO_SELECTION_VALUE);
const isExpanded = userInteracted;
const handleValueChange = (val: string) => {
if (userInteracted) {
setSelectedValue(val);
}
};
const handleUserInteract = () => {
if (!userInteracted) {
setUserInteracted(true);
}
};
/**
* Hack to prevent cmdk from auto-selecting the first element on open
*
* TODO: Find a clean solution to prevent cmdk from auto-selecting
* the first element on open
*/
const [selectedValue, _] = useState('__none__');
return (
<>
@@ -71,9 +62,8 @@ export const QuickSearch = ({
shouldFilter={false}
ref={ref}
tabIndex={-1}
value={selectedValue}
onValueChange={handleValueChange}
disablePointerSelection
value={selectedValue}
>
{showInput && (
<QuickSearchInput
@@ -82,8 +72,6 @@ export const QuickSearch = ({
onFilter={onFilter}
placeholder={placeholder}
listId={listId}
isExpanded={isExpanded}
onUserInteract={handleUserInteract}
>
{inputContent}
</QuickSearchInput>
@@ -18,7 +18,7 @@ export const QuickSearchGroup = <T,>({
renderElement,
}: Props<T>) => {
return (
<Box $margin={{ top: 'sm' }}>
<Box>
<Command.Group
key={group.groupName}
heading={group.groupName}
@@ -14,7 +14,6 @@ type QuickSearchInputProps = {
placeholder?: string;
withSeparator?: boolean;
listId?: string;
onUserInteract?: () => void;
isExpanded?: boolean;
};
export const QuickSearchInput = ({
@@ -24,8 +23,6 @@ export const QuickSearchInput = ({
children,
withSeparator: separator = true,
listId,
onUserInteract,
isExpanded,
}: PropsWithChildren<QuickSearchInputProps>) => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
@@ -46,19 +43,13 @@ export const QuickSearchInput = ({
$align="center"
className="quick-search-input"
$gap={spacingsTokens['2xs']}
$padding={{ horizontal: 'base', vertical: 'sm' }}
$padding={{ horizontal: 'base', vertical: 'xxs' }}
>
<Icon iconName="search" $variation="secondary" aria-hidden="true" />
<Command.Input
autoFocus={true}
aria-label={t('Quick search input')}
aria-expanded={isExpanded}
aria-controls={listId}
onClick={(e) => {
e.stopPropagation();
onUserInteract?.();
}}
onKeyDown={() => onUserInteract?.()}
value={inputValue}
role="combobox"
placeholder={placeholder ?? t('Search')}
@@ -67,7 +58,7 @@ export const QuickSearchInput = ({
data-testid="quick-search-input"
/>
</Box>
{separator && <HorizontalSeparator $withPadding={false} />}
{separator && <HorizontalSeparator $margin={{ top: 'base' }} />}
</>
);
};
@@ -19,7 +19,6 @@ export const QuickSearchStyle = createGlobalStyle`
border: none;
width: 100%;
font-size: 17px;
padding: var(--c--globals--spacings--xs);
background: white;
outline: none;
color: var(--c--contextuals--content--semantic--neutral--primary);
@@ -1,30 +1,31 @@
import { t } from 'i18next';
import { useEffect, useMemo } from 'react';
import { useEffect, useState } from 'react';
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 { Doc, useInfiniteDocs } from '../../doc-management';
import { DocSearchFiltersValues } from './DocSearchFilters';
import { DocSearchItem } from './DocSearchItem';
type DocSearchContentProps = {
groupName: string;
search: string;
filters: DocSearchFiltersValues;
filterResults?: (doc: Doc) => boolean;
isSearchNotMandatory?: boolean;
onSelect: (doc: Doc) => void;
onLoadingChange?: (loading: boolean) => void;
renderSearchElement?: (doc: Doc) => React.ReactNode;
};
export const DocSearchContent = ({
groupName,
search,
filters,
filterResults,
onSelect,
onLoadingChange,
renderSearchElement,
isSearchNotMandatory,
}: DocSearchContentProps) => {
const {
data,
@@ -35,33 +36,56 @@ export const DocSearchContent = ({
hasNextPage,
} = useInfiniteDocs({
page: 1,
title: search,
...filters,
...(search ? { title: search } : {}),
});
const loading = isFetching || isRefetching || isLoading;
const [docsData, setDocsData] = useState<QuickSearchData<Doc>>({
groupName: '',
groupKey: 'docs',
elements: [],
emptyString: t('Loading documents...'),
endActions: [],
});
useEffect(() => {
if (loading) {
return;
}
const docsData: QuickSearchData<Doc> = useMemo(() => {
let docs = data?.pages.flatMap((page) => page.results) || [];
if (filterResults) {
docs = docs.filter(filterResults);
}
return {
groupName: docs.length > 0 ? t('Select a document') : '',
setDocsData({
groupName: docs.length > 0 ? groupName : '',
groupKey: 'docs',
elements: search ? docs : [],
elements: search || isSearchNotMandatory ? docs : [],
emptyString: t('No document found'),
endActions: hasNextPage
? [
{
content: <InView onChange={() => void fetchNextPage()} />,
content: (
<Box $minHeight="1px">
<InView onChange={() => void fetchNextPage()} />
</Box>
),
},
]
: [],
};
}, [search, data?.pages, fetchNextPage, hasNextPage, filterResults]);
});
}, [
search,
data?.pages,
fetchNextPage,
hasNextPage,
filterResults,
groupName,
isSearchNotMandatory,
loading,
]);
useEffect(() => {
onLoadingChange?.(loading);
@@ -5,17 +5,15 @@ import {
useModal,
} from '@gouvfr-lasuite/cunningham-react';
import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit';
import Image from 'next/image';
import { useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { createGlobalStyle, css } from 'styled-components';
import { useDebouncedCallback } from 'use-debounce';
import { Box, ButtonCloseModal, Text } from '@/components';
import { Box, ButtonCloseModal, HorizontalSeparator, Text } from '@/components';
import { QuickSearch } from '@/components/quick-search';
import { Doc, useMoveDoc, useTrans } from '@/docs/doc-management';
import { DocSearchContent, DocSearchTarget } from '@/docs/doc-search';
import EmptySearchIcon from '@/docs/doc-search/assets/illustration-docs-empty.png';
import { DocSearchContent } from '@/docs/doc-search';
import { AlertModalRequestAccess } from '@/docs/doc-share';
import { useResponsiveStore } from '@/stores';
@@ -35,20 +33,17 @@ export const DocMoveModalStyle = createGlobalStyle`
overflow-y: auto;
}
.c__modal__title {
padding-inline: var(--c--globals--spacings--md);
padding-block: var(--c--globals--spacings--base);
border-bottom: 1px solid var(--c--contextuals--border--surface--primary);
div.c__modal__title {
padding: 0;
margin-bottom: 0;
}
.c__modal__footer {
margin-top: 0rem;
border-top: 1px solid var(--c--contextuals--border--surface--primary);
}
.quick-search-input{
padding-inline: var(--c--globals--spacings--md);
}
.c__modal__footer{
border-top: 1px solid var(--c--contextuals--border--surface--primary);
}
.quick-search-container [cmdk-item] {
border-radius: 4px;
}
@@ -79,7 +74,8 @@ export const DocMoveModal = ({
const modalRequest = useModal();
const { mutate: moveDoc } = useMoveDoc(true);
const [search, setSearch] = useState('');
const { isDesktop } = useResponsiveStore();
const { isDesktop, isTablet, isMobile } = useResponsiveStore();
const isModal = (isDesktop || isTablet) && !isMobile;
const handleInputSearch = useDebouncedCallback(setSearch, 700);
const handleSelect = (docSelected: Doc) => {
@@ -106,11 +102,15 @@ export const DocMoveModal = ({
isOpen={isOpen}
onClose={onClose}
closeOnClickOutside
size={isDesktop ? ModalSize.LARGE : ModalSize.FULL}
size={isModal ? ModalSize.MEDIUM : ModalSize.FULL}
hideCloseButton
aria-label={t('Move Modal')}
rightActions={
<Box $direction="row-reverse" $padding="md" $gap="small">
<Box
$direction="row-reverse"
$padding={{ vertical: 'base', horizontal: 'md' }}
$gap="small"
>
<Button
aria-label={t('Move the document to the selected location')}
variant="primary"
@@ -144,21 +144,22 @@ export const DocMoveModal = ({
}
title={
<Box>
<Box
$direction="row"
$justify="space-between"
$align="center"
$width="100%"
>
<Text as="h2" $margin="0" $size="h6" $align="flex-start">
{t('Move')}
</Text>
<Box $position="absolute" $css="top: 4px; right: 4px;">
<ButtonCloseModal
aria-label={t('Close the move modal')}
onClick={() => onClose()}
/>
</Box>
<Box $margin={{ top: 'sm' }}>
<Text
as="h2"
$margin="0"
$size="h6"
$align="flex-start"
$padding={{ horizontal: 'md', top: 'md' }}
>
{t('Move')}
</Text>
<Box $margin={{ top: 'sm' }} $padding={{ horizontal: 'md' }}>
<Text
$size="sm"
$variation="secondary"
@@ -171,6 +172,7 @@ export const DocMoveModal = ({
</Trans>
</Text>
</Box>
<HorizontalSeparator />
</Box>
}
>
@@ -195,81 +197,64 @@ export const DocMoveModal = ({
onFilter={handleInputSearch}
>
<Box
$padding={{ horizontal: 'md' }}
$height={isDesktop ? 'min(60vh, 500px)' : 'calc(100vh - 260px)'}
$padding={{ horizontal: 'md', top: 'base' }}
$height={isModal ? 'min(60vh, 350px)' : 'calc(100vh - 260px)'}
>
{search.length === 0 && (
<Box
$direction="column"
$height="100%"
$align="center"
$justify="center"
>
<Image
width={320}
src={EmptySearchIcon}
alt={t('No active search')}
style={{ maxWidth: '100%', height: 'auto' }}
priority
/>
</Box>
)}
{search && (
<Box>
<DocSearchContent
search={search}
filters={{ target: DocSearchTarget.ALL }}
filterResults={(docResults) => docResults.id !== doc.id}
onSelect={handleSelect}
onLoadingChange={setLoading}
renderSearchElement={(docSearch) => {
const isSelected = docSelected?.id === docSearch.id;
<Box>
<DocSearchContent
groupName={search ? t('Search results') : t('All docs')}
search={search}
filterResults={(docResults) => docResults.id !== doc.id}
onSelect={handleSelect}
onLoadingChange={setLoading}
isSearchNotMandatory
renderSearchElement={(docSearch) => {
const isSelected = docSelected?.id === docSearch.id;
return (
<Box
className="--docs--doc-move-modal-search-item"
$direction="row"
$align="center"
$justify="space-between"
$width="100%"
$gap="sm"
$padding="3xs"
$css={css`
background-color: ${isSelected
? 'var(--c--contextuals--background--semantic--brand--tertiary)'
return (
<Box
className="--docs--doc-move-modal-search-item"
$direction="row"
$align="center"
$justify="space-between"
$width="100%"
$gap="sm"
$padding="3xs"
$css={css`
background-color: ${isSelected
? 'var(--c--contextuals--background--semantic--brand--tertiary)'
: 'transparent'};
border: 1px solid
${isSelected
? 'var(--c--contextuals--border--semantic--brand--tertiary)'
: 'transparent'};
border: 1px solid
${isSelected
? 'var(--c--contextuals--border--semantic--brand--tertiary)'
: 'transparent'};
border-radius: var(--c--globals--spacings--3xs);
border-radius: var(--c--globals--spacings--3xs);
/* Arrow key navigation highlight */
&[data-selected='true'] {
${!isSelected &&
`
/* Arrow key navigation highlight */
&[data-selected='true'] {
${!isSelected &&
`
background-color: var(--c--contextuals--background--semantic--contextual--primary);
border-color: transparent;
`}
}
`}
aria-selected={isSelected}
>
<DocsGridItemTitle
doc={docSearch}
withTooltip={false}
/>
<DocsGridItemDate
doc={docSearch}
isDesktop={isDesktop}
isInTrashbin={false}
/>
</Box>
);
}}
/>
</Box>
)}
}
`}
aria-selected={isSelected}
>
<DocsGridItemTitle
doc={docSearch}
withTooltip={false}
/>
<DocsGridItemDate
doc={docSearch}
isDesktop={isModal}
isInTrashbin={false}
/>
</Box>
);
}}
/>
</Box>
</Box>
</QuickSearch>
</Box>
@@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { Card, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { CLASS_DOC_TITLE } from '@/docs/doc-header/components/DocTitle';
import { getEmojiAndTitle, useDocStore, useTrans } from '@/docs/doc-management';
@@ -58,15 +58,14 @@ export const LeftPanelCollapseButton = () => {
: t('Show the side panel for {{title}}', { title: docTitle });
return (
<Box
<Card
className="--docs--left-panel-collapse-button"
$direction="row"
$css={css`
display: inline-flex;
padding: var(--c--globals--spacings--xxxs);
align-items: center;
gap: var(--c--globals--spacings--xxxs);
border-radius: var(--c--globals--spacings--xs);
border: 1px solid var(--c--contextuals--border--surface--primary);
background: var(--c--contextuals--background--surface--primary);
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.05);
`}
>
@@ -79,13 +78,12 @@ export const LeftPanelCollapseButton = () => {
variant="tertiary"
icon={<LeftPanelIcon width={24} height={24} aria-hidden="true" />}
data-testid="floating-bar-toggle-left-panel"
>
{shouldShowButtonTitle ? (
<Text $size="sm" $weight={700} $color={colorsTokens['gray-1000']}>
{buttonTitle}
</Text>
) : undefined}
</Button>
</Box>
></Button>
{shouldShowButtonTitle && (
<Text $size="sm" $weight={700} $color={colorsTokens['gray-1000']}>
{buttonTitle}
</Text>
)}
</Card>
);
};