Compare commits

...

8 Commits

Author SHA1 Message Date
Nathan Panchout
2b2a579d9f wip 2025-03-24 17:15:14 +01:00
Nathan Panchout
615ab564cc fixup! (frontend) refactor and theme token update 2025-03-24 09:00:59 +01:00
Nathan Panchout
aadd6d9ec3 fixup! (frontend) added subpage management and document tree features 2025-03-24 08:55:38 +01:00
Nathan Panchout
0a6502a77d (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.
2025-03-24 08:55:36 +01:00
Nathan Panchout
a32ee20249 (Frontend) Added drag-and-drop functionality for document management
Added a new feature for moving documents within the user interface via
drag-and-drop. This includes the creation of Draggable and Droppable
components, as well as tests to verify document creation and movement
behavior. Changes have also been made to document types to include user
roles and child management capabilities.
2025-03-24 08:54:03 +01:00
Nathan Panchout
48db42f385 (frontend) refactor and theme token update
The configuration file has been simplified by importing configurations
from @gouvfr-lasuite/ui-kit . Colors and components have been updated to
reflect the new values. Additionally, adjustments have been made to
global styles, including the addition of styles for Material icons. Form
components have also been modified to incorporate the new style
properties.
2025-03-24 08:54:01 +01:00
Nathan Panchout
05b14b2948 (frontend) updated dependencies and added new packages
Added several new dependencies to the `package.json` file, including
`@dnd-kit/core`, `@dnd-kit/modifiers`, `@fontsource/material-icons`, and
`@gouvfr-lasuite/ui-kit`.
2025-03-24 08:53:04 +01:00
Nathan Panchout
12f4a72f5e 🐛(back) keep info if document has deleted children
With the soft delete feature, relying on the is_leaf method from the
treebeard is not accurate anymore. To determine if a node is a leaf, it
checks if the number of numchild is equal to 0. But a node can have soft
deleted children, then numchild is equal to 0, but it is not a leaf
because if we want to add a child we have to look for the last child to
compute a correct path. Otherwise we will have an error saying that the
path already exists.
2025-03-24 08:51:42 +01:00
46 changed files with 2041 additions and 126 deletions

View File

@@ -93,7 +93,6 @@ build: cache ?= --no-cache
build: ## build the project containers
@$(MAKE) build-backend cache=$(cache)
@$(MAKE) build-yjs-provider cache=$(cache)
@$(MAKE) build-frontend cache=$(cache)
.PHONY: build
build-backend: cache ?=
@@ -128,7 +127,6 @@ run-backend: ## Start only the backend application and all needed services
run: ## start the wsgi (production) and development server
run:
@$(MAKE) run-backend
@$(COMPOSE) up --force-recreate -d frontend
.PHONY: run
status: ## an alias for "docker compose ps"

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.1.7 on 2025-03-14 14:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0019_alter_user_language_default_to_null"),
]
operations = [
migrations.AddField(
model_name="document",
name="has_deleted_children",
field=models.BooleanField(default=False),
),
]

View File

@@ -486,6 +486,7 @@ class Document(MP_Node, BaseModel):
)
deleted_at = models.DateTimeField(null=True, blank=True)
ancestors_deleted_at = models.DateTimeField(null=True, blank=True)
has_deleted_children = models.BooleanField(default=False)
_content = None
@@ -546,6 +547,12 @@ class Document(MP_Node, BaseModel):
content_file = ContentFile(bytes_content)
default_storage.save(file_key, content_file)
def is_leaf(self):
"""
:returns: True if the node is has no children
"""
return not self.has_deleted_children and self.numchild == 0
@property
def key_base(self):
"""Key base of the location where the document is stored in object storage."""
@@ -903,7 +910,8 @@ class Document(MP_Node, BaseModel):
if self.depth > 1:
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
numchild=models.F("numchild") - 1
numchild=models.F("numchild") - 1,
has_deleted_children=True,
)
# Mark all descendants as soft deleted

View File

@@ -1297,3 +1297,47 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
def test_models_documents_get_select_options(ancestors_links, select_options):
"""Validate that the "get_select_options" method operates as expected."""
assert models.LinkReachChoices.get_select_options(ancestors_links) == select_options
def test_models_documents_children_create_after_sibling_deletion():
"""
It should be possible to create a new child after all children have been deleted.
"""
root = factories.DocumentFactory()
assert root.numchild == 0
assert root.has_deleted_children is False
assert root.is_leaf() is True
child1 = factories.DocumentFactory(parent=root)
child2 = factories.DocumentFactory(parent=root)
root.refresh_from_db()
assert root.numchild == 2
assert root.has_deleted_children is False
assert root.is_leaf() is False
child1.soft_delete()
child2.soft_delete()
root.refresh_from_db()
assert root.numchild == 0
assert root.has_deleted_children is True
assert root.is_leaf() is False
factories.DocumentFactory(parent=root)
root.refresh_from_db()
assert root.numchild == 1
assert root.has_deleted_children is True
assert root.is_leaf() is False
def test_models_documents_has_deleted_children():
"""
A document should have its has_deleted_children attribute set to True if one of its children
has been solf deleted no matter if numchild is 0 or not.
"""
root = factories.DocumentFactory()
child = factories.DocumentFactory(parent=root)
assert root.has_deleted_children is False
child.soft_delete()
root.refresh_from_db()
assert root.has_deleted_children is True

View File

@@ -200,6 +200,22 @@ export const mockedDocument = async (page: Page, json: object) => {
});
};
export const mockedListDocs = async (page: Page, data: object[] = []) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (request.method().includes('GET') && request.url().includes('page=')) {
await route.fulfill({
json: {
count: data.length,
next: null,
previous: null,
results: data,
},
});
}
});
};
export const mockedInvitations = async (page: Page, json?: object) => {
await page.route('**/invitations/**/', async (route) => {
const request = route.request();

View File

@@ -0,0 +1,309 @@
import { expect, test } from '@playwright/test';
import { createDoc, mockedListDocs } from './common';
test.describe('Doc grid dnd', () => {
test('it creates a doc', async ({ page, browserName }) => {
await page.goto('/');
const header = page.locator('header').first();
await createDoc(page, 'Draggable doc', browserName, 1);
await header.locator('h2').getByText('Docs').click();
await createDoc(page, 'Droppable doc', browserName, 1);
await header.locator('h2').getByText('Docs').click();
const response = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1') &&
response.status() === 200,
);
const responseJson = await response.json();
const items = responseJson.results;
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden();
const draggableElement = page.getByTestId(`draggable-doc-${items[1].id}`);
const dropZone = page.getByTestId(`droppable-doc-${items[0].id}`);
await expect(draggableElement).toBeVisible();
await expect(dropZone).toBeVisible();
// Obtenir les positions des éléments
const draggableBoundingBox = await draggableElement.boundingBox();
const dropZoneBoundingBox = await dropZone.boundingBox();
expect(draggableBoundingBox).toBeDefined();
expect(dropZoneBoundingBox).toBeDefined();
// eslint-disable-next-line playwright/no-conditional-in-test
if (!draggableBoundingBox || !dropZoneBoundingBox) {
throw new Error('Impossible de déterminer la position des éléments');
}
await page.mouse.move(
draggableBoundingBox.x + draggableBoundingBox.width / 2,
draggableBoundingBox.y + draggableBoundingBox.height / 2,
);
await page.mouse.down();
// Déplacer vers la zone cible
await page.mouse.move(
dropZoneBoundingBox.x + dropZoneBoundingBox.width / 2,
dropZoneBoundingBox.y + dropZoneBoundingBox.height / 2,
{ steps: 10 }, // Rendre le mouvement plus fluide
);
const dragOverlay = page.getByTestId('drag-doc-overlay');
await expect(dragOverlay).toBeVisible();
await expect(dragOverlay).toHaveText(items[1].title as string);
await page.mouse.up();
await expect(dragOverlay).toBeHidden();
});
test('it checks cant drop when we have not the minimum role', async ({
page,
}) => {
await mockedListDocs(page, data);
await page.goto('/');
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden();
const canDropAndDrag = page.getByTestId('droppable-doc-can-drop-and-drag');
const noDropAndNoDrag = page.getByTestId(
'droppable-doc-no-drop-and-no-drag',
);
await expect(canDropAndDrag).toBeVisible();
await expect(noDropAndNoDrag).toBeVisible();
const canDropAndDragBoundigBox = await canDropAndDrag.boundingBox();
const noDropAndNoDragBoundigBox = await noDropAndNoDrag.boundingBox();
if (!canDropAndDragBoundigBox || !noDropAndNoDragBoundigBox) {
throw new Error('Impossible de déterminer la position des éléments');
}
await page.mouse.move(
canDropAndDragBoundigBox.x + canDropAndDragBoundigBox.width / 2,
canDropAndDragBoundigBox.y + canDropAndDragBoundigBox.height / 2,
);
await page.mouse.down();
await page.mouse.move(
noDropAndNoDragBoundigBox.x + noDropAndNoDragBoundigBox.width / 2,
noDropAndNoDragBoundigBox.y + noDropAndNoDragBoundigBox.height / 2,
{ steps: 10 },
);
const dragOverlay = page.getByTestId('drag-doc-overlay');
await expect(dragOverlay).toBeVisible();
await expect(dragOverlay).toHaveText(
'You must be at least the editor of the target document',
);
await page.mouse.up();
});
test('it checks cant drag when we have not the minimum role', async ({
page,
}) => {
await mockedListDocs(page, data);
await page.goto('/');
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden();
const canDropAndDrag = page.getByTestId('droppable-doc-can-drop-and-drag');
const noDropAndNoDrag = page.getByTestId(
'droppable-doc-no-drop-and-no-drag',
);
await expect(canDropAndDrag).toBeVisible();
await expect(noDropAndNoDrag).toBeVisible();
const canDropAndDragBoundigBox = await canDropAndDrag.boundingBox();
const noDropAndNoDragBoundigBox = await noDropAndNoDrag.boundingBox();
if (!canDropAndDragBoundigBox || !noDropAndNoDragBoundigBox) {
throw new Error('Impossible de déterminer la position des éléments');
}
await page.mouse.move(
noDropAndNoDragBoundigBox.x + noDropAndNoDragBoundigBox.width / 2,
noDropAndNoDragBoundigBox.y + noDropAndNoDragBoundigBox.height / 2,
);
await page.mouse.down();
await page.mouse.move(
canDropAndDragBoundigBox.x + canDropAndDragBoundigBox.width / 2,
canDropAndDragBoundigBox.y + canDropAndDragBoundigBox.height / 2,
{ steps: 10 },
);
const dragOverlay = page.getByTestId('drag-doc-overlay');
await expect(dragOverlay).toBeVisible();
await expect(dragOverlay).toHaveText(
'You must be the owner to move the document',
);
await page.mouse.up();
});
});
const data = [
{
id: 'can-drop-and-drag',
abilities: {
accesses_manage: true,
accesses_view: true,
ai_transform: true,
ai_translate: true,
attachment_upload: true,
children_list: true,
children_create: true,
collaboration_auth: true,
descendants: true,
destroy: true,
favorite: true,
link_configuration: true,
invite_owner: true,
move: true,
partial_update: true,
restore: true,
retrieve: true,
media_auth: true,
link_select_options: {
restricted: ['reader', 'editor'],
authenticated: ['reader', 'editor'],
public: ['reader', 'editor'],
},
tree: true,
update: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
},
created_at: '2025-03-14T14:45:22.527221Z',
creator: 'bc6895e0-8f6d-4b00-827d-c143aa6b2ecb',
depth: 1,
excerpt: null,
is_favorite: false,
link_role: 'reader',
link_reach: 'restricted',
nb_accesses_ancestors: 1,
nb_accesses_direct: 1,
numchild: 5,
path: '000000o',
title: 'Can drop and drag',
updated_at: '2025-03-14T14:45:27.699542Z',
user_roles: ['owner'],
},
{
id: 'can-only-drop',
title: 'Can only drop',
abilities: {
accesses_manage: true,
accesses_view: true,
ai_transform: true,
ai_translate: true,
attachment_upload: true,
children_list: true,
children_create: true,
collaboration_auth: true,
descendants: true,
destroy: true,
favorite: true,
link_configuration: true,
invite_owner: true,
move: true,
partial_update: true,
restore: true,
retrieve: true,
media_auth: true,
link_select_options: {
restricted: ['reader', 'editor'],
authenticated: ['reader', 'editor'],
public: ['reader', 'editor'],
},
tree: true,
update: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
},
created_at: '2025-03-14T14:45:22.527221Z',
creator: 'bc6895e0-8f6d-4b00-827d-c143aa6b2ecb',
depth: 1,
excerpt: null,
is_favorite: false,
link_role: 'reader',
link_reach: 'restricted',
nb_accesses_ancestors: 1,
nb_accesses_direct: 1,
numchild: 5,
path: '000000o',
updated_at: '2025-03-14T14:45:27.699542Z',
user_roles: ['editor'],
},
{
id: 'no-drop-and-no-drag',
abilities: {
accesses_manage: false,
accesses_view: true,
ai_transform: false,
ai_translate: false,
attachment_upload: false,
children_list: true,
children_create: false,
collaboration_auth: true,
descendants: true,
destroy: false,
favorite: true,
link_configuration: false,
invite_owner: false,
move: false,
partial_update: false,
restore: false,
retrieve: true,
media_auth: true,
link_select_options: {
restricted: ['reader', 'editor'],
authenticated: ['reader', 'editor'],
public: ['reader', 'editor'],
},
tree: true,
update: false,
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
},
created_at: '2025-03-14T14:44:16.032773Z',
creator: '9264f420-f018-4bd6-96ae-4788f41af56d',
depth: 1,
excerpt: null,
is_favorite: false,
link_role: 'reader',
link_reach: 'restricted',
nb_accesses_ancestors: 14,
nb_accesses_direct: 14,
numchild: 0,
path: '000000l',
title: 'No drop and no drag',
updated_at: '2025-03-14T14:44:16.032774Z',
user_roles: ['reader'],
},
];

View File

@@ -0,0 +1,149 @@
/* eslint-disable playwright/no-conditional-in-test */
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
test.describe('Doc Tree', () => {
test('create new sub pages', async ({ page, browserName }) => {
await page.goto('/');
await createDoc(page, 'doc-tree-content', browserName, 1);
const addButton = page.getByRole('button', { name: 'New page' });
const docTree = page.getByTestId('doc-tree');
await expect(addButton).toBeVisible();
// Attendre et intercepter la requête POST pour créer une nouvelle page
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/documents/') &&
response.url().includes('/children/') &&
response.request().method() === 'POST',
);
await addButton.click();
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const subPageJson = await response.json();
await expect(docTree).toBeVisible();
const subPageItem = docTree
.getByTestId(`doc-sub-page-item-${subPageJson.id}`)
.first();
await expect(subPageItem).toBeVisible();
await subPageItem.click();
const input = page.getByRole('textbox', { name: 'doc title input' });
await input.click();
await input.fill('Test');
await input.press('Enter');
await expect(subPageItem.getByText('Test')).toBeVisible();
await page.reload();
await expect(subPageItem.getByText('Test')).toBeVisible();
});
test('check the reorder of sub pages', async ({ page, browserName }) => {
await page.goto('/');
await createDoc(page, 'doc-tree-content', browserName, 1);
const addButton = page.getByRole('button', { name: 'New page' });
await expect(addButton).toBeVisible();
const docTree = page.getByTestId('doc-tree');
// Create first sub page
const firstResponsePromise = page.waitForResponse(
(response) =>
response.url().includes('/documents/') &&
response.url().includes('/children/') &&
response.request().method() === 'POST',
);
await addButton.click();
const firstResponse = await firstResponsePromise;
expect(firstResponse.ok()).toBeTruthy();
const secondResponsePromise = page.waitForResponse(
(response) =>
response.url().includes('/documents/') &&
response.url().includes('/children/') &&
response.request().method() === 'POST',
);
// Create second sub page
await addButton.click();
const secondResponse = await secondResponsePromise;
expect(secondResponse.ok()).toBeTruthy();
const secondSubPageJson = await secondResponse.json();
const firstSubPageJson = await firstResponse.json();
const firstSubPageItem = docTree
.getByTestId(`doc-sub-page-item-${firstSubPageJson.id}`)
.first();
const secondSubPageItem = docTree
.getByTestId(`doc-sub-page-item-${secondSubPageJson.id}`)
.first();
// check that the sub pages are visible in the tree
await expect(firstSubPageItem).toBeVisible();
await expect(secondSubPageItem).toBeVisible();
// get the bounding boxes of the sub pages
const firstSubPageBoundingBox = await firstSubPageItem.boundingBox();
const secondSubPageBoundingBox = await secondSubPageItem.boundingBox();
expect(firstSubPageBoundingBox).toBeDefined();
expect(secondSubPageBoundingBox).toBeDefined();
if (!firstSubPageBoundingBox || !secondSubPageBoundingBox) {
throw new Error('Impossible de déterminer la position des éléments');
}
// move the first sub page to the second position
await page.mouse.move(
firstSubPageBoundingBox.x + firstSubPageBoundingBox.width / 2,
firstSubPageBoundingBox.y + firstSubPageBoundingBox.height / 2,
);
await page.mouse.down();
await page.mouse.move(
secondSubPageBoundingBox.x + secondSubPageBoundingBox.width / 2,
secondSubPageBoundingBox.y + secondSubPageBoundingBox.height + 4,
{ steps: 10 },
);
await page.mouse.up();
// check that the sub pages are visible in the tree
await expect(firstSubPageItem).toBeVisible();
await expect(secondSubPageItem).toBeVisible();
// reload the page
await page.reload();
// check that the sub pages are visible in the tree
await expect(firstSubPageItem).toBeVisible();
await expect(secondSubPageItem).toBeVisible();
// Check the position of the sub pages
const allSubPageItems = await docTree
.getByTestId(/^doc-sub-page-item/)
.all();
expect(allSubPageItems.length).toBe(2);
// Vérifier que le premier élément a l'ID de la deuxième sous-page après le drag and drop
await expect(allSubPageItems[0]).toHaveAttribute(
'data-testid',
`doc-sub-page-item-${secondSubPageJson.id}`,
);
// Vérifier que le deuxième élément a l'ID de la première sous-page après le drag and drop
await expect(allSubPageItems[1]).toHaveAttribute(
'data-testid',
`doc-sub-page-item-${firstSubPageJson.id}`,
);
});
});

View File

@@ -1,4 +1,8 @@
import { cunninghamConfig as tokens } from '@gouvfr-lasuite/ui-kit';
import { cunninghamConfig } from '@gouvfr-lasuite/ui-kit';
const tokens = {
...cunninghamConfig,
};
const customColors = {
'primary-action': '#1212FF',
@@ -34,7 +38,6 @@ const customColors = {
'yellow-500': '#B7A73F',
'yellow-600': '#66673D',
};
tokens.themes.default.theme.colors = {
...tokens.themes.default.theme.colors,
...customColors,

View File

@@ -21,15 +21,18 @@
"@blocknote/react": "0.23.2-hotfix.0",
"@blocknote/xl-docx-exporter": "0.23.2-hotfix.0",
"@blocknote/xl-pdf-exporter": "0.23.2-hotfix.0",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@fontsource/material-icons": "5.2.5",
"@gouvfr-lasuite/integration": "1.0.2",
"@gouvfr-lasuite/ui-kit": "0.1.3",
"@gouvfr-lasuite/ui-kit": "/Users/melde/Documents/societes/melde/clients/dinum/design-system",
"@hocuspocus/provider": "2.15.2",
"@openfun/cunningham-react": "3.0.0",
"@react-pdf/renderer": "4.1.6",
"@sentry/nextjs": "9.3.0",
"@tanstack/react-query": "5.67.1",
"canvg": "4.0.3",
"clsx": "2.1.1",
"cmdk": "1.0.4",
"crisp-sdk-web": "1.0.25",
"docx": "9.1.1",
@@ -46,6 +49,7 @@
"react-i18next": "15.4.1",
"react-intersection-observer": "9.15.1",
"react-select": "5.10.1",
"react-stately": "3.36.1",
"styled-components": "6.1.15",
"use-debounce": "10.0.4",
"y-protocols": "1.0.6",

View File

@@ -8,6 +8,7 @@ export type DropdownMenuOption = {
icon?: string;
label: string;
testId?: string;
value?: string;
callback?: () => void | Promise<unknown>;
danger?: boolean;
isSelected?: boolean;
@@ -23,6 +24,8 @@ export type DropdownMenuProps = {
buttonCss?: BoxProps['$css'];
disabled?: boolean;
topMessage?: string;
selectedValues?: string[];
afterOpenChange?: (isOpen: boolean) => void;
};
export const DropdownMenu = ({
@@ -34,6 +37,8 @@ export const DropdownMenu = ({
buttonCss,
label,
topMessage,
afterOpenChange,
selectedValues,
}: PropsWithChildren<DropdownMenuProps>) => {
const theme = useCunninghamTheme();
const spacings = theme.spacingsTokens();
@@ -43,6 +48,7 @@ export const DropdownMenu = ({
const onOpenChange = (isOpen: boolean) => {
setIsOpen(isOpen);
afterOpenChange?.(isOpen);
};
if (disabled) {
@@ -161,7 +167,8 @@ export const DropdownMenu = ({
{option.label}
</Text>
</Box>
{option.isSelected && (
{(option.isSelected ||
selectedValues?.includes(option.value ?? '')) && (
<Icon iconName="check" $size="20px" $theme="greyscale" />
)}
</BoxButton>

View File

@@ -8,7 +8,7 @@ type IconProps = TextType & {
};
export const Icon = ({ iconName, ...textProps }: IconProps) => {
return (
<Text $isMaterialIcon {...textProps}>
<Text $isMaterialIcon={textProps.$isMaterialIcon ?? true} {...textProps}>
{iconName}
</Text>
);
@@ -27,7 +27,7 @@ export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
$size="36px"
$theme="primary"
$variation="600"
$background={colorsTokens()['primary-bg']}
$background={colorsTokens()['greyscale-000']}
$css={`
border: 1px solid ${colorsTokens()['primary-200']};
user-select: none;

View File

@@ -1,3 +1,4 @@
import clsx from 'clsx';
import { CSSProperties, ComponentPropsWithRef, forwardRef } from 'react';
import styled from 'styled-components';
@@ -11,7 +12,7 @@ type TextSizes = keyof typeof sizes;
export interface TextProps extends BoxProps {
as?: 'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
$elipsis?: boolean;
$isMaterialIcon?: boolean;
$isMaterialIcon?: boolean | 'filled';
$weight?: CSSProperties['fontWeight'];
$textAlign?: CSSProperties['textAlign'];
$size?: TextSizes | (string & {});
@@ -58,13 +59,20 @@ export const TextStyled = styled(Box)<TextProps>`
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
({ className, $isMaterialIcon, ...props }, ref) => {
const isFilled = $isMaterialIcon === 'filled';
const isMaterialIcon =
typeof $isMaterialIcon === 'boolean' && $isMaterialIcon;
return (
<TextStyled
ref={ref}
as="span"
$theme="greyscale"
$variation="text"
className={`${className || ''}${$isMaterialIcon ? ' material-icons' : ''}`}
className={clsx(className || '', {
'material-icons': isMaterialIcon,
'material-icons-filled': isFilled,
})}
{...props}
/>
);

View File

@@ -0,0 +1,63 @@
import { css } from 'styled-components';
import { Box } from '../Box';
import { DropdownMenu, DropdownMenuOption } from '../DropdownMenu';
import { Icon } from '../Icon';
import { Text } from '../Text';
export type FilterDropdownProps = {
options: DropdownMenuOption[];
selectedValue?: string;
};
export const FilterDropdown = ({
options,
selectedValue,
}: FilterDropdownProps) => {
const selectedOption = options.find(
(option) => option.value === selectedValue,
);
if (options.length === 0) {
return null;
}
return (
<DropdownMenu
selectedValues={selectedValue ? [selectedValue] : undefined}
options={options}
>
<Box
$css={css`
border: 1px solid
${selectedOption
? 'var(--c--theme--colors--primary-500)'
: 'var(--c--theme--colors--greyscale-250)'};
border-radius: 4px;
background-color: ${selectedOption
? 'var(--c--theme--colors--primary-100)'
: 'var(--c--theme--colors--greyscale-000)'};
gap: var(--c--theme--spacings--2xs);
padding: var(--c--theme--spacings--2xs) var(--c--theme--spacings--xs);
`}
color="secondary"
$direction="row"
$align="center"
>
<Text
$weight={400}
$variation={selectedOption ? '800' : '600'}
$theme={selectedOption ? 'primary' : 'greyscale'}
>
{selectedOption?.label ?? options[0].label}
</Text>
<Icon
$size="16px"
iconName="keyboard_arrow_down"
$variation={selectedOption ? '800' : '600'}
$theme={selectedOption ? 'primary' : 'greyscale'}
/>
</Box>
</DropdownMenu>
);
};

View File

@@ -57,6 +57,9 @@ export const QuickSearchInput = ({
/* eslint-disable-next-line jsx-a11y/no-autofocus */
autoFocus={true}
aria-label={t('Quick search input')}
onClick={(e) => {
e.stopPropagation();
}}
value={inputValue}
role="combobox"
placeholder={placeholder ?? t('Search')}

View File

@@ -1,10 +1,7 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
import {
Tooltip,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { Tooltip } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -15,11 +12,13 @@ import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
KEY_SUB_DOC,
useTrans,
useUpdateDoc,
} from '@/docs/doc-management';
import { useBroadcastStore, useResponsiveStore } from '@/stores';
import { useDocTreeStore } from '../../doc-tree/context/DocTreeContext';
interface DocTitleProps {
doc: Doc;
}
@@ -55,20 +54,27 @@ export const DocTitleText = ({ title }: DocTitleTextProps) => {
const DocTitleInput = ({ doc }: DocTitleProps) => {
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
const queryClient = useQueryClient();
const { colorsTokens } = useCunninghamTheme();
const treeStore = useDocTreeStore();
const [titleDisplay, setTitleDisplay] = useState(doc.title);
const { toast } = useToastProvider();
const { untitledDocument } = useTrans();
const { broadcast } = useBroadcastStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
onSuccess(data) {
toast(t('Document title updated successfully'), VariantType.SUCCESS);
listInvalideQueries: [KEY_LIST_DOC],
onSuccess(updatedDoc) {
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${data.id}`);
broadcast(`${KEY_DOC}-${updatedDoc.id}`);
if (updatedDoc.id === treeStore.root?.id) {
treeStore.setRoot(updatedDoc);
}
queryClient.setQueryData(
[KEY_SUB_DOC, { id: updatedDoc.id }],
updatedDoc,
);
},
});

View File

@@ -6,6 +6,7 @@ import { Doc } from '../types';
export type DocParams = {
id: string;
isTree?: boolean;
};
export const getDoc = async ({ id }: DocParams): Promise<Doc> => {
@@ -19,14 +20,15 @@ export const getDoc = async ({ id }: DocParams): Promise<Doc> => {
};
export const KEY_DOC = 'doc';
export const KEY_SUB_DOC = 'sub-doc';
export const KEY_DOC_VISIBILITY = 'doc-visibility';
export function useDoc(
param: DocParams,
queryConfig?: UseQueryOptions<Doc, APIError, Doc>,
queryConfig?: Omit<UseQueryOptions<Doc, APIError, Doc>, 'queryFn'>,
) {
return useQuery<Doc, APIError, Doc>({
queryKey: [KEY_DOC, param],
queryKey: queryConfig?.queryKey ?? [KEY_DOC, param],
queryFn: () => getDoc(param),
...queryConfig,
});

View File

@@ -8,6 +8,7 @@ import {
useAPIInfiniteQuery,
} from '@/api';
import { DocSearchTarget } from '../../doc-search/components/DocSearchFilters';
import { Doc } from '../types';
export const isDocsOrdering = (data: string): data is DocsOrdering => {
@@ -31,6 +32,8 @@ export type DocsParams = {
is_creator_me?: boolean;
title?: string;
is_favorite?: boolean;
target?: DocSearchTarget;
parent_id?: string;
};
export type DocsResponse = APIList<Doc>;
@@ -53,8 +56,14 @@ export const getDocs = async (params: DocsParams): Promise<DocsResponse> => {
if (params.is_favorite !== undefined) {
searchParams.set('is_favorite', params.is_favorite.toString());
}
const response = await fetchAPI(`documents/?${searchParams.toString()}`);
let response: Response;
if (params.parent_id && params.target === DocSearchTarget.CURRENT) {
response = await fetchAPI(
`documents/${params.parent_id}/descendants/?${searchParams.toString()}`,
);
} else {
response = await fetchAPI(`documents/?${searchParams.toString()}`);
}
if (!response.ok) {
throw new APIError('Failed to get the docs', await errorCauses(response));

View File

@@ -17,16 +17,20 @@ import { Doc } from '../types';
interface ModalRemoveDocProps {
onClose: () => void;
doc: Doc;
afterDelete?: (doc: Doc) => void;
}
export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
export const ModalRemoveDoc = ({
onClose,
doc,
afterDelete,
}: ModalRemoveDocProps) => {
const { toast } = useToastProvider();
const { push } = useRouter();
const pathname = usePathname();
const {
mutate: removeDoc,
isError,
error,
} = useRemoveDoc({
@@ -34,6 +38,11 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
toast(t('The document has been deleted.'), VariantType.SUCCESS, {
duration: 4000,
});
if (afterDelete) {
afterDelete(doc);
return;
}
if (pathname === '/') {
onClose();
} else {
@@ -87,7 +96,9 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
<Box aria-label={t('Content modal to delete document')}>
{!isError && (
<Text $size="sm" $variation="600">
{t('Are you sure you want to delete this document ?')}
{t('Are you sure you want to delete the document "{{title}}"?', {
title: doc.title ?? t('Untitled document'),
})}
</Text>
)}

View File

@@ -42,10 +42,14 @@ export interface Doc {
is_favorite: boolean;
link_reach: LinkReach;
link_role: LinkRole;
nb_accesses_ancestors: number;
nb_accesses_direct: number;
user_roles: Role[];
created_at: string;
updated_at: string;
nb_accesses_direct: number;
nb_accesses_ancestors: number;
children?: Doc[];
childrenCount?: number;
numchild: number;
abilities: {
accesses_manage: boolean;
accesses_view: boolean;

View File

@@ -0,0 +1,66 @@
import { Button } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
import { FilterDropdown } from '@/components/filter/FilterDropdown';
export enum DocSearchTarget {
ALL = 'all',
CURRENT = 'current',
}
export type DocSearchFiltersValues = {
target?: DocSearchTarget;
};
export type DocSearchFiltersProps = {
values?: DocSearchFiltersValues;
onValuesChange?: (values: DocSearchFiltersValues) => void;
onReset?: () => void;
};
export const DocSearchFilters = ({
values,
onValuesChange,
onReset,
}: DocSearchFiltersProps) => {
const { t } = useTranslation();
const hasFilters = Object.keys(values ?? {}).length > 0;
const handleTargetChange = (target: DocSearchTarget) => {
onValuesChange?.({ ...values, target });
};
return (
<Box
$direction="row"
$align="center"
$height="35px"
$justify="space-between"
$gap="10px"
$margin={{ vertical: 'base' }}
>
<Box $direction="row" $align="center" $gap="10px">
<FilterDropdown
selectedValue={values?.target}
options={[
{
label: t('All docs'),
value: DocSearchTarget.ALL,
callback: () => handleTargetChange(DocSearchTarget.ALL),
},
{
label: t('Current doc'),
value: DocSearchTarget.CURRENT,
callback: () => handleTargetChange(DocSearchTarget.CURRENT),
},
]}
/>
</Box>
{hasFilters && (
<Button color="primary-text" size="small" onClick={onReset}>
{t('Reset')}
</Button>
)}
</Box>
);
};

View File

@@ -15,17 +15,37 @@ import {
import { Doc, useInfiniteDocs } from '@/docs/doc-management';
import { useResponsiveStore } from '@/stores';
import { useDocTreeStore } from '../../doc-tree/context/DocTreeContext';
import EmptySearchIcon from '../assets/illustration-docs-empty.png';
import {
DocSearchFilters,
DocSearchFiltersValues,
DocSearchTarget,
} from './DocSearchFilters';
import { DocSearchItem } from './DocSearchItem';
type DocSearchModalProps = ModalProps & {};
type DocSearchModalProps = ModalProps & {
showFilters?: boolean;
defaultFilters?: DocSearchFiltersValues;
};
export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
export const DocSearchModal = ({
showFilters = false,
defaultFilters,
...modalProps
}: DocSearchModalProps) => {
const { t } = useTranslation();
const router = useRouter();
const treeStore = useDocTreeStore();
const [search, setSearch] = useState('');
const [filters, setFilters] = useState<DocSearchFiltersValues>(
defaultFilters ?? {},
);
const { isDesktop } = useResponsiveStore();
const {
data,
isFetching,
@@ -36,27 +56,41 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
} = useInfiniteDocs({
page: 1,
title: search,
...filters,
parent_id: treeStore?.root?.id,
});
const loading = isFetching || isRefetching || isLoading;
const handleInputSearch = useDebouncedCallback(setSearch, 300);
const handleSelect = (doc: Doc) => {
if (treeStore?.initialRootId !== doc.id) {
treeStore.setSelectedNode(doc);
treeStore.setRoot(doc);
treeStore.setInitialTargetId(doc.id);
}
router.push(`/docs/${doc.id}`);
modalProps.onClose?.();
};
const handleResetFilters = () => {
setFilters({});
};
const docsData: QuickSearchData<Doc> = useMemo(() => {
const docs = data?.pages.flatMap((page) => page.results) || [];
const groupName =
filters.target === DocSearchTarget.CURRENT
? t('Select a page')
: t('Select a document');
return {
groupName: docs.length > 0 ? t('Select a document') : '',
groupName: docs.length > 0 ? groupName : '',
elements: search ? docs : [],
emptyString: t('No document found'),
endActions: hasNextPage
? [{ content: <InView onChange={() => void fetchNextPage()} /> }]
: [],
};
}, [data, hasNextPage, fetchNextPage, t, search]);
}, [data, hasNextPage, fetchNextPage, t, search, filters.target]);
return (
<Modal
@@ -75,6 +109,13 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
onFilter={handleInputSearch}
>
<Box $height={isDesktop ? '500px' : 'calc(100vh - 68px - 1rem)'}>
{showFilters && (
<DocSearchFilters
values={filters}
onValuesChange={setFilters}
onReset={handleResetFilters}
/>
)}
{search.length === 0 && (
<Box
$direction="column"

View File

@@ -3,6 +3,7 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -11,7 +12,7 @@ import { APIError } from '@/api';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { User } from '@/features/auth';
import { Doc, Role } from '@/features/docs';
import { Doc, KEY_SUB_DOC, Role } from '@/features/docs';
import { useCreateDocAccess, useCreateDocInvitation } from '../api';
import { OptionType } from '../types';
@@ -39,6 +40,7 @@ export const DocShareAddMemberList = ({
}: Props) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const queryClient = useQueryClient();
const [isLoading, setIsLoading] = useState(false);
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const [invitationRole, setInvitationRole] = useState<Role>(Role.EDITOR);
@@ -91,14 +93,32 @@ export const DocShareAddMemberList = ({
};
return isInvitationMode
? createInvitation({
...payload,
email: user.email,
})
: createDocAccess({
...payload,
memberId: user.id,
});
? createInvitation(
{
...payload,
email: user.email,
},
{
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_DOC, { id: doc.id }],
});
},
},
)
: createDocAccess(
{
...payload,
memberId: user.id,
},
{
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_DOC, { id: doc.id }],
});
},
},
);
});
const settledPromises = await Promise.allSettled(promises);

View File

@@ -1,4 +1,5 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
@@ -8,7 +9,7 @@ import {
IconOptions,
} from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, Role } from '@/docs/doc-management';
import { Doc, KEY_SUB_DOC, Role } from '@/docs/doc-management';
import { User } from '@/features/auth';
import { useDeleteDocInvitation, useUpdateDocInvitation } from '../api';
@@ -23,6 +24,7 @@ type Props = {
};
export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { spacingsTokens } = useCunninghamTheme();
const spacing = spacingsTokens();
const fakeUser: User = {
@@ -37,6 +39,11 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
const canUpdate = doc.abilities.accesses_manage;
const { mutate: updateDocInvitation } = useUpdateDocInvitation({
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_DOC, { id: doc.id }],
});
},
onError: (error) => {
toast(
error?.data?.role?.[0] ?? t('Error during update invitation'),
@@ -49,6 +56,11 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
});
const { mutate: removeDocInvitation } = useDeleteDocInvitation({
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_DOC, { id: doc.id }],
});
},
onError: (error) => {
toast(
error?.data?.role?.[0] ?? t('Error during delete invitation'),

View File

@@ -1,4 +1,5 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
@@ -8,7 +9,7 @@ import {
IconOptions,
} from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Access, Doc, Role } from '@/docs/doc-management/';
import { Access, Doc, KEY_SUB_DOC, Role } from '@/docs/doc-management/';
import { useResponsiveStore } from '@/stores';
import { useDeleteDocAccess, useUpdateDocAccess } from '../api';
@@ -25,13 +26,20 @@ export const DocShareMemberItem = ({ doc, access }: Props) => {
const { t } = useTranslation();
const { isLastOwner, isOtherOwner } = useWhoAmI(access);
const { toast } = useToastProvider();
const { isDesktop } = useResponsiveStore();
const { spacingsTokens } = useCunninghamTheme();
const queryClient = useQueryClient();
const spacing = spacingsTokens();
const isNotAllowed =
isOtherOwner || !!isLastOwner || !doc.abilities.accesses_manage;
const { mutate: updateDocAccess } = useUpdateDocAccess({
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_DOC, { id: doc.id }],
});
},
onError: () => {
toast(t('Error during invitation update'), VariantType.ERROR, {
duration: 4000,
@@ -40,6 +48,11 @@ export const DocShareMemberItem = ({ doc, access }: Props) => {
});
const { mutate: removeDocAccess } = useDeleteDocAccess({
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_SUB_DOC, { id: doc.id }],
});
},
onError: () => {
toast(t('Error while deleting invitation'), VariantType.ERROR, {
duration: 4000,

View File

@@ -0,0 +1,44 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc, KEY_LIST_DOC } from '../../doc-management';
export type CreateDocParam = Pick<Doc, 'title'> & {
parentId: string;
};
export const createDocChildren = async ({
title,
parentId,
}: CreateDocParam): Promise<Doc> => {
const response = await fetchAPI(`documents/${parentId}/children/`, {
method: 'POST',
body: JSON.stringify({
title,
}),
});
if (!response.ok) {
throw new APIError('Failed to create the doc', await errorCauses(response));
}
return response.json() as Promise<Doc>;
};
interface CreateDocProps {
onSuccess: (data: Doc) => void;
}
export function useCreateChildrenDoc({ onSuccess }: CreateDocProps) {
const queryClient = useQueryClient();
return useMutation<Doc, APIError, CreateDocParam>({
mutationFn: createDocChildren,
onSuccess: (data) => {
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC],
});
onSuccess(data);
},
});
}

View File

@@ -0,0 +1,58 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI, useAPIInfiniteQuery } from '@/api';
import { DocsResponse } from '../../doc-management';
export type DocsChildrenParams = {
docId: string;
page?: number;
page_size?: number;
};
export const getDocChildren = async (
params: DocsChildrenParams,
): Promise<DocsResponse> => {
const { docId, page, page_size } = params;
const searchParams = new URLSearchParams();
if (page) {
searchParams.set('page', page.toString());
}
if (page_size) {
searchParams.set('page_size', page_size.toString());
}
const response = await fetchAPI(
`documents/${docId}/children/?${searchParams.toString()}`,
);
if (!response.ok) {
throw new APIError(
'Failed to get the doc children',
await errorCauses(response),
);
}
return response.json() as Promise<DocsResponse>;
};
export const KEY_LIST_DOC_CHILDREN = 'doc-children';
export function useDocChildren(
params: DocsChildrenParams,
queryConfig?: Omit<
UseQueryOptions<DocsResponse, APIError, DocsResponse>,
'queryKey' | 'queryFn'
>,
) {
return useQuery<DocsResponse, APIError, DocsResponse>({
queryKey: [KEY_LIST_DOC_CHILDREN, params],
queryFn: () => getDocChildren(params),
...queryConfig,
});
}
export const useInfiniteDocChildren = (params: DocsChildrenParams) => {
return useAPIInfiniteQuery(KEY_LIST_DOC_CHILDREN, getDocChildren, params);
};

View File

@@ -0,0 +1,45 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc } from '../../doc-management';
export type DocsTreeParams = {
docId: string;
};
export const getDocTree = async (params: DocsTreeParams): Promise<Doc> => {
const { docId } = params;
const searchParams = new URLSearchParams();
const response = await fetchAPI(
`documents/${docId}/tree/?${searchParams.toString()}`,
);
if (!response.ok) {
throw new APIError(
'Failed to get the doc tree',
await errorCauses(response),
);
}
return response.json() as Promise<Doc>;
};
export const KEY_LIST_DOC_CHILDREN = 'doc-tree';
export function useDocTree(
params: DocsTreeParams,
queryConfig?: Omit<
UseQueryOptions<Doc, APIError, Doc>,
'queryKey' | 'queryFn'
>,
) {
return useQuery<Doc, APIError, Doc>({
queryKey: [KEY_LIST_DOC_CHILDREN, params],
queryFn: () => getDocTree(params),
staleTime: 0,
refetchOnWindowFocus: false,
...queryConfig,
});
}

View File

@@ -0,0 +1,36 @@
import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit';
import { useMutation } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
export type MoveDocParam = {
sourceDocumentId: string;
targetDocumentId: string;
position: TreeViewMoveModeEnum;
};
export const moveDoc = async ({
sourceDocumentId,
targetDocumentId,
position,
}: MoveDocParam): Promise<void> => {
const response = await fetchAPI(`documents/${sourceDocumentId}/move/`, {
method: 'POST',
body: JSON.stringify({
target_document_id: targetDocumentId,
position,
}),
});
if (!response.ok) {
throw new APIError('Failed to move the doc', await errorCauses(response));
}
return response.json() as Promise<void>;
};
export function useMoveDoc() {
return useMutation<void, APIError, MoveDocParam>({
mutationFn: moveDoc,
});
}

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.40918 4.69434C5.28613 4.69434 5.18359 4.65332 5.10156 4.57129C5.02409 4.48926 4.98535 4.389 4.98535 4.27051C4.98535 4.15202 5.02409 4.05404 5.10156 3.97656C5.18359 3.89453 5.28613 3.85352 5.40918 3.85352H10.5977C10.7161 3.85352 10.8141 3.89453 10.8916 3.97656C10.9736 4.05404 11.0146 4.15202 11.0146 4.27051C11.0146 4.389 10.9736 4.48926 10.8916 4.57129C10.8141 4.65332 10.7161 4.69434 10.5977 4.69434H5.40918ZM5.40918 7.08008C5.28613 7.08008 5.18359 7.03906 5.10156 6.95703C5.02409 6.875 4.98535 6.77474 4.98535 6.65625C4.98535 6.53776 5.02409 6.43978 5.10156 6.3623C5.18359 6.28027 5.28613 6.23926 5.40918 6.23926H10.5977C10.7161 6.23926 10.8141 6.28027 10.8916 6.3623C10.9736 6.43978 11.0146 6.53776 11.0146 6.65625C11.0146 6.77474 10.9736 6.875 10.8916 6.95703C10.8141 7.03906 10.7161 7.08008 10.5977 7.08008H5.40918ZM5.40918 9.46582C5.28613 9.46582 5.18359 9.42708 5.10156 9.34961C5.02409 9.26758 4.98535 9.1696 4.98535 9.05566C4.98535 8.93262 5.02409 8.83008 5.10156 8.74805C5.18359 8.66602 5.28613 8.625 5.40918 8.625H7.86328C7.98633 8.625 8.08659 8.66602 8.16406 8.74805C8.24609 8.83008 8.28711 8.93262 8.28711 9.05566C8.28711 9.1696 8.24609 9.26758 8.16406 9.34961C8.08659 9.42708 7.98633 9.46582 7.86328 9.46582H5.40918ZM2.25098 13.2529V2.88281C2.25098 2.17188 2.42643 1.63639 2.77734 1.27637C3.13281 0.916341 3.66374 0.736328 4.37012 0.736328H11.6299C12.3363 0.736328 12.8649 0.916341 13.2158 1.27637C13.5713 1.63639 13.749 2.17188 13.749 2.88281V13.2529C13.749 13.9684 13.5713 14.5039 13.2158 14.8594C12.8649 15.2148 12.3363 15.3926 11.6299 15.3926H4.37012C3.66374 15.3926 3.13281 15.2148 2.77734 14.8594C2.42643 14.5039 2.25098 13.9684 2.25098 13.2529ZM3.35156 13.2324C3.35156 13.5742 3.44043 13.8363 3.61816 14.0186C3.80046 14.2008 4.06934 14.292 4.4248 14.292H11.5752C11.9307 14.292 12.1973 14.2008 12.375 14.0186C12.5573 13.8363 12.6484 13.5742 12.6484 13.2324V2.90332C12.6484 2.56152 12.5573 2.29948 12.375 2.11719C12.1973 1.93034 11.9307 1.83691 11.5752 1.83691H4.4248C4.06934 1.83691 3.80046 1.93034 3.61816 2.11719C3.44043 2.29948 3.35156 2.56152 3.35156 2.90332V13.2324Z" fill="#8585F6"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,178 @@
import {
TreeViewItem,
TreeViewNodeProps,
useTree,
} from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { useEffect, useRef } from 'react';
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, KEY_SUB_DOC, useDoc } from '@/features/docs/doc-management';
import { useLeftPanelStore } from '@/features/left-panel';
import Logo from './../assets/sub-page-logo.svg';
import { DocTreeItemActions } from './DocTreeItemActions';
const ItemTextCss = css`
overflow: hidden;
text-overflow: ellipsis;
white-space: initial;
display: -webkit-box;
line-clamp: 1;
/* width: 100%; */
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
`;
type Props = TreeViewNodeProps<Doc> & {
treeData: ReturnType<typeof useTree<Doc>>;
doc: Doc;
setSelectedNode: (node: Doc) => void;
};
export const DocSubPageItem = ({
doc,
setSelectedNode,
treeData,
...props
}: Props) => {
const { loadChildren, node } = props;
const isInitialLoad = useRef(false);
const { spacingsTokens } = useCunninghamTheme();
const spacing = spacingsTokens();
const router = useRouter();
const { togglePanel } = useLeftPanelStore();
const { data: docQuery } = useDoc(
{ isTree: true, id: doc.id },
{
initialData: doc,
queryKey: [KEY_SUB_DOC, { id: doc.id }],
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
useEffect(() => {
if (docQuery && isInitialLoad.current === true) {
console.log('docQuery', docQuery);
treeData?.updateNode(docQuery.id, docQuery);
}
if (docQuery) {
isInitialLoad.current = true;
}
}, [docQuery, treeData]);
const afterCreate = (createdDoc: Doc) => {
const actualChildren = node.data.children ?? [];
if (actualChildren.length === 0 && loadChildren) {
loadChildren(node?.data.value)
.then((allChildren) => {
node.open();
router.push(`/docs/${doc.id}`);
treeData?.setChildren(node.data.value.id, allChildren);
togglePanel();
})
.catch(console.error);
} else {
const newDoc = {
...createdDoc,
children: [],
childrenCount: 0,
parentId: node.id,
};
treeData?.addChild(node.data.value.id, newDoc);
node.open();
router.push(`/docs/${createdDoc.id}`);
togglePanel();
}
};
if (!treeData) {
return null;
}
return (
<TreeViewItem
{...props}
loadChildren={() =>
treeData?.handleLoadChildren(props.node.data.value.id)
}
onClick={() => {
setSelectedNode(props.node.data.value as Doc);
router.push(`/docs/${props.node.data.value.id}`);
}}
>
<Box
data-testid={`doc-sub-page-item-${props.node.data.value.id}`}
$width="100%"
$direction="row"
$gap={spacing['xs']}
role="button"
tabIndex={0}
$align="center"
$css={css`
.light-doc-item-actions {
display: 'flex';
opacity: 0;
&:has(.isOpen) {
opacity: 1;
}
}
&:hover {
.light-doc-item-actions {
opacity: 1;
}
}
`}
>
<Box $width={16} $height={16}>
<Logo />
</Box>
<Box
$direction="row"
$align="center"
$css={css`
display: flex;
flex-direction: row;
width: 100%;
gap: 0.5rem;
align-items: center;
`}
>
<Text $css={ItemTextCss} $size="sm">
{doc.title}
</Text>
{doc.nb_accesses_direct > 1 && (
<Icon
$isMaterialIcon="filled"
iconName="group"
$size="16px"
$variation="400"
/>
)}
</Box>
<Box
$direction="row"
$gap={spacing['xs']}
$align="center"
className="light-doc-item-actions"
>
<DocTreeItemActions
doc={doc}
treeData={treeData}
parentId={node.data.parentKey}
onCreateSuccess={afterCreate}
/>
</Box>
</Box>
</TreeViewItem>
);
};

View File

@@ -0,0 +1,238 @@
import {
OpenMap,
TreeView,
TreeViewMoveResult,
useTree,
} from '@gouvfr-lasuite/ui-kit';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { css } from 'styled-components';
import { Box, SeparatedSection, StyledLink } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
KEY_DOC,
LinkReach,
LinkRole,
getDoc,
} from '../../doc-management';
import { SimpleDocItem } from '../../docs-grid';
import { getDocChildren } from '../api/useDocChildren';
import { useDocTree } from '../api/useDocTree';
import { useMoveDoc } from '../api/useMove';
import { subPageToTree, useDocTreeStore } from '../context/DocTreeContext';
import { DocSubPageItem } from './DocSubPageItem';
import { DocTreeItemActions } from './DocTreeItemActions';
type DocTreeProps = {
initialTargetId: string;
};
export const DocTree = ({ initialTargetId }: DocTreeProps) => {
const { spacingsTokens } = useCunninghamTheme();
const queryClient = useQueryClient();
const store = useDocTreeStore();
const spacing = spacingsTokens();
const treeData = useTree(
[],
async (docId) => {
const doc = await getDoc({ id: docId });
const newDoc = { ...doc, childrenCount: doc.numchild };
void queryClient.setQueryData([KEY_DOC, { id: docId }], newDoc);
return newDoc;
},
async (docId) => {
const doc = await getDocChildren({ docId: docId });
return subPageToTree(doc.results ?? []);
},
);
const router = useRouter();
const [initialOpenState, setInitialOpenState] = useState<OpenMap | undefined>(
undefined,
);
const { mutate: moveDoc } = useMoveDoc();
const { data } = useDocTree({
docId: initialTargetId,
});
const handleMove = (result: TreeViewMoveResult) => {
moveDoc({
sourceDocumentId: result.sourceId,
targetDocumentId: result.targetModeId,
position: result.mode,
});
treeData?.handleMove(result);
};
const buildDocTree = (data?: Doc) => {
if (!data) {
return;
}
const { children: rootChildren, ...root } = data;
const children = rootChildren ?? [];
store.setRoot(root);
const initialOpenState: OpenMap = {};
initialOpenState[root.id] = true;
subPageToTree(children, (child) => {
if (child?.children?.length && child?.children?.length > 0) {
initialOpenState[child.id] = true;
}
});
treeData.resetTree(children);
setInitialOpenState(initialOpenState);
if (initialTargetId === root.id) {
treeData?.setSelectedNode(root);
} else {
treeData?.selectNodeById(initialTargetId);
}
};
useEffect(() => {
if (treeData?.selectedNode?.id !== store.selectedNode?.id) {
store.setSelectedNode(treeData?.selectedNode ?? null);
}
}, [store, treeData?.selectedNode]);
useEffect(() => {
buildDocTree(data);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data]);
const rootIsSelected = treeData?.selectedNode?.id === store.root?.id;
if (!initialTargetId) {
return null;
}
return (
<Box data-testid="doc-tree" $height="100%">
<SeparatedSection showSeparator={false}>
<Box $padding={{ horizontal: 'sm' }}>
<Box
$css={css`
padding: ${spacing['2xs']};
border-radius: 4px;
width: 100%;
background-color: ${rootIsSelected
? 'var(--c--theme--colors--greyscale-100)'
: 'transparent'};
&:hover {
background-color: var(--c--theme--colors--greyscale-100);
}
.doc-tree-root-item-actions {
display: 'flex';
opacity: 0;
&:has(.isOpen) {
opacity: 1;
}
}
&:hover {
.doc-tree-root-item-actions {
opacity: 1;
}
}
`}
>
{store.root !== null && (
<StyledLink
$css={css`
width: 100%;
`}
href={`/docs/${store.root.id}`}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
treeData?.setSelectedNode(store.root ?? undefined);
router.push(`/docs/${store.root?.id}`);
}}
>
<Box $direction="row" $align="center" $width="100%">
<SimpleDocItem doc={store.root} showAccesses={true} />
<div className="doc-tree-root-item-actions">
<DocTreeItemActions
doc={store.root}
treeData={treeData}
onCreateSuccess={(createdDoc) => {
const newDoc = {
...createdDoc,
children: [],
childrenCount: 0,
parentId: store.root?.id ?? undefined,
};
treeData?.addChild(null, newDoc);
}}
/>
</div>
</Box>
</StyledLink>
)}
</Box>
</Box>
</SeparatedSection>
<button
onClick={() => {
const children = data?.children ?? [];
const newDoc = {
id: '1',
children: [],
childrenCount: 0,
title: 'TITI',
creator: 'test',
is_favorite: false,
};
// Ajout des propriétés manquantes pour correspondre au type Doc
const completeDoc = {
...newDoc,
link_reach: LinkReach.PUBLIC,
link_role: LinkRole.EDITOR,
user_roles: [],
// Ajoutez ici les autres propriétés requises par le type Doc
};
children.push(completeDoc);
buildDocTree(data);
}}
>
Refresh
</button>
{initialOpenState && treeData.nodes.length > 0 && (
<TreeView
handleMove={handleMove}
initialOpenState={initialOpenState}
selectedNodeId={
treeData.selectedNode?.id ?? store.initialTargetId ?? undefined
}
treeData={treeData.nodes ?? []}
rootNodeId={store.root?.id ?? ''}
renderNode={(props) => {
if (treeData === undefined) {
return null;
}
return (
<DocSubPageItem
{...props}
treeData={treeData}
doc={props.node.data.value as Doc}
loadChildren={(node) => treeData.handleLoadChildren(node.id)}
setSelectedNode={(node) => treeData.setSelectedNode(node)}
/>
);
}}
/>
)}
</Box>
);
};

View File

@@ -0,0 +1,129 @@
import {
DropdownMenu,
DropdownMenuOption,
useTree,
} from '@gouvfr-lasuite/ui-kit';
import { useModal } from '@openfun/cunningham-react';
import { useRouter } from 'next/navigation';
import { Fragment, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, BoxButton, Icon } from '@/components';
import { useLeftPanelStore } from '@/features/left-panel';
import { Doc, ModalRemoveDoc } from '../../doc-management';
import { useCreateChildrenDoc } from '../api/useCreateChildren';
import { useDocTreeStore } from '../context/DocTreeContext';
type DocTreeItemActionsProps = {
doc: Doc;
parentId?: string | null;
treeData: ReturnType<typeof useTree<Doc>>;
onCreateSuccess?: (newDoc: Doc) => void;
};
export const DocTreeItemActions = ({
doc,
parentId,
onCreateSuccess,
treeData,
}: DocTreeItemActionsProps) => {
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
const treeStore = useDocTreeStore();
const { t } = useTranslation();
const deleteModal = useModal();
const { togglePanel } = useLeftPanelStore();
const options: DropdownMenuOption[] = [
{
label: t('Delete'),
icon: <Icon iconName="delete" />,
callback: deleteModal.open,
},
];
const { mutate: createChildrenDoc } = useCreateChildrenDoc({
onSuccess: (doc) => {
onCreateSuccess?.(doc);
togglePanel();
treeData.setSelectedNode(doc);
router.push(`/docs/${doc.id}`);
},
});
const afterDelete = () => {
if (parentId) {
router.push(`/docs/${parentId}`);
treeData?.selectNodeById(parentId);
treeData?.deleteNode(doc.id);
void treeData?.refreshNode(parentId);
} else if (doc.id === treeStore.root?.id && !parentId) {
router.push(`/docs/`);
} else if (treeStore.root) {
router.push(`/docs/${treeStore.root.id}`);
treeData?.deleteNode(doc.id);
treeData?.setSelectedNode(treeStore.root);
}
};
return (
<Fragment>
<Box
$direction="row"
$align="center"
className={` ${isOpen ? 'isOpen' : ''}`}
$css={css`
gap: var(--c--theme----c--theme--spacings--xs);
`}
>
<DropdownMenu
options={options}
isOpen={isOpen}
onOpenChange={setIsOpen}
>
<Icon
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setIsOpen(!isOpen);
}}
iconName="more_horiz"
$isMaterialIcon="filled"
$theme="primary"
$variation="600"
/>
</DropdownMenu>
<BoxButton
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
createChildrenDoc({
title: t('Untitled page'),
parentId: doc.id,
});
}}
color="primary"
>
<Icon
$variation="800"
$theme="primary"
$isMaterialIcon="filled"
iconName="add_box"
/>
</BoxButton>
</Box>
{deleteModal.isOpen && (
<ModalRemoveDoc
onClose={deleteModal.onClose}
doc={doc}
afterDelete={afterDelete}
/>
)}
</Fragment>
);
};

View File

@@ -0,0 +1,37 @@
import { TreeViewDataType } from '@gouvfr-lasuite/ui-kit';
import { create } from 'zustand';
import { Doc } from '../../doc-management';
export const subPageToTree = (
children: Doc[],
callback?: (doc: Doc) => void,
): TreeViewDataType<Doc>[] => {
children.forEach((child) => {
child.childrenCount = child.numchild ?? 0;
callback?.(child);
subPageToTree(child.children ?? [], callback);
});
return children;
};
interface DocTreeStore {
initialTargetId?: string | null;
initialRootId?: string | null;
setRoot: (doc: Doc | null) => void;
root: Doc | null;
setInitialTargetId: (id: string) => void;
setSelectedNode: (node: Doc | null) => void;
selectedNode: Doc | null;
}
export const useDocTreeStore = create<DocTreeStore>((set) => ({
root: null,
selectedNode: null,
initialTargetId: undefined,
initialRootId: undefined,
setRoot: (doc) => set({ root: doc }),
setInitialTargetId: (id) => set({ initialTargetId: id }),
setSelectedNode: (node) => set({ selectedNode: node }),
}));

View File

@@ -0,0 +1,189 @@
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
KeyboardSensor,
Modifier,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { getEventCoordinates } from '@dnd-kit/utilities';
import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit';
import { useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import { Doc, KEY_LIST_DOC, Role } from '../../doc-management';
import { useMoveDoc } from '../../doc-tree/api/useMove';
import { DocsGridItem } from './DocsGridItem';
import { Draggable } from './dnd/Draggable';
import { Droppable } from './dnd/Droppable';
const activationConstraint = {
distance: 20,
};
type DocGridContentListProps = {
docs: Doc[];
};
export const DocGridContentList = ({ docs }: DocGridContentListProps) => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const [selectedDoc, setSelectedDoc] = useState<Doc>();
const canDrag = selectedDoc?.user_roles.some((role) => role === Role.OWNER);
const [canDrop, setCanDrop] = useState<boolean>();
const { mutate: handleMove } = useMoveDoc();
const mouseSensor = useSensor(MouseSensor, {
activationConstraint,
});
const touchSensor = useSensor(TouchSensor, {
activationConstraint,
});
const keyboardSensor = useSensor(KeyboardSensor, {});
const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor);
const handleDragStart = (e: DragStartEvent) => {
document.body.style.cursor = 'grabbing';
if (e.active.data.current) {
setSelectedDoc(e.active.data.current as Doc);
}
};
const handleDragEnd = (e: DragEndEvent) => {
setSelectedDoc(undefined);
setCanDrop(undefined);
document.body.style.cursor = 'default';
if (!canDrag || !canDrop) {
return;
}
const { active, over } = e;
if (!over?.id || active.id === over?.id) {
return;
}
handleMove(
{
sourceDocumentId: active.id as string,
targetDocumentId: over.id as string,
position: TreeViewMoveModeEnum.FIRST_CHILD,
},
{
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC],
});
},
},
);
};
const overlayText = useMemo(() => {
if (!canDrag) {
return t('You must be the owner to move the document');
}
if (!canDrop) {
return t('You must be at least the editor of the target document');
}
return selectedDoc?.title || t('Unnamed document');
}, [canDrag, canDrop, selectedDoc, t]);
const overlayBgColor = useMemo(() => {
if (!canDrag) {
return 'var(--c--theme--colors--danger-600)';
}
if (canDrop !== undefined && !canDrop) {
return 'var(--c--theme--colors--danger-600)';
}
return '#5858D3';
}, [canDrag, canDrop]);
if (docs.length === 0) {
return null;
}
return (
<DndContext
sensors={sensors}
modifiers={[snapToTopLeft]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
{docs.map((doc) => {
const canDropItem = doc.user_roles.some(
(role) =>
role === Role.ADMIN || role === Role.OWNER || role === Role.EDITOR,
);
return (
<Droppable
enabledDrop={canDrag}
canDrop={canDrag && canDropItem}
onOver={(isOver) => {
if (isOver) {
setCanDrop(canDropItem);
}
}}
key={doc.id}
id={doc.id}
data={doc}
>
<Draggable key={doc.id} id={doc.id} data={doc}>
<DocsGridItem dragMode={!!selectedDoc} doc={doc} key={doc.id} />
</Draggable>
</Droppable>
);
})}
<DragOverlay dropAnimation={null}>
<Box
$width="fit-content"
$padding={{ horizontal: 'xs', vertical: '3xs' }}
$radius="12px"
$background={overlayBgColor}
data-testid="drag-doc-overlay"
$height="auto"
>
<Text $size="xs" $variation="000" $weight="500">
{overlayText}
</Text>
</Box>
</DragOverlay>
</DndContext>
);
};
export const snapToTopLeft: Modifier = ({
activatorEvent,
draggingNodeRect,
transform,
}) => {
if (draggingNodeRect && activatorEvent) {
const activatorCoordinates = getEventCoordinates(activatorEvent);
if (!activatorCoordinates) {
return transform;
}
const offsetX = activatorCoordinates.x - draggingNodeRect.left;
const offsetY = activatorCoordinates.y - draggingNodeRect.top;
return {
...transform,
x: transform.x + offsetX - 3,
y: transform.y + offsetY - 3,
};
}
return transform;
};

View File

@@ -9,7 +9,7 @@ import { useResponsiveStore } from '@/stores';
import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid';
import { DocsGridItem } from './DocsGridItem';
import { DocGridContentList } from './DocGridContentList';
import { DocsGridLoader } from './DocsGridLoader';
type DocsGridProps = {
@@ -37,6 +37,9 @@ export const DocsGrid = ({
is_creator_me: target === DocDefaultFilter.MY_DOCS,
}),
});
const docs = data?.pages.flatMap((page) => page.results) ?? [];
const loading = isFetching || isLoading;
const hasDocs = data?.pages.some((page) => page.results.length > 0);
const loadMore = (inView: boolean) => {
@@ -114,11 +117,7 @@ export const DocsGrid = ({
)}
</Box>
{data?.pages.map((currentPage) => {
return currentPage.results.map((doc) => (
<DocsGridItem doc={doc} key={doc.id} />
));
})}
<DocGridContentList docs={docs} />
{hasNextPage && !loading && (
<InView

View File

@@ -16,8 +16,9 @@ import { DocsGridItemSharedButton } from './DocsGridItemSharedButton';
import { SimpleDocItem } from './SimpleDocItem';
type DocsGridItemProps = {
doc: Doc;
dragMode?: boolean;
};
export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
const { t } = useTranslation();
const { isDesktop } = useResponsiveStore();
const { flexLeft, flexRight } = useResponsiveDocGrid();
@@ -46,7 +47,9 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
cursor: pointer;
border-radius: 4px;
&:hover {
background-color: var(--c--theme--colors--greyscale-100);
background-color: ${dragMode
? 'none'
: 'var(--c--theme--colors--greyscale-100)'};
}
`}
>
@@ -79,25 +82,35 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
: undefined
}
>
<Tooltip
content={
<Text $textAlign="center" $variation="000">
{isPublic
? t('Accessible to anyone')
: t('Accessible to authenticated users')}
</Text>
}
placement="top"
>
<div>
<Icon
$theme="greyscale"
$variation="600"
$size="14px"
iconName={isPublic ? 'public' : 'vpn_lock'}
/>
</div>
</Tooltip>
{dragMode && (
<Icon
$theme="greyscale"
$variation="600"
$size="14px"
iconName={isPublic ? 'public' : 'vpn_lock'}
/>
)}
{!dragMode && (
<Tooltip
content={
<Text $textAlign="center" $variation="000">
{isPublic
? t('Accessible to anyone')
: t('Accessible to authenticated users')}
</Text>
}
placement="top"
>
<div>
<Icon
$theme="greyscale"
$variation="600"
$size="14px"
iconName={isPublic ? 'public' : 'vpn_lock'}
/>
</div>
</Tooltip>
)}
</Box>
)}
</Box>

View File

@@ -38,7 +38,7 @@ export const SimpleDocItem = ({
const { untitledDocument } = useTrans();
return (
<Box $direction="row" $gap={spacings.sm} $overflow="auto">
<Box $direction="row" $gap={spacings.sm} $overflow="auto" $width="100%">
<Box
$direction="row"
$align="center"

View File

@@ -0,0 +1,25 @@
import { Data, useDraggable } from '@dnd-kit/core';
type DraggableProps<T> = {
id: string;
data?: Data<T>;
children: React.ReactNode;
};
export const Draggable = <T,>(props: DraggableProps<T>) => {
const { attributes, listeners, setNodeRef } = useDraggable({
id: props.id,
data: props.data,
});
return (
<div
ref={setNodeRef}
{...listeners}
{...attributes}
data-testid={`draggable-doc-${props.id}`}
>
{props.children}
</div>
);
};

View File

@@ -0,0 +1,48 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { Data, useDroppable } from '@dnd-kit/core';
import { useEffect } from 'react';
import { css } from 'styled-components';
import { Box } from '@/components';
import { Doc } from '@/features/docs/doc-management';
type DroppableProps = {
id: string;
onOver?: (isOver: boolean, data?: Data<Doc>) => void;
data?: Data<Doc>;
children: React.ReactNode;
enabledDrop?: boolean;
canDrop?: boolean;
};
export const Droppable = (props: DroppableProps) => {
const { isOver, setNodeRef } = useDroppable({
id: props.id,
data: props.data,
});
const enableHover = props.canDrop && isOver;
useEffect(() => {
props.onOver?.(isOver, props.data);
}, [isOver, props.data, props.onOver]);
return (
<Box
ref={setNodeRef}
data-testid={`droppable-doc-${props.id}`}
$css={css`
border-radius: 4px;
background-color: ${enableHover
? 'var(--c--theme--colors--primary-100)'
: 'transparent'};
border: 1.5px solid
${enableHover
? 'var(--c--theme--colors--primary-500)'
: 'transparent'};
`}
>
{props.children}
</Box>
);
};

View File

@@ -1,15 +1,14 @@
import { css } from 'styled-components';
import { Box, SeparatedSection } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '@/components';
import { useDocStore } from '@/docs/doc-management';
import { SimpleDocItem } from '@/docs/docs-grid';
import { DocTree } from '@/features/docs/doc-tree/components/DocTree';
import { useDocTreeStore } from '@/features/docs/doc-tree/context/DocTreeContext';
export const LeftPanelDocContent = () => {
const { currentDoc } = useDocStore();
const { spacingsTokens } = useCunninghamTheme();
const spacing = spacingsTokens();
if (!currentDoc) {
const treeStore = useDocTreeStore();
if (!currentDoc || !treeStore.initialTargetId) {
return null;
}
@@ -19,19 +18,9 @@ export const LeftPanelDocContent = () => {
$width="100%"
$css="width: 100%; overflow-y: auto; overflow-x: hidden;"
>
<SeparatedSection showSeparator={false}>
<Box $padding={{ horizontal: 'sm' }}>
<Box
$css={css`
padding: ${spacing['2xs']};
border-radius: 4px;
background-color: var(--c--theme--colors--greyscale-100);
`}
>
<SimpleDocItem doc={currentDoc} showAccesses={true} />
</Box>
</Box>
</SeparatedSection>
{treeStore.initialTargetId && (
<DocTree initialTargetId={treeStore.initialTargetId} />
)}
</Box>
);
};

View File

@@ -1,12 +1,15 @@
import { Button, ModalSize, useModal } from '@openfun/cunningham-react';
import { t } from 'i18next';
import { useRouter } from 'next/navigation';
import { useRouter } from 'next/router';
import { PropsWithChildren } from 'react';
import { Box, Icon, SeparatedSection } from '@/components';
import { useCreateDoc } from '@/docs/doc-management';
import { DocSearchModal } from '@/docs/doc-search';
import { useAuth } from '@/features/auth';
import { useCreateDoc, useDocStore } from '@/features/docs/doc-management';
import { DocSearchTarget } from '@/features/docs/doc-search/components/DocSearchFilters';
import { useCreateChildrenDoc } from '@/features/docs/doc-tree/api/useCreateChildren';
import { useDocTreeStore } from '@/features/docs/doc-tree/context/DocTreeContext';
import { useCmdK } from '@/hook/useCmdK';
import { useLeftPanelStore } from '../stores';
@@ -15,6 +18,11 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
const router = useRouter();
const searchModal = useModal();
const { authenticated } = useAuth();
const treeStore = useDocTreeStore();
const { currentDoc } = useDocStore();
const isDoc = router.pathname === '/docs/[id]';
useCmdK(() => {
const isEditorToolbarOpen =
document.getElementsByClassName('bn-formatting-toolbar').length > 0;
@@ -28,18 +36,34 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
const { mutate: createDoc } = useCreateDoc({
onSuccess: (doc) => {
router.push(`/docs/${doc.id}`);
void router.push(`/docs/${doc.id}`);
togglePanel();
},
});
const { mutate: createChildrenDoc } = useCreateChildrenDoc({
onSuccess: (doc) => {
treeStore.treeData?.addRootNode(doc);
treeStore.treeData?.selectNodeById(doc.id);
void router.push(`/docs/${doc.id}`);
togglePanel();
},
});
const goToHome = () => {
router.push('/');
void router.push('/');
togglePanel();
};
const createNewDoc = () => {
createDoc();
if (treeStore.root && isDoc) {
createChildrenDoc({
title: t('Untitled page'),
parentId: treeStore.root.id,
});
} else {
createDoc();
}
};
return (
@@ -73,15 +97,29 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
/>
)}
</Box>
{authenticated && (
<Button onClick={createNewDoc}>{t('New doc')}</Button>
<Button
color={!isDoc ? 'primary' : 'tertiary'}
onClick={createNewDoc}
disabled={currentDoc && !currentDoc.abilities.update}
>
{t(isDoc ? 'New page' : 'New doc')}
</Button>
)}
</Box>
</SeparatedSection>
{children}
</Box>
{searchModal.isOpen && (
<DocSearchModal {...searchModal} size={ModalSize.LARGE} />
<DocSearchModal
{...searchModal}
size={ModalSize.LARGE}
showFilters={isDoc}
defaultFilters={{
target: isDoc ? DocSearchTarget.CURRENT : undefined,
}}
/>
)}
</>
);

View File

@@ -175,6 +175,7 @@ export class ApiPlugin implements WorkboxPlugin {
is_favorite: false,
nb_accesses_direct: 1,
nb_accesses_ancestors: 1,
numchild: 0,
updated_at: new Date().toISOString(),
abilities: {
accesses_manage: true,
@@ -201,6 +202,7 @@ export class ApiPlugin implements WorkboxPlugin {
},
link_reach: LinkReach.RESTRICTED,
link_role: LinkRole.READER,
user_roles: [],
};
await DocsDB.cacheResponse(

View File

@@ -1,6 +1,5 @@
import type { AppProps } from 'next/app';
import Head from 'next/head';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { AppProvider } from '@/core/';
@@ -19,14 +18,6 @@ export default function App({ Component, pageProps }: AppPropsWithLayout) {
const getLayout = Component.getLayout ?? ((page) => page);
const { t } = useTranslation();
useEffect(() => {
console.log(
`%c
\r\n \u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557 \r\n \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \r\n \u2588\u2588\u2551 \u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2588\u2588\u2554\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \r\n \u2588\u2588\u2551\u2588\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u255D \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2554\u255D\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u255D \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u255A\u2550\u2550\u2550\u2550\u2588\u2588\u2551 \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \r\n \u255A\u2588\u2588\u2588\u2554\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u255A\u2550\u255D \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551 \u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557 \r\n \u255A\u2550\u2550\u255D\u255A\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \r\n \r`,
'font-size: 11px;line-height:15px;background-image: linear-gradient(#000091, #005f91);color: transparent;background-clip: text;',
);
}, []);
return (
<>
<Head>

View File

@@ -7,14 +7,15 @@ import { useTranslation } from 'react-i18next';
import { Box, Text, TextErrors } from '@/components';
import { DocEditor } from '@/docs/doc-editor';
import { KEY_AUTH, setAuthUrl } from '@/features/auth';
import {
Doc,
KEY_DOC,
useCollaboration,
useDoc,
useDocStore,
} from '@/docs/doc-management/';
import { KEY_AUTH, setAuthUrl } from '@/features/auth';
} from '@/features/docs/doc-management/';
import { useDocTreeStore } from '@/features/docs/doc-tree/context/DocTreeContext';
import { MainLayout } from '@/layouts';
import { useBroadcastStore } from '@/stores';
import { NextPageWithLayout } from '@/types/next';
@@ -24,6 +25,14 @@ export function DocLayout() {
query: { id },
} = useRouter();
const treeStore = useDocTreeStore();
useEffect(() => {
if (typeof id === 'string' && !treeStore.initialTargetId) {
treeStore.setInitialTargetId(id);
}
}, [id, treeStore]);
if (typeof id !== 'string') {
return null;
}
@@ -35,7 +44,7 @@ export function DocLayout() {
</Head>
<MainLayout>
<DocPage id={id} />
{treeStore.initialTargetId && <DocPage id={id} />}
</MainLayout>
</>
);
@@ -56,6 +65,8 @@ const DocPage = ({ id }: DocProps) => {
{
staleTime: 0,
queryKey: [KEY_DOC, { id }],
refetchOnMount: false,
refetchOnWindowFocus: false,
},
);
@@ -84,6 +95,13 @@ const DocPage = ({ id }: DocProps) => {
setCurrentDoc(docQuery);
}, [docQuery, setCurrentDoc, isFetching]);
useEffect(() => {
return () => {
setCurrentDoc(undefined);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
/**
* We add a broadcast task to reset the query cache
* when the document visibility changes.

View File

@@ -70,11 +70,3 @@ main ::-webkit-scrollbar-thumb:hover,
/* Support for IE. */
font-feature-settings: 'liga';
}
[data-nextjs-dialog-overlay] {
display: none !important;
}
nextjs-portal {
display: none;
}

View File

@@ -1640,10 +1640,8 @@
resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/integration/-/integration-1.0.2.tgz#ed0000f4b738c5a19bb60f5b80a9a2f5d9414234"
integrity sha512-npOotZQSyu6SffHiPP+jQVOkJ3qW2KE2cANhEK92sNLX9uZqQaCqljO5GhzsBmh0lB76fiXnrr9i8SIpnDUSZg==
"@gouvfr-lasuite/ui-kit@0.1.3":
"@gouvfr-lasuite/ui-kit@file:../../../design-system":
version "0.1.3"
resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/ui-kit/-/ui-kit-0.1.3.tgz#1be7f1bdf12e7428e630d6ce11fbc77c4c9b9b21"
integrity sha512-ba3ZrAIhX84cofa2IwiWhgE0wzz85+ySbOTvB1lP9jeWYvWn/N5HsnxphA9bEMIrx1Yi91upzmYLvjHRoDq1Ww==
dependencies:
"@dnd-kit/core" "6.3.1"
"@dnd-kit/modifiers" "9.0.0"
@@ -13416,6 +13414,38 @@ react-stately@3.35.0, react-stately@^3.35.0:
"@react-stately/tree" "^3.8.7"
"@react-types/shared" "^3.27.0"
react-stately@3.36.1:
version "3.36.1"
resolved "https://registry.yarnpkg.com/react-stately/-/react-stately-3.36.1.tgz#605c18e6aa7a900f19b066699b5b35b7800cb759"
integrity sha512-H9kiGAylNec/iE5qk7qQLV1cvtSAIVq3mgt87zx2EA+f+/sYy2oBtchFPaDiBf/m7xMEKf0Fr9zSLU6G99xQ8g==
dependencies:
"@react-stately/calendar" "^3.7.1"
"@react-stately/checkbox" "^3.6.12"
"@react-stately/collections" "^3.12.2"
"@react-stately/color" "^3.8.3"
"@react-stately/combobox" "^3.10.3"
"@react-stately/data" "^3.12.2"
"@react-stately/datepicker" "^3.13.0"
"@react-stately/disclosure" "^3.0.2"
"@react-stately/dnd" "^3.5.2"
"@react-stately/form" "^3.1.2"
"@react-stately/list" "^3.12.0"
"@react-stately/menu" "^3.9.2"
"@react-stately/numberfield" "^3.9.10"
"@react-stately/overlays" "^3.6.14"
"@react-stately/radio" "^3.10.11"
"@react-stately/searchfield" "^3.5.10"
"@react-stately/select" "^3.6.11"
"@react-stately/selection" "^3.20.0"
"@react-stately/slider" "^3.6.2"
"@react-stately/table" "^3.14.0"
"@react-stately/tabs" "^3.8.0"
"@react-stately/toast" "^3.0.0"
"@react-stately/toggle" "^3.8.2"
"@react-stately/tooltip" "^3.5.2"
"@react-stately/tree" "^3.8.8"
"@react-types/shared" "^3.28.0"
react-stately@^3.34.0:
version "3.36.0"
resolved "https://registry.yarnpkg.com/react-stately/-/react-stately-3.36.0.tgz#1544f0a742145d9bc2d67a8c76af3648a9982fd6"