Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6d2f7d6b0 | |||
| 529e7f1737 | |||
| 51c5c4ee63 | |||
| 72f098c667 | |||
| 3b08ba4de1 | |||
| 590b67fd71 | |||
| b3980e7bf1 | |||
| e3b2fdbdf5 | |||
| 314a7fa7b0 | |||
| 93227466d2 | |||
| db7ae350ec | |||
| 236c8df5ae | |||
| ae1b05189e | |||
| 431c331154 | |||
| 5184723862 | |||
| ca10fb9a12 | |||
| 59e875764c | |||
| 7ed46ab225 | |||
| 18f4ab880f | |||
| e71c45077d | |||
| 14c84f000e | |||
| 6cc42636e5 | |||
| cc4bed6f8e | |||
| d8f90c04bd | |||
| 1fdf70bdcf | |||
| 8ab21ef00d | |||
| f337a2a8f2 | |||
| 3607faa475 | |||
| 0ea7dd727f | |||
| 6aca40a034 | |||
| ee3b05cb55 | |||
| c23ff546d8 | |||
| a751f1255a | |||
| 8ee50631f3 | |||
| e5e5fba0b3 | |||
| 0894bcdca5 | |||
| 75da342058 | |||
| 1ed01fd64b | |||
| e4aa85be83 | |||
| 2dc1e07b42 | |||
| fbdeb90113 | |||
| b773f09792 | |||
| d8c9283dd1 | |||
| 1e39d17914 | |||
| ecd2f97cf5 | |||
| 90624e83f5 | |||
| 5fc002658c | |||
| dfd5dc1545 | |||
| 69e7235f75 | |||
| 942c90c29f | |||
| c5f0142671 | |||
| 7f37d3bda4 | |||
| 7033d0ecf7 | |||
| 0dd6818e91 | |||
| eb225fc86f |
@@ -31,7 +31,7 @@ jobs:
|
||||
images: lasuite/impress-backend
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
context: .
|
||||
target: backend-production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
images: lasuite/impress-frontend
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
@@ -88,7 +88,7 @@ jobs:
|
||||
build-args: |
|
||||
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
PUBLISH_AS_MIT=false
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
images: lasuite/impress-y-provider
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
file: ./src/frontend/servers/y-provider/Dockerfile
|
||||
target: y-provider
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: ${{ github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
- build-and-push-frontend
|
||||
- build-and-push-backend
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'preview')
|
||||
steps:
|
||||
- uses: numerique-gouv/action-argocd-webhook-notification@main
|
||||
id: notify
|
||||
|
||||
@@ -79,6 +79,7 @@ jobs:
|
||||
--check-filenames \
|
||||
--ignore-words-list "Dokument,afterAll,excpt,statics" \
|
||||
--skip "./git/" \
|
||||
--skip "**/*.pdf" \
|
||||
--skip "**/*.po" \
|
||||
--skip "**/*.pot" \
|
||||
--skip "**/*.json" \
|
||||
|
||||
+41
-2
@@ -1,5 +1,3 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0),
|
||||
@@ -8,6 +6,46 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) add pdf block to the editor #1293
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(frontend) replace Arial font-family with token font #1411
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿(frontend) enable enter key to open documentss #1354
|
||||
- ♿(frontend) improve modal a11y: structure, labels, title #1349
|
||||
- ♿improve NVDA navigation in DocShareModal #1396
|
||||
- ♿ improve accessibility by adding landmark roles to layout #1394
|
||||
- ♿ add document visible in list and openable via enter key #1365
|
||||
- ♿ add pdf outline property to enable bookmarks display #1368
|
||||
- ♿ hide decorative icons from assistive tech with aria-hidden #1404
|
||||
- ♿ fix rgaa 1.9.1: convert to figure/figcaption structure #1426
|
||||
- ♿ remove redundant aria-label to avoid over-accessibility #1420
|
||||
- ♿ remove redundant aria-label on hidden icons and update tests #1432
|
||||
- ♿ improve semantic structure and aria roles of leftpanel #1431
|
||||
- ♿ add default background to left panel for better accessibility #1423
|
||||
- ♿ restyle checked checkboxes: removing strikethrough #1439
|
||||
- ♿ add h1 for SR on 40X pages and remove alt texts #1438
|
||||
- ♿ update labels and shared document icon accessibility #1442
|
||||
- 🍱(frontend) Fonts GDPR compliants #1453
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(backend) duplicate sub docs as root for reader users
|
||||
- ⚗️(service-worker) remove index from cache first strategy #1395
|
||||
- 🐛(frontend) fix 404 page when reload 403 page #1402
|
||||
- 🐛(frontend) fix legacy role computation #1376
|
||||
- 🛂(frontend) block editing title when not allowed #1412
|
||||
- 🐛(frontend) scroll back to top when navigate to a document #1406
|
||||
- 🐛(frontend) fix export pdf emoji problem #1453
|
||||
- 🐛(frontend) fix attachment download filename #1447
|
||||
- 🐛(frontend) exclude h4-h6 headings from table of contents #1441
|
||||
- 🔒(frontend) prevent readers from changing callout emoji #1449
|
||||
- 🐛(frontend) fix overlapping placeholders in multi-column layout #1455
|
||||
- 🐛(backend) filter invitation with case insensitive email
|
||||
- 🐛(frontend) reduce no access image size from 450 to 300 #1463
|
||||
|
||||
## [3.7.0] - 2025-09-12
|
||||
|
||||
@@ -71,6 +109,7 @@ and this project adheres to
|
||||
- 🐛(frontend) fix dnd conflict with tree and Blocknote #1328
|
||||
- 🐛(frontend) fix display bug on homepage #1332
|
||||
- 🐛link role update #1287
|
||||
- 🔧(keycloak) Fix https required issue in dev mode #1286
|
||||
|
||||
## [3.5.0] - 2025-07-31
|
||||
|
||||
|
||||
@@ -94,6 +94,14 @@ RUN chmod g=u /etc/passwd
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
|
||||
# Link certifi certificate from a static path /cert/cacert.pem to avoid issues
|
||||
# when python is upgraded and the path to the certificate changes.
|
||||
# The space between print and the ( is intended otherwise the git lint is failing
|
||||
RUN mkdir /cert && \
|
||||
path=`python -c 'import certifi;print (certifi.where())'` && \
|
||||
mv $path /cert/ && \
|
||||
ln -s /cert/cacert.pem $path
|
||||
|
||||
# Copy impress application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
|
||||
|
||||
@@ -54,16 +54,16 @@ Docs is a collaborative text editor designed to address common challenges in kno
|
||||
We use Kubernetes for our [production instance](https://docs.numerique.gouv.fr/) but also support Docker Compose. The community contributed a couple other methods (Nix, YunoHost etc.) check out the [docs](/docs/installation/README.md) to get detailed instructions and examples.
|
||||
|
||||
#### 🌍 Known instances
|
||||
We hope to see many more, here is an incomplete list of public Docs instances (urls listed in alphabetical order). Feel free to make a PR to add ones that are not listed below🙏
|
||||
|
||||
| | | |
|
||||
| --- | --- | ------- |
|
||||
We hope to see many more, here is an incomplete list of public Docs instances. Feel free to make a PR to add ones that are not listed below🙏
|
||||
|
||||
| Url | Org | Public |
|
||||
| docs.numerique.gouv.fr | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||
| docs.suite.anct.gouv.fr | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||
| notes.demo.opendesk.eu | ZenDiS | Demo instance of OpenDesk. Request access to get credentials |
|
||||
| notes.liiib.re | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||
| docs.federated.nexus | federated.nexus | Public instance, but you have to [sign up for a Federated Nexus account](https://federated.nexus/register/). |
|
||||
| --- | --- | ------- |
|
||||
| [docs.numerique.gouv.fr](https://docs.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||
| [docs.suite.anct.gouv.fr](https://docs.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||
| [notes.demo.opendesk.eu](https://notes.demo.opendesk.eu) | ZenDiS | Demo instance of OpenDesk. Request access to get credentials |
|
||||
| [notes.liiib.re](https://notes.liiib.re/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||
| [docs.federated.nexus](https://docs.federated.nexus/) | federated.nexus | Public instance, but you have to [sign up for a Federated Nexus account](https://federated.nexus/register/). |
|
||||
| [docs.demo.mosacloud.eu](https://docs.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||
|
||||
#### ⚠️ Advanced features
|
||||
For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under GPL and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
|
||||
|
||||
+4
-6
@@ -184,22 +184,20 @@ services:
|
||||
- env.d/development/kc_postgresql.local
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:20.0.1
|
||||
image: quay.io/keycloak/keycloak:26.3
|
||||
volumes:
|
||||
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
|
||||
command:
|
||||
- start-dev
|
||||
- --features=preview
|
||||
- --import-realm
|
||||
- --proxy=edge
|
||||
- --hostname-url=http://localhost:8083
|
||||
- --hostname-admin-url=http://localhost:8083/
|
||||
- --hostname=http://localhost:8083
|
||||
- --hostname-strict=false
|
||||
- --hostname-strict-https=false
|
||||
- --health-enabled=true
|
||||
- --metrics-enabled=true
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready"]
|
||||
test: ['CMD-SHELL', 'exec 3<>/dev/tcp/localhost/9000; echo -e "GET /health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; grep "HTTP/1.1 200 OK" <&3']
|
||||
start_period: 5s
|
||||
interval: 1s
|
||||
timeout: 2s
|
||||
retries: 300
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"oauth2DeviceCodeLifespan": 600,
|
||||
"oauth2DevicePollingInterval": 5,
|
||||
"enabled": true,
|
||||
"sslRequired": "external",
|
||||
"sslRequired": "none",
|
||||
"registrationAllowed": true,
|
||||
"registrationEmailAsUsername": false,
|
||||
"rememberMe": true,
|
||||
@@ -60,7 +60,7 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-chromium",
|
||||
"email": "user@chromium.test",
|
||||
"email": "user.test@chromium.test",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Chromium",
|
||||
"enabled": true,
|
||||
@@ -74,7 +74,7 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-webkit",
|
||||
"email": "user@webkit.test",
|
||||
"email": "user.test@webkit.test",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Webkit",
|
||||
"enabled": true,
|
||||
@@ -88,7 +88,7 @@
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-firefox",
|
||||
"email": "user@firefox.test",
|
||||
"email": "user.test@firefox.test",
|
||||
"firstName": "E2E",
|
||||
"lastName": "Firefox",
|
||||
"enabled": true,
|
||||
@@ -2270,7 +2270,7 @@
|
||||
"cibaInterval": "5",
|
||||
"realmReusableOtpCode": "false"
|
||||
},
|
||||
"keycloakVersion": "20.0.1",
|
||||
"keycloakVersion": "26.3.2",
|
||||
"userManagedAccessAllowed": false,
|
||||
"clientProfiles": {
|
||||
"profiles": []
|
||||
|
||||
@@ -82,7 +82,7 @@ backend:
|
||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumeMounts:
|
||||
- name: certs
|
||||
mountPath: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
|
||||
mountPath: /cert/cacert.pem
|
||||
subPath: cacert.pem
|
||||
|
||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
|
||||
@@ -506,6 +506,10 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
|
||||
We expose it separately from document in order to simplify and secure access control.
|
||||
"""
|
||||
|
||||
link_reach = serializers.ChoiceField(
|
||||
choices=models.LinkReachChoices.choices, required=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.Document
|
||||
fields = [
|
||||
@@ -513,6 +517,58 @@ class LinkDocumentSerializer(serializers.ModelSerializer):
|
||||
"link_reach",
|
||||
]
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate that link_role and link_reach are compatible using get_select_options."""
|
||||
link_reach = attrs.get("link_reach")
|
||||
link_role = attrs.get("link_role")
|
||||
|
||||
if not link_reach:
|
||||
raise serializers.ValidationError(
|
||||
{"link_reach": _("This field is required.")}
|
||||
)
|
||||
|
||||
# Get available options based on ancestors' link definition
|
||||
available_options = models.LinkReachChoices.get_select_options(
|
||||
**self.instance.ancestors_link_definition
|
||||
)
|
||||
|
||||
# Validate link_reach is allowed
|
||||
if link_reach not in available_options:
|
||||
msg = _(
|
||||
"Link reach '%(link_reach)s' is not allowed based on parent document configuration."
|
||||
)
|
||||
raise serializers.ValidationError(
|
||||
{"link_reach": msg % {"link_reach": link_reach}}
|
||||
)
|
||||
|
||||
# Validate link_role is compatible with link_reach
|
||||
allowed_roles = available_options[link_reach]
|
||||
|
||||
# Restricted reach: link_role must be None
|
||||
if link_reach == models.LinkReachChoices.RESTRICTED:
|
||||
if link_role is not None:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"link_role": (
|
||||
"Cannot set link_role when link_reach is 'restricted'. "
|
||||
"Link role must be null for restricted reach."
|
||||
)
|
||||
}
|
||||
)
|
||||
return attrs
|
||||
# Non-restricted: link_role must be in allowed roles
|
||||
if link_role not in allowed_roles:
|
||||
allowed_roles_str = ", ".join(allowed_roles) if allowed_roles else "none"
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"link_role": (
|
||||
f"Link role '{link_role}' is not allowed for link reach '{link_reach}'. "
|
||||
f"Allowed roles: {allowed_roles_str}"
|
||||
)
|
||||
}
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
class DocumentDuplicationSerializer(serializers.Serializer):
|
||||
"""
|
||||
@@ -684,6 +740,8 @@ class InvitationSerializer(serializers.ModelSerializer):
|
||||
if self.instance is None:
|
||||
attrs["issuer"] = user
|
||||
|
||||
attrs["email"] = attrs["email"].lower()
|
||||
|
||||
return attrs
|
||||
|
||||
def validate_role(self, role):
|
||||
|
||||
@@ -941,37 +941,64 @@ class DocumentViewSet(
|
||||
in the payload.
|
||||
"""
|
||||
# Get document while checking permissions
|
||||
document = self.get_object()
|
||||
document_to_duplicate = self.get_object()
|
||||
|
||||
serializer = serializers.DocumentDuplicationSerializer(
|
||||
data=request.data, partial=True
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
with_accesses = serializer.validated_data.get("with_accesses", False)
|
||||
is_owner_or_admin = document.get_role(request.user) in models.PRIVILEGED_ROLES
|
||||
user_role = document_to_duplicate.get_role(request.user)
|
||||
is_owner_or_admin = user_role in models.PRIVILEGED_ROLES
|
||||
|
||||
base64_yjs_content = document.content
|
||||
base64_yjs_content = document_to_duplicate.content
|
||||
|
||||
# Duplicate the document instance
|
||||
link_kwargs = (
|
||||
{"link_reach": document.link_reach, "link_role": document.link_role}
|
||||
{
|
||||
"link_reach": document_to_duplicate.link_reach,
|
||||
"link_role": document_to_duplicate.link_role,
|
||||
}
|
||||
if with_accesses
|
||||
else {}
|
||||
)
|
||||
extracted_attachments = set(extract_attachments(document.content))
|
||||
attachments = list(extracted_attachments & set(document.attachments))
|
||||
duplicated_document = document.add_sibling(
|
||||
extracted_attachments = set(extract_attachments(document_to_duplicate.content))
|
||||
attachments = list(
|
||||
extracted_attachments & set(document_to_duplicate.attachments)
|
||||
)
|
||||
title = capfirst(_("copy of {title}").format(title=document_to_duplicate.title))
|
||||
if not document_to_duplicate.is_root() and choices.RoleChoices.get_priority(
|
||||
user_role
|
||||
) < choices.RoleChoices.get_priority(models.RoleChoices.EDITOR):
|
||||
duplicated_document = models.Document.add_root(
|
||||
creator=self.request.user,
|
||||
title=title,
|
||||
content=base64_yjs_content,
|
||||
attachments=attachments,
|
||||
duplicated_from=document_to_duplicate,
|
||||
**link_kwargs,
|
||||
)
|
||||
models.DocumentAccess.objects.create(
|
||||
document=duplicated_document,
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
duplicated_document = document_to_duplicate.add_sibling(
|
||||
"right",
|
||||
title=capfirst(_("copy of {title}").format(title=document.title)),
|
||||
title=title,
|
||||
content=base64_yjs_content,
|
||||
attachments=attachments,
|
||||
duplicated_from=document,
|
||||
duplicated_from=document_to_duplicate,
|
||||
creator=request.user,
|
||||
**link_kwargs,
|
||||
)
|
||||
|
||||
# Always add the logged-in user as OWNER for root documents
|
||||
if document.is_root():
|
||||
if document_to_duplicate.is_root():
|
||||
accesses_to_create = [
|
||||
models.DocumentAccess(
|
||||
document=duplicated_document,
|
||||
@@ -983,7 +1010,7 @@ class DocumentViewSet(
|
||||
# If accesses should be duplicated, add other users' accesses as per original document
|
||||
if with_accesses and is_owner_or_admin:
|
||||
original_accesses = models.DocumentAccess.objects.filter(
|
||||
document=document
|
||||
document=document_to_duplicate
|
||||
).exclude(user=request.user)
|
||||
|
||||
accesses_to_create.extend(
|
||||
|
||||
@@ -221,7 +221,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
Expired invitations are ignored.
|
||||
"""
|
||||
valid_invitations = Invitation.objects.filter(
|
||||
email=self.email,
|
||||
email__iexact=self.email,
|
||||
created_at__gte=(
|
||||
timezone.now()
|
||||
- timedelta(seconds=settings.INVITATION_VALIDITY_DURATION)
|
||||
|
||||
@@ -596,6 +596,32 @@ def test_api_document_invitations_create_cannot_invite_existing_users():
|
||||
}
|
||||
|
||||
|
||||
def test_api_document_invitations_create_lower_email():
|
||||
"""
|
||||
No matter the case, the email should be converted to lowercase.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
document = factories.DocumentFactory(users=[(user, "owner")])
|
||||
|
||||
# Build an invitation to the email of an existing identity in the db
|
||||
invitation_values = {
|
||||
"email": "GuEst@example.com",
|
||||
"role": random.choice(models.RoleChoices.values),
|
||||
}
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{document.id!s}/invitations/",
|
||||
invitation_values,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["email"] == "guest@example.com"
|
||||
|
||||
|
||||
# Update
|
||||
|
||||
|
||||
|
||||
@@ -293,3 +293,28 @@ def test_api_documents_duplicate_non_root_document(role):
|
||||
assert duplicated_accesses.count() == 0
|
||||
assert duplicated_document.is_sibling_of(child)
|
||||
assert duplicated_document.is_child_of(document)
|
||||
|
||||
|
||||
def test_api_documents_duplicate_reader_non_root_document():
|
||||
"""
|
||||
Reader users should be able to duplicate non-root documents but will be
|
||||
created as a root document.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(users=[(user, "reader")])
|
||||
child = factories.DocumentFactory(parent=document)
|
||||
|
||||
assert child.get_role(user) == "reader"
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/documents/{child.id!s}/duplicate/", format="json"
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
duplicated_document = models.Document.objects.get(id=response.json()["id"])
|
||||
assert duplicated_document.is_root()
|
||||
assert duplicated_document.accesses.count() == 1
|
||||
assert duplicated_document.accesses.get(user=user).role == "owner"
|
||||
|
||||
@@ -133,7 +133,10 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory()
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED,
|
||||
link_role=models.LinkRoleChoices.READER,
|
||||
)
|
||||
if via == USER:
|
||||
factories.UserDocumentAccessFactory(document=document, user=user, role=role)
|
||||
elif via == TEAM:
|
||||
@@ -143,7 +146,10 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
|
||||
)
|
||||
|
||||
new_document_values = serializers.LinkDocumentSerializer(
|
||||
instance=factories.DocumentFactory()
|
||||
instance=factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.PUBLIC,
|
||||
link_role=models.LinkRoleChoices.EDITOR,
|
||||
)
|
||||
).data
|
||||
|
||||
with mock_reset_connections(document.id):
|
||||
@@ -158,3 +164,240 @@ def test_api_documents_link_configuration_update_authenticated_related_success(
|
||||
document_values = serializers.LinkDocumentSerializer(instance=document).data
|
||||
for key, value in document_values.items():
|
||||
assert value == new_document_values[key]
|
||||
|
||||
|
||||
def test_api_documents_link_configuration_update_role_restricted_forbidden():
|
||||
"""
|
||||
Test that trying to set link_role on a document with restricted link_reach
|
||||
returns a validation error.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
link_role=models.LinkRoleChoices.READER,
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
# Try to set a meaningful role on a restricted document
|
||||
new_data = {
|
||||
"link_reach": models.LinkReachChoices.RESTRICTED,
|
||||
"link_role": models.LinkRoleChoices.EDITOR,
|
||||
}
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
|
||||
new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "link_role" in response.json()
|
||||
assert (
|
||||
"Cannot set link_role when link_reach is 'restricted'"
|
||||
in response.json()["link_role"][0]
|
||||
)
|
||||
|
||||
|
||||
def test_api_documents_link_configuration_update_link_reach_required():
|
||||
"""
|
||||
Test that link_reach is required when updating link configuration.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.PUBLIC,
|
||||
link_role=models.LinkRoleChoices.READER,
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
# Try to update without providing link_reach
|
||||
new_data = {"link_role": models.LinkRoleChoices.EDITOR}
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
|
||||
new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "link_reach" in response.json()
|
||||
assert "This field is required" in response.json()["link_reach"][0]
|
||||
|
||||
|
||||
def test_api_documents_link_configuration_update_restricted_without_role_success(
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
):
|
||||
"""
|
||||
Test that setting link_reach to restricted without specifying link_role succeeds.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.PUBLIC,
|
||||
link_role=models.LinkRoleChoices.READER,
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
# Only specify link_reach, not link_role
|
||||
new_data = {
|
||||
"link_reach": models.LinkReachChoices.RESTRICTED,
|
||||
}
|
||||
|
||||
with mock_reset_connections(document.id):
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
|
||||
new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
document.refresh_from_db()
|
||||
assert document.link_reach == models.LinkReachChoices.RESTRICTED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach", [models.LinkReachChoices.PUBLIC, models.LinkReachChoices.AUTHENTICATED]
|
||||
)
|
||||
@pytest.mark.parametrize("role", models.LinkRoleChoices.values)
|
||||
def test_api_documents_link_configuration_update_non_restricted_with_valid_role_success(
|
||||
reach,
|
||||
role,
|
||||
mock_reset_connections, # pylint: disable=redefined-outer-name
|
||||
):
|
||||
"""
|
||||
Test that setting non-restricted link_reach with valid link_role succeeds.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
link_role=models.LinkRoleChoices.READER,
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=document, user=user, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
new_data = {
|
||||
"link_reach": reach,
|
||||
"link_role": role,
|
||||
}
|
||||
|
||||
with mock_reset_connections(document.id):
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{document.id!s}/link-configuration/",
|
||||
new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
document.refresh_from_db()
|
||||
assert document.link_reach == reach
|
||||
assert document.link_role == role
|
||||
|
||||
|
||||
def test_api_documents_link_configuration_update_with_ancestor_constraints():
|
||||
"""
|
||||
Test that link configuration respects ancestor constraints using get_select_options.
|
||||
This test may need adjustment based on the actual get_select_options implementation.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
parent_document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.PUBLIC,
|
||||
link_role=models.LinkRoleChoices.READER,
|
||||
)
|
||||
|
||||
child_document = factories.DocumentFactory(
|
||||
parent=parent_document,
|
||||
link_reach=models.LinkReachChoices.PUBLIC,
|
||||
link_role=models.LinkRoleChoices.READER,
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=child_document, user=user, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
# Try to set child to PUBLIC when parent is RESTRICTED
|
||||
new_data = {
|
||||
"link_reach": models.LinkReachChoices.RESTRICTED,
|
||||
"link_role": models.LinkRoleChoices.READER,
|
||||
}
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{child_document.id!s}/link-configuration/",
|
||||
new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "link_reach" in response.json()
|
||||
assert (
|
||||
"Link reach 'restricted' is not allowed based on parent"
|
||||
in response.json()["link_reach"][0]
|
||||
)
|
||||
|
||||
|
||||
def test_api_documents_link_configuration_update_invalid_role_for_reach_validation():
|
||||
"""
|
||||
Test the specific validation logic that checks if link_role is allowed for link_reach.
|
||||
This tests the code section that validates allowed_roles from get_select_options.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
parent_document = factories.DocumentFactory(
|
||||
link_reach=models.LinkReachChoices.AUTHENTICATED,
|
||||
link_role=models.LinkRoleChoices.EDITOR,
|
||||
)
|
||||
|
||||
child_document = factories.DocumentFactory(
|
||||
parent=parent_document,
|
||||
link_reach=models.LinkReachChoices.RESTRICTED,
|
||||
link_role=models.LinkRoleChoices.READER,
|
||||
)
|
||||
|
||||
factories.UserDocumentAccessFactory(
|
||||
document=child_document, user=user, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
new_data = {
|
||||
"link_reach": models.LinkReachChoices.AUTHENTICATED,
|
||||
"link_role": models.LinkRoleChoices.READER, # This should be rejected
|
||||
}
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/documents/{child_document.id!s}/link-configuration/",
|
||||
new_data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "link_role" in response.json()
|
||||
error_message = response.json()["link_role"][0]
|
||||
assert (
|
||||
"Link role 'reader' is not allowed for link reach 'authenticated'"
|
||||
in error_message
|
||||
)
|
||||
assert "Allowed roles: editor" in error_message
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -66,3 +66,33 @@ def test_models_users_sub_validator(sub, is_valid):
|
||||
match=("Enter a valid sub. This value should be ASCII only."),
|
||||
):
|
||||
user.full_clean()
|
||||
|
||||
|
||||
def test_modes_users_convert_valid_invitations():
|
||||
"""
|
||||
The "convert_valid_invitations" method should convert valid invitations to document accesses.
|
||||
"""
|
||||
email = "test@example.com"
|
||||
document = factories.DocumentFactory()
|
||||
other_document = factories.DocumentFactory()
|
||||
invitation_document = factories.InvitationFactory(email=email, document=document)
|
||||
invitation_other_document = factories.InvitationFactory(
|
||||
email="Test@example.coM", document=other_document
|
||||
)
|
||||
other_email_invitation = factories.InvitationFactory(
|
||||
email="pre_test@example.com", document=document
|
||||
)
|
||||
|
||||
assert document.accesses.count() == 0
|
||||
assert other_document.accesses.count() == 0
|
||||
|
||||
user = factories.UserFactory(email=email)
|
||||
|
||||
assert document.accesses.filter(user=user).count() == 1
|
||||
assert other_document.accesses.filter(user=user).count() == 1
|
||||
|
||||
assert not models.Invitation.objects.filter(id=invitation_document.id).exists()
|
||||
assert not models.Invitation.objects.filter(
|
||||
id=invitation_other_document.id
|
||||
).exists()
|
||||
assert models.Invitation.objects.filter(id=other_email_invitation.id).exists()
|
||||
|
||||
@@ -142,7 +142,7 @@ class Base(Configuration):
|
||||
)
|
||||
|
||||
# Document images
|
||||
DOCUMENT_IMAGE_MAX_SIZE = values.Value(
|
||||
DOCUMENT_IMAGE_MAX_SIZE = values.IntegerValue(
|
||||
10 * (2**20), # 10MB
|
||||
environ_name="DOCUMENT_IMAGE_MAX_SIZE",
|
||||
environ_prefix=None,
|
||||
|
||||
@@ -39,7 +39,7 @@ dependencies = [
|
||||
"django-redis==6.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.2.6",
|
||||
"django==5.2.7",
|
||||
"django-treebeard==4.7.1",
|
||||
"djangorestframework==3.16.0",
|
||||
"drf_spectacular==0.28.0",
|
||||
|
||||
@@ -50,7 +50,13 @@ ENV NEXT_PUBLIC_PUBLISH_AS_MIT=${PUBLISH_AS_MIT}
|
||||
RUN yarn build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.22 AS frontend-production
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
USER root
|
||||
RUN apk update && \
|
||||
apk upgrade && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
|
||||
Binary file not shown.
@@ -89,8 +89,8 @@ test.describe('Doc Create: Not logged', () => {
|
||||
const data = {
|
||||
title,
|
||||
content: markdown,
|
||||
sub: `user@${browserName}.test`,
|
||||
email: `user@${browserName}.test`,
|
||||
sub: `user.test@${browserName}.test`,
|
||||
email: `user.test@${browserName}.test`,
|
||||
};
|
||||
|
||||
const newDoc = await request.post(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable playwright/no-conditional-expect */
|
||||
import path from 'path';
|
||||
|
||||
import { chromium, expect, test } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import cs from 'convert-stream';
|
||||
|
||||
import {
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
overrideConfig,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
import { getEditor, openSuggestionMenu, writeInEditor } from './utils-editor';
|
||||
import { connectOtherUserToDoc, updateShareLink } from './utils-share';
|
||||
import { createRootSubPage, navigateToPageFromTree } from './utils-sub-pages';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -86,8 +88,7 @@ test.describe('Doc Editor', () => {
|
||||
// Is connected
|
||||
let framesentPromise = webSocket.waitForEvent('framesent');
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
await writeInEditor({ page, text: 'Hello World' });
|
||||
|
||||
let framesent = await framesentPromise;
|
||||
expect(framesent.payload).not.toBeNull();
|
||||
@@ -100,7 +101,7 @@ test.describe('Doc Editor', () => {
|
||||
const wsClosePromise = webSocket.waitForEvent('close');
|
||||
|
||||
await selectVisibility.click();
|
||||
await page.getByLabel('Connected').click();
|
||||
await page.getByRole('menuitem', { name: 'Connected' }).click();
|
||||
|
||||
// Assert that the doc reconnects to the ws
|
||||
const wsClose = await wsClosePromise;
|
||||
@@ -238,17 +239,7 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
test('it cannot edit if viewer', async ({ page }) => {
|
||||
await mockedDocument(page, {
|
||||
abilities: {
|
||||
destroy: false, // Means not owner
|
||||
link_configuration: false,
|
||||
versions_destroy: false,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
accesses_manage: false, // Means not admin
|
||||
update: false,
|
||||
partial_update: false, // Means not editor
|
||||
retrieve: true,
|
||||
},
|
||||
user_role: 'reader',
|
||||
});
|
||||
|
||||
await goToGridDoc(page);
|
||||
@@ -257,6 +248,9 @@ test.describe('Doc Editor', () => {
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
await expect(card.getByText('Reader')).toBeVisible();
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await expect(editor).toHaveAttribute('contenteditable', 'false');
|
||||
});
|
||||
|
||||
test('it adds an image to the doc editor', async ({ page, browserName }) => {
|
||||
@@ -512,10 +506,7 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
const editor = page.locator('.ProseMirror.bn-editor');
|
||||
|
||||
await editor.click();
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
const editor = await openSuggestionMenu({ page });
|
||||
await page.getByText('Embedded file').click();
|
||||
await page.getByText('Upload file').click();
|
||||
|
||||
@@ -570,20 +561,7 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
await page.getByTestId('doc-visibility').click();
|
||||
|
||||
await page
|
||||
.getByRole('menuitem', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByTestId('doc-access-mode').click();
|
||||
await page.getByRole('menuitem', { name: 'Editing' }).click();
|
||||
await updateShareLink(page, 'Public', 'Editing');
|
||||
|
||||
// Close the modal
|
||||
await page.getByRole('button', { name: 'close' }).first().click();
|
||||
@@ -607,17 +585,12 @@ test.describe('Doc Editor', () => {
|
||||
* We open another browser that will connect to the collaborative server
|
||||
* and will block the current browser to edit the doc.
|
||||
*/
|
||||
const otherBrowser = await chromium.launch({ headless: true });
|
||||
const otherContext = await otherBrowser.newContext({
|
||||
locale: 'en-US',
|
||||
timezoneId: 'Europe/Paris',
|
||||
permissions: [],
|
||||
storageState: {
|
||||
cookies: [],
|
||||
origins: [],
|
||||
},
|
||||
const { otherPage } = await connectOtherUserToDoc({
|
||||
browserName,
|
||||
docUrl: urlChildDoc,
|
||||
docTitle: childTitle,
|
||||
withoutSignIn: true,
|
||||
});
|
||||
const otherPage = await otherContext.newPage();
|
||||
|
||||
const webSocketPromise = otherPage.waitForEvent(
|
||||
'websocket',
|
||||
@@ -658,6 +631,11 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await expect(editor).toHaveAttribute('contenteditable', 'false');
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Document title' }),
|
||||
).toBeHidden();
|
||||
await expect(page.getByRole('heading', { name: childTitle })).toBeVisible();
|
||||
|
||||
await page.goto(urlParentDoc);
|
||||
|
||||
await verifyDocName(page, parentTitle);
|
||||
@@ -674,6 +652,11 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await expect(editor).toHaveAttribute('contenteditable', 'true');
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Document title' }),
|
||||
).toContainText(childTitle);
|
||||
await expect(page.getByRole('heading', { name: childTitle })).toBeHidden();
|
||||
|
||||
await expect(
|
||||
card.getByText('Others are editing. Your network prevent changes.'),
|
||||
).toBeHidden();
|
||||
@@ -682,9 +665,7 @@ test.describe('Doc Editor', () => {
|
||||
test('it checks if callout custom block', async ({ page, browserName }) => {
|
||||
await createDoc(page, 'doc-toolbar', browserName, 1);
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await openSuggestionMenu({ page });
|
||||
await page.getByText('Add a callout block').click();
|
||||
|
||||
const calloutBlock = page
|
||||
@@ -769,15 +750,21 @@ test.describe('Doc Editor', () => {
|
||||
await expect(searchContainer.getByText(docChild2)).toBeVisible();
|
||||
await expect(searchContainer.getByText(randomDoc)).toBeHidden();
|
||||
|
||||
// use keydown to select the second result
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
const interlink = page.getByRole('link', {
|
||||
name: 'child-2',
|
||||
// Wait for the search container to disappear, indicating selection was made
|
||||
await expect(searchContainer).toBeHidden();
|
||||
|
||||
// Wait for the interlink to be created and rendered
|
||||
const editor = page.locator('.ProseMirror.bn-editor');
|
||||
|
||||
const interlink = editor.getByRole('link', {
|
||||
name: docChild2,
|
||||
});
|
||||
|
||||
await expect(interlink).toBeVisible();
|
||||
await expect(interlink).toBeVisible({ timeout: 10000 });
|
||||
await interlink.click();
|
||||
|
||||
await verifyDocName(page, docChild2);
|
||||
@@ -798,4 +785,94 @@ test.describe('Doc Editor', () => {
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('it checks multiple big doc scroll to the top', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-scroll', browserName, 1);
|
||||
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('Enter');
|
||||
await writeInEditor({ page, text: 'Hello Parent ' + i });
|
||||
}
|
||||
|
||||
const editor = await getEditor({ page });
|
||||
await expect(
|
||||
editor.getByText('Hello Parent 1', { exact: true }),
|
||||
).not.toBeInViewport();
|
||||
await expect(editor.getByText('Hello Parent 14')).toBeInViewport();
|
||||
|
||||
const { name: docChild } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'doc-scroll-child',
|
||||
);
|
||||
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await page.keyboard.press('Enter');
|
||||
await writeInEditor({ page, text: 'Hello Child ' + i });
|
||||
}
|
||||
|
||||
await expect(
|
||||
editor.getByText('Hello Child 1', { exact: true }),
|
||||
).not.toBeInViewport();
|
||||
await expect(editor.getByText('Hello Child 14')).toBeInViewport();
|
||||
|
||||
await navigateToPageFromTree({ page, title: randomDoc });
|
||||
|
||||
await expect(
|
||||
editor.getByText('Hello Parent 1', { exact: true }),
|
||||
).toBeInViewport();
|
||||
await expect(editor.getByText('Hello Parent 14')).not.toBeInViewport();
|
||||
|
||||
await navigateToPageFromTree({ page, title: docChild });
|
||||
|
||||
await expect(
|
||||
editor.getByText('Hello Child 1', { exact: true }),
|
||||
).toBeInViewport();
|
||||
await expect(editor.getByText('Hello Child 14')).not.toBeInViewport();
|
||||
});
|
||||
|
||||
test('it embeds PDF', async ({ page, browserName }) => {
|
||||
await createDoc(page, 'doc-toolbar', browserName, 1);
|
||||
|
||||
await openSuggestionMenu({ page });
|
||||
await page.getByText('Embed a PDF file').click();
|
||||
|
||||
const pdfBlock = page.locator('div[data-content-type="pdf"]').first();
|
||||
|
||||
await expect(pdfBlock).toBeVisible();
|
||||
|
||||
await page.getByText('Add PDF').click();
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByText('Upload file').click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
|
||||
console.log(path.join(__dirname, 'assets/test-pdf.pdf'));
|
||||
await fileChooser.setFiles(path.join(__dirname, 'assets/test-pdf.pdf'));
|
||||
|
||||
// Wait for the media-check to be processed
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const pdfEmbed = page
|
||||
.locator('.--docs--editor-container embed.bn-visual-media')
|
||||
.first();
|
||||
|
||||
// Check src of pdf
|
||||
expect(await pdfEmbed.getAttribute('src')).toMatch(
|
||||
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.pdf/,
|
||||
);
|
||||
|
||||
await expect(pdfEmbed).toHaveAttribute('type', 'application/pdf');
|
||||
await expect(pdfEmbed).toHaveAttribute('role', 'presentation');
|
||||
|
||||
// Check download with original filename
|
||||
await page.locator('.bn-block-content[data-content-type="pdf"]').click();
|
||||
await page.locator('[data-test="downloadfile"]').click();
|
||||
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe('test-pdf.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
verifyDocName,
|
||||
waitForLanguageSwitch,
|
||||
} from './utils-common';
|
||||
import { openSuggestionMenu, writeInEditor } from './utils-editor';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -93,6 +94,7 @@ test.describe('Doc Export', () => {
|
||||
|
||||
expect(pdfData.numpages).toBe(2);
|
||||
expect(pdfData.text).toContain('\n\nHello\n\nWorld'); // This is the doc text
|
||||
expect(pdfData.info.Title).toBe(randomDoc);
|
||||
});
|
||||
|
||||
test('it exports the doc to docx', async ({ page, browserName }) => {
|
||||
@@ -152,11 +154,13 @@ test.describe('Doc Export', () => {
|
||||
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
await writeInEditor({
|
||||
page,
|
||||
text: 'Hello World 😃🎉🚀🙋♀️🧑🏿❤️💋🧑🏾',
|
||||
});
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await openSuggestionMenu({ page });
|
||||
await page.getByText('Resizable image with caption').click();
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
@@ -393,7 +397,7 @@ test.describe('Doc Export', () => {
|
||||
})
|
||||
.click();
|
||||
|
||||
const input = page.locator('.--docs--doc-title-input[role="textbox"]');
|
||||
const input = page.getByRole('textbox', { name: 'Titre du document' });
|
||||
await expect(input).toBeVisible();
|
||||
await expect(input).toHaveText('', { timeout: 10000 });
|
||||
await input.click();
|
||||
@@ -410,6 +414,10 @@ test.describe('Doc Export', () => {
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId('doc-open-modal-download-button'),
|
||||
).toBeVisible();
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${randomDocFrench}.pdf`);
|
||||
});
|
||||
|
||||
@@ -106,7 +106,7 @@ test.describe('Doc grid dnd', () => {
|
||||
|
||||
await expect(dragOverlay).toBeVisible();
|
||||
await expect(dragOverlay).toHaveText(
|
||||
'You must be at least the editor of the target document',
|
||||
'You must be at least the administrator of the target document',
|
||||
);
|
||||
|
||||
await page.mouse.up();
|
||||
|
||||
@@ -28,7 +28,7 @@ test.describe('Documents Grid mobile', () => {
|
||||
id: '8c1e047a-24e7-4a80-942b-8e9c7ab43e1f',
|
||||
user: {
|
||||
id: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
|
||||
email: 'test@test.test',
|
||||
email: 'test.test@test.test',
|
||||
full_name: 'John Doe',
|
||||
short_name: 'John',
|
||||
},
|
||||
@@ -117,7 +117,7 @@ test.describe('Document grid item options', () => {
|
||||
await page.getByText('push_pin').click();
|
||||
|
||||
// Check is pinned
|
||||
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeVisible();
|
||||
await expect(row.getByTestId('doc-pinned-icon')).toBeVisible();
|
||||
const leftPanelFavorites = page.getByTestId('left-panel-favorites');
|
||||
await expect(leftPanelFavorites.getByText(docTitle)).toBeVisible();
|
||||
|
||||
@@ -126,7 +126,7 @@ test.describe('Document grid item options', () => {
|
||||
await page.getByText('Unpin').click();
|
||||
|
||||
// Check is unpinned
|
||||
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeHidden();
|
||||
await expect(row.getByTestId('doc-pinned-icon')).toBeHidden();
|
||||
await expect(leftPanelFavorites.getByText(docTitle)).toBeHidden();
|
||||
});
|
||||
|
||||
|
||||
@@ -75,22 +75,22 @@ test.describe('Doc Header', () => {
|
||||
// Check the tree
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
await expect(docTree.getByText('Hello Emoji World')).toBeVisible();
|
||||
await expect(docTree.getByLabel('Document emoji icon')).toBeVisible();
|
||||
await expect(docTree.getByLabel('Simple document icon')).toBeHidden();
|
||||
await expect(docTree.getByTestId('doc-emoji-icon')).toBeVisible();
|
||||
await expect(docTree.getByTestId('doc-simple-icon')).toBeHidden();
|
||||
|
||||
await page.getByTestId('home-button').click();
|
||||
|
||||
// Check the documents grid
|
||||
const gridRow = await getGridRow(page, 'Hello Emoji World');
|
||||
await expect(gridRow.getByLabel('Document emoji icon')).toBeVisible();
|
||||
await expect(gridRow.getByLabel('Simple document icon')).toBeHidden();
|
||||
await expect(gridRow.getByTestId('doc-emoji-icon')).toBeVisible();
|
||||
await expect(gridRow.getByTestId('doc-simple-icon')).toBeHidden();
|
||||
});
|
||||
|
||||
test('it deletes the doc', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByLabel('Delete document').click();
|
||||
await page.getByRole('menuitem', { name: 'Delete document' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Delete a doc' }),
|
||||
@@ -142,13 +142,19 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Document title' }),
|
||||
).toContainText('Mocked document');
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Export the document' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(page.getByLabel('Delete document')).toBeDisabled();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Delete document' }),
|
||||
).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
@@ -164,7 +170,7 @@ test.describe('Doc Header', () => {
|
||||
const invitationCard = shareModal.getByLabel('List invitation card');
|
||||
await expect(invitationCard).toBeVisible();
|
||||
await expect(
|
||||
invitationCard.getByText('test@invitation.test').first(),
|
||||
invitationCard.getByText('test.test@invitation.test').first(),
|
||||
).toBeVisible();
|
||||
const invitationRole = invitationCard.getByLabel('doc-role-dropdown');
|
||||
await expect(invitationRole).toBeVisible();
|
||||
@@ -178,7 +184,7 @@ test.describe('Doc Header', () => {
|
||||
const roles = memberCard.getByLabel('doc-role-dropdown');
|
||||
await expect(memberCard).toBeVisible();
|
||||
await expect(
|
||||
memberCard.getByText('test@accesses.test').first(),
|
||||
memberCard.getByText('test.test@accesses.test').first(),
|
||||
).toBeVisible();
|
||||
await expect(roles).toBeVisible();
|
||||
|
||||
@@ -216,12 +222,18 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('textbox', { name: 'Document title' }),
|
||||
).toContainText('Mocked document');
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Export the document' }),
|
||||
).toBeVisible();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(page.getByLabel('Delete document')).toBeDisabled();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Delete document' }),
|
||||
).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
@@ -239,7 +251,7 @@ test.describe('Doc Header', () => {
|
||||
const invitationCard = shareModal.getByLabel('List invitation card');
|
||||
await expect(invitationCard).toBeVisible();
|
||||
await expect(
|
||||
invitationCard.getByText('test@invitation.test').first(),
|
||||
invitationCard.getByText('test.test@invitation.test').first(),
|
||||
).toBeVisible();
|
||||
await expect(invitationCard.getByLabel('Document role text')).toBeVisible();
|
||||
await expect(
|
||||
@@ -247,7 +259,7 @@ test.describe('Doc Header', () => {
|
||||
).toBeHidden();
|
||||
|
||||
const memberCard = shareModal.getByLabel('List members card');
|
||||
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
|
||||
await expect(memberCard.getByText('test.test@accesses.test')).toBeVisible();
|
||||
await expect(memberCard.getByLabel('Document role text')).toBeVisible();
|
||||
await expect(
|
||||
memberCard.getByRole('button', { name: 'more_horiz' }),
|
||||
@@ -282,12 +294,18 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Mocked document' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Export the document' }),
|
||||
).toBeVisible();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(page.getByLabel('Delete document')).toBeDisabled();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Delete document' }),
|
||||
).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
@@ -302,7 +320,7 @@ test.describe('Doc Header', () => {
|
||||
const invitationCard = shareModal.getByLabel('List invitation card');
|
||||
await expect(invitationCard).toBeVisible();
|
||||
await expect(
|
||||
invitationCard.getByText('test@invitation.test').first(),
|
||||
invitationCard.getByText('test.test@invitation.test').first(),
|
||||
).toBeVisible();
|
||||
await expect(invitationCard.getByLabel('Document role text')).toBeVisible();
|
||||
await expect(
|
||||
@@ -310,7 +328,7 @@ test.describe('Doc Header', () => {
|
||||
).toBeHidden();
|
||||
|
||||
const memberCard = shareModal.getByLabel('List members card');
|
||||
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
|
||||
await expect(memberCard.getByText('test.test@accesses.test')).toBeVisible();
|
||||
await expect(memberCard.getByLabel('Document role text')).toBeVisible();
|
||||
await expect(
|
||||
memberCard.getByRole('button', { name: 'more_horiz' }),
|
||||
@@ -343,7 +361,7 @@ test.describe('Doc Header', () => {
|
||||
|
||||
// Copy content to clipboard
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByLabel('Copy as Markdown').click();
|
||||
await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click();
|
||||
await expect(page.getByText('Copied to clipboard')).toBeVisible();
|
||||
|
||||
// Test that clipboard is in Markdown format
|
||||
@@ -377,7 +395,7 @@ test.describe('Doc Header', () => {
|
||||
|
||||
// Copy content to clipboard
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByLabel('Copy as HTML').click();
|
||||
await page.getByRole('menuitem', { name: 'Copy as HTML' }).click();
|
||||
await expect(page.getByText('Copied to clipboard')).toBeVisible();
|
||||
|
||||
// Test that clipboard is in HTML format
|
||||
@@ -434,11 +452,15 @@ test.describe('Doc Header', () => {
|
||||
test('it pins a document', async ({ page, browserName }) => {
|
||||
const [docTitle] = await createDoc(page, `Pin doc`, browserName);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the document options' })
|
||||
.click();
|
||||
|
||||
// Pin
|
||||
await page.getByText('push_pin').click();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the document options' })
|
||||
.click();
|
||||
await expect(page.getByText('Unpin')).toBeVisible();
|
||||
|
||||
await page.goto('/');
|
||||
@@ -446,22 +468,26 @@ test.describe('Doc Header', () => {
|
||||
const row = await getGridRow(page, docTitle);
|
||||
|
||||
// Check is pinned
|
||||
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeVisible();
|
||||
await expect(row.getByTestId('doc-pinned-icon')).toBeVisible();
|
||||
const leftPanelFavorites = page.getByTestId('left-panel-favorites');
|
||||
await expect(leftPanelFavorites.getByText(docTitle)).toBeVisible();
|
||||
|
||||
await row.getByText(docTitle).click();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the document options' })
|
||||
.click();
|
||||
|
||||
// Unpin
|
||||
await page.getByText('Unpin').click();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Open the document options' })
|
||||
.click();
|
||||
await expect(page.getByText('push_pin')).toBeVisible();
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// Check is unpinned
|
||||
await expect(row.locator('[data-testid^="doc-pinned-"]')).toBeHidden();
|
||||
await expect(row.getByTestId('doc-pinned-icon')).toBeHidden();
|
||||
await expect(leftPanelFavorites.getByText(docTitle)).toBeHidden();
|
||||
});
|
||||
|
||||
@@ -560,7 +586,7 @@ test.describe('Documents Header mobile', () => {
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Copy link' }),
|
||||
).toBeVisible();
|
||||
await page.getByLabel('Share').click();
|
||||
await page.getByRole('menuitem', { name: 'Share' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Copy link' })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -583,7 +609,7 @@ test.describe('Documents Header mobile', () => {
|
||||
await goToGridDoc(page);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByLabel('Share').click();
|
||||
await page.getByRole('menuitem', { name: 'Share' }).click();
|
||||
|
||||
const shareModal = page.getByRole('dialog', {
|
||||
name: 'Share modal content',
|
||||
|
||||
@@ -18,7 +18,7 @@ test.describe('Inherited share accesses', () => {
|
||||
).toBeVisible();
|
||||
|
||||
const user = page.getByTestId(
|
||||
`doc-share-member-row-user@${browserName}.test`,
|
||||
`doc-share-member-row-user.test@${browserName}.test`,
|
||||
);
|
||||
await expect(user).toBeVisible();
|
||||
await expect(user.getByText('E2E Chromium')).toBeVisible();
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
randomName,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { connectOtherUserToDoc, updateRoleUser } from './utils-share';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.describe('Document create member', () => {
|
||||
@@ -25,9 +26,8 @@ test.describe('Document create member', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
const inputSearch = page.getByTestId('quick-search-input');
|
||||
|
||||
await expect(inputSearch).toBeVisible();
|
||||
|
||||
// Select user 1 and verify tag
|
||||
@@ -74,13 +74,15 @@ test.describe('Document create member', () => {
|
||||
|
||||
// Check roles are displayed
|
||||
await list.getByLabel('doc-role-dropdown').click();
|
||||
await expect(page.getByLabel('Reader')).toBeVisible();
|
||||
await expect(page.getByLabel('Editor')).toBeVisible();
|
||||
await expect(page.getByLabel('Owner')).toBeVisible();
|
||||
await expect(page.getByLabel('Administrator')).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Reader' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Editor' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Owner' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Administrator' }),
|
||||
).toBeVisible();
|
||||
|
||||
// Validate
|
||||
await page.getByLabel('Administrator').click();
|
||||
await page.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
// Check invitation added
|
||||
@@ -117,9 +119,7 @@ test.describe('Document create member', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
const inputSearch = page.getByTestId('quick-search-input');
|
||||
|
||||
const [email] = randomName('test@test.fr', browserName, 1);
|
||||
await inputSearch.fill(email);
|
||||
@@ -128,7 +128,7 @@ test.describe('Document create member', () => {
|
||||
// Choose a role
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByLabel('Owner').click();
|
||||
await page.getByRole('menuitem', { name: 'Owner' }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -146,7 +146,7 @@ test.describe('Document create member', () => {
|
||||
|
||||
// Choose a role
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByLabel('Owner').click();
|
||||
await page.getByRole('menuitem', { name: 'Owner' }).click();
|
||||
|
||||
const responsePromiseCreateInvitationFail = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -167,18 +167,23 @@ test.describe('Document create member', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
const inputSearch = page.getByTestId('quick-search-input');
|
||||
|
||||
const email = randomName('test@test.fr', browserName, 1)[0];
|
||||
let email = 'user.test21@example.COM';
|
||||
await inputSearch.fill(email);
|
||||
|
||||
// Check email is found in search (case insensitive)
|
||||
await expect(page.getByRole('option').getByText(email)).toHaveCount(1);
|
||||
|
||||
email = email + 'M';
|
||||
await inputSearch.fill(email);
|
||||
|
||||
await page.getByTestId(`search-user-row-${email}`).click();
|
||||
|
||||
// Choose a role
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByLabel('Administrator').click();
|
||||
await page.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
@@ -193,26 +198,22 @@ test.describe('Document create member', () => {
|
||||
|
||||
const listInvitation = page.getByTestId('doc-share-quick-search');
|
||||
const userInvitation = listInvitation.getByTestId(
|
||||
`doc-share-invitation-row-${email}`,
|
||||
`doc-share-invitation-row-${email.toLowerCase()}`,
|
||||
);
|
||||
await expect(userInvitation).toBeVisible();
|
||||
|
||||
await userInvitation.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByLabel('Reader').click();
|
||||
await page.getByRole('menuitem', { name: 'Reader' }).click();
|
||||
|
||||
const moreActions = userInvitation.getByRole('button', {
|
||||
name: 'Open invitation actions menu',
|
||||
});
|
||||
await moreActions.click();
|
||||
|
||||
await page.getByLabel('Delete').click();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
|
||||
await expect(userInvitation).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Document create member: Multiple login', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('It creates a member from a request coming from a 403 page', async ({
|
||||
page,
|
||||
@@ -220,9 +221,6 @@ test.describe('Document create member: Multiple login', () => {
|
||||
}) => {
|
||||
test.slow();
|
||||
|
||||
await page.goto('/');
|
||||
await keyCloakSignIn(page, browserName);
|
||||
|
||||
const [docTitle] = await createDoc(
|
||||
page,
|
||||
'Member access request',
|
||||
@@ -232,67 +230,67 @@ test.describe('Document create member: Multiple login', () => {
|
||||
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Logout',
|
||||
})
|
||||
.click();
|
||||
.locator('.ProseMirror')
|
||||
.locator('.bn-block-outer')
|
||||
.last()
|
||||
.fill('Hello World');
|
||||
|
||||
const otherBrowser = BROWSERS.find((b) => b !== browserName);
|
||||
const docUrl = page.url();
|
||||
|
||||
await keyCloakSignIn(page, otherBrowser!);
|
||||
|
||||
await expect(page.getByTestId('header-logo-link')).toBeVisible();
|
||||
|
||||
await page.goto(urlDoc);
|
||||
// Other user will request access
|
||||
const { otherPage, otherBrowserName, cleanup } =
|
||||
await connectOtherUserToDoc({ browserName, docUrl });
|
||||
|
||||
await expect(
|
||||
page.getByText('Insufficient access rights to view the document.'),
|
||||
otherPage.getByText('Insufficient access rights to view the document.'),
|
||||
).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Request access' }).click();
|
||||
await otherPage.getByRole('button', { name: 'Request access' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText('Your access request for this document is pending.'),
|
||||
otherPage.getByText('Your access request for this document is pending.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Logout',
|
||||
})
|
||||
.click();
|
||||
|
||||
await page.goto('/');
|
||||
await keyCloakSignIn(page, browserName);
|
||||
|
||||
await expect(page.getByTestId('header-logo-link')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await page.goto(urlDoc);
|
||||
|
||||
// First user approves the request
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
await expect(page.getByText('Access Requests')).toBeVisible();
|
||||
await expect(page.getByText(`E2E ${otherBrowser}`)).toBeVisible();
|
||||
await expect(page.getByText(`E2E ${otherBrowserName}`)).toBeVisible();
|
||||
|
||||
const emailRequest = `user@${otherBrowser}.test`;
|
||||
const emailRequest = `user.test@${otherBrowserName}.test`;
|
||||
await expect(page.getByText(emailRequest)).toBeVisible();
|
||||
const container = page.getByTestId(
|
||||
`doc-share-access-request-row-${emailRequest}`,
|
||||
);
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByLabel('Administrator').click();
|
||||
await page.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
await container.getByRole('button', { name: 'Approve' }).click();
|
||||
|
||||
await expect(page.getByText('Access Requests')).toBeHidden();
|
||||
await expect(page.getByText('Share with 2 users')).toBeVisible();
|
||||
await expect(page.getByText(`E2E ${otherBrowser}`)).toBeVisible();
|
||||
await expect(page.getByText(`E2E ${otherBrowserName}`)).toBeVisible();
|
||||
|
||||
// Other user verifies he has access
|
||||
await otherPage.reload();
|
||||
await verifyDocName(otherPage, docTitle);
|
||||
await expect(otherPage.getByText('Hello World')).toBeVisible();
|
||||
|
||||
// Revoke access
|
||||
await updateRoleUser(page, 'Remove access', emailRequest);
|
||||
await expect(
|
||||
otherPage.getByText('Insufficient access rights to view the document.'),
|
||||
).toBeVisible();
|
||||
|
||||
// Cleanup: other user logout
|
||||
await cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Document create member: Multiple login', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test('It cannot request member access on child doc on a 403 page', async ({
|
||||
page,
|
||||
|
||||
@@ -139,7 +139,7 @@ test.describe('Document list members', () => {
|
||||
const list = page.getByTestId('doc-share-quick-search');
|
||||
await expect(list).toBeVisible();
|
||||
const currentUser = list.getByTestId(
|
||||
`doc-share-member-row-user@${browserName}.test`,
|
||||
`doc-share-member-row-user.test@${browserName}.test`,
|
||||
);
|
||||
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
|
||||
await expect(currentUser).toBeVisible();
|
||||
@@ -171,12 +171,12 @@ test.describe('Document list members', () => {
|
||||
});
|
||||
|
||||
await currentUserRole.click();
|
||||
await page.getByLabel('Administrator').click();
|
||||
await page.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
await list.click();
|
||||
await expect(currentUserRole).toBeVisible();
|
||||
|
||||
await currentUserRole.click();
|
||||
await page.getByLabel('Reader').click();
|
||||
await page.getByRole('menuitem', { name: 'Reader' }).click();
|
||||
await list.click();
|
||||
await expect(currentUserRole).toBeHidden();
|
||||
});
|
||||
@@ -190,7 +190,7 @@ test.describe('Document list members', () => {
|
||||
|
||||
const list = page.getByTestId('doc-share-quick-search');
|
||||
|
||||
const emailMyself = `user@${browserName}.test`;
|
||||
const emailMyself = `user.test@${browserName}.test`;
|
||||
const mySelf = list.getByTestId(`doc-share-member-row-${emailMyself}`);
|
||||
const mySelfRole = mySelf.getByRole('button', {
|
||||
name: 'doc-role-dropdown',
|
||||
|
||||
@@ -93,6 +93,12 @@ test.describe('Doc Routing', () => {
|
||||
await expect(page.getByText('Log in to access the document.')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveAttribute(
|
||||
'content',
|
||||
'noindex',
|
||||
);
|
||||
await expect(page).toHaveTitle(/401 Unauthorized - Docs/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ test.beforeEach(async ({ page }) => {
|
||||
|
||||
test.describe('Doc Table Content', () => {
|
||||
test('it checks the doc table content', async ({ page, browserName }) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
const [randomDoc] = await createDoc(
|
||||
page,
|
||||
'doc-table-content',
|
||||
|
||||
@@ -220,11 +220,11 @@ test.describe('Doc Tree', () => {
|
||||
|
||||
const list = page.getByTestId('doc-share-quick-search');
|
||||
const currentUser = list.getByTestId(
|
||||
`doc-share-member-row-user@${browserName}.test`,
|
||||
`doc-share-member-row-user.test@${browserName}.test`,
|
||||
);
|
||||
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
|
||||
await currentUserRole.click();
|
||||
await page.getByLabel('Administrator').click();
|
||||
await page.getByRole('menuitem', { name: 'Administrator' }).click();
|
||||
await list.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Ok' }).click();
|
||||
@@ -235,6 +235,12 @@ test.describe('Doc Tree', () => {
|
||||
'doc-tree-detach-child',
|
||||
);
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByLabel('It is the card information about the document.')
|
||||
.getByText('Administrator ·'),
|
||||
).toBeVisible();
|
||||
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
await expect(docTree.getByText(docChild)).toBeVisible();
|
||||
await docTree.click();
|
||||
@@ -252,6 +258,46 @@ test.describe('Doc Tree', () => {
|
||||
page.getByRole('menuitem', { name: 'Move to my docs' }),
|
||||
).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
test('keyboard navigation with Enter key opens documents', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
// Create a parent document
|
||||
const [docParent] = await createDoc(
|
||||
page,
|
||||
'doc-tree-keyboard-nav',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
await verifyDocName(page, docParent);
|
||||
|
||||
// Create a sub-document
|
||||
const { name: docChild } = await createRootSubPage(
|
||||
page,
|
||||
browserName,
|
||||
'doc-tree-keyboard-child',
|
||||
);
|
||||
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
await expect(docTree).toBeVisible();
|
||||
|
||||
// Test keyboard navigation on root document
|
||||
const rootItem = page.getByTestId('doc-tree-root-item');
|
||||
await expect(rootItem).toBeVisible();
|
||||
|
||||
// Focus on the root item and press Enter
|
||||
await rootItem.focus();
|
||||
await expect(rootItem).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Verify we navigated to the root document
|
||||
await verifyDocName(page, docParent);
|
||||
await expect(page).toHaveURL(/\/docs\/[^/]+\/?$/);
|
||||
|
||||
// Now test keyboard navigation on sub-document
|
||||
await expect(docTree.getByText(docChild)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Doc Tree: Inheritance', () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ test.describe('Doc Version', () => {
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByLabel('Version history').click();
|
||||
await page.getByRole('menuitem', { name: 'Version history' }).click();
|
||||
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
||||
|
||||
const modal = page.getByLabel('version history modal');
|
||||
@@ -54,7 +54,7 @@ test.describe('Doc Version', () => {
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByLabel('Version history').click();
|
||||
await page.getByRole('menuitem', { name: 'Version history' }).click();
|
||||
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
||||
@@ -82,7 +82,9 @@ test.describe('Doc Version', () => {
|
||||
await verifyDocName(page, 'Mocked document');
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await expect(page.getByLabel('Version history')).toBeDisabled();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Version history' }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('it restores the doc version', async ({ page, browserName }) => {
|
||||
@@ -109,7 +111,7 @@ test.describe('Doc Version', () => {
|
||||
await expect(page.getByText('World')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByLabel('Version history').click();
|
||||
await page.getByRole('menuitem', { name: 'Version history' }).click();
|
||||
|
||||
const modal = page.getByLabel('version history modal');
|
||||
const panel = modal.getByLabel('version list');
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
keyCloakSignIn,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
import { addNewMember, connectOtherUserToDoc } from './utils-share';
|
||||
import { createRootSubPage } from './utils-sub-pages';
|
||||
|
||||
test.describe('Doc Visibility', () => {
|
||||
@@ -44,17 +45,21 @@ test.describe('Doc Visibility', () => {
|
||||
|
||||
await expect(selectVisibility.getByText('Private')).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel('Read only')).toBeHidden();
|
||||
await expect(page.getByLabel('Can read and edit')).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Read only' }),
|
||||
).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Can read and edit' }),
|
||||
).toBeHidden();
|
||||
|
||||
await selectVisibility.click();
|
||||
await page.getByLabel('Connected').click();
|
||||
await page.getByRole('menuitem', { name: 'Connected' }).click();
|
||||
|
||||
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
|
||||
|
||||
await selectVisibility.click();
|
||||
|
||||
await page.getByLabel('Public', { exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: 'Public' }).click();
|
||||
|
||||
await expect(page.getByTestId('doc-access-mode')).toBeVisible();
|
||||
});
|
||||
@@ -146,47 +151,31 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page
|
||||
.locator('.ProseMirror')
|
||||
.locator('.bn-block-outer')
|
||||
.last()
|
||||
.fill('Hello World');
|
||||
|
||||
const docUrl = page.url();
|
||||
|
||||
const { otherBrowserName, otherPage } = await connectOtherUserToDoc({
|
||||
browserName,
|
||||
docUrl,
|
||||
});
|
||||
|
||||
await expect(
|
||||
otherPage.getByText('Insufficient access rights to view the document.'),
|
||||
).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
await addNewMember(page, 0, 'Reader', otherBrowserName);
|
||||
|
||||
const otherBrowser = BROWSERS.find((b) => b !== browserName);
|
||||
if (!otherBrowser) {
|
||||
throw new Error('No alternative browser found');
|
||||
}
|
||||
const username = `user@${otherBrowser}.test`;
|
||||
await inputSearch.fill(username);
|
||||
await page.getByRole('option', { name: username }).click();
|
||||
|
||||
// Choose a role
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByLabel('Reader').click();
|
||||
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Logout',
|
||||
})
|
||||
.click();
|
||||
|
||||
await keyCloakSignIn(page, otherBrowser);
|
||||
|
||||
await expect(page.getByTestId('header-logo-link')).toBeVisible();
|
||||
|
||||
await page.goto(urlDoc);
|
||||
|
||||
await verifyDocName(page, docTitle);
|
||||
await expect(page.getByLabel('Share button')).toBeVisible();
|
||||
await otherPage.reload();
|
||||
await expect(otherPage.getByText('Hello World')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -308,7 +297,7 @@ test.describe('Doc Visibility: Public', () => {
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByTestId('doc-access-mode').click();
|
||||
await page.getByLabel('Editing').click();
|
||||
await page.getByRole('menuitem', { name: 'Editing' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.').first(),
|
||||
@@ -531,7 +520,7 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
|
||||
const urlDoc = page.url();
|
||||
await page.getByTestId('doc-access-mode').click();
|
||||
await page.getByLabel('Editing').click();
|
||||
await page.getByRole('menuitem', { name: 'Editing' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.').first(),
|
||||
|
||||
@@ -47,7 +47,7 @@ test.describe('Footer', () => {
|
||||
// Check the translation
|
||||
const header = page.locator('header').first();
|
||||
await header.getByRole('button').getByText('English').click();
|
||||
await page.getByLabel('Français').click();
|
||||
await page.getByRole('menuitem', { name: 'Français' }).click();
|
||||
|
||||
await expect(
|
||||
page.locator('footer').getByText('Mentions légales'),
|
||||
@@ -132,7 +132,7 @@ test.describe('Footer', () => {
|
||||
// Check the translation
|
||||
const header = page.locator('header').first();
|
||||
await header.getByRole('button').getByText('English').click();
|
||||
await page.getByLabel('Français').click();
|
||||
await page.getByRole('menuitem', { name: 'Français' }).click();
|
||||
|
||||
await expect(
|
||||
page
|
||||
|
||||
@@ -131,7 +131,7 @@ test.describe('Home page', () => {
|
||||
|
||||
// Keyclock login page
|
||||
await expect(
|
||||
page.locator('.login-pf-page-header').getByText('impress'),
|
||||
page.locator('.login-pf #kc-header-wrapper').getByText('impress'),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
export const BROWSERS = ['chromium', 'webkit', 'firefox'];
|
||||
export type BrowserName = 'chromium' | 'firefox' | 'webkit';
|
||||
export const BROWSERS: BrowserName[] = ['chromium', 'webkit', 'firefox'];
|
||||
|
||||
export const CONFIG = {
|
||||
AI_FEATURE_ENABLED: true,
|
||||
@@ -56,7 +57,7 @@ export const keyCloakSignIn = async (
|
||||
const password = `password-e2e-${browserName}`;
|
||||
|
||||
await expect(
|
||||
page.locator('.login-pf-page-header').getByText('impress'),
|
||||
page.locator('.login-pf #kc-header-wrapper').getByText('impress'),
|
||||
).toBeVisible();
|
||||
|
||||
if (await page.getByLabel('Restart login').isVisible()) {
|
||||
@@ -65,7 +66,7 @@ export const keyCloakSignIn = async (
|
||||
|
||||
await page.getByRole('textbox', { name: 'username' }).fill(login);
|
||||
await page.getByRole('textbox', { name: 'password' }).fill(password);
|
||||
await page.click('input[type="submit"]', { force: true });
|
||||
await page.click('button[type="submit"]', { force: true });
|
||||
};
|
||||
|
||||
export const randomName = (name: string, browserName: string, length: number) =>
|
||||
@@ -322,5 +323,5 @@ export async function waitForLanguageSwitch(
|
||||
|
||||
await languagePicker.click();
|
||||
|
||||
await page.getByLabel(lang.label).click();
|
||||
await page.getByRole('menuitem', { name: lang.label }).click();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export const getEditor = async ({ page }: { page: Page }) => {
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
return editor;
|
||||
};
|
||||
|
||||
export const openSuggestionMenu = async ({ page }: { page: Page }) => {
|
||||
const editor = await getEditor({ page });
|
||||
await editor.click();
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
|
||||
return editor;
|
||||
};
|
||||
|
||||
export const writeInEditor = async ({
|
||||
page,
|
||||
text,
|
||||
}: {
|
||||
page: Page;
|
||||
text: string;
|
||||
}) => {
|
||||
const editor = await getEditor({ page });
|
||||
editor.locator('.bn-block-outer').last().fill(text);
|
||||
return editor;
|
||||
};
|
||||
@@ -1,8 +1,15 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
import { Page, chromium, expect } from '@playwright/test';
|
||||
|
||||
import {
|
||||
BROWSERS,
|
||||
BrowserName,
|
||||
keyCloakSignIn,
|
||||
verifyDocName,
|
||||
} from './utils-common';
|
||||
|
||||
export type Role = 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader';
|
||||
export type LinkReach = 'Private' | 'Connected' | 'Public';
|
||||
export type LinkRole = 'Reading' | 'Edition';
|
||||
export type LinkRole = 'Reading' | 'Editing';
|
||||
|
||||
export const addNewMember = async (
|
||||
page: Page,
|
||||
@@ -16,9 +23,7 @@ export const addNewMember = async (
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
const inputSearch = page.getByTestId('quick-search-input');
|
||||
|
||||
// Select a new user
|
||||
await inputSearch.fill(fillText);
|
||||
@@ -34,7 +39,7 @@ export const addNewMember = async (
|
||||
|
||||
// Choose a role
|
||||
await page.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByLabel(role).click();
|
||||
await page.getByRole('menuitem', { name: role }).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
return users[index].email;
|
||||
@@ -61,6 +66,80 @@ export const updateShareLink = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const updateRoleUser = async (
|
||||
page: Page,
|
||||
role: Role | 'Remove access',
|
||||
email: string,
|
||||
) => {
|
||||
const list = page.getByTestId('doc-share-quick-search');
|
||||
|
||||
const currentUser = list.getByTestId(`doc-share-member-row-${email}`);
|
||||
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
|
||||
await currentUserRole.click();
|
||||
await page.getByRole('menuitem', { name: role }).click();
|
||||
await list.click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Connects another user to a document.
|
||||
* Useful to test real-time collaboration features.
|
||||
* @param browserName The name of the browser to use.
|
||||
* @param docUrl The URL of the document to connect to.
|
||||
* @param docTitle The title of the document (optional).
|
||||
* @returns An object containing the other browser, context, and page.
|
||||
*/
|
||||
export const connectOtherUserToDoc = async ({
|
||||
browserName,
|
||||
docUrl,
|
||||
docTitle,
|
||||
withoutSignIn,
|
||||
}: {
|
||||
browserName: BrowserName;
|
||||
docUrl: string;
|
||||
docTitle?: string;
|
||||
withoutSignIn?: boolean;
|
||||
}) => {
|
||||
const otherBrowserName = BROWSERS.find((b) => b !== browserName);
|
||||
if (!otherBrowserName) {
|
||||
throw new Error('No alternative browser found');
|
||||
}
|
||||
|
||||
const otherBrowser = await chromium.launch({ headless: true });
|
||||
const otherContext = await otherBrowser.newContext({
|
||||
locale: 'en-US',
|
||||
timezoneId: 'Europe/Paris',
|
||||
permissions: [],
|
||||
storageState: {
|
||||
cookies: [],
|
||||
origins: [],
|
||||
},
|
||||
});
|
||||
const otherPage = await otherContext.newPage();
|
||||
await otherPage.goto(docUrl);
|
||||
|
||||
if (!withoutSignIn) {
|
||||
await otherPage
|
||||
.getByRole('main', { name: 'Main content' })
|
||||
.getByLabel('Login')
|
||||
.click({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
await keyCloakSignIn(otherPage, otherBrowserName, false);
|
||||
}
|
||||
if (docTitle) {
|
||||
await verifyDocName(otherPage, docTitle);
|
||||
}
|
||||
|
||||
const cleanup = async () => {
|
||||
await otherPage.close();
|
||||
await otherContext.close();
|
||||
await otherBrowser.close();
|
||||
};
|
||||
|
||||
return { otherBrowser, otherContext, otherPage, otherBrowserName, cleanup };
|
||||
};
|
||||
|
||||
export const mockedInvitations = async (page: Page, json?: object) => {
|
||||
let result = [
|
||||
{
|
||||
@@ -72,7 +151,7 @@ export const mockedInvitations = async (page: Page, json?: object) => {
|
||||
retrieve: true,
|
||||
},
|
||||
created_at: '2024-10-03T12:19:26.107687Z',
|
||||
email: 'test@invitation.test',
|
||||
email: 'test.test@invitation.test',
|
||||
document: '4888c328-8406-4412-9b0b-c0ba5b9e5fb6',
|
||||
role: 'editor',
|
||||
issuer: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
|
||||
@@ -129,7 +208,7 @@ export const mockedAccesses = async (page: Page, json?: object) => {
|
||||
id: 'bc8bbbc5-a635-4f65-9817-fd1e9ec8ef87',
|
||||
user: {
|
||||
id: 'b4a21bb3-722e-426c-9f78-9d190eda641c',
|
||||
email: 'test@accesses.test',
|
||||
email: 'test.test@accesses.test',
|
||||
},
|
||||
team: '',
|
||||
max_ancestors_role: null,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Page, expect } from '@playwright/test';
|
||||
import {
|
||||
randomName,
|
||||
updateDocTitle,
|
||||
verifyDocName,
|
||||
waitForResponseCreateDoc,
|
||||
} from './utils-common';
|
||||
|
||||
@@ -63,5 +64,17 @@ export const clickOnAddRootSubPage = async (page: Page) => {
|
||||
const rootItem = page.getByTestId('doc-tree-root-item');
|
||||
await expect(rootItem).toBeVisible();
|
||||
await rootItem.hover();
|
||||
await rootItem.getByRole('button', { name: 'add_box' }).click();
|
||||
await rootItem.getByTestId('doc-tree-item-actions-add-child').click();
|
||||
};
|
||||
|
||||
export const navigateToPageFromTree = async ({
|
||||
page,
|
||||
title,
|
||||
}: {
|
||||
page: Page;
|
||||
title: string;
|
||||
}) => {
|
||||
const docTree = page.getByTestId('doc-tree');
|
||||
await docTree.getByText(title).click();
|
||||
await verifyDocName(page, title);
|
||||
};
|
||||
|
||||
@@ -35,3 +35,8 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
|
||||
service-worker.js
|
||||
|
||||
# Font embedding
|
||||
public/assets/fonts/emoji/*
|
||||
!public/assets/fonts/emoji/fallback.png
|
||||
public/assets/fonts/Marianne/*
|
||||
@@ -1,2 +1,3 @@
|
||||
next-env.d.ts
|
||||
service-worker.js
|
||||
public/assets/fonts/*
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
|
||||
const CopyPlugin = require('copy-webpack-plugin');
|
||||
const { InjectManifest } = require('workbox-webpack-plugin');
|
||||
|
||||
const buildId = crypto.randomBytes(256).toString('hex').slice(0, 8);
|
||||
@@ -41,6 +43,30 @@ const nextConfig = {
|
||||
},
|
||||
);
|
||||
|
||||
// Copy necessary fonts from node_modules to public directory during build or dev
|
||||
config.plugins.push(
|
||||
new CopyPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: path.resolve(
|
||||
__dirname,
|
||||
'../../node_modules/emoji-datasource-apple/img/apple/64',
|
||||
),
|
||||
to: path.resolve(__dirname, 'public/assets/fonts/emoji'),
|
||||
force: true,
|
||||
},
|
||||
{
|
||||
from: path.resolve(
|
||||
__dirname,
|
||||
'../../node_modules/@gouvfr-lasuite/ui-kit/dist/assets/fonts/Marianne',
|
||||
),
|
||||
to: path.resolve(__dirname, 'public/assets/fonts/Marianne'),
|
||||
force: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
if (!isServer && process.env.NEXT_PUBLIC_SW_DEACTIVATED !== 'true') {
|
||||
config.plugins.push(
|
||||
new InjectManifest({
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@emoji-mart/data": "1.2.1",
|
||||
"@emoji-mart/react": "1.1.1",
|
||||
"@fontsource-variable/inter": "5.2.8",
|
||||
"@fontsource/material-icons": "5.2.5",
|
||||
"@gouvfr-lasuite/integration": "1.0.3",
|
||||
"@gouvfr-lasuite/ui-kit": "0.16.1",
|
||||
@@ -43,6 +44,7 @@
|
||||
"cmdk": "1.1.1",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"docx": "9.5.0",
|
||||
"emoji-datasource-apple": "16.0.0",
|
||||
"emoji-mart": "5.6.0",
|
||||
"emoji-regex": "10.5.0",
|
||||
"i18next": "25.5.2",
|
||||
@@ -77,6 +79,7 @@
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"@vitejs/plugin-react": "5.0.2",
|
||||
"copy-webpack-plugin": "13.0.1",
|
||||
"cross-env": "10.0.0",
|
||||
"dotenv": "17.2.2",
|
||||
"eslint-plugin-docs": "*",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,101 +0,0 @@
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Thin.woff2') format('woff2'),
|
||||
url('Marianne-Thin.woff') format('woff');
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Thin_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Thin_Italic.woff') format('woff');
|
||||
font-weight: 100;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Light.woff2') format('woff2'),
|
||||
url('Marianne-Light.woff') format('woff');
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Light_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Light_Italic.woff') format('woff');
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Regular.woff2') format('woff2'),
|
||||
url('Marianne-Regular.woff') format('woff');
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Regular_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Regular_Italic.woff') format('woff');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Medium.woff2') format('woff2'),
|
||||
url('Marianne-Medium.woff') format('woff');
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Medium_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Medium_Italic.woff') format('woff');
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Bold.woff2') format('woff2'),
|
||||
url('Marianne-Bold.woff') format('woff');
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-Bold_Italic.woff2') format('woff2'),
|
||||
url('Marianne-Bold_Italic.woff') format('woff');
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-ExtraBold.woff2') format('woff2'),
|
||||
url('Marianne-ExtraBold.woff') format('woff');
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Marianne;
|
||||
src:
|
||||
url('Marianne-ExtraBold_Italic.woff2') format('woff2'),
|
||||
url('Marianne-ExtraBold_Italic.woff') format('woff');
|
||||
font-weight: 800;
|
||||
font-style: italic;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -12,9 +12,14 @@ export const Icon = ({
|
||||
variant = 'outlined',
|
||||
...textProps
|
||||
}: IconProps) => {
|
||||
const hasLabel = 'aria-label' in textProps || 'aria-labelledby' in textProps;
|
||||
const ariaHidden =
|
||||
'aria-hidden' in textProps ? textProps['aria-hidden'] : !hasLabel;
|
||||
|
||||
return (
|
||||
<Text
|
||||
{...textProps}
|
||||
aria-hidden={ariaHidden}
|
||||
className={clsx('--docs--icon-bg', textProps.className, {
|
||||
'material-icons-filled': variant === 'filled',
|
||||
'material-icons': variant === 'outlined',
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface TextProps extends BoxProps {
|
||||
$ellipsis?: boolean;
|
||||
$weight?: CSSProperties['fontWeight'];
|
||||
$textAlign?: CSSProperties['textAlign'];
|
||||
$textTransform?: CSSProperties['textTransform'];
|
||||
$size?: TextSizes | (string & {});
|
||||
$theme?:
|
||||
| 'primary'
|
||||
@@ -43,6 +44,8 @@ export type TextType = ComponentPropsWithRef<typeof Text>;
|
||||
|
||||
export const TextStyled = styled(Box)<TextProps>`
|
||||
${({ $textAlign }) => $textAlign && `text-align: ${$textAlign};`}
|
||||
${({ $textTransform }) =>
|
||||
$textTransform && `text-transform: ${$textTransform};`}
|
||||
${({ $weight }) => $weight && `font-weight: ${$weight};`}
|
||||
${({ $size }) =>
|
||||
$size &&
|
||||
|
||||
@@ -162,7 +162,6 @@ export const DropdownMenu = ({
|
||||
menuItemRefs.current[index] = el;
|
||||
}}
|
||||
role="menuitem"
|
||||
aria-label={option.label}
|
||||
data-testid={option.testId}
|
||||
$direction="row"
|
||||
disabled={isDisabled}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { Command } from 'cmdk';
|
||||
import {
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { PropsWithChildren, ReactNode, useId, useRef, useState } from 'react';
|
||||
|
||||
import { hasChildrens } from '@/utils/children';
|
||||
|
||||
@@ -49,32 +43,23 @@ export const QuickSearch = ({
|
||||
children,
|
||||
}: PropsWithChildren<QuickSearchProps>) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const [selectedValue, setSelectedValue] = useState<string>('');
|
||||
const listId = useId();
|
||||
const NO_SELECTION_VALUE = '__none__';
|
||||
const [userInteracted, setUserInteracted] = useState(false);
|
||||
const [selectedValue, setSelectedValue] = useState(NO_SELECTION_VALUE);
|
||||
const isExpanded = userInteracted;
|
||||
|
||||
// Auto-select first item when children change
|
||||
useEffect(() => {
|
||||
if (!children) {
|
||||
setSelectedValue('');
|
||||
return;
|
||||
const handleValueChange = (val: string) => {
|
||||
if (userInteracted) {
|
||||
setSelectedValue(val);
|
||||
}
|
||||
};
|
||||
|
||||
// Small delay for DOM to update
|
||||
const timeoutId = setTimeout(() => {
|
||||
const firstItem = ref.current?.querySelector('[cmdk-item]');
|
||||
if (firstItem) {
|
||||
const value =
|
||||
firstItem.getAttribute('data-value') ||
|
||||
firstItem.getAttribute('value') ||
|
||||
firstItem.textContent?.trim() ||
|
||||
'';
|
||||
if (value) {
|
||||
setSelectedValue(value);
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [children]);
|
||||
const handleUserInteract = () => {
|
||||
if (!userInteracted) {
|
||||
setUserInteracted(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -84,9 +69,9 @@ export const QuickSearch = ({
|
||||
label={label}
|
||||
shouldFilter={false}
|
||||
ref={ref}
|
||||
value={selectedValue}
|
||||
onValueChange={setSelectedValue}
|
||||
tabIndex={0}
|
||||
value={selectedValue}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
{showInput && (
|
||||
<QuickSearchInput
|
||||
@@ -95,11 +80,14 @@ export const QuickSearch = ({
|
||||
inputValue={inputValue}
|
||||
onFilter={onFilter}
|
||||
placeholder={placeholder}
|
||||
listId={listId}
|
||||
isExpanded={isExpanded}
|
||||
onUserInteract={handleUserInteract}
|
||||
>
|
||||
{inputContent}
|
||||
</QuickSearchInput>
|
||||
)}
|
||||
<Command.List>
|
||||
<Command.List id={listId} aria-label={label} role="listbox">
|
||||
<Box>{children}</Box>
|
||||
</Command.List>
|
||||
</Command>
|
||||
|
||||
@@ -16,6 +16,9 @@ type Props = {
|
||||
placeholder?: string;
|
||||
children?: ReactNode;
|
||||
withSeparator?: boolean;
|
||||
listId?: string;
|
||||
onUserInteract?: () => void;
|
||||
isExpanded?: boolean;
|
||||
};
|
||||
export const QuickSearchInput = ({
|
||||
loading,
|
||||
@@ -24,6 +27,9 @@ export const QuickSearchInput = ({
|
||||
placeholder,
|
||||
children,
|
||||
withSeparator: separator = true,
|
||||
listId,
|
||||
onUserInteract,
|
||||
isExpanded,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
@@ -57,14 +63,19 @@ export const QuickSearchInput = ({
|
||||
<Command.Input
|
||||
autoFocus={true}
|
||||
aria-label={t('Quick search input')}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={listId}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUserInteract?.();
|
||||
}}
|
||||
onKeyDown={() => onUserInteract?.()}
|
||||
value={inputValue}
|
||||
role="combobox"
|
||||
placeholder={placeholder ?? t('Search')}
|
||||
onValueChange={onFilter}
|
||||
maxLength={254}
|
||||
data-testid="quick-search-input"
|
||||
/>
|
||||
</Box>
|
||||
{separator && <HorizontalSeparator $withPadding={false} />}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
@import url('@gouvfr-lasuite/ui-kit/style');
|
||||
@import url('./cunningham-tokens.css');
|
||||
@import url('/assets/fonts/Marianne/Marianne-font.css');
|
||||
|
||||
:root {
|
||||
/**
|
||||
@@ -38,6 +37,13 @@
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Button
|
||||
*/
|
||||
.c__button {
|
||||
contain: content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal
|
||||
*/
|
||||
@@ -69,29 +75,3 @@
|
||||
.c__toast__container:has(.c__toast) {
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Inter;
|
||||
font-style: italic;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('https://fonts.gstatic.com/s/inter/v18/UcCm3FwrK3iLTcvnUwQT9g.woff2')
|
||||
format('woff2');
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
|
||||
U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Inter;
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url('https://fonts.gstatic.com/s/inter/v18/UcCo3FwrK3iLTcviYwY.woff2')
|
||||
format('woff2');
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
|
||||
U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ import {
|
||||
AccessibleImageBlock,
|
||||
CalloutBlock,
|
||||
DividerBlock,
|
||||
PdfBlock,
|
||||
UploadLoaderBlock,
|
||||
} from './custom-blocks';
|
||||
import {
|
||||
InterlinkingLinkInlineContent,
|
||||
@@ -54,6 +56,8 @@ const baseBlockNoteSchema = withPageBreak(
|
||||
callout: CalloutBlock,
|
||||
divider: DividerBlock,
|
||||
image: AccessibleImageBlock,
|
||||
pdf: PdfBlock,
|
||||
uploadLoader: UploadLoaderBlock,
|
||||
},
|
||||
inlineContentSpecs: {
|
||||
...defaultInlineContentSpecs,
|
||||
|
||||
+7
-2
@@ -18,6 +18,7 @@ import {
|
||||
import {
|
||||
getCalloutReactSlashMenuItems,
|
||||
getDividerReactSlashMenuItems,
|
||||
getPdfReactSlashMenuItems,
|
||||
} from './custom-blocks';
|
||||
import { useGetInterlinkingMenuItems } from './custom-inline-content';
|
||||
import XLMultiColumn from './xl-multi-column';
|
||||
@@ -32,7 +33,10 @@ export const BlockNoteSuggestionMenu = () => {
|
||||
DocsStyleSchema
|
||||
>();
|
||||
const { t } = useTranslation();
|
||||
const basicBlocksName = useDictionary().slash_menu.page_break.group;
|
||||
const dictionaryDate = useDictionary();
|
||||
const basicBlocksName = dictionaryDate.slash_menu.page_break.group;
|
||||
const fileBlocksName = dictionaryDate.slash_menu.file.group;
|
||||
|
||||
const getInterlinkingMenuItems = useGetInterlinkingMenuItems();
|
||||
|
||||
const getSlashMenuItems = useMemo(() => {
|
||||
@@ -56,11 +60,12 @@ export const BlockNoteSuggestionMenu = () => {
|
||||
getMultiColumnSlashMenuItems?.(editor) || [],
|
||||
getPageBreakReactSlashMenuItems(editor),
|
||||
getDividerReactSlashMenuItems(editor, t, basicBlocksName),
|
||||
getPdfReactSlashMenuItems(editor, t, fileBlocksName),
|
||||
),
|
||||
query,
|
||||
),
|
||||
);
|
||||
}, [basicBlocksName, editor, getInterlinkingMenuItems, t]);
|
||||
}, [basicBlocksName, editor, getInterlinkingMenuItems, t, fileBlocksName]);
|
||||
|
||||
return (
|
||||
<SuggestionMenuController
|
||||
|
||||
+15
-2
@@ -76,11 +76,23 @@ export const FileDownloadButton = ({
|
||||
|
||||
if (!url.includes('-unsafe')) {
|
||||
const blob = (await exportResolveFileUrl(url)) as Blob;
|
||||
downloadFile(blob, url.split('/').pop() || 'file');
|
||||
downloadFile(
|
||||
blob,
|
||||
fileBlock.props.name || url.split('/').pop() || 'file',
|
||||
);
|
||||
} else {
|
||||
const onConfirm = async () => {
|
||||
const blob = (await exportResolveFileUrl(url)) as Blob;
|
||||
downloadFile(blob, url.split('/').pop() || 'file (unsafe)');
|
||||
|
||||
const baseName =
|
||||
fileBlock.props.name || url.split('/').pop() || 'file';
|
||||
|
||||
const regFindLastDot = /(\.[^/.]+)$/;
|
||||
const unsafeName = baseName.includes('.')
|
||||
? baseName.replace(regFindLastDot, '-unsafe$1')
|
||||
: baseName + '-unsafe';
|
||||
|
||||
downloadFile(blob, unsafeName);
|
||||
};
|
||||
|
||||
open(onConfirm);
|
||||
@@ -100,6 +112,7 @@ export const FileDownloadButton = ({
|
||||
<>
|
||||
<Components.FormattingToolbar.Button
|
||||
className="bn-button --docs--editor-file-download-button"
|
||||
data-test="downloadfile"
|
||||
label={
|
||||
dict.formatting_toolbar.file_download.tooltip[fileBlock.type] ||
|
||||
dict.formatting_toolbar.file_download.tooltip['file']
|
||||
|
||||
+68
-4
@@ -10,6 +10,7 @@ import {
|
||||
imageRender,
|
||||
imageToExternalHTML,
|
||||
} from '@blocknote/core';
|
||||
import { t } from 'i18next';
|
||||
|
||||
type ImageBlockConfig = typeof imageBlockConfig;
|
||||
|
||||
@@ -25,10 +26,73 @@ export const accessibleImageRender = (
|
||||
const dom = imageRenderComputed.dom;
|
||||
const imgSelector = dom.querySelector('img');
|
||||
|
||||
imgSelector?.setAttribute('alt', '');
|
||||
imgSelector?.setAttribute('role', 'presentation');
|
||||
imgSelector?.setAttribute('aria-hidden', 'true');
|
||||
imgSelector?.setAttribute('tabindex', '-1');
|
||||
const withCaption =
|
||||
block.props.caption && dom.querySelector('.bn-file-caption');
|
||||
|
||||
const accessibleImageWithCaption = () => {
|
||||
imgSelector?.setAttribute('alt', block.props.caption);
|
||||
imgSelector?.removeAttribute('aria-hidden');
|
||||
imgSelector?.setAttribute('tabindex', '0');
|
||||
|
||||
// Fix RGAA 1.9.1: Convert to figure/figcaption structure if caption exists
|
||||
const captionElement = dom.querySelector('.bn-file-caption');
|
||||
|
||||
if (captionElement) {
|
||||
const figureElement = document.createElement('figure');
|
||||
|
||||
// Copy all attributes from the original div
|
||||
figureElement.className = dom.className;
|
||||
const styleAttr = dom.getAttribute('style');
|
||||
if (styleAttr) {
|
||||
figureElement.setAttribute('style', styleAttr);
|
||||
}
|
||||
figureElement.style.setProperty('margin', '0');
|
||||
|
||||
Array.from(dom.children).forEach((child) => {
|
||||
figureElement.appendChild(child.cloneNode(true));
|
||||
});
|
||||
|
||||
// Replace the <p> caption with <figcaption>
|
||||
const figcaptionElement = document.createElement('figcaption');
|
||||
const originalCaption = figureElement.querySelector('.bn-file-caption');
|
||||
if (originalCaption) {
|
||||
figcaptionElement.className = originalCaption.className;
|
||||
figcaptionElement.textContent = originalCaption.textContent;
|
||||
originalCaption.parentNode?.replaceChild(
|
||||
figcaptionElement,
|
||||
originalCaption,
|
||||
);
|
||||
|
||||
// Add explicit role and aria-label for better screen reader support
|
||||
figureElement.setAttribute('role', 'img');
|
||||
figureElement.setAttribute(
|
||||
'aria-label',
|
||||
t(`Image: {{title}}`, { title: figcaptionElement.textContent }),
|
||||
);
|
||||
}
|
||||
|
||||
// Return the figure element as the new dom
|
||||
return {
|
||||
...imageRenderComputed,
|
||||
dom: figureElement,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const accessibleImage = () => {
|
||||
imgSelector?.setAttribute('alt', '');
|
||||
imgSelector?.setAttribute('role', 'presentation');
|
||||
imgSelector?.setAttribute('aria-hidden', 'true');
|
||||
imgSelector?.setAttribute('tabindex', '-1');
|
||||
};
|
||||
|
||||
// Set accessibility attributes for the image
|
||||
const result = withCaption ? accessibleImageWithCaption() : accessibleImage();
|
||||
|
||||
// Return the result if accessibleImageWithCaption created a figure, otherwise return original
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
...imageRenderComputed,
|
||||
|
||||
+11
-3
@@ -25,8 +25,12 @@ export const CalloutBlock = createReactBlockSpec(
|
||||
{
|
||||
render: ({ block, editor, contentRef }) => {
|
||||
const [openEmojiPicker, setOpenEmojiPicker] = useState(false);
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
const toggleEmojiPicker = (e: React.MouseEvent) => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpenEmojiPicker(!openEmojiPicker);
|
||||
@@ -65,9 +69,13 @@ export const CalloutBlock = createReactBlockSpec(
|
||||
onClick={toggleEmojiPicker}
|
||||
$css={css`
|
||||
font-size: 1.125rem;
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
cursor: ${isEditable ? 'pointer' : 'default'};
|
||||
${isEditable &&
|
||||
`
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`}
|
||||
`}
|
||||
$align="center"
|
||||
$height="28px"
|
||||
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
import { insertOrUpdateBlock } from '@blocknote/core';
|
||||
import {
|
||||
AddFileButton,
|
||||
ResizableFileBlockWrapper,
|
||||
createReactBlockSpec,
|
||||
} from '@blocknote/react';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createGlobalStyle } from 'styled-components';
|
||||
|
||||
import { Box, Icon } from '@/components';
|
||||
|
||||
import { DocsBlockNoteEditor } from '../../types';
|
||||
|
||||
const PDFBlockStyle = createGlobalStyle`
|
||||
.bn-block-content[data-content-type="pdf"] {
|
||||
width: fit-content;
|
||||
}
|
||||
`;
|
||||
|
||||
type FileBlockEditor = Parameters<typeof AddFileButton>[0]['editor'];
|
||||
|
||||
export const PdfBlock = createReactBlockSpec(
|
||||
{
|
||||
type: 'pdf',
|
||||
content: 'none',
|
||||
propSchema: {
|
||||
name: { default: '' as const },
|
||||
url: { default: '' as const },
|
||||
caption: { default: '' as const },
|
||||
showPreview: { default: true },
|
||||
previewWidth: { default: undefined, type: 'number' },
|
||||
},
|
||||
isFileBlock: true,
|
||||
fileBlockAccept: ['application/pdf'],
|
||||
},
|
||||
{
|
||||
render: ({ editor, block, contentRef }) => {
|
||||
const { t } = useTranslation();
|
||||
const pdfUrl = block.props.url;
|
||||
|
||||
return (
|
||||
<Box ref={contentRef} className="bn-file-block-content-wrapper">
|
||||
<PDFBlockStyle />
|
||||
<ResizableFileBlockWrapper
|
||||
buttonIcon={<Icon iconName="upload" />}
|
||||
block={block}
|
||||
editor={editor as unknown as FileBlockEditor}
|
||||
buttonText={t('Add PDF')}
|
||||
>
|
||||
<Box
|
||||
className="bn-visual-media"
|
||||
role="presentation"
|
||||
as="embed"
|
||||
$width="100%"
|
||||
$height="450px"
|
||||
type="application/pdf"
|
||||
src={pdfUrl}
|
||||
contentEditable={false}
|
||||
draggable={false}
|
||||
onClick={() => editor.setTextCursorPosition(block)}
|
||||
/>
|
||||
</ResizableFileBlockWrapper>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export const getPdfReactSlashMenuItems = (
|
||||
editor: DocsBlockNoteEditor,
|
||||
t: TFunction<'translation', undefined>,
|
||||
group: string,
|
||||
) => [
|
||||
{
|
||||
title: t('PDF'),
|
||||
onItemClick: () => {
|
||||
insertOrUpdateBlock(editor, { type: 'pdf' });
|
||||
},
|
||||
aliases: [t('pdf'), t('document'), t('embed'), t('file')],
|
||||
group,
|
||||
icon: <Icon iconName="picture_as_pdf" $size="18px" />,
|
||||
subtext: t('Embed a PDF file'),
|
||||
},
|
||||
];
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { createReactBlockSpec } from '@blocknote/react';
|
||||
|
||||
import { Box, Text } from '@/components';
|
||||
|
||||
import Loader from '../../assets/loader.svg';
|
||||
import Warning from '../../assets/warning.svg';
|
||||
|
||||
export const UploadLoaderBlock = createReactBlockSpec(
|
||||
{
|
||||
type: 'uploadLoader',
|
||||
propSchema: {
|
||||
information: { default: '' as const },
|
||||
type: {
|
||||
default: 'loading' as const,
|
||||
values: ['loading', 'warning'] as const,
|
||||
},
|
||||
},
|
||||
content: 'none',
|
||||
},
|
||||
{
|
||||
render: ({ block }) => {
|
||||
return (
|
||||
<Box className="bn-visual-media-wrapper" $direction="row" $gap="0.5rem">
|
||||
{block.props.type === 'warning' ? (
|
||||
<Warning />
|
||||
) : (
|
||||
<Loader style={{ animation: 'spin 1.5s linear infinite' }} />
|
||||
)}
|
||||
<Text>{block.props.information}</Text>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
export * from './AccessibleImageBlock';
|
||||
export * from './CalloutBlock';
|
||||
export * from './DividerBlock';
|
||||
export * from './PdfBlock';
|
||||
export * from './UploadLoaderBlock';
|
||||
|
||||
@@ -6,8 +6,6 @@ import { useMediaUrl } from '@/core/config';
|
||||
import { sleep } from '@/utils';
|
||||
|
||||
import { checkDocMediaStatus, useCreateDocAttachment } from '../api';
|
||||
import Loader from '../assets/loader.svg?url';
|
||||
import Warning from '../assets/warning.svg?url';
|
||||
import { DocsBlockNoteEditor } from '../types';
|
||||
|
||||
/**
|
||||
@@ -33,52 +31,6 @@ const loopCheckDocMediaStatus = async (url: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const informationStatus = (src: string, text: string) => {
|
||||
const loadingContainer = document.createElement('div');
|
||||
loadingContainer.style.display = 'flex';
|
||||
loadingContainer.style.alignItems = 'center';
|
||||
loadingContainer.style.justifyContent = 'left';
|
||||
loadingContainer.style.padding = '10px';
|
||||
loadingContainer.style.color = '#666';
|
||||
loadingContainer.className =
|
||||
'bn-visual-media bn-audio bn-file-name-with-icon';
|
||||
|
||||
// Create an image element for the SVG
|
||||
const imgElement = document.createElement('img');
|
||||
imgElement.src = src;
|
||||
|
||||
// Create a text span
|
||||
const textSpan = document.createElement('span');
|
||||
textSpan.textContent = text;
|
||||
textSpan.style.marginLeft = '8px';
|
||||
textSpan.style.verticalAlign = 'middle';
|
||||
imgElement.style.animation = 'spin 1.5s linear infinite';
|
||||
|
||||
// Add the spinner and text to the container
|
||||
loadingContainer.appendChild(imgElement);
|
||||
loadingContainer.appendChild(textSpan);
|
||||
|
||||
return loadingContainer;
|
||||
};
|
||||
|
||||
const replaceUploadContent = (blockId: string, elementReplace: HTMLElement) => {
|
||||
const blockEl = document.body.querySelector(
|
||||
`.bn-block[data-id="${blockId}"]`,
|
||||
);
|
||||
|
||||
blockEl
|
||||
?.querySelector('.bn-visual-media-wrapper .bn-visual-media')
|
||||
?.replaceWith(elementReplace);
|
||||
|
||||
blockEl
|
||||
?.querySelector('.bn-file-block-content-wrapper .bn-audio')
|
||||
?.replaceWith(elementReplace);
|
||||
|
||||
blockEl
|
||||
?.querySelector('.bn-file-block-content-wrapper .bn-file-name-with-icon')
|
||||
?.replaceWith(elementReplace);
|
||||
};
|
||||
|
||||
export const useUploadFile = (docId: string) => {
|
||||
const {
|
||||
mutateAsync: createDocAttachment,
|
||||
@@ -122,35 +74,55 @@ export const useUploadStatus = (editor: DocsBlockNoteEditor) => {
|
||||
|
||||
// Delay to let the time to the dom to be rendered
|
||||
const timoutId = setTimeout(() => {
|
||||
replaceUploadContent(
|
||||
blockId,
|
||||
informationStatus(Loader.src, t('Analyzing file...')),
|
||||
// Replace the resource block by a loading block
|
||||
const { insertedBlocks, removedBlocks } = editor.replaceBlocks(
|
||||
[blockId],
|
||||
[
|
||||
{
|
||||
type: 'uploadLoader',
|
||||
props: {
|
||||
information: t('Analyzing file...'),
|
||||
type: 'loading',
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
loopCheckDocMediaStatus(url)
|
||||
.then((response) => {
|
||||
const block = editor.getBlock(blockId);
|
||||
if (!block) {
|
||||
if (insertedBlocks.length === 0 || removedBlocks.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
block.props = {
|
||||
...block.props,
|
||||
const loadingBlockId = insertedBlocks[0].id;
|
||||
const removedBlock = removedBlocks[0];
|
||||
|
||||
removedBlock.props = {
|
||||
...removedBlock.props,
|
||||
url: `${mediaUrl}${response.file}`,
|
||||
};
|
||||
|
||||
editor.updateBlock(blockId, block);
|
||||
// Replace the loading block with the resource block (image, audio, video, pdf ...)
|
||||
editor.replaceBlocks([loadingBlockId], [removedBlock]);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error analyzing file:', error);
|
||||
|
||||
replaceUploadContent(
|
||||
blockId,
|
||||
informationStatus(
|
||||
Warning.src,
|
||||
t('The antivirus has detected an anomaly in your file.'),
|
||||
const loadingBlock = insertedBlocks[0];
|
||||
|
||||
if (!loadingBlock) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadingBlock.props = {
|
||||
...loadingBlock.props,
|
||||
type: 'warning',
|
||||
information: t(
|
||||
'The antivirus has detected an anomaly in your file.',
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
editor.updateBlock(loadingBlock.id, loadingBlock);
|
||||
});
|
||||
}, 250);
|
||||
|
||||
|
||||
@@ -29,7 +29,12 @@ export const useHeadingStore = create<UseHeadingStore>((set, get) => ({
|
||||
headings: [],
|
||||
setHeadings: (editor) => {
|
||||
const headingBlocks = editor?.document
|
||||
.filter((block) => block.type === 'heading')
|
||||
.filter(
|
||||
(block) =>
|
||||
block.type === 'heading' &&
|
||||
block.props.level >= 1 &&
|
||||
block.props.level <= 3,
|
||||
)
|
||||
.map((block) => ({
|
||||
...block,
|
||||
contentText: recursiveTextContent(
|
||||
|
||||
@@ -16,6 +16,19 @@ export const cssEditor = (readonly: boolean) => css`
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
|
||||
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
}
|
||||
.bn-block-content[data-is-empty-and-focused][data-content-type='paragraph']
|
||||
.bn-inline-content:has(> .ProseMirror-trailingBreak:only-child) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bn-side-menu .mantine-UnstyledButton-root svg {
|
||||
color: #767676 !important;
|
||||
}
|
||||
@@ -91,6 +104,11 @@ export const cssEditor = (readonly: boolean) => css`
|
||||
border-radius: var(--c--theme--spacings--3xs);
|
||||
}
|
||||
|
||||
.bn-block-content[data-content-type='checkListItem'][data-checked='true']
|
||||
.bn-inline-content {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,26 @@ import { Text } from '@react-pdf/renderer';
|
||||
|
||||
import { DocsExporterPDF } from '../types';
|
||||
|
||||
// Helper function to extract plain text from block content
|
||||
function extractTextFromBlockContent(content: unknown[]): string {
|
||||
return content
|
||||
.map((item) => {
|
||||
if (
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
'type' in item &&
|
||||
'text' in item
|
||||
) {
|
||||
if (item.type === 'text' && typeof item.text === 'string') {
|
||||
return item.text;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.join('')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export const blockMappingHeadingPDF: DocsExporterPDF['mappings']['blockMapping']['heading'] =
|
||||
(block, exporter) => {
|
||||
const PIXELS_PER_POINT = 0.75;
|
||||
@@ -9,9 +29,18 @@ export const blockMappingHeadingPDF: DocsExporterPDF['mappings']['blockMapping']
|
||||
const FONT_SIZE = 16;
|
||||
const fontSizeEM =
|
||||
block.props.level === 1 ? 2 : block.props.level === 2 ? 1.5 : 1.17;
|
||||
|
||||
// Extract plain text for bookmark title
|
||||
const bookmarkTitle =
|
||||
extractTextFromBlockContent(block.content) || 'Untitled';
|
||||
|
||||
return (
|
||||
<Text
|
||||
key={block.id}
|
||||
// @ts-expect-error: bookmark is supported by react-pdf but not typed
|
||||
bookmark={{
|
||||
title: bookmarkTitle,
|
||||
}}
|
||||
style={{
|
||||
fontSize: fontSizeEM * FONT_SIZE * PIXELS_PER_POINT,
|
||||
fontWeight: 700,
|
||||
|
||||
@@ -9,3 +9,5 @@ export * from './paragraphPDF';
|
||||
export * from './quoteDocx';
|
||||
export * from './quotePDF';
|
||||
export * from './tablePDF';
|
||||
export * from './uploadLoaderPDF';
|
||||
export * from './uploadLoaderDocx';
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { Paragraph, TextRun } from 'docx';
|
||||
|
||||
import { DocsExporterDocx } from '../types';
|
||||
|
||||
export const blockMappingUploadLoaderDocx: DocsExporterDocx['mappings']['blockMapping']['uploadLoader'] =
|
||||
(block) => {
|
||||
return new Paragraph({
|
||||
children: [
|
||||
new TextRun(block.props.type === 'loading' ? '⏳' : '⚠️'),
|
||||
new TextRun(' '),
|
||||
new TextRun(block.props.information),
|
||||
],
|
||||
});
|
||||
};
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { Text, View } from '@react-pdf/renderer';
|
||||
|
||||
import { DocsExporterPDF } from '../types';
|
||||
|
||||
export const blockMappingUploadLoaderPDF: DocsExporterPDF['mappings']['blockMapping']['uploadLoader'] =
|
||||
(block) => {
|
||||
return (
|
||||
<View wrap={false} style={{ flexDirection: 'row', gap: 4 }}>
|
||||
<Text>{block.props.type === 'loading' ? '⏳' : '⚠️'}</Text>
|
||||
<Text>{block.props.information}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useToastProvider,
|
||||
} from '@openfun/cunningham-react';
|
||||
import { DocumentProps, pdf } from '@react-pdf/renderer';
|
||||
import jsonemoji from 'emoji-datasource-apple' assert { type: 'json' };
|
||||
import i18next from 'i18next';
|
||||
import { cloneElement, isValidElement, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -76,12 +77,14 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
|
||||
setIsExporting(true);
|
||||
|
||||
const title = (doc.title || untitledDocument)
|
||||
const filename = (doc.title || untitledDocument)
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s/g, '-');
|
||||
|
||||
const documentTitle = doc.title || untitledDocument;
|
||||
|
||||
const html = templateSelected;
|
||||
let exportDocument = editor.document;
|
||||
if (html) {
|
||||
@@ -93,14 +96,32 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
if (format === DocDownloadFormat.PDF) {
|
||||
const exporter = new PDFExporter(editor.schema, pdfDocsSchemaMappings, {
|
||||
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
|
||||
emojiSource: {
|
||||
format: 'png',
|
||||
builder(code) {
|
||||
const emoji = jsonemoji.find((e) =>
|
||||
e.unified.toLocaleLowerCase().includes(code.toLowerCase()),
|
||||
);
|
||||
|
||||
if (emoji) {
|
||||
return `/assets/fonts/emoji/${emoji.image}`;
|
||||
}
|
||||
|
||||
return '/assets/fonts/emoji/fallback.png';
|
||||
},
|
||||
},
|
||||
});
|
||||
const rawPdfDocument = (await exporter.toReactPDFDocument(
|
||||
exportDocument,
|
||||
)) as React.ReactElement<DocumentProps>;
|
||||
|
||||
// Inject language for screen reader support
|
||||
// Add language, title and outline properties to improve PDF accessibility and navigation
|
||||
const pdfDocument = isValidElement(rawPdfDocument)
|
||||
? cloneElement(rawPdfDocument, { language: i18next.language })
|
||||
? cloneElement(rawPdfDocument, {
|
||||
language: i18next.language,
|
||||
title: documentTitle,
|
||||
pageMode: 'useOutlines',
|
||||
})
|
||||
: rawPdfDocument;
|
||||
|
||||
blobExport = await pdf(pdfDocument).toBlob();
|
||||
@@ -109,10 +130,13 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
resolveFileUrl: async (url) => exportCorsResolveFileUrl(doc.id, url),
|
||||
});
|
||||
|
||||
blobExport = await exporter.toBlob(exportDocument);
|
||||
blobExport = await exporter.toBlob(exportDocument, {
|
||||
documentOptions: { title: documentTitle },
|
||||
sectionOptions: {},
|
||||
});
|
||||
}
|
||||
|
||||
downloadFile(blobExport, `${title}.${format}`);
|
||||
downloadFile(blobExport, `${filename}.${format}`);
|
||||
|
||||
toast(
|
||||
t('Your {{format}} was downloaded succesfully', {
|
||||
@@ -141,7 +165,6 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
|
||||
color="secondary"
|
||||
fullWidth
|
||||
onClick={() => onClose()}
|
||||
disabled={isExporting}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
blockMappingDividerDocx,
|
||||
blockMappingImageDocx,
|
||||
blockMappingQuoteDocx,
|
||||
blockMappingUploadLoaderDocx,
|
||||
} from './blocks-mapping';
|
||||
import { inlineContentMappingInterlinkingLinkDocx } from './inline-content-mapping';
|
||||
import { DocsExporterDocx } from './types';
|
||||
@@ -16,8 +17,13 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
|
||||
...docxDefaultSchemaMappings.blockMapping,
|
||||
callout: blockMappingCalloutDocx,
|
||||
divider: blockMappingDividerDocx,
|
||||
// We're using the file block mapping for PDF blocks
|
||||
// The types don't match exactly but the implementation is compatible
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
pdf: docxDefaultSchemaMappings.blockMapping.file as any,
|
||||
quote: blockMappingQuoteDocx,
|
||||
image: blockMappingImageDocx,
|
||||
uploadLoader: blockMappingUploadLoaderDocx,
|
||||
},
|
||||
inlineContentMapping: {
|
||||
...docxDefaultSchemaMappings.inlineContentMapping,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
blockMappingParagraphPDF,
|
||||
blockMappingQuotePDF,
|
||||
blockMappingTablePDF,
|
||||
blockMappingUploadLoaderPDF,
|
||||
} from './blocks-mapping';
|
||||
import { inlineContentMappingInterlinkingLinkPDF } from './inline-content-mapping';
|
||||
import { DocsExporterPDF } from './types';
|
||||
@@ -23,6 +24,11 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = {
|
||||
divider: blockMappingDividerPDF,
|
||||
quote: blockMappingQuotePDF,
|
||||
table: blockMappingTablePDF,
|
||||
// We're using the file block mapping for PDF blocks
|
||||
// The types don't match exactly but the implementation is compatible
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
pdf: pdfDefaultSchemaMappings.blockMapping.file as any,
|
||||
uploadLoader: blockMappingUploadLoaderPDF,
|
||||
},
|
||||
inlineContentMapping: {
|
||||
...pdfDefaultSchemaMappings.inlineContentMapping,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Doc,
|
||||
LinkReach,
|
||||
Role,
|
||||
currentDocRole,
|
||||
getDocLinkReach,
|
||||
useIsCollaborativeEditable,
|
||||
useTrans,
|
||||
@@ -73,7 +72,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
|
||||
>
|
||||
{transRole(
|
||||
isEditable
|
||||
? currentDocRole(doc.abilities)
|
||||
? doc.user_role || doc.link_role
|
||||
: Role.READER,
|
||||
)}
|
||||
·
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
KEY_DOC,
|
||||
KEY_LIST_DOC,
|
||||
useDocStore,
|
||||
useIsCollaborativeEditable,
|
||||
useTrans,
|
||||
useUpdateDoc,
|
||||
} from '@/docs/doc-management';
|
||||
@@ -21,7 +22,10 @@ interface DocTitleProps {
|
||||
}
|
||||
|
||||
export const DocTitle = ({ doc }: DocTitleProps) => {
|
||||
if (!doc.abilities.partial_update) {
|
||||
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
|
||||
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading;
|
||||
|
||||
if (readOnly) {
|
||||
return <DocTitleText />;
|
||||
}
|
||||
|
||||
|
||||
+1
-52
@@ -1,11 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { LinkReach, LinkRole, Role } from '../types';
|
||||
import { LinkReach, LinkRole } from '../types';
|
||||
import {
|
||||
base64ToBlocknoteXmlFragment,
|
||||
base64ToYDoc,
|
||||
currentDocRole,
|
||||
getDocLinkReach,
|
||||
getDocLinkRole,
|
||||
getEmojiAndTitle,
|
||||
@@ -24,56 +23,6 @@ describe('doc-management utils', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('currentDocRole', () => {
|
||||
it('should return OWNER when destroy ability is true', () => {
|
||||
const abilities = {
|
||||
destroy: true,
|
||||
accesses_manage: false,
|
||||
partial_update: false,
|
||||
} as any;
|
||||
|
||||
const result = currentDocRole(abilities);
|
||||
|
||||
expect(result).toBe(Role.OWNER);
|
||||
});
|
||||
|
||||
it('should return ADMIN when accesses_manage ability is true and destroy is false', () => {
|
||||
const abilities = {
|
||||
destroy: false,
|
||||
accesses_manage: true,
|
||||
partial_update: false,
|
||||
} as any;
|
||||
|
||||
const result = currentDocRole(abilities);
|
||||
|
||||
expect(result).toBe(Role.ADMIN);
|
||||
});
|
||||
|
||||
it('should return EDITOR when partial_update ability is true and higher abilities are false', () => {
|
||||
const abilities = {
|
||||
destroy: false,
|
||||
accesses_manage: false,
|
||||
partial_update: true,
|
||||
} as any;
|
||||
|
||||
const result = currentDocRole(abilities);
|
||||
|
||||
expect(result).toBe(Role.EDITOR);
|
||||
});
|
||||
|
||||
it('should return READER when no higher abilities are true', () => {
|
||||
const abilities = {
|
||||
destroy: false,
|
||||
accesses_manage: false,
|
||||
partial_update: false,
|
||||
} as any;
|
||||
|
||||
const result = currentDocRole(abilities);
|
||||
|
||||
expect(result).toBe(Role.READER);
|
||||
});
|
||||
});
|
||||
|
||||
describe('base64ToYDoc', () => {
|
||||
it('should convert base64 string to Y.Doc', () => {
|
||||
const base64String = 'dGVzdA=='; // "test" in base64
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Text, TextType } from '@/components';
|
||||
|
||||
type DocIconProps = TextType & {
|
||||
@@ -15,8 +13,6 @@ export const DocIcon = ({
|
||||
$weight = '400',
|
||||
...textProps
|
||||
}: DocIconProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!emoji) {
|
||||
return <>{defaultIcon}</>;
|
||||
}
|
||||
@@ -28,7 +24,7 @@ export const DocIcon = ({
|
||||
$variation={$variation}
|
||||
$weight={$weight}
|
||||
aria-hidden="true"
|
||||
aria-label={t('Document emoji icon')}
|
||||
data-testid="doc-emoji-icon"
|
||||
>
|
||||
{emoji}
|
||||
</Text>
|
||||
|
||||
+5
-60
@@ -1,50 +1,23 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import Head from 'next/head';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import img403 from '@/assets/icons/icon-403.png';
|
||||
import { Box, Icon, Loading, StyledLink, Text } from '@/components';
|
||||
import { DEFAULT_QUERY_RETRY } from '@/core';
|
||||
import { KEY_DOC, useDoc } from '@/docs/doc-management';
|
||||
import { ButtonAccessRequest } from '@/docs/doc-share';
|
||||
import { useDocAccessRequests } from '@/docs/doc-share/api/useDocAccessRequest';
|
||||
import { MainLayout } from '@/layouts';
|
||||
import { NextPageWithLayout } from '@/types/next';
|
||||
|
||||
const StyledButton = styled(Button)`
|
||||
width: fit-content;
|
||||
`;
|
||||
|
||||
export function DocLayout() {
|
||||
const {
|
||||
query: { id },
|
||||
} = useRouter();
|
||||
|
||||
if (typeof id !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<meta name="robots" content="noindex" />
|
||||
</Head>
|
||||
|
||||
<MainLayout>
|
||||
<DocPage403 id={id} />
|
||||
</MainLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface DocProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const DocPage403 = ({ id }: DocProps) => {
|
||||
export const DocPage403 = ({ id }: DocProps) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data: requests,
|
||||
@@ -54,39 +27,19 @@ const DocPage403 = ({ id }: DocProps) => {
|
||||
docId: id,
|
||||
page: 1,
|
||||
});
|
||||
const { replace } = useRouter();
|
||||
|
||||
const hasRequested = !!requests?.results.find(
|
||||
(request) => request.document === id,
|
||||
);
|
||||
|
||||
const { error: docError, isLoading: isLoadingDoc } = useDoc(
|
||||
{ id },
|
||||
{
|
||||
staleTime: 0,
|
||||
queryKey: [KEY_DOC, { id }],
|
||||
retry: (failureCount, error) => {
|
||||
if (error.status == 403) {
|
||||
return false;
|
||||
} else {
|
||||
return failureCount < DEFAULT_QUERY_RETRY;
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!isLoadingDoc && docError?.status !== 403) {
|
||||
void replace(`/docs/${id}`);
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (isLoadingDoc || isLoadingRequest) {
|
||||
if (isLoadingRequest) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>
|
||||
{t('Access Denied - Error 403')} - {t('Docs')}
|
||||
</title>
|
||||
@@ -106,6 +59,8 @@ const DocPage403 = ({ id }: DocProps) => {
|
||||
className="c__image-system-filter"
|
||||
src={img403}
|
||||
alt={t('Image 403')}
|
||||
width={300}
|
||||
height={300}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
@@ -152,13 +107,3 @@ const DocPage403 = ({ id }: DocProps) => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Page: NextPageWithLayout = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
Page.getLayout = function getLayout() {
|
||||
return <DocLayout />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
+6
-2
@@ -49,6 +49,8 @@ export const SimpleDocItem = ({
|
||||
$overflow="auto"
|
||||
$width="100%"
|
||||
className="--docs--simple-doc-item"
|
||||
role="presentation"
|
||||
aria-label={`${t('Open document {{title}}', { title: doc.title || untitledDocument })}`}
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
@@ -59,11 +61,12 @@ export const SimpleDocItem = ({
|
||||
`}
|
||||
$padding={`${spacingsTokens['3xs']} 0`}
|
||||
data-testid={isPinned ? `doc-pinned-${doc.id}` : undefined}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{isPinned ? (
|
||||
<PinnedDocumentIcon
|
||||
aria-hidden="true"
|
||||
aria-label={t('Pin document icon')}
|
||||
data-testid="doc-pinned-icon"
|
||||
color={colorsTokens['primary-500']}
|
||||
/>
|
||||
) : (
|
||||
@@ -72,7 +75,7 @@ export const SimpleDocItem = ({
|
||||
defaultIcon={
|
||||
<SimpleFileIcon
|
||||
aria-hidden="true"
|
||||
aria-label={t('Simple document icon')}
|
||||
data-testid="doc-simple-icon"
|
||||
color={colorsTokens['primary-500']}
|
||||
/>
|
||||
}
|
||||
@@ -96,6 +99,7 @@ export const SimpleDocItem = ({
|
||||
$align="center"
|
||||
$gap={spacingsTokens['3xs']}
|
||||
$margin={{ top: '-2px' }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Text $variation="600" $size="xs">
|
||||
{DateTime.fromISO(doc.updated_at).toRelative()}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './DocPage403';
|
||||
export * from './ModalRemoveDoc';
|
||||
export * from './SimpleDocItem';
|
||||
|
||||
+13
-2
@@ -13,11 +13,14 @@ export interface UseCollaborationStore {
|
||||
destroyProvider: () => void;
|
||||
provider: HocuspocusProvider | undefined;
|
||||
isConnected: boolean;
|
||||
hasLostConnection: boolean;
|
||||
resetLostConnection: () => void;
|
||||
}
|
||||
|
||||
const defaultValues = {
|
||||
provider: undefined,
|
||||
isConnected: false,
|
||||
hasLostConnection: false,
|
||||
};
|
||||
|
||||
export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
@@ -36,8 +39,15 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
name: storeId,
|
||||
document: doc,
|
||||
onStatus: ({ status }) => {
|
||||
set({
|
||||
isConnected: status === WebSocketStatus.Connected,
|
||||
set((state) => {
|
||||
const nextConnected = status === WebSocketStatus.Connected;
|
||||
return {
|
||||
isConnected: nextConnected,
|
||||
hasLostConnection:
|
||||
state.isConnected && !nextConnected
|
||||
? true
|
||||
: state.hasLostConnection,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -56,4 +66,5 @@ export const useProviderStore = create<UseCollaborationStore>((set, get) => ({
|
||||
|
||||
set(defaultValues);
|
||||
},
|
||||
resetLostConnection: () => set({ hasLostConnection: false }),
|
||||
}));
|
||||
|
||||
@@ -60,7 +60,7 @@ export interface Doc {
|
||||
path: string;
|
||||
is_favorite: boolean;
|
||||
link_reach: LinkReach;
|
||||
link_role: LinkRole;
|
||||
link_role?: LinkRole;
|
||||
nb_accesses_direct: number;
|
||||
nb_accesses_ancestors: number;
|
||||
computed_link_reach: LinkReach;
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import emojiRegex from 'emoji-regex';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
import { Doc, LinkReach, LinkRole, Role } from './types';
|
||||
|
||||
export const currentDocRole = (abilities: Doc['abilities']): Role => {
|
||||
return abilities.destroy
|
||||
? Role.OWNER
|
||||
: abilities.accesses_manage
|
||||
? Role.ADMIN
|
||||
: abilities.partial_update
|
||||
? Role.EDITOR
|
||||
: Role.READER;
|
||||
};
|
||||
import { Doc, LinkReach } from './types';
|
||||
|
||||
export const base64ToYDoc = (base64: string) => {
|
||||
const uint8Array = Buffer.from(base64, 'base64');
|
||||
@@ -28,7 +18,7 @@ export const getDocLinkReach = (doc: Doc): LinkReach => {
|
||||
return doc.computed_link_reach ?? doc.link_reach;
|
||||
};
|
||||
|
||||
export const getDocLinkRole = (doc: Doc): LinkRole => {
|
||||
export const getDocLinkRole = (doc: Doc): Doc['link_role'] => {
|
||||
return doc.computed_link_role ?? doc.link_role;
|
||||
};
|
||||
|
||||
|
||||
+1
-7
@@ -125,13 +125,7 @@ export const DocRoleDropdown = ({
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
$theme="primary"
|
||||
$variation="800"
|
||||
$css={css`
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
`}
|
||||
>
|
||||
<Text $theme="primary" $variation="800">
|
||||
{transRole(currentRole)}
|
||||
</Text>
|
||||
</DropdownMenu>
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ export const DocShareAddMemberList = ({
|
||||
return isInvitationMode
|
||||
? createInvitation({
|
||||
...payload,
|
||||
email: user.email,
|
||||
email: user.email.toLowerCase(),
|
||||
})
|
||||
: createDocAccess({
|
||||
...payload,
|
||||
|
||||
+4
-4
@@ -151,12 +151,12 @@ export const DocShareModalInviteUserRow = ({
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$css={css`
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: var(--c--theme--font--sizes--sm);
|
||||
color: var(--c--theme--colors--greyscale-400);
|
||||
contain: content;
|
||||
`}
|
||||
$color="var(--c--theme--colors--greyscale-400)"
|
||||
$cursor="pointer"
|
||||
>
|
||||
<Text $theme="primary" $variation="800">
|
||||
<Text $theme="primary" $variation="800" $size="sm">
|
||||
{t('Add')}
|
||||
</Text>
|
||||
<Icon
|
||||
|
||||
@@ -135,8 +135,9 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
|
||||
isOpen
|
||||
closeOnClickOutside
|
||||
data-testid="doc-share-modal"
|
||||
aria-describedby="doc-share-modal-title"
|
||||
aria-labelledby="doc-share-modal-title"
|
||||
size={isDesktop ? ModalSize.LARGE : ModalSize.FULL}
|
||||
aria-modal="true"
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Box $direction="row" $justify="space-between" $align="center">
|
||||
@@ -160,13 +161,13 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
|
||||
>
|
||||
<ShareModalStyle />
|
||||
<Box
|
||||
role="dialog"
|
||||
aria-label={t('Share modal content')}
|
||||
$height="auto"
|
||||
$maxHeight={canViewAccesses ? modalContentHeight : 'none'}
|
||||
$overflow="hidden"
|
||||
className="--docs--doc-share-modal noPadding "
|
||||
$justify="space-between"
|
||||
role="dialog"
|
||||
aria-label={t('Share modal content')}
|
||||
>
|
||||
<Box
|
||||
$flex={1}
|
||||
@@ -223,6 +224,7 @@ export const DocShareModal = ({ doc, onClose, isRootDoc = true }: Props) => {
|
||||
)}
|
||||
{canViewAccesses && (
|
||||
<QuickSearch
|
||||
label={t('Search results')}
|
||||
onFilter={(str) => {
|
||||
setInputValue(str);
|
||||
onFilter(str);
|
||||
@@ -294,7 +296,9 @@ const QuickSearchInviteInputSection = ({
|
||||
language: '',
|
||||
};
|
||||
|
||||
const hasEmailInUsers = users.some((user) => user.email === userQuery);
|
||||
const hasEmailInUsers = users.some(
|
||||
(user) => user.email.toLowerCase() === userQuery.toLowerCase(),
|
||||
);
|
||||
|
||||
return {
|
||||
groupName: t('Search user result'),
|
||||
|
||||
+26
-21
@@ -54,6 +54,10 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
|
||||
const linkReachOptions: DropdownMenuOption[] = useMemo(() => {
|
||||
return Object.values(LinkReach).map((key) => {
|
||||
const isDisabled = doc.abilities.link_select_options[key] === undefined;
|
||||
let linkRole = undefined;
|
||||
if (key !== LinkReach.RESTRICTED) {
|
||||
linkRole = docLinkRole;
|
||||
}
|
||||
|
||||
return {
|
||||
label: linkReachTranslations[key],
|
||||
@@ -61,6 +65,7 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
|
||||
updateDocLink({
|
||||
id: doc.id,
|
||||
link_reach: key,
|
||||
link_role: linkRole,
|
||||
}),
|
||||
isSelected: docLinkReach === key,
|
||||
disabled: isDisabled,
|
||||
@@ -70,6 +75,7 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
|
||||
doc.abilities.link_select_options,
|
||||
doc.id,
|
||||
docLinkReach,
|
||||
docLinkRole,
|
||||
linkReachTranslations,
|
||||
updateDocLink,
|
||||
]);
|
||||
@@ -78,7 +84,8 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
|
||||
(option) => option.disabled,
|
||||
);
|
||||
|
||||
const showLinkRoleOptions = doc.computed_link_reach !== LinkReach.RESTRICTED;
|
||||
const showLinkRoleOptions =
|
||||
docLinkReach !== LinkReach.RESTRICTED && docLinkRole;
|
||||
|
||||
const linkRoleOptions: DropdownMenuOption[] = useMemo(() => {
|
||||
const options = doc.abilities.link_select_options[docLinkReach] ?? [];
|
||||
@@ -175,26 +182,24 @@ export const DocVisibility = ({ doc }: DocVisibilityProps) => {
|
||||
</Box>
|
||||
{showLinkRoleOptions && (
|
||||
<Box $direction="row" $align="center" $gap={spacingsTokens['3xs']}>
|
||||
{docLinkReach !== LinkReach.RESTRICTED && (
|
||||
<DropdownMenu
|
||||
testId="doc-access-mode"
|
||||
disabled={!canManage}
|
||||
showArrow={true}
|
||||
options={linkRoleOptions}
|
||||
topMessage={
|
||||
haveDisabledLinkRoleOptions
|
||||
? t(
|
||||
'You cannot restrict access to a subpage relative to its parent page.',
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
label={t('Document access mode')}
|
||||
>
|
||||
<Text $weight="initial" $variation="600">
|
||||
{linkModeTranslations[docLinkRole]}
|
||||
</Text>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<DropdownMenu
|
||||
testId="doc-access-mode"
|
||||
disabled={!canManage}
|
||||
showArrow={true}
|
||||
options={linkRoleOptions}
|
||||
topMessage={
|
||||
haveDisabledLinkRoleOptions
|
||||
? t(
|
||||
'You cannot restrict access to a subpage relative to its parent page.',
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
label={t('Document access mode')}
|
||||
>
|
||||
<Text $weight="initial" $variation="600">
|
||||
{linkModeTranslations[docLinkRole]}
|
||||
</Text>
|
||||
</DropdownMenu>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -21,7 +21,7 @@ export const SearchUserRow = ({
|
||||
alwaysShowRight = false,
|
||||
isInvitation = false,
|
||||
}: Props) => {
|
||||
const hasFullName = user.full_name != null && user.full_name !== '';
|
||||
const hasFullName = !!user.full_name;
|
||||
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { Text } from '@/components';
|
||||
import { tokens } from '@/cunningham';
|
||||
import { User } from '@/features/auth';
|
||||
|
||||
@@ -37,35 +37,26 @@ export const UserAvatar = ({ user, background }: Props) => {
|
||||
const splitName = name?.split(' ');
|
||||
|
||||
return (
|
||||
<Box
|
||||
<Text
|
||||
className="--docs--user-avatar"
|
||||
$align="center"
|
||||
$color="rgba(255, 255, 255, 0.9)"
|
||||
$justify="center"
|
||||
$background={background || getColorFromName(name)}
|
||||
$width="24px"
|
||||
$height="24px"
|
||||
$direction="row"
|
||||
$align="center"
|
||||
$justify="center"
|
||||
$radius="50%"
|
||||
$size="10px"
|
||||
$textAlign="center"
|
||||
$textTransform="uppercase"
|
||||
$weight={600}
|
||||
$css={css`
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
contain: content;
|
||||
`}
|
||||
className="--docs--user-avatar"
|
||||
>
|
||||
<Box
|
||||
$direction="row"
|
||||
$css={css`
|
||||
text-align: center;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-family:
|
||||
Arial, Helvetica, sans-serif; // Can't use marianne font because it's impossible to center with this font
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
`}
|
||||
>
|
||||
{splitName[0]?.charAt(0)}
|
||||
{splitName?.[1]?.charAt(0)}
|
||||
</Box>
|
||||
</Box>
|
||||
{splitName[0]?.charAt(0)}
|
||||
{splitName?.[1]?.charAt(0)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
+66
-34
@@ -5,9 +5,10 @@ import {
|
||||
} from '@gouvfr-lasuite/ui-kit';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, Icon, Text } from '@/components';
|
||||
import { Box, BoxButton, Icon, Text } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import {
|
||||
Doc,
|
||||
@@ -18,6 +19,8 @@ import { DocIcon } from '@/features/docs/doc-management/components/DocIcon';
|
||||
import { useLeftPanelStore } from '@/features/left-panel';
|
||||
import { useResponsiveStore } from '@/stores';
|
||||
|
||||
import { useKeyboardActivation } from '../hooks/useKeyboardActivation';
|
||||
|
||||
import SubPageIcon from './../assets/sub-page-logo.svg';
|
||||
import { DocTreeItemActions } from './DocTreeItemActions';
|
||||
|
||||
@@ -38,7 +41,11 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
||||
const { node } = props;
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const { isDesktop } = useResponsiveStore();
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const isSelectedNow = treeContext?.treeData.selectedNode?.id === doc.id;
|
||||
const isActive = node.isFocused || menuOpen || isSelectedNow;
|
||||
|
||||
const router = useRouter();
|
||||
const { togglePanel } = useLeftPanelStore();
|
||||
@@ -46,6 +53,11 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(doc.title || '');
|
||||
const displayTitle = titleWithoutEmoji || untitledDocument;
|
||||
|
||||
const handleActivate = () => {
|
||||
treeContext?.treeData.setSelectedNode(doc);
|
||||
router.push(`/docs/${doc.id}`);
|
||||
};
|
||||
|
||||
const afterCreate = (createdDoc: Doc) => {
|
||||
const actualChildren = node.data.children ?? [];
|
||||
|
||||
@@ -76,62 +88,80 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
||||
}
|
||||
};
|
||||
|
||||
useKeyboardActivation(
|
||||
['Enter'],
|
||||
isActive && !menuOpen,
|
||||
handleActivate,
|
||||
true,
|
||||
'.c__tree-view',
|
||||
);
|
||||
|
||||
const docTitle = doc.title || untitledDocument;
|
||||
const hasChildren = (doc.children?.length || 0) > 0;
|
||||
const isExpanded = node.isOpen;
|
||||
const isSelected = isSelectedNow;
|
||||
const ariaLabel = docTitle;
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="--docs-sub-page-item"
|
||||
draggable={doc.abilities.move && isDesktop}
|
||||
$position="relative"
|
||||
role="treeitem"
|
||||
aria-label={ariaLabel}
|
||||
aria-selected={isSelected}
|
||||
aria-expanded={hasChildren ? isExpanded : undefined}
|
||||
$css={css`
|
||||
background-color: ${actionsOpen
|
||||
background-color: ${menuOpen
|
||||
? 'var(--c--theme--colors--greyscale-100)'
|
||||
: 'var(--c--theme--colors--greyscale-000)'};
|
||||
|
||||
.light-doc-item-actions {
|
||||
display: ${actionsOpen || !isDesktop ? 'flex' : 'none'};
|
||||
display: ${menuOpen || !isDesktop ? 'flex' : 'none'};
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: ${isDesktop
|
||||
? 'var(--c--theme--colors--greyscale-100)'
|
||||
: 'var(--c--theme--colors--greyscale-000)'};
|
||||
}
|
||||
|
||||
.c__tree-view--node.isSelected {
|
||||
.light-doc-item-actions {
|
||||
background: var(--c--theme--colors--greyscale-100);
|
||||
}
|
||||
}
|
||||
|
||||
.c__tree-view--node.isFocused {
|
||||
outline: none !important;
|
||||
box-shadow: 0 0 0 2px var(--c--theme--colors--primary-500) !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--c--theme--colors--greyscale-100);
|
||||
border-radius: 4px;
|
||||
|
||||
.light-doc-item-actions {
|
||||
display: flex;
|
||||
background: var(--c--theme--colors--greyscale-100);
|
||||
}
|
||||
}
|
||||
|
||||
.row.preview & {
|
||||
background-color: inherit;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<TreeViewItem
|
||||
{...props}
|
||||
onClick={() => {
|
||||
treeContext?.treeData.setSelectedNode(props.node.data.value as Doc);
|
||||
router.push(`/docs/${props.node.data.value.id}`);
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
data-testid={`doc-sub-page-item-${props.node.data.value.id}`}
|
||||
<TreeViewItem {...props} onClick={handleActivate}>
|
||||
<BoxButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleActivate();
|
||||
}}
|
||||
$width="100%"
|
||||
$direction="row"
|
||||
$gap={spacingsTokens['xs']}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
$align="center"
|
||||
$minHeight="24px"
|
||||
data-testid={`doc-sub-page-item-${doc.id}`}
|
||||
aria-label={`${t('Open document {{title}}', { title: docTitle })}`}
|
||||
$css={css`
|
||||
text-align: left;
|
||||
`}
|
||||
>
|
||||
<Box $width="16px" $height="16px">
|
||||
<DocIcon emoji={emoji} defaultIcon={<SubPageIcon />} $size="sm" />
|
||||
@@ -157,23 +187,25 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
||||
iconName="group"
|
||||
$size="16px"
|
||||
$variation="400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
className="light-doc-item-actions"
|
||||
>
|
||||
<DocTreeItemActions
|
||||
doc={doc}
|
||||
isOpen={actionsOpen}
|
||||
onOpenChange={setActionsOpen}
|
||||
parentId={node.data.parentKey}
|
||||
onCreateSuccess={afterCreate}
|
||||
/>
|
||||
</Box>
|
||||
</BoxButton>
|
||||
<Box
|
||||
$direction="row"
|
||||
$align="center"
|
||||
className="light-doc-item-actions"
|
||||
role="toolbar"
|
||||
aria-label={`${t('Actions for {{title}}', { title: docTitle })}`}
|
||||
>
|
||||
<DocTreeItemActions
|
||||
doc={doc}
|
||||
isOpen={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
parentId={node.data.parentKey}
|
||||
onCreateSuccess={afterCreate}
|
||||
/>
|
||||
</Box>
|
||||
</TreeViewItem>
|
||||
</Box>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@gouvfr-lasuite/ui-kit';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, StyledLink } from '@/components';
|
||||
@@ -26,11 +27,16 @@ type DocTreeProps = {
|
||||
|
||||
export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
const { spacingsTokens } = useCunninghamTheme();
|
||||
const [rootActionsOpen, setRootActionsOpen] = useState(false);
|
||||
const treeContext = useTreeContext<Doc | null>();
|
||||
const router = useRouter();
|
||||
const { isDesktop } = useResponsive();
|
||||
const [treeRoot, setTreeRoot] = useState<HTMLElement | null>(null);
|
||||
const treeContext = useTreeContext<Doc | null>();
|
||||
const router = useRouter();
|
||||
const [rootActionsOpen, setRootActionsOpen] = useState(false);
|
||||
const rootIsSelected =
|
||||
!!treeContext?.root?.id &&
|
||||
treeContext?.treeData.selectedNode?.id === treeContext.root.id;
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [initialOpenState, setInitialOpenState] = useState<OpenMap | undefined>(
|
||||
undefined,
|
||||
@@ -39,9 +45,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
const { mutate: moveDoc } = useMoveDoc();
|
||||
|
||||
const { data: tree, isFetching } = useDocTree(
|
||||
{
|
||||
docId: currentDoc.id,
|
||||
},
|
||||
{ docId: currentDoc.id },
|
||||
{
|
||||
enabled: !treeContext?.root?.id,
|
||||
queryKey: [KEY_DOC_TREE, { id: currentDoc.id }],
|
||||
@@ -56,7 +60,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
});
|
||||
treeContext?.treeData.handleMove(result);
|
||||
};
|
||||
|
||||
/**
|
||||
* This function resets the tree states.
|
||||
*/
|
||||
@@ -64,11 +67,39 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
if (!treeContext?.root?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
treeContext?.setRoot(null);
|
||||
setInitialOpenState(undefined);
|
||||
}, [treeContext]);
|
||||
|
||||
const selectRoot = useCallback(() => {
|
||||
if (treeContext?.root) {
|
||||
treeContext.treeData.setSelectedNode(treeContext.root);
|
||||
}
|
||||
}, [treeContext]);
|
||||
|
||||
const navigateToRoot = useCallback(() => {
|
||||
const id = treeContext?.root?.id;
|
||||
if (id) {
|
||||
router.push(`/docs/${id}`);
|
||||
}
|
||||
}, [router, treeContext?.root?.id]);
|
||||
|
||||
const handleRootFocus = useCallback(() => {
|
||||
selectRoot();
|
||||
}, [selectRoot]);
|
||||
|
||||
// activate root document with enter or space
|
||||
const handleRootKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
selectRoot();
|
||||
navigateToRoot();
|
||||
}
|
||||
},
|
||||
[selectRoot, navigateToRoot],
|
||||
);
|
||||
|
||||
/**
|
||||
* This effect is used to reset the tree when a new document
|
||||
* that is not part of the current tree is loaded.
|
||||
@@ -77,7 +108,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
if (!treeContext?.root?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = findIndexInTree(treeContext.treeData.nodes, currentDoc.id);
|
||||
if (index === -1 && currentDoc.id !== treeContext.root?.id) {
|
||||
resetStateTree();
|
||||
@@ -92,7 +122,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
return () => {
|
||||
resetStateTree();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -144,15 +173,23 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootIsSelected =
|
||||
treeContext.treeData.selectedNode?.id === treeContext.root.id;
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={setTreeRoot}
|
||||
data-testid="doc-tree"
|
||||
$height="100%"
|
||||
role="tree"
|
||||
aria-label={t('Document tree')}
|
||||
$css={css`
|
||||
/* Remove outline from TreeViewItem wrapper elements */
|
||||
.c__tree-view--row {
|
||||
outline: none !important;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.c__tree-view--container {
|
||||
z-index: 1;
|
||||
margin-top: -10px;
|
||||
@@ -171,6 +208,12 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
>
|
||||
<Box
|
||||
data-testid="doc-tree-root-item"
|
||||
role="treeitem"
|
||||
aria-label={`${t('Root document {{title}}', { title: treeContext.root?.title || t('Untitled document') })}`}
|
||||
aria-selected={rootIsSelected}
|
||||
tabIndex={0}
|
||||
onFocus={handleRootFocus}
|
||||
onKeyDown={handleRootKeyDown}
|
||||
$css={css`
|
||||
padding: ${spacingsTokens['2xs']};
|
||||
border-radius: 4px;
|
||||
@@ -183,6 +226,12 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
background-color: var(--c--theme--colors--greyscale-100);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: 0 0 0 2px var(--c--theme--colors--primary-500) !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.doc-tree-root-item-actions {
|
||||
display: 'flex';
|
||||
opacity: ${rootActionsOpen ? '1' : '0'};
|
||||
@@ -191,7 +240,8 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
.doc-tree-root-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -211,6 +261,8 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
|
||||
);
|
||||
router.push(`/docs/${treeContext?.root?.id}`);
|
||||
}}
|
||||
aria-label={`${t('Open root document')}: ${treeContext.root?.title || t('Untitled document')}`}
|
||||
tabIndex={-1} // avoid double tabstop
|
||||
>
|
||||
<Box $direction="row" $align="center" $width="100%">
|
||||
<SimpleDocItem doc={treeContext.root} showAccesses={true} />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user