diff --git a/.github/workflows/docker-hub.yml b/.github/workflows/docker-hub.yml index 84b23c7e0..da903bed5 100644 --- a/.github/workflows/docker-hub.yml +++ b/.github/workflows/docker-hub.yml @@ -6,6 +6,7 @@ on: push: branches: - 'main' + - 'feature/doc-dnd' tags: - 'v*' pull_request: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ac31a5ab..02ca0c834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to - 🚸(backend) make document search on title accent-insensitive #874 - 🚩 add homepage feature flag #861 +- ✨(frontend) multi-pages #701 ## Changed diff --git a/docker-compose.yml b/docker-compose.yml index 03897a5a1..9b8250caa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,7 @@ services: - "1081:1080" minio: - user: ${DOCKER_USER:-1000} + # user: ${DOCKER_USER:-1000} image: minio/minio environment: - MINIO_ROOT_USER=impress diff --git a/src/backend/core/migrations/0022_remove_document_is_public_and_more.py b/src/backend/core/migrations/0022_remove_document_is_public_and_more.py new file mode 100644 index 000000000..cfce2c5ed --- /dev/null +++ b/src/backend/core/migrations/0022_remove_document_is_public_and_more.py @@ -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", "0021_activate_unaccent_extension"), + ] + + operations = [ + migrations.AddField( + model_name="document", + name="has_deleted_children", + field=models.BooleanField(default=False), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 2c5239ead..fd2211c61 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -464,6 +464,7 @@ def get_queryset(self): return self._queryset_class(self.model).order_by("path") +# pylint: disable=too-many-public-methods class Document(MP_Node, BaseModel): """Pad document carrying the content.""" @@ -486,6 +487,7 @@ class Document(MP_Node, BaseModel): ) deleted_at = models.DateTimeField(null=True, blank=True) ancestors_deleted_at = models.DateTimeField(null=True, blank=True) + has_deleted_children = models.BooleanField(default=False) duplicated_from = models.ForeignKey( "self", on_delete=models.SET_NULL, @@ -561,6 +563,12 @@ def save(self, *args, **kwargs): content_file = ContentFile(bytes_content) default_storage.save(file_key, content_file) + def is_leaf(self): + """ + :returns: True if the node is has no children + """ + return not self.has_deleted_children and self.numchild == 0 + @property def key_base(self): """Key base of the location where the document is stored in object storage.""" @@ -945,7 +953,8 @@ def soft_delete(self): if self.depth > 1: self._meta.model.objects.filter(pk=self.get_parent().pk).update( - numchild=models.F("numchild") - 1 + numchild=models.F("numchild") - 1, + has_deleted_children=True, ) # Mark all descendants as soft deleted diff --git a/src/backend/core/tests/test_models_documents.py b/src/backend/core/tests/test_models_documents.py index 3f2b8d6e0..96ac76e73 100644 --- a/src/backend/core/tests/test_models_documents.py +++ b/src/backend/core/tests/test_models_documents.py @@ -1307,6 +1307,50 @@ def test_models_documents_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 + + def test_models_documents_compute_ancestors_links_no_highest_readable(): """Test the compute_ancestors_links method.""" document = factories.DocumentFactory(link_reach="public") diff --git a/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/src/frontend/apps/e2e/__tests__/app-impress/common.ts index 874be8cd7..fa5785b6f 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -59,16 +59,19 @@ export const createDoc = async ( docName: string, browserName: string, length: number = 1, + isChild: boolean = false, ) => { const randomDocs = randomName(docName, browserName, length); for (let i = 0; i < randomDocs.length; i++) { - const header = page.locator('header').first(); - await header.locator('h2').getByText('Docs').click(); + if (!isChild) { + const header = page.locator('header').first(); + await header.locator('h2').getByText('Docs').click(); + } await page .getByRole('button', { - name: 'New doc', + name: isChild ? 'New page' : 'New doc', }) .click(); @@ -213,6 +216,7 @@ export const mockedDocument = async (page: Page, json: object) => { }, link_reach: 'restricted', created_at: '2021-09-01T09:00:00Z', + user_roles: ['owner'], ...json, }, }); @@ -222,6 +226,22 @@ export const mockedDocument = async (page: Page, json: object) => { }); }; +export const mockedListDocs = async (page: Page, data: object[] = []) => { + await page.route('**/documents/**/', async (route) => { + const request = route.request(); + if (request.method().includes('GET') && request.url().includes('page=')) { + await route.fulfill({ + json: { + count: data.length, + next: null, + previous: null, + results: data, + }, + }); + } + }); +}; + export const mockedInvitations = async (page: Page, json?: object) => { await page.route('**/invitations/**/', async (route) => { const request = route.request(); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts index 52aedce08..7946c1c66 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-create.spec.ts @@ -14,10 +14,10 @@ test.beforeEach(async ({ page }) => { test.describe('Doc Create', () => { test('it creates a doc', async ({ page, browserName }) => { - const [docTitle] = await createDoc(page, 'My new doc', browserName, 1); + const [docTitle] = await createDoc(page, 'my-new-doc', browserName, 1); await page.waitForFunction( - () => document.title.match(/My new doc - Docs/), + () => document.title.match(/my-new-doc - Docs/), { timeout: 5000 }, ); @@ -29,6 +29,46 @@ test.describe('Doc Create', () => { await expect(page.getByTestId('grid-loader')).toBeHidden(); await expect(docsGrid.getByText(docTitle)).toBeVisible(); }); + + test('it creates a sub doc from slash menu editor', async ({ + page, + browserName, + }) => { + const [title] = await createDoc(page, 'my-new-slash-doc', browserName, 1); + + await verifyDocName(page, title); + + await page.locator('.bn-block-outer').last().fill('/'); + await page.getByText('Add a new page').click(); + + const input = page.getByRole('textbox', { name: 'doc title input' }); + await expect(input).toHaveText(''); + await expect( + page.locator('.c__tree-view--row-content').getByText('Untitled page'), + ).toBeVisible(); + }); + + test('it creates a sub doc from interlinking dropdown', async ({ + page, + browserName, + }) => { + const [title] = await createDoc(page, 'my-new-slash-doc', browserName, 1); + + await verifyDocName(page, title); + + await page.locator('.bn-block-outer').last().fill('/'); + await page.getByText('Link to a page').first().click(); + await page + .locator('.quick-search-container') + .getByText('Add a new page') + .click(); + + const input = page.getByRole('textbox', { name: 'doc title input' }); + await expect(input).toHaveText(''); + await expect( + page.locator('.c__tree-view--row-content').getByText('Untitled page'), + ).toBeVisible(); + }); }); test.describe('Doc Create: Not loggued', () => { diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index 09f0cde2e..1989c281c 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -171,6 +171,7 @@ test.describe('Doc Editor', () => { await expect(editor.getByText('Hello World Doc 2')).toBeHidden(); await expect(editor.getByText('Hello World Doc 1')).toBeVisible(); + await page.goto('/'); await page .getByRole('button', { name: 'New doc', @@ -364,8 +365,6 @@ test.describe('Doc Editor', () => { retrieve: true, }, link_reach: 'public', - link_role: 'editor', - created_at: '2021-09-01T09:00:00Z', title: '', }); @@ -452,4 +451,69 @@ test.describe('Doc Editor', () => { const svgBuffer = await cs.toBuffer(await download.createReadStream()); expect(svgBuffer.toString()).toContain('Hello svg'); }); + + test('it checks interlink feature', async ({ page, browserName }) => { + const [randomDoc] = await createDoc(page, 'doc-interlink', browserName, 1); + + await verifyDocName(page, randomDoc); + + const [docChild1] = await createDoc( + page, + 'doc-interlink-child-1', + browserName, + 1, + true, + ); + + await verifyDocName(page, docChild1); + + const [docChild2] = await createDoc( + page, + 'doc-interlink-child-2', + browserName, + 1, + true, + ); + + await verifyDocName(page, docChild2); + + await page.locator('.bn-block-outer').last().fill('/'); + await page.getByText('Link to a page').first().click(); + + await page + .locator( + "span[data-inline-content-type='interlinkingSearchInline'] input", + ) + .fill('interlink-child-1'); + + await page + .locator('.quick-search-container') + .getByText('interlink-child-1') + .click(); + + const interlink = page.getByRole('link', { + name: 'child-1', + }); + + await expect(interlink).toBeVisible(); + await interlink.click(); + + await verifyDocName(page, docChild1); + }); + + test('it checks interlink shortcut @', async ({ page, browserName }) => { + const [randomDoc] = await createDoc(page, 'doc-interlink', browserName, 1); + + await verifyDocName(page, randomDoc); + + const editor = page.locator('.bn-block-outer').last(); + await editor.click(); + await page.keyboard.press('@'); + + await expect( + page.locator( + "span[data-inline-content-type='interlinkingSearchInline'] input", + ), + ).toBeVisible(); + }); }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts index 9d452b347..7208a6f87 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-export.spec.ts @@ -298,7 +298,7 @@ test.describe('Doc Export', () => { }) .click(); - await page + void page .getByRole('button', { name: 'Download', }) @@ -311,4 +311,72 @@ test.describe('Doc Export', () => { const pdfData = await pdf(pdfBuffer); expect(pdfData.text).toContain('Hello World'); }); + + test('it exports the doc with interlinking', async ({ + page, + browserName, + }) => { + const [randomDoc] = await createDoc( + page, + 'export-interlinking', + browserName, + 1, + ); + + await verifyDocName(page, randomDoc); + + const [docChild] = await createDoc( + page, + 'export-interlink-child', + browserName, + 1, + true, + ); + + await verifyDocName(page, docChild); + + await page.locator('.bn-block-outer').last().fill('/'); + await page.getByText('Link to a page').first().click(); + + await page + .locator( + "span[data-inline-content-type='interlinkingSearchInline'] input", + ) + .fill('interlink-child'); + + await page + .locator('.quick-search-container') + .getByText('interlink-child') + .click(); + + const interlink = page.getByRole('link', { + name: 'interlink-child', + }); + + await expect(interlink).toBeVisible(); + + const downloadPromise = page.waitForEvent('download', (download) => { + return download.suggestedFilename().includes(`${docChild}.pdf`); + }); + + await page + .getByRole('button', { + name: 'download', + }) + .click(); + + void page + .getByRole('button', { + name: 'Download', + }) + .click(); + + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe(`${docChild}.pdf`); + + const pdfBuffer = await cs.toBuffer(await download.createReadStream()); + const pdfData = await pdf(pdfBuffer); + + expect(pdfData.text).toContain('interlink-child'); // This is the pdf text + }); }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts new file mode 100644 index 000000000..924d3db5f --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts @@ -0,0 +1,311 @@ +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(); + + // eslint-disable-next-line playwright/no-conditional-in-test + 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(); + + // eslint-disable-next-line playwright/no-conditional-in-test + 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'], + }, +]; diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts index 758c8712c..71083ef52 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-grid.spec.ts @@ -59,6 +59,7 @@ test.describe('Documents Grid mobile', () => { link_reach: 'public', created_at: '2024-10-07T13:02:41.085298Z', updated_at: '2024-10-07T13:30:21.829690Z', + user_roles: ['owner'], }, ], }, @@ -168,6 +169,7 @@ test.describe('Document grid item options', () => { }, link_reach: 'restricted', created_at: '2021-09-01T09:00:00Z', + user_roles: ['editor'], }, ], }, diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts index be1bfcad1..ba33ddb0f 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts @@ -96,7 +96,9 @@ test.describe('Doc Header', () => { ).toBeVisible(); await expect( - page.getByText(`Are you sure you want to delete this document ?`), + page.getByText( + `Are you sure you want to delete the document "${randomDoc}"?`, + ), ).toBeVisible(); await page diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts index 95019784c..197c2bea8 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-routing.spec.ts @@ -58,7 +58,7 @@ test.describe('Doc Routing', () => { }); test('checks 401 on docs/[id] page', async ({ page, browserName }) => { - const [docTitle] = await createDoc(page, 'My new doc', browserName, 1); + const [docTitle] = await createDoc(page, '401-doc', browserName, 1); await verifyDocName(page, docTitle); const responsePromise = page.route( diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts index 27088c931..73534b273 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; -import { createDoc, verifyDocName } from './common'; +import { createDoc, randomName, verifyDocName } from './common'; test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -94,4 +94,85 @@ test.describe('Document search', () => { page.getByLabel('Search modal').getByText('search'), ).toBeHidden(); }); + + test("it checks we don't see filters in search modal", async ({ page }) => { + const searchButton = page + .getByTestId('left-panel-desktop') + .getByRole('button', { name: 'search' }); + + await expect(searchButton).toBeVisible(); + await page.getByRole('button', { name: 'search', exact: true }).click(); + await expect( + page.getByRole('combobox', { name: 'Quick search input' }), + ).toBeVisible(); + await expect(page.getByTestId('doc-search-filters')).toBeHidden(); + }); +}); + +test.describe('Sub page search', () => { + test('it check the precense of filters in search modal', async ({ + page, + browserName, + }) => { + await page.goto('/'); + const [doc1Title] = await createDoc( + page, + 'My sub page search', + browserName, + 1, + ); + await verifyDocName(page, doc1Title); + const searchButton = page + .getByTestId('left-panel-desktop') + .getByRole('button', { name: 'search' }); + await searchButton.click(); + const filters = page.getByTestId('doc-search-filters'); + await expect(filters).toBeVisible(); + await filters.click(); + await filters.getByRole('button', { name: 'Current doc' }).click(); + await expect( + page.getByRole('menuitem', { name: 'All docs' }), + ).toBeVisible(); + await expect( + page.getByRole('menuitem', { name: 'Current doc' }), + ).toBeVisible(); + await page.getByRole('menuitem', { name: 'Current doc' }).click(); + + await expect(page.getByRole('button', { name: 'Reset' })).toBeVisible(); + }); + + test('it searches sub pages', async ({ page, browserName }) => { + await page.goto('/'); + + const [doc1Title] = await createDoc( + page, + 'My sub page search', + browserName, + 1, + ); + await verifyDocName(page, doc1Title); + await page.getByRole('button', { name: 'New page' }).click(); + await verifyDocName(page, ''); + await page.getByRole('textbox', { name: 'doc title input' }).click(); + await page + .getByRole('textbox', { name: 'doc title input' }) + .press('ControlOrMeta+a'); + const [randomDocName] = randomName('doc-sub-page', browserName, 1); + await page + .getByRole('textbox', { name: 'doc title input' }) + .fill(randomDocName); + const searchButton = page + .getByTestId('left-panel-desktop') + .getByRole('button', { name: 'search' }); + + await searchButton.click(); + await expect( + page.getByRole('button', { name: 'Current doc' }), + ).toBeVisible(); + await page.getByRole('combobox', { name: 'Quick search input' }).click(); + await page + .getByRole('combobox', { name: 'Quick search input' }) + .fill('sub'); + await expect(page.getByLabel(randomDocName)).toBeVisible(); + }); }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts new file mode 100644 index 000000000..07a927aa4 --- /dev/null +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts @@ -0,0 +1,319 @@ +/* eslint-disable playwright/no-conditional-in-test */ +import { expect, test } from '@playwright/test'; + +import { + createDoc, + expectLoginPage, + keyCloakSignIn, + randomName, + verifyDocName, +} from './common'; + +test.describe('Doc Tree', () => { + test('create new sub pages', async ({ page, browserName }) => { + await page.goto('/'); + const [titleParent] = await createDoc( + page, + 'doc-tree-content', + browserName, + 1, + ); + await verifyDocName(page, titleParent); + const addButton = page.getByRole('button', { name: 'New page' }); + const docTree = page.getByTestId('doc-tree'); + + await expect(addButton).toBeVisible(); + + // Wait for and intercept the POST request to create a new 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(); + await verifyDocName(page, ''); + const input = page.getByRole('textbox', { name: 'doc title input' }); + await input.click(); + const [randomDocName] = randomName('doc-tree-test', browserName, 1); + await input.fill(randomDocName); + await input.press('Enter'); + await expect(subPageItem.getByText(randomDocName)).toBeVisible(); + await page.reload(); + await expect(subPageItem.getByText(randomDocName)).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); + + // Check that the first element has the ID of the second sub page after the drag and drop + await expect(allSubPageItems[0]).toHaveAttribute( + 'data-testid', + `doc-sub-page-item-${secondSubPageJson.id}`, + ); + + // Check that the second element has the ID of the first sub page after the drag and drop + await expect(allSubPageItems[1]).toHaveAttribute( + 'data-testid', + `doc-sub-page-item-${firstSubPageJson.id}`, + ); + }); + + test('it detachs a document', async ({ page, browserName }) => { + await page.goto('/'); + const [docParent] = await createDoc( + page, + 'doc-tree-detach', + browserName, + 1, + ); + await verifyDocName(page, docParent); + + const [docChild] = await createDoc( + page, + 'doc-tree-detach-child', + browserName, + 1, + true, + ); + await verifyDocName(page, docChild); + + const docTree = page.getByTestId('doc-tree'); + const child = docTree + .getByRole('treeitem') + .locator('.--docs-sub-page-item') + .filter({ + hasText: docChild, + }); + await child.hover(); + const menu = child.getByText(`more_horiz`); + await menu.click(); + await page.getByText('Convert to doc').click(); + + await expect( + page.getByRole('textbox', { name: 'doc title input' }), + ).not.toHaveText(docChild); + + const header = page.locator('header').first(); + await header.locator('h2').getByText('Docs').click(); + await expect(page.getByText(docChild)).toBeVisible(); + }); +}); + +test.describe('Doc Tree: Inheritance', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test('A child inherit from the parent', async ({ page, browserName }) => { + await page.goto('/'); + await keyCloakSignIn(page, browserName); + + const [docParent] = await createDoc( + page, + 'doc-tree-inheritance-parent', + browserName, + 1, + ); + await verifyDocName(page, docParent); + + await page.getByRole('button', { name: 'Share' }).click(); + const selectVisibility = page.getByLabel('Visibility', { exact: true }); + await selectVisibility.click(); + + await page + .getByRole('menuitem', { + name: 'Public', + }) + .click(); + + await expect( + page.getByText('The document visibility has been updated.'), + ).toBeVisible(); + + await page.getByRole('button', { name: 'close' }).click(); + + const [docChild] = await createDoc( + page, + 'doc-tree-inheritance-child', + browserName, + 1, + true, + ); + await verifyDocName(page, docChild); + + const urlDoc = page.url(); + + await page + .getByRole('button', { + name: 'Logout', + }) + .click(); + + await expectLoginPage(page); + + await page.goto(urlDoc); + + await expect(page.locator('h2').getByText(docChild)).toBeVisible(); + + const docTree = page.getByTestId('doc-tree'); + await expect(docTree.getByText(docParent)).toBeVisible(); + }); + + test('Do not show private parent from children', async ({ + page, + browserName, + }) => { + await page.goto('/'); + await keyCloakSignIn(page, browserName); + + const [docParent] = await createDoc( + page, + 'doc-tree-inheritance-private-parent', + browserName, + 1, + ); + await verifyDocName(page, docParent); + + const [docChild] = await createDoc( + page, + 'doc-tree-inheritance-private-child', + browserName, + 1, + true, + ); + await verifyDocName(page, docChild); + + await page.getByRole('button', { name: 'Share' }).click(); + const selectVisibility = page.getByLabel('Visibility', { exact: true }); + await selectVisibility.click(); + + await page + .getByRole('menuitem', { + name: 'Public', + }) + .click(); + + await expect( + page.getByText('The document visibility has been updated.'), + ).toBeVisible(); + + await page.getByRole('button', { name: 'close' }).click(); + + const urlDoc = page.url(); + + await page + .getByRole('button', { + name: 'Logout', + }) + .click(); + + await expectLoginPage(page); + + await page.goto(urlDoc); + + await expect(page.locator('h2').getByText(docChild)).toBeVisible(); + + const docTree = page.getByTestId('doc-tree'); + await expect(docTree.getByText(docParent)).toBeHidden(); + }); +}); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts index a28200b08..d27138cad 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts @@ -241,7 +241,7 @@ test.describe('Doc Visibility: Public', () => { ).toBeVisible(); await expect(page.getByRole('button', { name: 'search' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'New page' })).toBeVisible(); const urlDoc = page.url(); @@ -257,7 +257,7 @@ test.describe('Doc Visibility: Public', () => { await expect(page.locator('h2').getByText(docTitle)).toBeVisible(); await expect(page.getByRole('button', { name: 'search' })).toBeHidden(); - await expect(page.getByRole('button', { name: 'New doc' })).toBeHidden(); + await expect(page.getByRole('button', { name: 'New page' })).toBeHidden(); await expect(page.getByRole('button', { name: 'Share' })).toBeVisible(); const card = page.getByLabel('It is the card information'); await expect(card).toBeVisible(); diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index 45fbe9e68..f83bb47be 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -21,6 +21,8 @@ "@blocknote/react": "0.23.2-hotfix.0", "@blocknote/xl-docx-exporter": "0.23.2-hotfix.0", "@blocknote/xl-pdf-exporter": "0.23.2-hotfix.0", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/modifiers": "9.0.0", "@fontsource/material-icons": "5.2.5", "@gouvfr-lasuite/integration": "1.0.3", "@gouvfr-lasuite/ui-kit": "0.4.0", diff --git a/src/frontend/apps/impress/src/api/helpers.tsx b/src/frontend/apps/impress/src/api/helpers.tsx index 9f304d2e3..7b54753ce 100644 --- a/src/frontend/apps/impress/src/api/helpers.tsx +++ b/src/frontend/apps/impress/src/api/helpers.tsx @@ -21,6 +21,11 @@ export type DefinedInitialDataInfiniteOptionsAPI< TPageParam >; +export type InfiniteQueryConfig = Omit< + DefinedInitialDataInfiniteOptionsAPI, + 'queryKey' | 'initialData' | 'getNextPageParam' | 'initialPageParam' +>; + /** * @param param Used for infinite scroll pagination * @param queryConfig @@ -30,7 +35,7 @@ export const useAPIInfiniteQuery = ['next'] }>( key: string, api: (props: T & { page: number }) => Promise, param: T, - queryConfig?: DefinedInitialDataInfiniteOptionsAPI, + queryConfig?: InfiniteQueryConfig, ) => { return useInfiniteQuery, QueryKey, number>({ initialPageParam: 1, diff --git a/src/frontend/apps/impress/src/components/Box.tsx b/src/frontend/apps/impress/src/components/Box.tsx index 84e320571..fbbabad8e 100644 --- a/src/frontend/apps/impress/src/components/Box.tsx +++ b/src/frontend/apps/impress/src/components/Box.tsx @@ -44,13 +44,12 @@ export interface BoxProps { export type BoxType = ComponentPropsWithRef; export const Box = styled('div')` - display: flex; - flex-direction: column; ${({ $align }) => $align && `align-items: ${$align};`} ${({ $background }) => $background && `background: ${$background};`} ${({ $color }) => $color && `color: ${$color};`} - ${({ $direction }) => $direction && `flex-direction: ${$direction};`} - ${({ $display }) => $display && `display: ${$display};`} + ${({ $direction }) => `flex-direction: ${$direction || 'column'};`} + ${({ $display, as }) => + `display: ${$display || as?.match('span|input') ? 'inline-flex' : 'flex'};`} ${({ $flex }) => $flex && `flex: ${$flex};`} ${({ $gap }) => $gap && `gap: ${$gap};`} ${({ $height }) => $height && `height: ${$height};`} diff --git a/src/frontend/apps/impress/src/components/BoxButton.tsx b/src/frontend/apps/impress/src/components/BoxButton.tsx index 3b8d77511..15d1b3403 100644 --- a/src/frontend/apps/impress/src/components/BoxButton.tsx +++ b/src/frontend/apps/impress/src/components/BoxButton.tsx @@ -31,11 +31,11 @@ const BoxButton = forwardRef( $background="none" $margin="none" $padding="none" + $hasTransition $css={css` cursor: ${props.disabled ? 'not-allowed' : 'pointer'}; border: none; outline: none; - transition: all 0.2s ease-in-out; font-family: inherit; color: ${props.disabled diff --git a/src/frontend/apps/impress/src/components/DropdownMenu.tsx b/src/frontend/apps/impress/src/components/DropdownMenu.tsx index 8758588ed..bb0ede411 100644 --- a/src/frontend/apps/impress/src/components/DropdownMenu.tsx +++ b/src/frontend/apps/impress/src/components/DropdownMenu.tsx @@ -8,6 +8,7 @@ export type DropdownMenuOption = { icon?: string; label: string; testId?: string; + value?: string; callback?: () => void | Promise; danger?: boolean; isSelected?: boolean; @@ -22,7 +23,10 @@ export type DropdownMenuProps = { arrowCss?: BoxProps['$css']; buttonCss?: BoxProps['$css']; disabled?: boolean; + opened?: boolean; topMessage?: string; + selectedValues?: string[]; + afterOpenChange?: (isOpen: boolean) => void; }; export const DropdownMenu = ({ @@ -33,14 +37,18 @@ export const DropdownMenu = ({ arrowCss, buttonCss, label, + opened, topMessage, + afterOpenChange, + selectedValues, }: PropsWithChildren) => { const { spacingsTokens, colorsTokens } = useCunninghamTheme(); - const [isOpen, setIsOpen] = useState(false); + const [isOpen, setIsOpen] = useState(opened ?? false); const blockButtonRef = useRef(null); const onOpenChange = (isOpen: boolean) => { setIsOpen(isOpen); + afterOpenChange?.(isOpen); }; if (disabled) { @@ -163,7 +171,8 @@ export const DropdownMenu = ({ {option.label} - {option.isSelected && ( + {(option.isSelected || + selectedValues?.includes(option.value ?? '')) && ( )} diff --git a/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx b/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx new file mode 100644 index 000000000..313209bf4 --- /dev/null +++ b/src/frontend/apps/impress/src/components/filter/FilterDropdown.tsx @@ -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 ( + + + + {selectedOption?.label ?? options[0].label} + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/components/index.ts b/src/frontend/apps/impress/src/components/index.ts index 205b72246..b11539ea2 100644 --- a/src/frontend/apps/impress/src/components/index.ts +++ b/src/frontend/apps/impress/src/components/index.ts @@ -3,6 +3,7 @@ export * from './BoxButton'; export * from './Card'; export * from './DropButton'; export * from './DropdownMenu'; +export * from './quick-search'; export * from './Icon'; export * from './InfiniteScroll'; export * from './Link'; diff --git a/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx b/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx index 9ab52f53b..e78f6a564 100644 --- a/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx +++ b/src/frontend/apps/impress/src/components/quick-search/QuickSearchInput.tsx @@ -56,6 +56,9 @@ export const QuickSearchInput = ({ /* eslint-disable-next-line jsx-a11y/no-autofocus */ autoFocus={true} aria-label={t('Quick search input')} + onClick={(e) => { + e.stopPropagation(); + }} value={inputValue} role="combobox" placeholder={placeholder ?? t('Search')} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-found.svg b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-found.svg new file mode 100644 index 000000000..87d445d67 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-found.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-link.svg b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-link.svg new file mode 100644 index 000000000..bcb7b8815 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-link.svg @@ -0,0 +1,14 @@ + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-plus.svg b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-plus.svg new file mode 100644 index 000000000..219f8f170 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-plus.svg @@ -0,0 +1,30 @@ + + + + + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-selected.svg b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-selected.svg new file mode 100644 index 000000000..f36a74afb --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-selected.svg @@ -0,0 +1,6 @@ + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index e39bbf6fe..a55805b16 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -2,6 +2,7 @@ import { BlockNoteSchema, Dictionary, defaultBlockSpecs, + defaultInlineContentSpecs, locales, withPageBreak, } from '@blocknote/core'; @@ -18,9 +19,7 @@ import { Box, TextErrors } from '@/components'; import { Doc } from '@/docs/doc-management'; import { useAuth } from '@/features/auth'; -import { useUploadFile } from '../hook'; -import { useHeadings } from '../hook/useHeadings'; -import useSaveDoc from '../hook/useSaveDoc'; +import { useHeadings, useSaveDoc, useShortcuts, useUploadFile } from '../hook'; import { useEditorStore } from '../stores'; import { cssEditor } from '../styles'; import { randomColor } from '../utils'; @@ -28,6 +27,10 @@ import { randomColor } from '../utils'; import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu'; import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar'; import { DividerBlock, QuoteBlock } from './custom-blocks'; +import { + InterlinkingLinkInlineContent, + InterlinkingSearchInlineContent, +} from './custom-inline-content'; export const blockNoteSchema = withPageBreak( BlockNoteSchema.create({ @@ -36,6 +39,11 @@ export const blockNoteSchema = withPageBreak( divider: DividerBlock, quote: QuoteBlock, }, + inlineContentSpecs: { + ...defaultInlineContentSpecs, + interlinkingSearchInline: InterlinkingSearchInlineContent, + interlinkingLinkInline: InterlinkingLinkInlineContent, + }, }), ); @@ -119,6 +127,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => { [collabName, lang, provider, uploadFile], ); useHeadings(editor); + useShortcuts(editor); useEffect(() => { setEditor(editor); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx index d15e1b5ed..67e7a960b 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteSuggestionMenu.tsx @@ -9,24 +9,43 @@ import { import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { DocsBlockSchema } from '../types'; +import { + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema, +} from '../types'; import { getDividerReactSlashMenuItems, getQuoteReactSlashMenuItems, } from './custom-blocks'; +import { useGetInterlinkingMenuItems } from './custom-inline-content'; export const BlockNoteSuggestionMenu = () => { - const editor = useBlockNoteEditor(); + const editor = useBlockNoteEditor< + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema + >(); const { t } = useTranslation(); const basicBlocksName = useDictionary().slash_menu.page_break.group; + const getInterlinkingMenuItems = useGetInterlinkingMenuItems(); const getSlashMenuItems = useMemo(() => { + // We insert it after the "Code Block" item to have the interlinking block displayed after the basic blocks + const defaultMenu = getDefaultReactSlashMenuItems(editor); + const index = defaultMenu.findIndex((item) => item.title === 'Code Block'); + const newSlashMenuItems = [ + ...defaultMenu.slice(0, index + 1), + ...getInterlinkingMenuItems(editor, t), + ...defaultMenu.slice(index + 1), + ]; + return async (query: string) => Promise.resolve( filterSuggestionItems( combineByGroup( - getDefaultReactSlashMenuItems(editor), + newSlashMenuItems, getPageBreakReactSlashMenuItems(editor), getQuoteReactSlashMenuItems(editor, t, basicBlocksName), getDividerReactSlashMenuItems(editor, t, basicBlocksName), @@ -34,7 +53,7 @@ export const BlockNoteSuggestionMenu = () => { query, ), ); - }, [basicBlocksName, editor, t]); + }, [basicBlocksName, editor, getInterlinkingMenuItems, t]); return ( { - console.log('onClick'); if (onConfirm) { void onConfirm(); } diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx index d7e23aa50..5cf1db3b9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/DocEditor.tsx @@ -25,11 +25,8 @@ interface DocEditorProps { export const DocEditor = ({ doc, versionId }: DocEditorProps) => { const { isDesktop } = useResponsiveStore(); - const isVersion = !!versionId && typeof versionId === 'string'; - const { colorsTokens } = useCunninghamTheme(); - const { provider } = useProviderStore(); if (!provider) { @@ -59,11 +56,7 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => { $padding={{ horizontal: isDesktop ? '54px' : 'base' }} className="--docs--doc-editor-header" > - {isVersion ? ( - - ) : ( - - )} + {isVersion ? : } { + return ; + }, + }, +); + +interface LinkSelectedProps { + url: string; + title: string; +} +const LinkSelected = ({ url, title }: LinkSelectedProps) => { + const { colorsTokens } = useCunninghamTheme(); + + return ( + + + + {title} + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/InterlinkingSearchInlineContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/InterlinkingSearchInlineContent.tsx new file mode 100644 index 000000000..c1d241c6f --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/InterlinkingSearchInlineContent.tsx @@ -0,0 +1,86 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { createReactInlineContentSpec } from '@blocknote/react'; +import { TFunction } from 'i18next'; + +import { DocsBlockNoteEditor } from '@/docs/doc-editor'; +import LinkPageIcon from '@/docs/doc-editor/assets/doc-link.svg'; +import AddPageIcon from '@/docs/doc-editor/assets/doc-plus.svg'; +import { useCreateChildDocTree, useDocStore } from '@/docs/doc-management'; + +import { SearchPage } from './SearchPage'; + +export const InterlinkingSearchInlineContent = createReactInlineContentSpec( + { + type: 'interlinkingSearchInline', + propSchema: { + trigger: { + default: '/', + values: ['/', '@'], + }, + disabled: { + default: false, + values: [true, false], + }, + }, + content: 'styled', + }, + { + render: (props) => { + if (props.inlineContent.props.disabled) { + return null; + } + + return ( + + ); + }, + }, +); + +export const getInterlinkinghMenuItems = ( + editor: DocsBlockNoteEditor, + t: TFunction<'translation', undefined>, + group: string, + createPage: () => void, +) => [ + { + title: t('Link to a page'), + onItemClick: () => { + editor.insertInlineContent([ + { + type: 'interlinkingSearchInline', + props: { + disabled: false, + trigger: '/', + }, + }, + ]); + }, + aliases: ['interlinking', 'link', 'anchor', 'a'], + group, + icon: , + subtext: t('Link to a page'), + }, + { + title: t('New page'), + onItemClick: createPage, + aliases: ['new page'], + group, + icon: , + subtext: t('Add a new page'), + }, +]; + +export const useGetInterlinkingMenuItems = () => { + const { currentDoc } = useDocStore(); + const createChildDoc = useCreateChildDocTree(currentDoc?.id); + + return ( + editor: DocsBlockNoteEditor, + t: TFunction<'translation', undefined>, + ) => getInterlinkinghMenuItems(editor, t, t('Links'), createChildDoc); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx new file mode 100644 index 000000000..1b7b9842c --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx @@ -0,0 +1,260 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { + PartialCustomInlineContentFromConfig, + StyleSchema, +} from '@blocknote/core'; +import { useBlockNoteEditor } from '@blocknote/react'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { css } from 'styled-components'; + +import { + Box, + BoxButton, + Card, + Icon, + QuickSearch, + QuickSearchItemContent, + Text, +} from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema, +} from '@/docs/doc-editor'; +import FoundPageIcon from '@/docs/doc-editor/assets/doc-found.svg'; +import AddPageIcon from '@/docs/doc-editor/assets/doc-plus.svg'; +import { useCreateChildDocTree, useDocStore } from '@/docs/doc-management'; +import { DocSearchSubPageContent, DocSearchTarget } from '@/docs/doc-search'; + +const inputStyle = css` + background-color: var(--c--theme--colors--greyscale-100); + border: none; + outline: none; + color: var(--c--theme--colors--greyscale-700); + font-size: 16px; + width: 100%; + font-family: 'Inter'; +`; + +type SearchPageProps = { + trigger: string; + updateInlineContent: ( + update: PartialCustomInlineContentFromConfig< + { + type: string; + propSchema: { + disabled: { + default: boolean; + }; + trigger: { + default: string; + }; + }; + content: 'styled'; + }, + StyleSchema + >, + ) => void; + contentRef: (node: HTMLElement | null) => void; +}; + +export const SearchPage = ({ + contentRef, + trigger, + updateInlineContent, +}: SearchPageProps) => { + const { colorsTokens } = useCunninghamTheme(); + const editor = useBlockNoteEditor< + DocsBlockSchema, + DocsInlineContentSchema, + DocsStyleSchema + >(); + const { t } = useTranslation(); + const { currentDoc } = useDocStore(); + const createChildDoc = useCreateChildDocTree(currentDoc?.id); + const inputRef = useRef(null); + const [search, setSearch] = useState(''); + + /** + * createReactInlineContentSpec add automatically the focus after + * the inline content, so we need to set the focus on the input + * after the component is mounted. + */ + useEffect(() => { + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, 100); + }, [inputRef]); + + return ( + + + {' '} + {trigger} + { + const value = (e.target as HTMLInputElement).value; + setSearch(value); + }} + onKeyDown={(e) => { + if (e.key === 'Backspace' && search.length === 0) { + e.preventDefault(); + + updateInlineContent({ + type: 'interlinkingSearchInline', + props: { + disabled: true, + trigger, + }, + }); + + contentRef(null); + editor.focus(); + editor.insertInlineContent(['']); + } + }} + /> + + + + div { + margin-top: 0; + & [cmdk-group-heading] { + padding: 0.4rem; + margin: 0; + } + + & [cmdk-group-items] .ml-b { + margin-left: 0rem; + padding: 0.5rem; + font-size: 14px; + display: block; + } + + & [cmdk-item] { + border-radius: 0; + } + + & .--docs--doc-search-item > div { + gap: 0.8rem; + } + } + `} + $margin={{ left: '-0.7rem', top: '0.5rem' }} + > + { + updateInlineContent({ + type: 'interlinkingSearchInline', + props: { + disabled: true, + trigger, + }, + }); + + editor.insertInlineContent([ + { + type: 'interlinkingLinkInline', + props: { + url: `/docs/${doc.id}`, + title: doc.title || '', + }, + }, + ' ', + ]); + }} + renderElement={(doc) => ( + + + + {doc.title} + + + } + right={ + + } + /> + )} + /> + + + + + {t('Add a new page')} + + + + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/index.ts new file mode 100644 index 000000000..3fabd144a --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/index.ts @@ -0,0 +1,2 @@ +export * from './InterlinkingLinkInlineContent'; +export * from './InterlinkingSearchInlineContent'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/index.ts new file mode 100644 index 000000000..fa505a9e9 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/index.ts @@ -0,0 +1 @@ +export * from './Interlinking'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx index 0a20001db..0e2e15d85 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/__tests__/useSaveDoc.test.tsx @@ -5,7 +5,7 @@ import * as Y from 'yjs'; import { AppWrapper } from '@/tests/utils'; -import useSaveDoc from '../useSaveDoc'; +import { useSaveDoc } from '../useSaveDoc'; jest.mock('next/router', () => ({ useRouter: jest.fn(), diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts index 7cef48671..95a0804b2 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/index.ts @@ -1,2 +1,4 @@ +export * from './useHeadings'; export * from './useSaveDoc'; +export * from './useShortcuts'; export * from './useUploadFile'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx index 26e518914..3bd4048a5 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useSaveDoc.tsx @@ -10,7 +10,7 @@ import { toBase64 } from '../utils'; const SAVE_INTERVAL = 60000; -const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => { +export const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => { const { mutate: updateDoc } = useUpdateDoc({ listInvalideQueries: [KEY_LIST_DOC_VERSIONS], onSuccess: () => { @@ -92,5 +92,3 @@ const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => { }; }, [router.events, saveDoc]); }; - -export default useSaveDoc; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useShortcuts.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useShortcuts.tsx new file mode 100644 index 000000000..f2f3722fa --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/hook/useShortcuts.tsx @@ -0,0 +1,39 @@ +import { useEffect } from 'react'; + +import { DocsBlockNoteEditor } from '../types'; + +export const useShortcuts = (editor: DocsBlockNoteEditor) => { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === '@' && editor?.isFocused()) { + const selection = window.getSelection(); + const previousChar = + selection?.anchorNode?.textContent?.charAt( + selection.anchorOffset - 1, + ) || ''; + + if (![' ', ''].includes(previousChar)) { + return; + } + + event.preventDefault(); + editor.insertInlineContent([ + { + type: 'interlinkingSearchInline', + props: { + disabled: false, + trigger: '@', + }, + }, + ]); + } + }; + + // Attach the event listener to the document instead of the window + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [editor]); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/assets/doc-selected.png b/src/frontend/apps/impress/src/features/docs/doc-export/assets/doc-selected.png new file mode 100644 index 000000000..d4375fb49 Binary files /dev/null and b/src/frontend/apps/impress/src/features/docs/doc-export/assets/doc-selected.png differ diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/paragraphPDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/paragraphPDF.tsx index 86d74877e..691b9395b 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/paragraphPDF.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/paragraphPDF.tsx @@ -10,7 +10,7 @@ export const blockMappingParagraphPDF: DocsExporterPDF['mappings']['blockMapping */ if (Array.isArray(block.content)) { block.content.forEach((content) => { - if (content.type === 'text' && !content.text) { + if (content.type === 'text' && 'text' in content && !content.text) { content.text = ' '; } }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/quoteDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/quoteDocx.tsx index bcdaa68c8..1691386f7 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/quoteDocx.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/blocks-mapping/quoteDocx.tsx @@ -8,11 +8,17 @@ export const blockMappingQuoteDocx: DocsExporterDocx['mappings']['blockMapping'] if (Array.isArray(block.content)) { block.content.forEach((content) => { if (content.type === 'text') { - content.styles = { - ...content.styles, - italic: true, - textColor: 'gray', - }; + if ( + 'styles' in content && + typeof content.styles === 'object' && + content.styles !== null + ) { + content.styles = { + ...content.styles, + italic: true, + textColor: 'gray', + }; + } } }); } diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx index e576c8c68..aff7b018f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/components/ModalExport.tsx @@ -46,8 +46,7 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => { const [format, setFormat] = useState( DocDownloadFormat.PDF, ); - const { untitledDocument } = useTrans(); - + const { untitledDocument } = useTrans(doc); const templateOptions = useMemo(() => { const templateOptions = (templates?.pages || []) .map((page) => diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/index.ts b/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/index.ts new file mode 100644 index 000000000..83c7581d6 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/index.ts @@ -0,0 +1,2 @@ +export * from './interlinkingLinkPDF'; +export * from './interlinkingLinkDocX'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkDocx.tsx new file mode 100644 index 000000000..549196512 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkDocx.tsx @@ -0,0 +1,49 @@ +/* eslint-disable jsx-a11y/alt-text */ + +import { ExternalHyperlink, ImageRun, Paragraph, TextRun } from 'docx'; + +import DocSelectedIcon from '../assets/doc-selected.png'; +import { DocsExporterDocx } from '../types'; + +export const inlineContentMappingInterlinkingLinkDocx: DocsExporterDocx['mappings']['inlineContentMapping']['interlinkingLinkInline'] = + (inline) => { + const fetchImageData = async () => { + const response = await fetch(DocSelectedIcon.src); + return response.arrayBuffer(); + }; + //const file = new FileReader(); + //file.readAsArrayBuffer(DocSelectedIcon.src); + function dataURItoBlob(dataURI: string) { + const byteString = atob(dataURI.split(',')[1]); + const ab = new ArrayBuffer(byteString.length); + const ia = new Uint8Array(ab); + for (let i = 0; i < byteString.length; i++) { + ia[i] = byteString.charCodeAt(i); + } + const bb = new Blob([ab]); + return bb; + } + + const imageDataPromise = fetchImageData(); + + return new Paragraph({ + children: [ + new ExternalHyperlink({ + children: [ + new ImageRun({ + data: dataURItoBlob(DocSelectedIcon.src).arrayBuffer(), + transformation: { + width: 12, + height: 12, + }, + }), + new TextRun({ + text: inline.props.title, + style: 'Hyperlink', + }), + ], + link: window.location.origin + inline.props.url, + }), + ], + }); + }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkPDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkPDF.tsx new file mode 100644 index 000000000..2baeae33c --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkPDF.tsx @@ -0,0 +1,22 @@ +/* eslint-disable jsx-a11y/alt-text */ +import { Image, Link } from '@react-pdf/renderer'; + +import DocSelectedIcon from '../assets/doc-selected.png'; +import { DocsExporterPDF } from '../types'; + +export const inlineContentMappingInterlinkingLinkPDF: DocsExporterPDF['mappings']['inlineContentMapping']['interlinkingLinkInline'] = + (inline) => { + return ( + + {' '} + + {inline.props.title}{' '} + + ); + }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx index 449446924..260ae83ce 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/mappingDocx.tsx @@ -1,10 +1,12 @@ import { docxDefaultSchemaMappings } from '@blocknote/xl-docx-exporter'; +import { Paragraph } from 'docx'; import { blockMappingDividerDocx, blockMappingImageDocx, blockMappingQuoteDocx, } from './blocks-mapping'; +import { inlineContentMappingInterlinkingLinkDocx } from './inline-content-mapping'; import { DocsExporterDocx } from './types'; export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = { @@ -15,4 +17,9 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = { quote: blockMappingQuoteDocx, image: blockMappingImageDocx, }, + inlineContentMapping: { + ...docxDefaultSchemaMappings.inlineContentMapping, + interlinkingSearchInline: () => new Paragraph(''), + interlinkingLinkInline: inlineContentMappingInterlinkingLinkDocx, + }, }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx index 380e2f1af..1b25ae174 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/mappingPDF.tsx @@ -8,6 +8,7 @@ import { blockMappingQuotePDF, blockMappingTablePDF, } from './blocks-mapping'; +import { inlineContentMappingInterlinkingLinkPDF } from './inline-content-mapping'; import { DocsExporterPDF } from './types'; export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = { @@ -21,4 +22,9 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = { quote: blockMappingQuotePDF, table: blockMappingTablePDF, }, + inlineContentMapping: { + ...pdfDefaultSchemaMappings.inlineContentMapping, + interlinkingSearchInline: () => <>, + interlinkingLinkInline: inlineContentMappingInterlinkingLinkPDF, + }, }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx index b838ef440..b9440e6e9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocTitle.tsx @@ -1,10 +1,7 @@ /* eslint-disable jsx-a11y/click-events-have-key-events */ /* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ -import { - Tooltip, - VariantType, - useToastProvider, -} from '@openfun/cunningham-react'; +import { Tooltip } from '@openfun/cunningham-react'; +import { useQueryClient } from '@tanstack/react-query'; import React, { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -15,6 +12,8 @@ import { Doc, KEY_DOC, KEY_LIST_DOC, + KEY_SUB_PAGE, + useDocStore, useTrans, useUpdateDoc, } from '@/docs/doc-management'; @@ -26,19 +25,16 @@ interface DocTitleProps { export const DocTitle = ({ doc }: DocTitleProps) => { if (!doc.abilities.partial_update) { - return ; + return ; } return ; }; -interface DocTitleTextProps { - title?: string; -} - -export const DocTitleText = ({ title }: DocTitleTextProps) => { +export const DocTitleText = () => { const { isMobile } = useResponsiveStore(); - const { untitledDocument } = useTrans(); + const { currentDoc } = useDocStore(); + const { untitledDocument } = useTrans(currentDoc); return ( { $size={isMobile ? 'h4' : 'h2'} $variation="1000" > - {title || untitledDocument} + {currentDoc?.title || untitledDocument} ); }; const DocTitleInput = ({ doc }: DocTitleProps) => { const { isDesktop } = useResponsiveStore(); + const queryClient = useQueryClient(); const { t } = useTranslation(); const { colorsTokens } = useCunninghamTheme(); const [titleDisplay, setTitleDisplay] = useState(doc.title); - const { toast } = useToastProvider(); - const { untitledDocument } = useTrans(); + + const { untitledDocument } = useTrans(doc); const { broadcast } = useBroadcastStore(); const { mutate: updateDoc } = useUpdateDoc({ listInvalideQueries: [KEY_DOC, KEY_LIST_DOC], - onSuccess(data) { - toast(t('Document title updated successfully'), VariantType.SUCCESS); - + onSuccess(updatedDoc) { // Broadcast to every user connected to the document - broadcast(`${KEY_DOC}-${data.id}`); + broadcast(`${KEY_DOC}-${updatedDoc.id}`); + queryClient.setQueryData( + [KEY_SUB_PAGE, { id: updatedDoc.id }], + updatedDoc, + ); }, }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index 77a1fe50e..e967cfbfc 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx @@ -29,6 +29,7 @@ import { useDeleteFavoriteDoc, } from '@/docs/doc-management'; import { DocShareModal } from '@/docs/doc-share'; +import { useTreeUtils } from '@/docs/doc-tree/'; import { KEY_LIST_DOC_VERSIONS, ModalSelectVersion, @@ -44,6 +45,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { const { t } = useTranslation(); const hasAccesses = doc.nb_accesses_direct > 1 && doc.abilities.accesses_view; const queryClient = useQueryClient(); + const { isCurrentParent } = useTreeUtils(doc); const { spacingsTokens, colorsTokens } = useCunninghamTheme(); @@ -65,27 +67,26 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { }); const options: DropdownMenuOption[] = [ - ...(isSmallMobile - ? [ - { - label: t('Share'), - icon: 'group', - callback: modalShare.open, - }, - { - label: t('Export'), - icon: 'download', - callback: () => { - setIsModalExportOpen(true); - }, - }, - { - label: t('Copy link'), - icon: 'add_link', - callback: copyDocLink, - }, - ] - : []), + { + label: t('Share'), + icon: 'group', + callback: modalShare.open, + show: isSmallMobile, + }, + { + label: t('Export'), + icon: 'download', + callback: () => { + setIsModalExportOpen(true); + }, + show: isSmallMobile, + }, + { + label: t('Copy link'), + icon: 'add_link', + callback: copyDocLink, + show: isSmallMobile, + }, { label: doc.is_favorite ? t('Unpin') : t('Pin'), icon: 'push_pin', @@ -97,6 +98,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { } }, testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`, + show: isCurrentParent, }, { label: t('Version history'), @@ -190,6 +192,9 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { modalShare.open(); }} size={isSmallMobile ? 'small' : 'medium'} + style={{ + color: colorsTokens['primary-800'], + }} > {t('Share')} diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocVersionHeader.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocVersionHeader.tsx index fd8e91999..511b803c9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocVersionHeader.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocVersionHeader.tsx @@ -5,11 +5,7 @@ import { useCunninghamTheme } from '@/cunningham'; import { DocTitleText } from './DocTitle'; -interface DocVersionHeaderProps { - title?: string; -} - -export const DocVersionHeader = ({ title }: DocVersionHeaderProps) => { +export const DocVersionHeader = () => { const { spacingsTokens } = useCunninghamTheme(); const { t } = useTranslation(); @@ -23,7 +19,7 @@ export const DocVersionHeader = ({ title }: DocVersionHeaderProps) => { aria-label={t('It is the document title')} className="--docs--doc-version-header" > - + diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts b/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts index 11123c6bb..d19a0013f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/index.ts @@ -1,8 +1,10 @@ +export * from './useCreateChildDoc'; export * from './useCreateDoc'; +export * from './useCreateFavoriteDoc'; export * from './useDeleteFavoriteDoc'; export * from './useDoc'; export * from './useDocOptions'; export * from './useDocs'; -export * from './useCreateFavoriteDoc'; +export * from './useSubDocs'; export * from './useUpdateDoc'; export * from './useUpdateDocLink'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateChildDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateChildDoc.tsx new file mode 100644 index 000000000..dd2037830 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useCreateChildDoc.tsx @@ -0,0 +1,44 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +import { Doc, KEY_LIST_DOC } from '..'; + +export type CreateChildDocParam = Pick & { + parentId: string; +}; + +export const createChildDoc = async ({ + title, + parentId, +}: CreateChildDocParam): Promise => { + 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; +}; + +interface UseCreateChildDocProps { + onSuccess: (doc: Doc) => void; +} + +export function useCreateChildDoc({ onSuccess }: UseCreateChildDocProps) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createChildDoc, + onSuccess: (doc) => { + void queryClient.resetQueries({ + queryKey: [KEY_LIST_DOC], + }); + onSuccess(doc); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx index ebbb1d543..5365ad4d9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDoc.tsx @@ -19,6 +19,7 @@ export const getDoc = async ({ id }: DocParams): Promise => { }; export const KEY_DOC = 'doc'; +export const KEY_SUB_PAGE = 'sub-page'; export const KEY_DOC_VISIBILITY = 'doc-visibility'; export function useDoc( @@ -26,7 +27,7 @@ export function useDoc( queryConfig?: UseQueryOptions, ) { return useQuery({ - queryKey: [KEY_DOC, param], + queryKey: queryConfig?.queryKey ?? [KEY_DOC, param], queryFn: () => getDoc(param), ...queryConfig, }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx index c9881ad70..88f385df5 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useDocs.tsx @@ -8,22 +8,7 @@ import { useAPIInfiniteQuery, } from '@/api'; -import { Doc } from '../types'; - -export const isDocsOrdering = (data: string): data is DocsOrdering => { - return !!docsOrdering.find((validKey) => validKey === data); -}; - -const docsOrdering = [ - 'created_at', - '-created_at', - 'updated_at', - '-updated_at', - 'title', - '-title', -] as const; - -export type DocsOrdering = (typeof docsOrdering)[number]; +import { Doc, DocsOrdering } from '../types'; export type DocsParams = { page: number; @@ -33,20 +18,18 @@ export type DocsParams = { is_favorite?: boolean; }; -export type DocsResponse = APIList; -export const getDocs = async (params: DocsParams): Promise => { +export const constructParams = (params: DocsParams): URLSearchParams => { const searchParams = new URLSearchParams(); + if (params.page) { searchParams.set('page', params.page.toString()); } - if (params.ordering) { searchParams.set('ordering', params.ordering); } if (params.is_creator_me !== undefined) { searchParams.set('is_creator_me', params.is_creator_me.toString()); } - if (params.title && params.title.length > 0) { searchParams.set('title', params.title); } @@ -54,6 +37,12 @@ export const getDocs = async (params: DocsParams): Promise => { searchParams.set('is_favorite', params.is_favorite.toString()); } + return searchParams; +}; + +export type DocsResponse = APIList; +export const getDocs = async (params: DocsParams): Promise => { + const searchParams = constructParams(params); const response = await fetchAPI(`documents/?${searchParams.toString()}`); if (!response.ok) { diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/api/useSubDocs.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/api/useSubDocs.tsx new file mode 100644 index 000000000..e76c8bc4e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/api/useSubDocs.tsx @@ -0,0 +1,62 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query'; + +import { + APIError, + InfiniteQueryConfig, + errorCauses, + fetchAPI, + useAPIInfiniteQuery, +} from '@/api'; + +import { DocsOrdering } from '../types'; + +import { DocsResponse, constructParams } from './useDocs'; + +export type SubDocsParams = { + page: number; + ordering?: DocsOrdering; + is_creator_me?: boolean; + title?: string; + is_favorite?: boolean; + parent_id: string; +}; + +export const getSubDocs = async ( + params: SubDocsParams, +): Promise => { + const searchParams = constructParams(params); + searchParams.set('parent_id', params.parent_id); + + const response: Response = await fetchAPI( + `documents/${params.parent_id}/descendants/?${searchParams.toString()}`, + ); + + if (!response.ok) { + throw new APIError( + 'Failed to get the sub docs', + await errorCauses(response), + ); + } + + return response.json() as Promise; +}; + +export const KEY_LIST_SUB_DOC = 'sub-docs'; + +export function useSubDocs( + params: SubDocsParams, + queryConfig?: UseQueryOptions, +) { + return useQuery({ + queryKey: [KEY_LIST_SUB_DOC, params], + queryFn: () => getSubDocs(params), + ...queryConfig, + }); +} + +export const useInfiniteSubDocs = ( + params: SubDocsParams, + queryConfig?: InfiniteQueryConfig, +) => { + return useAPIInfiniteQuery(KEY_LIST_SUB_DOC, getSubDocs, params, queryConfig); +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/assets/pinned-document.svg b/src/frontend/apps/impress/src/features/docs/doc-management/assets/pinned-document.svg similarity index 100% rename from src/frontend/apps/impress/src/features/docs/docs-grid/assets/pinned-document.svg rename to src/frontend/apps/impress/src/features/docs/doc-management/assets/pinned-document.svg diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/assets/simple-document.svg b/src/frontend/apps/impress/src/features/docs/doc-management/assets/simple-document.svg similarity index 100% rename from src/frontend/apps/impress/src/features/docs/docs-grid/assets/simple-document.svg rename to src/frontend/apps/impress/src/features/docs/doc-management/assets/simple-document.svg diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx index c3ff4b5b7..f852e694e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalRemoveDoc.tsx @@ -12,21 +12,27 @@ import { useRouter } from 'next/router'; import { Box, Text, TextErrors } from '@/components'; import { useRemoveDoc } from '../api/useRemoveDoc'; +import { useTrans } from '../hooks'; import { Doc } from '../types'; interface ModalRemoveDocProps { onClose: () => void; doc: Doc; + afterDelete?: (doc: Doc) => void; } -export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => { +export const ModalRemoveDoc = ({ + onClose, + doc, + afterDelete, +}: ModalRemoveDocProps) => { const { toast } = useToastProvider(); const { push } = useRouter(); const pathname = usePathname(); + const { untitledDocument } = useTrans(doc); const { mutate: removeDoc, - isError, error, } = useRemoveDoc({ @@ -34,6 +40,11 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => { toast(t('The document has been deleted.'), VariantType.SUCCESS, { duration: 4000, }); + if (afterDelete) { + afterDelete(doc); + return; + } + if (pathname === '/') { onClose(); } else { @@ -90,7 +101,9 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => { > {!isError && ( - {t('Are you sure you want to delete this document ?')} + {t('Are you sure you want to delete the document "{{title}}"?', { + title: doc.title ?? untitledDocument, + })} )} diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx similarity index 97% rename from src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx rename to src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx index 1a02d87a6..3cc8108bc 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/SimpleDocItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx @@ -34,13 +34,14 @@ export const SimpleDocItem = ({ const { t } = useTranslation(); const { spacingsTokens } = useCunninghamTheme(); const { isDesktop } = useResponsiveStore(); - const { untitledDocument } = useTrans(); + const { untitledDocument } = useTrans(doc); return ( { + const treeContext = useTreeContext(); + const router = useRouter(); + + const { mutate: createChildDoc } = useCreateChildDoc({ + onSuccess: (createdDoc) => { + const newDoc = { + ...createdDoc, + children: [], + childrenCount: 0, + parentId: parentId ?? undefined, + }; + treeContext?.treeData.addChild(parentId || null, newDoc); + + router.push(`/docs/${newDoc.id}`); + treeContext?.treeData.setSelectedNode(createdDoc); + }, + }); + + return () => { + if (!parentId) { + return null; + } + + createChildDoc({ + parentId, + }); + }; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useTrans.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useTrans.tsx index eb9a70163..086877025 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useTrans.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useTrans.tsx @@ -1,9 +1,10 @@ import { useTranslation } from 'react-i18next'; -import { Role } from '../types'; +import { Doc, Role } from '../types'; -export const useTrans = () => { +export const useTrans = (doc?: Doc) => { const { t } = useTranslation(); + const isChild = doc && doc.nb_accesses_ancestors > 1; const translatedRoles = { [Role.READER]: t('Reader'), @@ -38,7 +39,7 @@ export const useTrans = () => { transRole: (role: Role) => { return translatedRoles[role]; }, - untitledDocument: t('Untitled document'), + untitledDocument: isChild ? t('Untitled page') : t('Untitled document'), translatedRoles, getNotAllowedMessage, }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx index e57dc6e14..c09b9de17 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/types.tsx @@ -37,15 +37,20 @@ export type Base64 = string; export interface Doc { id: string; title?: string; + children?: Doc[]; + childrenCount?: number; content: Base64; + created_at: string; creator: string; + depth: number; is_favorite: boolean; link_reach: LinkReach; link_role: LinkRole; - nb_accesses_ancestors: number; nb_accesses_direct: number; - created_at: string; + nb_accesses_ancestors: number; + numchild: number; updated_at: string; + user_roles: Role[]; abilities: { accesses_manage: boolean; accesses_view: boolean; @@ -76,3 +81,12 @@ export enum DocDefaultFilter { MY_DOCS = 'my_docs', SHARED_WITH_ME = 'shared_with_me', } + +export type DocsOrdering = + | 'title' + | 'created_at' + | '-created_at' + | 'updated_at' + | '-updated_at' + | '-title' + | undefined; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts index 2c229128e..7fe304fc2 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/utils.ts @@ -22,3 +22,9 @@ export const base64ToYDoc = (base64: string) => { export const base64ToBlocknoteXmlFragment = (base64: string) => { return base64ToYDoc(base64).getXmlFragment('document-store'); }; + +export const isOwnerOrAdmin = (doc: Doc): boolean => { + return doc.user_roles.some( + (role) => role === Role.OWNER || role === Role.ADMIN, + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchContent.tsx new file mode 100644 index 000000000..fc14d8bc6 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchContent.tsx @@ -0,0 +1,68 @@ +import { t } from 'i18next'; +import { useEffect, useMemo } from 'react'; +import { InView } from 'react-intersection-observer'; + +import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search'; + +import { Doc, useInfiniteDocs } from '../../doc-management'; + +import { DocSearchFiltersValues } from './DocSearchFilters'; +import { DocSearchItem } from './DocSearchItem'; + +type DocSearchContentProps = { + search: string; + filters: DocSearchFiltersValues; + onSelect: (doc: Doc) => void; + onLoadingChange?: (loading: boolean) => void; +}; + +export const DocSearchContent = ({ + search, + filters, + onSelect, + onLoadingChange, +}: DocSearchContentProps) => { + const { + data, + isFetching, + isRefetching, + isLoading, + fetchNextPage, + hasNextPage, + } = useInfiniteDocs({ + page: 1, + title: search, + ...filters, + }); + + const loading = isFetching || isRefetching || isLoading; + + const docsData: QuickSearchData = useMemo(() => { + const docs = data?.pages.flatMap((page) => page.results) || []; + + return { + groupName: docs.length > 0 ? t('Select a document') : '', + elements: search ? docs : [], + emptyString: t('No document found'), + endActions: hasNextPage + ? [ + { + content: void fetchNextPage()} />, + }, + ] + : [], + }; + }, [search, data?.pages, fetchNextPage, hasNextPage]); + + useEffect(() => { + onLoadingChange?.(loading); + }, [loading, onLoadingChange]); + + return ( + } + /> + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchFilters.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchFilters.tsx new file mode 100644 index 000000000..96edcf71d --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchFilters.tsx @@ -0,0 +1,67 @@ +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 ( + + + handleTargetChange(DocSearchTarget.ALL), + }, + { + label: t('Current doc'), + value: DocSearchTarget.CURRENT, + callback: () => handleTargetChange(DocSearchTarget.CURRENT), + }, + ]} + /> + + {hasFilters && ( + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchItem.tsx index 7d7414076..59b9b47b5 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchItem.tsx @@ -1,7 +1,6 @@ import { Box, Icon } from '@/components'; import { QuickSearchItemContent } from '@/components/quick-search/'; -import { Doc } from '@/docs/doc-management'; -import { SimpleDocItem } from '@/docs/docs-grid/'; +import { Doc, SimpleDocItem } from '@/docs/doc-management'; import { useResponsiveStore } from '@/stores'; type DocSearchItemProps = { diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx index 48fbbf48c..6214031a6 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchModal.tsx @@ -1,65 +1,62 @@ import { Modal, ModalSize } from '@openfun/cunningham-react'; import Image from 'next/image'; -import { useRouter } from 'next/navigation'; -import { useMemo, useState } from 'react'; +import { useRouter } from 'next/router'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { InView } from 'react-intersection-observer'; import { useDebouncedCallback } from 'use-debounce'; import { Box } from '@/components'; -import { - QuickSearch, - QuickSearchData, - QuickSearchGroup, -} from '@/components/quick-search'; -import { Doc, useInfiniteDocs } from '@/docs/doc-management'; +import { QuickSearch } from '@/components/quick-search'; import { useResponsiveStore } from '@/stores'; +import { Doc } from '../../doc-management'; import EmptySearchIcon from '../assets/illustration-docs-empty.png'; +import { DocSearchContent } from './DocSearchContent'; +import { + DocSearchFilters, + DocSearchFiltersValues, + DocSearchTarget, +} from './DocSearchFilters'; import { DocSearchItem } from './DocSearchItem'; +import { DocSearchSubPageContent } from './DocSearchSubPageContent'; type DocSearchModalProps = { onClose: () => void; isOpen: boolean; + showFilters?: boolean; + defaultFilters?: DocSearchFiltersValues; }; -export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => { +export const DocSearchModal = ({ + showFilters = false, + defaultFilters, + ...modalProps +}: DocSearchModalProps) => { const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const router = useRouter(); + const isDocPage = router.pathname === '/docs/[id]'; + const [search, setSearch] = useState(''); + const [filters, setFilters] = useState( + defaultFilters ?? {}, + ); + + const target = filters.target ?? DocSearchTarget.ALL; const { isDesktop } = useResponsiveStore(); - const { - data, - isFetching, - isRefetching, - isLoading, - fetchNextPage, - hasNextPage, - } = useInfiniteDocs({ - page: 1, - title: search, - }); - const loading = isFetching || isRefetching || isLoading; + const handleInputSearch = useDebouncedCallback(setSearch, 300); const handleSelect = (doc: Doc) => { - router.push(`/docs/${doc.id}`); + void router.push(`/docs/${doc.id}`); modalProps.onClose?.(); }; - const docsData: QuickSearchData = useMemo(() => { - const docs = data?.pages.flatMap((page) => page.results) || []; - - return { - groupName: docs.length > 0 ? t('Select a document') : '', - elements: search ? docs : [], - emptyString: t('No document found'), - endActions: hasNextPage - ? [{ content: void fetchNextPage()} /> }] - : [], - }; - }, [data, hasNextPage, fetchNextPage, t, search]); + const handleResetFilters = () => { + setFilters({}); + }; return ( { onFilter={handleInputSearch} > + {showFilters && ( + + )} {search.length === 0 && ( { )} {search && ( - } - /> + <> + {target === DocSearchTarget.ALL && ( + + )} + {isDocPage && target === DocSearchTarget.CURRENT && ( + } + /> + )} + )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchSubPageContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchSubPageContent.tsx new file mode 100644 index 000000000..585d8dff9 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/DocSearchSubPageContent.tsx @@ -0,0 +1,74 @@ +import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; +import { t } from 'i18next'; +import React, { useEffect, useMemo } from 'react'; +import { InView } from 'react-intersection-observer'; + +import { QuickSearchData, QuickSearchGroup } from '@/components/quick-search'; + +import { Doc } from '../../doc-management'; +import { useInfiniteSubDocs } from '../../doc-management/api/useSubDocs'; + +import { DocSearchFiltersValues } from './DocSearchFilters'; + +type DocSearchSubPageContentProps = { + search: string; + filters: DocSearchFiltersValues; + onSelect: (doc: Doc) => void; + onLoadingChange?: (loading: boolean) => void; + renderElement: (doc: Doc) => React.ReactNode; +}; + +export const DocSearchSubPageContent = ({ + search, + filters, + onSelect, + onLoadingChange, + renderElement, +}: DocSearchSubPageContentProps) => { + const treeContext = useTreeContext(); + + const { + data: subDocsData, + isFetching, + isRefetching, + isLoading, + fetchNextPage: subDocsFetchNextPage, + hasNextPage: subDocsHasNextPage, + } = useInfiniteSubDocs({ + page: 1, + title: search, + ...filters, + parent_id: treeContext?.root?.id ?? '', + }); + + const loading = isFetching || isRefetching || isLoading; + + const docsData: QuickSearchData = useMemo(() => { + const subDocs = subDocsData?.pages.flatMap((page) => page.results) || []; + + return { + groupName: subDocs.length > 0 ? t('Select a page') : '', + elements: search ? subDocs : [], + emptyString: t('No document found'), + endActions: subDocsHasNextPage + ? [ + { + content: void subDocsFetchNextPage()} />, + }, + ] + : [], + }; + }, [search, subDocsData, subDocsFetchNextPage, subDocsHasNextPage]); + + useEffect(() => { + onLoadingChange?.(loading); + }, [loading, onLoadingChange]); + + return ( + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts b/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts index a5cb98858..d5d0e0c44 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-search/components/index.ts @@ -1 +1,3 @@ export * from './DocSearchModal'; +export * from './DocSearchFilters'; +export * from './DocSearchSubPageContent'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx index fdded178d..67c907f0a 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx @@ -3,6 +3,7 @@ import { VariantType, useToastProvider, } from '@openfun/cunningham-react'; +import { useQueryClient } from '@tanstack/react-query'; import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -11,7 +12,7 @@ import { APIError } from '@/api'; import { Box } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; import { User } from '@/features/auth'; -import { Doc, Role } from '@/features/docs'; +import { Doc, KEY_SUB_PAGE, Role } from '@/features/docs'; import { useCreateDocAccess, useCreateDocInvitation } from '../api'; import { OptionType } from '../types'; @@ -39,11 +40,12 @@ export const DocShareAddMemberList = ({ }: Props) => { const { t } = useTranslation(); const { toast } = useToastProvider(); + const [isLoading, setIsLoading] = useState(false); const { spacingsTokens, colorsTokens } = useCunninghamTheme(); const [invitationRole, setInvitationRole] = useState(Role.EDITOR); const canShare = doc.abilities.accesses_manage; - + const queryClient = useQueryClient(); const { mutateAsync: createInvitation } = useCreateDocInvitation(); const { mutateAsync: createDocAccess } = useCreateDocAccess(); @@ -89,14 +91,32 @@ export const DocShareAddMemberList = ({ }; return isInvitationMode - ? createInvitation({ - ...payload, - email: user.email, - }) - : createDocAccess({ - ...payload, - memberId: user.id, - }); + ? createInvitation( + { + ...payload, + email: user.email, + }, + { + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + }); + }, + }, + ) + : createDocAccess( + { + ...payload, + memberId: user.id, + }, + { + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + }); + }, + }, + ); }); const settledPromises = await Promise.allSettled(promises); diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitationItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitationItem.tsx index 76a04fbd0..de91ccf24 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitationItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitationItem.tsx @@ -1,4 +1,5 @@ import { VariantType, useToastProvider } from '@openfun/cunningham-react'; +import { useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { @@ -8,7 +9,7 @@ import { IconOptions, } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { Doc, Role } from '@/docs/doc-management'; +import { Doc, KEY_SUB_PAGE, Role } from '@/docs/doc-management'; import { User } from '@/features/auth'; import { useDeleteDocInvitation, useUpdateDocInvitation } from '../api'; @@ -23,7 +24,9 @@ type Props = { }; export const DocShareInvitationItem = ({ doc, invitation }: Props) => { const { t } = useTranslation(); + const queryClient = useQueryClient(); const { spacingsTokens } = useCunninghamTheme(); + const fakeUser: User = { id: invitation.email, full_name: invitation.email, @@ -36,6 +39,11 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => { const canUpdate = doc.abilities.accesses_manage; const { mutate: updateDocInvitation } = useUpdateDocInvitation({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + }); + }, onError: (error) => { toast( error?.data?.role?.[0] ?? t('Error during update invitation'), @@ -48,6 +56,11 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => { }); const { mutate: removeDocInvitation } = useDeleteDocInvitation({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + }); + }, onError: (error) => { toast( error?.data?.role?.[0] ?? t('Error during delete invitation'), diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx index f07d08dc1..7829752c8 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMemberItem.tsx @@ -1,4 +1,5 @@ import { VariantType, useToastProvider } from '@openfun/cunningham-react'; +import { useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { @@ -8,7 +9,7 @@ import { IconOptions, } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { Access, Doc, Role } from '@/docs/doc-management/'; +import { Access, Doc, KEY_SUB_PAGE, Role } from '@/docs/doc-management/'; import { useResponsiveStore } from '@/stores'; import { useDeleteDocAccess, useUpdateDocAccess } from '../api'; @@ -23,14 +24,21 @@ type Props = { }; export const DocShareMemberItem = ({ doc, access }: Props) => { const { t } = useTranslation(); + const queryClient = useQueryClient(); const { isLastOwner, isOtherOwner } = useWhoAmI(access); const { toast } = useToastProvider(); + const { isDesktop } = useResponsiveStore(); const { spacingsTokens } = useCunninghamTheme(); const isNotAllowed = isOtherOwner || !!isLastOwner || !doc.abilities.accesses_manage; const { mutate: updateDocAccess } = useUpdateDocAccess({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + }); + }, onError: () => { toast(t('Error during invitation update'), VariantType.ERROR, { duration: 4000, @@ -39,6 +47,11 @@ export const DocShareMemberItem = ({ doc, access }: Props) => { }); const { mutate: removeDocAccess } = useDeleteDocAccess({ + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + }); + }, onError: () => { toast(t('Error while deleting invitation'), VariantType.ERROR, { duration: 4000, diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts new file mode 100644 index 000000000..98d4836ff --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/index.ts @@ -0,0 +1 @@ +export * from './useDocChildren'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDetach.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDetach.tsx new file mode 100644 index 000000000..8e261c492 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDetach.tsx @@ -0,0 +1,51 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; + +import { KEY_DOC, KEY_LIST_DOC } from '../../doc-management'; + +export type DetachDocParam = { + documentId: string; + rootId: string; +}; + +enum POSITION_MOVE { + FIRST_CHILD = 'first-child', + LAST_CHILD = 'last-child', + FIRST_SIBLING = 'first-sibling', + LAST_SIBLING = 'last-sibling', + LEFT = 'left', + RIGHT = 'right', +} + +export const detachDoc = async ({ + documentId, + rootId, +}: DetachDocParam): Promise => { + const response = await fetchAPI(`documents/${documentId}/move/`, { + method: 'POST', + body: JSON.stringify({ + target_document_id: rootId, + position: POSITION_MOVE.LAST_SIBLING, + }), + }); + + if (!response.ok) { + throw new APIError('Failed to move the doc', await errorCauses(response)); + } + + return response.json() as Promise; +}; + +export function useDetachDoc() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: detachDoc, + onSuccess: (_data, variables) => { + void queryClient.invalidateQueries({ queryKey: [KEY_LIST_DOC] }); + void queryClient.invalidateQueries({ + queryKey: [KEY_DOC, { id: variables.documentId }], + }); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocChildren.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocChildren.tsx new file mode 100644 index 000000000..406c32a77 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocChildren.tsx @@ -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 => { + 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; +}; + +export const KEY_LIST_DOC_CHILDREN = 'doc-children'; + +export function useDocChildren( + params: DocsChildrenParams, + queryConfig?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' + >, +) { + return useQuery({ + queryKey: [KEY_LIST_DOC_CHILDREN, params], + queryFn: () => getDocChildren(params), + ...queryConfig, + }); +} + +export const useInfiniteDocChildren = (params: DocsChildrenParams) => { + return useAPIInfiniteQuery(KEY_LIST_DOC_CHILDREN, getDocChildren, params); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocTree.tsx new file mode 100644 index 000000000..bebb1d828 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useDocTree.tsx @@ -0,0 +1,44 @@ +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 ({ docId }: DocsTreeParams): Promise => { + 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; +}; + +export const KEY_LIST_DOC_CHILDREN = 'doc-tree'; + +export function useDocTree( + params: DocsTreeParams, + queryConfig?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' + >, +) { + return useQuery({ + queryKey: [KEY_LIST_DOC_CHILDREN, params], + queryFn: () => getDocTree(params), + staleTime: 0, + refetchOnWindowFocus: false, + ...queryConfig, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/api/useMove.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useMove.tsx new file mode 100644 index 000000000..1ba87df43 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/api/useMove.tsx @@ -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 => { + 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; +}; + +export function useMoveDoc() { + return useMutation({ + mutationFn: moveDoc, + }); +} diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/assets/doc-extract-bold.svg b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/doc-extract-bold.svg new file mode 100644 index 000000000..47d4fa1a9 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/doc-extract-bold.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/assets/sub-page-logo.svg b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/sub-page-logo.svg new file mode 100644 index 000000000..790684c6e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/assets/sub-page-logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx new file mode 100644 index 000000000..292c2f4f7 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx @@ -0,0 +1,168 @@ +import { + TreeViewItem, + TreeViewNodeProps, + useTreeContext, +} from '@gouvfr-lasuite/ui-kit'; +import { useRouter } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; +import { css } from 'styled-components'; + +import { Box, Icon, Text } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { + Doc, + KEY_SUB_PAGE, + useDoc, + useTrans, +} 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; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; +`; + +type Props = TreeViewNodeProps; +export const DocSubPageItem = (props: Props) => { + const doc = props.node.data.value as Doc; + const treeContext = useTreeContext(); + const { untitledDocument } = useTrans(doc); + const { node } = props; + const { spacingsTokens } = useCunninghamTheme(); + const [isHover, setIsHover] = useState(false); + + const router = useRouter(); + const { togglePanel } = useLeftPanelStore(); + + const isInitialLoad = useRef(false); + const { data: docQuery } = useDoc( + { id: doc.id }, + { + initialData: doc, + queryKey: [KEY_SUB_PAGE, { id: doc.id }], + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + useEffect(() => { + if (docQuery && isInitialLoad.current === true) { + treeContext?.treeData.updateNode(docQuery.id, docQuery); + } + + if (docQuery) { + isInitialLoad.current = true; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [docQuery]); + + const afterCreate = (createdDoc: Doc) => { + const actualChildren = node.data.children ?? []; + + if (actualChildren.length === 0) { + treeContext?.treeData + .handleLoadChildren(node?.data.value.id) + .then((allChildren) => { + node.open(); + + router.push(`/docs/${doc.id}`); + treeContext?.treeData.setChildren(node.data.value.id, allChildren); + togglePanel(); + }) + .catch(console.error); + } else { + const newDoc = { + ...createdDoc, + children: [], + childrenCount: 0, + parentId: node.id, + }; + treeContext?.treeData.addChild(node.data.value.id, newDoc); + node.open(); + router.push(`/docs/${createdDoc.id}`); + togglePanel(); + } + }; + + return ( + setIsHover(true)} + onMouseLeave={() => setIsHover(false)} + $css={css` + &:not(:has(.isSelected)):has(.light-doc-item-actions) { + background-color: var(--c--theme--colors--greyscale-100); + } + `} + > + { + treeContext?.treeData.setSelectedNode(props.node.data.value as Doc); + router.push(`/docs/${props.node.data.value.id}`); + }} + > + + + + + + + + {doc.title || untitledDocument} + + {doc.nb_accesses_direct > 1 && ( + + )} + + + {isHover && ( + + + + )} + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx new file mode 100644 index 000000000..8118d54af --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTree.tsx @@ -0,0 +1,237 @@ +import { + OpenMap, + TreeView, + TreeViewMoveResult, + useTreeContext, +} from '@gouvfr-lasuite/ui-kit'; +import { useRouter } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; +import { css } from 'styled-components'; + +import { Box, StyledLink } from '@/components'; +import { useCunninghamTheme } from '@/cunningham'; +import { + Doc, + KEY_SUB_PAGE, + SimpleDocItem, + useDoc, + useDocStore, +} from '@/docs/doc-management'; + +import { useDocTree } from '../api/useDocTree'; +import { useMoveDoc } from '../api/useMove'; +import { canDrag, canDrop, serializeDocToSubPage } from '../utils'; + +import { DocSubPageItem } from './DocSubPageItem'; +import { DocTreeItemActions } from './DocTreeItemActions'; + +type DocTreeProps = { + initialTargetId: string; +}; +export const DocTree = ({ initialTargetId }: DocTreeProps) => { + const { spacingsTokens } = useCunninghamTheme(); + const treeContext = useTreeContext(); + const { currentDoc } = useDocStore(); + const router = useRouter(); + + const previousDocId = useRef(initialTargetId); + + const { data: rootNode } = useDoc( + { id: treeContext?.root?.id ?? '' }, + { + enabled: !!treeContext?.root?.id, + initialData: treeContext?.root ?? undefined, + queryKey: [KEY_SUB_PAGE, { id: treeContext?.root?.id ?? '' }], + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + ); + + const [initialOpenState, setInitialOpenState] = useState( + undefined, + ); + + const { mutate: moveDoc } = useMoveDoc(); + + const { data } = useDocTree({ + docId: initialTargetId, + }); + + const handleMove = (result: TreeViewMoveResult) => { + moveDoc({ + sourceDocumentId: result.sourceId, + targetDocumentId: result.targetModeId, + position: result.mode, + }); + treeContext?.treeData.handleMove(result); + }; + + useEffect(() => { + if (!data) { + return; + } + + const { children: rootChildren, ...root } = data; + const children = rootChildren ?? []; + treeContext?.setRoot(root); + const initialOpenState: OpenMap = {}; + initialOpenState[root.id] = true; + const serialize = (children: Doc[]) => { + children.forEach((child) => { + child.childrenCount = child.numchild ?? 0; + if (child?.children?.length && child?.children?.length > 0) { + initialOpenState[child.id] = true; + } + serialize(child.children ?? []); + }); + }; + serialize(children); + + treeContext?.treeData.resetTree(children); + setInitialOpenState(initialOpenState); + if (initialTargetId === root.id) { + treeContext?.treeData.setSelectedNode(root); + } else { + treeContext?.treeData.selectNodeById(initialTargetId); + } + + // Because treeData change in the treeContext, we have a infinite loop + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, initialTargetId]); + + useEffect(() => { + if ( + !currentDoc || + (previousDocId.current && previousDocId.current === currentDoc.id) + ) { + return; + } + + const item = treeContext?.treeData.getNode(currentDoc?.id ?? ''); + if (!item && currentDoc.id !== rootNode?.id) { + treeContext?.treeData.resetTree([]); + treeContext?.setRoot(currentDoc); + treeContext?.setInitialTargetId(currentDoc.id); + } else if (item) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { children, ...rest } = currentDoc; + treeContext?.treeData.updateNode( + currentDoc.id, + serializeDocToSubPage(rest), + ); + } + if (currentDoc?.id && currentDoc?.id !== previousDocId.current) { + previousDocId.current = currentDoc?.id; + } + + treeContext?.treeData.setSelectedNode(currentDoc); + + // we don't need to run this effect on every change of treeContext.data bacause it cause an infinite loop + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentDoc, rootNode?.id]); + + const rootIsSelected = + treeContext?.treeData.selectedNode?.id === treeContext?.root?.id; + + if (!initialTargetId || !treeContext) { + return null; + } + + return ( + + + + {treeContext.root !== null && rootNode && ( + { + e.stopPropagation(); + e.preventDefault(); + treeContext.treeData.setSelectedNode( + treeContext.root ?? undefined, + ); + router.push(`/docs/${treeContext?.root?.id}`); + }} + > + + +
+ { + const newDoc = { + ...createdDoc, + children: [], + childrenCount: 0, + parentId: treeContext.root?.id ?? undefined, + }; + treeContext?.treeData.addChild(null, newDoc); + }} + /> +
+
+
+ )} +
+
+ + {initialOpenState && treeContext.treeData.nodes.length > 0 && ( + { + if (!rootNode) { + return false; + } + const parentDoc = parentNode?.data.value as Doc; + if (!parentDoc) { + return canDrop(rootNode); + } + return canDrop(parentDoc); + }} + canDrag={(node) => { + const doc = node.value as Doc; + return canDrag(doc); + }} + rootNodeId={treeContext.root?.id ?? ''} + renderNode={DocSubPageItem} + /> + )} +
+ ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx new file mode 100644 index 000000000..d8c1e3840 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx @@ -0,0 +1,175 @@ +import { + DropdownMenu, + DropdownMenuOption, + useTreeContext, +} 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 { + Doc, + ModalRemoveDoc, + isOwnerOrAdmin, + useCopyDocLink, + useCreateChildDoc, +} from '@/docs/doc-management'; +import { useLeftPanelStore } from '@/features/left-panel'; + +import { useDetachDoc } from '../api/useDetach'; +import MoveDocIcon from '../assets/doc-extract-bold.svg'; +import { useTreeUtils } from '../hooks'; + +type DocTreeItemActionsProps = { + doc: Doc; + parentId?: string | null; + onCreateSuccess?: (newDoc: Doc) => void; +}; + +export const DocTreeItemActions = ({ + doc, + parentId, + onCreateSuccess, +}: DocTreeItemActionsProps) => { + const [isOpen, setIsOpen] = useState(false); + const router = useRouter(); + const { t } = useTranslation(); + const deleteModal = useModal(); + const { togglePanel } = useLeftPanelStore(); + const copyLink = useCopyDocLink(doc.id); + const canUpdate = isOwnerOrAdmin(doc); + const { isCurrentParent } = useTreeUtils(doc); + const { mutate: detachDoc } = useDetachDoc(); + const treeContext = useTreeContext(); + + const handleDetachDoc = () => { + if (!treeContext?.root) { + return; + } + + detachDoc( + { documentId: doc.id, rootId: treeContext.root.id }, + { + onSuccess: () => { + treeContext.treeData.deleteNode(doc.id); + if (treeContext.root) { + treeContext.treeData.setSelectedNode(treeContext.root); + router.push(`/docs/${treeContext.root.id}`); + } + }, + }, + ); + }; + + const options: DropdownMenuOption[] = [ + { + label: t('Copy link'), + icon: , + callback: copyLink, + }, + ...(!isCurrentParent + ? [ + { + label: t('Convert to doc'), + isDisabled: !canUpdate, + icon: ( + + + + ), + callback: handleDetachDoc, + }, + ] + : []), + { + label: t('Delete'), + isDisabled: !canUpdate, + icon: , + callback: deleteModal.open, + }, + ]; + + const { mutate: createChildDoc } = useCreateChildDoc({ + onSuccess: (doc) => { + onCreateSuccess?.(doc); + togglePanel(); + router.push(`/docs/${doc.id}`); + treeContext?.treeData.setSelectedNode(doc); + }, + }); + + const afterDelete = () => { + if (parentId) { + treeContext?.treeData.deleteNode(doc.id); + router.push(`/docs/${parentId}`); + } else if (doc.id === treeContext?.root?.id && !parentId) { + router.push(`/docs/`); + } else if (treeContext && treeContext.root) { + treeContext?.treeData.deleteNode(doc.id); + router.push(`/docs/${treeContext.root.id}`); + } + }; + + return ( + + + + { + e.stopPropagation(); + e.preventDefault(); + setIsOpen(!isOpen); + }} + iconName="more_horiz" + variant="filled" + $theme="primary" + $variation="600" + /> + + {canUpdate && ( + { + e.stopPropagation(); + e.preventDefault(); + + createChildDoc({ + parentId: doc.id, + }); + }} + color="primary" + > + + + )} + + {deleteModal.isOpen && ( + + )} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/index.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/index.ts new file mode 100644 index 000000000..3fb57a348 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/index.ts @@ -0,0 +1 @@ +export * from './useTreeUtils'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx new file mode 100644 index 000000000..55ebff958 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/hooks/useTreeUtils.tsx @@ -0,0 +1,13 @@ +import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; + +import { Doc } from '@/docs/doc-management'; + +export const useTreeUtils = (doc: Doc) => { + const treeContext = useTreeContext(); + + return { + isParent: doc.nb_accesses_ancestors <= 1, // it is a parent + isChild: doc.nb_accesses_ancestors > 1, // it is a child + isCurrentParent: treeContext?.root?.id === doc.id || doc.depth === 1, // it can be a child but not for the current user + } as const; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/index.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/index.ts new file mode 100644 index 000000000..608f00da5 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/index.ts @@ -0,0 +1,3 @@ +export * from './api'; +export * from './hooks'; +export * from './utils'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts b/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts new file mode 100644 index 000000000..98efb6f2f --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/utils.ts @@ -0,0 +1,23 @@ +import { TreeViewDataType } from '@gouvfr-lasuite/ui-kit'; + +import { Doc, isOwnerOrAdmin } from '../doc-management'; + +export const serializeDocToSubPage = (doc: Doc): Doc => { + return { ...doc, childrenCount: doc.numchild }; +}; + +export const subPageToTree = (children: Doc[]): TreeViewDataType[] => { + children.forEach((child) => { + child.childrenCount = child.numchild ?? 0; + subPageToTree(child.children ?? []); + }); + return children; +}; + +export const canDrag = (doc: Doc): boolean => { + return isOwnerOrAdmin(doc); +}; + +export const canDrop = (doc: Doc): boolean => { + return isOwnerOrAdmin(doc); +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx new file mode 100644 index 000000000..6d03cac77 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocGridContentList.tsx @@ -0,0 +1,173 @@ +import { DndContext, DragOverlay, Modifier } 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 } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Box, Text } from '@/components'; +import { Doc, KEY_LIST_DOC, Role } from '@/docs/doc-management'; +import { useMoveDoc } from '@/docs/doc-tree/api/useMove'; + +import { useDragAndDrop } from '../hooks/useDragAndDrop'; + +import { DocsGridItem } from './DocsGridItem'; +import { Draggable } from './Draggable'; +import { Droppable } from './Droppable'; + +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; +}; + +type DocGridContentListProps = { + docs: Doc[]; +}; + +export const DocGridContentList = ({ docs }: DocGridContentListProps) => { + const { mutate: handleMove, isError } = useMoveDoc(); + const queryClient = useQueryClient(); + const onDrag = (sourceDocumentId: string, targetDocumentId: string) => + handleMove( + { + sourceDocumentId, + targetDocumentId, + position: TreeViewMoveModeEnum.FIRST_CHILD, + }, + { + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: [KEY_LIST_DOC], + }); + }, + }, + ); + + const { + selectedDoc, + canDrag, + canDrop, + sensors, + handleDragStart, + handleDragEnd, + updateCanDrop, + } = useDragAndDrop(onDrag); + + const { t } = useTranslation(); + + 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)'; + } + if (isError) { + return 'var(--c--theme--colors--danger-600)'; + } + + return '#5858D3'; + }, [canDrag, canDrop, isError]); + + if (docs.length === 0) { + return null; + } + + return ( + + {docs.map((doc) => ( + + ))} + + + + {overlayText} + + + + + ); +}; + +interface DocGridItemProps { + doc: Doc; + dragMode: boolean; + canDrag: boolean; + updateCanDrop: (canDrop: boolean, isOver: boolean) => void; +} + +export const DraggableDocGridItem = ({ + doc, + dragMode, + canDrag, + updateCanDrop, +}: DocGridItemProps) => { + const canDropItem = doc.user_roles.some( + (role) => + role === Role.ADMIN || role === Role.OWNER || role === Role.EDITOR, + ); + + return ( + updateCanDrop(canDropItem, isOver)} + id={doc.id} + data={doc} + > + + + + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx index 534252102..fc0dba2ff 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/DocsGrid.tsx @@ -9,7 +9,7 @@ import { useResponsiveStore } from '@/stores'; import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid'; -import { DocsGridItem } from './DocsGridItem'; +import { DocGridContentList } from './DocGridContentList'; import { DocsGridLoader } from './DocsGridLoader'; type DocsGridProps = { @@ -37,6 +37,9 @@ export const DocsGrid = ({ is_creator_me: target === DocDefaultFilter.MY_DOCS, }), }); + + const docs = data?.pages.flatMap((page) => page.results) ?? []; + const loading = isFetching || isLoading; const hasDocs = data?.pages.some((page) => page.results.length > 0); const loadMore = (inView: boolean) => { @@ -115,11 +118,7 @@ export const DocsGrid = ({ )}
- {data?.pages.map((currentPage) => { - return currentPage.results.map((doc) => ( - - )); - })} + {hasNextPage && !loading && ( { + +export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => { const { t } = useTranslation(); const { isDesktop } = useResponsiveStore(); const { flexLeft, flexRight } = useResponsiveDocGrid(); @@ -45,7 +47,9 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => { cursor: pointer; border-radius: 4px; &:hover { - background-color: var(--c--theme--colors--greyscale-100); + background-color: ${dragMode + ? 'none' + : 'var(--c--theme--colors--greyscale-100)'}; } `} className="--docs--doc-grid-item" @@ -79,25 +83,35 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => { : undefined } > - - {isPublic - ? t('Accessible to anyone') - : t('Accessible to authenticated users')} - - } - placement="top" - > -
- -
-
+ {dragMode && ( + + )} + {!dragMode && ( + + {isPublic + ? t('Accessible to anyone') + : t('Accessible to authenticated users')} + + } + placement="top" + > +
+ +
+
+ )}
)} diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/Draggable.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/Draggable.tsx new file mode 100644 index 000000000..bafacf0b4 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/Draggable.tsx @@ -0,0 +1,26 @@ +import { Data, useDraggable } from '@dnd-kit/core'; + +type DraggableProps = { + id: string; + data?: Data; + children: React.ReactNode; +}; + +export const Draggable = (props: DraggableProps) => { + const { attributes, listeners, setNodeRef } = useDraggable({ + id: props.id, + data: props.data, + }); + + return ( +
+ {props.children} +
+ ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/Droppable.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/components/Droppable.tsx new file mode 100644 index 000000000..851bf6f6e --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/Droppable.tsx @@ -0,0 +1,53 @@ +import { Data, useDroppable } from '@dnd-kit/core'; +import { PropsWithChildren, useEffect } from 'react'; +import { css } from 'styled-components'; + +import { Box } from '@/components'; +import { Doc } from '@/docs/doc-management'; + +type DroppableProps = { + id: string; + onOver?: (isOver: boolean, data?: Data) => void; + data?: Data; + enabledDrop?: boolean; + canDrop?: boolean; +}; + +export const Droppable = ({ + onOver, + canDrop, + data, + children, + id, +}: PropsWithChildren) => { + const { isOver, setNodeRef } = useDroppable({ + id, + data, + }); + + const enableHover = canDrop && isOver; + + useEffect(() => { + onOver?.(isOver, data); + }, [isOver, data, onOver]); + + return ( + + {children} + + ); +}; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/components/index.ts b/src/frontend/apps/impress/src/features/docs/docs-grid/components/index.ts index a017747f9..540d636cc 100644 --- a/src/frontend/apps/impress/src/features/docs/docs-grid/components/index.ts +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/components/index.ts @@ -1,4 +1,3 @@ export * from './DocsGrid'; export * from './DocsGridActions'; -export * from './SimpleDocItem'; export * from './DocsGridLoader'; diff --git a/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useDragAndDrop.tsx b/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useDragAndDrop.tsx new file mode 100644 index 000000000..b2a8030cb --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/docs-grid/hooks/useDragAndDrop.tsx @@ -0,0 +1,70 @@ +import { + DragEndEvent, + DragStartEvent, + KeyboardSensor, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { useState } from 'react'; + +import { Doc, Role } from '@/docs/doc-management'; + +const activationConstraint = { + distance: 20, +}; + +export function useDragAndDrop( + onDrag: (sourceDocumentId: string, targetDocumentId: string) => void, +) { + const [selectedDoc, setSelectedDoc] = useState(); + const [canDrop, setCanDrop] = useState(); + + const canDrag = selectedDoc?.user_roles.some((role) => role === Role.OWNER); + + 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; + } + + onDrag(active.id as string, over.id as string); + }; + + const updateCanDrop = (docCanDrop: boolean, isOver: boolean) => { + if (isOver) { + setCanDrop(docCanDrop); + } + }; + + return { + selectedDoc, + canDrag, + canDrop, + sensors, + handleDragStart, + handleDragEnd, + updateCanDrop, + }; +} diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx index 505735780..31b00f074 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelDocContent.tsx @@ -1,14 +1,14 @@ -import { css } from 'styled-components'; +import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; -import { Box, SeparatedSection } from '@/components'; -import { useCunninghamTheme } from '@/cunningham'; -import { useDocStore } from '@/docs/doc-management'; -import { SimpleDocItem } from '@/docs/docs-grid'; +import { Box } from '@/components'; +import { Doc, useDocStore } from '@/docs/doc-management'; +import { DocTree } from '@/features/docs/doc-tree/components/DocTree'; export const LeftPanelDocContent = () => { const { currentDoc } = useDocStore(); - const { spacingsTokens } = useCunninghamTheme(); - if (!currentDoc) { + const tree = useTreeContext(); + + if (!currentDoc || !tree) { return null; } @@ -19,19 +19,9 @@ export const LeftPanelDocContent = () => { $css="width: 100%; overflow-y: auto; overflow-x: hidden;" className="--docs--left-panel-doc-content" > - - - - - - - + {tree.initialTargetId && ( + + )} ); }; diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFavoriteItem.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFavoriteItem.tsx index 32c74d6e7..dc424eb92 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFavoriteItem.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFavoriteItem.tsx @@ -3,9 +3,9 @@ import { css } from 'styled-components'; import { Box, StyledLink } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { Doc } from '@/docs/doc-management'; +import { Doc, SimpleDocItem } from '@/docs/doc-management'; import { DocShareModal } from '@/docs/doc-share'; -import { DocsGridActions, SimpleDocItem } from '@/docs/docs-grid'; +import { DocsGridActions } from '@/docs/docs-grid'; import { useResponsiveStore } from '@/stores'; type LeftPanelFavoriteItemProps = { diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx index 7177bc2c7..5733b0dff 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeader.tsx @@ -1,19 +1,21 @@ import { Button } from '@openfun/cunningham-react'; -import { t } from 'i18next'; -import { useRouter } from 'next/navigation'; +import { useRouter } from 'next/router'; import { PropsWithChildren, useCallback, useState } from 'react'; import { Box, Icon, SeparatedSection } from '@/components'; -import { useCreateDoc } from '@/docs/doc-management'; -import { DocSearchModal } from '@/docs/doc-search'; +import { DocSearchModal, DocSearchTarget } from '@/docs/doc-search/'; import { useAuth } from '@/features/auth'; import { useCmdK } from '@/hook/useCmdK'; import { useLeftPanelStore } from '../stores'; +import { LeftPanelHeaderButton } from './LeftPanelHeaderButton'; + export const LeftPanelHeader = ({ children }: PropsWithChildren) => { const router = useRouter(); const { authenticated } = useAuth(); + const isDoc = router.pathname === '/docs/[id]'; + const [isSearchModalOpen, setIsSearchModalOpen] = useState(false); const openSearchModal = useCallback(() => { @@ -33,22 +35,11 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => { useCmdK(openSearchModal); const { togglePanel } = useLeftPanelStore(); - const { mutate: createDoc, isPending: isCreatingDoc } = useCreateDoc({ - onSuccess: (doc) => { - router.push(`/docs/${doc.id}`); - togglePanel(); - }, - }); - const goToHome = () => { - router.push('/'); + void router.push('/'); togglePanel(); }; - const createNewDoc = () => { - createDoc(); - }; - return ( <> @@ -80,17 +71,21 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => { /> )} - {authenticated && ( - - )} + + {authenticated && } {children} {isSearchModalOpen && ( - + )} ); diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx new file mode 100644 index 000000000..784ffcdbe --- /dev/null +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelHeaderButton.tsx @@ -0,0 +1,78 @@ +import { useTreeContext } from '@gouvfr-lasuite/ui-kit'; +import { Button } from '@openfun/cunningham-react'; +import { useRouter } from 'next/router'; +import { useTranslation } from 'react-i18next'; + +import { + Doc, + isOwnerOrAdmin, + useCreateChildDoc, + useCreateDoc, + useDocStore, +} from '@/docs/doc-management'; + +import { useLeftPanelStore } from '../stores'; + +export const LeftPanelHeaderButton = () => { + const router = useRouter(); + const isDoc = router.pathname === '/docs/[id]'; + + if (isDoc) { + return ; + } + + return ; +}; + +export const LeftPanelHeaderHomeButton = () => { + const router = useRouter(); + const { t } = useTranslation(); + const { togglePanel } = useLeftPanelStore(); + const { mutate: createDoc } = useCreateDoc({ + onSuccess: (doc) => { + void router.push(`/docs/${doc.id}`); + togglePanel(); + }, + }); + return ( + + ); +}; + +export const LeftPanelHeaderDocButton = () => { + const router = useRouter(); + const { currentDoc } = useDocStore(); + const { t } = useTranslation(); + const { togglePanel } = useLeftPanelStore(); + const treeContext = useTreeContext(); + const tree = treeContext?.treeData; + const { mutate: createChildDoc } = useCreateChildDoc({ + onSuccess: (doc) => { + tree?.addRootNode(doc); + tree?.selectNodeById(doc.id); + void router.push(`/docs/${doc.id}`); + togglePanel(); + }, + }); + + const onCreateDoc = () => { + if (!treeContext?.root) { + return; + } + + createChildDoc({ + parentId: treeContext.root.id, + }); + }; + + const disabled = + (currentDoc && !isOwnerOrAdmin(currentDoc)) || !treeContext?.root; + + return ( + + ); +}; diff --git a/src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts b/src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts index 63127f971..9b38b3cba 100644 --- a/src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts @@ -172,9 +172,11 @@ export class ApiPlugin implements WorkboxPlugin { content: '', created_at: new Date().toISOString(), creator: 'dummy-id', + depth: 1, is_favorite: false, nb_accesses_direct: 1, nb_accesses_ancestors: 1, + numchild: 0, updated_at: new Date().toISOString(), abilities: { accesses_manage: true, @@ -201,6 +203,7 @@ export class ApiPlugin implements WorkboxPlugin { }, link_reach: LinkReach.RESTRICTED, link_role: LinkRole.READER, + user_roles: [], }; await DocsDB.cacheResponse( diff --git a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx index 74f2d0391..168dc29a9 100644 --- a/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx +++ b/src/frontend/apps/impress/src/pages/docs/[id]/index.tsx @@ -1,3 +1,4 @@ +import { TreeProvider } from '@gouvfr-lasuite/ui-kit'; import { Loader } from '@openfun/cunningham-react'; import { useQueryClient } from '@tanstack/react-query'; import Head from 'next/head'; @@ -7,14 +8,15 @@ import { useTranslation } from 'react-i18next'; import { Box, Icon, TextErrors } from '@/components'; import { DocEditor } from '@/docs/doc-editor'; +import { KEY_AUTH, setAuthUrl } from '@/features/auth'; import { Doc, KEY_DOC, useCollaboration, useDoc, useDocStore, -} from '@/docs/doc-management/'; -import { KEY_AUTH, setAuthUrl } from '@/features/auth'; +} from '@/features/docs/doc-management/'; +import { getDocChildren, subPageToTree } from '@/features/docs/doc-tree/'; import { MainLayout } from '@/layouts'; import { useBroadcastStore } from '@/stores'; import { NextPageWithLayout } from '@/types/next'; @@ -34,9 +36,17 @@ export function DocLayout() { - - - + { + const doc = await getDocChildren({ docId }); + return subPageToTree(doc.results); + }} + > + + + + ); } @@ -84,6 +94,12 @@ const DocPage = ({ id }: DocProps) => { setCurrentDoc(docQuery); }, [docQuery, setCurrentDoc, isFetching]); + useEffect(() => { + return () => { + setCurrentDoc(undefined); + }; + }, [setCurrentDoc]); + /** * We add a broadcast task to reset the query cache * when the document visibility changes. diff --git a/src/frontend/apps/impress/src/tests/utils.tsx b/src/frontend/apps/impress/src/tests/utils.tsx index b0d7c7ded..523fa01d5 100644 --- a/src/frontend/apps/impress/src/tests/utils.tsx +++ b/src/frontend/apps/impress/src/tests/utils.tsx @@ -1,3 +1,4 @@ +import { TreeProvider } from '@gouvfr-lasuite/ui-kit'; import { CunninghamProvider } from '@openfun/cunningham-react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { PropsWithChildren } from 'react'; @@ -14,8 +15,10 @@ export const AppWrapper = ({ children }: PropsWithChildren) => { }); return ( - - {children} - + + + {children} + + ); };