Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9790824ce1 | |||
| ccadd9567a | |||
| 2c9758eb30 | |||
| 0f71a3fcfa | |||
| 839b78a6d0 | |||
| 69f2641159 | |||
| e5de5a4345 | |||
| c2d6e60ae8 | |||
| ff832239d3 | |||
| a60399883b | |||
| 12d32fe933 | |||
| 038d868d29 | |||
| bfde526361 | |||
| 132da0837f | |||
| 164ed885ca | |||
| f5a35c66bb | |||
| 8100422776 | |||
| f48e385d3d | |||
| b5895bf297 | |||
| 0fb0c71cfa | |||
| a0fea64630 | |||
| de922e1c04 | |||
| 8007c45a35 | |||
| 3d370e5714 | |||
| 03953f0fe7 | |||
| 67f4ddeef7 | |||
| 6f4b4bb7d3 | |||
| c27e6c1b06 | |||
| d2888d0b9d | |||
| 6b8af1f9ec | |||
| 35852dff0b | |||
| be93598b2d | |||
| 7d3fd25c61 |
@@ -1,4 +1,4 @@
|
||||
name: impress Workflow
|
||||
name: Frontend Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: impress Workflow
|
||||
name: Main Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
+17
-1
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+19
-22
@@ -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,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'],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+19
-44
@@ -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],
|
||||
});
|
||||
});
|
||||
|
||||
-75
@@ -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>
|
||||
);
|
||||
};
|
||||
+190
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
-119
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+33
@@ -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';
|
||||
|
||||
+2
-2
@@ -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({
|
||||
|
||||
+44
-73
@@ -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>
|
||||
);
|
||||
};
|
||||
+17
-25
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+19
@@ -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';
|
||||
|
||||
-101
@@ -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();
|
||||
});
|
||||
});
|
||||
-396
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
-305
@@ -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();
|
||||
});
|
||||
});
|
||||
-92
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
-129
@@ -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>
|
||||
);
|
||||
};
|
||||
-132
@@ -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>
|
||||
);
|
||||
};
|
||||
-47
@@ -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>
|
||||
);
|
||||
};
|
||||
-129
@@ -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';
|
||||
+1
-1
@@ -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) {
|
||||
+49
-3
@@ -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
Reference in New Issue
Block a user