Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50dfb53a17 | |||
| 6eac20da61 | |||
| c42fd9be9c | |||
| 2b824c0862 | |||
| fb494c8c71 | |||
| 8d5648005f | |||
| 713d9e48c8 | |||
| bef541f956 | |||
| 4b61ffce01 | |||
| a9383212a3 | |||
| 1ed20c3896 | |||
| b5443676c1 | |||
| 424c100eeb | |||
| 91be4f5a21 | |||
| 6eb0ac99e4 | |||
| e79a74083a | |||
| 905000550d | |||
| 76d623a89b | |||
| 61cf8aae74 | |||
| 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 | |||
| e3fe647e5b | |||
| 8f5c413482 | |||
| a66231d72e | |||
| 2e0b6b2a2a | |||
| edffbbaf99 | |||
| a393cd4df0 | |||
| e26b806965 | |||
| aef19cfddd |
@@ -1,4 +1,4 @@
|
||||
name: impress Workflow
|
||||
name: Frontend Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: impress Workflow
|
||||
name: Main Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
+54
-21
@@ -8,30 +8,61 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.0] - 2024-08-06
|
||||
|
||||
## Added
|
||||
|
||||
- 🎨(frontend) better conversion editor to pdf #151
|
||||
- ✨(frontend) Versioning #147
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(y-webrtc) fix prob connection #147
|
||||
- ⚡️(frontend) improve select share stability #159
|
||||
|
||||
## Changed
|
||||
|
||||
- 🎨(frontend) stop limit layout height to screen size #158
|
||||
|
||||
## [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
|
||||
|
||||
- Manage the document's right (#75)
|
||||
- Update document (#68)
|
||||
- Remove document (#68)
|
||||
- (docker) dockerize dev frontend (#63)
|
||||
- (backend) list users with email filtering (#79)
|
||||
- (frontend) add user to a document (#52)
|
||||
- (frontend) invite user to a document (#52)
|
||||
- (frontend) manage members (update role / list / remove) (#81)
|
||||
- 🛂(frontend) Manage the document's right (#75)
|
||||
- ✨(frontend) Update document (#68)
|
||||
- ✨(frontend) Remove document (#68)
|
||||
- 🐳(docker) dockerize dev frontend (#63)
|
||||
- 👔(backend) list users with email filtering (#79)
|
||||
- ✨(frontend) add user to a document (#52)
|
||||
- ✨(frontend) invite user to a document (#52)
|
||||
- 🛂(frontend) manage members (update role / list / remove) (#81)
|
||||
- ✨(frontend) offline mode (#88)
|
||||
- (frontend) translate cgu (#83)
|
||||
- 🌐(frontend) translate cgu (#83)
|
||||
- ✨(service-worker) offline doc management (#94)
|
||||
- (frontend) Add beta tag on logo (#121)
|
||||
- ⚗️(frontend) Add beta tag on logo (#121)
|
||||
|
||||
## Changed
|
||||
|
||||
- Change site from Impress to Docs (#76)
|
||||
- Generate PDF from a modal (#68)
|
||||
- 🔧 (helm) sticky session by request_uri for signaling server (#78)
|
||||
- (frontend) change logo (#84)
|
||||
- (frontend) pdf has title doc (#84)
|
||||
- ♻️(frontend) Change site from Impress to Docs (#76)
|
||||
- ✨(frontend) Generate PDF from a modal (#68)
|
||||
- 🔧(helm) sticky session by request_uri for signaling server (#78)
|
||||
- ♻️(frontend) change logo (#84)
|
||||
- ♻️(frontend) pdf has title doc (#84)
|
||||
- ⚡️(e2e) unique login between tests (#80)
|
||||
- ⚡️(CI) improve e2e job (#86)
|
||||
- ♻️(frontend) improve the error and message info ui (#93)
|
||||
@@ -39,21 +70,23 @@ and this project adheres to
|
||||
|
||||
## Fixed
|
||||
|
||||
- Fix the break line when generate PDF (#84)
|
||||
- 🐛(frontend) Fix the break line when generate PDF (#84)
|
||||
|
||||
## Delete
|
||||
|
||||
- Remove trigger workflow on push tags on CI (#68)
|
||||
- (frontend) Remove coming soon page (#121)
|
||||
- 💚(CI) Remove trigger workflow on push tags on CI (#68)
|
||||
- 🔥(frontend) Remove coming soon page (#121)
|
||||
|
||||
## [0.1.0] - 2024-05-24
|
||||
|
||||
## Added
|
||||
|
||||
- Coming Soon page (#67)
|
||||
- Impress, project to manage your documents easily and collaboratively.
|
||||
- ✨(frontend) Coming Soon page (#67)
|
||||
- 🚀 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.2.0...main
|
||||
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
|
||||
[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):
|
||||
@@ -250,3 +259,12 @@ class InvitationSerializer(serializers.ModelSerializer):
|
||||
attrs["document_id"] = document_id
|
||||
attrs["issuer"] = user
|
||||
return attrs
|
||||
|
||||
|
||||
class DocumentVersionSerializer(serializers.Serializer):
|
||||
"""Serialize Versions."""
|
||||
|
||||
etag = serializers.CharField()
|
||||
is_latest = serializers.BooleanField()
|
||||
last_modified = serializers.DateTimeField()
|
||||
version_id = serializers.CharField()
|
||||
|
||||
@@ -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):
|
||||
@@ -337,10 +337,18 @@ class DocumentViewSet(
|
||||
Q(user=request.user) | Q(team__in=request.user.get_teams()),
|
||||
)
|
||||
)
|
||||
return drf_response.Response(
|
||||
document.get_versions_slice(from_datetime=from_datetime)
|
||||
|
||||
versions_data = document.get_versions_slice(from_datetime=from_datetime)[
|
||||
"versions"
|
||||
]
|
||||
paginator = pagination.PageNumberPagination()
|
||||
paginated_versions = paginator.paginate_queryset(versions_data, request)
|
||||
serialized_versions = serializers.DocumentVersionSerializer(
|
||||
paginated_versions, many=True
|
||||
)
|
||||
|
||||
return paginator.get_paginated_response(serialized_versions.data)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["get", "delete"],
|
||||
@@ -377,6 +385,7 @@ class DocumentViewSet(
|
||||
{
|
||||
"content": response["Body"].read().decode("utf-8"),
|
||||
"last_modified": response["LastModified"],
|
||||
"id": version_id,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -125,7 +125,8 @@ def test_api_document_versions_list_authenticated_related(via, mock_user_get_tea
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["versions"]) == 0
|
||||
assert len(content["results"]) == 0
|
||||
assert content["count"] == 0
|
||||
|
||||
# Add a new version to the document
|
||||
document.content = "new content"
|
||||
@@ -137,9 +138,8 @@ def test_api_document_versions_list_authenticated_related(via, mock_user_get_tea
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["versions"]) == 1
|
||||
assert content["next_version_id_marker"] == ""
|
||||
assert content["is_truncated"] is False
|
||||
assert len(content["results"]) == 1
|
||||
assert content["count"] == 1
|
||||
|
||||
|
||||
def test_api_document_versions_retrieve_anonymous_public():
|
||||
|
||||
@@ -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 = "0.1.0"
|
||||
version = "1.2.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",
|
||||
@@ -51,7 +51,7 @@ dependencies = [
|
||||
"PyJWT==2.8.0",
|
||||
"python-frontmatter==1.0.1",
|
||||
"requests==2.32.2",
|
||||
"sentry-sdk==1.38.0",
|
||||
"sentry-sdk==2.8.0",
|
||||
"url-normalize==1.4.3",
|
||||
"WeasyPrint>=60.2",
|
||||
"whitenoise==6.6.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,69 @@ 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;
|
||||
};
|
||||
|
||||
export const mockedDocument = async (page: Page, json: object) => {
|
||||
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: false, // Means not owner
|
||||
versions_destroy: false,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
manage_accesses: false, // Means not admin
|
||||
update: false,
|
||||
partial_update: false, // Means not editor
|
||||
retrieve: true,
|
||||
},
|
||||
is_public: false,
|
||||
created_at: '2021-09-01T09:00:00Z',
|
||||
...json,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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, mockedDocument } 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,48 +145,31 @@ 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 }) => {
|
||||
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: false, // Means not owner
|
||||
versions_destroy: false,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
manage_accesses: false, // Means not admin
|
||||
update: false,
|
||||
partial_update: false, // Means not editor
|
||||
retrieve: true,
|
||||
},
|
||||
is_public: false,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
test('it cannot edit if viewer', async ({ page }) => {
|
||||
await mockedDocument(page, {
|
||||
abilities: {
|
||||
destroy: false, // Means not owner
|
||||
versions_destroy: false,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
manage_accesses: false, // Means not admin
|
||||
update: false,
|
||||
partial_update: false, // Means not editor
|
||||
retrieve: true,
|
||||
},
|
||||
});
|
||||
|
||||
await createDoc(page, 'doc-right-edit', browserName, 1);
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
"Read only, you don't have the right to update this document.",
|
||||
),
|
||||
page.getByText('Read only, you cannot edit this document.'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(/Members/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,66 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { goToGridDoc, mockedDocument } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Doc Header', () => {
|
||||
test('it checks the element are correctly displayed', async ({ page }) => {
|
||||
await mockedDocument(page, {
|
||||
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',
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
+36
-24
@@ -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,17 +166,17 @@ 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/);
|
||||
|
||||
const email = randomName('test@test.fr', browserName, 1)[0];
|
||||
const [email] = randomName('test@test.fr', browserName, 1);
|
||||
await inputSearch.fill(email);
|
||||
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,23 @@ 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.getByRole('option', { name: email })).toBeHidden();
|
||||
await page.getByRole('option', { name: email }).click();
|
||||
// Choose a role
|
||||
await page.getByRole('combobox', { name: /Choose a role/ }).click();
|
||||
await page.getByRole('option', { name: 'Owner' }).click();
|
||||
|
||||
const responsePromiseCreateInvitationFail = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations/') && response.status() === 400,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Validate' }).click();
|
||||
await expect(
|
||||
page.getByText(`"${email}" is already invited to the document.`),
|
||||
).toBeVisible();
|
||||
const responseCreateInvitationFail =
|
||||
await responsePromiseCreateInvitationFail;
|
||||
expect(responseCreateInvitationFail.ok()).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -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,161 +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();
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
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, mockedDocument } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -67,6 +67,21 @@ test.describe('Doc Tools', () => {
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('.bn-block-outer').last().fill('Break');
|
||||
await expect(page.getByText('Break')).toBeVisible();
|
||||
|
||||
// Center the text
|
||||
await page.getByText('Break').dblclick();
|
||||
await page.locator('button[data-test="alignTextCenter"]').click();
|
||||
|
||||
// Change the background color
|
||||
await page.getByText('Break').dblclick();
|
||||
await page.locator('button[data-test="colors"]').click();
|
||||
await page.locator('button[data-test="background-color-brown"]').click();
|
||||
|
||||
// Change the text color
|
||||
await page.getByText('Break').dblclick();
|
||||
await page.locator('button[data-test="colors"]').click();
|
||||
await page.locator('button[data-test="text-color-orange"]').click();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
@@ -82,7 +97,10 @@ test.describe('Doc Tools', () => {
|
||||
.click();
|
||||
|
||||
// Empty paragraph should be replaced by a <br/>
|
||||
expect(body).toContain('<br/><br/>');
|
||||
expect(body.match(/<br\/>/g)?.length).toBeGreaterThanOrEqual(2);
|
||||
expect(body).toContain('style="color: orange;"');
|
||||
expect(body).toContain('style="text-align: center;"');
|
||||
expect(body).toContain('style="background-color: brown;"');
|
||||
});
|
||||
|
||||
test('it updates the doc', async ({ page, browserName }) => {
|
||||
@@ -123,10 +141,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,53 +188,39 @@ 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,
|
||||
}) => {
|
||||
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: false, // Means not owner
|
||||
versions_destroy: true,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
manage_accesses: true, // Means admin
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
},
|
||||
is_public: false,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
test('it checks the options available if administrator', async ({ page }) => {
|
||||
await mockedDocument(page, {
|
||||
abilities: {
|
||||
destroy: false, // Means not owner
|
||||
versions_destroy: true,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
manage_accesses: true, // Means admin
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
},
|
||||
});
|
||||
|
||||
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,49 +232,28 @@ test.describe('Doc Tools', () => {
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('it checks the options available if editor', 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: false, // Means not owner
|
||||
versions_destroy: true,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
manage_accesses: false, // Means not admin
|
||||
update: true,
|
||||
partial_update: true, // Means editor
|
||||
retrieve: true,
|
||||
},
|
||||
is_public: false,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
test('it checks the options available if editor', async ({ page }) => {
|
||||
await mockedDocument(page, {
|
||||
abilities: {
|
||||
destroy: false, // Means not owner
|
||||
versions_destroy: true,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
manage_accesses: false, // Means not admin
|
||||
update: true,
|
||||
partial_update: true, // Means editor
|
||||
retrieve: true,
|
||||
},
|
||||
});
|
||||
|
||||
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,49 +265,29 @@ test.describe('Doc Tools', () => {
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('it checks the options available if reader', 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: false, // Means not owner
|
||||
versions_destroy: false,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
manage_accesses: false, // Means not admin
|
||||
update: false,
|
||||
partial_update: false, // Means not editor
|
||||
retrieve: true,
|
||||
},
|
||||
is_public: false,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
test('it checks the options available if reader', async ({ page }) => {
|
||||
await mockedDocument(page, {
|
||||
abilities: {
|
||||
destroy: false, // Means not owner
|
||||
versions_destroy: false,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
manage_accesses: false, // Means not admin
|
||||
update: false,
|
||||
partial_update: false, // Means not editor
|
||||
retrieve: true,
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, goToGridDoc, mockedDocument } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Doc Version', () => {
|
||||
test('it displays the doc versions', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
|
||||
const panel = page.getByLabel('Document version panel');
|
||||
|
||||
await expect(panel.getByText('Current version')).toBeVisible();
|
||||
expect(await panel.locator('li').count()).toBe(1);
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').last().fill('Hello World');
|
||||
|
||||
await goToGridDoc(page, {
|
||||
title: randomDoc,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Hello World')).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.ProseMirror.bn-editor')
|
||||
.last()
|
||||
.fill('It will create a version');
|
||||
|
||||
await goToGridDoc(page, {
|
||||
title: randomDoc,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Hello World')).toBeHidden();
|
||||
await expect(page.getByText('It will create a version')).toBeVisible();
|
||||
|
||||
await expect(panel.getByText('Current version')).toBeVisible();
|
||||
expect(await panel.locator('li').count()).toBe(2);
|
||||
|
||||
await panel.locator('li').nth(1).click();
|
||||
await expect(
|
||||
page.getByText('Read only, you cannot edit document versions.'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Hello World')).toBeVisible();
|
||||
await expect(page.getByText('It will create a version')).toBeHidden();
|
||||
|
||||
await panel.getByText('Current version').click();
|
||||
await expect(page.getByText('Hello World')).toBeHidden();
|
||||
await expect(page.getByText('It will create a version')).toBeVisible();
|
||||
});
|
||||
|
||||
test('it does not display the doc versions if not allowed', async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockedDocument(page, {
|
||||
abilities: {
|
||||
versions_list: false,
|
||||
partial_update: true,
|
||||
},
|
||||
});
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel('Document version panel')).toBeHidden();
|
||||
});
|
||||
|
||||
test('it restores the doc version', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
await page.locator('.bn-block-outer').last().fill('Hello');
|
||||
|
||||
await goToGridDoc(page, {
|
||||
title: randomDoc,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Hello')).toBeVisible();
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('.bn-block-outer').last().fill('World');
|
||||
|
||||
await goToGridDoc(page, {
|
||||
title: randomDoc,
|
||||
});
|
||||
|
||||
await expect(page.getByText('World')).toBeVisible();
|
||||
|
||||
const panel = page.getByLabel('Document version panel');
|
||||
await panel.locator('li').nth(1).click();
|
||||
await expect(page.getByText('World')).toBeHidden();
|
||||
|
||||
await panel.getByLabel('Open the version options').click();
|
||||
await page.getByText('Restore the version').click();
|
||||
|
||||
await expect(panel.locator('li')).toHaveCount(3);
|
||||
|
||||
await panel.getByText('Current version').click();
|
||||
await expect(page.getByText('Hello')).toBeVisible();
|
||||
await expect(page.getByText('World')).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts",
|
||||
@@ -9,7 +9,7 @@
|
||||
"test:ui": "yarn test --ui"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.45.0",
|
||||
"@playwright/test": "1.45.3",
|
||||
"@types/node": "*",
|
||||
"@types/pdf-parse": "1.1.4",
|
||||
"eslint-config-impress": "*",
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -8,6 +8,10 @@ server {
|
||||
try_files $uri index.html $uri/ =404;
|
||||
}
|
||||
|
||||
location ~ ^/docs/(.*)/versions/(.*)/$ {
|
||||
error_page 404 /docs/[id]/versions/[versionId]/;
|
||||
}
|
||||
|
||||
location /docs/ {
|
||||
error_page 404 /docs/[id]/;
|
||||
}
|
||||
|
||||
@@ -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.2.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,36 +15,36 @@
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blocknote/core": "0.14.2",
|
||||
"@blocknote/mantine": "0.14.2",
|
||||
"@blocknote/react": "0.14.2",
|
||||
"@gouvfr-lasuite/integration": "1.0.1",
|
||||
"@blocknote/core": "*",
|
||||
"@blocknote/mantine": "*",
|
||||
"@blocknote/react": "*",
|
||||
"@gouvfr-lasuite/integration": "1.0.2",
|
||||
"@openfun/cunningham-react": "2.9.3",
|
||||
"@tanstack/react-query": "5.48.0",
|
||||
"i18next": "23.11.5",
|
||||
"@tanstack/react-query": "5.51.16",
|
||||
"i18next": "23.12.2",
|
||||
"idb": "8.0.0",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.4.4",
|
||||
"next": "14.2.4",
|
||||
"next": "14.2.5",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.2.1",
|
||||
"react-aria-components": "1.3.1",
|
||||
"react-dom": "*",
|
||||
"react-i18next": "14.1.2",
|
||||
"react-i18next": "15.0.0",
|
||||
"react-select": "5.8.0",
|
||||
"styled-components": "6.1.11",
|
||||
"styled-components": "6.1.12",
|
||||
"y-webrtc": "10.3.0",
|
||||
"yjs": "*",
|
||||
"zustand": "4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.48.0",
|
||||
"@testing-library/dom": "10.2.0",
|
||||
"@testing-library/jest-dom": "6.4.6",
|
||||
"@tanstack/react-query-devtools": "5.51.16",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "6.4.8",
|
||||
"@testing-library/react": "16.0.0",
|
||||
"@testing-library/user-event": "14.5.2",
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/lodash": "4.17.5",
|
||||
"@types/lodash": "4.17.7",
|
||||
"@types/luxon": "3.4.2",
|
||||
"@types/node": "*",
|
||||
"@types/react": "18.3.3",
|
||||
@@ -56,10 +56,10 @@
|
||||
"jest": "29.7.0",
|
||||
"jest-environment-jsdom": "29.7.0",
|
||||
"node-fetch": "2.7.0",
|
||||
"prettier": "3.3.2",
|
||||
"stylelint": "16.6.1",
|
||||
"prettier": "3.3.3",
|
||||
"stylelint": "16.8.1",
|
||||
"stylelint-config-standard": "36.0.1",
|
||||
"stylelint-prettier": "5.0.0",
|
||||
"stylelint-prettier": "5.0.2",
|
||||
"typescript": "*",
|
||||
"workbox-webpack-plugin": "7.1.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
DefinedInitialDataInfiniteOptions,
|
||||
InfiniteData,
|
||||
QueryKey,
|
||||
UseQueryOptions,
|
||||
useInfiniteQuery,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import { APIError } from './APIError';
|
||||
import { APIList } from './types';
|
||||
|
||||
export type UseQueryOptionsAPI<Q> = UseQueryOptions<Q, APIError, Q>;
|
||||
export type DefinedInitialDataInfiniteOptionsAPI<Q> =
|
||||
DefinedInitialDataInfiniteOptions<
|
||||
Q,
|
||||
APIError,
|
||||
InfiniteData<Q>,
|
||||
QueryKey,
|
||||
number
|
||||
>;
|
||||
|
||||
/**
|
||||
* @param param Used for infinite scroll pagination
|
||||
* @param queryConfig
|
||||
* @returns
|
||||
*/
|
||||
export const useAPIInfiniteQuery = <T, Q extends { next?: APIList<Q>['next'] }>(
|
||||
key: string,
|
||||
api: (props: T & { page: number }) => Promise<Q>,
|
||||
param: T,
|
||||
queryConfig?: DefinedInitialDataInfiniteOptionsAPI<Q>,
|
||||
) => {
|
||||
return useInfiniteQuery<Q, APIError, InfiniteData<Q>, QueryKey, number>({
|
||||
initialPageParam: 1,
|
||||
queryKey: [key, param],
|
||||
queryFn: ({ pageParam }) =>
|
||||
api({
|
||||
...param,
|
||||
page: pageParam,
|
||||
}),
|
||||
getNextPageParam(lastPage, allPages) {
|
||||
return lastPage.next ? allPages.length + 1 : undefined;
|
||||
},
|
||||
...queryConfig,
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './APIError';
|
||||
export * from './fetchApi';
|
||||
export * from './helpers';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
|
||||
@@ -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',
|
||||
|
||||
+28
-11
@@ -8,6 +8,7 @@ import { WebrtcProvider } from 'y-webrtc';
|
||||
import { Box } from '@/components';
|
||||
import { useAuthStore } from '@/core/auth';
|
||||
import { Doc } from '@/features/docs/doc-management';
|
||||
import { Version } from '@/features/docs/doc-versioning/';
|
||||
|
||||
import useSaveDoc from '../hook/useSaveDoc';
|
||||
import { useDocStore } from '../stores';
|
||||
@@ -17,31 +18,46 @@ import { BlockNoteToolbar } from './BlockNoteToolbar';
|
||||
|
||||
interface BlockNoteEditorProps {
|
||||
doc: Doc;
|
||||
version?: Version;
|
||||
}
|
||||
|
||||
export const BlockNoteEditor = ({ doc }: BlockNoteEditorProps) => {
|
||||
export const BlockNoteEditor = ({ doc, version }: BlockNoteEditorProps) => {
|
||||
const { createProvider, docsStore } = useDocStore();
|
||||
const provider = docsStore?.[doc.id]?.provider;
|
||||
const storeId = version?.id || doc.id;
|
||||
const initialContent = version?.content || doc.content;
|
||||
const provider = docsStore?.[storeId]?.provider;
|
||||
|
||||
useEffect(() => {
|
||||
if (!provider || provider.doc.guid !== storeId) {
|
||||
createProvider(storeId, initialContent);
|
||||
}
|
||||
}, [createProvider, initialContent, provider, storeId]);
|
||||
|
||||
if (!provider) {
|
||||
createProvider(doc.id, doc.content);
|
||||
return null;
|
||||
}
|
||||
|
||||
return <BlockNoteContent doc={doc} provider={provider} />;
|
||||
return <BlockNoteContent doc={doc} provider={provider} storeId={storeId} />;
|
||||
};
|
||||
|
||||
interface BlockNoteContentProps {
|
||||
doc: Doc;
|
||||
provider: WebrtcProvider;
|
||||
storeId: string;
|
||||
}
|
||||
|
||||
export const BlockNoteContent = ({ doc, provider }: BlockNoteContentProps) => {
|
||||
export const BlockNoteContent = ({
|
||||
doc,
|
||||
provider,
|
||||
storeId,
|
||||
}: BlockNoteContentProps) => {
|
||||
const isVersion = doc.id !== storeId;
|
||||
const { userData } = useAuthStore();
|
||||
const { setEditor, docsStore } = useDocStore();
|
||||
useSaveDoc(doc.id, provider.doc, doc.abilities.partial_update);
|
||||
const { setStore, docsStore } = useDocStore();
|
||||
const canSave = doc.abilities.partial_update && !isVersion;
|
||||
useSaveDoc(doc.id, provider.doc, canSave);
|
||||
|
||||
const storedEditor = docsStore?.[doc.id]?.editor;
|
||||
const storedEditor = docsStore?.[storeId]?.editor;
|
||||
const editor = useMemo(() => {
|
||||
if (storedEditor) {
|
||||
return storedEditor;
|
||||
@@ -60,8 +76,8 @@ export const BlockNoteContent = ({ doc, provider }: BlockNoteContentProps) => {
|
||||
}, [provider, storedEditor, userData?.email]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditor(doc.id, editor);
|
||||
}, [setEditor, doc.id, editor]);
|
||||
setStore(storeId, { editor });
|
||||
}, [setStore, storeId, editor]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -77,7 +93,8 @@ export const BlockNoteContent = ({ doc, provider }: BlockNoteContentProps) => {
|
||||
<BlockNoteView
|
||||
editor={editor}
|
||||
formattingToolbar={false}
|
||||
editable={doc.abilities.partial_update}
|
||||
editable={doc.abilities.partial_update && !isVersion}
|
||||
theme="light"
|
||||
>
|
||||
<BlockNoteToolbar />
|
||||
</BlockNoteView>
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { Alert, VariantType } from '@openfun/cunningham-react';
|
||||
import { Alert, Loader, VariantType } from '@openfun/cunningham-react';
|
||||
import { useRouter as useNavigate } from 'next/navigation';
|
||||
import { useRouter } from 'next/router';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Card, Text } from '@/components';
|
||||
import { Box, Card, Text, TextErrors } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { DocHeader } from '@/features/docs/doc-header';
|
||||
import { Doc } from '@/features/docs/doc-management';
|
||||
import { DocToolBox } from '@/features/docs/doc-tools';
|
||||
import {
|
||||
Panel,
|
||||
Versions,
|
||||
useDocVersion,
|
||||
} from '@/features/docs/doc-versioning/';
|
||||
|
||||
import { BlockNoteEditor } from './BlockNoteEditor';
|
||||
|
||||
@@ -12,34 +21,100 @@ interface DocEditorProps {
|
||||
}
|
||||
|
||||
export const DocEditor = ({ doc }: DocEditorProps) => {
|
||||
const {
|
||||
query: { versionId },
|
||||
} = useRouter();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isVersion = versionId && typeof versionId === 'string';
|
||||
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
|
||||
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;">
|
||||
<Alert
|
||||
type={VariantType.WARNING}
|
||||
>{`Read only, you don't have the right to update this document.`}</Alert>
|
||||
<Box $margin={{ all: 'small', top: 'none' }}>
|
||||
<Alert type={VariantType.WARNING}>
|
||||
{t(`Read only, you cannot edit this document.`)}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
<Card
|
||||
$margin={{ all: 'big', top: 'none' }}
|
||||
$padding="big"
|
||||
$css="flex:1;"
|
||||
$overflow="auto"
|
||||
{isVersion && (
|
||||
<Box $margin={{ all: 'small', top: 'none' }}>
|
||||
<Alert type={VariantType.WARNING}>
|
||||
{t(`Read only, you cannot edit document versions.`)}
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
$background={colorsTokens()['primary-bg']}
|
||||
$height="100%"
|
||||
$direction="row"
|
||||
$margin={{ all: 'small', top: 'none' }}
|
||||
$gap="1rem"
|
||||
>
|
||||
<BlockNoteEditor doc={doc} />
|
||||
</Card>
|
||||
<Card $padding="big" $css="flex:1;" $overflow="auto">
|
||||
{isVersion ? (
|
||||
<DocVersionEditor doc={doc} versionId={versionId} />
|
||||
) : (
|
||||
<BlockNoteEditor doc={doc} />
|
||||
)}
|
||||
</Card>
|
||||
{doc.abilities.versions_list && <Panel doc={doc} />}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface DocVersionEditorProps {
|
||||
doc: Doc;
|
||||
versionId: Versions['version_id'];
|
||||
}
|
||||
|
||||
export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
|
||||
const {
|
||||
data: version,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useDocVersion({
|
||||
docId: doc.id,
|
||||
versionId,
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (isError && error) {
|
||||
if (error.status === 404) {
|
||||
navigate.replace(`/404`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box $margin="large">
|
||||
<TextErrors
|
||||
causes={error.cause}
|
||||
icon={
|
||||
error.status === 502 ? (
|
||||
<Text className="material-icons" $theme="danger">
|
||||
wifi_off
|
||||
</Text>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !version) {
|
||||
return (
|
||||
<Box $align="center" $justify="center" $height="100%">
|
||||
<Loader />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <BlockNoteEditor doc={doc} version={version} />;
|
||||
};
|
||||
|
||||
@@ -5,12 +5,15 @@ import { useTranslation } from 'react-i18next';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { useUpdateDoc } from '@/features/docs/doc-management/';
|
||||
import { KEY_LIST_DOC_VERSIONS } from '@/features/docs/doc-versioning';
|
||||
|
||||
import { useDocStore } from '../stores';
|
||||
import { toBase64 } from '../utils';
|
||||
|
||||
const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
|
||||
const { toast } = useToastProvider();
|
||||
const { t } = useTranslation();
|
||||
const { forceSave, setForceSave } = useDocStore();
|
||||
|
||||
const { mutate: updateDoc } = useUpdateDoc({
|
||||
onSuccess: (data) => {
|
||||
@@ -19,8 +22,12 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
|
||||
docTitle: data.title,
|
||||
}),
|
||||
VariantType.SUCCESS,
|
||||
{
|
||||
duration: 1500,
|
||||
},
|
||||
);
|
||||
},
|
||||
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
|
||||
});
|
||||
const [initialDoc, setInitialDoc] = useState<string>(
|
||||
toBase64(Y.encodeStateAsUpdate(doc)),
|
||||
@@ -57,10 +64,14 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
|
||||
/**
|
||||
* Check if the doc has been updated and can be saved.
|
||||
*/
|
||||
const shouldSave = useCallback(() => {
|
||||
const hasChanged = useCallback(() => {
|
||||
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
|
||||
return initialDoc !== newDoc && canSave;
|
||||
}, [canSave, doc, initialDoc]);
|
||||
return initialDoc !== newDoc;
|
||||
}, [doc, initialDoc]);
|
||||
|
||||
const shouldSave = useCallback(() => {
|
||||
return hasChanged() && canSave;
|
||||
}, [canSave, hasChanged]);
|
||||
|
||||
const saveDoc = useCallback(() => {
|
||||
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
|
||||
@@ -72,6 +83,18 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
|
||||
});
|
||||
}, [doc, docId, updateDoc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (forceSave === 'false') {
|
||||
return;
|
||||
}
|
||||
|
||||
setForceSave('false');
|
||||
|
||||
if ((forceSave === 'current' && hasChanged()) || forceSave === 'version') {
|
||||
saveDoc();
|
||||
}
|
||||
}, [forceSave, hasChanged, saveDoc, setForceSave]);
|
||||
|
||||
const timeout = useRef<NodeJS.Timeout>();
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
@@ -4,60 +4,57 @@ import * as Y from 'yjs';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { signalingUrl } from '@/core';
|
||||
import { Base64, Doc } from '@/features/docs/doc-management';
|
||||
import { Base64 } from '@/features/docs/doc-management';
|
||||
|
||||
export interface DocStore {
|
||||
docsStore: {
|
||||
[docId: Doc['id']]: {
|
||||
provider: WebrtcProvider;
|
||||
editor?: BlockNoteEditor;
|
||||
};
|
||||
};
|
||||
createProvider: (docId: Doc['id'], initialDoc: Base64) => WebrtcProvider;
|
||||
setEditor: (docId: Doc['id'], editor: BlockNoteEditor) => void;
|
||||
interface DocStore {
|
||||
provider: WebrtcProvider;
|
||||
editor?: BlockNoteEditor;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
docsStore: {},
|
||||
};
|
||||
type ForceSaveState = 'false' | 'version' | 'current';
|
||||
|
||||
export const useDocStore = create<DocStore>((set) => ({
|
||||
docsStore: initialState.docsStore,
|
||||
createProvider: (docId: string, initialDoc: Base64) => {
|
||||
export interface UseDocStore {
|
||||
docsStore: {
|
||||
[storeId: string]: DocStore;
|
||||
};
|
||||
createProvider: (storeId: string, initialDoc: Base64) => WebrtcProvider;
|
||||
setStore: (storeId: string, props: Partial<DocStore>) => void;
|
||||
forceSave: ForceSaveState;
|
||||
setForceSave: (forceSave: ForceSaveState) => void;
|
||||
}
|
||||
|
||||
export const useDocStore = create<UseDocStore>((set, get) => ({
|
||||
docsStore: {},
|
||||
forceSave: 'false',
|
||||
setForceSave: (forceSave) => {
|
||||
set(() => ({ forceSave }));
|
||||
},
|
||||
createProvider: (storeId: string, initialDoc: Base64) => {
|
||||
const doc = new Y.Doc({
|
||||
guid: docId,
|
||||
guid: storeId,
|
||||
});
|
||||
|
||||
if (initialDoc) {
|
||||
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
|
||||
}
|
||||
|
||||
const provider = new WebrtcProvider(docId, doc, {
|
||||
signaling: [signalingUrl(docId)],
|
||||
const provider = new WebrtcProvider(storeId, doc, {
|
||||
signaling: [signalingUrl(storeId)],
|
||||
maxConns: 5,
|
||||
});
|
||||
|
||||
set(({ docsStore }) => {
|
||||
return {
|
||||
docsStore: {
|
||||
...docsStore,
|
||||
[docId]: {
|
||||
provider,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
get().setStore(storeId, { provider });
|
||||
|
||||
return provider;
|
||||
},
|
||||
setEditor: (docId, editor) => {
|
||||
setStore: (storeId, props) => {
|
||||
set(({ docsStore }) => {
|
||||
return {
|
||||
docsStore: {
|
||||
...docsStore,
|
||||
[docId]: {
|
||||
...docsStore[docId],
|
||||
editor,
|
||||
[storeId]: {
|
||||
...docsStore[storeId],
|
||||
...props,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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="small"
|
||||
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>
|
||||
);
|
||||
};
|
||||
+21
-72
@@ -1,18 +1,14 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
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 { TemplatesOrdering, useTemplates } from '../api/useTemplates';
|
||||
|
||||
import { ModalPDF } from './ModalPDF';
|
||||
|
||||
@@ -22,35 +18,28 @@ interface DocToolBoxProps {
|
||||
|
||||
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { data: templates } = useTemplates({
|
||||
ordering: TemplatesOrdering.BY_CREATED_ON_DESC,
|
||||
});
|
||||
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);
|
||||
|
||||
const templateOptions = useMemo(() => {
|
||||
if (!templates?.pages) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const templateOptions = templates.pages
|
||||
.map((page) =>
|
||||
page.results.map((template) => ({
|
||||
label: template.title,
|
||||
value: template.id,
|
||||
})),
|
||||
)
|
||||
.flat();
|
||||
|
||||
return templateOptions;
|
||||
}, [templates?.pages]);
|
||||
|
||||
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
|
||||
@@ -62,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={() => {
|
||||
@@ -127,25 +90,11 @@ 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)}
|
||||
templateOptions={templateOptions}
|
||||
doc={doc}
|
||||
/>
|
||||
<ModalPDF onClose={() => setIsModalPDFOpen(false)} doc={doc} />
|
||||
)}
|
||||
{isModalUpdateOpen && (
|
||||
<ModalUpdateDoc onClose={() => setIsModalUpdateOpen(false)} doc={doc} />
|
||||
+29
-10
@@ -9,25 +9,25 @@ import {
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { t } from 'i18next';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
import { useDocStore } from '@/features/docs/doc-editor/';
|
||||
import { Doc } from '@/features/docs/doc-management';
|
||||
|
||||
import { useCreatePdf } from '../api/useCreatePdf';
|
||||
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
|
||||
import { adaptBlockNoteHTML, downloadFile } from '../utils';
|
||||
|
||||
interface ModalPDFProps {
|
||||
onClose: () => void;
|
||||
templateOptions: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
doc: Doc;
|
||||
}
|
||||
|
||||
export const ModalPDF = ({ onClose, templateOptions, doc }: ModalPDFProps) => {
|
||||
export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
|
||||
const { data: templates } = useTemplates({
|
||||
ordering: TemplatesOrdering.BY_CREATED_ON_DESC,
|
||||
});
|
||||
const { toast } = useToastProvider();
|
||||
const { docsStore } = useDocStore();
|
||||
const {
|
||||
@@ -38,9 +38,28 @@ export const ModalPDF = ({ onClose, templateOptions, doc }: ModalPDFProps) => {
|
||||
isPending,
|
||||
error,
|
||||
} = useCreatePdf();
|
||||
const [templateIdSelected, setTemplateIdSelected] = useState<string>(
|
||||
templateOptions?.[0].value,
|
||||
);
|
||||
const [templateIdSelected, setTemplateIdSelected] = useState<string>();
|
||||
|
||||
const templateOptions = useMemo(() => {
|
||||
if (!templates?.pages) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const templateOptions = templates.pages
|
||||
.map((page) =>
|
||||
page.results.map((template) => ({
|
||||
label: template.title,
|
||||
value: template.id,
|
||||
})),
|
||||
)
|
||||
.flat();
|
||||
|
||||
if (templateOptions.length) {
|
||||
setTemplateIdSelected(templateOptions[0].value);
|
||||
}
|
||||
|
||||
return templateOptions;
|
||||
}, [templates?.pages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) {
|
||||
@@ -85,7 +104,7 @@ export const ModalPDF = ({ onClose, templateOptions, doc }: ModalPDFProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
let body = await editor.blocksToHTMLLossy(editor.document);
|
||||
let body = await editor.blocksToFullHTML(editor.document);
|
||||
body = adaptBlockNoteHTML(body);
|
||||
|
||||
createPdf({
|
||||
@@ -0,0 +1 @@
|
||||
export * from './DocHeader';
|
||||
+11
-1
@@ -11,5 +11,15 @@ export function downloadFile(blob: Blob, filename: string) {
|
||||
}
|
||||
|
||||
export const adaptBlockNoteHTML = (html: string) => {
|
||||
return html.replaceAll('<p class="bn-inline-content"></p>', '<br/>');
|
||||
html = html.replaceAll('<p class="bn-inline-content"></p>', '<br/>');
|
||||
html = html.replaceAll(
|
||||
/data-text-alignment=\"([a-z]+)\"/g,
|
||||
'style="text-align: $1;"',
|
||||
);
|
||||
html = html.replaceAll(/data-text-color=\"([a-z]+)\"/g, 'style="color: $1;"');
|
||||
html = html.replaceAll(
|
||||
/data-background-color=\"([a-z]+)\"/g,
|
||||
'style="background-color: $1;"',
|
||||
);
|
||||
return html;
|
||||
};
|
||||
@@ -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,2 @@
|
||||
export * from './useDocVersions';
|
||||
export * from './useDocVersion';
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, UseQueryOptionsAPI, errorCauses, fetchAPI } from '@/api';
|
||||
|
||||
import { Version } from '../types';
|
||||
|
||||
export type DocVersionParam = {
|
||||
docId: string;
|
||||
versionId: string;
|
||||
};
|
||||
|
||||
const getDocVersion = async ({
|
||||
versionId,
|
||||
docId,
|
||||
}: DocVersionParam): Promise<Version> => {
|
||||
const url = `documents/${docId}/versions/${versionId}/`;
|
||||
|
||||
const response = await fetchAPI(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to get the doc version',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<Version>;
|
||||
};
|
||||
|
||||
export const KEY_DOC_VERSION = 'doc-version';
|
||||
|
||||
export function useDocVersion(
|
||||
params: DocVersionParam,
|
||||
queryConfig?: UseQueryOptionsAPI<Version>,
|
||||
) {
|
||||
return useQuery<Version, APIError, Version>({
|
||||
queryKey: [KEY_DOC_VERSION, params],
|
||||
queryFn: () => getDocVersion(params),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
APIError,
|
||||
APIList,
|
||||
DefinedInitialDataInfiniteOptionsAPI,
|
||||
UseQueryOptionsAPI,
|
||||
errorCauses,
|
||||
fetchAPI,
|
||||
useAPIInfiniteQuery,
|
||||
} from '@/api';
|
||||
|
||||
import { Versions } from '../types';
|
||||
|
||||
export type DocVersionsParam = {
|
||||
docId: string;
|
||||
};
|
||||
|
||||
export type DocVersionsAPIParams = DocVersionsParam & {
|
||||
page: number;
|
||||
};
|
||||
|
||||
type VersionsResponse = APIList<Versions>;
|
||||
|
||||
const getDocVersions = async ({
|
||||
page,
|
||||
docId,
|
||||
}: DocVersionsAPIParams): Promise<VersionsResponse> => {
|
||||
const url = `documents/${docId}/versions/?page=${page}`;
|
||||
|
||||
const response = await fetchAPI(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new APIError(
|
||||
'Failed to get the doc versions',
|
||||
await errorCauses(response),
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<VersionsResponse>;
|
||||
};
|
||||
|
||||
export const KEY_LIST_DOC_VERSIONS = 'doc-versions';
|
||||
|
||||
export function useDocVersions(
|
||||
params: DocVersionsAPIParams,
|
||||
queryConfig?: UseQueryOptionsAPI<VersionsResponse>,
|
||||
) {
|
||||
return useQuery<VersionsResponse, APIError, VersionsResponse>({
|
||||
queryKey: [KEY_LIST_DOC_VERSIONS, params],
|
||||
queryFn: () => getDocVersions(params),
|
||||
...queryConfig,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDocVersionsInfiniteQuery(
|
||||
param: DocVersionsParam,
|
||||
queryConfig?: DefinedInitialDataInfiniteOptionsAPI<VersionsResponse>,
|
||||
) {
|
||||
return useAPIInfiniteQuery<DocVersionsParam, VersionsResponse>(
|
||||
KEY_LIST_DOC_VERSIONS,
|
||||
getDocVersions,
|
||||
param,
|
||||
queryConfig,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Card, IconBG, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { Doc } from '@/features/docs/doc-management';
|
||||
|
||||
import { VersionList } from './VersionList';
|
||||
|
||||
interface PanelProps {
|
||||
doc: Doc;
|
||||
}
|
||||
|
||||
export const Panel = ({ doc }: PanelProps) => {
|
||||
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 (
|
||||
<Card
|
||||
$width="100%"
|
||||
$maxWidth="20rem"
|
||||
$position="sticky"
|
||||
$maxHeight="96vh"
|
||||
$height="100%"
|
||||
$css={`
|
||||
top: 2vh;
|
||||
transition: ${transition};
|
||||
${
|
||||
!isOpen &&
|
||||
`
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
`
|
||||
}
|
||||
`}
|
||||
aria-label={t('Document version panel')}
|
||||
{...closedOverridingStyles}
|
||||
>
|
||||
<IconBG
|
||||
iconName="menu_open"
|
||||
aria-label={
|
||||
isOpen
|
||||
? t('Close the document version panel')
|
||||
: t('Open the document version panel')
|
||||
}
|
||||
$background="transparent"
|
||||
$size="h2"
|
||||
$zIndex={1}
|
||||
$css={`
|
||||
cursor: pointer;
|
||||
left: ${isOpen ? '0' : '-3.3'}rem;
|
||||
top: 0.1rem;
|
||||
transform: rotate(${isOpen ? '180' : '0'}deg);
|
||||
transition: ${transition};
|
||||
user-select: none;
|
||||
`}
|
||||
$position="absolute"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
$radius="2px"
|
||||
/>
|
||||
<Box
|
||||
$overflow="hidden"
|
||||
$css={`
|
||||
opacity: ${isOpen ? '1' : '0'};
|
||||
transition: ${transition};
|
||||
`}
|
||||
>
|
||||
<Box
|
||||
$padding={{ all: 'small' }}
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$justify="center"
|
||||
$css={`border-top: 2px solid ${colorsTokens()['primary-600']};`}
|
||||
>
|
||||
<Text $weight="bold" $size="l" $theme="primary">
|
||||
{t('VERSIONS')}
|
||||
</Text>
|
||||
</Box>
|
||||
<VersionList doc={doc} />
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { t } from 'i18next';
|
||||
import React, { PropsWithChildren, useState } from 'react';
|
||||
|
||||
import { Box, DropButton, IconOptions, StyledLink, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { useDocStore } from '@/features/docs/doc-editor';
|
||||
import { Doc } from '@/features/docs/doc-management';
|
||||
|
||||
import { Versions } from '../types';
|
||||
import { revertUpdate } from '../utils';
|
||||
|
||||
interface VersionItemProps {
|
||||
docId: Doc['id'];
|
||||
text: string;
|
||||
link: string;
|
||||
versionId?: Versions['version_id'];
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export const VersionItem = ({
|
||||
docId,
|
||||
versionId,
|
||||
text,
|
||||
link,
|
||||
isActive,
|
||||
}: VersionItemProps) => {
|
||||
const { setForceSave, docsStore, setStore } = useDocStore();
|
||||
const { colorsTokens } = useCunninghamTheme();
|
||||
const [isDropOpen, setIsDropOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="li"
|
||||
$background={isActive ? colorsTokens()['primary-300'] : 'transparent'}
|
||||
$css={`
|
||||
border-left: 4px solid transparent;
|
||||
border-bottom: 1px solid ${colorsTokens()['primary-100']};
|
||||
&:hover{
|
||||
border-left: 4px solid ${colorsTokens()['primary-400']};
|
||||
background: ${colorsTokens()['primary-300']};
|
||||
}
|
||||
`}
|
||||
$hasTransition
|
||||
$minWidth="13rem"
|
||||
>
|
||||
<Link href={link} isActive={isActive}>
|
||||
<Box
|
||||
$padding={{ vertical: '0.7rem', horizontal: 'small' }}
|
||||
$align="center"
|
||||
$direction="row"
|
||||
$justify="space-between"
|
||||
$width="100%"
|
||||
>
|
||||
<Box $direction="row" $gap="0.5rem" $align="center">
|
||||
<Text $isMaterialIcon $size="24px" $theme="primary">
|
||||
description
|
||||
</Text>
|
||||
<Text $weight="bold" $theme="primary" $size="m">
|
||||
{text}
|
||||
</Text>
|
||||
</Box>
|
||||
{isActive && versionId && (
|
||||
<DropButton
|
||||
button={
|
||||
<IconOptions
|
||||
isOpen={isDropOpen}
|
||||
aria-label={t('Open the version options')}
|
||||
/>
|
||||
}
|
||||
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
|
||||
isOpen={isDropOpen}
|
||||
>
|
||||
<Box>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setIsDropOpen(false);
|
||||
setForceSave(versionId ? 'version' : 'current');
|
||||
|
||||
if (
|
||||
!docsStore?.[docId]?.provider ||
|
||||
!docsStore?.[versionId]?.provider
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStore(docId, {
|
||||
editor: undefined,
|
||||
});
|
||||
|
||||
revertUpdate(
|
||||
docsStore[docId].provider.doc,
|
||||
docsStore[docId].provider.doc,
|
||||
docsStore[versionId].provider.doc,
|
||||
);
|
||||
}}
|
||||
color="primary-text"
|
||||
icon={<span className="material-icons">save</span>}
|
||||
size="small"
|
||||
>
|
||||
<Text $theme="primary">{t('Restore the version')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</DropButton>
|
||||
)}
|
||||
</Box>
|
||||
</Link>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface LinkProps {
|
||||
href: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const Link = ({ href, children, isActive }: PropsWithChildren<LinkProps>) => {
|
||||
return isActive ? (
|
||||
<>{children}</>
|
||||
) : (
|
||||
<StyledLink href={href}>{children}</StyledLink>
|
||||
);
|
||||
};
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { Loader } from '@openfun/cunningham-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { APIError } from '@/api';
|
||||
import { Box, InfiniteScroll, Text, TextErrors } from '@/components';
|
||||
import { Doc } from '@/features/docs/doc-management';
|
||||
import { useDate } from '@/hook';
|
||||
|
||||
import { useDocVersionsInfiniteQuery } from '../api/useDocVersions';
|
||||
import { Versions } from '../types';
|
||||
|
||||
import { VersionItem } from './VersionItem';
|
||||
|
||||
interface VersionListStateProps {
|
||||
isLoading: boolean;
|
||||
error: APIError<unknown> | null;
|
||||
versions?: Versions[];
|
||||
doc: Doc;
|
||||
}
|
||||
|
||||
const VersionListState = ({
|
||||
isLoading,
|
||||
error,
|
||||
versions,
|
||||
doc,
|
||||
}: VersionListStateProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { formatDate } = useDate();
|
||||
const {
|
||||
query: { versionId },
|
||||
} = useRouter();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box $align="center" $margin="large">
|
||||
<Loader />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<VersionItem
|
||||
text={t('Current version')}
|
||||
versionId={undefined}
|
||||
link={`/docs/${doc.id}/`}
|
||||
docId={doc.id}
|
||||
isActive={!versionId}
|
||||
/>
|
||||
{versions?.map((version) => (
|
||||
<VersionItem
|
||||
key={version.version_id}
|
||||
versionId={version.version_id}
|
||||
text={formatDate(version.last_modified, {
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
})}
|
||||
link={`/docs/${doc.id}/versions/${version.version_id}`}
|
||||
docId={doc.id}
|
||||
isActive={version.version_id === versionId}
|
||||
/>
|
||||
))}
|
||||
{error && (
|
||||
<Box
|
||||
$justify="center"
|
||||
$margin={{ vertical: 'small', horizontal: 'small' }}
|
||||
>
|
||||
<TextErrors
|
||||
causes={error.cause}
|
||||
icon={
|
||||
error.status === 502 ? (
|
||||
<Text $isMaterialIcon $theme="danger">
|
||||
wifi_off
|
||||
</Text>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface VersionListProps {
|
||||
doc: Doc;
|
||||
}
|
||||
|
||||
export const VersionList = ({ doc }: VersionListProps) => {
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useDocVersionsInfiniteQuery({
|
||||
docId: doc.id,
|
||||
});
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const versions = useMemo(() => {
|
||||
return data?.pages.reduce((acc, page) => {
|
||||
return acc.concat(page.results);
|
||||
}, [] as Versions[]);
|
||||
}, [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"
|
||||
>
|
||||
<VersionListState
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
versions={versions}
|
||||
doc={doc}
|
||||
/>
|
||||
</InfiniteScroll>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './api';
|
||||
export * from './components';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Doc } from '../doc-management';
|
||||
|
||||
export interface Versions {
|
||||
etag: string;
|
||||
is_latest: boolean;
|
||||
last_modified: string;
|
||||
version_id: string;
|
||||
}
|
||||
|
||||
export interface Version {
|
||||
content: Doc['content'];
|
||||
last_modified: string;
|
||||
id: string;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as Y from 'yjs';
|
||||
|
||||
/**
|
||||
* Revert the doc to a previous state.
|
||||
*
|
||||
* We cannot simply replace a doc with another previous doc,
|
||||
* because Y.js will act as if the previous doc is a new doc and so
|
||||
* merge it with the current doc, so we need to revert the doc (undo).
|
||||
*
|
||||
* To do so we simulate a history of the doc by saving snapshots of the doc
|
||||
* and then revert the doc to a previous snapshot.
|
||||
*
|
||||
* @param doc
|
||||
* @param snapshotOrigin
|
||||
* @param snapshotUpdate
|
||||
*/
|
||||
export function revertUpdate(
|
||||
doc: Y.Doc,
|
||||
snapshotOrigin: Y.Doc,
|
||||
snapshotUpdate: Y.Doc,
|
||||
) {
|
||||
try {
|
||||
const snapshotDoc = new Y.Doc();
|
||||
Y.applyUpdate(
|
||||
snapshotDoc,
|
||||
Y.encodeStateAsUpdate(snapshotUpdate),
|
||||
snapshotOrigin,
|
||||
);
|
||||
|
||||
const currentStateVector = Y.encodeStateVector(doc);
|
||||
const snapshotStateVector = Y.encodeStateVector(snapshotDoc);
|
||||
|
||||
const changesSinceSnapshotUpdate = Y.encodeStateAsUpdate(
|
||||
doc,
|
||||
snapshotStateVector,
|
||||
);
|
||||
|
||||
const undoManager = new Y.UndoManager(
|
||||
[snapshotDoc.getMap('document-store')],
|
||||
{
|
||||
trackedOrigins: new Set([snapshotOrigin]),
|
||||
},
|
||||
);
|
||||
|
||||
Y.applyUpdate(snapshotDoc, changesSinceSnapshotUpdate, snapshotOrigin);
|
||||
undoManager.undo();
|
||||
const revertChangesSinceSnapshotUpdate = Y.encodeStateAsUpdate(
|
||||
snapshotDoc,
|
||||
currentStateVector,
|
||||
);
|
||||
Y.applyUpdate(doc, revertChangesSinceSnapshotUpdate, snapshotOrigin);
|
||||
} catch (e) {}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
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 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('Members'),
|
||||
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}
|
||||
emptyPlaceholderLabel={t("You don't have any document yet.")}
|
||||
/>
|
||||
</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 './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({
|
||||
|
||||
+59
-78
@@ -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:
|
||||
@@ -86,14 +70,23 @@ export const ModalAddMembers = ({
|
||||
duration: 4000,
|
||||
};
|
||||
|
||||
const onError = (dataError: APIErrorUser['data']) => {
|
||||
const messageError =
|
||||
dataError?.type === OptionType.INVITATION
|
||||
const onError = (dataError: APIErrorUser) => {
|
||||
let messageError =
|
||||
dataError['data']?.type === OptionType.INVITATION
|
||||
? t(`Failed to create the invitation for {{email}}.`, {
|
||||
email: dataError?.value,
|
||||
email: dataError['data']?.value,
|
||||
})
|
||||
: t(`Failed to add the member in the document.`);
|
||||
|
||||
if (
|
||||
dataError.cause?.[0] ===
|
||||
'Document invitation with this Email address and Document already exists.'
|
||||
) {
|
||||
messageError = t('"{{email}}" is already invited to the document.', {
|
||||
email: dataError['data']?.value,
|
||||
});
|
||||
}
|
||||
|
||||
toast(messageError, VariantType.ERROR, toastOptions);
|
||||
};
|
||||
|
||||
@@ -112,17 +105,22 @@ 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);
|
||||
setSelectedUsers([]);
|
||||
|
||||
settledPromises.forEach((settledPromise) => {
|
||||
switch (settledPromise.status) {
|
||||
case 'rejected':
|
||||
onError((settledPromise.reason as APIErrorUser).data);
|
||||
onError(settledPromise.reason as APIErrorUser);
|
||||
break;
|
||||
|
||||
case 'fulfilled':
|
||||
@@ -133,63 +131,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;" $width="15rem">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user