mirror of
https://github.com/suitenumerique/docs.git
synced 2026-04-26 01:25:05 +02:00
Compare commits
8 Commits
improve-co
...
feature/do
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b2a579d9f | ||
|
|
615ab564cc | ||
|
|
aadd6d9ec3 | ||
|
|
0a6502a77d | ||
|
|
a32ee20249 | ||
|
|
48db42f385 | ||
|
|
05b14b2948 | ||
|
|
12f4a72f5e |
2
Makefile
2
Makefile
@@ -93,7 +93,6 @@ build: cache ?= --no-cache
|
|||||||
build: ## build the project containers
|
build: ## build the project containers
|
||||||
@$(MAKE) build-backend cache=$(cache)
|
@$(MAKE) build-backend cache=$(cache)
|
||||||
@$(MAKE) build-yjs-provider cache=$(cache)
|
@$(MAKE) build-yjs-provider cache=$(cache)
|
||||||
@$(MAKE) build-frontend cache=$(cache)
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
|
|
||||||
build-backend: cache ?=
|
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: ## start the wsgi (production) and development server
|
||||||
run:
|
run:
|
||||||
@$(MAKE) run-backend
|
@$(MAKE) run-backend
|
||||||
@$(COMPOSE) up --force-recreate -d frontend
|
|
||||||
.PHONY: run
|
.PHONY: run
|
||||||
|
|
||||||
status: ## an alias for "docker compose ps"
|
status: ## an alias for "docker compose ps"
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -486,6 +486,7 @@ class Document(MP_Node, BaseModel):
|
|||||||
)
|
)
|
||||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||||
ancestors_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
|
_content = None
|
||||||
|
|
||||||
@@ -546,6 +547,12 @@ class Document(MP_Node, BaseModel):
|
|||||||
content_file = ContentFile(bytes_content)
|
content_file = ContentFile(bytes_content)
|
||||||
default_storage.save(file_key, content_file)
|
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
|
@property
|
||||||
def key_base(self):
|
def key_base(self):
|
||||||
"""Key base of the location where the document is stored in object storage."""
|
"""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:
|
if self.depth > 1:
|
||||||
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
|
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
|
# Mark all descendants as soft deleted
|
||||||
|
|||||||
@@ -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):
|
def test_models_documents_get_select_options(ancestors_links, select_options):
|
||||||
"""Validate that the "get_select_options" method operates as expected."""
|
"""Validate that the "get_select_options" method operates as expected."""
|
||||||
assert models.LinkReachChoices.get_select_options(ancestors_links) == select_options
|
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
|
||||||
|
|||||||
@@ -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) => {
|
export const mockedInvitations = async (page: Page, json?: object) => {
|
||||||
await page.route('**/invitations/**/', async (route) => {
|
await page.route('**/invitations/**/', async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
|
|||||||
309
src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts
Normal file
309
src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 = {
|
const customColors = {
|
||||||
'primary-action': '#1212FF',
|
'primary-action': '#1212FF',
|
||||||
@@ -34,7 +38,6 @@ const customColors = {
|
|||||||
'yellow-500': '#B7A73F',
|
'yellow-500': '#B7A73F',
|
||||||
'yellow-600': '#66673D',
|
'yellow-600': '#66673D',
|
||||||
};
|
};
|
||||||
|
|
||||||
tokens.themes.default.theme.colors = {
|
tokens.themes.default.theme.colors = {
|
||||||
...tokens.themes.default.theme.colors,
|
...tokens.themes.default.theme.colors,
|
||||||
...customColors,
|
...customColors,
|
||||||
|
|||||||
@@ -21,15 +21,18 @@
|
|||||||
"@blocknote/react": "0.23.2-hotfix.0",
|
"@blocknote/react": "0.23.2-hotfix.0",
|
||||||
"@blocknote/xl-docx-exporter": "0.23.2-hotfix.0",
|
"@blocknote/xl-docx-exporter": "0.23.2-hotfix.0",
|
||||||
"@blocknote/xl-pdf-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",
|
"@fontsource/material-icons": "5.2.5",
|
||||||
"@gouvfr-lasuite/integration": "1.0.2",
|
"@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",
|
"@hocuspocus/provider": "2.15.2",
|
||||||
"@openfun/cunningham-react": "3.0.0",
|
"@openfun/cunningham-react": "3.0.0",
|
||||||
"@react-pdf/renderer": "4.1.6",
|
"@react-pdf/renderer": "4.1.6",
|
||||||
"@sentry/nextjs": "9.3.0",
|
"@sentry/nextjs": "9.3.0",
|
||||||
"@tanstack/react-query": "5.67.1",
|
"@tanstack/react-query": "5.67.1",
|
||||||
"canvg": "4.0.3",
|
"canvg": "4.0.3",
|
||||||
|
"clsx": "2.1.1",
|
||||||
"cmdk": "1.0.4",
|
"cmdk": "1.0.4",
|
||||||
"crisp-sdk-web": "1.0.25",
|
"crisp-sdk-web": "1.0.25",
|
||||||
"docx": "9.1.1",
|
"docx": "9.1.1",
|
||||||
@@ -46,6 +49,7 @@
|
|||||||
"react-i18next": "15.4.1",
|
"react-i18next": "15.4.1",
|
||||||
"react-intersection-observer": "9.15.1",
|
"react-intersection-observer": "9.15.1",
|
||||||
"react-select": "5.10.1",
|
"react-select": "5.10.1",
|
||||||
|
"react-stately": "3.36.1",
|
||||||
"styled-components": "6.1.15",
|
"styled-components": "6.1.15",
|
||||||
"use-debounce": "10.0.4",
|
"use-debounce": "10.0.4",
|
||||||
"y-protocols": "1.0.6",
|
"y-protocols": "1.0.6",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type DropdownMenuOption = {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
label: string;
|
label: string;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
|
value?: string;
|
||||||
callback?: () => void | Promise<unknown>;
|
callback?: () => void | Promise<unknown>;
|
||||||
danger?: boolean;
|
danger?: boolean;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
@@ -23,6 +24,8 @@ export type DropdownMenuProps = {
|
|||||||
buttonCss?: BoxProps['$css'];
|
buttonCss?: BoxProps['$css'];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
topMessage?: string;
|
topMessage?: string;
|
||||||
|
selectedValues?: string[];
|
||||||
|
afterOpenChange?: (isOpen: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DropdownMenu = ({
|
export const DropdownMenu = ({
|
||||||
@@ -34,6 +37,8 @@ export const DropdownMenu = ({
|
|||||||
buttonCss,
|
buttonCss,
|
||||||
label,
|
label,
|
||||||
topMessage,
|
topMessage,
|
||||||
|
afterOpenChange,
|
||||||
|
selectedValues,
|
||||||
}: PropsWithChildren<DropdownMenuProps>) => {
|
}: PropsWithChildren<DropdownMenuProps>) => {
|
||||||
const theme = useCunninghamTheme();
|
const theme = useCunninghamTheme();
|
||||||
const spacings = theme.spacingsTokens();
|
const spacings = theme.spacingsTokens();
|
||||||
@@ -43,6 +48,7 @@ export const DropdownMenu = ({
|
|||||||
|
|
||||||
const onOpenChange = (isOpen: boolean) => {
|
const onOpenChange = (isOpen: boolean) => {
|
||||||
setIsOpen(isOpen);
|
setIsOpen(isOpen);
|
||||||
|
afterOpenChange?.(isOpen);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
@@ -161,7 +167,8 @@ export const DropdownMenu = ({
|
|||||||
{option.label}
|
{option.label}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{option.isSelected && (
|
{(option.isSelected ||
|
||||||
|
selectedValues?.includes(option.value ?? '')) && (
|
||||||
<Icon iconName="check" $size="20px" $theme="greyscale" />
|
<Icon iconName="check" $size="20px" $theme="greyscale" />
|
||||||
)}
|
)}
|
||||||
</BoxButton>
|
</BoxButton>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ type IconProps = TextType & {
|
|||||||
};
|
};
|
||||||
export const Icon = ({ iconName, ...textProps }: IconProps) => {
|
export const Icon = ({ iconName, ...textProps }: IconProps) => {
|
||||||
return (
|
return (
|
||||||
<Text $isMaterialIcon {...textProps}>
|
<Text $isMaterialIcon={textProps.$isMaterialIcon ?? true} {...textProps}>
|
||||||
{iconName}
|
{iconName}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
@@ -27,7 +27,7 @@ export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
|
|||||||
$size="36px"
|
$size="36px"
|
||||||
$theme="primary"
|
$theme="primary"
|
||||||
$variation="600"
|
$variation="600"
|
||||||
$background={colorsTokens()['primary-bg']}
|
$background={colorsTokens()['greyscale-000']}
|
||||||
$css={`
|
$css={`
|
||||||
border: 1px solid ${colorsTokens()['primary-200']};
|
border: 1px solid ${colorsTokens()['primary-200']};
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
import { CSSProperties, ComponentPropsWithRef, forwardRef } from 'react';
|
import { CSSProperties, ComponentPropsWithRef, forwardRef } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ type TextSizes = keyof typeof sizes;
|
|||||||
export interface TextProps extends BoxProps {
|
export interface TextProps extends BoxProps {
|
||||||
as?: 'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
as?: 'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
||||||
$elipsis?: boolean;
|
$elipsis?: boolean;
|
||||||
$isMaterialIcon?: boolean;
|
$isMaterialIcon?: boolean | 'filled';
|
||||||
$weight?: CSSProperties['fontWeight'];
|
$weight?: CSSProperties['fontWeight'];
|
||||||
$textAlign?: CSSProperties['textAlign'];
|
$textAlign?: CSSProperties['textAlign'];
|
||||||
$size?: TextSizes | (string & {});
|
$size?: TextSizes | (string & {});
|
||||||
@@ -58,13 +59,20 @@ export const TextStyled = styled(Box)<TextProps>`
|
|||||||
|
|
||||||
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
|
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
|
||||||
({ className, $isMaterialIcon, ...props }, ref) => {
|
({ className, $isMaterialIcon, ...props }, ref) => {
|
||||||
|
const isFilled = $isMaterialIcon === 'filled';
|
||||||
|
const isMaterialIcon =
|
||||||
|
typeof $isMaterialIcon === 'boolean' && $isMaterialIcon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextStyled
|
<TextStyled
|
||||||
ref={ref}
|
ref={ref}
|
||||||
as="span"
|
as="span"
|
||||||
$theme="greyscale"
|
$theme="greyscale"
|
||||||
$variation="text"
|
$variation="text"
|
||||||
className={`${className || ''}${$isMaterialIcon ? ' material-icons' : ''}`}
|
className={clsx(className || '', {
|
||||||
|
'material-icons': isMaterialIcon,
|
||||||
|
'material-icons-filled': isFilled,
|
||||||
|
})}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -57,6 +57,9 @@ export const QuickSearchInput = ({
|
|||||||
/* eslint-disable-next-line jsx-a11y/no-autofocus */
|
/* eslint-disable-next-line jsx-a11y/no-autofocus */
|
||||||
autoFocus={true}
|
autoFocus={true}
|
||||||
aria-label={t('Quick search input')}
|
aria-label={t('Quick search input')}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
role="combobox"
|
role="combobox"
|
||||||
placeholder={placeholder ?? t('Search')}
|
placeholder={placeholder ?? t('Search')}
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
||||||
import {
|
import { Tooltip } from '@openfun/cunningham-react';
|
||||||
Tooltip,
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
VariantType,
|
|
||||||
useToastProvider,
|
|
||||||
} from '@openfun/cunningham-react';
|
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { css } from 'styled-components';
|
import { css } from 'styled-components';
|
||||||
@@ -15,11 +12,13 @@ import {
|
|||||||
Doc,
|
Doc,
|
||||||
KEY_DOC,
|
KEY_DOC,
|
||||||
KEY_LIST_DOC,
|
KEY_LIST_DOC,
|
||||||
|
KEY_SUB_DOC,
|
||||||
useTrans,
|
useTrans,
|
||||||
useUpdateDoc,
|
useUpdateDoc,
|
||||||
} from '@/docs/doc-management';
|
} from '@/docs/doc-management';
|
||||||
import { useBroadcastStore, useResponsiveStore } from '@/stores';
|
import { useBroadcastStore, useResponsiveStore } from '@/stores';
|
||||||
|
|
||||||
|
import { useDocTreeStore } from '../../doc-tree/context/DocTreeContext';
|
||||||
interface DocTitleProps {
|
interface DocTitleProps {
|
||||||
doc: Doc;
|
doc: Doc;
|
||||||
}
|
}
|
||||||
@@ -55,20 +54,27 @@ export const DocTitleText = ({ title }: DocTitleTextProps) => {
|
|||||||
const DocTitleInput = ({ doc }: DocTitleProps) => {
|
const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||||
const { isDesktop } = useResponsiveStore();
|
const { isDesktop } = useResponsiveStore();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { colorsTokens } = useCunninghamTheme();
|
const { colorsTokens } = useCunninghamTheme();
|
||||||
|
const treeStore = useDocTreeStore();
|
||||||
const [titleDisplay, setTitleDisplay] = useState(doc.title);
|
const [titleDisplay, setTitleDisplay] = useState(doc.title);
|
||||||
const { toast } = useToastProvider();
|
|
||||||
const { untitledDocument } = useTrans();
|
const { untitledDocument } = useTrans();
|
||||||
|
|
||||||
const { broadcast } = useBroadcastStore();
|
const { broadcast } = useBroadcastStore();
|
||||||
|
|
||||||
const { mutate: updateDoc } = useUpdateDoc({
|
const { mutate: updateDoc } = useUpdateDoc({
|
||||||
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
|
listInvalideQueries: [KEY_LIST_DOC],
|
||||||
onSuccess(data) {
|
onSuccess(updatedDoc) {
|
||||||
toast(t('Document title updated successfully'), VariantType.SUCCESS);
|
|
||||||
|
|
||||||
// Broadcast to every user connected to the document
|
// 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,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Doc } from '../types';
|
|||||||
|
|
||||||
export type DocParams = {
|
export type DocParams = {
|
||||||
id: string;
|
id: string;
|
||||||
|
isTree?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDoc = async ({ id }: DocParams): Promise<Doc> => {
|
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_DOC = 'doc';
|
||||||
|
export const KEY_SUB_DOC = 'sub-doc';
|
||||||
export const KEY_DOC_VISIBILITY = 'doc-visibility';
|
export const KEY_DOC_VISIBILITY = 'doc-visibility';
|
||||||
|
|
||||||
export function useDoc(
|
export function useDoc(
|
||||||
param: DocParams,
|
param: DocParams,
|
||||||
queryConfig?: UseQueryOptions<Doc, APIError, Doc>,
|
queryConfig?: Omit<UseQueryOptions<Doc, APIError, Doc>, 'queryFn'>,
|
||||||
) {
|
) {
|
||||||
return useQuery<Doc, APIError, Doc>({
|
return useQuery<Doc, APIError, Doc>({
|
||||||
queryKey: [KEY_DOC, param],
|
queryKey: queryConfig?.queryKey ?? [KEY_DOC, param],
|
||||||
queryFn: () => getDoc(param),
|
queryFn: () => getDoc(param),
|
||||||
...queryConfig,
|
...queryConfig,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
useAPIInfiniteQuery,
|
useAPIInfiniteQuery,
|
||||||
} from '@/api';
|
} from '@/api';
|
||||||
|
|
||||||
|
import { DocSearchTarget } from '../../doc-search/components/DocSearchFilters';
|
||||||
import { Doc } from '../types';
|
import { Doc } from '../types';
|
||||||
|
|
||||||
export const isDocsOrdering = (data: string): data is DocsOrdering => {
|
export const isDocsOrdering = (data: string): data is DocsOrdering => {
|
||||||
@@ -31,6 +32,8 @@ export type DocsParams = {
|
|||||||
is_creator_me?: boolean;
|
is_creator_me?: boolean;
|
||||||
title?: string;
|
title?: string;
|
||||||
is_favorite?: boolean;
|
is_favorite?: boolean;
|
||||||
|
target?: DocSearchTarget;
|
||||||
|
parent_id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DocsResponse = APIList<Doc>;
|
export type DocsResponse = APIList<Doc>;
|
||||||
@@ -53,8 +56,14 @@ export const getDocs = async (params: DocsParams): Promise<DocsResponse> => {
|
|||||||
if (params.is_favorite !== undefined) {
|
if (params.is_favorite !== undefined) {
|
||||||
searchParams.set('is_favorite', params.is_favorite.toString());
|
searchParams.set('is_favorite', params.is_favorite.toString());
|
||||||
}
|
}
|
||||||
|
let response: Response;
|
||||||
const response = await fetchAPI(`documents/?${searchParams.toString()}`);
|
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) {
|
if (!response.ok) {
|
||||||
throw new APIError('Failed to get the docs', await errorCauses(response));
|
throw new APIError('Failed to get the docs', await errorCauses(response));
|
||||||
|
|||||||
@@ -17,16 +17,20 @@ import { Doc } from '../types';
|
|||||||
interface ModalRemoveDocProps {
|
interface ModalRemoveDocProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
doc: Doc;
|
doc: Doc;
|
||||||
|
afterDelete?: (doc: Doc) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
|
export const ModalRemoveDoc = ({
|
||||||
|
onClose,
|
||||||
|
doc,
|
||||||
|
afterDelete,
|
||||||
|
}: ModalRemoveDocProps) => {
|
||||||
const { toast } = useToastProvider();
|
const { toast } = useToastProvider();
|
||||||
const { push } = useRouter();
|
const { push } = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutate: removeDoc,
|
mutate: removeDoc,
|
||||||
|
|
||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
} = useRemoveDoc({
|
} = useRemoveDoc({
|
||||||
@@ -34,6 +38,11 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
|
|||||||
toast(t('The document has been deleted.'), VariantType.SUCCESS, {
|
toast(t('The document has been deleted.'), VariantType.SUCCESS, {
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
});
|
});
|
||||||
|
if (afterDelete) {
|
||||||
|
afterDelete(doc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname === '/') {
|
if (pathname === '/') {
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
@@ -87,7 +96,9 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
|
|||||||
<Box aria-label={t('Content modal to delete document')}>
|
<Box aria-label={t('Content modal to delete document')}>
|
||||||
{!isError && (
|
{!isError && (
|
||||||
<Text $size="sm" $variation="600">
|
<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>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -42,10 +42,14 @@ export interface Doc {
|
|||||||
is_favorite: boolean;
|
is_favorite: boolean;
|
||||||
link_reach: LinkReach;
|
link_reach: LinkReach;
|
||||||
link_role: LinkRole;
|
link_role: LinkRole;
|
||||||
nb_accesses_ancestors: number;
|
user_roles: Role[];
|
||||||
nb_accesses_direct: number;
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
nb_accesses_direct: number;
|
||||||
|
nb_accesses_ancestors: number;
|
||||||
|
children?: Doc[];
|
||||||
|
childrenCount?: number;
|
||||||
|
numchild: number;
|
||||||
abilities: {
|
abilities: {
|
||||||
accesses_manage: boolean;
|
accesses_manage: boolean;
|
||||||
accesses_view: boolean;
|
accesses_view: boolean;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -15,17 +15,37 @@ import {
|
|||||||
import { Doc, useInfiniteDocs } from '@/docs/doc-management';
|
import { Doc, useInfiniteDocs } from '@/docs/doc-management';
|
||||||
import { useResponsiveStore } from '@/stores';
|
import { useResponsiveStore } from '@/stores';
|
||||||
|
|
||||||
|
import { useDocTreeStore } from '../../doc-tree/context/DocTreeContext';
|
||||||
import EmptySearchIcon from '../assets/illustration-docs-empty.png';
|
import EmptySearchIcon from '../assets/illustration-docs-empty.png';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DocSearchFilters,
|
||||||
|
DocSearchFiltersValues,
|
||||||
|
DocSearchTarget,
|
||||||
|
} from './DocSearchFilters';
|
||||||
import { DocSearchItem } from './DocSearchItem';
|
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 { t } = useTranslation();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const treeStore = useDocTreeStore();
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [filters, setFilters] = useState<DocSearchFiltersValues>(
|
||||||
|
defaultFilters ?? {},
|
||||||
|
);
|
||||||
const { isDesktop } = useResponsiveStore();
|
const { isDesktop } = useResponsiveStore();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
isFetching,
|
isFetching,
|
||||||
@@ -36,27 +56,41 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
|
|||||||
} = useInfiniteDocs({
|
} = useInfiniteDocs({
|
||||||
page: 1,
|
page: 1,
|
||||||
title: search,
|
title: search,
|
||||||
|
...filters,
|
||||||
|
parent_id: treeStore?.root?.id,
|
||||||
});
|
});
|
||||||
const loading = isFetching || isRefetching || isLoading;
|
const loading = isFetching || isRefetching || isLoading;
|
||||||
const handleInputSearch = useDebouncedCallback(setSearch, 300);
|
const handleInputSearch = useDebouncedCallback(setSearch, 300);
|
||||||
|
|
||||||
const handleSelect = (doc: Doc) => {
|
const handleSelect = (doc: Doc) => {
|
||||||
|
if (treeStore?.initialRootId !== doc.id) {
|
||||||
|
treeStore.setSelectedNode(doc);
|
||||||
|
treeStore.setRoot(doc);
|
||||||
|
treeStore.setInitialTargetId(doc.id);
|
||||||
|
}
|
||||||
router.push(`/docs/${doc.id}`);
|
router.push(`/docs/${doc.id}`);
|
||||||
modalProps.onClose?.();
|
modalProps.onClose?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResetFilters = () => {
|
||||||
|
setFilters({});
|
||||||
|
};
|
||||||
|
|
||||||
const docsData: QuickSearchData<Doc> = useMemo(() => {
|
const docsData: QuickSearchData<Doc> = useMemo(() => {
|
||||||
const docs = data?.pages.flatMap((page) => page.results) || [];
|
const docs = data?.pages.flatMap((page) => page.results) || [];
|
||||||
|
const groupName =
|
||||||
|
filters.target === DocSearchTarget.CURRENT
|
||||||
|
? t('Select a page')
|
||||||
|
: t('Select a document');
|
||||||
return {
|
return {
|
||||||
groupName: docs.length > 0 ? t('Select a document') : '',
|
groupName: docs.length > 0 ? groupName : '',
|
||||||
elements: search ? docs : [],
|
elements: search ? docs : [],
|
||||||
emptyString: t('No document found'),
|
emptyString: t('No document found'),
|
||||||
endActions: hasNextPage
|
endActions: hasNextPage
|
||||||
? [{ content: <InView onChange={() => void fetchNextPage()} /> }]
|
? [{ content: <InView onChange={() => void fetchNextPage()} /> }]
|
||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
}, [data, hasNextPage, fetchNextPage, t, search]);
|
}, [data, hasNextPage, fetchNextPage, t, search, filters.target]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -75,6 +109,13 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
|
|||||||
onFilter={handleInputSearch}
|
onFilter={handleInputSearch}
|
||||||
>
|
>
|
||||||
<Box $height={isDesktop ? '500px' : 'calc(100vh - 68px - 1rem)'}>
|
<Box $height={isDesktop ? '500px' : 'calc(100vh - 68px - 1rem)'}>
|
||||||
|
{showFilters && (
|
||||||
|
<DocSearchFilters
|
||||||
|
values={filters}
|
||||||
|
onValuesChange={setFilters}
|
||||||
|
onReset={handleResetFilters}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{search.length === 0 && (
|
{search.length === 0 && (
|
||||||
<Box
|
<Box
|
||||||
$direction="column"
|
$direction="column"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
VariantType,
|
VariantType,
|
||||||
useToastProvider,
|
useToastProvider,
|
||||||
} from '@openfun/cunningham-react';
|
} from '@openfun/cunningham-react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { css } from 'styled-components';
|
import { css } from 'styled-components';
|
||||||
@@ -11,7 +12,7 @@ import { APIError } from '@/api';
|
|||||||
import { Box } from '@/components';
|
import { Box } from '@/components';
|
||||||
import { useCunninghamTheme } from '@/cunningham';
|
import { useCunninghamTheme } from '@/cunningham';
|
||||||
import { User } from '@/features/auth';
|
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 { useCreateDocAccess, useCreateDocInvitation } from '../api';
|
||||||
import { OptionType } from '../types';
|
import { OptionType } from '../types';
|
||||||
@@ -39,6 +40,7 @@ export const DocShareAddMemberList = ({
|
|||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { toast } = useToastProvider();
|
const { toast } = useToastProvider();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||||
const [invitationRole, setInvitationRole] = useState<Role>(Role.EDITOR);
|
const [invitationRole, setInvitationRole] = useState<Role>(Role.EDITOR);
|
||||||
@@ -91,14 +93,32 @@ export const DocShareAddMemberList = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return isInvitationMode
|
return isInvitationMode
|
||||||
? createInvitation({
|
? createInvitation(
|
||||||
...payload,
|
{
|
||||||
email: user.email,
|
...payload,
|
||||||
})
|
email: user.email,
|
||||||
: createDocAccess({
|
},
|
||||||
...payload,
|
{
|
||||||
memberId: user.id,
|
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);
|
const settledPromises = await Promise.allSettled(promises);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -8,7 +9,7 @@ import {
|
|||||||
IconOptions,
|
IconOptions,
|
||||||
} from '@/components';
|
} from '@/components';
|
||||||
import { useCunninghamTheme } from '@/cunningham';
|
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 { User } from '@/features/auth';
|
||||||
|
|
||||||
import { useDeleteDocInvitation, useUpdateDocInvitation } from '../api';
|
import { useDeleteDocInvitation, useUpdateDocInvitation } from '../api';
|
||||||
@@ -23,6 +24,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
|
export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { spacingsTokens } = useCunninghamTheme();
|
const { spacingsTokens } = useCunninghamTheme();
|
||||||
const spacing = spacingsTokens();
|
const spacing = spacingsTokens();
|
||||||
const fakeUser: User = {
|
const fakeUser: User = {
|
||||||
@@ -37,6 +39,11 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
|
|||||||
const canUpdate = doc.abilities.accesses_manage;
|
const canUpdate = doc.abilities.accesses_manage;
|
||||||
|
|
||||||
const { mutate: updateDocInvitation } = useUpdateDocInvitation({
|
const { mutate: updateDocInvitation } = useUpdateDocInvitation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: [KEY_SUB_DOC, { id: doc.id }],
|
||||||
|
});
|
||||||
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast(
|
toast(
|
||||||
error?.data?.role?.[0] ?? t('Error during update invitation'),
|
error?.data?.role?.[0] ?? t('Error during update invitation'),
|
||||||
@@ -49,6 +56,11 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { mutate: removeDocInvitation } = useDeleteDocInvitation({
|
const { mutate: removeDocInvitation } = useDeleteDocInvitation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: [KEY_SUB_DOC, { id: doc.id }],
|
||||||
|
});
|
||||||
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast(
|
toast(
|
||||||
error?.data?.role?.[0] ?? t('Error during delete invitation'),
|
error?.data?.role?.[0] ?? t('Error during delete invitation'),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -8,7 +9,7 @@ import {
|
|||||||
IconOptions,
|
IconOptions,
|
||||||
} from '@/components';
|
} from '@/components';
|
||||||
import { useCunninghamTheme } from '@/cunningham';
|
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 { useResponsiveStore } from '@/stores';
|
||||||
|
|
||||||
import { useDeleteDocAccess, useUpdateDocAccess } from '../api';
|
import { useDeleteDocAccess, useUpdateDocAccess } from '../api';
|
||||||
@@ -25,13 +26,20 @@ export const DocShareMemberItem = ({ doc, access }: Props) => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isLastOwner, isOtherOwner } = useWhoAmI(access);
|
const { isLastOwner, isOtherOwner } = useWhoAmI(access);
|
||||||
const { toast } = useToastProvider();
|
const { toast } = useToastProvider();
|
||||||
|
|
||||||
const { isDesktop } = useResponsiveStore();
|
const { isDesktop } = useResponsiveStore();
|
||||||
const { spacingsTokens } = useCunninghamTheme();
|
const { spacingsTokens } = useCunninghamTheme();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const spacing = spacingsTokens();
|
const spacing = spacingsTokens();
|
||||||
const isNotAllowed =
|
const isNotAllowed =
|
||||||
isOtherOwner || !!isLastOwner || !doc.abilities.accesses_manage;
|
isOtherOwner || !!isLastOwner || !doc.abilities.accesses_manage;
|
||||||
|
|
||||||
const { mutate: updateDocAccess } = useUpdateDocAccess({
|
const { mutate: updateDocAccess } = useUpdateDocAccess({
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: [KEY_SUB_DOC, { id: doc.id }],
|
||||||
|
});
|
||||||
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast(t('Error during invitation update'), VariantType.ERROR, {
|
toast(t('Error during invitation update'), VariantType.ERROR, {
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
@@ -40,6 +48,11 @@ export const DocShareMemberItem = ({ doc, access }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { mutate: removeDocAccess } = useDeleteDocAccess({
|
const { mutate: removeDocAccess } = useDeleteDocAccess({
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: [KEY_SUB_DOC, { id: doc.id }],
|
||||||
|
});
|
||||||
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast(t('Error while deleting invitation'), VariantType.ERROR, {
|
toast(t('Error while deleting invitation'), VariantType.ERROR, {
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 }),
|
||||||
|
}));
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -9,7 +9,7 @@ import { useResponsiveStore } from '@/stores';
|
|||||||
|
|
||||||
import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid';
|
import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid';
|
||||||
|
|
||||||
import { DocsGridItem } from './DocsGridItem';
|
import { DocGridContentList } from './DocGridContentList';
|
||||||
import { DocsGridLoader } from './DocsGridLoader';
|
import { DocsGridLoader } from './DocsGridLoader';
|
||||||
|
|
||||||
type DocsGridProps = {
|
type DocsGridProps = {
|
||||||
@@ -37,6 +37,9 @@ export const DocsGrid = ({
|
|||||||
is_creator_me: target === DocDefaultFilter.MY_DOCS,
|
is_creator_me: target === DocDefaultFilter.MY_DOCS,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const docs = data?.pages.flatMap((page) => page.results) ?? [];
|
||||||
|
|
||||||
const loading = isFetching || isLoading;
|
const loading = isFetching || isLoading;
|
||||||
const hasDocs = data?.pages.some((page) => page.results.length > 0);
|
const hasDocs = data?.pages.some((page) => page.results.length > 0);
|
||||||
const loadMore = (inView: boolean) => {
|
const loadMore = (inView: boolean) => {
|
||||||
@@ -114,11 +117,7 @@ export const DocsGrid = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{data?.pages.map((currentPage) => {
|
<DocGridContentList docs={docs} />
|
||||||
return currentPage.results.map((doc) => (
|
|
||||||
<DocsGridItem doc={doc} key={doc.id} />
|
|
||||||
));
|
|
||||||
})}
|
|
||||||
|
|
||||||
{hasNextPage && !loading && (
|
{hasNextPage && !loading && (
|
||||||
<InView
|
<InView
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ import { DocsGridItemSharedButton } from './DocsGridItemSharedButton';
|
|||||||
import { SimpleDocItem } from './SimpleDocItem';
|
import { SimpleDocItem } from './SimpleDocItem';
|
||||||
type DocsGridItemProps = {
|
type DocsGridItemProps = {
|
||||||
doc: Doc;
|
doc: Doc;
|
||||||
|
dragMode?: boolean;
|
||||||
};
|
};
|
||||||
export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
|
export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isDesktop } = useResponsiveStore();
|
const { isDesktop } = useResponsiveStore();
|
||||||
const { flexLeft, flexRight } = useResponsiveDocGrid();
|
const { flexLeft, flexRight } = useResponsiveDocGrid();
|
||||||
@@ -46,7 +47,9 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
&:hover {
|
&: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
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Tooltip
|
{dragMode && (
|
||||||
content={
|
<Icon
|
||||||
<Text $textAlign="center" $variation="000">
|
$theme="greyscale"
|
||||||
{isPublic
|
$variation="600"
|
||||||
? t('Accessible to anyone')
|
$size="14px"
|
||||||
: t('Accessible to authenticated users')}
|
iconName={isPublic ? 'public' : 'vpn_lock'}
|
||||||
</Text>
|
/>
|
||||||
}
|
)}
|
||||||
placement="top"
|
{!dragMode && (
|
||||||
>
|
<Tooltip
|
||||||
<div>
|
content={
|
||||||
<Icon
|
<Text $textAlign="center" $variation="000">
|
||||||
$theme="greyscale"
|
{isPublic
|
||||||
$variation="600"
|
? t('Accessible to anyone')
|
||||||
$size="14px"
|
: t('Accessible to authenticated users')}
|
||||||
iconName={isPublic ? 'public' : 'vpn_lock'}
|
</Text>
|
||||||
/>
|
}
|
||||||
</div>
|
placement="top"
|
||||||
</Tooltip>
|
>
|
||||||
|
<div>
|
||||||
|
<Icon
|
||||||
|
$theme="greyscale"
|
||||||
|
$variation="600"
|
||||||
|
$size="14px"
|
||||||
|
iconName={isPublic ? 'public' : 'vpn_lock'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const SimpleDocItem = ({
|
|||||||
const { untitledDocument } = useTrans();
|
const { untitledDocument } = useTrans();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box $direction="row" $gap={spacings.sm} $overflow="auto">
|
<Box $direction="row" $gap={spacings.sm} $overflow="auto" $width="100%">
|
||||||
<Box
|
<Box
|
||||||
$direction="row"
|
$direction="row"
|
||||||
$align="center"
|
$align="center"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import { css } from 'styled-components';
|
import { Box } from '@/components';
|
||||||
|
|
||||||
import { Box, SeparatedSection } from '@/components';
|
|
||||||
import { useCunninghamTheme } from '@/cunningham';
|
|
||||||
import { useDocStore } from '@/docs/doc-management';
|
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 = () => {
|
export const LeftPanelDocContent = () => {
|
||||||
const { currentDoc } = useDocStore();
|
const { currentDoc } = useDocStore();
|
||||||
const { spacingsTokens } = useCunninghamTheme();
|
|
||||||
const spacing = spacingsTokens();
|
const treeStore = useDocTreeStore();
|
||||||
if (!currentDoc) {
|
|
||||||
|
if (!currentDoc || !treeStore.initialTargetId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,19 +18,9 @@ export const LeftPanelDocContent = () => {
|
|||||||
$width="100%"
|
$width="100%"
|
||||||
$css="width: 100%; overflow-y: auto; overflow-x: hidden;"
|
$css="width: 100%; overflow-y: auto; overflow-x: hidden;"
|
||||||
>
|
>
|
||||||
<SeparatedSection showSeparator={false}>
|
{treeStore.initialTargetId && (
|
||||||
<Box $padding={{ horizontal: 'sm' }}>
|
<DocTree initialTargetId={treeStore.initialTargetId} />
|
||||||
<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>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { Button, ModalSize, useModal } from '@openfun/cunningham-react';
|
import { Button, ModalSize, useModal } from '@openfun/cunningham-react';
|
||||||
import { t } from 'i18next';
|
import { t } from 'i18next';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/router';
|
||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import { Box, Icon, SeparatedSection } from '@/components';
|
import { Box, Icon, SeparatedSection } from '@/components';
|
||||||
import { useCreateDoc } from '@/docs/doc-management';
|
|
||||||
import { DocSearchModal } from '@/docs/doc-search';
|
import { DocSearchModal } from '@/docs/doc-search';
|
||||||
import { useAuth } from '@/features/auth';
|
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 { useCmdK } from '@/hook/useCmdK';
|
||||||
|
|
||||||
import { useLeftPanelStore } from '../stores';
|
import { useLeftPanelStore } from '../stores';
|
||||||
@@ -15,6 +18,11 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchModal = useModal();
|
const searchModal = useModal();
|
||||||
const { authenticated } = useAuth();
|
const { authenticated } = useAuth();
|
||||||
|
const treeStore = useDocTreeStore();
|
||||||
|
|
||||||
|
const { currentDoc } = useDocStore();
|
||||||
|
const isDoc = router.pathname === '/docs/[id]';
|
||||||
|
|
||||||
useCmdK(() => {
|
useCmdK(() => {
|
||||||
const isEditorToolbarOpen =
|
const isEditorToolbarOpen =
|
||||||
document.getElementsByClassName('bn-formatting-toolbar').length > 0;
|
document.getElementsByClassName('bn-formatting-toolbar').length > 0;
|
||||||
@@ -28,18 +36,34 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
|||||||
|
|
||||||
const { mutate: createDoc } = useCreateDoc({
|
const { mutate: createDoc } = useCreateDoc({
|
||||||
onSuccess: (doc) => {
|
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();
|
togglePanel();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const goToHome = () => {
|
const goToHome = () => {
|
||||||
router.push('/');
|
void router.push('/');
|
||||||
togglePanel();
|
togglePanel();
|
||||||
};
|
};
|
||||||
|
|
||||||
const createNewDoc = () => {
|
const createNewDoc = () => {
|
||||||
createDoc();
|
if (treeStore.root && isDoc) {
|
||||||
|
createChildrenDoc({
|
||||||
|
title: t('Untitled page'),
|
||||||
|
parentId: treeStore.root.id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
createDoc();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -73,15 +97,29 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{authenticated && (
|
{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>
|
</Box>
|
||||||
</SeparatedSection>
|
</SeparatedSection>
|
||||||
{children}
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
{searchModal.isOpen && (
|
{searchModal.isOpen && (
|
||||||
<DocSearchModal {...searchModal} size={ModalSize.LARGE} />
|
<DocSearchModal
|
||||||
|
{...searchModal}
|
||||||
|
size={ModalSize.LARGE}
|
||||||
|
showFilters={isDoc}
|
||||||
|
defaultFilters={{
|
||||||
|
target: isDoc ? DocSearchTarget.CURRENT : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ export class ApiPlugin implements WorkboxPlugin {
|
|||||||
is_favorite: false,
|
is_favorite: false,
|
||||||
nb_accesses_direct: 1,
|
nb_accesses_direct: 1,
|
||||||
nb_accesses_ancestors: 1,
|
nb_accesses_ancestors: 1,
|
||||||
|
numchild: 0,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
abilities: {
|
abilities: {
|
||||||
accesses_manage: true,
|
accesses_manage: true,
|
||||||
@@ -201,6 +202,7 @@ export class ApiPlugin implements WorkboxPlugin {
|
|||||||
},
|
},
|
||||||
link_reach: LinkReach.RESTRICTED,
|
link_reach: LinkReach.RESTRICTED,
|
||||||
link_role: LinkRole.READER,
|
link_role: LinkRole.READER,
|
||||||
|
user_roles: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
await DocsDB.cacheResponse(
|
await DocsDB.cacheResponse(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { AppProps } from 'next/app';
|
import type { AppProps } from 'next/app';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { AppProvider } from '@/core/';
|
import { AppProvider } from '@/core/';
|
||||||
@@ -19,14 +18,6 @@ export default function App({ Component, pageProps }: AppPropsWithLayout) {
|
|||||||
const getLayout = Component.getLayout ?? ((page) => page);
|
const getLayout = Component.getLayout ?? ((page) => page);
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
|
|||||||
@@ -7,14 +7,15 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { Box, Text, TextErrors } from '@/components';
|
import { Box, Text, TextErrors } from '@/components';
|
||||||
import { DocEditor } from '@/docs/doc-editor';
|
import { DocEditor } from '@/docs/doc-editor';
|
||||||
|
import { KEY_AUTH, setAuthUrl } from '@/features/auth';
|
||||||
import {
|
import {
|
||||||
Doc,
|
Doc,
|
||||||
KEY_DOC,
|
KEY_DOC,
|
||||||
useCollaboration,
|
useCollaboration,
|
||||||
useDoc,
|
useDoc,
|
||||||
useDocStore,
|
useDocStore,
|
||||||
} from '@/docs/doc-management/';
|
} from '@/features/docs/doc-management/';
|
||||||
import { KEY_AUTH, setAuthUrl } from '@/features/auth';
|
import { useDocTreeStore } from '@/features/docs/doc-tree/context/DocTreeContext';
|
||||||
import { MainLayout } from '@/layouts';
|
import { MainLayout } from '@/layouts';
|
||||||
import { useBroadcastStore } from '@/stores';
|
import { useBroadcastStore } from '@/stores';
|
||||||
import { NextPageWithLayout } from '@/types/next';
|
import { NextPageWithLayout } from '@/types/next';
|
||||||
@@ -24,6 +25,14 @@ export function DocLayout() {
|
|||||||
query: { id },
|
query: { id },
|
||||||
} = useRouter();
|
} = useRouter();
|
||||||
|
|
||||||
|
const treeStore = useDocTreeStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof id === 'string' && !treeStore.initialTargetId) {
|
||||||
|
treeStore.setInitialTargetId(id);
|
||||||
|
}
|
||||||
|
}, [id, treeStore]);
|
||||||
|
|
||||||
if (typeof id !== 'string') {
|
if (typeof id !== 'string') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -35,7 +44,7 @@ export function DocLayout() {
|
|||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<MainLayout>
|
<MainLayout>
|
||||||
<DocPage id={id} />
|
{treeStore.initialTargetId && <DocPage id={id} />}
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -56,6 +65,8 @@ const DocPage = ({ id }: DocProps) => {
|
|||||||
{
|
{
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
queryKey: [KEY_DOC, { id }],
|
queryKey: [KEY_DOC, { id }],
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -84,6 +95,13 @@ const DocPage = ({ id }: DocProps) => {
|
|||||||
setCurrentDoc(docQuery);
|
setCurrentDoc(docQuery);
|
||||||
}, [docQuery, setCurrentDoc, isFetching]);
|
}, [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
|
* We add a broadcast task to reset the query cache
|
||||||
* when the document visibility changes.
|
* when the document visibility changes.
|
||||||
|
|||||||
@@ -70,11 +70,3 @@ main ::-webkit-scrollbar-thumb:hover,
|
|||||||
/* Support for IE. */
|
/* Support for IE. */
|
||||||
font-feature-settings: 'liga';
|
font-feature-settings: 'liga';
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-nextjs-dialog-overlay] {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextjs-portal {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1640,10 +1640,8 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/integration/-/integration-1.0.2.tgz#ed0000f4b738c5a19bb60f5b80a9a2f5d9414234"
|
resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/integration/-/integration-1.0.2.tgz#ed0000f4b738c5a19bb60f5b80a9a2f5d9414234"
|
||||||
integrity sha512-npOotZQSyu6SffHiPP+jQVOkJ3qW2KE2cANhEK92sNLX9uZqQaCqljO5GhzsBmh0lB76fiXnrr9i8SIpnDUSZg==
|
integrity sha512-npOotZQSyu6SffHiPP+jQVOkJ3qW2KE2cANhEK92sNLX9uZqQaCqljO5GhzsBmh0lB76fiXnrr9i8SIpnDUSZg==
|
||||||
|
|
||||||
"@gouvfr-lasuite/ui-kit@0.1.3":
|
"@gouvfr-lasuite/ui-kit@file:../../../design-system":
|
||||||
version "0.1.3"
|
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:
|
dependencies:
|
||||||
"@dnd-kit/core" "6.3.1"
|
"@dnd-kit/core" "6.3.1"
|
||||||
"@dnd-kit/modifiers" "9.0.0"
|
"@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-stately/tree" "^3.8.7"
|
||||||
"@react-types/shared" "^3.27.0"
|
"@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:
|
react-stately@^3.34.0:
|
||||||
version "3.36.0"
|
version "3.36.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-stately/-/react-stately-3.36.0.tgz#1544f0a742145d9bc2d67a8c76af3648a9982fd6"
|
resolved "https://registry.yarnpkg.com/react-stately/-/react-stately-3.36.0.tgz#1544f0a742145d9bc2d67a8c76af3648a9982fd6"
|
||||||
|
|||||||
Reference in New Issue
Block a user