Compare commits

..

33 Commits

Author SHA1 Message Date
Anthony LC 9790824ce1 🔖(minor) minor release to 1.1.0
Added:
- 🤡(demo) generate dummy documents on dev users
- (frontend) create side modal component
- (frontend) Doc grid actions (update / delete)
- (frontend) Doc editor header information

Changed:
- ♻️(frontend) replace docs panel with docs grid
- ♻️(frontend) create a doc from a modal
- ♻️(frontend) manage members from the share modal
2024-07-15 17:48:31 +02:00
renovate[bot] ccadd9567a ⬆️(dependencies) update django to v5.0.7 [SECURITY] 2024-07-15 17:24:18 +02:00
Anthony LC 2c9758eb30 🌐(frontend) translate last features
Translate:
- doc datagrid
- share feature
- remove translations not used anymore
2024-07-15 17:01:09 +02:00
Anthony LC 0f71a3fcfa 💄(frontend) force light theme on BlockNote editor
The BlockNote editor theme was not being forced to
light mode. Depend the user theme preference,
the blocknote theme could be dark.
We force it to light mode.
2024-07-15 10:58:44 +02:00
Anthony LC 839b78a6d0 📱(frontend) improve responsive share modal
Improved the responsiveness of the share modal
on small devices.
2024-07-15 10:58:44 +02:00
Anthony LC 69f2641159 ♻️(frontend) list members from side modal
We refactorize the members grid to display
it inside the share side modal.
It is not a member grid anymore but a member list
with infinite scroll. We can directly update the
role or delete a member from the each row of the
list.
2024-07-12 15:41:32 +02:00
Anthony LC e5de5a4345 💄(frontend) create IconBG component
Create IconBG component, a premade Icon component
with a background color.
2024-07-12 15:41:32 +02:00
Anthony LC c2d6e60ae8 ♻️(frontend) add user from side modal
We move the add user functionality to a side modal.
The side modal is opened from the share button.
2024-07-12 15:41:32 +02:00
Anthony LC ff832239d3 (frontend) add doc editor panel header
Add doc editor panel header to show
the doc title and other info.
2024-07-10 14:11:27 +02:00
Anthony LC a60399883b 🏷️(frontend) improve props currentDocRole
props of currentDocRole is now more accurate.
2024-07-10 14:11:27 +02:00
Anthony LC 12d32fe933 🛂(frontend) don't show action buttons
Don't show actions buttons if the user
doesn't have permission to edit or delete
the document.
2024-07-10 14:11:27 +02:00
Anthony LC 038d868d29 (frontend) create hook useDate
Create hook useDate, it is an date helper.
We use it for now to format date.
2024-07-10 14:11:27 +02:00
Anthony LC bfde526361 🐛(i18n) fix key that contain colon
Keys that contains colon where not being
translated correctly. This was due to the
colon being used as a separator for the
key and the value. This was fixed by
replacing the colon with a different
character that is not used in the key
or the value.
2024-07-10 14:11:27 +02:00
Anthony LC 132da0837f 🐛(e2e) sync doc grid sorting check
The sorting check was not the same between
what django provide and what the e2e test
check, it was giving some flakiness.
Django seems to ignore the punctuation (space)
in its sorting.
We improve other test to be more robust
as well.
2024-07-09 14:14:27 +02:00
Anthony LC 164ed885ca 🎨(frontend) improve interface SideModal
Improve the interface of the SideModal
component.
Set the width and the side by default.
2024-07-09 14:14:27 +02:00
Anthony LC f5a35c66bb (frontend) add doc grid actions button
Add document action buttons to the document grid:
- update document
- delete document
2024-07-09 14:14:27 +02:00
Anthony LC 8100422776 📝(github-actions) rename workflows
Change name workflows.
2024-07-09 14:14:27 +02:00
Anthony LC f48e385d3d 🛂(docker) add user minio
Add a user on Minio container in docker-compose.
2024-07-09 14:14:27 +02:00
Anthony LC b5895bf297 ⬆️(frontend) fix blocknote conflicts
Different versions of blocknote
were installed, we pint the same version
in all packages.json.
2024-07-08 17:10:54 +02:00
Anthony LC 0fb0c71cfa ⬆️(mail) bump mail dependencies
Lot of dependencies were outdated, so
we updated them.
2024-07-08 17:10:54 +02:00
Anthony LC a0fea64630 (frontend) create side modal component
Create SideModal component for
displaying modal on the side of the screen.
This component is build above the Cunningham
component, so all the cunnighama props are
still available.
2024-07-08 16:52:22 +02:00
Anthony LC de922e1c04 ♻️(frontend) create a doc from a modal
We refacto the create doc feature to use a modal
instead of a page and a card component.
It is more consistent with the other features.
2024-07-08 15:04:35 +02:00
Anthony LC 8007c45a35 👔(service-worker) add new field to create doc
We now display the creation and modification date
of the document in the document grid, so when we
create a new document in offline mode we need to
set the dates as well.
2024-07-08 13:41:08 +02:00
Anthony LC 3d370e5714 💄(frontend) darken the background as the mockup
Darken the background as the mockup to make the
elements more visible.
2024-07-05 19:02:01 +02:00
Anthony LC 03953f0fe7 ️(frontend) use resetQueries instead of invalidateQueries
With the list of documents, invalidateQueries doesn't
refresh as expected the list of documents. We
will prefer resetQueries, it seems to be more
appropriate for this case.
2024-07-05 19:02:01 +02:00
Anthony LC 67f4ddeef7 (e2e) adapt e2e test without the panel
The datagrid replace the panel. Lot of tests
need to be adapted to this new architecture.
2024-07-05 19:02:01 +02:00
Anthony LC 6f4b4bb7d3 🔥(frontend) remove docs-panel feature
Remove docs-panel feature, we will replace it
with a datagrid.
2024-07-05 19:02:01 +02:00
Anthony LC c27e6c1b06 (frontend) create docs-grid feature
Create docs-grid feature. It will be used to display
the list of documents in a grid view.
Grid view are more useful to display lot of
information, we can easily sort the information.
2024-07-05 19:02:01 +02:00
Anthony LC d2888d0b9d (frontend) create hook useTransRole
Create the hook useTransRole to get a translated
role.
2024-07-05 19:02:01 +02:00
Anthony LC 6b8af1f9ec ♻️(backend) add more doc sorting
Update the viewset to be able to sort by:
- created date
- updated date
- title
2024-07-05 19:02:01 +02:00
Anthony LC 35852dff0b ♻️(backend) add more info to doc
Update the serializer to include more info
about the doc:
- created date
- updated date
2024-07-05 19:02:01 +02:00
Anthony LC be93598b2d 🌱(demo) create dev users and make them doc accesses
To be able to test with dummy data, we need to create
our dev users from the demo and to give them access to
the docs.
The sub is the unicity of the user for our oidc provider,
so we need to know the sub to be able to create
correctly the user, it is why we set the sub
as the email of the user in the realm.json file.
2024-07-05 19:02:01 +02:00
Anthony LC 7d3fd25c61 🤡(demo) demo generate dummy documents
The demo command will generate dummy documents
and dummy accesses.
2024-07-05 19:02:01 +02:00
130 changed files with 3716 additions and 4555 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
name: impress Workflow
name: Frontend Workflow
on:
push:
+1 -1
View File
@@ -1,4 +1,4 @@
name: impress Workflow
name: Main Workflow
on:
push:
+17 -1
View File
@@ -8,6 +8,21 @@ and this project adheres to
## [Unreleased]
## [1.1.0] - 2024-07-15
## Added
- 🤡(demo) generate dummy documents on dev users #120
- ✨(frontend) create side modal component #134
- ✨(frontend) Doc grid actions (update / delete) #136
- ✨(frontend) Doc editor header information #137
## Changed
- ♻️(frontend) replace docs panel with docs grid #120
- ♻️(frontend) create a doc from a modal #132
- ♻️(frontend) manage members from the share modal #140
## [1.0.0] - 2024-07-02
## Added
@@ -54,6 +69,7 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.0.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.1.0...main
[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
+15
View File
@@ -1339,6 +1339,21 @@
"jsonType.label": "String"
}
},
{
"id": "qb109597-e31e-46d7-7844-62e5fcf32ac8",
"name": "email sub",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "email",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "sub",
"jsonType.label": "String"
}
},
{
"id": "61c135e5-2447-494b-bc70-9612f383be27",
"name": "email verified",
+11 -2
View File
@@ -139,8 +139,17 @@ class DocumentSerializer(BaseResourceSerializer):
class Meta:
model = models.Document
fields = ["id", "content", "title", "accesses", "abilities", "is_public"]
read_only_fields = ["id", "accesses", "abilities"]
fields = [
"id",
"content",
"title",
"accesses",
"abilities",
"is_public",
"created_at",
"updated_at",
]
read_only_fields = ["id", "accesses", "abilities", "created_at", "updated_at"]
class TemplateSerializer(BaseResourceSerializer):
+1 -1
View File
@@ -160,7 +160,7 @@ class ResourceViewsetMixin:
"""Mixin with methods common to all resource viewsets that are managed with accesses."""
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at"]
ordering_fields = ["created_at", "updated_at", "title"]
ordering = ["-created_at"]
def get_queryset(self):
@@ -4,12 +4,14 @@ Tests for Documents API endpoint in impress's core app: list
from unittest import mock
import pytest
from faker import Faker
from rest_framework.pagination import PageNumberPagination
from rest_framework.status import HTTP_200_OK
from rest_framework.test import APIClient
from core import factories
fake = Faker()
pytestmark = pytest.mark.django_db
@@ -166,7 +168,7 @@ def test_api_documents_list_authenticated_distinct():
assert content["results"][0]["id"] == str(document.id)
def test_api_documents_order():
def test_api_documents_order_created_at_desc():
"""
Test that the endpoint GET documents is sorted in 'created_at' descending order by default.
"""
@@ -174,11 +176,13 @@ def test_api_documents_order():
client = APIClient()
client.force_login(user)
document_ids = [
str(document.id)
documents_created = [
document.created_at.isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
documents_created.sort(reverse=True)
response = client.get(
"/api/v1.0/documents/",
)
@@ -186,15 +190,16 @@ def test_api_documents_order():
assert response.status_code == 200
response_data = response.json()
response_document_ids = [document["id"] for document in response_data["results"]]
response_document_created = [
document["created_at"] for document in response_data["results"]
]
document_ids.reverse()
assert (
response_document_ids == document_ids
response_document_created == documents_created
), "created_at values are not sorted from newest to oldest"
def test_api_documents_order_param():
def test_api_documents_order_created_at_asc():
"""
Test that the 'created_at' field is sorted in ascending order
when the 'ordering' query parameter is set.
@@ -203,20 +208,152 @@ def test_api_documents_order_param():
client = APIClient()
client.force_login(user)
documents_ids = [
str(document.id)
documents_created = [
document.created_at.isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
response = APIClient().get(
documents_created.sort()
response = client.get(
"/api/v1.0/documents/?ordering=created_at",
)
assert response.status_code == 200
response_data = response.json()
response_document_created = [
document["created_at"] for document in response_data["results"]
]
assert (
response_document_created == documents_created
), "created_at values are not sorted from oldest to newest"
def test_api_documents_order_updated_at_desc():
"""
Test that the 'updated_at' field is sorted in descending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents_updated = [
document.updated_at.isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
documents_updated.sort(reverse=True)
response = APIClient().get(
"/api/v1.0/documents/?ordering=-updated_at",
)
assert response.status_code == 200
response_data = response.json()
response_document_ids = [document["id"] for document in response_data["results"]]
response_document_updated = [
document["updated_at"] for document in response_data["results"]
]
assert (
response_document_ids == documents_ids
), "created_at values are not sorted from oldest to newest"
response_document_updated == documents_updated
), "updated_at values are not sorted from newest to oldest"
def test_api_documents_order_updated_at_asc():
"""
Test that the 'updated_at' field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents_updated = [
document.updated_at.isoformat().replace("+00:00", "Z")
for document in factories.DocumentFactory.create_batch(5, is_public=True)
]
documents_updated.sort()
response = APIClient().get(
"/api/v1.0/documents/?ordering=updated_at",
)
assert response.status_code == 200
response_data = response.json()
response_document_updated = [
document["updated_at"] for document in response_data["results"]
]
assert (
response_document_updated == documents_updated
), "updated_at values are not sorted from oldest to newest"
def test_api_documents_order_title_desc():
"""
Test that the 'title' field is sorted in descending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents_title = [
factories.DocumentFactory(is_public=True, title=fake.sentence(nb_words=4)).title
for _ in range(5)
]
documents_title.sort(reverse=True)
response = APIClient().get(
"/api/v1.0/documents/?ordering=-title",
)
assert response.status_code == 200
response_data = response.json()
response_documents_title = [
document["title"] for document in response_data["results"]
]
assert (
response_documents_title == documents_title
), "title values are not sorted descending"
def test_api_documents_order_title_asc():
"""
Test that the 'title' field is sorted in ascending order
when the 'ordering' query parameter is set.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
documents_title = [
factories.DocumentFactory(is_public=True, title=fake.sentence(nb_words=4)).title
for _ in range(5)
]
documents_title.sort()
response = APIClient().get(
"/api/v1.0/documents/?ordering=title",
)
assert response.status_code == 200
response_data = response.json()
response_documents_title = [
document["title"] for document in response_data["results"]
]
assert (
response_documents_title == documents_title
), "title values are not sorted ascending"
@@ -33,6 +33,8 @@ def test_api_documents_retrieve_anonymous_public():
"title": document.title,
"is_public": True,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -78,6 +80,8 @@ def test_api_documents_retrieve_authenticated_unrelated_public():
"title": document.title,
"is_public": True,
"content": document.content,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -146,6 +150,8 @@ def test_api_documents_retrieve_authenticated_related_direct():
"content": document.content,
"abilities": document.get_abilities(user),
"is_public": document.is_public,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -277,6 +283,8 @@ def test_api_documents_retrieve_authenticated_related_team_members(
"content": document.content,
"abilities": document.get_abilities(user),
"is_public": False,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -394,6 +402,8 @@ def test_api_documents_retrieve_authenticated_related_team_administrators(
"content": document.content,
"abilities": document.get_abilities(user),
"is_public": False,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -515,4 +525,6 @@ def test_api_documents_retrieve_authenticated_related_team_owners(
"content": document.content,
"abilities": document.get_abilities(user),
"is_public": False,
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
@@ -141,8 +141,10 @@ def test_api_documents_update_authenticated_editor_administrator_or_owner(
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses"]:
if key in ["id", "accesses", "created_at"]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
else:
assert value == new_document_values[key]
@@ -178,8 +180,10 @@ def test_api_documents_update_authenticated_owners(via, mock_user_get_teams):
document = models.Document.objects.get(pk=document.pk)
document_values = serializers.DocumentSerializer(instance=document).data
for key, value in document_values.items():
if key in ["id", "accesses"]:
if key in ["id", "accesses", "created_at"]:
assert value == old_document_values[key]
elif key == "updated_at":
assert value > old_document_values[key]
else:
assert value == new_document_values[key]
+19 -1
View File
@@ -1,5 +1,23 @@
"""Parameters that define how the demo site will be built."""
NB_OBJECTS = {
"users": 100,
"users": 50,
"docs": 50,
"max_users_per_document": 50,
}
DEV_USERS = [
{
"username": "impress",
"email": "impress@impress.world",
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.e2e",
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.e2e",
},
{"username": "user-e2e-chromium", "email": "user@chromium.e2e"},
]
@@ -125,6 +125,65 @@ def create_demo(stdout):
)
queue.flush()
with Timeit(stdout, "Creating documents"):
for _ in range(defaults.NB_OBJECTS["docs"]):
queue.push(
models.Document(
title=fake.sentence(nb_words=4),
is_public=random_true_with_probability(0.5),
)
)
queue.flush()
with Timeit(stdout, "Creating docs accesses"):
docs_ids = list(models.Document.objects.values_list("id", flat=True))
users_ids = list(models.User.objects.values_list("id", flat=True))
for doc_id in docs_ids:
for user_id in random.sample(
users_ids,
random.randint(1, defaults.NB_OBJECTS["max_users_per_document"]),
):
role = random.choice(models.RoleChoices.choices)
queue.push(
models.DocumentAccess(
document_id=doc_id, user_id=user_id, role=role[0]
)
)
queue.flush()
with Timeit(stdout, "Creating development users"):
for dev_user in defaults.DEV_USERS:
queue.push(
models.User(
admin_email=dev_user["email"],
email=dev_user["email"],
sub=dev_user["email"],
password="!",
is_superuser=False,
is_active=True,
is_staff=False,
language=random.choice(settings.LANGUAGES)[0],
)
)
queue.flush()
with Timeit(stdout, "Creating docs accesses on development users"):
for dev_user in defaults.DEV_USERS:
docs_ids = list(models.Document.objects.values_list("id", flat=True))
user_id = models.User.objects.get(email=dev_user["email"]).id
for doc_id in docs_ids:
role = random.choice(models.RoleChoices.choices)
queue.push(
models.DocumentAccess(
document_id=doc_id, user_id=user_id, role=role[0]
)
)
queue.flush()
with Timeit(stdout, "Creating Template"):
with open(
file="demo/data/template/code.txt", mode="r", encoding="utf-8"
@@ -16,3 +16,16 @@ def test_commands_create_demo():
call_command("create_demo")
assert models.Template.objects.count() == 1
assert models.User.objects.count() >= 50
assert models.Document.objects.count() >= 50
assert models.DocumentAccess.objects.count() > 50
# assert dev users have doc accesses
user = models.User.objects.get(email="impress@impress.world")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@webkit.e2e")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@firefox.e2e")
assert models.DocumentAccess.objects.filter(user=user).exists()
user = models.User.objects.get(email="user@chromium.e2e")
assert models.DocumentAccess.objects.filter(user=user).exists()
+2 -2
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.0.0"
version = "1.1.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -36,7 +36,7 @@ dependencies = [
"django-redis==5.4.0",
"django-storages[s3]==1.14.2",
"django-timezone-field>=5.1",
"django==5.0.3",
"django==5.0.7",
"djangorestframework==3.14.0",
"drf_spectacular==0.26.5",
"dockerflow==2022.8.0",
@@ -33,7 +33,6 @@ export const createDoc = async (
length: number,
isPublic: boolean = false,
) => {
const panel = page.getByLabel('Documents panel').first();
const buttonCreate = page.getByRole('button', {
name: 'Create the document',
});
@@ -41,8 +40,20 @@ export const createDoc = async (
const randomDocs = randomName(docName, browserName, length);
for (let i = 0; i < randomDocs.length; i++) {
await panel.getByRole('button', { name: 'Add a document' }).click();
await page.getByText('Document name').fill(randomDocs[i]);
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await buttonCreateHomepage.click();
// Fill input
await page
.getByRole('textbox', {
name: 'Document name',
})
.fill(randomDocs[i]);
if (isPublic) {
await page.getByText('Is it public ?').click();
@@ -50,45 +61,17 @@ export const createDoc = async (
await expect(buttonCreate).toBeEnabled();
await buttonCreate.click();
await expect(panel.locator('li').getByText(randomDocs[i])).toBeVisible();
await expect(page.locator('h2').getByText(randomDocs[i])).toBeVisible();
}
return randomDocs;
};
export const createTemplate = async (
page: Page,
templateName: string,
browserName: string,
length: number,
) => {
const menu = page.locator('menu').first();
await menu.getByLabel(`Template button`).click();
const panel = page.getByLabel('Templates panel').first();
const buttonCreate = page.getByRole('button', {
name: 'Create the template',
});
const randomTemplates = randomName(templateName, browserName, length);
for (let i = 0; i < randomTemplates.length; i++) {
await panel.getByRole('button', { name: 'Add a template' }).click();
await page.getByText('Template name').fill(randomTemplates[i]);
await expect(buttonCreate).toBeEnabled();
await buttonCreate.click();
await expect(
panel.locator('li').getByText(randomTemplates[i]),
).toBeVisible();
}
return randomTemplates;
};
export const addNewMember = async (
page: Page,
index: number,
role: 'Admin' | 'Owner' | 'Member',
role: 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader',
fillText: string = 'user',
) => {
const responsePromiseSearchUser = page.waitForResponse(
@@ -97,9 +80,6 @@ export const addNewMember = async (
response.status() === 200,
);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
// Select a new user
@@ -115,8 +95,8 @@ export const addNewMember = async (
await page.getByRole('option', { name: users[index].email }).click();
// Choose a role
await page.getByRole('radio', { name: role }).click();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: role }).click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
@@ -125,3 +105,38 @@ export const addNewMember = async (
return users[index].email;
};
interface GoToGridDocOptions {
nthRow?: number;
title?: string;
}
export const goToGridDoc = async (
page: Page,
{ nthRow = 1, title }: GoToGridDocOptions = {},
) => {
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
const rows = datagrid.getByRole('row');
const row = title
? rows.filter({
hasText: title,
})
: rows.nth(nthRow);
const docTitleCell = row.getByRole('cell').nth(1);
const docTitle = await docTitleCell.textContent();
expect(docTitle).toBeDefined();
await docTitleCell.click();
return docTitle as string;
};
@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
@@ -12,18 +14,12 @@ test.describe('Doc Create', () => {
await buttonCreateHomepage.click();
await expect(buttonCreateHomepage).toBeHidden();
const card = page.getByLabel('Create new document card').first();
await expect(card.getByLabel('Document name')).toBeVisible();
await expect(card.getByLabel('icon group')).toBeVisible();
const card = page.getByRole('dialog').first();
await expect(
card.getByRole('heading', {
name: 'Name the document',
level: 3,
}),
card.locator('h2').getByText('Create a new document'),
).toBeVisible();
await expect(card.getByLabel('Document name')).toBeVisible();
await expect(card.getByText('Is it public ?')).toBeVisible();
@@ -33,11 +29,7 @@ test.describe('Doc Create', () => {
}),
).toBeVisible();
await expect(
card.getByRole('button', {
name: 'Cancel',
}),
).toBeVisible();
await expect(card.getByLabel('Close the modal')).toBeVisible();
});
test('checks the cancel button interaction', async ({ page }) => {
@@ -47,84 +39,37 @@ test.describe('Doc Create', () => {
await buttonCreateHomepage.click();
await expect(buttonCreateHomepage).toBeHidden();
const card = page.getByLabel('Create new document card').first();
const card = page.getByRole('dialog').first();
await card
.getByRole('button', {
name: 'Cancel',
})
.click();
await card.getByLabel('Close the modal').click();
await expect(buttonCreateHomepage).toBeVisible();
});
test('checks the routing on new doc created', async ({
page,
browserName,
}) => {
const panel = page.getByLabel('Documents panel').first();
await panel.getByRole('button', { name: 'Add a document' }).click();
const docName = `My routing doc ${browserName}-${Math.floor(Math.random() * 1000)}`;
await page.getByText('Document name').fill(docName);
await page.getByRole('button', { name: 'Create the document' }).click();
const elDoc = page.locator('h2').getByText(docName);
await expect(elDoc).toBeVisible();
await panel.getByRole('button', { name: 'Add a document' }).click();
await expect(elDoc).toBeHidden();
await panel.locator('li').getByText(docName).click();
await expect(elDoc).toBeVisible();
});
test('checks alias docs url with homepage', async ({ page }) => {
await expect(page).toHaveURL('/');
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await expect(buttonCreateHomepage).toBeVisible();
await page.goto('/docs');
await expect(buttonCreateHomepage).toBeVisible();
await expect(page).toHaveURL(/\/docs$/);
});
test('checks 404 on docs/[id] page', async ({ page }) => {
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(300);
await page.goto('/docs/some-unknown-doc');
await expect(
page.getByText(
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
),
).toBeVisible({
timeout: 15000,
});
});
test('checks that the doc is public', async ({ page, browserName }) => {
const responsePromiseDoc = page.waitForResponse(
(response) =>
response.url().includes('/documents/') && response.status() === 201,
test('create a new public doc', async ({ page, browserName }) => {
const [docTitle] = await createDoc(
page,
'My new doc',
browserName,
1,
true,
);
const panel = page.getByLabel('Documents panel').first();
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
await panel.getByRole('button', { name: 'Add a document' }).click();
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const docName = `My routing doc ${browserName}-${Math.floor(Math.random() * 1000)}`;
await page.getByText('Document name').fill(docName);
await page.getByText('Is it public ?').click();
await page.getByRole('button', { name: 'Create the document' }).click();
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
const responseDoc = await responsePromiseDoc;
const is_public = (await responseDoc.json()).is_public;
expect(is_public).toBeTruthy();
await expect(datagrid.getByText(docTitle)).toBeVisible();
const row = datagrid.getByRole('row').filter({
hasText: docTitle,
});
await expect(row.getByRole('cell').nth(0)).toHaveText('Public');
});
});
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
import { createDoc, goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -76,26 +76,18 @@ test.describe('Doc Editor', () => {
test('it renders correctly when we switch from one doc to another', async ({
page,
browserName,
}) => {
const [firstDoc, secondDoc] = await createDoc(
page,
'doc-multiple',
browserName,
2,
);
const panel = page.getByLabel('Documents panel').first();
// Check the first doc
await panel.getByText(firstDoc).click();
const firstDoc = await goToGridDoc(page);
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World Doc 1');
await expect(page.getByText('Hello World Doc 1')).toBeVisible();
// Check the second doc
await panel.getByText(secondDoc).click();
const secondDoc = await goToGridDoc(page, {
nthRow: 2,
});
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await expect(page.getByText('Hello World Doc 1')).toBeHidden();
await page.locator('.ProseMirror.bn-editor').click();
@@ -103,22 +95,17 @@ test.describe('Doc Editor', () => {
await expect(page.getByText('Hello World Doc 2')).toBeVisible();
// Check the first doc again
await panel.getByText(firstDoc).click();
await goToGridDoc(page, {
title: firstDoc,
});
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
await expect(page.getByText('Hello World Doc 2')).toBeHidden();
await expect(page.getByText('Hello World Doc 1')).toBeVisible();
});
test('it saves the doc when we change pages', async ({
page,
browserName,
}) => {
const [doc] = await createDoc(page, 'doc-save-page', browserName, 1);
const panel = page.getByLabel('Documents panel').first();
test('it saves the doc when we change pages', async ({ page }) => {
// Check the first doc
await panel.getByText(doc).click();
const doc = await goToGridDoc(page);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page
@@ -126,27 +113,19 @@ test.describe('Doc Editor', () => {
.fill('Hello World Doc persisted 1');
await expect(page.getByText('Hello World Doc persisted 1')).toBeVisible();
await panel
.getByRole('button', {
name: 'Add a document',
})
.click();
const secondDoc = await goToGridDoc(page, {
nthRow: 2,
});
await expect(
page.getByText(`Your document "${doc}" has been saved.`),
).toBeVisible();
const card = page.getByLabel('Create new document card').first();
await expect(
card.getByRole('heading', {
name: 'Name the document',
level: 3,
}),
).toBeVisible();
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await page.goto('/');
await panel.getByText(doc).click();
await goToGridDoc(page, {
title: doc,
});
await expect(page.getByText('Hello World Doc persisted 1')).toBeVisible();
});
@@ -155,12 +134,8 @@ test.describe('Doc Editor', () => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(browserName === 'webkit', 'This test is very flaky with webkit');
const [doc] = await createDoc(page, 'doc-save-quit', browserName, 1);
const panel = page.getByLabel('Documents panel').first();
// Check the first doc
await panel.getByText(doc).click();
const doc = await goToGridDoc(page);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page
@@ -170,12 +145,14 @@ test.describe('Doc Editor', () => {
await page.goto('/');
await panel.getByText(doc).click();
await goToGridDoc(page, {
title: doc,
});
await expect(page.getByText('Hello World Doc persisted 2')).toBeVisible();
});
test('it cannot edit if viewer', async ({ page, browserName }) => {
test('it cannot edit if viewer', async ({ page }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
@@ -206,7 +183,7 @@ test.describe('Doc Editor', () => {
}
});
await createDoc(page, 'doc-right-edit', browserName, 1);
await goToGridDoc(page);
await expect(
page.getByText(
@@ -0,0 +1,249 @@
import { expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Documents Grid', () => {
test('checks all the elements are visible', async ({ page }) => {
await expect(page.locator('h2').getByText('Documents')).toBeVisible();
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const thead = datagrid.locator('thead');
await expect(thead.getByText(/Document name/i)).toBeVisible();
await expect(thead.getByText(/Created at/i)).toBeVisible();
await expect(thead.getByText(/Updated at/i)).toBeVisible();
await expect(thead.getByText(/Your role/i)).toBeVisible();
await expect(thead.getByText(/Users number/i)).toBeVisible();
const row1 = datagrid.getByRole('row').nth(1).getByRole('cell');
const docName = await row1.nth(1).textContent();
expect(docName).toBeDefined();
const docCreatedAt = await row1.nth(2).textContent();
expect(docCreatedAt).toBeDefined();
const docUpdatedAt = await row1.nth(3).textContent();
expect(docUpdatedAt).toBeDefined();
const docRole = await row1.nth(4).textContent();
expect(
docRole &&
['Administrator', 'Owner', 'Reader', 'Editor'].includes(docRole),
).toBeTruthy();
const docUserNumber = await row1.nth(5).textContent();
expect(docUserNumber).toBeDefined();
// Open the document
await row1.nth(1).click();
await expect(page.locator('h2').getByText(docName!)).toBeVisible();
});
[
{
nameColumn: 'Document name',
ordering: 'title',
cellNumber: 1,
},
{
nameColumn: 'Created at',
ordering: 'created_at',
cellNumber: 2,
},
{
nameColumn: 'Updated at',
ordering: 'updated_at',
cellNumber: 3,
},
].forEach(({ nameColumn, ordering, cellNumber }) => {
test(`checks datagrid ordering ${ordering}`, async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1`) &&
response.status() === 200,
);
const responsePromiseOrderingDesc = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1&ordering=-${ordering}`) &&
response.status() === 200,
);
const responsePromiseOrderingAsc = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1&ordering=${ordering}`) &&
response.status() === 200,
);
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const thead = datagrid.locator('thead');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const docNameRow1 = datagrid
.getByRole('row')
.nth(1)
.getByRole('cell')
.nth(cellNumber);
const docNameRow2 = datagrid
.getByRole('row')
.nth(2)
.getByRole('cell')
.nth(cellNumber);
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
// Initial state
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const initialDocNameRow1 = await docNameRow1.textContent();
const initialDocNameRow2 = await docNameRow2.textContent();
expect(initialDocNameRow1).toBeDefined();
expect(initialDocNameRow2).toBeDefined();
// Ordering ASC
await thead.getByText(nameColumn).click();
const responseOrderingAsc = await responsePromiseOrderingAsc;
expect(responseOrderingAsc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Asc = await docNameRow1.textContent();
const textDocNameRow2Asc = await docNameRow2.textContent();
expect(
textDocNameRow1Asc &&
textDocNameRow2Asc &&
textDocNameRow1Asc.localeCompare(textDocNameRow2Asc, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
}) <= 0,
).toBeTruthy();
// Ordering Desc
await thead.getByText(nameColumn).click();
const responseOrderingDesc = await responsePromiseOrderingDesc;
expect(responseOrderingDesc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden();
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Desc = await docNameRow1.textContent();
const textDocNameRow2Desc = await docNameRow2.textContent();
expect(
textDocNameRow1Desc &&
textDocNameRow2Desc &&
textDocNameRow1Desc.localeCompare(textDocNameRow2Desc, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
}) >= 0,
).toBeTruthy();
});
});
test('checks the pagination', async ({ page }) => {
const responsePromisePage1 = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1`) &&
response.status() === 200,
);
const responsePromisePage2 = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=2`) &&
response.status() === 200,
);
const datagridPage1 = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const responsePage1 = await responsePromisePage1;
expect(responsePage1.ok()).toBeTruthy();
await expect(
datagridPage1.getByRole('row').nth(1).getByRole('cell').nth(1),
).toHaveText(/.*/);
await page.getByLabel('Go to page 2').click();
const datagridPage2 = page
.getByLabel('Datagrid of the documents page 2')
.getByRole('table');
const responsePage2 = await responsePromisePage2;
expect(responsePage2.ok()).toBeTruthy();
await expect(
datagridPage2.getByRole('row').nth(1).getByRole('cell').nth(1),
).toHaveText(/.*/);
});
test('it updates document', async ({ page }) => {
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const docRow = datagrid.getByRole('row').nth(1).getByRole('cell');
const docName = await docRow.nth(1).textContent();
await docRow.getByLabel('Open the document options').click();
await page.getByText('Update document').click();
await page.getByLabel('Document name').fill(`${docName} updated`);
await page.getByText('Validate the modification').click();
await expect(datagrid.getByText(`${docName} updated`)).toBeVisible();
});
test('it deletes the document', async ({ page }) => {
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const docRow = datagrid.getByRole('row').nth(1).getByRole('cell');
const docName = await docRow.nth(1).textContent();
await docRow.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Delete document',
})
.click();
await expect(
page.locator('h2').getByText(`Deleting the document "${docName}"`),
).toBeVisible();
await page
.getByRole('button', {
name: 'Confirm deletion',
})
.click();
await expect(
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(datagrid.getByText(docName!)).toBeHidden();
});
});
@@ -0,0 +1,81 @@
import { expect, test } from '@playwright/test';
import { goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Header', () => {
test('it checks the element are correctly displayed', async ({ page }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super@owner.com',
},
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'admin',
user: {
email: 'super@admin.com',
},
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super2@owner.com',
},
},
],
abilities: {
destroy: true, // Means owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
update: true,
partial_update: true,
retrieve: true,
},
is_public: true,
created_at: '2021-09-01T09:00:00Z',
},
});
} else {
await route.continue();
}
});
await goToGridDoc(page);
const card = page.getByLabel(
'It is the card information about the document.',
);
await expect(card.locator('a').getByText('home')).toBeVisible();
await expect(card.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(card.getByText('Public')).toBeVisible();
await expect(
card.getByText('Created at 09/01/2021, 11:00 AM'),
).toBeVisible();
await expect(
card.getByText('Owners: super@owner.com / super2@owner.com'),
).toBeVisible();
await expect(card.getByText('Your role: Owner')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
});
@@ -6,7 +6,7 @@ test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Document add users', () => {
test.describe('Document create member', () => {
test('it selects 2 users and 1 invitation', async ({ page, browserName }) => {
const responsePromise = page.waitForResponse(
(response) =>
@@ -14,8 +14,7 @@ test.describe('Document add users', () => {
);
await createDoc(page, 'select-multi-users', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
await expect(inputSearch).toBeVisible();
@@ -56,12 +55,14 @@ test.describe('Document add users', () => {
await expect(page.getByLabel(`Remove ${email}`)).toBeVisible();
// Check roles are displayed
await expect(page.getByText(/Choose a role/)).toBeVisible();
await expect(page.getByRole('radio', { name: 'Reader' })).toBeChecked();
await expect(page.getByRole('radio', { name: 'Owner' })).toBeVisible();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await expect(page.getByRole('option', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Editor' })).toBeVisible();
await expect(
page.getByRole('radio', { name: 'Administrator' }),
page.getByRole('option', { name: 'Administrator' }),
).toBeVisible();
await expect(page.getByRole('option', { name: 'Owner' })).toBeVisible();
});
test('it sends a new invitation and adds a new user', async ({
@@ -75,8 +76,7 @@ test.describe('Document add users', () => {
await createDoc(page, 'user-invitation', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
@@ -93,7 +93,8 @@ test.describe('Document add users', () => {
await page.getByRole('option', { name: user.email }).click();
// Choose a role
await page.getByRole('radio', { name: 'Administrator' }).click();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -127,8 +128,7 @@ test.describe('Document add users', () => {
await createDoc(page, 'user-twice', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
await inputSearch.fill('user');
@@ -139,7 +139,8 @@ test.describe('Document add users', () => {
await page.getByRole('option', { name: user.email }).click();
// Choose a role
await page.getByRole('radio', { name: 'Owner' }).click();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Owner' }).click();
const responsePromiseAddMember = page.waitForResponse(
(response) =>
@@ -154,10 +155,8 @@ test.describe('Document add users', () => {
const responseAddMember = await responsePromiseAddMember;
expect(responseAddMember.ok()).toBeTruthy();
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await inputSearch.fill('user');
await expect(page.getByText('Loading...')).toBeHidden();
await expect(page.getByRole('option', { name: user.email })).toBeHidden();
});
@@ -167,8 +166,7 @@ test.describe('Document add users', () => {
}) => {
await createDoc(page, 'invitation-twice', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
@@ -177,7 +175,8 @@ test.describe('Document add users', () => {
await page.getByRole('option', { name: email }).click();
// Choose a role
await page.getByRole('radio', { name: 'Owner' }).click();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
@@ -191,10 +190,8 @@ test.describe('Document add users', () => {
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Add members' }).click();
await inputSearch.fill(email);
await expect(page.getByText('Loading...')).toBeHidden();
await expect(page.getByRole('option', { name: email })).toBeHidden();
});
});
@@ -1,192 +0,0 @@
import { expect, test } from '@playwright/test';
import { addNewMember, createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Members Delete', () => {
test('it cannot delete himself when it is the last owner', async ({
page,
browserName,
}) => {
await createDoc(page, 'member-delete-1', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
const cells = table.getByRole('row').nth(1).getByRole('cell');
await expect(cells.nth(0)).toHaveText(
new RegExp(`user@${browserName}.e2e`, 'i'),
);
await cells.nth(2).getByLabel('Member options').click();
await page.getByLabel('Open the modal to delete this member').click();
await expect(
page.getByText(
'You are the last owner, you cannot be removed from your document.',
),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Validate' })).toBeDisabled();
});
test('it deletes himself when it is not the last owner', async ({
page,
browserName,
}) => {
await createDoc(page, 'member-delete-2', browserName, 1);
await addNewMember(page, 0, 'Owner');
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
// find row where regexp match the name
const cells = table
.getByRole('row')
.filter({ hasText: new RegExp(`user@${browserName}.e2e`, 'i') })
.getByRole('cell');
await cells.nth(2).getByLabel('Member options').click();
await page.getByLabel('Open the modal to delete this member').click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`The member has been removed from the document`),
).toBeVisible();
await expect(
page.getByRole('button', { name: `Create a new document` }),
).toBeVisible();
});
test('it cannot delete owner member', async ({ page, browserName }) => {
await createDoc(page, 'member-delete-3', browserName, 1);
const username = await addNewMember(page, 0, 'Owner');
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
// find row where regexp match the name
const cells = table
.getByRole('row')
.filter({ hasText: username })
.getByRole('cell');
await cells.getByLabel('Member options').click();
await page.getByLabel('Open the modal to delete this member').click();
await expect(
page.getByText(`You cannot remove other owner.`),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Validate' })).toBeDisabled();
});
test('it deletes admin member', async ({ page, browserName }) => {
await createDoc(page, 'member-delete-4', browserName, 1);
const username = await addNewMember(page, 0, 'Admin');
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
// find row where regexp match the name
const cells = table
.getByRole('row')
.filter({ hasText: username })
.getByRole('cell');
await cells.getByLabel('Member options').click();
await page.getByLabel('Open the modal to delete this member').click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`The member has been removed from the document`),
).toBeVisible();
await expect(table.getByText(username)).toBeHidden();
});
test('it cannot delete owner member when admin', async ({
page,
browserName,
}) => {
await createDoc(page, 'member-delete-5', browserName, 1);
const username = await addNewMember(page, 0, 'Owner');
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
// find row where regexp match the name
const myCells = table
.getByRole('row')
.filter({ hasText: new RegExp(`user@${browserName}.e2e`, 'i') })
.getByRole('cell');
await myCells.getByLabel('Member options').click();
// Change role to Admin
await page.getByText('Update role').click();
const radioGroup = page.getByLabel('Radio buttons to update the roles');
await radioGroup.getByRole('radio', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Validate' }).click();
const cells = table
.getByRole('row')
.filter({ hasText: username })
.getByRole('cell');
await expect(cells.getByLabel('Member options')).toBeHidden();
});
test('it deletes admin member when admin', async ({ page, browserName }) => {
await createDoc(page, 'member-delete-6', browserName, 1);
// To not be the only owner
await addNewMember(page, 0, 'Owner');
const username = await addNewMember(page, 1, 'Admin');
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
const table = page.getByLabel('List members card').getByRole('table');
// find row where regexp match the name
const myCells = table
.getByRole('row')
.filter({ hasText: new RegExp(`user@${browserName}.e2e`, 'i') })
.getByRole('cell');
await myCells.getByLabel('Member options').click();
// Change role to Admin
await page.getByText('Update role').click();
const radioGroup = page.getByLabel('Radio buttons to update the roles');
await radioGroup.getByRole('radio', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(page.getByText(`The role has been updated`)).toBeVisible();
await expect(page.getByText(`The role has been updated`)).toBeHidden({
timeout: 5000,
});
const cells = table
.getByRole('row')
.filter({ hasText: new RegExp(username, 'i') })
.getByRole('cell');
await cells.nth(2).getByLabel('Member options').click();
await page.getByLabel('Open the modal to delete this member').click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`The member has been removed from the document`),
).toBeVisible();
await expect(table.getByText(username)).toBeHidden();
});
});
@@ -1,113 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Document grid members', () => {
test('it display the grid', async ({ page, browserName }) => {
await createDoc(page, 'grid-display', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
await expect(page.getByText('Members of the document')).toBeVisible();
const table = page.getByLabel('List members card').getByRole('table');
const thead = table.locator('thead');
await expect(thead.getByText(/Emails/i)).toBeVisible();
await expect(thead.getByText(/Roles/i)).toBeVisible();
const cells = table.getByRole('row').nth(1).getByRole('cell');
await expect(cells.nth(0)).toHaveText(`user@${browserName}.e2e`);
await expect(cells.nth(1)).toHaveText(/Owner/i);
await expect(cells.nth(2)).toHaveAccessibleName(
'Open the member options modal',
);
});
test('it display the grid with many members', async ({
page,
browserName,
}) => {
await page.route('**/documents/*/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: true,
manage_accesses: true,
partial_update: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
});
await page.route(
'**/documents/b0df4343-c8bd-4c20-9ff6-fbf94fc94egg/accesses/?page=*',
async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page');
const accesses = {
count: 100,
next: null,
previous: null,
results: Array.from({ length: 20 }, (_, i) => ({
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
user: {
id: `fc092149-cafa-4ffa-a29d-e4b18af751-${pageId}-${i}`,
email: `impress@impress.world-page-${pageId}-${i}`,
},
team: '',
role: 'owner',
abilities: {
destroy: false,
partial_update: true,
},
})),
};
if (request.method().includes('GET')) {
await route.fulfill({
json: accesses,
});
} else {
await route.continue();
}
},
);
await createDoc(page, 'grid-no-member', browserName, 1);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Manage members' }).click();
await expect(
page.getByText('impress@impress.world-page-1-19'),
).toBeVisible();
await page.getByLabel('Go to page 4').click();
await expect(
page.getByText('impress@impress.world-page-1-19'),
).toBeHidden();
await expect(
page.getByText('impress@impress.world-page-4-19'),
).toBeVisible();
});
});
@@ -0,0 +1,164 @@
import { expect, test } from '@playwright/test';
import { waitForElementCount } from '../helpers';
import { addNewMember, createDoc, goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Document list members', () => {
test('it checks a big list of members', async ({ page }) => {
await page.route(
/.*\/documents\/.*\/accesses\/\?page=.*/,
async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page');
const accesses = {
count: 100,
next: 'http://anything/?page=2',
previous: null,
results: Array.from({ length: 20 }, (_, i) => ({
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
user: {
id: `fc092149-cafa-4ffa-a29d-e4b18af751-${pageId}-${i}`,
email: `impress@impress.world-page-${pageId}-${i}`,
},
team: '',
role: 'editor',
abilities: {
destroy: false,
partial_update: true,
set_role_to: [],
},
})),
};
if (request.method().includes('GET')) {
await route.fulfill({
json: accesses,
});
} else {
await route.continue();
}
},
);
await goToGridDoc(page);
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List members card').locator('ul');
await expect(list.locator('li')).toHaveCount(20);
await list.getByText(`impress@impress.world-page-${1}-18`).hover();
await page.mouse.wheel(0, 10);
await waitForElementCount(list.locator('li'), 21, 10000);
expect(await list.locator('li').count()).toBeGreaterThan(20);
await expect(
list.getByText(`impress@impress.world-page-1-16`),
).toBeVisible();
await expect(
list.getByText(`impress@impress.world-page-2-15`),
).toBeVisible();
});
test('it checks the role rules', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List members card').locator('ul');
await expect(list.getByText(`user@${browserName}.e2e`)).toBeVisible();
const soleOwner = list.getByText(
`You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.`,
);
await expect(soleOwner).toBeVisible();
const username = await addNewMember(page, 0, 'Owner');
await expect(list.getByText(username)).toBeVisible();
await expect(soleOwner).toBeHidden();
const otherOwner = list.getByText(
`You cannot update the role or remove other owner.`,
);
await expect(otherOwner).toBeVisible();
const SelectRoleCurrentUser = list
.locator('li')
.filter({
hasText: `user@${browserName}.e2e`,
})
.getByRole('combobox', { name: 'Role' });
await SelectRoleCurrentUser.click();
await page.getByRole('option', { name: 'Administrator' }).click();
await expect(page.getByText('The role has been updated')).toBeVisible();
// Admin still have the right to share
await expect(page.locator('h3').getByText('Share')).toBeVisible();
await SelectRoleCurrentUser.click();
await page.getByRole('option', { name: 'Reader' }).click();
await expect(page.getByText('The role has been updated')).toBeVisible();
// Reader does not have the right to share
await expect(page.locator('h3').getByText('Share')).toBeHidden();
});
test('it checks the delete members', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByLabel('List members card').locator('ul');
const nameMyself = `user@${browserName}.e2e`;
await expect(list.getByText(nameMyself)).toBeVisible();
const userOwner = await addNewMember(page, 0, 'Owner');
await expect(list.getByText(userOwner)).toBeVisible();
const userReader = await addNewMember(page, 0, 'Reader');
await expect(list.getByText(userReader)).toBeVisible();
await list
.locator('li')
.filter({
hasText: userReader,
})
.getByText('delete')
.click();
await expect(list.getByText(userReader)).toBeHidden();
await list
.locator('li')
.filter({
hasText: nameMyself,
})
.getByText('delete')
.click();
await expect(list.getByText(nameMyself)).toBeHidden();
await expect(
page.getByText('The member has been removed from the document').first(),
).toBeVisible();
await expect(page.getByText('Share')).toBeHidden();
});
});
@@ -1,159 +0,0 @@
import { expect, test } from '@playwright/test';
import { waitForElementCount } from '../helpers';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Documents Panel', () => {
test('checks all the elements are visible', async ({ page }) => {
const panel = page.getByLabel('Documents panel').first();
await expect(panel.getByText('Documents')).toBeVisible();
await expect(
panel.getByRole('button', {
name: 'Sort the documents',
}),
).toBeVisible();
await expect(
panel.getByRole('button', {
name: 'Add a document',
}),
).toBeVisible();
});
test('checks the sort button', async ({ page }) => {
const responsePromiseSortDesc = page.waitForResponse(
(response) =>
response.url().includes('/documents/?page=1&ordering=-created_at') &&
response.status() === 200,
);
const responsePromiseSortAsc = page.waitForResponse(
(response) =>
response.url().includes('/documents/?page=1&ordering=created_at') &&
response.status() === 200,
);
const panel = page.getByLabel('Documents panel').first();
await panel
.getByRole('button', {
name: 'Sort the documents by creation date ascendent',
})
.click();
const responseSortAsc = await responsePromiseSortAsc;
expect(responseSortAsc.ok()).toBeTruthy();
await panel
.getByRole('button', {
name: 'Sort the documents by creation date descendent',
})
.click();
const responseSortDesc = await responsePromiseSortDesc;
expect(responseSortDesc.ok()).toBeTruthy();
});
test('checks the infinite scroll', async ({ page }) => {
await page.route(
/.*\/documents\/\?page=.*&ordering=-created_at/,
async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page');
const documents = {
count: 40,
next: 'http://localhost:3000/documents/?page=2&ordering=-created_at',
previous: null,
results: Array.from({ length: 20 }, (_, i) => ({
id: `2ff-${pageId}-${i}`,
title: `My document-${pageId}-${i}`,
accesses: [
{
id: 'b644e9b1-0517-4cfb-90ca-f7d6f2f6bb9a',
role: `owner`,
team: '',
user: {
id: 'a4743608-c9d8-4692-bef4-f795e25a3a88',
email: 'user@chromium.e2e',
},
},
],
content: '',
is_public: true,
abilities: {},
})),
};
if (request.method().includes('GET')) {
await route.fulfill({
json: documents,
});
} else {
await route.continue();
}
},
);
await page.route(`**/documents/2ff-1-16/`, async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
id: '2ff-1-16',
title: 'My document-1-16',
content: '',
abilities: {
partial_update: true,
},
accesses: [
{
id: 'b644e9b1-0517-4cfb-90ca-f7d6f2f6bb9a',
role: `owner`,
team: '',
user: {
id: 'a4743608-c9d8-4692-bef4-f795e25a3a88',
email: '',
},
},
],
},
});
} else {
await route.continue();
}
});
const panel = page.getByLabel('Documents panel').first();
await expect(panel.locator('li')).toHaveCount(20);
await panel.getByText(`My document-1-16`).click();
await waitForElementCount(panel.locator('li'), 21, 10000);
expect(await panel.locator('li').count()).toBeGreaterThan(20);
await expect(panel.getByText(`My document-1-16`)).toBeVisible();
await expect(panel.getByText(`My document-2-15`)).toBeVisible();
});
test('checks the hover and selected state', async ({ page, browserName }) => {
const panel = page.getByLabel('Documents panel').first();
await createDoc(page, 'doc-hover', browserName, 2);
const selectedDoc = panel.locator('li').nth(0);
await expect(selectedDoc).toHaveCSS(
'background-color',
'rgb(202, 202, 251)',
);
const hoverDoc = panel.locator('li').nth(1);
await hoverDoc.hover();
await expect(hoverDoc).toHaveCSS('background-color', 'rgb(227, 227, 253)');
});
});
@@ -0,0 +1,35 @@
import { expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Routing', () => {
test('checks alias docs url with homepage', async ({ page }) => {
await expect(page).toHaveURL('/');
const buttonCreateHomepage = page.getByRole('button', {
name: 'Create a new document',
});
await expect(buttonCreateHomepage).toBeVisible();
await page.goto('/docs');
await expect(buttonCreateHomepage).toBeVisible();
await expect(page).toHaveURL(/\/docs$/);
});
test('checks 404 on docs/[id] page', async ({ page }) => {
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(300);
await page.goto('/docs/some-unknown-doc');
await expect(
page.getByText(
'It seems that the page you are looking for does not exist or cannot be displayed correctly.',
),
).toBeVisible({
timeout: 15000,
});
});
});
@@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import pdf from 'pdf-parse';
import { createDoc } from './common';
import { createDoc, goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -123,10 +123,11 @@ test.describe('Doc Tools', () => {
page.getByText('The document has been updated.'),
).toBeVisible();
const panel = page.getByLabel('Documents panel').first();
await expect(
panel.locator('li').getByText(`${randomDoc}-updated`),
).toBeVisible();
const docTitle = await goToGridDoc(page, {
title: `${randomDoc}-updated`,
});
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
@@ -169,14 +170,18 @@ test.describe('Doc Tools', () => {
page.getByRole('button', { name: 'Create a new document' }),
).toBeVisible();
const panel = page.getByLabel('Documents panel').first();
await expect(panel.locator('li').getByText(randomDoc)).toBeHidden();
const row = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table')
.getByRole('row')
.filter({
hasText: randomDoc,
});
expect(await row.count()).toBe(0);
});
test('it checks the options available if administrator', async ({
page,
browserName,
}) => {
test('it checks the options available if administrator', async ({ page }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
@@ -207,15 +212,14 @@ test.describe('Doc Tools', () => {
}
});
await createDoc(page, 'doc-tools-right-admin', browserName, 1);
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Add members' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Generate PDF' }),
).toBeVisible();
@@ -227,10 +231,7 @@ test.describe('Doc Tools', () => {
).toBeHidden();
});
test('it checks the options available if editor', async ({
page,
browserName,
}) => {
test('it checks the options available if editor', async ({ page }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
@@ -261,15 +262,14 @@ test.describe('Doc Tools', () => {
}
});
await createDoc(page, 'doc-tools-right-editor', browserName, 1);
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Add members' }),
).toBeHidden();
await expect(
page.getByRole('button', { name: 'Generate PDF' }),
).toBeVisible();
@@ -281,10 +281,7 @@ test.describe('Doc Tools', () => {
).toBeHidden();
});
test('it checks the options available if reader', async ({
page,
browserName,
}) => {
test('it checks the options available if reader', async ({ page }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
@@ -315,15 +312,15 @@ test.describe('Doc Tools', () => {
}
});
await createDoc(page, 'doc-tools-right-reader', browserName, 1);
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Add members' }),
).toBeHidden();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(
page.getByRole('button', { name: 'Generate PDF' }),
).toBeVisible();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -48,6 +48,7 @@ export default defineConfig({
use: {
...devices['Desktop Chrome'],
locale: 'en-US',
timezoneId: 'Europe/Paris',
storageState: 'playwright/.auth/user-chromium.json',
},
dependencies: ['setup'],
@@ -57,6 +58,7 @@ export default defineConfig({
use: {
...devices['Desktop Safari'],
locale: 'en-US',
timezoneId: 'Europe/Paris',
storageState: 'playwright/.auth/user-webkit.json',
},
dependencies: ['setup'],
+2
View File
@@ -344,6 +344,7 @@ const config = {
'border-color': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
'value-color': 'var(--c--theme--colors--primary-text)',
'font-size': '14px',
},
'forms-labelledbox': {
'label-color': {
@@ -351,6 +352,7 @@ const config = {
},
},
'forms-select': {
'item-font-size': '14px',
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#ffffff',
+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "app-impress",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "prettier --check . && yarn stylelint && next build",
"build:ci": "cp .env.development .env.local && yarn build",
"build-theme": "cunningham -g css,ts -o src/cunningham --utility-classes",
"build-theme": "cunningham -g css,ts -o src/cunningham --utility-classes && yarn prettier",
"start": "npx -y serve@latest out",
"lint": "tsc --noEmit && next lint",
"prettier": "prettier --write .",
@@ -15,9 +15,9 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@blocknote/core": "0.14.2",
"@blocknote/mantine": "0.14.2",
"@blocknote/react": "0.14.2",
"@blocknote/core": "*",
"@blocknote/mantine": "*",
"@blocknote/react": "*",
"@gouvfr-lasuite/integration": "1.0.1",
"@openfun/cunningham-react": "2.9.3",
"@tanstack/react-query": "5.48.0",
@@ -35,6 +35,7 @@ export interface BoxProps {
$radius?: CSSProperties['borderRadius'];
$transition?: CSSProperties['transition'];
$width?: CSSProperties['width'];
$wrap?: CSSProperties['flexWrap'];
$zIndex?: CSSProperties['zIndex'];
}
@@ -65,6 +66,7 @@ export const Box = styled('div')<BoxProps>`
${({ $radius }) => $radius && `border-radius: ${$radius};`}
${({ $transition }) => $transition && `transition: ${$transition};`}
${({ $width }) => $width && `width: ${$width};`}
${({ $wrap }) => $wrap && `flex-wrap: ${$wrap};`}
${({ $css }) => $css && `${$css};`}
${({ $zIndex }) => $zIndex && `z-index: ${$zIndex};`}
${({ $effect }) => {
@@ -0,0 +1,46 @@
import { Text, TextType } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
interface IconBGProps extends TextType {
iconName: string;
}
export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Text
$isMaterialIcon
$size="36px"
$theme="primary"
$background={colorsTokens()['primary-bg']}
$css={`border: 1px solid ${colorsTokens()['primary-200']}`}
$radius="12px"
$padding="4px"
$margin="auto"
{...textProps}
>
{iconName}
</Text>
);
};
interface IconOptionsProps {
isOpen: boolean;
'aria-label': string;
}
export const IconOptions = ({ isOpen, ...props }: IconOptionsProps) => {
return (
<Text
aria-label={props['aria-label']}
$isMaterialIcon
$css={`
transition: all 0.3s ease-in-out;
transform: rotate(${isOpen ? '90' : '0'}deg);
`}
>
more_vert
</Text>
);
};
@@ -1,21 +0,0 @@
import { Text } from '@/components';
interface IconOptionsProps {
isOpen: boolean;
'aria-label': string;
}
export const IconOptions = ({ isOpen, ...props }: IconOptionsProps) => {
return (
<Text
aria-label={props['aria-label']}
className="material-icons"
$css={`
transition: all 0.3s ease-in-out;
transform: rotate(${isOpen ? '90' : '0'}deg);
`}
>
more_vert
</Text>
);
};
@@ -0,0 +1,57 @@
import { Modal, ModalSize } from '@openfun/cunningham-react';
import { ComponentPropsWithRef, PropsWithChildren } from 'react';
import { createGlobalStyle } from 'styled-components';
interface SideModalStyleProps {
side: 'left' | 'right';
width: string;
$css?: string;
}
const SideModalStyle = createGlobalStyle<SideModalStyleProps>`
@keyframes slidein {
from {
transform: translateX(100%);
}
to {
transform: translateX(0%);
}
}
& .c__modal{
animation: slidein 0.7s;
width: ${({ width }) => width};
${({ side }) => side === 'right' && 'left: auto;'};
.c__modal__scroller {
height: 100%;
display: flex;
flex-direction: column;
}
${({ $css }) => $css}
}
`;
type SideModalType = Omit<ComponentPropsWithRef<typeof Modal>, 'size'>;
type SideModalProps = SideModalType & Partial<SideModalStyleProps>;
export const SideModal = ({
children,
side = 'right',
width = '35vw',
$css,
...modalProps
}: PropsWithChildren<SideModalProps>) => {
return (
<>
<SideModalStyle width={width} side={side} $css={$css} />
<Modal {...modalProps} size={ModalSize.FULL}>
{children}
</Modal>
</>
);
};
@@ -13,6 +13,8 @@ export interface TextProps extends BoxProps {
ReactHTML,
'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
>;
$elipsis?: boolean;
$isMaterialIcon?: boolean;
$weight?: CSSProperties['fontWeight'];
$textAlign?: CSSProperties['textAlign'];
// eslint-disable-next-line @typescript-eslint/ban-types
@@ -49,12 +51,23 @@ export const TextStyled = styled(Box)<TextProps>`
${({ $theme, $variation }) =>
`color: var(--c--theme--colors--${$theme}-${$variation});`}
${({ $color }) => $color && `color: ${$color};`}
${({ $elipsis }) =>
$elipsis &&
`white-space: nowrap; overflow: hidden; text-overflow: ellipsis;`}
`;
export const Text = ({
className,
$isMaterialIcon,
...props
}: ComponentPropsWithRef<typeof TextStyled>) => {
return (
<TextStyled as="span" $theme="greyscale" $variation="text" {...props} />
<TextStyled
as="span"
$theme="greyscale"
$variation="text"
className={`${className || ''}${$isMaterialIcon ? ' material-icons' : ''}`}
{...props}
/>
);
};
@@ -2,7 +2,8 @@ export * from './Box';
export * from './BoxButton';
export * from './Card';
export * from './DropButton';
export * from './IconOptions';
export * from './Icon';
export * from './InfiniteScroll';
export * from './Link';
export * from './Text';
export * from './TextErrors';
@@ -1,6 +1,5 @@
import { CunninghamProvider } from '@openfun/cunningham-react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useCunninghamTheme } from '@/cunningham';
import '@/i18n/initI18n';
@@ -27,7 +26,6 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools />
<CunninghamProvider theme={theme}>
<Auth>{children}</Auth>
</CunninghamProvider>
@@ -213,8 +213,8 @@ input:-webkit-autofill:focus {
.c__datagrid__table__container > table tbody tr {
border: none;
border-top: 1px var(--c--theme--colors--greyscale-300) solid;
border-bottom: 1px var(--c--theme--colors--greyscale-300) solid;
border-top: 1px var(--c--theme--colors--greyscale-100) solid;
border-bottom: 1px var(--c--theme--colors--greyscale-100) solid;
}
.c__datagrid__table__container > table tbody {
@@ -227,6 +227,14 @@ input:-webkit-autofill:focus {
);
}
.c__datagrid__table__container > table {
table-layout: auto;
}
.c__datagrid__table__container > table td {
white-space: break-spaces;
}
.c__datagrid__table__container > table th:first-child,
.c__datagrid__table__container > table td:first-child {
padding-left: 2rem;
@@ -435,6 +443,13 @@ input:-webkit-autofill:focus {
color: var(--c--components--button--tertiary-text--color-hover);
}
.c__button--tertiary-text:disabled {
background-color: var(
--c--components--button--tertiary-text--background--color-disabled
);
color: var(--c--components--button--tertiary-text--color-disabled);
}
.c__button--danger {
background-color: var(--c--components--button--danger--background--color);
}
@@ -469,6 +484,10 @@ input:-webkit-autofill:focus {
padding: 1.5rem 1rem;
}
.c__modal--full .c__modal__content {
overflow-y: auto;
}
/**
* Toast
*/
@@ -469,9 +469,11 @@
--c--components--forms-input--value-color: var(
--c--theme--colors--primary-text
);
--c--components--forms-input--font-size: 14px;
--c--components--forms-labelledbox--label-color--big: var(
--c--theme--colors--primary-text
);
--c--components--forms-select--item-font-size: 14px;
--c--components--forms-select--border-radius: 4px;
--c--components--forms-select--border-radius-hover: 4px;
--c--components--forms-select--background-color: #fff;
@@ -472,11 +472,13 @@ export const tokens = {
'border-color': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
'value-color': 'var(--c--theme--colors--primary-text)',
'font-size': '14px',
},
'forms-labelledbox': {
'label-color': { big: 'var(--c--theme--colors--primary-text)' },
},
'forms-select': {
'item-font-size': '14px',
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#ffffff',
@@ -78,6 +78,7 @@ export const BlockNoteContent = ({ doc, provider }: BlockNoteContentProps) => {
editor={editor}
formattingToolbar={false}
editable={doc.abilities.partial_update}
theme="light"
>
<BlockNoteToolbar />
</BlockNoteView>
@@ -1,9 +1,9 @@
import { Alert, VariantType } from '@openfun/cunningham-react';
import React from 'react';
import { Box, Card, Text } from '@/components';
import { Box, Card } from '@/components';
import { DocHeader } from '@/features/docs/doc-header';
import { Doc } from '@/features/docs/doc-management';
import { DocToolBox } from '@/features/docs/doc-tools';
import { BlockNoteEditor } from './BlockNoteEditor';
@@ -14,19 +14,9 @@ interface DocEditorProps {
export const DocEditor = ({ doc }: DocEditorProps) => {
return (
<>
<Box
$direction="row"
$margin={{ all: 'big', right: 'none' }}
$align="center"
$position="relative"
>
<Text as="h2" $align="center" $margin="auto">
{doc.title}
</Text>
<DocToolBox doc={doc} />
</Box>
<DocHeader doc={doc} />
{!doc.abilities.partial_update && (
<Box className="m-b" $css="margin-top:0;">
<Box $margin={{ all: 'small', top: 'none' }}>
<Alert
type={VariantType.WARNING}
>{`Read only, you don't have the right to update this document.`}</Alert>
@@ -0,0 +1,104 @@
import React, { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
Role,
currentDocRole,
useTransRole,
} from '@/features/docs/doc-management';
import { useDate } from '@/hook';
import { DocToolBox } from './DocToolBox';
interface DocHeaderProps {
doc: Doc;
}
export const DocHeader = ({ doc }: DocHeaderProps) => {
const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
const { formatDate } = useDate();
const transRole = useTransRole();
return (
<Card
$margin="big"
aria-label={t('It is the card information about the document.')}
>
<Box $padding="small" $direction="row" $align="center">
<StyledLink href="/">
<Text
$isMaterialIcon
$theme="primary"
$size="2rem"
$css={`&:hover {background-color: ${colorsTokens()['primary-100']}; };`}
$hasTransition
$radius="5px"
$padding="tiny"
>
home
</Text>
</StyledLink>
<Box
$width="1px"
$height="70%"
$background={colorsTokens()['greyscale-100']}
$margin={{ horizontal: 'small' }}
/>
<Text as="h2" $align="center" $margin={{ all: 'none', left: 'tiny' }}>
{doc.title}
</Text>
<DocToolBox doc={doc} />
</Box>
<Box
$direction="row"
$align="center"
$css="border-top:1px solid #eee"
$padding={{ horizontal: 'big', vertical: 'tiny' }}
$gap="0.5rem 2rem"
$justify="space-between"
$wrap="wrap"
>
<Box $direction="row" $align="center" $gap="0.5rem 2rem" $wrap="wrap">
{doc.is_public && (
<Text
$weight="bold"
$background={colorsTokens()['primary-600']}
$color="white"
$padding="xtiny"
$radius="3px"
$size="s"
>
{t('Public')}
</Text>
)}
<Text $size="s" $display="inline">
{t('Created at')} <strong>{formatDate(doc.created_at)}</strong>
</Text>
<Text $size="s" $display="inline" $elipsis $maxWidth="60vw">
{t('Owners:')}{' '}
<strong>
{doc.accesses
.filter(
(access) => access.role === Role.OWNER && access.user.email,
)
.map((access, index, accesses) => (
<Fragment key={`access-${index}`}>
{access.user.email}{' '}
{index < accesses.length - 1 ? ' / ' : ''}
</Fragment>
))}
</strong>
</Text>
</Box>
<Text $size="s" $display="inline">
{t('Your role:')}{' '}
<strong>{transRole(currentDocRole(doc.abilities))}</strong>
</Text>
</Box>
</Card>
);
};
@@ -6,11 +6,9 @@ import { Box, DropButton, IconOptions, Text } from '@/components';
import {
Doc,
ModalRemoveDoc,
ModalShare,
ModalUpdateDoc,
currentDocRole,
} from '@/features/docs/doc-management';
import { ModalAddMembers } from '@/features/docs/members/members-add';
import { ModalGridMembers } from '@/features/docs/members/members-grid/';
import { ModalPDF } from './ModalPDF';
@@ -20,15 +18,28 @@ interface DocToolBoxProps {
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation();
const [isModalAddMembersOpen, setIsModalAddMembersOpen] = useState(false);
const [isModalGridMembersOpen, setIsModalGridMembersOpen] = useState(false);
const [isModalShareOpen, setIsModalShareOpen] = useState(false);
const [isModalUpdateOpen, setIsModalUpdateOpen] = useState(false);
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isModalPDFOpen, setIsModalPDFOpen] = useState(false);
const [isDropOpen, setIsDropOpen] = useState(false);
return (
<Box $margin="big" $position="absolute" $css="right:1rem;">
<Box
$margin={{ left: 'auto' }}
$direction="row"
$align="center"
$gap="1rem"
>
{doc.abilities.manage_accesses && (
<Button
onClick={() => {
setIsModalShareOpen(true);
}}
>
{t('Share')}
</Button>
)}
<DropButton
button={
<IconOptions
@@ -40,32 +51,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
isOpen={isDropOpen}
>
<Box>
{doc.abilities.manage_accesses && (
<>
<Button
onClick={() => {
setIsModalAddMembersOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">person_add</span>}
size="small"
>
<Text $theme="primary">{t('Add members')}</Text>
</Button>
<Button
onClick={() => {
setIsModalGridMembersOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">group</span>}
size="small"
>
<Text $theme="primary">{t('Manage members')}</Text>
</Button>
</>
)}
{doc.abilities.partial_update && (
<Button
onClick={() => {
@@ -105,18 +90,8 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
</Button>
</Box>
</DropButton>
{isModalGridMembersOpen && (
<ModalGridMembers
onClose={() => setIsModalGridMembersOpen(false)}
doc={doc}
/>
)}
{isModalAddMembersOpen && (
<ModalAddMembers
onClose={() => setIsModalAddMembersOpen(false)}
doc={doc}
currentRole={currentDocRole(doc)}
/>
{isModalShareOpen && (
<ModalShare onClose={() => setIsModalShareOpen(false)} doc={doc} />
)}
{isModalPDFOpen && (
<ModalPDF onClose={() => setIsModalPDFOpen(false)} doc={doc} />
@@ -0,0 +1 @@
export * from './DocHeader';
@@ -1,9 +1,12 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Doc, KEY_LIST_DOC } from '@/features/docs';
type CreateDocParam = Pick<Doc, 'title' | 'is_public'>;
import { Doc } from '../types';
import { KEY_LIST_DOC } from './useDocs';
export type CreateDocParam = Pick<Doc, 'title' | 'is_public'>;
export const createDoc = async ({
title,
@@ -33,7 +36,7 @@ export function useCreateDoc({ onSuccess }: CreateDocProps) {
return useMutation<Doc, APIError, CreateDocParam>({
mutationFn: createDoc,
onSuccess: (data) => {
void queryClient.invalidateQueries({
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC],
});
onSuccess(data);
@@ -1,23 +1,27 @@
import {
DefinedInitialDataInfiniteOptions,
InfiniteData,
QueryKey,
useInfiniteQuery,
} from '@tanstack/react-query';
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import { Doc } from '@/features/docs/doc-management';
export enum DocsOrdering {
BY_CREATED_ON = 'created_at',
BY_CREATED_ON_DESC = '-created_at',
}
import { Doc } from '../types';
export const isDocsOrdering = (data: string): data is DocsOrdering => {
return !!docsOrdering.find((validKey) => validKey === data);
};
const docsOrdering = [
'created_at',
'-created_at',
'updated_at',
'-updated_at',
'title',
'-title',
] as const;
export type DocsOrdering = (typeof docsOrdering)[number];
export type DocsParams = {
ordering: DocsOrdering;
};
type DocsAPIParams = DocsParams & {
page: number;
ordering?: DocsOrdering;
};
export type DocsResponse = APIList<Doc>;
@@ -25,7 +29,7 @@ export type DocsResponse = APIList<Doc>;
export const getDocs = async ({
ordering,
page,
}: DocsAPIParams): Promise<DocsResponse> => {
}: DocsParams): Promise<DocsResponse> => {
const orderingQuery = ordering ? `&ordering=${ordering}` : '';
const response = await fetchAPI(`documents/?page=${page}${orderingQuery}`);
@@ -39,32 +43,12 @@ export const getDocs = async ({
export const KEY_LIST_DOC = 'docs';
export function useDocs(
param: DocsParams,
queryConfig?: DefinedInitialDataInfiniteOptions<
DocsResponse,
APIError,
InfiniteData<DocsResponse>,
QueryKey,
number
>,
params: DocsParams,
queryConfig?: UseQueryOptions<DocsResponse, APIError, DocsResponse>,
) {
return useInfiniteQuery<
DocsResponse,
APIError,
InfiniteData<DocsResponse>,
QueryKey,
number
>({
initialPageParam: 1,
queryKey: [KEY_LIST_DOC, param],
queryFn: ({ pageParam }) =>
getDocs({
...param,
page: pageParam,
}),
getNextPageParam(lastPage, allPages) {
return lastPage.next ? allPages.length + 1 : undefined;
},
return useQuery<DocsResponse, APIError, DocsResponse>({
queryKey: [KEY_LIST_DOC, params],
queryFn: () => getDocs(params),
...queryConfig,
});
}
@@ -30,7 +30,7 @@ export const useRemoveDoc = (options?: UseRemoveDocOptions) => {
mutationFn: removeDoc,
...options,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC],
});
if (options?.onSuccess) {
@@ -38,7 +38,7 @@ export function useUpdateDoc({
mutationFn: updateDoc,
onSuccess: (data) => {
listInvalideQueries?.forEach((queryKey) => {
void queryClient.invalidateQueries({
void queryClient.resetQueries({
queryKey: [queryKey],
});
});
@@ -1,75 +0,0 @@
import { Button, Switch } from '@openfun/cunningham-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import IconGroup from '@/assets/icons/icon-group2.svg';
import { Box, Card, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useCreateDoc } from '../api/useCreateDoc';
import { InputDocName } from './InputDocName';
export const CardCreateDoc = () => {
const { t } = useTranslation();
const router = useRouter();
const {
mutate: createDoc,
isError,
isPending,
error,
} = useCreateDoc({
onSuccess: (doc) => {
router.push(`/docs/${doc.id}`);
},
});
const [docName, setDocName] = useState('');
const [docPublic, setDocPublic] = useState(false);
const { colorsTokens } = useCunninghamTheme();
return (
<Card
$padding="big"
$height="70%"
$justify="space-between"
$width="100%"
$maxWidth="24rem"
$minWidth="22rem"
aria-label={t('Create new document card')}
>
<Box $gap="1rem">
<Box $align="center">
<IconGroup
width={44}
color={colorsTokens()['primary-text']}
aria-label={t('icon group')}
/>
<Text as="h3" $textAlign="center">
{t('Name the document')}
</Text>
</Box>
<InputDocName
label={t('Document name')}
{...{ error, isError, isPending, setDocName }}
/>
<Switch
label={t('Is it public ?')}
labelSide="right"
onChange={() => setDocPublic(!docPublic)}
/>
</Box>
<Box $justify="space-between" $direction="row" $align="center">
<StyledLink href="/">
<Button color="secondary">{t('Cancel')}</Button>
</StyledLink>
<Button
onClick={() => createDoc({ title: docName, is_public: docPublic })}
disabled={!docName}
>
{t('Create the document')}
</Button>
</Box>
</Card>
);
};
@@ -0,0 +1,190 @@
import {
Alert,
Button,
Modal,
ModalSize,
Switch,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { UseMutationResult } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { APIError } from '@/api';
import { Box, Text } from '@/components';
import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
import { KEY_DOC, KEY_LIST_DOC } from '../api';
import { useCreateDoc } from '../api/useCreateDoc';
import { useUpdateDoc } from '../api/useUpdateDoc';
import IconEdit from '../assets/icon-edit.svg';
import { Doc } from '../types';
import { InputDocName } from './InputDocName';
interface ModalCreateDocProps {
onClose: () => void;
}
export const ModalCreateDoc = ({ onClose }: ModalCreateDocProps) => {
const router = useRouter();
const api = useCreateDoc({
onSuccess: (doc) => {
router.push(`/docs/${doc.id}`);
},
});
const { t } = useTranslation();
return (
<ModalDoc
{...{
buttonText: t('Create the document'),
onClose,
isPublic: false,
titleModal: t('Create a new document'),
validate: (title, is_public) =>
api.mutate({
is_public,
title,
}),
...api,
}}
/>
);
};
interface ModalUpdateDocProps {
onClose: () => void;
doc: Doc;
}
export const ModalUpdateDoc = ({ onClose, doc }: ModalUpdateDocProps) => {
const { toast } = useToastProvider();
const { t } = useTranslation();
const api = useUpdateDoc({
onSuccess: () => {
toast(t('The document has been updated.'), VariantType.SUCCESS, {
duration: 4000,
});
onClose();
},
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
});
return (
<ModalDoc
{...{
buttonText: t('Validate the modification'),
onClose,
initialTitle: doc.title,
isPublic: doc.is_public,
infoText: t('Enter the new name of the selected document.'),
titleModal: t('Update document "{{documentTitle}}"', {
documentTitle: doc.title,
}),
validate: (title, is_public) =>
api.mutate({
is_public,
title,
id: doc.id,
}),
...api,
}}
/>
);
};
type ModalDoc<T> = {
buttonText: string;
isPublic: boolean;
onClose: () => void;
titleModal: string;
validate: (title: string, is_public: boolean) => void;
initialTitle?: string;
infoText?: string;
} & UseMutationResult<Doc, APIError<unknown>, T, unknown>;
const ModalDoc = <T,>({
buttonText,
infoText,
initialTitle,
isPublic,
onClose,
titleModal,
validate,
...api
}: ModalDoc<T>) => {
const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
const [title, setTitle] = useState(initialTitle || '');
const [docPublic, setDocPublic] = useState(isPublic);
return (
<Modal
isOpen
closeOnClickOutside
hideCloseButton
leftActions={
<Button
aria-label={t('Close the modal')}
color="secondary"
fullWidth
onClick={() => onClose()}
>
{t('Cancel')}
</Button>
}
onClose={() => onClose()}
rightActions={
<Button
aria-label={buttonText}
color="primary"
fullWidth
onClick={() => validate(title, docPublic)}
>
{buttonText}
</Button>
}
size={ModalSize.MEDIUM}
title={
<Box $align="center" $gap="1rem" $margin={{ bottom: '2.5rem' }}>
<IconEdit width={48} color={colorsTokens()['primary-text']} />
<Text as="h2" $size="h3" $margin="none">
{titleModal}
</Text>
</Box>
}
>
<Box $margin={{ bottom: 'xl' }} $gap="1rem">
{infoText && (
<Alert canClose={false} type={VariantType.INFO}>
<Text>{infoText}</Text>
</Alert>
)}
<Box $gap="1rem">
<InputDocName
label={t('Document name')}
defaultValue={title}
{...{
error: api.error,
isError: api.isError,
isPending: api.isPending,
setDocName: setTitle,
}}
/>
<Switch
label={t('Is it public ?')}
labelSide="right"
defaultChecked={docPublic}
onChange={() => setDocPublic(!docPublic)}
/>
</Box>
</Box>
</Modal>
);
};
@@ -0,0 +1,74 @@
import { t } from 'i18next';
import { useEffect } from 'react';
import { createGlobalStyle } from 'styled-components';
import { Box, Card, Text } from '@/components';
import { SideModal } from '@/components/SideModal';
import { AddMembers } from '../../members/members-add';
import { MemberList } from '../../members/members-list/components/MemberList';
import { Doc } from '../types';
import { currentDocRole } from '../utils';
const ModalShareStyle = createGlobalStyle`
& .c__modal__scroller{
background: #FAFAFA;
padding: 1.5rem .5rem;
.c__modal__title{
padding: 0;
margin: 0;
}
}
`;
interface ModalShareProps {
onClose: () => void;
doc: Doc;
}
export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
useEffect(() => {
if (!doc.abilities.manage_accesses) {
onClose();
}
}, [doc.abilities.manage_accesses, onClose]);
return (
<>
<ModalShareStyle />
<SideModal
isOpen
closeOnClickOutside
hideCloseButton
onClose={onClose}
width="70vw"
$css="min-width: 320px;max-width: 777px;"
title={
<Card
$direction="row"
$align="center"
$margin={{ horizontal: 'tiny', top: 'none', bottom: 'big' }}
$padding="tiny"
$gap="1rem"
>
<Text $isMaterialIcon $size="48px" $theme="primary">
share
</Text>
<Box $align="flex-start">
<Text as="h3" $size="26px" $margin="none">
{t('Share')}
</Text>
<Text $size="small" $weight="normal" $textAlign="left">
{doc.title}
</Text>
</Box>
</Card>
}
>
<AddMembers doc={doc} currentRole={currentDocRole(doc.abilities)} />
<MemberList doc={doc} />
</SideModal>
</>
);
};
@@ -1,119 +0,0 @@
import {
Alert,
Button,
Modal,
ModalSize,
Switch,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import useCunninghamTheme from '@/cunningham/useCunninghamTheme';
import { KEY_DOC, KEY_LIST_DOC } from '../api';
import { useUpdateDoc } from '../api/useUpdateDoc';
import IconEdit from '../assets/icon-edit.svg';
import { Doc } from '../types';
import { InputDocName } from './InputDocName';
interface ModalUpdateDocProps {
onClose: () => void;
doc: Doc;
}
export const ModalUpdateDoc = ({ onClose, doc }: ModalUpdateDocProps) => {
const { colorsTokens } = useCunninghamTheme();
const [title, setTitle] = useState(doc.title);
const { toast } = useToastProvider();
const [docPublic, setDocPublic] = useState(doc.is_public);
const { t } = useTranslation();
const {
mutate: updateDoc,
isError,
isPending,
error,
} = useUpdateDoc({
onSuccess: () => {
toast(t('The document has been updated.'), VariantType.SUCCESS, {
duration: 4000,
});
onClose();
},
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
});
return (
<Modal
isOpen
closeOnClickOutside
hideCloseButton
leftActions={
<Button
aria-label={t('Close the modal')}
color="secondary"
fullWidth
onClick={() => onClose()}
>
{t('Cancel')}
</Button>
}
onClose={() => onClose()}
rightActions={
<Button
aria-label={t('Validate the modification')}
color="primary"
fullWidth
onClick={() =>
updateDoc({
title,
id: doc.id,
is_public: docPublic,
})
}
>
{t('Validate the modification')}
</Button>
}
size={ModalSize.MEDIUM}
title={
<Box $align="center" $gap="1rem">
<IconEdit width={48} color={colorsTokens()['primary-text']} />
<Text as="h2" $size="h3" $margin="none">
{t('Update document "{{documentTitle}}"', {
documentTitle: doc.title,
})}
</Text>
</Box>
}
>
<Box
$margin={{ bottom: 'xl' }}
aria-label={t('Content modal to update the document')}
$gap="1rem"
>
<Alert canClose={false} type={VariantType.INFO}>
<Text>{t('Enter the new name of the selected document.')}</Text>
</Alert>
<Box $gap="1rem">
<InputDocName
label={t('Document name')}
defaultValue={title}
{...{ error, isError, isPending, setDocName: setTitle }}
/>
<Switch
label={t('Is it public ?')}
labelSide="right"
defaultChecked={docPublic}
onChange={() => setDocPublic(!docPublic)}
/>
</Box>
</Box>
</Modal>
);
};
@@ -1,3 +1,3 @@
export * from './CardCreateDoc';
export * from './ModalCreateUpdateDoc';
export * from './ModalRemoveDoc';
export * from './ModalUpdateDoc';
export * from './ModalShare';
@@ -0,0 +1 @@
export * from './useTransRole';
@@ -0,0 +1,20 @@
import { useTranslation } from 'react-i18next';
import { Role } from '../types';
export const useTransRole = () => {
const { t } = useTranslation();
const translatedRoles = {
[Role.ADMIN]: t('Administrator'),
[Role.READER]: t('Reader'),
[Role.OWNER]: t('Owner'),
[Role.EDITOR]: t('Editor'),
};
const transRole = (role: Role) => {
return translatedRoles[role];
};
return transRole;
};
@@ -1,4 +1,5 @@
export * from './api';
export * from './components';
export * from './hooks';
export * from './types';
export * from './utils';
@@ -1,11 +1,11 @@
import { Doc, Role } from './types';
export const currentDocRole = (doc: Doc): Role => {
return doc.abilities.destroy
export const currentDocRole = (abilities: Doc['abilities']): Role => {
return abilities.destroy
? Role.OWNER
: doc.abilities.manage_accesses
: abilities.manage_accesses
? Role.ADMIN
: doc.abilities.partial_update
: abilities.partial_update
? Role.EDITOR
: Role.READER;
};
@@ -1 +0,0 @@
export * from './DocToolBox';
@@ -0,0 +1,199 @@
import { DataGrid, SortModel, usePagination } from '@openfun/cunningham-react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { Card, StyledLink, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
DocsOrdering,
currentDocRole,
isDocsOrdering,
useDocs,
useTransRole,
} from '@/features/docs/doc-management';
import { useDate } from '@/hook/';
import { PAGE_SIZE } from '../conf';
import { DocsGridActions } from './DocsGridActions';
const DocsGridStyle = createGlobalStyle`
& .c__datagrid{
max-height: 91%;
}
& .c__datagrid thead{
position: sticky;
top: 0;
background: #fff;
z-index: 1;
}
& .c__pagination__goto{
display:none;
}
`;
type SortModelItem = {
field: string;
sort: 'asc' | 'desc' | null;
};
function formatSortModel(sortModel: SortModelItem): DocsOrdering | undefined {
const { field, sort } = sortModel;
const orderingField = sort === 'desc' ? `-${field}` : field;
if (isDocsOrdering(orderingField)) {
return orderingField;
}
}
export const DocsGrid = () => {
const { colorsTokens } = useCunninghamTheme();
const transRole = useTransRole();
const { t } = useTranslation();
const { formatDate } = useDate();
const pagination = usePagination({
pageSize: PAGE_SIZE,
});
const [sortModel, setSortModel] = useState<SortModel>([]);
const { page, pageSize, setPagesCount } = pagination;
const [docs, setDocs] = useState<Doc[]>([]);
const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined;
const { data, isLoading, error } = useDocs({
page,
ordering,
});
useEffect(() => {
if (isLoading) {
return;
}
setDocs(data?.results || []);
}, [data?.results, t, isLoading]);
useEffect(() => {
setPagesCount(data?.count ? Math.ceil(data.count / pageSize) : 0);
}, [data?.count, pageSize, setPagesCount]);
return (
<Card
$padding={{ bottom: 'small', horizontal: 'big' }}
$margin={{ all: 'big', top: 'none' }}
$overflow="auto"
aria-label={t(`Datagrid of the documents page {{page}}`, { page })}
>
<DocsGridStyle />
<Text
$weight="bold"
as="h2"
$theme="primary"
$margin={{ bottom: 'none' }}
>
{t('Documents')}
</Text>
{error && <TextErrors causes={error.cause} />}
<DataGrid
columns={[
{
headerName: '',
id: 'visibility',
size: 95,
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
{row.is_public && (
<Text
$weight="bold"
$background={colorsTokens()['primary-600']}
$color="white"
$padding="xtiny"
$radius="3px"
>
{row.is_public ? t('Public') : ''}
</Text>
)}
</StyledLink>
);
},
},
{
headerName: t('Document name'),
field: 'title',
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
<Text $weight="bold" $theme="primary">
{row.title}
</Text>
</StyledLink>
);
},
},
{
headerName: t('Created at'),
field: 'created_at',
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
<Text $weight="bold">{formatDate(row.created_at)}</Text>
</StyledLink>
);
},
},
{
headerName: t('Updated at'),
field: 'updated_at',
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
<Text $weight="bold">{formatDate(row.updated_at)}</Text>
</StyledLink>
);
},
},
{
headerName: t('Your role'),
id: 'your_role',
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
<Text $weight="bold">
{transRole(currentDocRole(row.abilities))}
</Text>
</StyledLink>
);
},
},
{
headerName: t('Users number'),
id: 'users_number',
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
<Text $weight="bold">{row.accesses.length}</Text>
</StyledLink>
);
},
},
{
id: 'column-actions',
renderCell: ({ row }) => {
return <DocsGridActions doc={row} />;
},
},
]}
rows={docs}
isLoading={isLoading}
pagination={pagination}
onSortModelChange={setSortModel}
sortModel={sortModel}
/>
</Card>
);
};
@@ -0,0 +1,75 @@
import { Button } from '@openfun/cunningham-react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, DropButton, IconOptions, Text } from '@/components';
import {
Doc,
ModalRemoveDoc,
ModalUpdateDoc,
} from '@/features/docs/doc-management';
interface DocsGridActionsProps {
doc: Doc;
}
export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
const { t } = useTranslation();
const [isModalUpdateOpen, setIsModalUpdateOpen] = useState(false);
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isDropOpen, setIsDropOpen] = useState(false);
if (!doc.abilities.partial_update && !doc.abilities.destroy) {
return null;
}
return (
<>
<DropButton
button={
<IconOptions
isOpen={isDropOpen}
aria-label={t('Open the document options')}
/>
}
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
isOpen={isDropOpen}
>
<Box>
{doc.abilities.partial_update && (
<Button
onClick={() => {
setIsModalUpdateOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">edit</span>}
size="small"
>
<Text $theme="primary">{t('Update document')}</Text>
</Button>
)}
{doc.abilities.destroy && (
<Button
onClick={() => {
setIsModalRemoveOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">delete</span>}
size="small"
>
<Text $theme="primary">{t('Delete document')}</Text>
</Button>
)}
</Box>
</DropButton>
{isModalUpdateOpen && (
<ModalUpdateDoc onClose={() => setIsModalUpdateOpen(false)} doc={doc} />
)}
{isModalRemoveOpen && (
<ModalRemoveDoc onClose={() => setIsModalRemoveOpen(false)} doc={doc} />
)}
</>
);
};
@@ -0,0 +1,33 @@
import { Button } from '@openfun/cunningham-react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card } from '@/components';
import { ModalCreateDoc } from '@/features/docs/doc-management';
import { DocsGrid } from './DocsGrid';
export const DocsGridContainer = () => {
const { t } = useTranslation();
const [isModalCreateOpen, setIsModalCreateOpen] = useState(false);
return (
<Box $overflow="auto">
<Card $margin="big" $padding="tiny">
<Box $align="flex-end" $justify="center">
<Button
onClick={() => {
setIsModalCreateOpen(true);
}}
>
{t('Create a new document')}
</Button>
</Box>
</Card>
<DocsGrid />
{isModalCreateOpen && (
<ModalCreateDoc onClose={() => setIsModalCreateOpen(false)} />
)}
</Box>
);
};
@@ -0,0 +1 @@
export * from './DocsGridContainer';
@@ -1,168 +0,0 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import { AppWrapper } from '@/tests/utils';
import { DocList } from '../components/DocList';
import { Panel } from '../components/Panel';
window.HTMLElement.prototype.scroll = function () {};
jest.mock('next/router', () => ({
...jest.requireActual('next/router'),
useRouter: () => ({
query: {},
}),
}));
describe('PanelDocs', () => {
afterEach(() => {
fetchMock.restore();
});
it('renders with no doc to display', async () => {
fetchMock.mock(`end:/documents/?page=1&ordering=-created_at`, {
count: 0,
results: [],
});
render(<DocList />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(
await screen.findByText(
'Create your first document by clicking on the "Create a new document" button.',
),
).toBeInTheDocument();
});
it('renders an empty doc', async () => {
fetchMock.mock(`end:/documents/?page=1&ordering=-created_at`, {
count: 1,
results: [
{
id: '1',
name: 'Team 1',
accesses: [],
},
],
});
render(<DocList />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(await screen.findByLabelText('Empty docs icon')).toBeInTheDocument();
});
it('renders a doc with only 1 member', async () => {
fetchMock.mock(`end:/documents/?page=1&ordering=-created_at`, {
count: 1,
results: [
{
id: '1',
name: 'Team 1',
accesses: [
{
id: '1',
role: 'owner',
},
],
},
],
});
render(<DocList />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(await screen.findByLabelText('Empty docs icon')).toBeInTheDocument();
});
it('renders a non-empty doc', async () => {
fetchMock.mock(`end:/documents/?page=1&ordering=-created_at`, {
count: 1,
results: [
{
id: '1',
name: 'Doc 1',
accesses: [
{
id: '1',
role: 'admin',
},
{
id: '2',
role: 'member',
},
],
},
],
});
render(<DocList />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(await screen.findByLabelText('Docs icon')).toBeInTheDocument();
});
it('renders the error', async () => {
fetchMock.mock(`end:/documents/?page=1&ordering=-created_at`, {
status: 500,
});
render(<DocList />, { wrapper: AppWrapper });
expect(screen.getByRole('status')).toBeInTheDocument();
expect(
await screen.findByText('Something bad happens, please retry.'),
).toBeInTheDocument();
});
it('renders with doc panel open', async () => {
fetchMock.mock(`end:/documents/?page=1&ordering=-created_at`, {
count: 1,
results: [],
});
render(<Panel />, { wrapper: AppWrapper });
expect(
screen.getByRole('button', { name: 'Close the documents panel' }),
).toBeVisible();
expect(await screen.findByText('Documents')).toBeVisible();
});
it('closes and opens the doc panel', async () => {
fetchMock.mock(`end:/documents/?page=1&ordering=-created_at`, {
count: 1,
results: [],
});
render(<Panel />, { wrapper: AppWrapper });
expect(await screen.findByText('Documents')).toBeVisible();
await userEvent.click(
screen.getByRole('button', {
name: 'Close the documents panel',
}),
);
expect(await screen.findByText('Documents')).not.toBeVisible();
await userEvent.click(
screen.getByRole('button', {
name: 'Open the documents panel',
}),
);
expect(await screen.findByText('Documents')).toBeVisible();
});
});
@@ -1,6 +0,0 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M20.75 0.75H3.25C1.8625 0.75 0.75 1.875 0.75 3.25V20.75C0.75 22.125 1.8625 23.25 3.25 23.25H20.75C22.125 23.25 23.25 22.125 23.25 20.75V3.25C23.25 1.875 22.125 0.75 20.75 0.75ZM17 13.25H13.25V17C13.25 17.6875 12.6875 18.25 12 18.25C11.3125 18.25 10.75 17.6875 10.75 17V13.25H7C6.3125 13.25 5.75 12.6875 5.75 12C5.75 11.3125 6.3125 10.75 7 10.75H10.75V7C10.75 6.3125 11.3125 5.75 12 5.75C12.6875 5.75 13.25 6.3125 13.25 7V10.75H17C17.6875 10.75 18.25 11.3125 18.25 12C18.25 12.6875 17.6875 13.25 17 13.25Z"
fill="currentColor"
/>
</svg>

Before

Width:  |  Height:  |  Size: 630 B

@@ -1,13 +0,0 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_508_5524)">
<path
d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM4 12C4 7.58 7.58 4 12 4C13.85 4 15.55 4.63 16.9 5.69L5.69 16.9C4.63 15.55 4 13.85 4 12ZM12 20C10.15 20 8.45 19.37 7.1 18.31L18.31 7.1C19.37 8.45 20 10.15 20 12C20 16.42 16.42 20 12 20Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_508_5524">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 578 B

@@ -1,13 +0,0 @@
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_178_17837)">
<path
d="M11.25 3.75L6.25 8.7375H10V17.5H12.5V8.7375H16.25L11.25 3.75ZM20 21.2625V12.5H17.5V21.2625H13.75L18.75 26.25L23.75 21.2625H20Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_178_17837">
<rect width="30" height="30" fill="white" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 429 B

@@ -1,102 +0,0 @@
import { useRouter } from 'next/router';
import React from 'react';
import { useTranslation } from 'react-i18next';
import IconGroup from '@/assets/icons/icon-group.svg';
import { Box, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc } from '@/features/docs/doc-management';
import IconNone from '../assets/icon-none.svg';
interface DocItemProps {
doc: Doc;
}
export const DocItem = ({ doc }: DocItemProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const {
query: { id },
} = useRouter();
// There is at least 1 owner in the team
const hasMembers = doc.accesses.length > 1;
const isActive = doc.id === id;
const commonProps = {
className: 'p-t',
width: 52,
style: {
borderRadius: '10px',
flexShrink: 0,
background: '#fff',
},
};
const activeStyle = `
border-right: 4px solid ${colorsTokens()['primary-600']};
background: ${colorsTokens()['primary-400']};
span{
color: ${colorsTokens()['primary-text']};
}
`;
const hoverStyle = `
&:hover{
border-right: 4px solid ${colorsTokens()['primary-400']};
background: ${colorsTokens()['primary-300']};
span{
color: ${colorsTokens()['primary-text']};
}
}
`;
return (
<Box
$margin="none"
as="li"
$css={`
transition: all 0.2s ease-in;
border-right: 4px solid transparent;
${isActive ? activeStyle : hoverStyle}
`}
>
<StyledLink className="p-s pt-t pb-t" href={`/docs/${doc.id}`}>
<Box $align="center" $direction="row" $gap="0.5rem">
{hasMembers ? (
<IconGroup
aria-label={t(`Docs icon`)}
color={colorsTokens()['primary-500']}
{...commonProps}
style={{
...commonProps.style,
border: `1px solid ${colorsTokens()['primary-300']}`,
}}
/>
) : (
<IconNone
aria-label={t(`Empty docs icon`)}
color={colorsTokens()['greyscale-500']}
{...commonProps}
style={{
...commonProps.style,
border: `1px solid ${colorsTokens()['greyscale-300']}`,
}}
/>
)}
<Text
$weight="bold"
$color={!hasMembers ? colorsTokens()['greyscale-600'] : undefined}
$css={`
min-width: 14rem;
`}
>
{doc.title}
</Text>
</Box>
</StyledLink>
</Box>
);
};
@@ -1,112 +0,0 @@
import { Loader } from '@openfun/cunningham-react';
import React, { useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { APIError } from '@/api';
import { Box, Text, TextErrors } from '@/components';
import { InfiniteScroll } from '@/components/InfiniteScroll';
import { Doc, useDocs } from '@/features/docs/doc-management';
import { useDocPanelStore } from '../store';
import { DocItem } from './DocItem';
interface PanelTeamsStateProps {
isLoading: boolean;
error: APIError<unknown> | null;
docs?: Doc[];
}
const DocListState = ({ isLoading, error, docs }: PanelTeamsStateProps) => {
const { t } = useTranslation();
if (isLoading) {
return (
<Box $align="center" $margin="large">
<Loader />
</Box>
);
}
if (!docs?.length && !error) {
return (
<Box $justify="center" $margin="small">
<Text
as="p"
$margin={{ vertical: 'none' }}
$theme="greyscale"
$variation="500"
>
{t('0 group to display.')}
</Text>
<Text as="p" $theme="greyscale" $variation="500">
{t(
'Create your first document by clicking on the "Create a new document" button.',
)}
</Text>
</Box>
);
}
return (
<>
{docs?.map((doc) => <DocItem doc={doc} key={doc.id} />)}
{error && (
<Box
$justify="center"
$margin={{ vertical: 'big', horizontal: 'auto' }}
>
<TextErrors
causes={error.cause}
icon={
error.status === 502 ? (
<Text className="material-icons" $theme="danger">
wifi_off
</Text>
) : undefined
}
/>
</Box>
)}
</>
);
};
export const DocList = () => {
const ordering = useDocPanelStore((state) => state.ordering);
const {
data,
error,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useDocs({
ordering,
});
const containerRef = useRef<HTMLDivElement>(null);
const docs = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return acc.concat(page.results);
}, [] as Doc[]);
}, [data?.pages]);
return (
<Box $css="overflow-y: auto; overflow-x: hidden;" ref={containerRef}>
<InfiniteScroll
hasMore={hasNextPage}
isLoading={isFetchingNextPage}
next={() => {
void fetchNextPage();
}}
scrollContainer={containerRef.current}
as="ul"
$padding="none"
$margin={{ top: 'none' }}
role="listbox"
>
<DocListState isLoading={isLoading} error={error} docs={docs} />
</InfiniteScroll>
</Box>
);
};
@@ -1,87 +0,0 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocList } from './DocList';
import { PanelActions } from './PanelActions';
export const Panel = () => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [isOpen, setIsOpen] = useState(true);
const closedOverridingStyles = !isOpen && {
$width: '0',
$maxWidth: '0',
$minWidth: '0',
};
const transition = 'all 0.5s ease-in-out';
return (
<Box
$width="100%"
$maxWidth="20rem"
$minWidth="14rem"
$css={`
position: relative;
border-right: 1px solid ${colorsTokens()['primary-300']};
transition: ${transition};
`}
$height="inherit"
aria-label="Documents panel"
{...closedOverridingStyles}
>
<BoxButton
className="material-icons"
aria-label={
isOpen
? t('Close the documents panel')
: t('Open the documents panel')
}
$background="white"
$color={colorsTokens()['primary-600']}
$radius="100%"
$padding="0.3rem"
$position="absolute"
onClick={() => setIsOpen(!isOpen)}
$css={`
right: ${isOpen ? '-1.3' : '-2.8'}rem;
top: ${isOpen ? '0.7' : '0.25'}rem;
transform: rotate(${isOpen ? '0' : '180'}deg);
transition: ${transition};
font-size: 1.8rem;
border: 1px solid #fafafa;
box-shadow: ${isOpen ? '1px 1px' : '-1px -1px'} 3px #dfdfdf;
z-index: 1;
`}
>
menu_open
</BoxButton>
<Box
$css={`
overflow: hidden;
opacity: ${isOpen ? '1' : '0'};
transition: ${transition};
`}
>
<Box
$padding={{ all: 'small', right: 'large' }}
$direction="row"
$align="center"
$justify="space-between"
$css={`border-bottom: 1px solid ${colorsTokens()['primary-300']};`}
>
<Text $weight="bold" $size="1.25rem">
{t('Documents')}
</Text>
<PanelActions />
</Box>
<DocList />
</Box>
</Box>
);
};
@@ -1,60 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, StyledLink } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocsOrdering } from '@/features/docs/doc-management';
import IconAdd from '../assets/icon-add.svg';
import IconSort from '../assets/icon-sort.svg';
import { useDocPanelStore } from '../store';
export const PanelActions = () => {
const { t } = useTranslation();
const { changeOrdering, ordering } = useDocPanelStore();
const { colorsTokens } = useCunninghamTheme();
const isSortAsc = ordering === DocsOrdering.BY_CREATED_ON;
return (
<Box
$direction="row"
$gap="1rem"
$css={`
& button {
padding: 0;
svg {
padding: 0.1rem;
}
}
`}
>
<BoxButton
aria-label={
isSortAsc
? t('Sort the documents by creation date descendent')
: t('Sort the documents by creation date ascendent')
}
onClick={changeOrdering}
$radius="100%"
$background={isSortAsc ? colorsTokens()['primary-200'] : 'transparent'}
$color={colorsTokens()['primary-600']}
>
<IconSort
width={30}
height={30}
aria-label={t('Sort documents icon')}
/>
</BoxButton>
<StyledLink href="/docs/create">
<BoxButton
aria-label={t('Add a document')}
$color={colorsTokens()['primary-600']}
>
<IconAdd width={30} height={30} aria-label={t('Add document icon')} />
</BoxButton>
</StyledLink>
</Box>
);
};
@@ -1 +0,0 @@
export * from './Panel';
@@ -1 +0,0 @@
export * from './useDocPanelStore';
@@ -1,19 +0,0 @@
import { create } from 'zustand';
import { DocsOrdering } from '@/features/docs/doc-management/api';
interface DocPanelStore {
ordering: DocsOrdering;
changeOrdering: () => void;
}
export const useDocPanelStore = create<DocPanelStore>((set) => ({
ordering: DocsOrdering.BY_CREATED_ON_DESC,
changeOrdering: () =>
set(({ ordering }) => ({
ordering:
ordering === DocsOrdering.BY_CREATED_ON
? DocsOrdering.BY_CREATED_ON_DESC
: DocsOrdering.BY_CREATED_ON,
})),
}));
@@ -1,3 +1,2 @@
export * from './doc-editor';
export * from './doc-management';
export * from './docs-panel';
@@ -8,7 +8,7 @@ import {
KEY_LIST_DOC,
Role,
} from '@/features/docs/doc-management';
import { KEY_LIST_DOC_ACCESSES } from '@/features/docs/members/members-grid/';
import { KEY_LIST_DOC_ACCESSES } from '@/features/docs/members/members-list';
import { OptionType } from '../types';
@@ -50,7 +50,7 @@ export function useCreateDocAccess() {
return useMutation<Access, APIError, CreateDocAccessParams>({
mutationFn: createDocAccess,
onSuccess: () => {
void queryClient.invalidateQueries({
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC],
});
void queryClient.resetQueries({
@@ -1,21 +1,16 @@
import {
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { APIError } from '@/api';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Box, Card, IconBG } from '@/components';
import { Doc, Role } from '@/features/docs/doc-management';
import { useCreateDocAccess, useCreateInvitation } from '../api';
import IconAddUser from '../assets/add-user.svg';
import {
OptionInvitation,
OptionNewMember,
@@ -27,12 +22,6 @@ import {
import { ChooseRole } from './ChooseRole';
import { OptionsSelect, SearchUsers } from './SearchUsers';
const GlobalStyle = createGlobalStyle`
.c__modal {
overflow: visible;
}
`;
type APIErrorUser = APIError<{
value: string;
type: OptionType;
@@ -40,26 +29,21 @@ type APIErrorUser = APIError<{
interface ModalAddMembersProps {
currentRole: Role;
onClose: () => void;
doc: Doc;
}
export const ModalAddMembers = ({
currentRole,
onClose,
doc,
}: ModalAddMembersProps) => {
const { colorsTokens } = useCunninghamTheme();
export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
const { t } = useTranslation();
const [selectedUsers, setSelectedUsers] = useState<OptionsSelect>([]);
const [selectedRole, setSelectedRole] = useState<Role>(Role.READER);
const [selectedRole, setSelectedRole] = useState<Role>();
const { toast } = useToastProvider();
const { mutateAsync: createInvitation } = useCreateInvitation();
const { mutateAsync: createDocAccess } = useCreateDocAccess();
const [resetKey, setResetKey] = useState(1);
const [isPending, setIsPending] = useState<boolean>(false);
const switchActions = (selectedUsers: OptionsSelect) =>
const switchActions = (selectedUsers: OptionsSelect, selectedRole: Role) =>
selectedUsers.map(async (selectedUser) => {
switch (selectedUser.type) {
case OptionType.INVITATION:
@@ -112,12 +96,16 @@ export const ModalAddMembers = ({
const handleValidate = async () => {
setIsPending(true);
if (!selectedRole) {
return;
}
const settledPromises = await Promise.allSettled<
OptionInvitation | OptionNewMember
>(switchActions(selectedUsers));
>(switchActions(selectedUsers, selectedRole));
onClose();
setIsPending(false);
setResetKey(resetKey + 1);
settledPromises.forEach((settledPromise) => {
switch (settledPromise.status) {
@@ -133,63 +121,46 @@ export const ModalAddMembers = ({
};
return (
<Modal
isOpen
leftActions={
<Button
color="secondary"
fullWidth
onClick={onClose}
disabled={isPending}
>
{t('Cancel')}
</Button>
}
onClose={onClose}
closeOnClickOutside
hideCloseButton
rightActions={
<Button
color="primary"
fullWidth
disabled={!selectedUsers.length || isPending}
onClick={() => void handleValidate()}
>
{t('Validate')}
</Button>
}
size={ModalSize.MEDIUM}
title={
<Box $align="center" $gap="1rem">
<IconAddUser width={48} color={colorsTokens()['primary-text']} />
<Text $size="h3" $margin="none">
{t('Add members to the document')}
</Text>
</Box>
}
<Card
$gap="1rem"
$padding="1rem"
$margin="tiny"
$direction="row"
$align="center"
$wrap="wrap"
>
<GlobalStyle />
<Box $margin={{ bottom: 'xl', top: 'large' }}>
<SearchUsers
doc={doc}
setSelectedUsers={setSelectedUsers}
selectedUsers={selectedUsers}
disabled={isPending}
/>
{selectedUsers.length >= 0 && (
<Box $margin={{ top: 'small' }}>
<Text as="h4" $textAlign="left" $margin={{ bottom: 'tiny' }}>
{t('Choose a role')}
</Text>
<IconBG iconName="group_add" />
<Box $gap="0.7rem" $direction="row" $wrap="wrap" $css="flex: 70%;">
<Box $gap="0.7rem" $direction="row" $wrap="wrap" $css="flex: 80%;">
<Box $css="flex: auto;">
<SearchUsers
key={resetKey + 1}
doc={doc}
setSelectedUsers={setSelectedUsers}
selectedUsers={selectedUsers}
disabled={isPending}
/>
</Box>
<Box $css="flex: auto;">
<ChooseRole
key={resetKey}
currentRole={currentRole}
disabled={isPending}
defaultRole={Role.READER}
setRole={setSelectedRole}
/>
</Box>
)}
</Box>
<Box $align="center" $justify="center" $css="flex: auto;">
<Button
color="primary"
disabled={!selectedUsers.length || isPending || !selectedRole}
onClick={() => void handleValidate()}
style={{ height: '100%', maxHeight: '55px' }}
>
{t('Validate')}
</Button>
</Box>
</Box>
</Modal>
</Card>
);
};
@@ -1,13 +1,14 @@
import { Radio, RadioGroup } from '@openfun/cunningham-react';
import { Select } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { Role } from '@/features/docs/doc-management';
import { Role, useTransRole } from '@/features/docs/doc-management';
interface ChooseRoleProps {
currentRole: Role;
disabled: boolean;
defaultRole: Role;
defaultRole?: Role;
setRole: (role: Role) => void;
label?: string;
}
export const ChooseRole = ({
@@ -15,31 +16,22 @@ export const ChooseRole = ({
disabled,
currentRole,
setRole,
label,
}: ChooseRoleProps) => {
const { t } = useTranslation();
const translatedRoles = {
[Role.ADMIN]: t('Administrator'),
[Role.READER]: t('Reader'),
[Role.OWNER]: t('Owner'),
[Role.EDITOR]: t('Editor'),
};
const transRole = useTransRole();
return (
<RadioGroup>
{Object.values(Role).map((role) => (
<Radio
key={role}
label={translatedRoles[role]}
value={role}
name="role"
onChange={(evt) => setRole(evt.target.value as Role)}
defaultChecked={defaultRole === role}
disabled={
disabled || (currentRole !== Role.OWNER && role === Role.OWNER)
}
/>
))}
</RadioGroup>
<Select
label={label || t('Choose a role')}
options={Object.values(Role).map((role) => ({
label: transRole(role),
value: role,
disabled: currentRole !== Role.OWNER && role === Role.OWNER,
}))}
onChange={(evt) => setRole(evt.target.value as Role)}
disabled={disabled}
value={defaultRole}
/>
);
};
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { Options } from 'react-select';
import AsyncSelect from 'react-select/async';
import { useCunninghamTheme } from '@/cunningham';
import { Doc } from '@/features/docs/doc-management';
import { isValidEmail } from '@/utils';
@@ -24,6 +25,7 @@ export const SearchUsers = ({
setSelectedUsers,
disabled,
}: SearchUsersProps) => {
const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
const [input, setInput] = useState('');
const [userQuery, setUserQuery] = useState('');
@@ -102,6 +104,23 @@ export const SearchUsers = ({
return (
<AsyncSelect
styles={{
placeholder: (base) => ({
...base,
fontSize: '14px',
color: colorsTokens()['primary-600'],
}),
control: (base) => ({
...base,
minHeight: '45px',
borderColor: colorsTokens()['primary-600'],
}),
input: (base) => ({
...base,
minHeight: '45px',
fontSize: '14px',
}),
}}
isDisabled={disabled}
aria-label={t('Find a member to add to the document')}
isMulti
@@ -0,0 +1,2 @@
export * from './AddMembers';
export * from './ChooseRole';
@@ -1 +1 @@
export * from './components/ModalAddMembers';
export * from './components';
@@ -1,101 +0,0 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { Access, Doc, Role } from '@/features/docs/doc-management';
import { AppWrapper } from '@/tests/utils';
import { MemberAction } from '../components/MemberAction';
const access: Access = {
id: '789',
role: Role.ADMIN,
user: {
id: '11',
email: 'user1@test.com',
},
team: '',
abilities: {
set_role_to: [Role.READER, Role.ADMIN],
} as any,
};
const doc = {
id: '123456',
title: 'teamName',
} as Doc;
describe('MemberAction', () => {
afterEach(() => {
fetchMock.restore();
});
it('checks the render when owner', async () => {
render(
<MemberAction access={access} currentRole={Role.OWNER} doc={doc} />,
{
wrapper: AppWrapper,
},
);
expect(
await screen.findByLabelText('Open the member options modal'),
).toBeInTheDocument();
});
it('checks the render when reader', () => {
render(
<MemberAction access={access} currentRole={Role.READER} doc={doc} />,
{
wrapper: AppWrapper,
},
);
expect(
screen.queryByLabelText('Open the member options modal'),
).not.toBeInTheDocument();
});
it('checks the render when editor', () => {
render(
<MemberAction access={access} currentRole={Role.EDITOR} doc={doc} />,
{
wrapper: AppWrapper,
},
);
expect(
screen.queryByLabelText('Open the member options modal'),
).not.toBeInTheDocument();
});
it('checks the render when admin', async () => {
render(
<MemberAction access={access} currentRole={Role.ADMIN} doc={doc} />,
{
wrapper: AppWrapper,
},
);
expect(
await screen.findByLabelText('Open the member options modal'),
).toBeInTheDocument();
});
it('checks the render when admin to owner', () => {
render(
<MemberAction
access={{ ...access, role: Role.OWNER }}
currentRole={Role.ADMIN}
doc={doc}
/>,
{
wrapper: AppWrapper,
},
);
expect(
screen.queryByLabelText('Open the member options modal'),
).not.toBeInTheDocument();
});
});
@@ -1,396 +0,0 @@
import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import { Access, Doc, Role } from '@/features/docs/doc-management';
import { AppWrapper } from '@/tests/utils';
import { MemberGrid } from '../components/MemberGrid';
const doc: Doc = {
id: '123456',
title: 'teamName',
abilities: {
destroy: true,
manage_accesses: true,
partial_update: true,
retrieve: true,
update: true,
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
},
content: 'content',
is_public: false,
accesses: [],
created_at: '2021-09-01T12:00:00Z',
updated_at: '2021-09-01T12:00:00Z',
};
describe('MemberGrid', () => {
afterEach(() => {
fetchMock.restore();
});
it('renders with no member to display', async () => {
fetchMock.mock(`end:/documents/123456/accesses/?page=1`, {
count: 0,
results: [],
});
render(<MemberGrid doc={doc} />, {
wrapper: AppWrapper,
});
expect(screen.getByRole('status')).toBeInTheDocument();
expect(await screen.findByRole('img')).toHaveAttribute(
'alt',
'Illustration of an empty table',
);
expect(screen.getByText('This table is empty')).toBeInTheDocument();
});
it('checks the render with members', async () => {
const accesses: Access[] = [
{
id: '1',
role: Role.OWNER,
team: '123456',
user: {
id: '11',
email: 'user1@test.com',
},
abilities: {} as any,
},
{
id: '2',
team: '123456',
role: Role.EDITOR,
user: {
id: '22',
email: 'user2@test.com',
},
abilities: {} as any,
},
{
id: '3',
team: '123456',
role: Role.READER,
user: {
id: '33',
email: 'user3@test.com',
},
abilities: {} as any,
},
{
id: '4',
role: Role.ADMIN,
team: '123456',
user: {
id: '44',
email: 'user4@test.com',
},
abilities: {} as any,
},
];
fetchMock.mock(`end:/documents/123456/accesses/?page=1`, {
count: 3,
results: accesses,
});
render(<MemberGrid doc={doc} />, {
wrapper: AppWrapper,
});
expect(screen.getByRole('status')).toBeInTheDocument();
expect(await screen.findByText('user1@test.com')).toBeInTheDocument();
expect(screen.getByText('user2@test.com')).toBeInTheDocument();
expect(screen.getByText('user3@test.com')).toBeInTheDocument();
expect(screen.getByText('user4@test.com')).toBeInTheDocument();
expect(screen.getByText('Owner')).toBeInTheDocument();
expect(screen.getByText('Administrator')).toBeInTheDocument();
expect(screen.getByText('Reader')).toBeInTheDocument();
expect(screen.getByText('Editor')).toBeInTheDocument();
});
it('checks the pagination', async () => {
const regexp = new RegExp(/.*\/documents\/123456\/accesses\/\?page=.*/);
fetchMock.get(regexp, {
count: 40,
results: Array.from({ length: 20 }, (_, i) => ({
id: i,
role: Role.OWNER,
user: {
id: i,
email: `user${i}@test.com`,
},
abilities: {} as any,
})),
});
render(<MemberGrid doc={doc} />, {
wrapper: AppWrapper,
});
expect(screen.getByRole('status')).toBeInTheDocument();
expect(fetchMock.lastUrl()).toContain('/documents/123456/accesses/?page=1');
expect(
await screen.findByLabelText('You are currently on page 1'),
).toBeInTheDocument();
await userEvent.click(screen.getByLabelText('Go to page 2'));
expect(
await screen.findByLabelText('You are currently on page 2'),
).toBeInTheDocument();
expect(fetchMock.lastUrl()).toContain('/documents/123456/accesses/?page=2');
});
[
{
role: Role.OWNER,
expected: true,
},
{
role: Role.EDITOR,
expected: false,
},
{
role: Role.READER,
expected: false,
},
{
role: Role.ADMIN,
expected: true,
},
].forEach(({ role, expected }) => {
it(`checks action button when ${role}`, async () => {
const regexp = new RegExp(/.*\/documents\/123456\/accesses\/\?page=.*/);
fetchMock.get(regexp, {
count: 1,
results: [
{
id: 1,
role: Role.ADMIN,
user: {
id: 1,
email: `user1@test.com`,
},
abilities: {} as any,
},
],
});
render(<MemberGrid doc={doc} />, {
wrapper: AppWrapper,
});
expect(screen.getByRole('status')).toBeInTheDocument();
/* eslint-disable jest/no-conditional-expect */
if (expected) {
expect(
await screen.findAllByRole('button', {
name: 'Open the member options modal',
}),
).toBeDefined();
} else {
expect(
screen.queryByRole('button', {
name: 'Open the member options modal',
}),
).not.toBeInTheDocument();
}
/* eslint-enable jest/no-conditional-expect */
});
});
it('controls the render when api error', async () => {
fetchMock.mock(`end:/documents/123456/accesses/?page=1`, {
status: 500,
body: {
cause: 'All broken :(',
},
});
render(<MemberGrid doc={doc} />, {
wrapper: AppWrapper,
});
expect(screen.getByRole('status')).toBeInTheDocument();
expect(await screen.findByText('All broken :(')).toBeInTheDocument();
});
[
{
ordering: 'email',
header_name: 'Emails',
},
{
ordering: 'role',
header_name: 'Roles',
},
].forEach(({ ordering, header_name }) => {
it(`checks the sorting per ${ordering}`, async () => {
const mockedData = [
{
id: '123',
role: Role.ADMIN,
user: {
id: '123',
email: 'albert@test.com',
},
abilities: {} as any,
},
{
id: '789',
role: Role.OWNER,
user: {
id: '456',
email: 'philipp@test.com',
},
abilities: {} as any,
},
{
id: '456',
role: Role.READER,
user: {
id: '789',
email: 'fany@test.com',
},
abilities: {} as any,
},
{
id: '963',
role: Role.EDITOR,
user: {
id: '4548',
email: 'gege@test.com',
},
abilities: {} as any,
},
];
const sortedMockedData = [...mockedData].sort((a, b) => {
if (ordering === 'email') {
return a.user.email > b.user.email ? 1 : -1;
}
return a.role > b.role ? 1 : -1;
});
const reversedMockedData = [...sortedMockedData].reverse();
fetchMock.get(`end:/documents/123456/accesses/?page=1`, {
count: 4,
results: mockedData,
});
fetchMock.get(
`end:/documents/123456/accesses/?page=1&ordering=${ordering}`,
{
count: 4,
results: sortedMockedData,
},
);
fetchMock.get(
`end:/documents/123456/accesses/?page=1&ordering=-${ordering}`,
{
count: 4,
results: reversedMockedData,
},
);
render(<MemberGrid doc={doc} />, {
wrapper: AppWrapper,
});
expect(screen.getByRole('status')).toBeInTheDocument();
expect(fetchMock.lastUrl()).toContain(
`/documents/123456/accesses/?page=1`,
);
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
let rows = screen.getAllByRole('row');
expect(rows[1]).toHaveTextContent('albert');
expect(rows[2]).toHaveTextContent('philipp');
expect(rows[3]).toHaveTextContent('fany');
expect(rows[4]).toHaveTextContent('gege');
expect(
screen.queryByLabelText('arrow_drop_down'),
).not.toBeInTheDocument();
expect(screen.queryByLabelText('arrow_drop_up')).not.toBeInTheDocument();
await userEvent.click(screen.getByText(header_name));
expect(fetchMock.lastUrl()).toContain(
`/documents/123456/accesses/?page=1&ordering=${ordering}`,
);
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
rows = screen.getAllByRole('row');
expect(rows[1]).toHaveTextContent(sortedMockedData[0].user.email);
expect(rows[2]).toHaveTextContent(sortedMockedData[1].user.email);
expect(rows[3]).toHaveTextContent(sortedMockedData[2].user.email);
expect(rows[4]).toHaveTextContent(sortedMockedData[3].user.email);
expect(await screen.findByText('arrow_drop_up')).toBeInTheDocument();
await userEvent.click(screen.getByText(header_name));
expect(fetchMock.lastUrl()).toContain(
`/documents/123456/accesses/?page=1&ordering=-${ordering}`,
);
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
rows = screen.getAllByRole('row');
expect(rows[1]).toHaveTextContent(reversedMockedData[0].user.email);
expect(rows[2]).toHaveTextContent(reversedMockedData[1].user.email);
expect(rows[3]).toHaveTextContent(reversedMockedData[2].user.email);
expect(rows[4]).toHaveTextContent(reversedMockedData[3].user.email);
expect(await screen.findByText('arrow_drop_down')).toBeInTheDocument();
await userEvent.click(screen.getByText(header_name));
expect(fetchMock.lastUrl()).toContain(
'/documents/123456/accesses/?page=1',
);
await waitFor(() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
});
rows = screen.getAllByRole('row');
expect(rows[1]).toHaveTextContent('albert');
expect(rows[2]).toHaveTextContent('philipp');
expect(rows[3]).toHaveTextContent('fany');
expect(rows[4]).toHaveTextContent('gege');
expect(
screen.queryByLabelText('arrow_drop_down'),
).not.toBeInTheDocument();
expect(screen.queryByLabelText('arrow_drop_up')).not.toBeInTheDocument();
});
});
});
@@ -1,305 +0,0 @@
import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import { useAuthStore } from '@/core/auth';
import { Access, Role } from '@/features/docs/doc-management';
import { AppWrapper } from '@/tests/utils';
import { ModalRole } from '../components/ModalRole';
const toast = jest.fn();
jest.mock('@openfun/cunningham-react', () => ({
...jest.requireActual('@openfun/cunningham-react'),
useToastProvider: () => ({
toast,
}),
}));
HTMLDialogElement.prototype.showModal = jest.fn(function mock(
this: HTMLDialogElement,
) {
this.open = true;
});
const access: Access = {
id: '789',
role: Role.ADMIN,
team: '123',
user: {
id: '11',
email: 'user1@test.com',
},
abilities: {
set_role_to: [Role.EDITOR, Role.ADMIN],
} as any,
};
describe('ModalRole', () => {
afterEach(() => {
fetchMock.restore();
});
it('checks the cancel button', async () => {
const onClose = jest.fn();
render(
<ModalRole
access={access}
currentRole={Role.ADMIN}
onClose={onClose}
docId="123"
/>,
{
wrapper: AppWrapper,
},
);
await userEvent.click(
screen.getByRole('button', {
name: 'Cancel',
}),
);
expect(onClose).toHaveBeenCalled();
});
it('updates the role successfully', async () => {
fetchMock.mock(`end:/documents/123/accesses/789/`, {
status: 200,
ok: true,
});
const onClose = jest.fn();
render(
<ModalRole
access={access}
currentRole={Role.OWNER}
onClose={onClose}
docId="123"
/>,
{ wrapper: AppWrapper },
);
expect(
screen.getByRole('radio', {
name: 'Administrator',
}),
).toBeChecked();
await userEvent.click(
screen.getByRole('radio', {
name: 'Reader',
}),
);
await userEvent.click(
screen.getByRole('button', {
name: 'Validate',
}),
);
await waitFor(() => {
expect(toast).toHaveBeenCalledWith(
'The role has been updated',
'success',
{
duration: 4000,
},
);
});
expect(fetchMock.lastUrl()).toContain(`/documents/123/accesses/789/`);
expect(onClose).toHaveBeenCalled();
});
it('fails to update the role', async () => {
fetchMock.patchOnce(`end:/documents/123/accesses/789/`, {
status: 500,
body: {
detail: 'The server is totally broken',
},
});
render(
<ModalRole
access={access}
currentRole={Role.OWNER}
onClose={jest.fn()}
docId="123"
/>,
{ wrapper: AppWrapper },
);
await userEvent.click(
screen.getByRole('radio', {
name: 'Reader',
}),
);
await userEvent.click(
screen.getByRole('button', {
name: 'Validate',
}),
);
expect(
await screen.findByText('The server is totally broken'),
).toBeInTheDocument();
});
it('checks the render when last owner', () => {
useAuthStore.setState({
userData: access.user,
});
const access2: Access = {
...access,
role: Role.OWNER,
abilities: {
set_role_to: [],
} as any,
};
render(
<ModalRole
access={access2}
currentRole={Role.OWNER}
onClose={jest.fn()}
docId="123"
/>,
{ wrapper: AppWrapper },
);
expect(
screen.getByText(
'You are the sole owner of this group, make another member the group owner, before you can change your own role.',
),
).toBeInTheDocument();
expect(
screen.getByRole('radio', {
name: 'Administrator',
}),
).toBeDisabled();
expect(
screen.getByRole('radio', {
name: 'Owner',
}),
).toBeDisabled();
expect(
screen.getByRole('radio', {
name: 'Reader',
}),
).toBeDisabled();
expect(
screen.getByRole('radio', {
name: 'Editor',
}),
).toBeDisabled();
expect(
screen.getByRole('button', {
name: 'Validate',
}),
).toBeDisabled();
});
it('checks the render when it is another owner', () => {
useAuthStore.setState({
userData: {
id: '12',
email: 'username2@test.com',
},
});
const access2: Access = {
...access,
role: Role.OWNER,
};
render(
<ModalRole
access={access2}
currentRole={Role.OWNER}
onClose={jest.fn()}
docId="123"
/>,
{ wrapper: AppWrapper },
);
expect(
screen.getByText('You cannot update the role of other owner.'),
).toBeInTheDocument();
expect(
screen.getByRole('radio', {
name: 'Administrator',
}),
).toBeDisabled();
expect(
screen.getByRole('radio', {
name: 'Owner',
}),
).toBeDisabled();
expect(
screen.getByRole('radio', {
name: 'Reader',
}),
).toBeDisabled();
expect(
screen.getByRole('radio', {
name: 'Editor',
}),
).toBeDisabled();
expect(
screen.getByRole('button', {
name: 'Validate',
}),
).toBeDisabled();
});
it('checks the render when current user is admin', () => {
render(
<ModalRole
access={access}
currentRole={Role.ADMIN}
onClose={jest.fn()}
docId="123"
/>,
{ wrapper: AppWrapper },
);
expect(
screen.getByRole('radio', {
name: 'Editor',
}),
).toBeEnabled();
expect(
screen.getByRole('radio', {
name: 'Reader',
}),
).toBeEnabled();
expect(
screen.getByRole('radio', {
name: 'Administrator',
}),
).toBeEnabled();
expect(
screen.getByRole('radio', {
name: 'Owner',
}),
).toBeDisabled();
});
});
@@ -1,92 +0,0 @@
import { Button } from '@openfun/cunningham-react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, DropButton, IconOptions, Text } from '@/components';
import { Access, Doc, Role } from '@/features/docs/doc-management';
import { ModalDelete } from './ModalDelete';
import { ModalRole } from './ModalRole';
interface MemberActionProps {
access: Access;
currentRole: Role;
doc: Doc;
}
export const MemberAction = ({
access,
currentRole,
doc,
}: MemberActionProps) => {
const { t } = useTranslation();
const [isModalRoleOpen, setIsModalRoleOpen] = useState(false);
const [isModalDeleteOpen, setIsModalDeleteOpen] = useState(false);
const [isDropOpen, setIsDropOpen] = useState(false);
if (
currentRole === Role.EDITOR ||
currentRole === Role.READER ||
(access.role === Role.OWNER && currentRole === Role.ADMIN)
) {
return null;
}
return (
<>
<DropButton
button={
<IconOptions
isOpen={isDropOpen}
aria-label={t('Open the member options modal')}
/>
}
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
isOpen={isDropOpen}
>
<Box>
<Button
aria-label={t('Open the modal to update the role of this member')}
onClick={() => {
setIsModalRoleOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">edit</span>}
size="small"
>
<Text $theme="primary">{t('Update role')}</Text>
</Button>
<Button
aria-label={t('Open the modal to delete this member')}
onClick={() => {
setIsModalDeleteOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">delete</span>}
size="small"
>
{t('Remove from group')}
</Button>
</Box>
</DropButton>
{isModalRoleOpen && (
<ModalRole
access={access}
currentRole={currentRole}
onClose={() => setIsModalRoleOpen(false)}
docId={doc.id}
/>
)}
{isModalDeleteOpen && (
<ModalDelete
access={access}
currentRole={currentRole}
onClose={() => setIsModalDeleteOpen(false)}
doc={doc}
/>
)}
</>
);
};
@@ -1,129 +0,0 @@
import { DataGrid, SortModel, usePagination } from '@openfun/cunningham-react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, TextErrors } from '@/components';
import { Doc, Role, currentDocRole } from '@/features/docs/doc-management';
import { useDocAccesses } from '../api';
import { PAGE_SIZE } from '../conf';
import { MemberAction } from './MemberAction';
interface MemberGridProps {
doc: Doc;
}
// FIXME : ask Cunningham to export this type
type SortModelItem = {
field: string;
sort: 'asc' | 'desc' | null;
};
const defaultOrderingMapping: Record<string, string> = {
'user.name': 'name',
'user.email': 'email',
localizedRole: 'role',
};
/**
* Formats the sorting model based on a given mapping.
* @param {SortModelItem} sortModel The sorting model item containing field and sort direction.
* @param {Record<string, string>} mapping The mapping object to map field names.
* @returns {string} The formatted sorting string.
*/
function formatSortModel(
sortModel: SortModelItem,
mapping = defaultOrderingMapping,
) {
const { field, sort } = sortModel;
const orderingField = mapping[field] || field;
return sort === 'desc' ? `-${orderingField}` : orderingField;
}
export const MemberGrid = ({ doc }: MemberGridProps) => {
const { t } = useTranslation();
const pagination = usePagination({
pageSize: PAGE_SIZE,
});
const [sortModel, setSortModel] = useState<SortModel>([]);
const { page, pageSize, setPagesCount } = pagination;
const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined;
const { data, isLoading, error } = useDocAccesses({
docId: doc.id,
page,
ordering,
});
const translatedRoles = {
[Role.ADMIN]: t('Administrator'),
[Role.READER]: t('Reader'),
[Role.OWNER]: t('Owner'),
[Role.EDITOR]: t('Editor'),
};
/*
* Bug occurs from the Cunningham Datagrid component, when applying sorting
* on null values. Sanitize empty values to ensure consistent sorting functionality.
*/
const accesses =
data?.results?.map((access) => ({
...access,
localizedRole: translatedRoles[access.role],
email: access.user.email,
})) || [];
useEffect(() => {
setPagesCount(data?.count ? Math.ceil(data.count / pageSize) : 0);
}, [data?.count, pageSize, setPagesCount]);
return (
<Card
$padding={{ bottom: 'small' }}
$margin={{ all: 'big', top: 'none' }}
$overflow="auto"
$css={`
& table td:last-child {
text-align: right;
}
`}
aria-label={t('List members card')}
>
{error && <TextErrors causes={error.cause} />}
<DataGrid
columns={[
{
field: 'email',
headerName: t('Emails'),
},
{
field: 'localizedRole',
headerName: t('Roles'),
size: 200,
},
{
id: 'column-actions',
size: 125,
renderCell: ({ row }) => {
return (
<MemberAction
doc={doc}
access={row}
currentRole={currentDocRole(doc)}
/>
);
},
},
]}
rows={accesses}
isLoading={isLoading}
pagination={pagination}
onSortModelChange={setSortModel}
sortModel={sortModel}
/>
</Card>
);
};
@@ -1,132 +0,0 @@
import {
Alert,
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useRouter } from 'next/navigation';
import { useTranslation } from 'react-i18next';
import IconUser from '@/assets/icons/icon-user.svg';
import { Box, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Access, Doc, Role } from '@/features/docs/doc-management';
import { useDeleteDocAccess } from '../api';
import IconRemoveMember from '../assets/icon-remove-member.svg';
import { useWhoAmI } from '../hooks/useWhoAmI';
interface ModalDeleteProps {
access: Access;
currentRole: Role;
onClose: () => void;
doc: Doc;
}
export const ModalDelete = ({ access, onClose, doc }: ModalDeleteProps) => {
const { toast } = useToastProvider();
const { colorsTokens } = useCunninghamTheme();
const router = useRouter();
const { t } = useTranslation();
const { isMyself, isLastOwner, isOtherOwner } = useWhoAmI(access);
const isNotAllowed = isOtherOwner || isLastOwner;
const {
mutate: removeDocAccess,
error: errorUpdate,
isError: isErrorUpdate,
} = useDeleteDocAccess({
onSuccess: () => {
toast(
t('The member has been removed from the document'),
VariantType.SUCCESS,
{
duration: 4000,
},
);
// If we remove ourselves, we redirect to the home page
// because we are no longer part of the team
isMyself ? router.push('/') : onClose();
},
});
return (
<Modal
isOpen
closeOnClickOutside
hideCloseButton
leftActions={
<Button color="secondary" fullWidth onClick={() => onClose()}>
{t('Cancel')}
</Button>
}
onClose={onClose}
rightActions={
<Button
color="primary"
fullWidth
onClick={() => {
removeDocAccess({
docId: doc.id,
accessId: access.id,
});
}}
disabled={isNotAllowed}
>
{t('Validate')}
</Button>
}
size={ModalSize.MEDIUM}
title={
<Box $align="center" $gap="1rem">
<IconRemoveMember width={48} color={colorsTokens()['primary-text']} />
<Text $size="h3" $margin="none">
{t('Remove the member')}
</Text>
</Box>
}
>
<Box aria-label={t('Radio buttons to update the roles')}>
{!isLastOwner && !isOtherOwner && !isErrorUpdate && (
<Alert canClose={false} type={VariantType.INFO}>
<Text>
{t(
'Are you sure you want to remove this member from the document?',
)}
</Text>
</Alert>
)}
{isErrorUpdate && <TextErrors causes={errorUpdate.cause} />}
{(isLastOwner || isOtherOwner) && !isErrorUpdate && (
<Alert canClose={false} type={VariantType.WARNING}>
<Text>
{isLastOwner &&
t(
'You are the last owner, you cannot be removed from your document.',
)}
{isOtherOwner && t('You cannot remove other owner.')}
</Text>
</Alert>
)}
<Text
as="p"
$padding="big"
$direction="row"
$gap="0.5rem"
$background={colorsTokens()['primary-150']}
$theme="primary"
>
<IconUser width={20} height={20} />
<Text>{access.user.email}</Text>
</Text>
</Box>
</Modal>
);
};
@@ -1,47 +0,0 @@
import { Modal, ModalSize } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { Box, Text } from '@/components';
import { Doc } from '@/features/docs/doc-management';
import { MemberGrid } from './MemberGrid';
const GlobalStyle = createGlobalStyle`
.c__modal {
overflow: visible;
}
`;
interface ModalGridMembersProps {
onClose: () => void;
doc: Doc;
}
export const ModalGridMembers = ({ doc, onClose }: ModalGridMembersProps) => {
const { t } = useTranslation();
return (
<Modal
isOpen
onClose={onClose}
closeOnClickOutside
size={ModalSize.LARGE}
title={
<Box $align="center" $gap="1rem">
<Text className="material-icons" $size="3.5rem" $theme="primary">
group
</Text>
<Text $size="h3" $margin="none">
{t('Members of the document')}
</Text>
</Box>
}
>
<GlobalStyle />
<Box $margin={{ top: 'large' }} $maxHeight="60vh">
<MemberGrid doc={doc} />
</Box>
</Modal>
);
};
@@ -1,129 +0,0 @@
import {
Alert,
Button,
Modal,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text, TextErrors } from '@/components';
import { Access, Role } from '@/features/docs/doc-management';
import { ChooseRole } from '../../members-add/components/ChooseRole';
import { useUpdateDocAccess } from '../api';
import { useWhoAmI } from '../hooks/useWhoAmI';
interface ModalRoleProps {
access: Access;
currentRole: Role;
onClose: () => void;
docId: string;
}
export const ModalRole = ({
access,
currentRole,
onClose,
docId,
}: ModalRoleProps) => {
const { t } = useTranslation();
const [localRole, setLocalRole] = useState(access.role);
const { toast } = useToastProvider();
const {
mutate: updateDocAccess,
error: errorUpdate,
isError: isErrorUpdate,
isPending,
} = useUpdateDocAccess({
onSuccess: () => {
toast(t('The role has been updated'), VariantType.SUCCESS, {
duration: 4000,
});
onClose();
},
});
const { isLastOwner, isOtherOwner } = useWhoAmI(access);
const isNotAllowed = isOtherOwner || isLastOwner;
return (
<Modal
isOpen
leftActions={
<Button
color="secondary"
fullWidth
onClick={() => onClose()}
disabled={isPending}
>
{t('Cancel')}
</Button>
}
onClose={() => onClose()}
closeOnClickOutside
hideCloseButton
rightActions={
<Button
color="primary"
fullWidth
onClick={() => {
updateDocAccess({
role: localRole,
docId,
accessId: access.id,
});
}}
disabled={isNotAllowed || isPending}
>
{t('Validate')}
</Button>
}
size={ModalSize.MEDIUM}
title={t('Update the role')}
>
<Box aria-label={t('Radio buttons to update the roles')}>
{isErrorUpdate && <TextErrors causes={errorUpdate.cause} />}
{(isLastOwner || isOtherOwner) && (
<Box
$direction="row"
$align="center"
$gap="0.5rem"
$margin={{ bottom: 'tiny', top: 'none' }}
>
<Alert
canClose={false}
type={VariantType.WARNING}
icon={
<Text className="material-icons" $theme="warning">
warning
</Text>
}
>
{isLastOwner && (
<Box $direction="column" $gap="0.2rem">
<Text $theme="warning">
{t(
'You are the sole owner of this group, make another member the group owner, before you can change your own role.',
)}
</Text>
</Box>
)}
{isOtherOwner && t('You cannot update the role of other owner.')}
</Alert>
</Box>
)}
<ChooseRole
defaultRole={access.role}
currentRole={currentRole}
disabled={isNotAllowed}
setRole={setLocalRole}
/>
</Box>
</Modal>
);
};
@@ -1 +0,0 @@
export * from './ModalGridMembers';
@@ -48,7 +48,7 @@ export const useDeleteDocAccess = (options?: UseDeleteDocAccessOptions) => {
void queryClient.invalidateQueries({
queryKey: [KEY_DOC],
});
void queryClient.invalidateQueries({
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC],
});
if (options?.onSuccess) {
@@ -1,14 +1,24 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import {
DefinedInitialDataInfiniteOptions,
InfiniteData,
QueryKey,
UseQueryOptions,
useInfiniteQuery,
useQuery,
} from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import { Access } from '@/features/docs/doc-management';
export type DocAccessesAPIParams = {
page: number;
export type DocAccessesParam = {
docId: string;
ordering?: string;
};
export type DocAccessesAPIParams = DocAccessesParam & {
page: number;
};
type AccessesResponse = APIList<Access>;
export const getDocAccesses = async ({
@@ -46,3 +56,39 @@ export function useDocAccesses(
...queryConfig,
});
}
/**
* @param param Used for infinite scroll pagination
* @param queryConfig
* @returns
*/
export function useDocAccessesInfinite(
param: DocAccessesParam,
queryConfig?: DefinedInitialDataInfiniteOptions<
AccessesResponse,
APIError,
InfiniteData<AccessesResponse>,
QueryKey,
number
>,
) {
return useInfiniteQuery<
AccessesResponse,
APIError,
InfiniteData<AccessesResponse>,
QueryKey,
number
>({
initialPageParam: 1,
queryKey: [KEY_LIST_DOC_ACCESSES, param],
queryFn: ({ pageParam }) =>
getDocAccesses({
...param,
page: pageParam,
}),
getNextPageParam(lastPage, allPages) {
return lastPage.next ? allPages.length + 1 : undefined;
},
...queryConfig,
});
}

Some files were not shown because too many files have changed in this diff Show More