Skip to content

Commit b717fc6

Browse files
✨(frontend) added subpage management and document tree features
New components were created to manage subpages in the document tree, including the ability to add, reorder, and view subpages. Tests were added to verify the functionality of these features. Additionally, API changes were made to manage the creation and retrieval of document children.
1 parent c369be4 commit b717fc6

File tree

24 files changed

+1162
-67
lines changed

24 files changed

+1162
-67
lines changed
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/* eslint-disable playwright/no-conditional-in-test */
2+
import { expect, test } from '@playwright/test';
3+
4+
import { createDoc } from './common';
5+
6+
test.describe('Doc Tree', () => {
7+
test('create new sub pages', async ({ page, browserName }) => {
8+
await page.goto('/');
9+
await createDoc(page, 'doc-tree-content', browserName, 1);
10+
const addButton = page.getByRole('button', { name: 'New page' });
11+
const docTree = page.getByTestId('doc-tree');
12+
13+
await expect(addButton).toBeVisible();
14+
15+
// Attendre et intercepter la requête POST pour créer une nouvelle page
16+
const responsePromise = page.waitForResponse(
17+
(response) =>
18+
response.url().includes('/documents/') &&
19+
response.url().includes('/children/') &&
20+
response.request().method() === 'POST',
21+
);
22+
23+
await addButton.click();
24+
const response = await responsePromise;
25+
expect(response.ok()).toBeTruthy();
26+
const subPageJson = await response.json();
27+
28+
await expect(docTree).toBeVisible();
29+
const subPageItem = docTree
30+
.getByTestId(`doc-sub-page-item-${subPageJson.id}`)
31+
.first();
32+
33+
await expect(subPageItem).toBeVisible();
34+
await subPageItem.click();
35+
const input = page.getByRole('textbox', { name: 'doc title input' });
36+
await input.click();
37+
await input.fill('Test');
38+
await input.press('Enter');
39+
await expect(subPageItem.getByText('Test')).toBeVisible();
40+
await page.reload();
41+
await expect(subPageItem.getByText('Test')).toBeVisible();
42+
});
43+
44+
test('check the reorder of sub pages', async ({ page, browserName }) => {
45+
await page.goto('/');
46+
await createDoc(page, 'doc-tree-content', browserName, 1);
47+
const addButton = page.getByRole('button', { name: 'New page' });
48+
await expect(addButton).toBeVisible();
49+
50+
const docTree = page.getByTestId('doc-tree');
51+
52+
// Create first sub page
53+
const firstResponsePromise = page.waitForResponse(
54+
(response) =>
55+
response.url().includes('/documents/') &&
56+
response.url().includes('/children/') &&
57+
response.request().method() === 'POST',
58+
);
59+
60+
await addButton.click();
61+
const firstResponse = await firstResponsePromise;
62+
expect(firstResponse.ok()).toBeTruthy();
63+
64+
const secondResponsePromise = page.waitForResponse(
65+
(response) =>
66+
response.url().includes('/documents/') &&
67+
response.url().includes('/children/') &&
68+
response.request().method() === 'POST',
69+
);
70+
71+
// Create second sub page
72+
await addButton.click();
73+
const secondResponse = await secondResponsePromise;
74+
expect(secondResponse.ok()).toBeTruthy();
75+
76+
const secondSubPageJson = await secondResponse.json();
77+
const firstSubPageJson = await firstResponse.json();
78+
79+
const firstSubPageItem = docTree
80+
.getByTestId(`doc-sub-page-item-${firstSubPageJson.id}`)
81+
.first();
82+
83+
const secondSubPageItem = docTree
84+
.getByTestId(`doc-sub-page-item-${secondSubPageJson.id}`)
85+
.first();
86+
87+
// check that the sub pages are visible in the tree
88+
await expect(firstSubPageItem).toBeVisible();
89+
await expect(secondSubPageItem).toBeVisible();
90+
91+
// get the bounding boxes of the sub pages
92+
const firstSubPageBoundingBox = await firstSubPageItem.boundingBox();
93+
const secondSubPageBoundingBox = await secondSubPageItem.boundingBox();
94+
95+
expect(firstSubPageBoundingBox).toBeDefined();
96+
expect(secondSubPageBoundingBox).toBeDefined();
97+
98+
if (!firstSubPageBoundingBox || !secondSubPageBoundingBox) {
99+
throw new Error('Impossible de déterminer la position des éléments');
100+
}
101+
102+
// move the first sub page to the second position
103+
await page.mouse.move(
104+
firstSubPageBoundingBox.x + firstSubPageBoundingBox.width / 2,
105+
firstSubPageBoundingBox.y + firstSubPageBoundingBox.height / 2,
106+
);
107+
108+
await page.mouse.down();
109+
110+
await page.mouse.move(
111+
secondSubPageBoundingBox.x + secondSubPageBoundingBox.width / 2,
112+
secondSubPageBoundingBox.y + secondSubPageBoundingBox.height + 4,
113+
{ steps: 10 },
114+
);
115+
116+
await page.mouse.up();
117+
118+
// check that the sub pages are visible in the tree
119+
await expect(firstSubPageItem).toBeVisible();
120+
await expect(secondSubPageItem).toBeVisible();
121+
122+
// reload the page
123+
await page.reload();
124+
125+
// check that the sub pages are visible in the tree
126+
await expect(firstSubPageItem).toBeVisible();
127+
await expect(secondSubPageItem).toBeVisible();
128+
129+
// Check the position of the sub pages
130+
const allSubPageItems = await docTree
131+
.getByTestId(/^doc-sub-page-item/)
132+
.all();
133+
134+
expect(allSubPageItems.length).toBe(2);
135+
136+
// Vérifier que le premier élément a l'ID de la deuxième sous-page après le drag and drop
137+
138+
await expect(allSubPageItems[0]).toHaveAttribute(
139+
'data-testid',
140+
`doc-sub-page-item-${secondSubPageJson.id}`,
141+
);
142+
143+
// Vérifier que le deuxième élément a l'ID de la première sous-page après le drag and drop
144+
await expect(allSubPageItems[1]).toHaveAttribute(
145+
'data-testid',
146+
`doc-sub-page-item-${firstSubPageJson.id}`,
147+
);
148+
});
149+
});

src/frontend/apps/impress/src/components/DropdownMenu.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type DropdownMenuOption = {
88
icon?: string;
99
label: string;
1010
testId?: string;
11+
value?: string;
1112
callback?: () => void | Promise<unknown>;
1213
danger?: boolean;
1314
isSelected?: boolean;
@@ -23,6 +24,8 @@ export type DropdownMenuProps = {
2324
buttonCss?: BoxProps['$css'];
2425
disabled?: boolean;
2526
topMessage?: string;
27+
selectedValues?: string[];
28+
afterOpenChange?: (isOpen: boolean) => void;
2629
};
2730

2831
export const DropdownMenu = ({
@@ -34,6 +37,8 @@ export const DropdownMenu = ({
3437
buttonCss,
3538
label,
3639
topMessage,
40+
afterOpenChange,
41+
selectedValues,
3742
}: PropsWithChildren<DropdownMenuProps>) => {
3843
const theme = useCunninghamTheme();
3944
const spacings = theme.spacingsTokens();
@@ -43,6 +48,7 @@ export const DropdownMenu = ({
4348

4449
const onOpenChange = (isOpen: boolean) => {
4550
setIsOpen(isOpen);
51+
afterOpenChange?.(isOpen);
4652
};
4753

4854
if (disabled) {
@@ -161,7 +167,8 @@ export const DropdownMenu = ({
161167
{option.label}
162168
</Text>
163169
</Box>
164-
{option.isSelected && (
170+
{(option.isSelected ||
171+
selectedValues?.includes(option.value ?? '')) && (
165172
<Icon iconName="check" $size="20px" $theme="greyscale" />
166173
)}
167174
</BoxButton>

src/frontend/apps/impress/src/components/Icon.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,19 @@ import { useCunninghamTheme } from '@/cunningham';
55

66
type IconProps = TextType & {
77
iconName: string;
8+
isFilled?: boolean;
89
};
9-
export const Icon = ({ iconName, ...textProps }: IconProps) => {
10+
export const Icon = ({ iconName, isFilled, ...textProps }: IconProps) => {
1011
return (
11-
<Text $isMaterialIcon {...textProps}>
12+
<Text
13+
$isMaterialIcon={!isFilled}
14+
{...textProps}
15+
className={
16+
isFilled
17+
? `material-icons-filled ${textProps.className}`
18+
: textProps.className
19+
}
20+
>
1221
{iconName}
1322
</Text>
1423
);
@@ -27,7 +36,7 @@ export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
2736
$size="36px"
2837
$theme="primary"
2938
$variation="600"
30-
$background={colorsTokens()['primary-bg']}
39+
$background={colorsTokens()['greyscale-000']}
3140
$css={`
3241
border: 1px solid ${colorsTokens()['primary-200']};
3342
user-select: none;
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { css } from 'styled-components';
2+
3+
import { Box } from '../Box';
4+
import { DropdownMenu, DropdownMenuOption } from '../DropdownMenu';
5+
import { Icon } from '../Icon';
6+
import { Text } from '../Text';
7+
8+
export type FilterDropdownProps = {
9+
options: DropdownMenuOption[];
10+
selectedValue?: string;
11+
};
12+
13+
export const FilterDropdown = ({
14+
options,
15+
selectedValue,
16+
}: FilterDropdownProps) => {
17+
const selectedOption = options.find(
18+
(option) => option.value === selectedValue,
19+
);
20+
21+
if (options.length === 0) {
22+
return null;
23+
}
24+
25+
return (
26+
<DropdownMenu
27+
selectedValues={selectedValue ? [selectedValue] : undefined}
28+
options={options}
29+
>
30+
<Box
31+
$css={css`
32+
border: 1px solid
33+
${selectedOption
34+
? 'var(--c--theme--colors--primary-500)'
35+
: 'var(--c--theme--colors--greyscale-250)'};
36+
border-radius: 4px;
37+
background-color: ${selectedOption
38+
? 'var(--c--theme--colors--primary-100)'
39+
: 'var(--c--theme--colors--greyscale-000)'};
40+
gap: var(--c--theme--spacings--2xs);
41+
padding: var(--c--theme--spacings--2xs) var(--c--theme--spacings--xs);
42+
`}
43+
color="secondary"
44+
$direction="row"
45+
$align="center"
46+
>
47+
<Text
48+
$weight={400}
49+
$variation={selectedOption ? '800' : '600'}
50+
$theme={selectedOption ? 'primary' : 'greyscale'}
51+
>
52+
{selectedOption?.label ?? options[0].label}
53+
</Text>
54+
<Icon
55+
$size="16px"
56+
iconName="keyboard_arrow_down"
57+
$variation={selectedOption ? '800' : '600'}
58+
$theme={selectedOption ? 'primary' : 'greyscale'}
59+
/>
60+
</Box>
61+
</DropdownMenu>
62+
);
63+
};

src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ export const QuickSearchInput = ({
5757
/* eslint-disable-next-line jsx-a11y/no-autofocus */
5858
autoFocus={true}
5959
aria-label={t('Quick search input')}
60+
onClick={(e) => {
61+
e.stopPropagation();
62+
}}
6063
value={inputValue}
6164
role="combobox"
6265
placeholder={placeholder ?? t('Search')}

src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
/* eslint-disable jsx-a11y/click-events-have-key-events */
22
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
3-
import {
4-
Tooltip,
5-
VariantType,
6-
useToastProvider,
7-
} from '@openfun/cunningham-react';
3+
import { Tooltip } from '@openfun/cunningham-react';
84
import React, { useCallback, useEffect, useState } from 'react';
95
import { useTranslation } from 'react-i18next';
106
import { css } from 'styled-components';
@@ -20,6 +16,8 @@ import {
2016
} from '@/docs/doc-management';
2117
import { useBroadcastStore, useResponsiveStore } from '@/stores';
2218

19+
import { useDocTreeData } from '../../doc-tree/context/DocTreeContext';
20+
2321
interface DocTitleProps {
2422
doc: Doc;
2523
}
@@ -57,18 +55,20 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
5755
const { t } = useTranslation();
5856
const { colorsTokens } = useCunninghamTheme();
5957
const [titleDisplay, setTitleDisplay] = useState(doc.title);
60-
const { toast } = useToastProvider();
58+
const data = useDocTreeData();
6159
const { untitledDocument } = useTrans();
6260

6361
const { broadcast } = useBroadcastStore();
6462

6563
const { mutate: updateDoc } = useUpdateDoc({
6664
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
67-
onSuccess(data) {
68-
toast(t('Document title updated successfully'), VariantType.SUCCESS);
69-
65+
onSuccess(updatedDoc) {
7066
// Broadcast to every user connected to the document
71-
broadcast(`${KEY_DOC}-${data.id}`);
67+
broadcast(`${KEY_DOC}-${updatedDoc.id}`);
68+
data?.tree?.updateNode(updatedDoc.id, { title: updatedDoc.title });
69+
if (updatedDoc.id === data?.root?.id) {
70+
void data?.refreshRoot();
71+
}
7272
},
7373
});
7474

src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
useAPIInfiniteQuery,
99
} from '@/api';
1010

11+
import { DocSearchTarget } from '../../doc-search/components/DocSearchFilters';
1112
import { Doc } from '../types';
1213

1314
export const isDocsOrdering = (data: string): data is DocsOrdering => {
@@ -31,6 +32,8 @@ export type DocsParams = {
3132
is_creator_me?: boolean;
3233
title?: string;
3334
is_favorite?: boolean;
35+
target?: DocSearchTarget;
36+
parent_id?: string;
3437
};
3538

3639
export type DocsResponse = APIList<Doc>;
@@ -53,8 +56,14 @@ export const getDocs = async (params: DocsParams): Promise<DocsResponse> => {
5356
if (params.is_favorite !== undefined) {
5457
searchParams.set('is_favorite', params.is_favorite.toString());
5558
}
56-
57-
const response = await fetchAPI(`documents/?${searchParams.toString()}`);
59+
let response: Response;
60+
if (params.parent_id && params.target === DocSearchTarget.CURRENT) {
61+
response = await fetchAPI(
62+
`documents/${params.parent_id}/descendants/?${searchParams.toString()}`,
63+
);
64+
} else {
65+
response = await fetchAPI(`documents/?${searchParams.toString()}`);
66+
}
5867

5968
if (!response.ok) {
6069
throw new APIError('Failed to get the docs', await errorCauses(response));

0 commit comments

Comments
 (0)