mirror of
https://github.com/suitenumerique/docs.git
synced 2026-04-25 17:15:01 +02:00
Compare commits
8 Commits
improve-co
...
feature/do
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b2a579d9f | ||
|
|
615ab564cc | ||
|
|
aadd6d9ec3 | ||
|
|
0a6502a77d | ||
|
|
a32ee20249 | ||
|
|
48db42f385 | ||
|
|
05b14b2948 | ||
|
|
12f4a72f5e |
2
Makefile
2
Makefile
@@ -93,7 +93,6 @@ build: cache ?= --no-cache
|
||||
build: ## build the project containers
|
||||
@$(MAKE) build-backend cache=$(cache)
|
||||
@$(MAKE) build-yjs-provider cache=$(cache)
|
||||
@$(MAKE) build-frontend cache=$(cache)
|
||||
.PHONY: build
|
||||
|
||||
build-backend: cache ?=
|
||||
@@ -128,7 +127,6 @@ run-backend: ## Start only the backend application and all needed services
|
||||
run: ## start the wsgi (production) and development server
|
||||
run:
|
||||
@$(MAKE) run-backend
|
||||
@$(COMPOSE) up --force-recreate -d frontend
|
||||
.PHONY: run
|
||||
|
||||
status: ## an alias for "docker compose ps"
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.7 on 2025-03-14 14:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("core", "0019_alter_user_language_default_to_null"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="document",
|
||||
name="has_deleted_children",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -486,6 +486,7 @@ class Document(MP_Node, BaseModel):
|
||||
)
|
||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||
ancestors_deleted_at = models.DateTimeField(null=True, blank=True)
|
||||
has_deleted_children = models.BooleanField(default=False)
|
||||
|
||||
_content = None
|
||||
|
||||
@@ -546,6 +547,12 @@ class Document(MP_Node, BaseModel):
|
||||
content_file = ContentFile(bytes_content)
|
||||
default_storage.save(file_key, content_file)
|
||||
|
||||
def is_leaf(self):
|
||||
"""
|
||||
:returns: True if the node is has no children
|
||||
"""
|
||||
return not self.has_deleted_children and self.numchild == 0
|
||||
|
||||
@property
|
||||
def key_base(self):
|
||||
"""Key base of the location where the document is stored in object storage."""
|
||||
@@ -903,7 +910,8 @@ class Document(MP_Node, BaseModel):
|
||||
|
||||
if self.depth > 1:
|
||||
self._meta.model.objects.filter(pk=self.get_parent().pk).update(
|
||||
numchild=models.F("numchild") - 1
|
||||
numchild=models.F("numchild") - 1,
|
||||
has_deleted_children=True,
|
||||
)
|
||||
|
||||
# Mark all descendants as soft deleted
|
||||
|
||||
@@ -1297,3 +1297,47 @@ def test_models_documents_restore_complex_bis(django_assert_num_queries):
|
||||
def test_models_documents_get_select_options(ancestors_links, select_options):
|
||||
"""Validate that the "get_select_options" method operates as expected."""
|
||||
assert models.LinkReachChoices.get_select_options(ancestors_links) == select_options
|
||||
|
||||
|
||||
def test_models_documents_children_create_after_sibling_deletion():
|
||||
"""
|
||||
It should be possible to create a new child after all children have been deleted.
|
||||
"""
|
||||
|
||||
root = factories.DocumentFactory()
|
||||
assert root.numchild == 0
|
||||
assert root.has_deleted_children is False
|
||||
assert root.is_leaf() is True
|
||||
child1 = factories.DocumentFactory(parent=root)
|
||||
child2 = factories.DocumentFactory(parent=root)
|
||||
|
||||
root.refresh_from_db()
|
||||
assert root.numchild == 2
|
||||
assert root.has_deleted_children is False
|
||||
assert root.is_leaf() is False
|
||||
|
||||
child1.soft_delete()
|
||||
child2.soft_delete()
|
||||
root.refresh_from_db()
|
||||
assert root.numchild == 0
|
||||
assert root.has_deleted_children is True
|
||||
assert root.is_leaf() is False
|
||||
|
||||
factories.DocumentFactory(parent=root)
|
||||
root.refresh_from_db()
|
||||
assert root.numchild == 1
|
||||
assert root.has_deleted_children is True
|
||||
assert root.is_leaf() is False
|
||||
|
||||
|
||||
def test_models_documents_has_deleted_children():
|
||||
"""
|
||||
A document should have its has_deleted_children attribute set to True if one of its children
|
||||
has been solf deleted no matter if numchild is 0 or not.
|
||||
"""
|
||||
root = factories.DocumentFactory()
|
||||
child = factories.DocumentFactory(parent=root)
|
||||
assert root.has_deleted_children is False
|
||||
child.soft_delete()
|
||||
root.refresh_from_db()
|
||||
assert root.has_deleted_children is True
|
||||
|
||||
@@ -200,6 +200,22 @@ export const mockedDocument = async (page: Page, json: object) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const mockedListDocs = async (page: Page, data: object[] = []) => {
|
||||
await page.route('**/documents/**/', async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method().includes('GET') && request.url().includes('page=')) {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
count: data.length,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: data,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const mockedInvitations = async (page: Page, json?: object) => {
|
||||
await page.route('**/invitations/**/', async (route) => {
|
||||
const request = route.request();
|
||||
|
||||
309
src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts
Normal file
309
src/frontend/apps/e2e/__tests__/app-impress/doc-grid-dnd.spec.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, mockedListDocs } from './common';
|
||||
|
||||
test.describe('Doc grid dnd', () => {
|
||||
test('it creates a doc', async ({ page, browserName }) => {
|
||||
await page.goto('/');
|
||||
const header = page.locator('header').first();
|
||||
await createDoc(page, 'Draggable doc', browserName, 1);
|
||||
await header.locator('h2').getByText('Docs').click();
|
||||
await createDoc(page, 'Droppable doc', browserName, 1);
|
||||
await header.locator('h2').getByText('Docs').click();
|
||||
|
||||
const response = await page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith('documents/?page=1') &&
|
||||
response.status() === 200,
|
||||
);
|
||||
const responseJson = await response.json();
|
||||
|
||||
const items = responseJson.results;
|
||||
|
||||
const docsGrid = page.getByTestId('docs-grid');
|
||||
await expect(docsGrid).toBeVisible();
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
const draggableElement = page.getByTestId(`draggable-doc-${items[1].id}`);
|
||||
const dropZone = page.getByTestId(`droppable-doc-${items[0].id}`);
|
||||
await expect(draggableElement).toBeVisible();
|
||||
await expect(dropZone).toBeVisible();
|
||||
|
||||
// Obtenir les positions des éléments
|
||||
const draggableBoundingBox = await draggableElement.boundingBox();
|
||||
const dropZoneBoundingBox = await dropZone.boundingBox();
|
||||
|
||||
expect(draggableBoundingBox).toBeDefined();
|
||||
expect(dropZoneBoundingBox).toBeDefined();
|
||||
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (!draggableBoundingBox || !dropZoneBoundingBox) {
|
||||
throw new Error('Impossible de déterminer la position des éléments');
|
||||
}
|
||||
|
||||
await page.mouse.move(
|
||||
draggableBoundingBox.x + draggableBoundingBox.width / 2,
|
||||
draggableBoundingBox.y + draggableBoundingBox.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
|
||||
// Déplacer vers la zone cible
|
||||
await page.mouse.move(
|
||||
dropZoneBoundingBox.x + dropZoneBoundingBox.width / 2,
|
||||
dropZoneBoundingBox.y + dropZoneBoundingBox.height / 2,
|
||||
{ steps: 10 }, // Rendre le mouvement plus fluide
|
||||
);
|
||||
|
||||
const dragOverlay = page.getByTestId('drag-doc-overlay');
|
||||
|
||||
await expect(dragOverlay).toBeVisible();
|
||||
await expect(dragOverlay).toHaveText(items[1].title as string);
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(dragOverlay).toBeHidden();
|
||||
});
|
||||
|
||||
test('it checks cant drop when we have not the minimum role', async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockedListDocs(page, data);
|
||||
await page.goto('/');
|
||||
const docsGrid = page.getByTestId('docs-grid');
|
||||
await expect(docsGrid).toBeVisible();
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
|
||||
const canDropAndDrag = page.getByTestId('droppable-doc-can-drop-and-drag');
|
||||
|
||||
const noDropAndNoDrag = page.getByTestId(
|
||||
'droppable-doc-no-drop-and-no-drag',
|
||||
);
|
||||
|
||||
await expect(canDropAndDrag).toBeVisible();
|
||||
|
||||
await expect(noDropAndNoDrag).toBeVisible();
|
||||
|
||||
const canDropAndDragBoundigBox = await canDropAndDrag.boundingBox();
|
||||
|
||||
const noDropAndNoDragBoundigBox = await noDropAndNoDrag.boundingBox();
|
||||
|
||||
if (!canDropAndDragBoundigBox || !noDropAndNoDragBoundigBox) {
|
||||
throw new Error('Impossible de déterminer la position des éléments');
|
||||
}
|
||||
|
||||
await page.mouse.move(
|
||||
canDropAndDragBoundigBox.x + canDropAndDragBoundigBox.width / 2,
|
||||
canDropAndDragBoundigBox.y + canDropAndDragBoundigBox.height / 2,
|
||||
);
|
||||
|
||||
await page.mouse.down();
|
||||
|
||||
await page.mouse.move(
|
||||
noDropAndNoDragBoundigBox.x + noDropAndNoDragBoundigBox.width / 2,
|
||||
noDropAndNoDragBoundigBox.y + noDropAndNoDragBoundigBox.height / 2,
|
||||
{ steps: 10 },
|
||||
);
|
||||
|
||||
const dragOverlay = page.getByTestId('drag-doc-overlay');
|
||||
|
||||
await expect(dragOverlay).toBeVisible();
|
||||
await expect(dragOverlay).toHaveText(
|
||||
'You must be at least the editor of the target document',
|
||||
);
|
||||
|
||||
await page.mouse.up();
|
||||
});
|
||||
|
||||
test('it checks cant drag when we have not the minimum role', async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockedListDocs(page, data);
|
||||
await page.goto('/');
|
||||
const docsGrid = page.getByTestId('docs-grid');
|
||||
await expect(docsGrid).toBeVisible();
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
|
||||
const canDropAndDrag = page.getByTestId('droppable-doc-can-drop-and-drag');
|
||||
|
||||
const noDropAndNoDrag = page.getByTestId(
|
||||
'droppable-doc-no-drop-and-no-drag',
|
||||
);
|
||||
|
||||
await expect(canDropAndDrag).toBeVisible();
|
||||
|
||||
await expect(noDropAndNoDrag).toBeVisible();
|
||||
|
||||
const canDropAndDragBoundigBox = await canDropAndDrag.boundingBox();
|
||||
|
||||
const noDropAndNoDragBoundigBox = await noDropAndNoDrag.boundingBox();
|
||||
|
||||
if (!canDropAndDragBoundigBox || !noDropAndNoDragBoundigBox) {
|
||||
throw new Error('Impossible de déterminer la position des éléments');
|
||||
}
|
||||
|
||||
await page.mouse.move(
|
||||
noDropAndNoDragBoundigBox.x + noDropAndNoDragBoundigBox.width / 2,
|
||||
noDropAndNoDragBoundigBox.y + noDropAndNoDragBoundigBox.height / 2,
|
||||
);
|
||||
|
||||
await page.mouse.down();
|
||||
|
||||
await page.mouse.move(
|
||||
canDropAndDragBoundigBox.x + canDropAndDragBoundigBox.width / 2,
|
||||
canDropAndDragBoundigBox.y + canDropAndDragBoundigBox.height / 2,
|
||||
{ steps: 10 },
|
||||
);
|
||||
|
||||
const dragOverlay = page.getByTestId('drag-doc-overlay');
|
||||
|
||||
await expect(dragOverlay).toBeVisible();
|
||||
await expect(dragOverlay).toHaveText(
|
||||
'You must be the owner to move the document',
|
||||
);
|
||||
|
||||
await page.mouse.up();
|
||||
});
|
||||
});
|
||||
|
||||
const data = [
|
||||
{
|
||||
id: 'can-drop-and-drag',
|
||||
abilities: {
|
||||
accesses_manage: true,
|
||||
accesses_view: true,
|
||||
ai_transform: true,
|
||||
ai_translate: true,
|
||||
attachment_upload: true,
|
||||
children_list: true,
|
||||
children_create: true,
|
||||
collaboration_auth: true,
|
||||
descendants: true,
|
||||
destroy: true,
|
||||
favorite: true,
|
||||
link_configuration: true,
|
||||
invite_owner: true,
|
||||
move: true,
|
||||
partial_update: true,
|
||||
restore: true,
|
||||
retrieve: true,
|
||||
media_auth: true,
|
||||
link_select_options: {
|
||||
restricted: ['reader', 'editor'],
|
||||
authenticated: ['reader', 'editor'],
|
||||
public: ['reader', 'editor'],
|
||||
},
|
||||
tree: true,
|
||||
update: true,
|
||||
versions_destroy: true,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
},
|
||||
created_at: '2025-03-14T14:45:22.527221Z',
|
||||
creator: 'bc6895e0-8f6d-4b00-827d-c143aa6b2ecb',
|
||||
depth: 1,
|
||||
excerpt: null,
|
||||
is_favorite: false,
|
||||
link_role: 'reader',
|
||||
link_reach: 'restricted',
|
||||
nb_accesses_ancestors: 1,
|
||||
nb_accesses_direct: 1,
|
||||
numchild: 5,
|
||||
path: '000000o',
|
||||
title: 'Can drop and drag',
|
||||
updated_at: '2025-03-14T14:45:27.699542Z',
|
||||
user_roles: ['owner'],
|
||||
},
|
||||
{
|
||||
id: 'can-only-drop',
|
||||
title: 'Can only drop',
|
||||
abilities: {
|
||||
accesses_manage: true,
|
||||
accesses_view: true,
|
||||
ai_transform: true,
|
||||
ai_translate: true,
|
||||
attachment_upload: true,
|
||||
children_list: true,
|
||||
children_create: true,
|
||||
collaboration_auth: true,
|
||||
descendants: true,
|
||||
destroy: true,
|
||||
favorite: true,
|
||||
link_configuration: true,
|
||||
invite_owner: true,
|
||||
move: true,
|
||||
partial_update: true,
|
||||
restore: true,
|
||||
retrieve: true,
|
||||
media_auth: true,
|
||||
link_select_options: {
|
||||
restricted: ['reader', 'editor'],
|
||||
authenticated: ['reader', 'editor'],
|
||||
public: ['reader', 'editor'],
|
||||
},
|
||||
tree: true,
|
||||
update: true,
|
||||
versions_destroy: true,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
},
|
||||
created_at: '2025-03-14T14:45:22.527221Z',
|
||||
creator: 'bc6895e0-8f6d-4b00-827d-c143aa6b2ecb',
|
||||
depth: 1,
|
||||
excerpt: null,
|
||||
is_favorite: false,
|
||||
link_role: 'reader',
|
||||
link_reach: 'restricted',
|
||||
nb_accesses_ancestors: 1,
|
||||
nb_accesses_direct: 1,
|
||||
numchild: 5,
|
||||
path: '000000o',
|
||||
|
||||
updated_at: '2025-03-14T14:45:27.699542Z',
|
||||
user_roles: ['editor'],
|
||||
},
|
||||
{
|
||||
id: 'no-drop-and-no-drag',
|
||||
abilities: {
|
||||
accesses_manage: false,
|
||||
accesses_view: true,
|
||||
ai_transform: false,
|
||||
ai_translate: false,
|
||||
attachment_upload: false,
|
||||
children_list: true,
|
||||
children_create: false,
|
||||
collaboration_auth: true,
|
||||
descendants: true,
|
||||
destroy: false,
|
||||
favorite: true,
|
||||
link_configuration: false,
|
||||
invite_owner: false,
|
||||
move: false,
|
||||
partial_update: false,
|
||||
restore: false,
|
||||
retrieve: true,
|
||||
media_auth: true,
|
||||
link_select_options: {
|
||||
restricted: ['reader', 'editor'],
|
||||
authenticated: ['reader', 'editor'],
|
||||
public: ['reader', 'editor'],
|
||||
},
|
||||
tree: true,
|
||||
update: false,
|
||||
versions_destroy: false,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
},
|
||||
created_at: '2025-03-14T14:44:16.032773Z',
|
||||
creator: '9264f420-f018-4bd6-96ae-4788f41af56d',
|
||||
depth: 1,
|
||||
excerpt: null,
|
||||
is_favorite: false,
|
||||
link_role: 'reader',
|
||||
link_reach: 'restricted',
|
||||
nb_accesses_ancestors: 14,
|
||||
nb_accesses_direct: 14,
|
||||
numchild: 0,
|
||||
path: '000000l',
|
||||
title: 'No drop and no drag',
|
||||
updated_at: '2025-03-14T14:44:16.032774Z',
|
||||
user_roles: ['reader'],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,149 @@
|
||||
/* eslint-disable playwright/no-conditional-in-test */
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc } from './common';
|
||||
|
||||
test.describe('Doc Tree', () => {
|
||||
test('create new sub pages', async ({ page, browserName }) => {
|
||||
await page.goto('/');
|
||||
await createDoc(page, 'doc-tree-content', browserName, 1);
|
||||
const addButton = page.getByRole('button', { name: 'New page' });
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
|
||||
await expect(addButton).toBeVisible();
|
||||
|
||||
// Attendre et intercepter la requête POST pour créer une nouvelle page
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/documents/') &&
|
||||
response.url().includes('/children/') &&
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
|
||||
await addButton.click();
|
||||
const response = await responsePromise;
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const subPageJson = await response.json();
|
||||
|
||||
await expect(docTree).toBeVisible();
|
||||
const subPageItem = docTree
|
||||
.getByTestId(`doc-sub-page-item-${subPageJson.id}`)
|
||||
.first();
|
||||
|
||||
await expect(subPageItem).toBeVisible();
|
||||
await subPageItem.click();
|
||||
const input = page.getByRole('textbox', { name: 'doc title input' });
|
||||
await input.click();
|
||||
await input.fill('Test');
|
||||
await input.press('Enter');
|
||||
await expect(subPageItem.getByText('Test')).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(subPageItem.getByText('Test')).toBeVisible();
|
||||
});
|
||||
|
||||
test('check the reorder of sub pages', async ({ page, browserName }) => {
|
||||
await page.goto('/');
|
||||
await createDoc(page, 'doc-tree-content', browserName, 1);
|
||||
const addButton = page.getByRole('button', { name: 'New page' });
|
||||
await expect(addButton).toBeVisible();
|
||||
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
|
||||
// Create first sub page
|
||||
const firstResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/documents/') &&
|
||||
response.url().includes('/children/') &&
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
|
||||
await addButton.click();
|
||||
const firstResponse = await firstResponsePromise;
|
||||
expect(firstResponse.ok()).toBeTruthy();
|
||||
|
||||
const secondResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/documents/') &&
|
||||
response.url().includes('/children/') &&
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
|
||||
// Create second sub page
|
||||
await addButton.click();
|
||||
const secondResponse = await secondResponsePromise;
|
||||
expect(secondResponse.ok()).toBeTruthy();
|
||||
|
||||
const secondSubPageJson = await secondResponse.json();
|
||||
const firstSubPageJson = await firstResponse.json();
|
||||
|
||||
const firstSubPageItem = docTree
|
||||
.getByTestId(`doc-sub-page-item-${firstSubPageJson.id}`)
|
||||
.first();
|
||||
|
||||
const secondSubPageItem = docTree
|
||||
.getByTestId(`doc-sub-page-item-${secondSubPageJson.id}`)
|
||||
.first();
|
||||
|
||||
// check that the sub pages are visible in the tree
|
||||
await expect(firstSubPageItem).toBeVisible();
|
||||
await expect(secondSubPageItem).toBeVisible();
|
||||
|
||||
// get the bounding boxes of the sub pages
|
||||
const firstSubPageBoundingBox = await firstSubPageItem.boundingBox();
|
||||
const secondSubPageBoundingBox = await secondSubPageItem.boundingBox();
|
||||
|
||||
expect(firstSubPageBoundingBox).toBeDefined();
|
||||
expect(secondSubPageBoundingBox).toBeDefined();
|
||||
|
||||
if (!firstSubPageBoundingBox || !secondSubPageBoundingBox) {
|
||||
throw new Error('Impossible de déterminer la position des éléments');
|
||||
}
|
||||
|
||||
// move the first sub page to the second position
|
||||
await page.mouse.move(
|
||||
firstSubPageBoundingBox.x + firstSubPageBoundingBox.width / 2,
|
||||
firstSubPageBoundingBox.y + firstSubPageBoundingBox.height / 2,
|
||||
);
|
||||
|
||||
await page.mouse.down();
|
||||
|
||||
await page.mouse.move(
|
||||
secondSubPageBoundingBox.x + secondSubPageBoundingBox.width / 2,
|
||||
secondSubPageBoundingBox.y + secondSubPageBoundingBox.height + 4,
|
||||
{ steps: 10 },
|
||||
);
|
||||
|
||||
await page.mouse.up();
|
||||
|
||||
// check that the sub pages are visible in the tree
|
||||
await expect(firstSubPageItem).toBeVisible();
|
||||
await expect(secondSubPageItem).toBeVisible();
|
||||
|
||||
// reload the page
|
||||
await page.reload();
|
||||
|
||||
// check that the sub pages are visible in the tree
|
||||
await expect(firstSubPageItem).toBeVisible();
|
||||
await expect(secondSubPageItem).toBeVisible();
|
||||
|
||||
// Check the position of the sub pages
|
||||
const allSubPageItems = await docTree
|
||||
.getByTestId(/^doc-sub-page-item/)
|
||||
.all();
|
||||
|
||||
expect(allSubPageItems.length).toBe(2);
|
||||
|
||||
// Vérifier que le premier élément a l'ID de la deuxième sous-page après le drag and drop
|
||||
|
||||
await expect(allSubPageItems[0]).toHaveAttribute(
|
||||
'data-testid',
|
||||
`doc-sub-page-item-${secondSubPageJson.id}`,
|
||||
);
|
||||
|
||||
// Vérifier que le deuxième élément a l'ID de la première sous-page après le drag and drop
|
||||
await expect(allSubPageItems[1]).toHaveAttribute(
|
||||
'data-testid',
|
||||
`doc-sub-page-item-${firstSubPageJson.id}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { cunninghamConfig as tokens } from '@gouvfr-lasuite/ui-kit';
|
||||
import { cunninghamConfig } from '@gouvfr-lasuite/ui-kit';
|
||||
|
||||
const tokens = {
|
||||
...cunninghamConfig,
|
||||
};
|
||||
|
||||
const customColors = {
|
||||
'primary-action': '#1212FF',
|
||||
@@ -34,7 +38,6 @@ const customColors = {
|
||||
'yellow-500': '#B7A73F',
|
||||
'yellow-600': '#66673D',
|
||||
};
|
||||
|
||||
tokens.themes.default.theme.colors = {
|
||||
...tokens.themes.default.theme.colors,
|
||||
...customColors,
|
||||
|
||||
@@ -21,15 +21,18 @@
|
||||
"@blocknote/react": "0.23.2-hotfix.0",
|
||||
"@blocknote/xl-docx-exporter": "0.23.2-hotfix.0",
|
||||
"@blocknote/xl-pdf-exporter": "0.23.2-hotfix.0",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@fontsource/material-icons": "5.2.5",
|
||||
"@gouvfr-lasuite/integration": "1.0.2",
|
||||
"@gouvfr-lasuite/ui-kit": "0.1.3",
|
||||
"@gouvfr-lasuite/ui-kit": "/Users/melde/Documents/societes/melde/clients/dinum/design-system",
|
||||
"@hocuspocus/provider": "2.15.2",
|
||||
"@openfun/cunningham-react": "3.0.0",
|
||||
"@react-pdf/renderer": "4.1.6",
|
||||
"@sentry/nextjs": "9.3.0",
|
||||
"@tanstack/react-query": "5.67.1",
|
||||
"canvg": "4.0.3",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.0.4",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"docx": "9.1.1",
|
||||
@@ -46,6 +49,7 @@
|
||||
"react-i18next": "15.4.1",
|
||||
"react-intersection-observer": "9.15.1",
|
||||
"react-select": "5.10.1",
|
||||
"react-stately": "3.36.1",
|
||||
"styled-components": "6.1.15",
|
||||
"use-debounce": "10.0.4",
|
||||
"y-protocols": "1.0.6",
|
||||
|
||||
@@ -8,6 +8,7 @@ export type DropdownMenuOption = {
|
||||
icon?: string;
|
||||
label: string;
|
||||
testId?: string;
|
||||
value?: string;
|
||||
callback?: () => void | Promise<unknown>;
|
||||
danger?: boolean;
|
||||
isSelected?: boolean;
|
||||
@@ -23,6 +24,8 @@ export type DropdownMenuProps = {
|
||||
buttonCss?: BoxProps['$css'];
|
||||
disabled?: boolean;
|
||||
topMessage?: string;
|
||||
selectedValues?: string[];
|
||||
afterOpenChange?: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const DropdownMenu = ({
|
||||
@@ -34,6 +37,8 @@ export const DropdownMenu = ({
|
||||
buttonCss,
|
||||
label,
|
||||
topMessage,
|
||||
afterOpenChange,
|
||||
selectedValues,
|
||||
}: PropsWithChildren<DropdownMenuProps>) => {
|
||||
const theme = useCunninghamTheme();
|
||||
const spacings = theme.spacingsTokens();
|
||||
@@ -43,6 +48,7 @@ export const DropdownMenu = ({
|
||||
|
||||
const onOpenChange = (isOpen: boolean) => {
|
||||
setIsOpen(isOpen);
|
||||
afterOpenChange?.(isOpen);
|
||||
};
|
||||
|
||||
if (disabled) {
|
||||
@@ -161,7 +167,8 @@ export const DropdownMenu = ({
|
||||
{option.label}
|
||||
</Text>
|
||||
</Box>
|
||||
{option.isSelected && (
|
||||
{(option.isSelected ||
|
||||
selectedValues?.includes(option.value ?? '')) && (
|
||||
<Icon iconName="check" $size="20px" $theme="greyscale" />
|
||||
)}
|
||||
</BoxButton>
|
||||
|
||||
@@ -8,7 +8,7 @@ type IconProps = TextType & {
|
||||
};
|
||||
export const Icon = ({ iconName, ...textProps }: IconProps) => {
|
||||
return (
|
||||
<Text $isMaterialIcon {...textProps}>
|
||||
<Text $isMaterialIcon={textProps.$isMaterialIcon ?? true} {...textProps}>
|
||||
{iconName}
|
||||
</Text>
|
||||
);
|
||||
@@ -27,7 +27,7 @@ export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
|
||||
$size="36px"
|
||||
$theme="primary"
|
||||
$variation="600"
|
||||
$background={colorsTokens()['primary-bg']}
|
||||
$background={colorsTokens()['greyscale-000']}
|
||||
$css={`
|
||||
border: 1px solid ${colorsTokens()['primary-200']};
|
||||
user-select: none;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from 'clsx';
|
||||
import { CSSProperties, ComponentPropsWithRef, forwardRef } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
@@ -11,7 +12,7 @@ type TextSizes = keyof typeof sizes;
|
||||
export interface TextProps extends BoxProps {
|
||||
as?: 'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
||||
$elipsis?: boolean;
|
||||
$isMaterialIcon?: boolean;
|
||||
$isMaterialIcon?: boolean | 'filled';
|
||||
$weight?: CSSProperties['fontWeight'];
|
||||
$textAlign?: CSSProperties['textAlign'];
|
||||
$size?: TextSizes | (string & {});
|
||||
@@ -58,13 +59,20 @@ export const TextStyled = styled(Box)<TextProps>`
|
||||
|
||||
const Text = forwardRef<HTMLElement, ComponentPropsWithRef<typeof TextStyled>>(
|
||||
({ className, $isMaterialIcon, ...props }, ref) => {
|
||||
const isFilled = $isMaterialIcon === 'filled';
|
||||
const isMaterialIcon =
|
||||
typeof $isMaterialIcon === 'boolean' && $isMaterialIcon;
|
||||
|
||||
return (
|
||||
<TextStyled
|
||||
ref={ref}
|
||||
as="span"
|
||||
$theme="greyscale"
|
||||
$variation="text"
|
||||
className={`${className || ''}${$isMaterialIcon ? ' material-icons' : ''}`}
|
||||
className={clsx(className || '', {
|
||||
'material-icons': isMaterialIcon,
|
||||
'material-icons-filled': isFilled,
|
||||
})}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box } from '../Box';
|
||||
import { DropdownMenu, DropdownMenuOption } from '../DropdownMenu';
|
||||
import { Icon } from '../Icon';
|
||||
import { Text } from '../Text';
|
||||
|
||||
export type FilterDropdownProps = {
|
||||
options: DropdownMenuOption[];
|
||||
selectedValue?: string;
|
||||
};
|
||||
|
||||
export const FilterDropdown = ({
|
||||
options,
|
||||
selectedValue,
|
||||
}: FilterDropdownProps) => {
|
||||
const selectedOption = options.find(
|
||||
(option) => option.value === selectedValue,
|
||||
);
|
||||
|
||||
if (options.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
selectedValues={selectedValue ? [selectedValue] : undefined}
|
||||
options={options}
|
||||
>
|
||||
<Box
|
||||
$css={css`
|
||||
border: 1px solid
|
||||
${selectedOption
|
||||
? 'var(--c--theme--colors--primary-500)'
|
||||
: 'var(--c--theme--colors--greyscale-250)'};
|
||||
border-radius: 4px;
|
||||
background-color: ${selectedOption
|
||||
? 'var(--c--theme--colors--primary-100)'
|
||||
: 'var(--c--theme--colors--greyscale-000)'};
|
||||
gap: var(--c--theme--spacings--2xs);
|
||||
padding: var(--c--theme--spacings--2xs) var(--c--theme--spacings--xs);
|
||||
`}
|
||||
color="secondary"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
>
|
||||
<Text
|
||||
$weight={400}
|
||||
$variation={selectedOption ? '800' : '600'}
|
||||
$theme={selectedOption ? 'primary' : 'greyscale'}
|
||||
>
|
||||
{selectedOption?.label ?? options[0].label}
|
||||
</Text>
|
||||
<Icon
|
||||
$size="16px"
|
||||
iconName="keyboard_arrow_down"
|
||||
$variation={selectedOption ? '800' : '600'}
|
||||
$theme={selectedOption ? 'primary' : 'greyscale'}
|
||||
/>
|
||||
</Box>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -57,6 +57,9 @@ export const QuickSearchInput = ({
|
||||
/* eslint-disable-next-line jsx-a11y/no-autofocus */
|
||||
autoFocus={true}
|
||||
aria-label={t('Quick search input')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
value={inputValue}
|
||||
role="combobox"
|
||||
placeholder={placeholder ?? t('Search')}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */
|
||||
import {
|
||||
Tooltip,
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { Tooltip } from '@openfun/cunningham-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
@@ -15,11 +12,13 @@ import {
|
||||
Doc,
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
KEY_SUB_DOC,
|
||||
useTrans,
|
||||
useUpdateDoc,
|
||||
} from '@/docs/doc-management';
|
||||
import { useBroadcastStore, useResponsiveStore } from '@/stores';
|
||||
|
||||
import { useDocTreeStore } from '../../doc-tree/context/DocTreeContext';
|
||||
interface DocTitleProps {
|
||||
doc: Doc;
|
||||
}
|
||||
@@ -55,20 +54,27 @@ export const DocTitleText = ({ title }: DocTitleTextProps) => {
|
||||
const DocTitleInput = ({ doc }: DocTitleProps) => {
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const treeStore = useDocTreeStore();
|
||||
const [titleDisplay, setTitleDisplay] = useState(doc.title);
|
||||
const { toast } = useToastProvider();
|
||||
|
||||
const { untitledDocument } = useTrans();
|
||||
|
||||
const { broadcast } = useBroadcastStore();
|
||||
|
||||
const { mutate: updateDoc } = useUpdateDoc({
|
||||
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
|
||||
onSuccess(data) {
|
||||
toast(t('Document title updated successfully'), VariantType.SUCCESS);
|
||||
|
||||
listInvalideQueries: [KEY_LIST_DOC],
|
||||
onSuccess(updatedDoc) {
|
||||
// Broadcast to every user connected to the document
|
||||
broadcast(`${KEY_DOC}-${data.id}`);
|
||||
broadcast(`${KEY_DOC}-${updatedDoc.id}`);
|
||||
if (updatedDoc.id === treeStore.root?.id) {
|
||||
treeStore.setRoot(updatedDoc);
|
||||
}
|
||||
queryClient.setQueryData(
|
||||
[KEY_SUB_DOC, { id: updatedDoc.id }],
|
||||
updatedDoc,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Doc } from '../types';
|
||||
|
||||
export type DocParams = {
|
||||
id: string;
|
||||
isTree?: boolean;
|
||||
};
|
||||
|
||||
export const getDoc = async ({ id }: DocParams): Promise<Doc> => {
|
||||
@@ -19,14 +20,15 @@ export const getDoc = async ({ id }: DocParams): Promise<Doc> => {
|
||||
};
|
||||
|
||||
export const KEY_DOC = 'doc';
|
||||
export const KEY_SUB_DOC = 'sub-doc';
|
||||
export const KEY_DOC_VISIBILITY = 'doc-visibility';
|
||||
|
||||
export function useDoc(
|
||||
param: DocParams,
|
||||
queryConfig?: UseQueryOptions<Doc, APIError, Doc>,
|
||||
queryConfig?: Omit<UseQueryOptions<Doc, APIError, Doc>, 'queryFn'>,
|
||||
) {
|
||||
return useQuery<Doc, APIError, Doc>({
|
||||
queryKey: [KEY_DOC, param],
|
||||
queryKey: queryConfig?.queryKey ?? [KEY_DOC, param],
|
||||
queryFn: () => getDoc(param),
|
||||
...queryConfig,
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useAPIInfiniteQuery,
|
||||
} from '@/api';
|
||||
|
||||
import { DocSearchTarget } from '../../doc-search/components/DocSearchFilters';
|
||||
import { Doc } from '../types';
|
||||
|
||||
export const isDocsOrdering = (data: string): data is DocsOrdering => {
|
||||
@@ -31,6 +32,8 @@ export type DocsParams = {
|
||||
is_creator_me?: boolean;
|
||||
title?: string;
|
||||
is_favorite?: boolean;
|
||||
target?: DocSearchTarget;
|
||||
parent_id?: string;
|
||||
};
|
||||
|
||||
export type DocsResponse = APIList<Doc>;
|
||||
@@ -53,8 +56,14 @@ export const getDocs = async (params: DocsParams): Promise<DocsResponse> => {
|
||||
if (params.is_favorite !== undefined) {
|
||||
searchParams.set('is_favorite', params.is_favorite.toString());
|
||||
}
|
||||
|
||||
const response = await fetchAPI(`documents/?${searchParams.toString()}`);
|
||||
let response: Response;
|
||||
if (params.parent_id && params.target === DocSearchTarget.CURRENT) {
|
||||
response = await fetchAPI(
|
||||
`documents/${params.parent_id}/descendants/?${searchParams.toString()}`,
|
||||
);
|
||||
} else {
|
||||
response = await fetchAPI(`documents/?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to get the docs', await errorCauses(response));
|
||||
|
||||
@@ -17,16 +17,20 @@ import { Doc } from '../types';
|
||||
interface ModalRemoveDocProps {
|
||||
onClose: () => void;
|
||||
doc: Doc;
|
||||
afterDelete?: (doc: Doc) => void;
|
||||
}
|
||||
|
||||
export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
|
||||
export const ModalRemoveDoc = ({
|
||||
onClose,
|
||||
doc,
|
||||
afterDelete,
|
||||
}: ModalRemoveDocProps) => {
|
||||
const { toast } = useToastProvider();
|
||||
const { push } = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const {
|
||||
mutate: removeDoc,
|
||||
|
||||
isError,
|
||||
error,
|
||||
} = useRemoveDoc({
|
||||
@@ -34,6 +38,11 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
|
||||
toast(t('The document has been deleted.'), VariantType.SUCCESS, {
|
||||
duration: 4000,
|
||||
});
|
||||
if (afterDelete) {
|
||||
afterDelete(doc);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/') {
|
||||
onClose();
|
||||
} else {
|
||||
@@ -87,7 +96,9 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
|
||||
<Box aria-label={t('Content modal to delete document')}>
|
||||
{!isError && (
|
||||
<Text $size="sm" $variation="600">
|
||||
{t('Are you sure you want to delete this document ?')}
|
||||
{t('Are you sure you want to delete the document "{{title}}"?', {
|
||||
title: doc.title ?? t('Untitled document'),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
|
||||
@@ -42,10 +42,14 @@ export interface Doc {
|
||||
is_favorite: boolean;
|
||||
link_reach: LinkReach;
|
||||
link_role: LinkRole;
|
||||
nb_accesses_ancestors: number;
|
||||
nb_accesses_direct: number;
|
||||
user_roles: Role[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
nb_accesses_direct: number;
|
||||
nb_accesses_ancestors: number;
|
||||
children?: Doc[];
|
||||
childrenCount?: number;
|
||||
numchild: number;
|
||||
abilities: {
|
||||
accesses_manage: boolean;
|
||||
accesses_view: boolean;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { FilterDropdown } from '@/components/filter/FilterDropdown';
|
||||
|
||||
export enum DocSearchTarget {
|
||||
ALL = 'all',
|
||||
CURRENT = 'current',
|
||||
}
|
||||
|
||||
export type DocSearchFiltersValues = {
|
||||
target?: DocSearchTarget;
|
||||
};
|
||||
|
||||
export type DocSearchFiltersProps = {
|
||||
values?: DocSearchFiltersValues;
|
||||
onValuesChange?: (values: DocSearchFiltersValues) => void;
|
||||
onReset?: () => void;
|
||||
};
|
||||
|
||||
export const DocSearchFilters = ({
|
||||
values,
|
||||
onValuesChange,
|
||||
onReset,
|
||||
}: DocSearchFiltersProps) => {
|
||||
const { t } = useTranslation();
|
||||
const hasFilters = Object.keys(values ?? {}).length > 0;
|
||||
const handleTargetChange = (target: DocSearchTarget) => {
|
||||
onValuesChange?.({ ...values, target });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$height="35px"
|
||||
$justify="space-between"
|
||||
$gap="10px"
|
||||
$margin={{ vertical: 'base' }}
|
||||
>
|
||||
<Box $direction="row" $align="center" $gap="10px">
|
||||
<FilterDropdown
|
||||
selectedValue={values?.target}
|
||||
options={[
|
||||
{
|
||||
label: t('All docs'),
|
||||
value: DocSearchTarget.ALL,
|
||||
callback: () => handleTargetChange(DocSearchTarget.ALL),
|
||||
},
|
||||
{
|
||||
label: t('Current doc'),
|
||||
value: DocSearchTarget.CURRENT,
|
||||
callback: () => handleTargetChange(DocSearchTarget.CURRENT),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Box>
|
||||
{hasFilters && (
|
||||
<Button color="primary-text" size="small" onClick={onReset}>
|
||||
{t('Reset')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -15,17 +15,37 @@ import {
|
||||
import { Doc, useInfiniteDocs } from '@/docs/doc-management';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { useDocTreeStore } from '../../doc-tree/context/DocTreeContext';
|
||||
import EmptySearchIcon from '../assets/illustration-docs-empty.png';
|
||||
|
||||
import {
|
||||
DocSearchFilters,
|
||||
DocSearchFiltersValues,
|
||||
DocSearchTarget,
|
||||
} from './DocSearchFilters';
|
||||
import { DocSearchItem } from './DocSearchItem';
|
||||
|
||||
type DocSearchModalProps = ModalProps & {};
|
||||
type DocSearchModalProps = ModalProps & {
|
||||
showFilters?: boolean;
|
||||
defaultFilters?: DocSearchFiltersValues;
|
||||
};
|
||||
|
||||
export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
|
||||
export const DocSearchModal = ({
|
||||
showFilters = false,
|
||||
defaultFilters,
|
||||
...modalProps
|
||||
}: DocSearchModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const router = useRouter();
|
||||
const treeStore = useDocTreeStore();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [filters, setFilters] = useState<DocSearchFiltersValues>(
|
||||
defaultFilters ?? {},
|
||||
);
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
@@ -36,27 +56,41 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
|
||||
} = useInfiniteDocs({
|
||||
page: 1,
|
||||
title: search,
|
||||
...filters,
|
||||
parent_id: treeStore?.root?.id,
|
||||
});
|
||||
const loading = isFetching || isRefetching || isLoading;
|
||||
const handleInputSearch = useDebouncedCallback(setSearch, 300);
|
||||
|
||||
const handleSelect = (doc: Doc) => {
|
||||
if (treeStore?.initialRootId !== doc.id) {
|
||||
treeStore.setSelectedNode(doc);
|
||||
treeStore.setRoot(doc);
|
||||
treeStore.setInitialTargetId(doc.id);
|
||||
}
|
||||
router.push(`/docs/${doc.id}`);
|
||||
modalProps.onClose?.();
|
||||
};
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setFilters({});
|
||||
};
|
||||
|
||||
const docsData: QuickSearchData<Doc> = useMemo(() => {
|
||||
const docs = data?.pages.flatMap((page) => page.results) || [];
|
||||
|
||||
const groupName =
|
||||
filters.target === DocSearchTarget.CURRENT
|
||||
? t('Select a page')
|
||||
: t('Select a document');
|
||||
return {
|
||||
groupName: docs.length > 0 ? t('Select a document') : '',
|
||||
groupName: docs.length > 0 ? groupName : '',
|
||||
elements: search ? docs : [],
|
||||
emptyString: t('No document found'),
|
||||
endActions: hasNextPage
|
||||
? [{ content: <InView onChange={() => void fetchNextPage()} /> }]
|
||||
: [],
|
||||
};
|
||||
}, [data, hasNextPage, fetchNextPage, t, search]);
|
||||
}, [data, hasNextPage, fetchNextPage, t, search, filters.target]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -75,6 +109,13 @@ export const DocSearchModal = ({ ...modalProps }: DocSearchModalProps) => {
|
||||
onFilter={handleInputSearch}
|
||||
>
|
||||
<Box $height={isDesktop ? '500px' : 'calc(100vh - 68px - 1rem)'}>
|
||||
{showFilters && (
|
||||
<DocSearchFilters
|
||||
values={filters}
|
||||
onValuesChange={setFilters}
|
||||
onReset={handleResetFilters}
|
||||
/>
|
||||
)}
|
||||
{search.length === 0 && (
|
||||
<Box
|
||||
$direction="column"
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
VariantType,
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
@@ -11,7 +12,7 @@ import { APIError } from '@/api';
|
||||
import { Box } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { User } from '@/features/auth';
|
||||
import { Doc, Role } from '@/features/docs';
|
||||
import { Doc, KEY_SUB_DOC, Role } from '@/features/docs';
|
||||
|
||||
import { useCreateDocAccess, useCreateDocInvitation } from '../api';
|
||||
import { OptionType } from '../types';
|
||||
@@ -39,6 +40,7 @@ export const DocShareAddMemberList = ({
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToastProvider();
|
||||
const queryClient = useQueryClient();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
const [invitationRole, setInvitationRole] = useState<Role>(Role.EDITOR);
|
||||
@@ -91,14 +93,32 @@ export const DocShareAddMemberList = ({
|
||||
};
|
||||
|
||||
return isInvitationMode
|
||||
? createInvitation({
|
||||
...payload,
|
||||
email: user.email,
|
||||
})
|
||||
: createDocAccess({
|
||||
...payload,
|
||||
memberId: user.id,
|
||||
});
|
||||
? createInvitation(
|
||||
{
|
||||
...payload,
|
||||
email: user.email,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_SUB_DOC, { id: doc.id }],
|
||||
});
|
||||
},
|
||||
},
|
||||
)
|
||||
: createDocAccess(
|
||||
{
|
||||
...payload,
|
||||
memberId: user.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_SUB_DOC, { id: doc.id }],
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const settledPromises = await Promise.allSettled(promises);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
IconOptions,
|
||||
} from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Doc, Role } from '@/docs/doc-management';
|
||||
import { Doc, KEY_SUB_DOC, Role } from '@/docs/doc-management';
|
||||
import { User } from '@/features/auth';
|
||||
|
||||
import { useDeleteDocInvitation, useUpdateDocInvitation } from '../api';
|
||||
@@ -23,6 +24,7 @@ type Props = {
|
||||
};
|
||||
export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const spacing = spacingsTokens();
|
||||
const fakeUser: User = {
|
||||
@@ -37,6 +39,11 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
|
||||
const canUpdate = doc.abilities.accesses_manage;
|
||||
|
||||
const { mutate: updateDocInvitation } = useUpdateDocInvitation({
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_SUB_DOC, { id: doc.id }],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(
|
||||
error?.data?.role?.[0] ?? t('Error during update invitation'),
|
||||
@@ -49,6 +56,11 @@ export const DocShareInvitationItem = ({ doc, invitation }: Props) => {
|
||||
});
|
||||
|
||||
const { mutate: removeDocInvitation } = useDeleteDocInvitation({
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_SUB_DOC, { id: doc.id }],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(
|
||||
error?.data?.role?.[0] ?? t('Error during delete invitation'),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
IconOptions,
|
||||
} from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Access, Doc, Role } from '@/docs/doc-management/';
|
||||
import { Access, Doc, KEY_SUB_DOC, Role } from '@/docs/doc-management/';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { useDeleteDocAccess, useUpdateDocAccess } from '../api';
|
||||
@@ -25,13 +26,20 @@ export const DocShareMemberItem = ({ doc, access }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { isLastOwner, isOtherOwner } = useWhoAmI(access);
|
||||
const { toast } = useToastProvider();
|
||||
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const queryClient = useQueryClient();
|
||||
const spacing = spacingsTokens();
|
||||
const isNotAllowed =
|
||||
isOtherOwner || !!isLastOwner || !doc.abilities.accesses_manage;
|
||||
|
||||
const { mutate: updateDocAccess } = useUpdateDocAccess({
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_SUB_DOC, { id: doc.id }],
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast(t('Error during invitation update'), VariantType.ERROR, {
|
||||
duration: 4000,
|
||||
@@ -40,6 +48,11 @@ export const DocShareMemberItem = ({ doc, access }: Props) => {
|
||||
});
|
||||
|
||||
const { mutate: removeDocAccess } = useDeleteDocAccess({
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_SUB_DOC, { id: doc.id }],
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast(t('Error while deleting invitation'), VariantType.ERROR, {
|
||||
duration: 4000,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { Doc, KEY_LIST_DOC } from '../../doc-management';
|
||||
|
||||
export type CreateDocParam = Pick<Doc, 'title'> & {
|
||||
parentId: string;
|
||||
};
|
||||
|
||||
export const createDocChildren = async ({
|
||||
title,
|
||||
parentId,
|
||||
}: CreateDocParam): Promise<Doc> => {
|
||||
const response = await fetchAPI(`documents/${parentId}/children/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to create the doc', await errorCauses(response));
|
||||
}
|
||||
|
||||
return response.json() as Promise<Doc>;
|
||||
};
|
||||
|
||||
interface CreateDocProps {
|
||||
onSuccess: (data: Doc) => void;
|
||||
}
|
||||
|
||||
export function useCreateChildrenDoc({ onSuccess }: CreateDocProps) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<Doc, APIError, CreateDocParam>({
|
||||
mutationFn: createDocChildren,
|
||||
onSuccess: (data) => {
|
||||
void queryClient.resetQueries({
|
||||
queryKey: [KEY_LIST_DOC],
|
||||
});
|
||||
onSuccess(data);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI, useAPIInfiniteQuery } from '@/api';
|
||||
|
||||
import { DocsResponse } from '../../doc-management';
|
||||
|
||||
export type DocsChildrenParams = {
|
||||
docId: string;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
};
|
||||
|
||||
export const getDocChildren = async (
|
||||
params: DocsChildrenParams,
|
||||
): Promise<DocsResponse> => {
|
||||
const { docId, page, page_size } = params;
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (page) {
|
||||
searchParams.set('page', page.toString());
|
||||
}
|
||||
if (page_size) {
|
||||
searchParams.set('page_size', page_size.toString());
|
||||
}
|
||||
|
||||
const response = await fetchAPI(
|
||||
`documents/${docId}/children/?${searchParams.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to get the doc children',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<DocsResponse>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_DOC_CHILDREN = 'doc-children';
|
||||
|
||||
export function useDocChildren(
|
||||
params: DocsChildrenParams,
|
||||
queryConfig?: Omit<
|
||||
UseQueryOptions<DocsResponse, APIError, DocsResponse>,
|
||||
'queryKey' | 'queryFn'
|
||||
>,
|
||||
) {
|
||||
return useQuery<DocsResponse, APIError, DocsResponse>({
|
||||
queryKey: [KEY_LIST_DOC_CHILDREN, params],
|
||||
queryFn: () => getDocChildren(params),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
|
||||
export const useInfiniteDocChildren = (params: DocsChildrenParams) => {
|
||||
return useAPIInfiniteQuery(KEY_LIST_DOC_CHILDREN, getDocChildren, params);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { Doc } from '../../doc-management';
|
||||
|
||||
export type DocsTreeParams = {
|
||||
docId: string;
|
||||
};
|
||||
|
||||
export const getDocTree = async (params: DocsTreeParams): Promise<Doc> => {
|
||||
const { docId } = params;
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
const response = await fetchAPI(
|
||||
`documents/${docId}/tree/?${searchParams.toString()}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to get the doc tree',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<Doc>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_DOC_CHILDREN = 'doc-tree';
|
||||
|
||||
export function useDocTree(
|
||||
params: DocsTreeParams,
|
||||
queryConfig?: Omit<
|
||||
UseQueryOptions<Doc, APIError, Doc>,
|
||||
'queryKey' | 'queryFn'
|
||||
>,
|
||||
) {
|
||||
return useQuery<Doc, APIError, Doc>({
|
||||
queryKey: [KEY_LIST_DOC_CHILDREN, params],
|
||||
queryFn: () => getDocTree(params),
|
||||
staleTime: 0,
|
||||
refetchOnWindowFocus: false,
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
export type MoveDocParam = {
|
||||
sourceDocumentId: string;
|
||||
targetDocumentId: string;
|
||||
position: TreeViewMoveModeEnum;
|
||||
};
|
||||
|
||||
export const moveDoc = async ({
|
||||
sourceDocumentId,
|
||||
targetDocumentId,
|
||||
position,
|
||||
}: MoveDocParam): Promise<void> => {
|
||||
const response = await fetchAPI(`documents/${sourceDocumentId}/move/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
target_document_id: targetDocumentId,
|
||||
position,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError('Failed to move the doc', await errorCauses(response));
|
||||
}
|
||||
|
||||
return response.json() as Promise<void>;
|
||||
};
|
||||
|
||||
export function useMoveDoc() {
|
||||
return useMutation<void, APIError, MoveDocParam>({
|
||||
mutationFn: moveDoc,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.40918 4.69434C5.28613 4.69434 5.18359 4.65332 5.10156 4.57129C5.02409 4.48926 4.98535 4.389 4.98535 4.27051C4.98535 4.15202 5.02409 4.05404 5.10156 3.97656C5.18359 3.89453 5.28613 3.85352 5.40918 3.85352H10.5977C10.7161 3.85352 10.8141 3.89453 10.8916 3.97656C10.9736 4.05404 11.0146 4.15202 11.0146 4.27051C11.0146 4.389 10.9736 4.48926 10.8916 4.57129C10.8141 4.65332 10.7161 4.69434 10.5977 4.69434H5.40918ZM5.40918 7.08008C5.28613 7.08008 5.18359 7.03906 5.10156 6.95703C5.02409 6.875 4.98535 6.77474 4.98535 6.65625C4.98535 6.53776 5.02409 6.43978 5.10156 6.3623C5.18359 6.28027 5.28613 6.23926 5.40918 6.23926H10.5977C10.7161 6.23926 10.8141 6.28027 10.8916 6.3623C10.9736 6.43978 11.0146 6.53776 11.0146 6.65625C11.0146 6.77474 10.9736 6.875 10.8916 6.95703C10.8141 7.03906 10.7161 7.08008 10.5977 7.08008H5.40918ZM5.40918 9.46582C5.28613 9.46582 5.18359 9.42708 5.10156 9.34961C5.02409 9.26758 4.98535 9.1696 4.98535 9.05566C4.98535 8.93262 5.02409 8.83008 5.10156 8.74805C5.18359 8.66602 5.28613 8.625 5.40918 8.625H7.86328C7.98633 8.625 8.08659 8.66602 8.16406 8.74805C8.24609 8.83008 8.28711 8.93262 8.28711 9.05566C8.28711 9.1696 8.24609 9.26758 8.16406 9.34961C8.08659 9.42708 7.98633 9.46582 7.86328 9.46582H5.40918ZM2.25098 13.2529V2.88281C2.25098 2.17188 2.42643 1.63639 2.77734 1.27637C3.13281 0.916341 3.66374 0.736328 4.37012 0.736328H11.6299C12.3363 0.736328 12.8649 0.916341 13.2158 1.27637C13.5713 1.63639 13.749 2.17188 13.749 2.88281V13.2529C13.749 13.9684 13.5713 14.5039 13.2158 14.8594C12.8649 15.2148 12.3363 15.3926 11.6299 15.3926H4.37012C3.66374 15.3926 3.13281 15.2148 2.77734 14.8594C2.42643 14.5039 2.25098 13.9684 2.25098 13.2529ZM3.35156 13.2324C3.35156 13.5742 3.44043 13.8363 3.61816 14.0186C3.80046 14.2008 4.06934 14.292 4.4248 14.292H11.5752C11.9307 14.292 12.1973 14.2008 12.375 14.0186C12.5573 13.8363 12.6484 13.5742 12.6484 13.2324V2.90332C12.6484 2.56152 12.5573 2.29948 12.375 2.11719C12.1973 1.93034 11.9307 1.83691 11.5752 1.83691H4.4248C4.06934 1.83691 3.80046 1.93034 3.61816 2.11719C3.44043 2.29948 3.35156 2.56152 3.35156 2.90332V13.2324Z" fill="#8585F6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
TreeViewItem,
|
||||
TreeViewNodeProps,
|
||||
useTree,
|
||||
} from '@gouvfr-lasuite/ui-kit';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Icon, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Doc, KEY_SUB_DOC, useDoc } from '@/features/docs/doc-management';
|
||||
import { useLeftPanelStore } from '@/features/left-panel';
|
||||
|
||||
import Logo from './../assets/sub-page-logo.svg';
|
||||
import { DocTreeItemActions } from './DocTreeItemActions';
|
||||
|
||||
const ItemTextCss = css`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: initial;
|
||||
display: -webkit-box;
|
||||
line-clamp: 1;
|
||||
/* width: 100%; */
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
`;
|
||||
|
||||
type Props = TreeViewNodeProps<Doc> & {
|
||||
treeData: ReturnType<typeof useTree<Doc>>;
|
||||
doc: Doc;
|
||||
setSelectedNode: (node: Doc) => void;
|
||||
};
|
||||
|
||||
export const DocSubPageItem = ({
|
||||
doc,
|
||||
setSelectedNode,
|
||||
treeData,
|
||||
...props
|
||||
}: Props) => {
|
||||
const { loadChildren, node } = props;
|
||||
const isInitialLoad = useRef(false);
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const spacing = spacingsTokens();
|
||||
const router = useRouter();
|
||||
const { togglePanel } = useLeftPanelStore();
|
||||
|
||||
const { data: docQuery } = useDoc(
|
||||
{ isTree: true, id: doc.id },
|
||||
{
|
||||
initialData: doc,
|
||||
queryKey: [KEY_SUB_DOC, { id: doc.id }],
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (docQuery && isInitialLoad.current === true) {
|
||||
console.log('docQuery', docQuery);
|
||||
treeData?.updateNode(docQuery.id, docQuery);
|
||||
}
|
||||
if (docQuery) {
|
||||
isInitialLoad.current = true;
|
||||
}
|
||||
}, [docQuery, treeData]);
|
||||
|
||||
const afterCreate = (createdDoc: Doc) => {
|
||||
const actualChildren = node.data.children ?? [];
|
||||
|
||||
if (actualChildren.length === 0 && loadChildren) {
|
||||
loadChildren(node?.data.value)
|
||||
.then((allChildren) => {
|
||||
node.open();
|
||||
|
||||
router.push(`/docs/${doc.id}`);
|
||||
treeData?.setChildren(node.data.value.id, allChildren);
|
||||
togglePanel();
|
||||
})
|
||||
.catch(console.error);
|
||||
} else {
|
||||
const newDoc = {
|
||||
...createdDoc,
|
||||
children: [],
|
||||
childrenCount: 0,
|
||||
parentId: node.id,
|
||||
};
|
||||
treeData?.addChild(node.data.value.id, newDoc);
|
||||
node.open();
|
||||
router.push(`/docs/${createdDoc.id}`);
|
||||
togglePanel();
|
||||
}
|
||||
};
|
||||
|
||||
if (!treeData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeViewItem
|
||||
{...props}
|
||||
loadChildren={() =>
|
||||
treeData?.handleLoadChildren(props.node.data.value.id)
|
||||
}
|
||||
onClick={() => {
|
||||
setSelectedNode(props.node.data.value as Doc);
|
||||
router.push(`/docs/${props.node.data.value.id}`);
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
data-testid={`doc-sub-page-item-${props.node.data.value.id}`}
|
||||
$width="100%"
|
||||
$direction="row"
|
||||
$gap={spacing['xs']}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
$align="center"
|
||||
$css={css`
|
||||
.light-doc-item-actions {
|
||||
display: 'flex';
|
||||
opacity: 0;
|
||||
|
||||
&:has(.isOpen) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
.light-doc-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Box $width={16} $height={16}>
|
||||
<Logo />
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$css={css`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
`}
|
||||
>
|
||||
<Text $css={ItemTextCss} $size="sm">
|
||||
{doc.title}
|
||||
</Text>
|
||||
{doc.nb_accesses_direct > 1 && (
|
||||
<Icon
|
||||
$isMaterialIcon="filled"
|
||||
iconName="group"
|
||||
$size="16px"
|
||||
$variation="400"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
$direction="row"
|
||||
$gap={spacing['xs']}
|
||||
$align="center"
|
||||
className="light-doc-item-actions"
|
||||
>
|
||||
<DocTreeItemActions
|
||||
doc={doc}
|
||||
treeData={treeData}
|
||||
parentId={node.data.parentKey}
|
||||
onCreateSuccess={afterCreate}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</TreeViewItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,238 @@
|
||||
import {
|
||||
OpenMap,
|
||||
TreeView,
|
||||
TreeViewMoveResult,
|
||||
useTree,
|
||||
} from '@gouvfr-lasuite/ui-kit';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, SeparatedSection, StyledLink } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
|
||||
import {
|
||||
Doc,
|
||||
KEY_DOC,
|
||||
LinkReach,
|
||||
LinkRole,
|
||||
getDoc,
|
||||
} from '../../doc-management';
|
||||
import { SimpleDocItem } from '../../docs-grid';
|
||||
import { getDocChildren } from '../api/useDocChildren';
|
||||
import { useDocTree } from '../api/useDocTree';
|
||||
import { useMoveDoc } from '../api/useMove';
|
||||
import { subPageToTree, useDocTreeStore } from '../context/DocTreeContext';
|
||||
|
||||
import { DocSubPageItem } from './DocSubPageItem';
|
||||
import { DocTreeItemActions } from './DocTreeItemActions';
|
||||
|
||||
type DocTreeProps = {
|
||||
initialTargetId: string;
|
||||
};
|
||||
export const DocTree = ({ initialTargetId }: DocTreeProps) => {
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const queryClient = useQueryClient();
|
||||
const store = useDocTreeStore();
|
||||
|
||||
const spacing = spacingsTokens();
|
||||
|
||||
const treeData = useTree(
|
||||
[],
|
||||
async (docId) => {
|
||||
const doc = await getDoc({ id: docId });
|
||||
const newDoc = { ...doc, childrenCount: doc.numchild };
|
||||
void queryClient.setQueryData([KEY_DOC, { id: docId }], newDoc);
|
||||
return newDoc;
|
||||
},
|
||||
async (docId) => {
|
||||
const doc = await getDocChildren({ docId: docId });
|
||||
return subPageToTree(doc.results ?? []);
|
||||
},
|
||||
);
|
||||
const router = useRouter();
|
||||
const [initialOpenState, setInitialOpenState] = useState<OpenMap | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const { mutate: moveDoc } = useMoveDoc();
|
||||
|
||||
const { data } = useDocTree({
|
||||
docId: initialTargetId,
|
||||
});
|
||||
|
||||
const handleMove = (result: TreeViewMoveResult) => {
|
||||
moveDoc({
|
||||
sourceDocumentId: result.sourceId,
|
||||
targetDocumentId: result.targetModeId,
|
||||
position: result.mode,
|
||||
});
|
||||
treeData?.handleMove(result);
|
||||
};
|
||||
|
||||
const buildDocTree = (data?: Doc) => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const { children: rootChildren, ...root } = data;
|
||||
const children = rootChildren ?? [];
|
||||
store.setRoot(root);
|
||||
const initialOpenState: OpenMap = {};
|
||||
initialOpenState[root.id] = true;
|
||||
subPageToTree(children, (child) => {
|
||||
if (child?.children?.length && child?.children?.length > 0) {
|
||||
initialOpenState[child.id] = true;
|
||||
}
|
||||
});
|
||||
|
||||
treeData.resetTree(children);
|
||||
setInitialOpenState(initialOpenState);
|
||||
if (initialTargetId === root.id) {
|
||||
treeData?.setSelectedNode(root);
|
||||
} else {
|
||||
treeData?.selectNodeById(initialTargetId);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (treeData?.selectedNode?.id !== store.selectedNode?.id) {
|
||||
store.setSelectedNode(treeData?.selectedNode ?? null);
|
||||
}
|
||||
}, [store, treeData?.selectedNode]);
|
||||
|
||||
useEffect(() => {
|
||||
buildDocTree(data);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data]);
|
||||
|
||||
const rootIsSelected = treeData?.selectedNode?.id === store.root?.id;
|
||||
|
||||
if (!initialTargetId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box data-testid="doc-tree" $height="100%">
|
||||
<SeparatedSection showSeparator={false}>
|
||||
<Box $padding={{ horizontal: 'sm' }}>
|
||||
<Box
|
||||
$css={css`
|
||||
padding: ${spacing['2xs']};
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
background-color: ${rootIsSelected
|
||||
? 'var(--c--theme--colors--greyscale-100)'
|
||||
: 'transparent'};
|
||||
|
||||
&:hover {
|
||||
background-color: var(--c--theme--colors--greyscale-100);
|
||||
}
|
||||
|
||||
.doc-tree-root-item-actions {
|
||||
display: 'flex';
|
||||
opacity: 0;
|
||||
|
||||
&:has(.isOpen) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
.doc-tree-root-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`}
|
||||
>
|
||||
{store.root !== null && (
|
||||
<StyledLink
|
||||
$css={css`
|
||||
width: 100%;
|
||||
`}
|
||||
href={`/docs/${store.root.id}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
treeData?.setSelectedNode(store.root ?? undefined);
|
||||
router.push(`/docs/${store.root?.id}`);
|
||||
}}
|
||||
>
|
||||
<Box $direction="row" $align="center" $width="100%">
|
||||
<SimpleDocItem doc={store.root} showAccesses={true} />
|
||||
<div className="doc-tree-root-item-actions">
|
||||
<DocTreeItemActions
|
||||
doc={store.root}
|
||||
treeData={treeData}
|
||||
onCreateSuccess={(createdDoc) => {
|
||||
const newDoc = {
|
||||
...createdDoc,
|
||||
children: [],
|
||||
childrenCount: 0,
|
||||
parentId: store.root?.id ?? undefined,
|
||||
};
|
||||
treeData?.addChild(null, newDoc);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
</StyledLink>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</SeparatedSection>
|
||||
<button
|
||||
onClick={() => {
|
||||
const children = data?.children ?? [];
|
||||
const newDoc = {
|
||||
id: '1',
|
||||
children: [],
|
||||
childrenCount: 0,
|
||||
title: 'TITI',
|
||||
creator: 'test',
|
||||
is_favorite: false,
|
||||
};
|
||||
|
||||
// Ajout des propriétés manquantes pour correspondre au type Doc
|
||||
const completeDoc = {
|
||||
...newDoc,
|
||||
link_reach: LinkReach.PUBLIC,
|
||||
link_role: LinkRole.EDITOR,
|
||||
user_roles: [],
|
||||
// Ajoutez ici les autres propriétés requises par le type Doc
|
||||
};
|
||||
|
||||
children.push(completeDoc);
|
||||
buildDocTree(data);
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
|
||||
{initialOpenState && treeData.nodes.length > 0 && (
|
||||
<TreeView
|
||||
handleMove={handleMove}
|
||||
initialOpenState={initialOpenState}
|
||||
selectedNodeId={
|
||||
treeData.selectedNode?.id ?? store.initialTargetId ?? undefined
|
||||
}
|
||||
treeData={treeData.nodes ?? []}
|
||||
rootNodeId={store.root?.id ?? ''}
|
||||
renderNode={(props) => {
|
||||
if (treeData === undefined) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<DocSubPageItem
|
||||
{...props}
|
||||
treeData={treeData}
|
||||
doc={props.node.data.value as Doc}
|
||||
loadChildren={(node) => treeData.handleLoadChildren(node.id)}
|
||||
setSelectedNode={(node) => treeData.setSelectedNode(node)}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuOption,
|
||||
useTree,
|
||||
} from '@gouvfr-lasuite/ui-kit';
|
||||
import { useModal } from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Fragment, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, BoxButton, Icon } from '@/components';
|
||||
import { useLeftPanelStore } from '@/features/left-panel';
|
||||
|
||||
import { Doc, ModalRemoveDoc } from '../../doc-management';
|
||||
import { useCreateChildrenDoc } from '../api/useCreateChildren';
|
||||
import { useDocTreeStore } from '../context/DocTreeContext';
|
||||
|
||||
type DocTreeItemActionsProps = {
|
||||
doc: Doc;
|
||||
parentId?: string | null;
|
||||
treeData: ReturnType<typeof useTree<Doc>>;
|
||||
onCreateSuccess?: (newDoc: Doc) => void;
|
||||
};
|
||||
|
||||
export const DocTreeItemActions = ({
|
||||
doc,
|
||||
parentId,
|
||||
onCreateSuccess,
|
||||
treeData,
|
||||
}: DocTreeItemActionsProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const treeStore = useDocTreeStore();
|
||||
const { t } = useTranslation();
|
||||
const deleteModal = useModal();
|
||||
const { togglePanel } = useLeftPanelStore();
|
||||
|
||||
const options: DropdownMenuOption[] = [
|
||||
{
|
||||
label: t('Delete'),
|
||||
icon: <Icon iconName="delete" />,
|
||||
callback: deleteModal.open,
|
||||
},
|
||||
];
|
||||
|
||||
const { mutate: createChildrenDoc } = useCreateChildrenDoc({
|
||||
onSuccess: (doc) => {
|
||||
onCreateSuccess?.(doc);
|
||||
|
||||
togglePanel();
|
||||
treeData.setSelectedNode(doc);
|
||||
router.push(`/docs/${doc.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
const afterDelete = () => {
|
||||
if (parentId) {
|
||||
router.push(`/docs/${parentId}`);
|
||||
treeData?.selectNodeById(parentId);
|
||||
treeData?.deleteNode(doc.id);
|
||||
void treeData?.refreshNode(parentId);
|
||||
} else if (doc.id === treeStore.root?.id && !parentId) {
|
||||
router.push(`/docs/`);
|
||||
} else if (treeStore.root) {
|
||||
router.push(`/docs/${treeStore.root.id}`);
|
||||
treeData?.deleteNode(doc.id);
|
||||
treeData?.setSelectedNode(treeStore.root);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
className={` ${isOpen ? 'isOpen' : ''}`}
|
||||
$css={css`
|
||||
gap: var(--c--theme----c--theme--spacings--xs);
|
||||
`}
|
||||
>
|
||||
<DropdownMenu
|
||||
options={options}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
<Icon
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
iconName="more_horiz"
|
||||
$isMaterialIcon="filled"
|
||||
$theme="primary"
|
||||
$variation="600"
|
||||
/>
|
||||
</DropdownMenu>
|
||||
<BoxButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
createChildrenDoc({
|
||||
title: t('Untitled page'),
|
||||
parentId: doc.id,
|
||||
});
|
||||
}}
|
||||
color="primary"
|
||||
>
|
||||
<Icon
|
||||
$variation="800"
|
||||
$theme="primary"
|
||||
$isMaterialIcon="filled"
|
||||
iconName="add_box"
|
||||
/>
|
||||
</BoxButton>
|
||||
</Box>
|
||||
{deleteModal.isOpen && (
|
||||
<ModalRemoveDoc
|
||||
onClose={deleteModal.onClose}
|
||||
doc={doc}
|
||||
afterDelete={afterDelete}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { TreeViewDataType } from '@gouvfr-lasuite/ui-kit';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { Doc } from '../../doc-management';
|
||||
|
||||
export const subPageToTree = (
|
||||
children: Doc[],
|
||||
callback?: (doc: Doc) => void,
|
||||
): TreeViewDataType<Doc>[] => {
|
||||
children.forEach((child) => {
|
||||
child.childrenCount = child.numchild ?? 0;
|
||||
callback?.(child);
|
||||
subPageToTree(child.children ?? [], callback);
|
||||
});
|
||||
return children;
|
||||
};
|
||||
|
||||
interface DocTreeStore {
|
||||
initialTargetId?: string | null;
|
||||
initialRootId?: string | null;
|
||||
setRoot: (doc: Doc | null) => void;
|
||||
root: Doc | null;
|
||||
setInitialTargetId: (id: string) => void;
|
||||
setSelectedNode: (node: Doc | null) => void;
|
||||
selectedNode: Doc | null;
|
||||
}
|
||||
|
||||
export const useDocTreeStore = create<DocTreeStore>((set) => ({
|
||||
root: null,
|
||||
selectedNode: null,
|
||||
initialTargetId: undefined,
|
||||
initialRootId: undefined,
|
||||
|
||||
setRoot: (doc) => set({ root: doc }),
|
||||
setInitialTargetId: (id) => set({ initialTargetId: id }),
|
||||
setSelectedNode: (node) => set({ selectedNode: node }),
|
||||
}));
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
KeyboardSensor,
|
||||
Modifier,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { getEventCoordinates } from '@dnd-kit/utilities';
|
||||
import { TreeViewMoveModeEnum } from '@gouvfr-lasuite/ui-kit';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
|
||||
import { Doc, KEY_LIST_DOC, Role } from '../../doc-management';
|
||||
import { useMoveDoc } from '../../doc-tree/api/useMove';
|
||||
|
||||
import { DocsGridItem } from './DocsGridItem';
|
||||
import { Draggable } from './dnd/Draggable';
|
||||
import { Droppable } from './dnd/Droppable';
|
||||
|
||||
const activationConstraint = {
|
||||
distance: 20,
|
||||
};
|
||||
|
||||
type DocGridContentListProps = {
|
||||
docs: Doc[];
|
||||
};
|
||||
|
||||
export const DocGridContentList = ({ docs }: DocGridContentListProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const [selectedDoc, setSelectedDoc] = useState<Doc>();
|
||||
const canDrag = selectedDoc?.user_roles.some((role) => role === Role.OWNER);
|
||||
const [canDrop, setCanDrop] = useState<boolean>();
|
||||
const { mutate: handleMove } = useMoveDoc();
|
||||
|
||||
const mouseSensor = useSensor(MouseSensor, {
|
||||
activationConstraint,
|
||||
});
|
||||
|
||||
const touchSensor = useSensor(TouchSensor, {
|
||||
activationConstraint,
|
||||
});
|
||||
const keyboardSensor = useSensor(KeyboardSensor, {});
|
||||
|
||||
const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor);
|
||||
|
||||
const handleDragStart = (e: DragStartEvent) => {
|
||||
document.body.style.cursor = 'grabbing';
|
||||
if (e.active.data.current) {
|
||||
setSelectedDoc(e.active.data.current as Doc);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (e: DragEndEvent) => {
|
||||
setSelectedDoc(undefined);
|
||||
setCanDrop(undefined);
|
||||
document.body.style.cursor = 'default';
|
||||
if (!canDrag || !canDrop) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { active, over } = e;
|
||||
|
||||
if (!over?.id || active.id === over?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleMove(
|
||||
{
|
||||
sourceDocumentId: active.id as string,
|
||||
targetDocumentId: over.id as string,
|
||||
position: TreeViewMoveModeEnum.FIRST_CHILD,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [KEY_LIST_DOC],
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const overlayText = useMemo(() => {
|
||||
if (!canDrag) {
|
||||
return t('You must be the owner to move the document');
|
||||
}
|
||||
if (!canDrop) {
|
||||
return t('You must be at least the editor of the target document');
|
||||
}
|
||||
|
||||
return selectedDoc?.title || t('Unnamed document');
|
||||
}, [canDrag, canDrop, selectedDoc, t]);
|
||||
|
||||
const overlayBgColor = useMemo(() => {
|
||||
if (!canDrag) {
|
||||
return 'var(--c--theme--colors--danger-600)';
|
||||
}
|
||||
if (canDrop !== undefined && !canDrop) {
|
||||
return 'var(--c--theme--colors--danger-600)';
|
||||
}
|
||||
return '#5858D3';
|
||||
}, [canDrag, canDrop]);
|
||||
|
||||
if (docs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
modifiers={[snapToTopLeft]}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{docs.map((doc) => {
|
||||
const canDropItem = doc.user_roles.some(
|
||||
(role) =>
|
||||
role === Role.ADMIN || role === Role.OWNER || role === Role.EDITOR,
|
||||
);
|
||||
return (
|
||||
<Droppable
|
||||
enabledDrop={canDrag}
|
||||
canDrop={canDrag && canDropItem}
|
||||
onOver={(isOver) => {
|
||||
if (isOver) {
|
||||
setCanDrop(canDropItem);
|
||||
}
|
||||
}}
|
||||
key={doc.id}
|
||||
id={doc.id}
|
||||
data={doc}
|
||||
>
|
||||
<Draggable key={doc.id} id={doc.id} data={doc}>
|
||||
<DocsGridItem dragMode={!!selectedDoc} doc={doc} key={doc.id} />
|
||||
</Draggable>
|
||||
</Droppable>
|
||||
);
|
||||
})}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
<Box
|
||||
$width="fit-content"
|
||||
$padding={{ horizontal: 'xs', vertical: '3xs' }}
|
||||
$radius="12px"
|
||||
$background={overlayBgColor}
|
||||
data-testid="drag-doc-overlay"
|
||||
$height="auto"
|
||||
>
|
||||
<Text $size="xs" $variation="000" $weight="500">
|
||||
{overlayText}
|
||||
</Text>
|
||||
</Box>
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
export const snapToTopLeft: Modifier = ({
|
||||
activatorEvent,
|
||||
draggingNodeRect,
|
||||
transform,
|
||||
}) => {
|
||||
if (draggingNodeRect && activatorEvent) {
|
||||
const activatorCoordinates = getEventCoordinates(activatorEvent);
|
||||
|
||||
if (!activatorCoordinates) {
|
||||
return transform;
|
||||
}
|
||||
|
||||
const offsetX = activatorCoordinates.x - draggingNodeRect.left;
|
||||
const offsetY = activatorCoordinates.y - draggingNodeRect.top;
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: transform.x + offsetX - 3,
|
||||
y: transform.y + offsetY - 3,
|
||||
};
|
||||
}
|
||||
|
||||
return transform;
|
||||
};
|
||||
@@ -9,7 +9,7 @@ import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { useResponsiveDocGrid } from '../hooks/useResponsiveDocGrid';
|
||||
|
||||
import { DocsGridItem } from './DocsGridItem';
|
||||
import { DocGridContentList } from './DocGridContentList';
|
||||
import { DocsGridLoader } from './DocsGridLoader';
|
||||
|
||||
type DocsGridProps = {
|
||||
@@ -37,6 +37,9 @@ export const DocsGrid = ({
|
||||
is_creator_me: target === DocDefaultFilter.MY_DOCS,
|
||||
}),
|
||||
});
|
||||
|
||||
const docs = data?.pages.flatMap((page) => page.results) ?? [];
|
||||
|
||||
const loading = isFetching || isLoading;
|
||||
const hasDocs = data?.pages.some((page) => page.results.length > 0);
|
||||
const loadMore = (inView: boolean) => {
|
||||
@@ -114,11 +117,7 @@ export const DocsGrid = ({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{data?.pages.map((currentPage) => {
|
||||
return currentPage.results.map((doc) => (
|
||||
<DocsGridItem doc={doc} key={doc.id} />
|
||||
));
|
||||
})}
|
||||
<DocGridContentList docs={docs} />
|
||||
|
||||
{hasNextPage && !loading && (
|
||||
<InView
|
||||
|
||||
@@ -16,8 +16,9 @@ import { DocsGridItemSharedButton } from './DocsGridItemSharedButton';
|
||||
import { SimpleDocItem } from './SimpleDocItem';
|
||||
type DocsGridItemProps = {
|
||||
doc: Doc;
|
||||
dragMode?: boolean;
|
||||
};
|
||||
export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
|
||||
export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const { flexLeft, flexRight } = useResponsiveDocGrid();
|
||||
@@ -46,7 +47,9 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
&:hover {
|
||||
background-color: var(--c--theme--colors--greyscale-100);
|
||||
background-color: ${dragMode
|
||||
? 'none'
|
||||
: 'var(--c--theme--colors--greyscale-100)'};
|
||||
}
|
||||
`}
|
||||
>
|
||||
@@ -79,25 +82,35 @@ export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
content={
|
||||
<Text $textAlign="center" $variation="000">
|
||||
{isPublic
|
||||
? t('Accessible to anyone')
|
||||
: t('Accessible to authenticated users')}
|
||||
</Text>
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<div>
|
||||
<Icon
|
||||
$theme="greyscale"
|
||||
$variation="600"
|
||||
$size="14px"
|
||||
iconName={isPublic ? 'public' : 'vpn_lock'}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{dragMode && (
|
||||
<Icon
|
||||
$theme="greyscale"
|
||||
$variation="600"
|
||||
$size="14px"
|
||||
iconName={isPublic ? 'public' : 'vpn_lock'}
|
||||
/>
|
||||
)}
|
||||
{!dragMode && (
|
||||
<Tooltip
|
||||
content={
|
||||
<Text $textAlign="center" $variation="000">
|
||||
{isPublic
|
||||
? t('Accessible to anyone')
|
||||
: t('Accessible to authenticated users')}
|
||||
</Text>
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<div>
|
||||
<Icon
|
||||
$theme="greyscale"
|
||||
$variation="600"
|
||||
$size="14px"
|
||||
iconName={isPublic ? 'public' : 'vpn_lock'}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -38,7 +38,7 @@ export const SimpleDocItem = ({
|
||||
const { untitledDocument } = useTrans();
|
||||
|
||||
return (
|
||||
<Box $direction="row" $gap={spacings.sm} $overflow="auto">
|
||||
<Box $direction="row" $gap={spacings.sm} $overflow="auto" $width="100%">
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Data, useDraggable } from '@dnd-kit/core';
|
||||
|
||||
type DraggableProps<T> = {
|
||||
id: string;
|
||||
data?: Data<T>;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const Draggable = <T,>(props: DraggableProps<T>) => {
|
||||
const { attributes, listeners, setNodeRef } = useDraggable({
|
||||
id: props.id,
|
||||
data: props.data,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
data-testid={`draggable-doc-${props.id}`}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { Data, useDroppable } from '@dnd-kit/core';
|
||||
import { useEffect } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { Doc } from '@/features/docs/doc-management';
|
||||
|
||||
type DroppableProps = {
|
||||
id: string;
|
||||
onOver?: (isOver: boolean, data?: Data<Doc>) => void;
|
||||
data?: Data<Doc>;
|
||||
children: React.ReactNode;
|
||||
enabledDrop?: boolean;
|
||||
canDrop?: boolean;
|
||||
};
|
||||
|
||||
export const Droppable = (props: DroppableProps) => {
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: props.id,
|
||||
data: props.data,
|
||||
});
|
||||
|
||||
const enableHover = props.canDrop && isOver;
|
||||
|
||||
useEffect(() => {
|
||||
props.onOver?.(isOver, props.data);
|
||||
}, [isOver, props.data, props.onOver]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={setNodeRef}
|
||||
data-testid={`droppable-doc-${props.id}`}
|
||||
$css={css`
|
||||
border-radius: 4px;
|
||||
background-color: ${enableHover
|
||||
? 'var(--c--theme--colors--primary-100)'
|
||||
: 'transparent'};
|
||||
border: 1.5px solid
|
||||
${enableHover
|
||||
? 'var(--c--theme--colors--primary-500)'
|
||||
: 'transparent'};
|
||||
`}
|
||||
>
|
||||
{props.children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,14 @@
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, SeparatedSection } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Box } from '@/components';
|
||||
import { useDocStore } from '@/docs/doc-management';
|
||||
import { SimpleDocItem } from '@/docs/docs-grid';
|
||||
import { DocTree } from '@/features/docs/doc-tree/components/DocTree';
|
||||
import { useDocTreeStore } from '@/features/docs/doc-tree/context/DocTreeContext';
|
||||
|
||||
export const LeftPanelDocContent = () => {
|
||||
const { currentDoc } = useDocStore();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const spacing = spacingsTokens();
|
||||
if (!currentDoc) {
|
||||
|
||||
const treeStore = useDocTreeStore();
|
||||
|
||||
if (!currentDoc || !treeStore.initialTargetId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -19,19 +18,9 @@ export const LeftPanelDocContent = () => {
|
||||
$width="100%"
|
||||
$css="width: 100%; overflow-y: auto; overflow-x: hidden;"
|
||||
>
|
||||
<SeparatedSection showSeparator={false}>
|
||||
<Box $padding={{ horizontal: 'sm' }}>
|
||||
<Box
|
||||
$css={css`
|
||||
padding: ${spacing['2xs']};
|
||||
border-radius: 4px;
|
||||
background-color: var(--c--theme--colors--greyscale-100);
|
||||
`}
|
||||
>
|
||||
<SimpleDocItem doc={currentDoc} showAccesses={true} />
|
||||
</Box>
|
||||
</Box>
|
||||
</SeparatedSection>
|
||||
{treeStore.initialTargetId && (
|
||||
<DocTree initialTargetId={treeStore.initialTargetId} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Button, ModalSize, useModal } from '@openfun/cunningham-react';
|
||||
import { t } from 'i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter } from 'next/router';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { Box, Icon, SeparatedSection } from '@/components';
|
||||
import { useCreateDoc } from '@/docs/doc-management';
|
||||
import { DocSearchModal } from '@/docs/doc-search';
|
||||
import { useAuth } from '@/features/auth';
|
||||
import { useCreateDoc, useDocStore } from '@/features/docs/doc-management';
|
||||
import { DocSearchTarget } from '@/features/docs/doc-search/components/DocSearchFilters';
|
||||
import { useCreateChildrenDoc } from '@/features/docs/doc-tree/api/useCreateChildren';
|
||||
import { useDocTreeStore } from '@/features/docs/doc-tree/context/DocTreeContext';
|
||||
import { useCmdK } from '@/hook/useCmdK';
|
||||
|
||||
import { useLeftPanelStore } from '../stores';
|
||||
@@ -15,6 +18,11 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
||||
const router = useRouter();
|
||||
const searchModal = useModal();
|
||||
const { authenticated } = useAuth();
|
||||
const treeStore = useDocTreeStore();
|
||||
|
||||
const { currentDoc } = useDocStore();
|
||||
const isDoc = router.pathname === '/docs/[id]';
|
||||
|
||||
useCmdK(() => {
|
||||
const isEditorToolbarOpen =
|
||||
document.getElementsByClassName('bn-formatting-toolbar').length > 0;
|
||||
@@ -28,18 +36,34 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
||||
|
||||
const { mutate: createDoc } = useCreateDoc({
|
||||
onSuccess: (doc) => {
|
||||
router.push(`/docs/${doc.id}`);
|
||||
void router.push(`/docs/${doc.id}`);
|
||||
togglePanel();
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: createChildrenDoc } = useCreateChildrenDoc({
|
||||
onSuccess: (doc) => {
|
||||
treeStore.treeData?.addRootNode(doc);
|
||||
treeStore.treeData?.selectNodeById(doc.id);
|
||||
void router.push(`/docs/${doc.id}`);
|
||||
togglePanel();
|
||||
},
|
||||
});
|
||||
|
||||
const goToHome = () => {
|
||||
router.push('/');
|
||||
void router.push('/');
|
||||
togglePanel();
|
||||
};
|
||||
|
||||
const createNewDoc = () => {
|
||||
createDoc();
|
||||
if (treeStore.root && isDoc) {
|
||||
createChildrenDoc({
|
||||
title: t('Untitled page'),
|
||||
parentId: treeStore.root.id,
|
||||
});
|
||||
} else {
|
||||
createDoc();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -73,15 +97,29 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{authenticated && (
|
||||
<Button onClick={createNewDoc}>{t('New doc')}</Button>
|
||||
<Button
|
||||
color={!isDoc ? 'primary' : 'tertiary'}
|
||||
onClick={createNewDoc}
|
||||
disabled={currentDoc && !currentDoc.abilities.update}
|
||||
>
|
||||
{t(isDoc ? 'New page' : 'New doc')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</SeparatedSection>
|
||||
{children}
|
||||
</Box>
|
||||
{searchModal.isOpen && (
|
||||
<DocSearchModal {...searchModal} size={ModalSize.LARGE} />
|
||||
<DocSearchModal
|
||||
{...searchModal}
|
||||
size={ModalSize.LARGE}
|
||||
showFilters={isDoc}
|
||||
defaultFilters={{
|
||||
target: isDoc ? DocSearchTarget.CURRENT : undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -175,6 +175,7 @@ export class ApiPlugin implements WorkboxPlugin {
|
||||
is_favorite: false,
|
||||
nb_accesses_direct: 1,
|
||||
nb_accesses_ancestors: 1,
|
||||
numchild: 0,
|
||||
updated_at: new Date().toISOString(),
|
||||
abilities: {
|
||||
accesses_manage: true,
|
||||
@@ -201,6 +202,7 @@ export class ApiPlugin implements WorkboxPlugin {
|
||||
},
|
||||
link_reach: LinkReach.RESTRICTED,
|
||||
link_role: LinkRole.READER,
|
||||
user_roles: [],
|
||||
};
|
||||
|
||||
await DocsDB.cacheResponse(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AppProps } from 'next/app';
|
||||
import Head from 'next/head';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { AppProvider } from '@/core/';
|
||||
@@ -19,14 +18,6 @@ export default function App({ Component, pageProps }: AppPropsWithLayout) {
|
||||
const getLayout = Component.getLayout ?? ((page) => page);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
`%c
|
||||
\r\n \u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557 \r\n \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \r\n \u2588\u2588\u2551 \u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2588\u2588\u2554\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \r\n \u2588\u2588\u2551\u2588\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u255D \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2554\u255D\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u255D \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u255A\u2550\u2550\u2550\u2550\u2588\u2588\u2551 \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \r\n \u255A\u2588\u2588\u2588\u2554\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u255A\u2550\u255D \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551 \u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557 \r\n \u255A\u2550\u2550\u255D\u255A\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \r\n \r`,
|
||||
'font-size: 11px;line-height:15px;background-image: linear-gradient(#000091, #005f91);color: transparent;background-clip: text;',
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
|
||||
@@ -7,14 +7,15 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text, TextErrors } from '@/components';
|
||||
import { DocEditor } from '@/docs/doc-editor';
|
||||
import { KEY_AUTH, setAuthUrl } from '@/features/auth';
|
||||
import {
|
||||
Doc,
|
||||
KEY_DOC,
|
||||
useCollaboration,
|
||||
useDoc,
|
||||
useDocStore,
|
||||
} from '@/docs/doc-management/';
|
||||
import { KEY_AUTH, setAuthUrl } from '@/features/auth';
|
||||
} from '@/features/docs/doc-management/';
|
||||
import { useDocTreeStore } from '@/features/docs/doc-tree/context/DocTreeContext';
|
||||
import { MainLayout } from '@/layouts';
|
||||
import { useBroadcastStore } from '@/stores';
|
||||
import { NextPageWithLayout } from '@/types/next';
|
||||
@@ -24,6 +25,14 @@ export function DocLayout() {
|
||||
query: { id },
|
||||
} = useRouter();
|
||||
|
||||
const treeStore = useDocTreeStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof id === 'string' && !treeStore.initialTargetId) {
|
||||
treeStore.setInitialTargetId(id);
|
||||
}
|
||||
}, [id, treeStore]);
|
||||
|
||||
if (typeof id !== 'string') {
|
||||
return null;
|
||||
}
|
||||
@@ -35,7 +44,7 @@ export function DocLayout() {
|
||||
</Head>
|
||||
|
||||
<MainLayout>
|
||||
<DocPage id={id} />
|
||||
{treeStore.initialTargetId && <DocPage id={id} />}
|
||||
</MainLayout>
|
||||
</>
|
||||
);
|
||||
@@ -56,6 +65,8 @@ const DocPage = ({ id }: DocProps) => {
|
||||
{
|
||||
staleTime: 0,
|
||||
queryKey: [KEY_DOC, { id }],
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -84,6 +95,13 @@ const DocPage = ({ id }: DocProps) => {
|
||||
setCurrentDoc(docQuery);
|
||||
}, [docQuery, setCurrentDoc, isFetching]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setCurrentDoc(undefined);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* We add a broadcast task to reset the query cache
|
||||
* when the document visibility changes.
|
||||
|
||||
@@ -70,11 +70,3 @@ main ::-webkit-scrollbar-thumb:hover,
|
||||
/* Support for IE. */
|
||||
font-feature-settings: 'liga';
|
||||
}
|
||||
|
||||
[data-nextjs-dialog-overlay] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
nextjs-portal {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1640,10 +1640,8 @@
|
||||
resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/integration/-/integration-1.0.2.tgz#ed0000f4b738c5a19bb60f5b80a9a2f5d9414234"
|
||||
integrity sha512-npOotZQSyu6SffHiPP+jQVOkJ3qW2KE2cANhEK92sNLX9uZqQaCqljO5GhzsBmh0lB76fiXnrr9i8SIpnDUSZg==
|
||||
|
||||
"@gouvfr-lasuite/ui-kit@0.1.3":
|
||||
"@gouvfr-lasuite/ui-kit@file:../../../design-system":
|
||||
version "0.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@gouvfr-lasuite/ui-kit/-/ui-kit-0.1.3.tgz#1be7f1bdf12e7428e630d6ce11fbc77c4c9b9b21"
|
||||
integrity sha512-ba3ZrAIhX84cofa2IwiWhgE0wzz85+ySbOTvB1lP9jeWYvWn/N5HsnxphA9bEMIrx1Yi91upzmYLvjHRoDq1Ww==
|
||||
dependencies:
|
||||
"@dnd-kit/core" "6.3.1"
|
||||
"@dnd-kit/modifiers" "9.0.0"
|
||||
@@ -13416,6 +13414,38 @@ react-stately@3.35.0, react-stately@^3.35.0:
|
||||
"@react-stately/tree" "^3.8.7"
|
||||
"@react-types/shared" "^3.27.0"
|
||||
|
||||
react-stately@3.36.1:
|
||||
version "3.36.1"
|
||||
resolved "https://registry.yarnpkg.com/react-stately/-/react-stately-3.36.1.tgz#605c18e6aa7a900f19b066699b5b35b7800cb759"
|
||||
integrity sha512-H9kiGAylNec/iE5qk7qQLV1cvtSAIVq3mgt87zx2EA+f+/sYy2oBtchFPaDiBf/m7xMEKf0Fr9zSLU6G99xQ8g==
|
||||
dependencies:
|
||||
"@react-stately/calendar" "^3.7.1"
|
||||
"@react-stately/checkbox" "^3.6.12"
|
||||
"@react-stately/collections" "^3.12.2"
|
||||
"@react-stately/color" "^3.8.3"
|
||||
"@react-stately/combobox" "^3.10.3"
|
||||
"@react-stately/data" "^3.12.2"
|
||||
"@react-stately/datepicker" "^3.13.0"
|
||||
"@react-stately/disclosure" "^3.0.2"
|
||||
"@react-stately/dnd" "^3.5.2"
|
||||
"@react-stately/form" "^3.1.2"
|
||||
"@react-stately/list" "^3.12.0"
|
||||
"@react-stately/menu" "^3.9.2"
|
||||
"@react-stately/numberfield" "^3.9.10"
|
||||
"@react-stately/overlays" "^3.6.14"
|
||||
"@react-stately/radio" "^3.10.11"
|
||||
"@react-stately/searchfield" "^3.5.10"
|
||||
"@react-stately/select" "^3.6.11"
|
||||
"@react-stately/selection" "^3.20.0"
|
||||
"@react-stately/slider" "^3.6.2"
|
||||
"@react-stately/table" "^3.14.0"
|
||||
"@react-stately/tabs" "^3.8.0"
|
||||
"@react-stately/toast" "^3.0.0"
|
||||
"@react-stately/toggle" "^3.8.2"
|
||||
"@react-stately/tooltip" "^3.5.2"
|
||||
"@react-stately/tree" "^3.8.8"
|
||||
"@react-types/shared" "^3.28.0"
|
||||
|
||||
react-stately@^3.34.0:
|
||||
version "3.36.0"
|
||||
resolved "https://registry.yarnpkg.com/react-stately/-/react-stately-3.36.0.tgz#1544f0a742145d9bc2d67a8c76af3648a9982fd6"
|
||||
|
||||
Reference in New Issue
Block a user