Compare commits

..

2 Commits

Author SHA1 Message Date
Jacques ROUSSEL
62b5797223 test 2024-12-24 09:35:56 +01:00
Jacques ROUSSEL
ce83d8d72d wip 2024-12-24 09:31:59 +01:00
259 changed files with 6318 additions and 12016 deletions

View File

@@ -19,9 +19,26 @@ jobs:
build-and-push-backend:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Docker meta
id: meta
@@ -31,7 +48,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -53,9 +70,26 @@ jobs:
build-and-push-frontend:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Docker meta
id: meta
@@ -65,7 +99,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -88,9 +122,26 @@ jobs:
build-and-push-y-provider:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Docker meta
id: meta
@@ -100,7 +151,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -128,12 +179,29 @@ jobs:
if: |
github.event_name != 'pull_request'
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "impress,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/impress/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET}}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL

View File

@@ -2,7 +2,6 @@ name: Helmfile lint
run-name: Helmfile lint
on:
push:
pull_request:
branches:
- 'main'
@@ -13,18 +12,11 @@ jobs:
container:
image: ghcr.io/helmfile/helmfile:latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Helmfile lint
shell: bash
run: |
set -e
HELMFILE=src/helm/helmfile.yaml
environments=$(awk '/environments:/ {flag=1; next} flag && NF {print} !NF {flag=0}' "$HELMFILE" | grep -E '^[[:space:]]{2}[a-zA-Z]+' | sed 's/^[[:space:]]*//;s/:.*//')
for env in $environments; do
echo "################### $env lint ###################"
helmfile -e $env -f $HELMFILE lint || exit 1
echo -e "\n"
done
-
uses: numerique-gouv/action-helmfile-lint@main
with:
app-id: ${{ secrets.APP_ID }}
age-key: ${{ secrets.SOPS_PRIVATE }}
private-key: ${{ secrets.PRIVATE_KEY }}
helmfile-src: "src/helm"
repositories: "impress,secrets"

View File

@@ -3,8 +3,6 @@ run-name: Release Chart
on:
push:
paths:
- src/helm/impress/**
jobs:
release:
@@ -15,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
fetch-depth: 0
@@ -27,8 +25,9 @@ jobs:
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Publish Helm charts
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.6.0
with:
charts_dir: ./src/helm
token: ${{ secrets.GITHUB_TOKEN }}
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"

3
.gitmodules vendored
View File

@@ -0,0 +1,3 @@
[submodule "secrets"]
path = secrets
url = ../secrets

View File

@@ -11,23 +11,7 @@ and this project adheres to
## Added
- 🔧(backend) add option to configure list of essential OIDC claims #525 & #531
- 🔧(helm) add option to disable default tls setting by @dominikkaminski #519
- 💄(frontend) Add left panel #420
- 💄(frontend) add filtering to left panel #475
- ✨(frontend) new share modal ui #489
- ✨(frontend) add favorite feature #515
## Changed
- 🏗️(yjs-server) organize yjs server #528
- ♻️(frontend) better separation collaboration process #528
- 💄(frontend) updating the header and leftpanel for responsive #421
- 💄(frontend) update DocsGrid component #431
- 💄(frontend) update DocsGridOptions component #432
- 💄(frontend) update DocHeader ui #446
- 💄(frontend) update doc versioning ui #463
- 💄(frontend) update doc summary ui #473
🔧(helm) add option to disable default tls setting by @dominikkaminski #519
## [1.10.0] - 2024-12-17
@@ -47,11 +31,6 @@ and this project adheres to
- ⚡️(e2e) reduce flakiness on e2e tests #511
## Fixed
- 🐛(frontend) update doc editor height #481
- 💄(frontend) add doc search #485
## [1.9.0] - 2024-12-11
## Added
@@ -197,7 +176,7 @@ and this project adheres to
- 🛂(frontend) match email if no existing user matches the sub
- 🐛(backend) gitlab oicd userinfo endpoint #232
- 🛂(frontend) redirect to the OIDC when private doc and unauthenticated #292
- 🛂(frontend) redirect to the OIDC when private doc and unauthentified #292
- ♻️(backend) getting list of document versions available for a user #258
- 🔧(backend) fix configuration to avoid different ssl warning #297
- 🐛(frontend) fix editor break line not working #302
@@ -326,7 +305,7 @@ and this project adheres to
- ⚡️(e2e) unique login between tests (#80)
- ⚡️(CI) improve e2e job (#86)
- ♻️(frontend) improve the error and message info ui (#93)
- ✏️(frontend) change all occurrences of pad to doc (#99)
- ✏️(frontend) change all occurences of pad to doc (#99)
## Fixed

View File

@@ -1,2 +1,103 @@
#!/bin/sh
curl https://raw.githubusercontent.com/numerique-gouv/tools/refs/heads/main/kind/create_cluster.sh | bash -s -- impress
set -o errexit
CURRENT_DIR=$(pwd)
echo "0. Create ca"
# 0. Create ca
mkcert -install
cd /tmp
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
cd $CURRENT_DIR
echo "1. Create registry container unless it already exists"
# 1. Create registry container unless it already exists
reg_name='kind-registry'
reg_port='5001'
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
docker run \
-d --restart=unless-stopped -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
registry:2
fi
echo "2. Create kind cluster with containerd registry config dir enabled"
# 2. Create kind cluster with containerd registry config dir enabled
# TODO: kind will eventually enable this by default and this patch will
# be unnecessary.
#
# See:
# https://github.com/kubernetes-sigs/kind/issues/2875
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
cat <<EOF | kind create cluster --config=-
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
containerdConfigPatches:
- |-
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
nodes:
- role: control-plane
image: kindest/node:v1.27.3
kubeadmConfigPatches:
- |
kind: InitConfiguration
nodeRegistration:
kubeletExtraArgs:
node-labels: "ingress-ready=true"
extraPortMappings:
- containerPort: 80
hostPort: 80
protocol: TCP
- containerPort: 443
hostPort: 443
protocol: TCP
- role: worker
image: kindest/node:v1.27.3
- role: worker
image: kindest/node:v1.27.3
EOF
echo "3. Add the registry config to the nodes"
# 3. Add the registry config to the nodes
#
# This is necessary because localhost resolves to loopback addresses that are
# network-namespace local.
# In other words: localhost in the container is not localhost on the host.
#
# We want a consistent name that works from both ends, so we tell containerd to
# alias localhost:${reg_port} to the registry container when pulling images
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
for node in $(kind get nodes); do
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
[host."http://${reg_name}:5000"]
EOF
done
echo "4. Connect the registry to the cluster network if not already connected"
# 4. Connect the registry to the cluster network if not already connected
# This allows kind to bootstrap the network but ensures they're on the same network
if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${reg_name}")" = 'null' ]; then
docker network connect "kind" "${reg_name}"
fi
echo "5. Document the local registry"
# 5. Document the local registry
# https://github.com/kubernetes/enhancements/tree/master/keps/sig-cluster-lifecycle/generic/1755-communicating-a-local-registry
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: local-registry-hosting
namespace: kube-public
data:
localRegistryHosting.v1: |
host: "localhost:${reg_port}"
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
EOF
echo "6. Install ingress-nginx"
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"}]'

View File

@@ -1,156 +0,0 @@
image:
repository: lasuite/impress-backend
pullPolicy: Always
tag: "latest"
backend:
replicas: 1
envVars:
COLLABORATION_API_URL: https://impress.127.0.0.1.nip.io/collaboration/api/
COLLABORATION_SERVER_SECRET: my-secret
DJANGO_CSRF_TRUSTED_ORIGINS: https://impress.127.0.0.1.nip.io
DJANGO_CONFIGURATION: Feature
DJANGO_ALLOWED_HOSTS: impress.127.0.0.1.nip.io
DJANGO_SERVER_TO_SERVER_API_TOKENS: secret-api-key
DJANGO_SECRET_KEY: AgoodOrAbadKey
DJANGO_SETTINGS_MODULE: impress.settings
DJANGO_SUPERUSER_PASSWORD: admin
DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique"
DJANGO_EMAIL_HOST: "mailcatcher"
DJANGO_EMAIL_LOGO_IMG: https://impress.127.0.0.1.nip.io/assets/logo-suite-numerique.png
DJANGO_EMAIL_PORT: 1025
DJANGO_EMAIL_USE_SSL: False
LOGGING_LEVEL_HANDLERS_CONSOLE: ERROR
LOGGING_LEVEL_LOGGERS_ROOT: INFO
LOGGING_LEVEL_LOGGERS_APP: INFO
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end
OIDC_RP_CLIENT_ID: impress
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
OIDC_VERIFY_SSL: False
USER_OIDC_FIELD_TO_SHORTNAME: "given_name"
USER_OIDC_FIELDS_TO_FULLNAME: "given_name,usual_name"
OIDC_REDIRECT_ALLOWED_HOSTS: https://impress.127.0.0.1.nip.io
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
LOGIN_REDIRECT_URL: https://impress.127.0.0.1.nip.io
LOGIN_REDIRECT_URL_FAILURE: https://impress.127.0.0.1.nip.io
LOGOUT_REDIRECT_URL: https://impress.127.0.0.1.nip.io
DB_HOST: postgresql
DB_NAME: impress
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: impress
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
AWS_S3_ENDPOINT_URL: http://minio.impress.svc.cluster.local:9000
AWS_S3_ACCESS_KEY_ID: root
AWS_S3_SECRET_ACCESS_KEY: password
AWS_STORAGE_BUCKET_NAME: impress-media-storage
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
Y_PROVIDER_API_BASE_URL: http://impress-y-provider:443/api/
Y_PROVIDER_API_KEY: my-secret
migrate:
command:
- "/bin/sh"
- "-c"
- |
python manage.py migrate --no-input &&
python manage.py create_demo --force
restartPolicy: Never
command:
- "gunicorn"
- "-c"
- "/usr/local/etc/gunicorn/impress.py"
- "impress.wsgi:application"
- "--reload"
createsuperuser:
command:
- "/bin/sh"
- "-c"
- |
python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts:
- name: certs
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
subPath: cacert.pem
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumes:
- name: certs
configMap:
name: certifi
items:
- key: cacert.pem
path: cacert.pem
frontend:
envVars:
PORT: 8080
NEXT_PUBLIC_API_ORIGIN: https://impress.127.0.0.1.nip.io
replicas: 1
image:
repository: lasuite/impress-frontend
pullPolicy: Always
tag: "latest"
yProvider:
replicas: 1
image:
repository: lasuite/impress-y-provider
pullPolicy: Always
tag: "latest"
envVars:
COLLABORATION_LOGGING: true
COLLABORATION_SERVER_ORIGIN: https://impress.127.0.0.1.nip.io
COLLABORATION_SERVER_SECRET: my-secret
Y_PROVIDER_API_KEY: my-secret
ingress:
enabled: true
host: impress.127.0.0.1.nip.io
ingressCollaborationWS:
enabled: true
host: impress.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/collaboration-auth/
ingressCollaborationApi:
enabled: true
host: impress.127.0.0.1.nip.io
ingressAdmin:
enabled: true
host: impress.127.0.0.1.nip.io
ingressMedia:
enabled: true
host: impress.127.0.0.1.nip.io
annotations:
nginx.ingress.kubernetes.io/auth-url: https://impress.127.0.0.1.nip.io/api/v1.0/documents/media-auth/
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
nginx.ingress.kubernetes.io/upstream-vhost: minio.impress.svc.cluster.local:9000
nginx.ingress.kubernetes.io/rewrite-target: /impress-media-storage/$1
serviceMedia:
host: minio.impress.svc.cluster.local
port: 9000

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +0,0 @@
auth:
rootUser: root
rootPassword: password
provisioning:
enabled: true
buckets:
- name: impress-media-storage
versioning: true

View File

@@ -1,7 +0,0 @@
auth:
username: dinum
password: pass
database: impress
tls:
enabled: true
autoGenerated: true

View File

@@ -1,4 +0,0 @@
auth:
password: pass
architecture: standalone

View File

@@ -1,231 +0,0 @@
# Installation on a k8s cluster
This document is a step-by-step guide that describes how to install Docs on a k8s cluster without AI features. It's a teaching document to learn how it's work. It needs to be adapt for production environment.
## Prerequisites
- k8s cluster with an nginx-ingress controller
- an OIDC provider (if you don't have one, we will provide an example)
- a PostgreSQL server (if you don't have one, we will provide an example)
- a Memcached server (if you don't have one, we will provide an example)
- a S3 bucket (if you don't have one, we will provide an example)
### Test cluster
If you do not have a test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
To be able to use the script, you will need to install:
- Docker (https://docs.docker.com/desktop/)
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
- Mkcert (https://github.com/FiloSottile/mkcert#installation)
- Helm (https://helm.sh/docs/intro/quickstart/#install-helm)
```
./bin/start-kind.sh
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 4700 100 4700 0 0 92867 0 --:--:-- --:--:-- --:--:-- 94000
0. Create ca
The local CA is already installed in the system trust store! 👍
The local CA is already installed in the Firefox and/or Chrome/Chromium trust store! 👍
Created a new certificate valid for the following names 📜
- "127.0.0.1.nip.io"
- "*.127.0.0.1.nip.io"
Reminder: X.509 wildcards only go one level deep, so this won't match a.b.127.0.0.1.nip.io
The certificate is at "./127.0.0.1.nip.io+1.pem" and the key at "./127.0.0.1.nip.io+1-key.pem" ✅
It will expire on 24 March 2027 🗓
1. Create registry container unless it already exists
2. Create kind cluster with containerd registry config dir enabled
Creating cluster "suite" ...
✓ Ensuring node image (kindest/node:v1.27.3) 🖼
✓ Preparing nodes 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
Set kubectl context to "kind-suite"
You can now use your cluster with:
kubectl cluster-info --context kind-suite
Thanks for using kind! 😊
3. Add the registry config to the nodes
4. Connect the registry to the cluster network if not already connected
5. Document the local registry
configmap/local-registry-hosting created
Warning: resource configmaps/coredns is missing the kubectl.kubernetes.io/last-applied-configuration annotation which is required by kubectl apply. kubectl apply should only be used on resources created declaratively by either kubectl create --save-config or kubectl apply. The missing annotation will be patched automatically.
configmap/coredns configured
deployment.apps/coredns restarted
6. Install ingress-nginx
namespace/ingress-nginx created
serviceaccount/ingress-nginx created
serviceaccount/ingress-nginx-admission created
role.rbac.authorization.k8s.io/ingress-nginx created
role.rbac.authorization.k8s.io/ingress-nginx-admission created
clusterrole.rbac.authorization.k8s.io/ingress-nginx created
clusterrole.rbac.authorization.k8s.io/ingress-nginx-admission created
rolebinding.rbac.authorization.k8s.io/ingress-nginx created
rolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
configmap/ingress-nginx-controller created
service/ingress-nginx-controller created
service/ingress-nginx-controller-admission created
deployment.apps/ingress-nginx-controller created
job.batch/ingress-nginx-admission-create created
job.batch/ingress-nginx-admission-patch created
ingressclass.networking.k8s.io/nginx created
validatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created
secret/mkcert created
deployment.apps/ingress-nginx-controller patched
7. Setup namespace
namespace/impress created
Context "kind-suite" modified.
secret/mkcert created
$ kubectl -n ingress-nginx get po
NAME READY STATUS RESTARTS AGE
ingress-nginx-admission-create-t55ph 0/1 Completed 0 2m56s
ingress-nginx-admission-patch-94dvt 0/1 Completed 1 2m56s
ingress-nginx-controller-57c548c4cd-2rx47 1/1 Running 0 2m56s
```
When your k8s cluster is ready (the ingress nginx controller is up), you can start the deployment. This cluster is special because it uses the *.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
Please remember that *.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
## Preparation
### What will you use to authenticate your users ?
Docs uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Docs) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
```
$ kubectl create namespace impress
$ kubectl config set-context --current --namespace=impress
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
$ #wait until
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 6m48s
keycloak-postgresql-0 1/1 Running 0 6m48s
```
From here the important informations you will need are :
```
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/session/end
OIDC_RP_CLIENT_ID: impress
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
```
You can find these values in **examples/keycloak.values.yaml**
### Find redis server connexion values
Impress need a redis so we will start by deploying a redis :
```
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 26m
keycloak-postgresql-0 1/1 Running 0 26m
redis-master-0 1/1 Running 0 35s
```
### Find postgresql connexion values
Impress uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follow:
```
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 28m
keycloak-postgresql-0 1/1 Running 0 28m
postgresql-0 1/1 Running 0 14m
redis-master-0 1/1 Running 0 42s
```
From here important informations you will need are :
```
DB_HOST: postgres-postgresql
DB_NAME: impress
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: impress
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
```
### Find s3 bucket connexion values
Impress uses a s3 bucket to store documents so if you have a provider obtain the necessary information to use it. If you do not have, you can install a local minio testing environment as follow:
```
$ helm install minio oci://registry-1.docker.io/bitnamicharts/minio -f examples/minio.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 38m
keycloak-postgresql-0 1/1 Running 0 38m
minio-84f5c66895-bbhsk 1/1 Running 0 42s
minio-provisioning-2b5sq 0/1 Completed 0 42s
postgresql-0 1/1 Running 0 24m
redis-master-0 1/1 Running 0 10m
```
## Deployment
Now you are ready to deploy Impress without AI. AI requiered more dependancies (openai API). To deploy impress you need to provide all previous informations to the helm chart.
```
$ helm repo add impress https://suitenumerique.github.io/docs/
$ helm repo update
$ helm install impress impress/docs -f examples/impress.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
impress-docs-backend-96558758d-xtkbp 0/1 Running 0 79s
impress-docs-backend-createsuperuser-r7ltc 0/1 Completed 0 79s
impress-docs-backend-migrate-c949s 0/1 Completed 0 79s
impress-docs-frontend-6749f644f7-p5s42 1/1 Running 0 79s
impress-docs-y-provider-6947fd8f54-78f2l 1/1 Running 0 79s
keycloak-0 1/1 Running 0 48m
keycloak-postgresql-0 1/1 Running 0 48m
minio-84f5c66895-bbhsk 1/1 Running 0 10m
minio-provisioning-2b5sq 0/1 Completed 0 10m
postgresql-0 1/1 Running 0 34m
redis-master-0 1/1 Running 0 20m
```
## Test your deployment
In order to test your deployment you have to login to your instance. If you use exclusively our examples you can do :
```
$ kubectl get ingress
NAME CLASS HOSTS ADDRESS PORTS AGE
impress-docs <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-admin <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-collaboration-api <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-media <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
impress-docs-ws <none> impress.127.0.0.1.nip.io localhost 80, 443 114s
keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 49m
```
You can use impress on https://impress.127.0.0.1.nip.io. The provisionning user in keycloak is impress/impress.

View File

@@ -6,7 +6,7 @@ Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standar
2. Bump the release number for backend project, frontend projects, and Helm files:
- for backend, update the version number by hand in `pyproject.toml`,
- for each project (`src/frontend`, `src/frontend/apps/*`, `src/frontend/packages/*`, `src/mail`), run `yarn version --new-version --no-git-tag-version 4.18.1` in their directory. This will update their `package.json` for you,
- for each projects (`src/frontend`, `src/frontend/apps/*`, `src/frontend/packages/*`, `src/mail`), run `yarn version --new-version --no-git-tag-version 4.18.1` in their directory. This will update their `package.json` for you,
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
```yaml

1
secrets Submodule

Submodule secrets added at 38594182e8

View File

@@ -140,7 +140,6 @@ class UserViewSet(
permission_classes = [permissions.IsSelf]
queryset = models.User.objects.all()
serializer_class = serializers.UserSerializer
ordering = ["-created_at"]
def get_queryset(self):
"""
@@ -630,7 +629,7 @@ class DocumentViewSet(
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged-in user, we must decide if we authorize Nginx
Based on the original url and the logged in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
@@ -835,7 +834,7 @@ class DocumentAccessViewSet(
serializer_class = serializers.DocumentAccessSerializer
def perform_create(self, serializer):
"""Add new access to the document and email the new added user."""
"""Add a new access to the document and send an email to the new added user."""
access = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")
@@ -847,7 +846,7 @@ class DocumentAccessViewSet(
)
def perform_update(self, serializer):
"""Update access to the document and notify the collaboration server."""
"""Update an access to the document and notify the collaboration server."""
access = serializer.save()
access_user_id = None
@@ -860,7 +859,7 @@ class DocumentAccessViewSet(
)
def perform_destroy(self, instance):
"""Delete access to the document and notify the collaboration server."""
"""Delete an access to the document and notify the collaboration server."""
instance.delete()
# Notify collaboration server about the access removed
@@ -1099,7 +1098,7 @@ class InvitationViewset(
return queryset
def perform_create(self, serializer):
"""Save invitation to a document then email the invited user."""
"""Save invitation to a document then send an email to the invited user."""
invitation = serializer.save()
language = self.request.headers.get("Content-Language", "en-us")

View File

@@ -1,7 +1,5 @@
"""Authentication Backends for the Impress core app."""
import logging
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
@@ -13,8 +11,6 @@ from mozilla_django_oidc.auth import (
from core.models import User
logger = logging.getLogger(__name__)
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
@@ -63,29 +59,10 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
return userinfo
def verify_claims(self, claims):
"""
Verify the presence of essential claims and the "sub" (which is mandatory as defined
by the OIDC specification) to decide if authentication should be allowed.
"""
essential_claims = settings.USER_OIDC_ESSENTIAL_CLAIMS
missing_claims = [claim for claim in essential_claims if claim not in claims]
if missing_claims:
logger.error("Missing essential claims: %s", missing_claims)
return False
return True
def get_or_create_user(self, access_token, id_token, payload):
"""Return a User based on userinfo. Create a new user if no match is found."""
user_info = self.get_userinfo(access_token, id_token, payload)
if not self.verify_claims(user_info):
raise SuspiciousOperation("Claims verification failed.")
sub = user_info["sub"]
email = user_info.get("email")
# Get user's full name from OIDC fields defined in settings
@@ -98,6 +75,12 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"short_name": short_name,
}
sub = user_info.get("sub")
if not sub:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
user = self.get_existing_user(sub, email)
if user:
@@ -118,13 +101,15 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
return full_name or None
def get_existing_user(self, sub, email):
"""Fetch an existing user by sub (or email as a fallback respecting fallback setting."""
"""Fetch existing user by sub or email."""
try:
return User.objects.get(sub=sub)
except User.DoesNotExist:
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
return User.objects.filter(email=email).first()
try:
return User.objects.get(email=email)
except User.DoesNotExist:
pass
return None
def update_user_if_needed(self, user, claims):
@@ -134,4 +119,4 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
)
if has_changed:
updated_claims = {key: value for key, value in claims.items() if value}
self.UserModel.objects.filter(id=user.id).update(**updated_claims)
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)

View File

@@ -2,7 +2,7 @@
from django.urls import path
from mozilla_django_oidc.urls import urlpatterns as mozilla_oidc_urls
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
from .views import OIDCLogoutCallbackView, OIDCLogoutView
@@ -14,5 +14,5 @@ urlpatterns = [
OIDCLogoutCallbackView.as_view(),
name="oidc_logout_callback",
),
*mozilla_oidc_urls,
*mozzila_oidc_urls,
]

View File

@@ -19,7 +19,6 @@ class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.User
skip_postgeneration_save = True
sub = factory.Sequence(lambda n: f"user{n!s}")
email = factory.Faker("email")
@@ -37,8 +36,6 @@ class UserFactory(factory.django.DjangoModelFactory):
if create and (extracted is True):
UserDocumentAccessFactory(user=self, role="owner")
self.save()
@factory.post_generation
def with_owned_template(self, create, extracted, **kwargs):
"""
@@ -48,8 +45,6 @@ class UserFactory(factory.django.DjangoModelFactory):
if create and (extracted is True):
UserTemplateAccessFactory(user=self, role="owner")
self.save()
class DocumentFactory(factory.django.DjangoModelFactory):
"""A factory to create documents"""

View File

@@ -1,5 +1,7 @@
# Generated by Django 5.0.3 on 2024-05-28 20:29
import django.contrib.auth.models
import django.core.validators
import django.db.models.deletion
import timezone_field.fields
import uuid
@@ -143,7 +145,7 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='documentaccess',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_document_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
),
migrations.AddConstraint(
model_name='invitation',
@@ -159,6 +161,6 @@ class Migration(migrations.Migration):
),
migrations.AddConstraint(
model_name='templateaccess',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
constraint=models.CheckConstraint(check=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_template_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
),
]

View File

@@ -1,17 +0,0 @@
# Generated by Django 5.1.4 on 2025-01-13 22:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0012_make_document_creator_and_invitation_issuer_optional'),
]
operations = [
migrations.AlterModelOptions(
name='user',
options={'ordering': ('-created_at',), 'verbose_name': 'user', 'verbose_name_plural': 'users'},
),
]

View File

@@ -155,7 +155,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
email = models.EmailField(_("identity email address"), blank=True, null=True)
# Unlike the "email" field which stores the email coming from the OIDC token, this field
# stores the email used by staff users to log in to the admin site
# stores the email used by staff users to login to the admin site
admin_email = models.EmailField(
_("admin email address"), unique=True, blank=True, null=True
)
@@ -199,7 +199,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
class Meta:
db_table = "impress_user"
ordering = ("-created_at",)
verbose_name = _("user")
verbose_name_plural = _("users")
@@ -696,7 +695,7 @@ class DocumentAccess(BaseAccess):
violation_error_message=_("This team is already in this document."),
),
models.CheckConstraint(
condition=models.Q(user__isnull=False, team="")
check=models.Q(user__isnull=False, team="")
| models.Q(user__isnull=True, team__gt=""),
name="check_document_access_either_user_or_team",
violation_error_message=_("Either user or team must be set, not both."),
@@ -761,7 +760,7 @@ class Template(BaseModel):
"""
document_html = weasyprint.HTML(
string=DjangoTemplate(self.code).render(
Context({"body": html.format_html("{}", body_html), **metadata})
Context({"body": html.format_html(body_html), **metadata})
)
)
css = weasyprint.CSS(
@@ -780,7 +779,7 @@ class Template(BaseModel):
Generate and return a docx document wrapped around the current template
"""
template_string = DjangoTemplate(self.code).render(
Context({"body": html.format_html("{}", body_html), **metadata})
Context({"body": html.format_html(body_html), **metadata})
)
html_string = f"""
@@ -798,6 +797,7 @@ class Template(BaseModel):
"""
reference_docx = "core/static/reference.docx"
output = BytesIO()
# Convert the HTML to a temporary docx file
with tempfile.NamedTemporaryFile(suffix=".docx", prefix="docx_") as tmp_file:
@@ -884,7 +884,7 @@ class TemplateAccess(BaseAccess):
violation_error_message=_("This team is already in this template."),
),
models.CheckConstraint(
condition=models.Q(user__isnull=False, team="")
check=models.Q(user__isnull=False, team="")
| models.Q(user__isnull=True, team__gt=""),
name="check_template_access_either_user_or_team",
violation_error_message=_("Either user or team must be set, not both."),

View File

@@ -17,7 +17,7 @@ class CollaborationService:
def reset_connections(self, room, user_id=None):
"""
Reset connections of a room in the collaboration server.
Resetting a connection means that the user will be disconnected and will
Reseting a connection means that the user will be disconnected and will
have to reconnect to the collaboration server, with updated rights.
"""
endpoint = "reset-connections"

View File

@@ -1,8 +1,6 @@
"""Unit tests for the Authentication Backends."""
import re
from logging import Logger
from unittest import mock
from django.core.exceptions import SuspiciousOperation
from django.test.utils import override_settings
@@ -130,12 +128,11 @@ def test_authentication_getter_existing_user_with_email(
("Jack", "Duy", "jack.duy@example.com"),
],
)
def test_authentication_getter_existing_user_change_fields_sub(
def test_authentication_getter_existing_user_change_fields(
first_name, last_name, email, django_assert_num_queries, monkeypatch
):
"""
It should update the email or name fields on the user when they change
and the user was identified by its "sub".
It should update the email or name fields on the user when they change.
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(
@@ -165,48 +162,6 @@ def test_authentication_getter_existing_user_change_fields_sub(
assert user.short_name == first_name
@pytest.mark.parametrize(
"first_name, last_name, email",
[
("Jack", "Doe", "john.doe@example.com"),
("John", "Duy", "john.doe@example.com"),
],
)
def test_authentication_getter_existing_user_change_fields_email(
first_name, last_name, email, django_assert_num_queries, monkeypatch
):
"""
It should update the name fields on the user when they change
and the user was identified by its "email" as fallback.
"""
klass = OIDCAuthenticationBackend()
user = UserFactory(
full_name="John Doe", short_name="John", email="john.doe@example.com"
)
def get_userinfo_mocked(*args):
return {
"sub": "123",
"email": user.email,
"first_name": first_name,
"last_name": last_name,
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(3):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == authenticated_user
user.refresh_from_db()
assert user.email == email
assert user.full_name == f"{first_name:s} {last_name:s}"
assert user.short_name == first_name
def test_authentication_getter_new_user_no_email(monkeypatch):
"""
If no user matches the user's info sub, a user should be created.
@@ -258,6 +213,29 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert models.User.objects.count() == 1
def test_authentication_getter_invalid_token(django_assert_num_queries, monkeypatch):
"""The user's info doesn't contain a sub."""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {
"test": "123",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(0),
pytest.raises(
SuspiciousOperation,
match="User info contained no recognizable user identification",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@responses.activate
def test_authentication_get_userinfo_json_response():
@@ -363,7 +341,7 @@ def test_authentication_getter_existing_disabled_user_via_email(
django_assert_num_queries, monkeypatch
):
"""
If an existing user does not match the sub but matches the email and is disabled,
If an existing user does not matches the sub but matches the email and is disabled,
an error should be raised and a user should not be created.
"""
@@ -387,102 +365,3 @@ def test_authentication_getter_existing_disabled_user_via_email(
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.count() == 1
# Essential claims
def test_authentication_verify_claims_default(django_assert_num_queries, monkeypatch):
"""The sub claim should be mandatory by default."""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {
"test": "123",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(0),
pytest.raises(
KeyError,
match="sub",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
@pytest.mark.parametrize(
"essential_claims, missing_claims",
[
(["email", "sub"], ["email"]),
(["Email", "sub"], ["Email"]), # Case sensitivity
],
)
@override_settings(OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo")
@mock.patch.object(Logger, "error")
def test_authentication_verify_claims_essential_missing(
mock_logger,
essential_claims,
missing_claims,
django_assert_num_queries,
monkeypatch,
):
"""Ensure SuspiciousOperation is raised if essential claims are missing."""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {
"sub": "123",
"last_name": "Doe",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with (
django_assert_num_queries(0),
pytest.raises(
SuspiciousOperation,
match="Claims verification failed",
),
override_settings(USER_OIDC_ESSENTIAL_CLAIMS=essential_claims),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
mock_logger.assert_called_once_with("Missing essential claims: %s", missing_claims)
@override_settings(
OIDC_OP_USER_ENDPOINT="http://oidc.endpoint.test/userinfo",
USER_OIDC_ESSENTIAL_CLAIMS=["email", "last_name"],
)
def test_authentication_verify_claims_success(django_assert_num_queries, monkeypatch):
"""Ensure user is authenticated when all essential claims are present."""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {
"email": "john.doe@example.com",
"last_name": "Doe",
"sub": "123",
}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(6):
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert models.User.objects.filter(id=user.id).exists()
assert user.sub == "123"
assert user.full_name == "Doe"
assert user.short_name is None
assert user.email == "john.doe@example.com"

View File

@@ -698,7 +698,7 @@ def test_api_document_accesses_delete_administrators_except_owners(
mock_reset_connections, # pylint: disable=redefined-outer-name
):
"""
Users who are administrators in a document should be allowed to delete access
Users who are administrators in a document should be allowed to delete an access
from the document provided it is not ownership.
"""
user = factories.UserFactory()

View File

@@ -285,7 +285,7 @@ def test_api_document_versions_retrieve_authenticated_related(via, mock_user_tea
assert response.status_code == 404
# Create a new version should not make it available to the user because
# only the current version is available to the user, but it is excluded
# only the current version is available to the user but it is excluded
# from the list
document.content = "new content 1"
document.save()

View File

@@ -134,7 +134,7 @@ def test_api_documents_ai_transform_authenticated_forbidden(reach, role):
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_transform_authenticated_success(mock_create, reach, role):
"""
Authenticated who are not related to a document should be able to request AI transform
Autenticated who are not related to a document should be able to request AI transform
if the link reach and role permit it.
"""
user = factories.UserFactory()

View File

@@ -154,7 +154,7 @@ def test_api_documents_ai_translate_authenticated_forbidden(reach, role):
@patch("openai.resources.chat.completions.Completions.create")
def test_api_documents_ai_translate_authenticated_success(mock_create, reach, role):
"""
Authenticated who are not related to a document should be able to request AI translate
Autenticated who are not related to a document should be able to request AI translate
if the link reach and role permit it.
"""
user = factories.UserFactory()

View File

@@ -111,7 +111,7 @@ def test_api_documents_attachment_upload_authenticated_forbidden(reach, role):
)
def test_api_documents_attachment_upload_authenticated_success(reach, role):
"""
Authenticated who are not related to a document should be able to upload a file
Autenticated who are not related to a document should be able to upload a file
if the link reach and role permit it.
"""
user = factories.UserFactory()
@@ -225,7 +225,7 @@ def test_api_documents_attachment_upload_invalid(client):
def test_api_documents_attachment_upload_size_limit_exceeded(settings):
"""The uploaded file should not exceed the maximum size in settings."""
"""The uploaded file should not exceeed the maximum size in settings."""
settings.DOCUMENT_IMAGE_MAX_SIZE = 1048576 # 1 MB for test
user = factories.UserFactory()

View File

@@ -160,7 +160,7 @@ def test_api_documents_media_auth_authenticated_restricted():
@pytest.mark.parametrize("via", VIA)
def test_api_documents_media_auth_related(via, mock_user_teams):
"""
Users who have specific access to a document, whatever the role, should be able to
Users who have a specific access to a document, whatever the role, should be able to
retrieve related attachments.
"""
user = factories.UserFactory()

View File

@@ -647,7 +647,7 @@ def test_api_template_accesses_delete_administrators_except_owners(
via, mock_user_teams
):
"""
Users who are administrators in a template should be allowed to delete access
Users who are administrators in a template should be allowed to delete an access
from the template provided it is not ownership.
"""
user = factories.UserFactory()

View File

@@ -84,7 +84,7 @@ def test_models_documents_file_key():
def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role):
"""
Check abilities returned for a document giving insufficient roles to link holders
i.e. anonymous users or authenticated users who have no specific role on the document.
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role=role)
user = factories.UserFactory() if is_authenticated else AnonymousUser()
@@ -121,7 +121,7 @@ def test_models_documents_get_abilities_forbidden(is_authenticated, reach, role)
def test_models_documents_get_abilities_reader(is_authenticated, reach):
"""
Check abilities returned for a document giving reader role to link holders
i.e. anonymous users or authenticated users who have no specific role on the document.
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role="reader")
user = factories.UserFactory() if is_authenticated else AnonymousUser()
@@ -158,7 +158,7 @@ def test_models_documents_get_abilities_reader(is_authenticated, reach):
def test_models_documents_get_abilities_editor(is_authenticated, reach):
"""
Check abilities returned for a document giving editor role to link holders
i.e. anonymous users or authenticated users who have no specific role on the document.
i.e anonymous users or authenticated users who have no specific role on the document.
"""
document = factories.DocumentFactory(link_reach=reach, link_role="editor")
user = factories.UserFactory() if is_authenticated else AnonymousUser()
@@ -449,7 +449,7 @@ def test_models_documents__email_invitation__success():
def test_models_documents__email_invitation__success_fr():
"""
The email invitation is sent successfully in French.
The email invitation is sent successfully in french.
"""
document = factories.DocumentFactory()

View File

@@ -27,7 +27,7 @@ def test_models_users_id_unique():
def test_models_users_send_mail_main_existing():
"""The 'email_user' method should send mail to the user's email address."""
"""The "email_user' method should send mail to the user's email address."""
user = factories.UserFactory()
with mock.patch("django.core.mail.send_mail") as mock_send:
@@ -37,7 +37,7 @@ def test_models_users_send_mail_main_existing():
def test_models_users_send_mail_main_missing():
"""The 'email_user' method should fail if the user has no email address."""
"""The "email_user' method should fail if the user has no email address."""
user = factories.UserFactory(email=None)
with pytest.raises(ValueError) as excinfo:

View File

@@ -1,5 +1,5 @@
"""
Test AI API endpoints in the impress core app.
Test ai API endpoints in the impress core app.
"""
import json

View File

@@ -15,7 +15,7 @@ class Command(BaseCommand):
"""Define required arguments "email" and "password"."""
parser.add_argument(
"--email",
help="Email for the user.",
help=("Email for the user."),
)
parser.add_argument(
"--password",

View File

@@ -474,9 +474,6 @@ class Base(Configuration):
environ_prefix=None,
)
USER_OIDC_ESSENTIAL_CLAIMS = values.ListValue(
default=[], environ_name="USER_OIDC_ESSENTIAL_CLAIMS", environ_prefix=None
)
USER_OIDC_FIELDS_TO_FULLNAME = values.ListValue(
default=["first_name", "last_name"],
environ_name="USER_OIDC_FIELDS_TO_FULLNAME",
@@ -622,9 +619,8 @@ class Base(Configuration):
release=get_release(),
integrations=[DjangoIntegration()],
)
# Add the application name to the Sentry scope
scope = sentry_sdk.get_global_scope()
scope.set_tag("application", "backend")
with sentry_sdk.configure_scope() as scope:
scope.set_extra("application", "backend")
class Build(Base):

View File

@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"boto3==1.35.90",
"boto3==1.35.81",
"Brotli==1.1.0",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
@@ -47,7 +47,7 @@ dependencies = [
"jsonschema==4.23.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"openai==1.58.1",
"openai==1.57.4",
"psycopg[binary]==3.2.3",
"PyJWT==2.10.1",
"pypandoc==1.14",
@@ -73,17 +73,17 @@ dev = [
"drf-spectacular-sidecar==2024.12.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==8.31.0",
"ipython==8.30.0",
"pyfakefs==5.7.3",
"pylint-django==2.6.1",
"pylint==3.3.3",
"pylint==3.3.2",
"pytest-cov==6.0.0",
"pytest-django==4.9.0",
"pytest==8.3.4",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.8.4",
"ruff==0.8.3",
"types-requests==2.32.0.20241016",
]

View File

@@ -36,25 +36,18 @@ export const createDoc = async (
await page
.getByRole('button', {
name: 'New doc',
name: 'Create a new document',
})
.click();
const input = page.getByRole('textbox', { name: 'doc title input' });
await input.click();
await input.fill(randomDocs[i]);
await input.blur();
await page.getByRole('heading', { name: 'Untitled document' }).click();
await page.keyboard.type(randomDocs[i]);
await page.getByText('Created at ').click();
}
return randomDocs;
};
export const verifyDocName = async (page: Page, docName: string) => {
const input = page.getByRole('textbox', { name: 'doc title input' });
await expect(input).toBeVisible();
await expect(input).toHaveText(docName);
};
export const addNewMember = async (
page: Page,
index: number,
@@ -67,9 +60,7 @@ export const addNewMember = async (
response.status() === 200,
);
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const inputSearch = page.getByLabel(/Find a member to add to the document/);
// Select a new user
await inputSearch.fill(fillText);
@@ -84,9 +75,13 @@ export const addNewMember = async (
await page.getByRole('option', { name: users[index].email }).click();
// Choose a role
await page.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: role }).click();
await page.getByRole('button', { name: 'Invite' }).click();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: role }).click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`User ${users[index].email} added to the document.`),
).toBeVisible();
return users[index].email;
};
@@ -102,22 +97,24 @@ export const goToGridDoc = async (
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(docsGrid.getByTestId('grid-loader')).toBeHidden();
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
const rows = docsGrid.getByRole('row');
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
const rows = datagridTable.getByRole('row');
const row = title
? rows.filter({
hasText: title,
})
: rows.nth(nthRow);
await expect(row).toBeVisible();
const docTitleCell = row.getByRole('cell').nth(1);
const docTitle = await docTitleCell.textContent();
const docTitleContent = row.locator('[aria-describedby="doc-title"]').first();
const docTitle = await docTitleContent.textContent();
expect(docTitle).toBeDefined();
await row.getByRole('link').first().click();

View File

@@ -2,7 +2,7 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './common';
import { createDoc } from './common';
const config = {
CRISP_WEBSITE_ID: null,
@@ -129,8 +129,7 @@ test.describe('Config', () => {
browserName,
1,
);
await verifyDocName(page, randomDoc[0]);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain('ws://localhost:8083/collaboration/ws/');

View File

@@ -1,12 +1,6 @@
import { expect, test } from '@playwright/test';
import {
createDoc,
goToGridDoc,
keyCloakSignIn,
randomName,
verifyDocName,
} from './common';
import { createDoc, goToGridDoc, keyCloakSignIn, randomName } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -24,12 +18,15 @@ test.describe('Doc Create', () => {
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
await expect(page.getByTestId('grid-loader')).toBeVisible();
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden();
await expect(docsGrid.getByText(docTitle)).toBeVisible();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(datagridTable.getByText(docTitle)).toBeVisible({
timeout: 5000,
});
});
});
@@ -67,7 +64,7 @@ test.describe('Doc Create: Not loggued', () => {
await goToGridDoc(page, { title });
await verifyDocName(page, title);
await expect(page.getByRole('heading', { name: title })).toBeVisible();
const editor = page.locator('.ProseMirror');
await expect(editor.getByText('This is a normal text')).toBeVisible();

View File

@@ -2,12 +2,7 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import {
createDoc,
goToGridDoc,
mockedDocument,
verifyDocName,
} from './common';
import { createDoc, goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -103,7 +98,7 @@ test.describe('Doc Editor', () => {
});
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
await verifyDocName(page, randomDoc[0]);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
let webSocket = await webSocketPromise;
expect(webSocket.url()).toContain(
@@ -121,15 +116,17 @@ test.describe('Doc Editor', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
const selectVisibility = page.getByRole('combobox', {
name: 'Visibility',
});
// When the visibility is changed, the ws should close the connection (backend signal)
// When the visibility is changed, the ws should closed the connection (backend signal)
const wsClosePromise = webSocket.waitForEvent('close');
await selectVisibility.click();
await page
.getByRole('button', {
name: 'Connected',
.getByRole('option', {
name: 'Authenticated',
})
.click();
@@ -156,7 +153,7 @@ test.describe('Doc Editor', () => {
}) => {
const randomDoc = await createDoc(page, 'doc-markdown', browserName, 1);
await verifyDocName(page, randomDoc[0]);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
@@ -181,7 +178,7 @@ test.describe('Doc Editor', () => {
}) => {
// Check the first doc
const [firstDoc] = await createDoc(page, 'doc-switch-1', browserName, 1);
await verifyDocName(page, firstDoc);
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
@@ -190,8 +187,7 @@ test.describe('Doc Editor', () => {
// Check the second doc
const [secondDoc] = await createDoc(page, 'doc-switch-2', browserName, 1);
await verifyDocName(page, secondDoc);
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await expect(editor.getByText('Hello World Doc 1')).toBeHidden();
await editor.click();
await editor.fill('Hello World Doc 2');
@@ -201,18 +197,9 @@ test.describe('Doc Editor', () => {
await goToGridDoc(page, {
title: firstDoc,
});
await verifyDocName(page, firstDoc);
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
await expect(editor.getByText('Hello World Doc 2')).toBeHidden();
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
await page
.getByRole('button', {
name: 'New doc',
})
.click();
await expect(editor.getByText('Hello World Doc 1')).toBeHidden();
await expect(editor.getByText('Hello World Doc 2')).toBeHidden();
});
test('it saves the doc when we change pages', async ({
@@ -221,7 +208,7 @@ test.describe('Doc Editor', () => {
}) => {
// Check the first doc
const [doc] = await createDoc(page, 'doc-saves-change', browserName, 1);
await verifyDocName(page, doc);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
@@ -232,7 +219,7 @@ test.describe('Doc Editor', () => {
nthRow: 2,
});
await verifyDocName(page, secondDoc);
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await goToGridDoc(page, {
title: doc,
@@ -246,9 +233,8 @@ test.describe('Doc Editor', () => {
test.skip(browserName === 'webkit', 'This test is very flaky with webkit');
// Check the first doc
const doc = await goToGridDoc(page);
await verifyDocName(page, doc);
const [doc] = await createDoc(page, 'doc-quit-1', browserName, 1);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
@@ -281,10 +267,9 @@ test.describe('Doc Editor', () => {
await goToGridDoc(page);
const card = page.getByLabel('It is the card information');
await expect(card).toBeVisible();
await expect(card.getByText('Reader')).toBeVisible();
await expect(
page.getByText('Read only, you cannot edit this document.'),
).toBeVisible();
});
test('it adds an image to the doc editor', async ({ page, browserName }) => {

View File

@@ -3,44 +3,13 @@ import cs from 'convert-stream';
import jsdom from 'jsdom';
import pdf from 'pdf-parse';
import { createDoc, verifyDocName } from './common';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Export', () => {
test('it check if all elements are visible', async ({
page,
browserName,
}) => {
await createDoc(page, 'doc-editor', browserName, 1);
await page
.getByRole('button', {
name: 'download',
})
.click();
await expect(
page
.locator('div')
.filter({ hasText: /^Download$/ })
.first(),
).toBeVisible();
await expect(
page.getByText(
'Upload your docs to a Microsoft Word, Open Office or PDF document',
),
).toBeVisible();
await expect(
page.getByRole('combobox', { name: 'Template' }),
).toBeVisible();
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Close the modal' }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Download' })).toBeVisible();
});
test('it converts the doc to pdf with a template integrated', async ({
page,
browserName,
@@ -51,14 +20,15 @@ test.describe('Doc Export', () => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
await verifyDocName(page, randomDoc);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'download',
name: 'Export',
})
.click();
@@ -87,19 +57,19 @@ test.describe('Doc Export', () => {
return download.suggestedFilename().includes(`${randomDoc}.docx`);
});
await verifyDocName(page, randomDoc);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'download',
name: 'Export',
})
.click();
await page.getByRole('combobox', { name: 'Format' }).click();
await page.getByRole('option', { name: 'Word / Open Office' }).click();
await page.getByText('Docx').click();
await page
.getByRole('button', {
@@ -127,7 +97,7 @@ test.describe('Doc Export', () => {
await route.continue();
});
await verifyDocName(page, randomDoc);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.bn-block-outer').last().fill('Hello World');
await page.locator('.bn-block-outer').last().click();
@@ -220,9 +190,10 @@ test.describe('Doc Export', () => {
.click();
// Download
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'download',
name: 'Export',
})
.click();

View File

@@ -1,75 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './common';
type SmallDoc = {
id: string;
title: string;
};
test.describe('Document favorite', () => {
test('it check the favorite workflow', async ({ page, browserName }) => {
const id = Math.random().toString(7);
await page.goto('/');
// Create document
const createdDoc = await createDoc(page, `Doc ${id}`, browserName, 1);
await verifyDocName(page, createdDoc[0]);
// Reload page
await page.reload();
await page.goto('/');
// Get all documents
let docs: SmallDoc[] = [];
const response = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1') &&
response.status() === 200,
);
const result = await response.json();
docs = result.results as SmallDoc[];
const docsGrid = page.getByTestId('docs-grid');
await docsGrid.getByRole('heading', { name: 'All docs' }).click();
await expect(docsGrid.getByText(`Doc ${id}`)).toBeVisible();
const doc = docs.find((doc) => doc.title === createdDoc[0]) as SmallDoc;
// Check document
expect(doc).not.toBeUndefined();
expect(doc?.title).toBe(createdDoc[0]);
// Open document actions
const button = docsGrid.getByTestId(`docs-grid-actions-button-${doc.id}`);
await expect(button).toBeVisible();
await button.click();
// Pin document
const pinButton = page.getByTestId(`docs-grid-actions-pin-${docs[0].id}`);
await expect(pinButton).toBeVisible();
await pinButton.click();
// Check response
const responsePin = await page.waitForResponse(
(response) =>
response.url().includes(`documents/${doc.id}/favorite/`) &&
response.status() === 201,
);
expect(responsePin.ok()).toBeTruthy();
// Check left panel favorites
const leftPanelFavorites = page.getByTestId('left-panel-favorites');
await expect(leftPanelFavorites).toBeVisible();
await expect(leftPanelFavorites.getByText(`Doc ${id}`)).toBeVisible();
//
await button.click();
const unpinButton = page.getByTestId(
`docs-grid-actions-unpin-${docs[0].id}`,
);
await expect(unpinButton).toBeVisible();
await unpinButton.click();
// Check left panel favorites
await expect(leftPanelFavorites.getByText(`Doc ${id}`)).toBeHidden();
});
});

View File

@@ -1,14 +1,264 @@
import { expect, test } from '@playwright/test';
type SmallDoc = {
id: string;
title: string;
};
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Documents Grid', () => {
test('checks all the elements are visible', async ({ page }) => {
await expect(page.locator('h2').getByText('Documents')).toBeVisible();
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const thead = datagrid.locator('thead');
await expect(thead.getByText(/Document name/i)).toBeVisible();
await expect(thead.getByText(/Created at/i)).toBeVisible();
await expect(thead.getByText(/Updated at/i)).toBeVisible();
await expect(thead.getByText(/Your role/i)).toBeVisible();
await expect(thead.getByText(/Members/i)).toBeVisible();
const row1 = datagrid.getByRole('row').nth(1).getByRole('cell');
const docName = await row1.nth(1).textContent();
expect(docName).toBeDefined();
const docCreatedAt = await row1.nth(2).textContent();
expect(docCreatedAt).toBeDefined();
const docUpdatedAt = await row1.nth(3).textContent();
expect(docUpdatedAt).toBeDefined();
const docRole = await row1.nth(4).textContent();
expect(
docRole &&
['Administrator', 'Owner', 'Reader', 'Editor'].includes(docRole),
).toBeTruthy();
const docUserNumber = await row1.nth(5).textContent();
expect(docUserNumber).toBeDefined();
// Open the document
await row1.nth(1).click();
await expect(page.locator('h2').getByText(docName!)).toBeVisible();
});
[
{
nameColumn: 'Document name',
ordering: 'title',
cellNumber: 1,
orderDefault: '',
orderDesc: '&ordering=-title',
orderAsc: '&ordering=title',
defaultColumn: false,
},
{
nameColumn: 'Created at',
ordering: 'created_at',
cellNumber: 2,
orderDefault: '',
orderDesc: '&ordering=-created_at',
orderAsc: '&ordering=created_at',
defaultColumn: false,
},
{
nameColumn: 'Updated at',
ordering: 'updated_at',
cellNumber: 3,
orderDefault: '&ordering=-updated_at',
orderDesc: '&ordering=updated_at',
orderAsc: '',
defaultColumn: true,
},
].forEach(
({
nameColumn,
ordering,
cellNumber,
orderDefault,
orderDesc,
orderAsc,
defaultColumn,
}) => {
test(`checks datagrid ordering ${ordering}`, async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderDefault}`) &&
response.status() === 200,
);
const responsePromiseOrderingDesc = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderDesc}`) &&
response.status() === 200,
);
const responsePromiseOrderingAsc = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderAsc}`) &&
response.status() === 200,
);
// Checks the initial state
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
const thead = datagridTable.locator('thead');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const docNameRow1 = datagridTable
.getByRole('row')
.nth(1)
.getByRole('cell')
.nth(cellNumber);
const docNameRow2 = datagridTable
.getByRole('row')
.nth(2)
.getByRole('cell')
.nth(cellNumber);
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
// Initial state
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const initialDocNameRow1 = await docNameRow1.textContent();
const initialDocNameRow2 = await docNameRow2.textContent();
expect(initialDocNameRow1).toBeDefined();
expect(initialDocNameRow2).toBeDefined();
// Ordering ASC
await thead.getByText(nameColumn).click();
const responseOrderingAsc = await responsePromiseOrderingAsc;
expect(responseOrderingAsc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Asc = await docNameRow1.textContent();
const textDocNameRow2Asc = await docNameRow2.textContent();
const compare = (comp1: string, comp2: string) => {
const comparisonResult = comp1.localeCompare(comp2, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
});
// eslint-disable-next-line playwright/no-conditional-in-test
return defaultColumn ? comparisonResult >= 0 : comparisonResult <= 0;
};
expect(
textDocNameRow1Asc &&
textDocNameRow2Asc &&
compare(textDocNameRow1Asc, textDocNameRow2Asc),
).toBeTruthy();
// Ordering Desc
await thead.getByText(nameColumn).click();
const responseOrderingDesc = await responsePromiseOrderingDesc;
expect(responseOrderingDesc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Desc = await docNameRow1.textContent();
const textDocNameRow2Desc = await docNameRow2.textContent();
expect(
textDocNameRow1Desc &&
textDocNameRow2Desc &&
compare(textDocNameRow2Desc, textDocNameRow1Desc),
).toBeTruthy();
});
},
);
test('checks the pagination', async ({ page }) => {
const responsePromisePage1 = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1`) &&
response.status() === 200,
);
const responsePromisePage2 = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=2`) &&
response.status() === 200,
);
const datagridPage1 = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const responsePage1 = await responsePromisePage1;
expect(responsePage1.ok()).toBeTruthy();
await expect(
datagridPage1.getByRole('row').nth(1).getByRole('cell').nth(1),
).toHaveText(/.*/);
await page.getByLabel('Go to page 2').click();
const datagridPage2 = page
.getByLabel('Datagrid of the documents page 2')
.getByRole('table');
const responsePage2 = await responsePromisePage2;
expect(responsePage2.ok()).toBeTruthy();
await expect(
datagridPage2.getByRole('row').nth(1).getByRole('cell').nth(1),
).toHaveText(/.*/);
});
test('it deletes the document', async ({ page }) => {
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const docRow = datagrid.getByRole('row').nth(1).getByRole('cell');
const docName = await docRow.nth(1).textContent();
await docRow
.getByRole('button', {
name: 'Delete the document',
})
.click();
await expect(
page.locator('h2').getByText(`Deleting the document "${docName}"`),
).toBeVisible();
await page
.getByRole('button', {
name: 'Confirm deletion',
})
.click();
await expect(
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(datagrid.getByText(docName!)).toBeHidden();
});
});
test.describe('Documents Grid mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
@@ -76,256 +326,19 @@ test.describe('Documents Grid mobile', () => {
await page.goto('/');
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('grid-loader')).toBeHidden();
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const tableDatagrid = datagrid.getByRole('table');
const rows = docsGrid.getByRole('row');
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
const rows = tableDatagrid.getByRole('row');
const row = rows.filter({
hasText: 'My mocked document',
});
await expect(
row.locator('[aria-describedby="doc-title"]').nth(0),
).toHaveText('My mocked document');
});
});
test.describe('Document grid item options', () => {
test('it deletes the document', async ({ page }) => {
let docs: SmallDoc[] = [];
const response = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1') &&
response.status() === 200,
);
const result = await response.json();
docs = result.results as SmallDoc[];
const button = page.getByTestId(`docs-grid-actions-button-${docs[0].id}`);
await expect(button).toBeVisible();
await button.click();
const removeButton = page.getByTestId(
`docs-grid-actions-remove-${docs[0].id}`,
);
await expect(removeButton).toBeVisible();
await removeButton.click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),
).toBeVisible();
await page
.getByRole('button', {
name: 'Confirm deletion',
})
.click();
const refetchResponse = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1') &&
response.status() === 200,
);
const resultRefetch = await refetchResponse.json();
expect(resultRefetch.count).toBe(result.count - 1);
await expect(page.getByTestId('main-layout-loader')).toBeHidden();
await expect(
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(button).toBeHidden();
});
test("it checks if the delete option is disabled if we don't have the destroy capability", async ({
page,
}) => {
await page.route('*/**/api/v1.0/documents/?page=1', async (route) => {
await route.fulfill({
json: {
results: [
{
id: 'mocked-document-id',
content: '',
title: 'Mocked document',
accesses: [],
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,
},
link_reach: 'restricted',
created_at: '2021-09-01T09:00:00Z',
},
],
},
});
});
await page.goto('/');
const button = page.getByTestId(
`docs-grid-actions-button-mocked-document-id`,
);
await expect(button).toBeVisible();
await button.click();
const removeButton = page.getByTestId(
`docs-grid-actions-remove-mocked-document-id`,
);
await expect(removeButton).toBeVisible();
await removeButton.isDisabled();
});
});
test.describe('Documents filters', () => {
test('it checks the prebuild left panel filters', async ({ page }) => {
// All Docs
await expect(page.getByTestId('grid-loader')).toBeVisible();
const response = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1') &&
response.status() === 200,
);
const result = await response.json();
const allCount = result.count as number;
await expect(page.getByTestId('grid-loader')).toBeHidden();
const allDocs = page.getByLabel('All docs');
const myDocs = page.getByLabel('My docs');
const sharedWithMe = page.getByLabel('Shared with me');
// Initial state
await expect(allDocs).toBeVisible();
await expect(allDocs).toHaveCSS('background-color', 'rgb(238, 238, 238)');
await expect(allDocs).toHaveAttribute('aria-selected', 'true');
await expect(myDocs).toBeVisible();
await expect(myDocs).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
await expect(myDocs).toHaveAttribute('aria-selected', 'false');
await expect(sharedWithMe).toBeVisible();
await expect(sharedWithMe).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
await expect(sharedWithMe).toHaveAttribute('aria-selected', 'false');
await allDocs.click();
let url = new URL(page.url());
let target = url.searchParams.get('target');
expect(target).toBe('all_docs');
// My docs
await myDocs.click();
url = new URL(page.url());
target = url.searchParams.get('target');
expect(target).toBe('my_docs');
await expect(page.getByTestId('grid-loader')).toBeVisible();
const responseMyDocs = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1&is_creator_me=true') &&
response.status() === 200,
);
const resultMyDocs = await responseMyDocs.json();
const countMyDocs = resultMyDocs.count as number;
await expect(page.getByTestId('grid-loader')).toBeHidden();
expect(countMyDocs).toBeLessThanOrEqual(allCount);
// Shared with me
await sharedWithMe.click();
url = new URL(page.url());
target = url.searchParams.get('target');
expect(target).toBe('shared_with_me');
await expect(page.getByTestId('grid-loader')).toBeVisible();
const responseSharedWithMe = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1&is_creator_me=false') &&
response.status() === 200,
);
const resultSharedWithMe = await responseSharedWithMe.json();
const countSharedWithMe = resultSharedWithMe.count as number;
await expect(page.getByTestId('grid-loader')).toBeHidden();
expect(countSharedWithMe).toBeLessThanOrEqual(allCount);
expect(countSharedWithMe + countMyDocs).toEqual(allCount);
});
});
test.describe('Documents Grid', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the elements are visible', async ({ page }) => {
let docs: SmallDoc[] = [];
await expect(page.getByTestId('grid-loader')).toBeVisible();
const response = await page.waitForResponse(
(response) =>
response.url().endsWith('documents/?page=1') &&
response.status() === 200,
);
const result = await response.json();
docs = result.results as SmallDoc[];
await expect(page.getByTestId('grid-loader')).toBeHidden();
await expect(page.locator('h4').getByText('All docs')).toBeVisible();
const thead = page.getByTestId('docs-grid-header');
await expect(thead.getByText(/Name/i)).toBeVisible();
await expect(thead.getByText(/Updated at/i)).toBeVisible();
await Promise.all(
docs.map(async (doc) => {
await expect(
page.getByTestId(`docs-grid-name-${doc.id}`),
).toBeVisible();
}),
);
});
test('checks the infinite scroll', async ({ page }) => {
let docs: SmallDoc[] = [];
const responsePromisePage1 = page.waitForResponse(
(response) =>
response.url().endsWith(`/documents/?page=1`) &&
response.status() === 200,
);
const responsePromisePage2 = page.waitForResponse(
(response) =>
response.url().endsWith(`/documents/?page=2`) &&
response.status() === 200,
);
const responsePage1 = await responsePromisePage1;
expect(responsePage1.ok()).toBeTruthy();
let result = await responsePage1.json();
docs = result.results as SmallDoc[];
await Promise.all(
docs.map(async (doc) => {
await expect(
page.getByTestId(`docs-grid-name-${doc.id}`),
).toBeVisible();
}),
);
await page.getByTestId('infinite-scroll-trigger').scrollIntoViewIfNeeded();
const responsePage2 = await responsePromisePage2;
result = await responsePage2.json();
docs = result.results as SmallDoc[];
await Promise.all(
docs.map(async (doc) => {
await expect(
page.getByTestId(`docs-grid-name-${doc.id}`),
).toBeVisible();
}),
);
await expect(row.getByRole('cell').nth(0)).toHaveText('My mocked document');
await expect(row.getByRole('cell').nth(1)).toHaveText('Public');
});
});

View File

@@ -6,7 +6,6 @@ import {
mockedAccesses,
mockedDocument,
mockedInvitations,
verifyDocName,
} from './common';
test.beforeEach(async ({ page }) => {
@@ -60,31 +59,84 @@ test.describe('Doc Header', () => {
const card = page.getByLabel(
'It is the card information about the document.',
);
const docTitle = card.getByRole('textbox', { name: 'doc title input' });
await expect(docTitle).toBeVisible();
await expect(card.getByText('Public document')).toBeVisible();
await expect(card.getByText('Owner ·')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await expect(card.locator('a').getByText('home')).toBeVisible();
await expect(card.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(card.getByText('Public')).toBeVisible();
await expect(
page.getByRole('button', { name: 'Open the document options' }),
card.getByText('Created at 09/01/2021, 11:00 AM'),
).toBeVisible();
await expect(card.getByText('Your role: Owner')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
test('it updates the title doc', async ({ page, browserName }) => {
await createDoc(page, 'doc-update', browserName, 1);
const docTitle = page.getByRole('textbox', { name: 'doc title input' });
await expect(docTitle).toBeVisible();
await docTitle.fill('Hello World');
await docTitle.blur();
await verifyDocName(page, 'Hello World');
const [randomDoc] = await createDoc(page, 'doc-update', browserName, 1);
await page.getByRole('heading', { name: randomDoc }).fill(' ');
await page.getByText('Created at').click();
await expect(
page.getByRole('heading', { name: 'Untitled document' }),
).toBeVisible();
});
test('it updates the title doc from editor heading', async ({ page }) => {
await page
.getByRole('button', {
name: 'Create a new document',
})
.click();
const docHeader = page.getByLabel(
'It is the card information about the document.',
);
await expect(
docHeader.getByRole('heading', { name: 'Untitled document', level: 2 }),
).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.locator('h1').click();
await page.keyboard.type('Hello World', { delay: 100 });
await expect(
docHeader.getByRole('heading', { name: 'Hello World', level: 2 }),
).toBeVisible();
await expect(
page.getByText('Document title updated successfully'),
).toBeVisible();
await docHeader
.getByRole('heading', { name: 'Hello World', level: 2 })
.fill('Top World');
await editor.locator('h1').fill('Super World');
await expect(
docHeader.getByRole('heading', { name: 'Top World', level: 2 }),
).toBeVisible();
await editor.locator('h1').fill('');
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(500);
await docHeader
.getByRole('heading', { name: 'Top World', level: 2 })
.fill(' ');
await page.getByText('Created at').click();
await expect(
docHeader.getByRole('heading', { name: 'Untitled document', level: 2 }),
).toBeVisible();
});
test('it deletes the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
@@ -94,13 +146,7 @@ test.describe('Doc Header', () => {
.click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),
).toBeVisible();
await expect(
page.getByText(
`Are you sure you want to delete the document "${randomDoc}"?`,
),
page.locator('h2').getByText(`Deleting the document "${randomDoc}"`),
).toBeVisible();
await page
@@ -113,7 +159,9 @@ test.describe('Doc Header', () => {
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(page.getByRole('button', { name: 'New do' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Create a new document' }),
).toBeVisible();
const row = page
.getByLabel('Datagrid of the documents page 1')
@@ -147,13 +195,16 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await expect(
page.locator('h2').getByText('Mocked document'),
).toHaveAttribute('contenteditable');
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -161,40 +212,34 @@ test.describe('Doc Header', () => {
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share modal');
await expect(shareModal).toBeVisible();
await expect(page.getByText('Share the document')).toBeVisible();
await expect(page.getByPlaceholder('Type a name or email')).toBeVisible();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).not.toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeVisible();
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(invitationCard).toBeVisible();
await expect(
invitationCard.getByText('test@invitation.test').first(),
invitationCard.getByText('test@invitation.test'),
).toBeVisible();
await expect(invitationCard.getByLabel('doc-role-dropdown')).toBeVisible();
await invitationCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(
page.getByRole('button', {
invitationCard.getByRole('combobox', { name: 'Role' }),
).toBeEnabled();
await expect(
invitationCard.getByRole('button', {
name: 'delete',
}),
).toBeEnabled();
await invitationCard.click();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard).toBeVisible();
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(
memberCard.getByText('test@accesses.test').first(),
).toBeVisible();
await expect(memberCard.getByLabel('doc-role-dropdown')).toBeVisible();
memberCard.getByRole('combobox', { name: 'Role' }),
).toBeEnabled();
await expect(
memberCard.getByRole('button', { name: 'more_horiz' }),
).toBeVisible();
await memberCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(
page.getByRole('button', {
memberCard.getByRole('button', {
name: 'delete',
}),
).toBeEnabled();
@@ -228,12 +273,16 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await expect(
page.locator('h2').getByText('Mocked document'),
).toHaveAttribute('contenteditable');
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -241,24 +290,36 @@ test.describe('Doc Header', () => {
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share modal');
await expect(page.getByText('Share the document')).toBeVisible();
await expect(page.getByPlaceholder('Type a name or email')).toBeHidden();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(
invitationCard.getByText('test@invitation.test').first(),
invitationCard.getByText('test@invitation.test'),
).toBeVisible();
await expect(invitationCard.getByLabel('doc-role-text')).toBeVisible();
await expect(
invitationCard.getByRole('button', { name: 'more_horiz' }),
invitationCard.getByRole('combobox', { name: 'Role' }),
).toHaveAttribute('disabled');
await expect(
invitationCard.getByRole('button', {
name: 'delete',
}),
).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(memberCard.getByLabel('doc-role-text')).toBeVisible();
await expect(
memberCard.getByRole('button', { name: 'more_horiz' }),
memberCard.getByRole('combobox', { name: 'Role' }),
).toHaveAttribute('disabled');
await expect(
memberCard.getByRole('button', {
name: 'delete',
}),
).toBeHidden();
});
@@ -290,12 +351,16 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await expect(
page.locator('h2').getByText('Mocked document'),
).not.toHaveAttribute('contenteditable');
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -303,24 +368,36 @@ test.describe('Doc Header', () => {
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share modal');
await expect(page.getByText('Share the document')).toBeVisible();
await expect(page.getByPlaceholder('Type a name or email')).toBeHidden();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(
invitationCard.getByText('test@invitation.test').first(),
invitationCard.getByText('test@invitation.test'),
).toBeVisible();
await expect(invitationCard.getByLabel('doc-role-text')).toBeVisible();
await expect(
invitationCard.getByRole('button', { name: 'more_horiz' }),
invitationCard.getByRole('combobox', { name: 'Role' }),
).toHaveAttribute('disabled');
await expect(
invitationCard.getByRole('button', {
name: 'delete',
}),
).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(memberCard.getByLabel('doc-role-text')).toBeVisible();
await expect(
memberCard.getByRole('button', { name: 'more_horiz' }),
memberCard.getByRole('combobox', { name: 'Role' }),
).toHaveAttribute('disabled');
await expect(
memberCard.getByRole('button', {
name: 'delete',
}),
).toBeHidden();
});
@@ -337,7 +414,7 @@ test.describe('Doc Header', () => {
// create page and navigate to it
await page
.getByRole('button', {
name: 'New doc',
name: 'Create a new document',
})
.click();
@@ -372,7 +449,7 @@ test.describe('Doc Header', () => {
// create page and navigate to it
await page
.getByRole('button', {
name: 'New doc',
name: 'Create a new document',
})
.click();
@@ -394,7 +471,9 @@ test.describe('Doc Header', () => {
navigator.clipboard.readText(),
);
const clipboardContent = await handle.jsonValue();
expect(clipboardContent.trim()).toBe(`<h1>Hello World</h1><p></p>`);
expect(clipboardContent.trim()).toBe(
`<h1 data-level="1">Hello World</h1><p></p>`,
);
});
});
@@ -422,7 +501,6 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Share' }).click();
await expect(page.getByLabel('Share modal')).toBeVisible();

View File

@@ -16,82 +16,163 @@ test.describe('Document create member', () => {
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const inputSearch = page.getByLabel(/Find a member to add to the document/);
await expect(inputSearch).toBeVisible();
// Select user 1 and verify tag
// Select user 1
await inputSearch.fill('user');
const response = await responsePromise;
const users = (await response.json()).results as {
email: string;
full_name: string;
}[];
const list = page.getByTestId('doc-share-add-member-list');
await expect(list).toBeHidden();
const quickSearchContent = page.getByTestId('doc-share-quick-search');
await quickSearchContent
.getByTestId(`search-user-row-${users[0].email}`)
.click();
await page.getByRole('option', { name: users[0].email }).click();
await expect(list).toBeVisible();
await expect(
list.getByTestId(`doc-share-add-member-${users[0].email}`),
).toBeVisible();
await expect(list.getByText(`${users[0].full_name}`)).toBeVisible();
// Select user 2 and verify tag
// Select user 2
await inputSearch.fill('user');
await quickSearchContent
.getByTestId(`search-user-row-${users[1].email}`)
.click();
await page.getByRole('option', { name: users[1].email }).click();
await expect(
list.getByTestId(`doc-share-add-member-${users[1].email}`),
).toBeVisible();
await expect(list.getByText(`${users[1].full_name}`)).toBeVisible();
// Select email and verify tag
// Select email
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
await quickSearchContent.getByText(email).click();
await expect(list.getByText(email)).toBeVisible();
await page.getByRole('option', { name: email }).click();
// Check user 1 tag
await expect(
page.getByText(`${users[0].email}`, { exact: true }),
).toBeVisible();
await expect(page.getByLabel(`Remove ${users[0].email}`)).toBeVisible();
// Check user 2 tag
await expect(
page.getByText(`${users[1].email}`, { exact: true }),
).toBeVisible();
await expect(page.getByLabel(`Remove ${users[1].email}`)).toBeVisible();
// Check invitation tag
await expect(page.getByText(email, { exact: true })).toBeVisible();
await expect(page.getByLabel(`Remove ${email}`)).toBeVisible();
// Check roles are displayed
await list.getByLabel('doc-role-dropdown').click();
await expect(page.getByRole('button', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Editor' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Owner' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Administrator' }),
).toBeVisible();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
// Validate
await page.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
// Check invitation added
await expect(page.getByRole('option', { name: 'Reader' })).toBeVisible();
await expect(page.getByRole('option', { name: 'Editor' })).toBeVisible();
await expect(
quickSearchContent.getByText('Pending invitations'),
page.getByRole('option', { name: 'Administrator' }),
).toBeVisible();
await expect(quickSearchContent.getByText(email).first()).toBeVisible();
await expect(page.getByRole('option', { name: 'Owner' })).toBeVisible();
});
test('it sends a new invitation and adds a new user', async ({
page,
browserName,
}) => {
const responsePromiseSearchUser = page.waitForResponse(
(response) =>
response.url().includes('/users/?q=user') && response.status() === 200,
);
await createDoc(page, 'user-invitation', browserName, 1);
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
await page.getByRole('option', { name: email }).click();
// Select a new user
await inputSearch.fill('user');
const responseSearchUser = await responsePromiseSearchUser;
const [user] = (await responseSearchUser.json()).results as {
email: string;
}[];
await page.getByRole('option', { name: user.email }).click();
// Choose a role
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
const responsePromiseAddUser = page.waitForResponse(
(response) =>
response.url().includes('/accesses/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Validate' }).click();
// Check invitation sent
await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
expect(
responseCreateInvitation.request().headers()['content-language'],
).toBe('en-us');
// Check user added
await expect(page.getByText('Share with 3 users')).toBeVisible();
await expect(
quickSearchContent.getByText(users[0].full_name).first(),
page.getByText(`User ${user.email} added to the document.`),
).toBeVisible();
const responseAddUser = await responsePromiseAddUser;
expect(responseAddUser.ok()).toBeTruthy();
expect(responseAddUser.request().headers()['content-language']).toBe(
'en-us',
);
const listInvitation = page.getByLabel('List invitation card');
await expect(listInvitation.locator('li').getByText(email)).toBeVisible();
await expect(
quickSearchContent.getByText(users[0].email).first(),
listInvitation.locator('li').getByText('Invited'),
).toBeVisible();
const listMember = page.getByLabel('List members card');
await expect(listMember.locator('li').getByText(user.email)).toBeVisible();
});
test('it try to add twice the same user', async ({ page, browserName }) => {
const responsePromiseSearchUser = page.waitForResponse(
(response) =>
response.url().includes('/users/?q=user') && response.status() === 200,
);
await createDoc(page, 'user-twice', browserName, 1);
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByLabel(/Find a member to add to the document/);
await inputSearch.fill('user');
const responseSearchUser = await responsePromiseSearchUser;
const [user] = (await responseSearchUser.json()).results as {
email: string;
}[];
await page.getByRole('option', { name: user.email }).click();
// Choose a role
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Owner' }).click();
const responsePromiseAddMember = page.waitForResponse(
(response) =>
response.url().includes('/accesses/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
quickSearchContent.getByText(users[1].email).first(),
).toBeVisible();
await expect(
quickSearchContent.getByText(users[1].full_name).first(),
page.getByText(`User ${user.email} added to the document.`),
).toBeVisible();
const responseAddMember = await responsePromiseAddMember;
expect(responseAddMember.ok()).toBeTruthy();
await inputSearch.fill('user');
await expect(page.getByText('Loading...')).toBeHidden();
await expect(page.getByRole('option', { name: user.email })).toBeHidden();
});
test('it try to add twice the same invitation', async ({
@@ -102,43 +183,40 @@ test.describe('Document create member', () => {
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const inputSearch = page.getByLabel(/Find a member to add to the document/);
const [email] = randomName('test@test.fr', browserName, 1);
await inputSearch.fill(email);
await page.getByTestId(`search-user-row-${email}`).click();
await page.getByRole('option', { name: email }).click();
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: 'Owner' }).click();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Owner' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Invite' }).click();
await page.getByRole('button', { name: 'Validate' }).click();
// Check invitation sent
await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
await inputSearch.fill(email);
await page.getByTestId(`search-user-row-${email}`).click();
await page.getByRole('option', { name: email }).click();
// Choose a role
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: 'Owner' }).click();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Owner' }).click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 400,
);
await page.getByRole('button', { name: 'Invite' }).click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`"${email}" is already invited to the document.`),
).toBeVisible();
@@ -155,32 +233,31 @@ test.describe('Document create member', () => {
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('EN').click();
await header.getByRole('option', { name: 'translate Français' }).click();
await header.getByRole('option', { name: 'FR' }).click();
await page.getByRole('button', { name: 'Partager' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const inputSearch = page.getByLabel(
/Trouver un membre à ajouter au document/,
);
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
await page.getByTestId(`search-user-row-${email}`).click();
await page.getByRole('option', { name: email }).click();
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: 'Administrateur' }).click();
await page.getByRole('combobox', { name: /Choisissez un rôle/ }).click();
await page.getByRole('option', { name: 'Administrateur' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Invite' }).click();
await page.getByRole('button', { name: 'Valider' }).click();
// Check invitation sent
await expect(page.getByText(`Invitation envoyée à ${email}`)).toBeVisible();
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
expect(
@@ -193,46 +270,41 @@ test.describe('Document create member', () => {
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const inputSearch = page.getByLabel(/Find a member to add to the document/);
const email = randomName('test@test.fr', browserName, 1)[0];
await inputSearch.fill(email);
await page.getByTestId(`search-user-row-${email}`).click();
await page.getByRole('option', { name: email }).click();
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Administrator' }).click();
const responsePromiseCreateInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 201,
);
await page.getByRole('button', { name: 'Invite' }).click();
await page.getByRole('button', { name: 'Validate' }).click();
// Check invitation sent
await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
const responseCreateInvitation = await responsePromiseCreateInvitation;
expect(responseCreateInvitation.ok()).toBeTruthy();
const listInvitation = page.getByTestId('doc-share-quick-search');
const userInvitation = listInvitation.getByTestId(
`doc-share-invitation-row-${email}`,
);
await expect(userInvitation).toBeVisible();
await userInvitation.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: 'Reader' }).click();
const moreActions = userInvitation.getByRole('button', {
name: 'more_horiz',
const listInvitation = page.getByLabel('List invitation card');
const li = listInvitation.locator('li').filter({
hasText: email,
});
await moreActions.click();
await expect(li.getByText(email)).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(userInvitation).toBeHidden();
await li.getByRole('combobox', { name: /Role/ }).click();
await li.getByRole('option', { name: 'Reader' }).click();
await expect(page.getByText(`The role has been updated.`)).toBeVisible();
await li.getByText('delete').click();
await expect(
page.getByText(`The invitation has been removed.`),
).toBeVisible();
await expect(listInvitation.locator('li').getByText(email)).toBeHidden();
});
});

View File

@@ -1,6 +1,8 @@
import { expect, test } from '@playwright/test';
import { addNewMember, createDoc, goToGridDoc, verifyDocName } from './common';
import { waitForElementCount } from '../helpers';
import { addNewMember, createDoc, goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -13,11 +15,10 @@ test.describe('Document list members', () => {
async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page') ?? '1';
const pageId = url.searchParams.get('page');
const accesses = {
count: 40,
next: +pageId < 2 ? 'http://anything/?page=2' : undefined,
count: 100,
next: 'http://anything/?page=2',
previous: null,
results: Array.from({ length: 20 }, (_, i) => ({
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
@@ -46,23 +47,26 @@ test.describe('Document list members', () => {
},
);
const docTitle = await goToGridDoc(page);
await verifyDocName(page, docTitle);
await goToGridDoc(page);
await page.getByRole('button', { name: 'Share' }).click();
const prefix = 'doc-share-member-row';
const elements = page.locator(`[data-testid^="${prefix}"]`);
const loadMore = page.getByTestId('load-more-members');
const list = page.getByLabel('List members card').locator('ul');
await expect(list.locator('li')).toHaveCount(20);
await list.getByText(`impress@impress.world-page-${1}-18`).hover();
await page.mouse.wheel(0, 10);
await expect(elements).toHaveCount(20);
await expect(page.getByText(`Impress World Page 1-16`)).toBeVisible();
await waitForElementCount(list.locator('li'), 21, 10000);
await loadMore.click();
await expect(elements).toHaveCount(40);
await expect(page.getByText(`Impress World Page 2-15`)).toBeVisible();
await expect(loadMore).toBeHidden();
expect(await list.locator('li').count()).toBeGreaterThan(20);
await expect(list.getByText(`Impress World Page 1-16`)).toBeVisible();
await expect(
list.getByText(`impress@impress.world-page-1-16`),
).toBeVisible();
await expect(list.getByText(`Impress World Page 2-15`)).toBeVisible();
await expect(
list.getByText(`impress@impress.world-page-2-15`),
).toBeVisible();
});
test('it checks a big list of invitations', async ({ page }) => {
@@ -71,10 +75,10 @@ test.describe('Document list members', () => {
async (route) => {
const request = route.request();
const url = new URL(request.url());
const pageId = url.searchParams.get('page') ?? '1';
const pageId = url.searchParams.get('page');
const accesses = {
count: 40,
next: +pageId < 2 ? 'http://anything/?page=2' : null,
count: 100,
next: 'http://anything/?page=2',
previous: null,
results: Array.from({ length: 20 }, (_, i) => ({
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
@@ -100,128 +104,131 @@ test.describe('Document list members', () => {
},
);
const docTitle = await goToGridDoc(page);
await verifyDocName(page, docTitle);
await goToGridDoc(page);
await page.getByRole('button', { name: 'Share' }).click();
const prefix = 'doc-share-invitation';
const elements = page.locator(`[data-testid^="${prefix}"]`);
const loadMore = page.getByTestId('load-more-invitations');
const list = page.getByLabel('List invitation card').locator('ul');
await expect(list.locator('li')).toHaveCount(20);
await list.getByText(`impress@impress.world-page-${1}-18`).hover();
await page.mouse.wheel(0, 10);
await expect(elements).toHaveCount(20);
await waitForElementCount(list.locator('li'), 21, 10000);
expect(await list.locator('li').count()).toBeGreaterThan(20);
await expect(
page.getByText(`impress@impress.world-page-1-16`).first(),
list.getByText(`impress@impress.world-page-1-16`),
).toBeVisible();
await loadMore.click();
await expect(elements).toHaveCount(40);
await expect(
page.getByText(`impress@impress.world-page-2-16`).first(),
list.getByText(`impress@impress.world-page-2-15`),
).toBeVisible();
await expect(loadMore).toBeHidden();
});
test('it checks the role rules', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
await verifyDocName(page, docTitle);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByTestId('doc-share-quick-search');
await expect(list).toBeVisible();
const currentUser = list.getByTestId(
`doc-share-member-row-user@chromium.e2e`,
);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
await expect(currentUser).toBeVisible();
await expect(currentUserRole).toBeVisible();
await currentUserRole.click();
const soloOwner = page.getByText(
const list = page.getByLabel('List members card').locator('ul');
await expect(list.getByText(`user@${browserName}.e2e`)).toBeVisible();
const soleOwner = list.getByText(
`You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.`,
);
await expect(soloOwner).toBeVisible();
await list.click();
const newUserEmail = await addNewMember(page, 0, 'Owner');
const newUser = list.getByTestId(`doc-share-member-row-${newUserEmail}`);
const newUserRoles = newUser.getByLabel('doc-role-dropdown');
await expect(newUser).toBeVisible();
await expect(soleOwner).toBeVisible();
await currentUserRole.click();
await expect(soloOwner).toBeHidden();
await list.click();
const username = await addNewMember(page, 0, 'Owner');
const otherOwner = page.getByText(
await expect(list.getByText(username)).toBeVisible();
await expect(soleOwner).toBeHidden();
const otherOwner = list.getByText(
`You cannot update the role or remove other owner.`,
);
await newUserRoles.click();
await expect(otherOwner).toBeVisible();
await list.click();
await currentUserRole.click();
await page.getByRole('button', { name: 'Administrator' }).click();
await list.click();
await expect(currentUserRole).toBeVisible();
const SelectRoleCurrentUser = list
.locator('li')
.filter({
hasText: `user@${browserName}.e2e`,
})
.getByRole('combobox', { name: 'Role' });
await currentUserRole.click();
await page.getByRole('button', { name: 'Reader' }).click();
await list.click();
await expect(currentUserRole).toBeHidden();
await SelectRoleCurrentUser.click();
await page.getByRole('option', { name: 'Administrator' }).click();
await expect(page.getByText('The role has been updated')).toBeVisible();
const shareModal = page.getByLabel('Share modal');
// Admin still have the right to share
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).not.toHaveAttribute('disabled');
await SelectRoleCurrentUser.click();
await page.getByRole('option', { name: 'Reader' }).click();
await expect(page.getByText('The role has been updated')).toBeVisible();
// Reader does not have the right to share
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
});
test('it checks the delete members', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
await verifyDocName(page, docTitle);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const list = page.getByTestId('doc-share-quick-search');
const list = page.getByLabel('List members card').locator('ul');
const emailMyself = `user@${browserName}.e2e`;
const mySelf = list.getByTestId(`doc-share-member-row-${emailMyself}`);
const mySelfMoreActions = mySelf.getByRole('button', {
name: 'more_horiz',
});
const nameMyself = `user@${browserName}.e2e`;
await expect(list.getByText(nameMyself)).toBeVisible();
const userOwnerEmail = await addNewMember(page, 0, 'Owner');
const userOwner = list.getByTestId(
`doc-share-member-row-${userOwnerEmail}`,
);
const userOwnerMoreActions = userOwner.getByRole('button', {
name: 'more_horiz',
});
const userOwner = await addNewMember(page, 0, 'Owner');
await expect(list.getByText(userOwner)).toBeVisible();
await page.getByRole('button', { name: 'close' }).first().click();
await page.getByRole('button', { name: 'Share' }).first().click();
const userReader = await addNewMember(page, 0, 'Reader');
await expect(list.getByText(userReader)).toBeVisible();
const userReaderEmail = await addNewMember(page, 0, 'Reader');
await list
.locator('li')
.filter({
hasText: userReader,
})
.getByText('delete')
.click();
const userReader = list.getByTestId(
`doc-share-member-row-${userReaderEmail}`,
);
const userReaderMoreActions = userReader.getByRole('button', {
name: 'more_horiz',
});
await expect(list.getByText(userReader)).toBeHidden();
await expect(mySelf).toBeVisible();
await expect(userOwner).toBeVisible();
await expect(userReader).toBeVisible();
await list
.locator('li')
.filter({
hasText: nameMyself,
})
.getByText('delete')
.click();
await expect(userOwnerMoreActions).toBeVisible();
await expect(userReaderMoreActions).toBeVisible();
await expect(mySelfMoreActions).toBeVisible();
await expect(list.getByText(nameMyself)).toBeHidden();
await userReaderMoreActions.click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(userReader).toBeHidden();
await mySelfMoreActions.click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(
page.getByText('You do not have permission to perform this action.'),
page.getByText('The member has been removed from the document').first(),
).toBeVisible();
await expect(
page.getByRole('heading', { name: 'Share', level: 3 }),
).toBeHidden();
});
});

View File

@@ -9,7 +9,7 @@ test.describe('Doc Routing', () => {
test('Check the presence of the meta tag noindex', async ({ page }) => {
const buttonCreateHomepage = page.getByRole('button', {
name: 'New doc',
name: 'Create a new document',
});
await expect(buttonCreateHomepage).toBeVisible();
@@ -27,7 +27,7 @@ test.describe('Doc Routing', () => {
await expect(page).toHaveURL('/');
const buttonCreateHomepage = page.getByRole('button', {
name: 'New doc',
name: 'Create a new document',
});
await expect(buttonCreateHomepage).toBeVisible();

View File

@@ -1,114 +0,0 @@
import { expect, test } from '@playwright/test';
import { DateTime } from 'luxon';
import { createDoc, verifyDocName } from './common';
type SmallDoc = {
id: string;
title: string;
updated_at: string;
};
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Document search', () => {
test('it checks all elements are visible', async ({ page }) => {
await page.getByRole('button', { name: 'search' }).click();
await expect(
page.getByRole('img', { name: 'No active search' }),
).toBeVisible();
await expect(
page.getByLabel('Search modal').getByText('search'),
).toBeVisible();
await expect(
page.getByPlaceholder('Type the name of a document'),
).toBeVisible();
});
test('it checks search for a document', async ({ page, browserName }) => {
const id = Math.random().toString(36).substring(7);
const doc1 = await createDoc(page, `My super ${id} doc`, browserName, 1);
await verifyDocName(page, doc1[0]);
await page.goto('/');
const doc2 = await createDoc(
page,
`My super ${id} very doc`,
browserName,
1,
);
await verifyDocName(page, doc2[0]);
await page.goto('/');
await page.getByRole('button', { name: 'search' }).click();
await page.getByPlaceholder('Type the name of a document').click();
await page
.getByPlaceholder('Type the name of a document')
.fill(`My super ${id}`);
let responsePromisePage = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1&title=My+super+${id}`) &&
response.status() === 200,
);
let response = await responsePromisePage;
let result = (await response.json()) as { results: SmallDoc[] };
let docs = result.results;
expect(docs.length).toEqual(2);
await Promise.all(
docs.map(async (doc: SmallDoc) => {
await expect(
page.getByTestId(`doc-search-item-${doc.id}`),
).toBeVisible();
const updatedAt = DateTime.fromISO(doc.updated_at ?? DateTime.now())
.setLocale('en')
.toRelative();
await expect(
page.getByTestId(`doc-search-item-${doc.id}`).getByText(updatedAt!),
).toBeVisible();
}),
);
const firstDoc = docs[0];
await expect(
page
.getByTestId(`doc-search-item-${firstDoc.id}`)
.getByText('keyboard_return'),
).toBeVisible();
await page
.getByPlaceholder('Type the name of a document')
.press('ArrowDown');
const secondDoc = docs[1];
await expect(
page
.getByTestId(`doc-search-item-${secondDoc.id}`)
.getByText('keyboard_return'),
).toBeVisible();
await page.getByPlaceholder('Type the name of a document').click();
await page
.getByPlaceholder('Type the name of a document')
.fill(`My super ${id} doc`);
responsePromisePage = page.waitForResponse(
(response) =>
response
.url()
.includes(`/documents/?page=1&title=My+super+${id}+doc`) &&
response.status() === 200,
);
response = await responsePromisePage;
result = (await response.json()) as { results: SmallDoc[] };
docs = result.results;
expect(docs.length).toEqual(1);
});
});

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, verifyDocName } from './common';
import { createDoc, goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -17,29 +17,123 @@ test.describe('Doc Table Content', () => {
1,
);
await verifyDocName(page, randomDoc);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.ProseMirror').click();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Table of contents',
})
.click();
await page.keyboard.type('# Level 1\n## Level 2\n### Level 3');
const panel = page.getByLabel('Document panel');
const editor = page.locator('.ProseMirror');
const summaryContainer = page.locator('#summaryContainer');
await summaryContainer.click();
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 1').click();
await page.keyboard.type('Hello World');
await editor.getByText('Hello').dblclick();
await page.getByRole('button', { name: 'Strike' }).click();
const level1 = summaryContainer.getByText('Level 1');
const level2 = summaryContainer.getByText('Level 2');
const level3 = summaryContainer.getByText('Level 3');
await page.locator('.bn-block-outer').first().click();
await page.locator('.bn-block-outer').last().click();
await expect(level1).toBeVisible();
await expect(level1).toHaveCSS('padding', /4px 0px/);
await expect(level1).toHaveAttribute('aria-selected', 'true');
// Create space to fill the viewport
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Enter');
}
await expect(level2).toBeVisible();
await expect(level2).toHaveCSS('padding-left', /14.4px/);
await expect(level2).toHaveAttribute('aria-selected', 'false');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 2').click();
await page.keyboard.type('Super World', { delay: 100 });
await expect(level3).toBeVisible();
await expect(level3).toHaveCSS('padding-left', /24px/);
await expect(level3).toHaveAttribute('aria-selected', 'false');
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 10; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 3').click();
await page.keyboard.type('Another World');
const hello = panel.getByText('Hello World');
const superW = panel.getByText('Super World');
const another = panel.getByText('Another World');
await expect(hello).toBeVisible();
await expect(hello).toHaveCSS('font-size', /17/);
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toBeVisible();
await expect(superW).toHaveCSS('font-size', /14/);
await expect(superW).toHaveAttribute('aria-selected', 'false');
await expect(another).toBeVisible();
await expect(another).toHaveCSS('font-size', /12/);
await expect(another).toHaveAttribute('aria-selected', 'false');
await hello.click();
await expect(editor.getByText('Hello World')).toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toHaveAttribute('aria-selected', 'false');
await another.click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'false');
await expect(superW).toHaveAttribute('aria-selected', 'true');
await panel.getByText('Back to top').click();
await expect(editor.getByText('Hello World')).toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toHaveAttribute('aria-selected', 'false');
await panel.getByText('Go to bottom').click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await expect(superW).toHaveAttribute('aria-selected', 'true');
});
test('it checks that table contents panel is opened automaticaly if more that 2 headings', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(
page,
'doc-table-content',
browserName,
1,
);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await expect(page.getByLabel('Open the panel')).toBeHidden();
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 1').click();
await page.keyboard.type('Hello World', { delay: 100 });
await page.keyboard.press('Enter');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 2').click();
await page.keyboard.type('Super World', { delay: 100 });
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByLabel('Close the panel')).toBeVisible();
const panel = page.getByLabel('Document panel');
await expect(panel.getByText('Hello World')).toBeVisible();
await expect(panel.getByText('Super World')).toBeVisible();
await page.getByLabel('Close the panel').click();
await expect(panel).toHaveAttribute('aria-hidden', 'true');
});
});

View File

@@ -1,11 +1,6 @@
import { expect, test } from '@playwright/test';
import {
createDoc,
goToGridDoc,
mockedDocument,
verifyDocName,
} from './common';
import { createDoc, goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -15,7 +10,7 @@ test.describe('Doc Version', () => {
test('it displays the doc versions', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
await verifyDocName(page, randomDoc);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
@@ -23,29 +18,24 @@ test.describe('Doc Version', () => {
name: 'Version history',
})
.click();
await expect(page.getByText('History', { exact: true })).toBeVisible();
const modal = page.getByLabel('version history modal');
const panel = modal.getByLabel('version list');
await expect(panel).toBeVisible();
await expect(modal.getByText('No versions')).toBeVisible();
const panel = page.getByLabel('Document panel');
const editor = page.locator('.ProseMirror');
await modal.getByRole('button', { name: 'close' }).click();
await editor.click();
await page.keyboard.type('# Hello World');
await expect(panel.getByText('Current version')).toBeVisible();
expect(await panel.locator('li').count()).toBe(1);
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').last().fill('Hello World');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(
page.getByRole('heading', { name: 'Hello World' }),
).toBeVisible();
await expect(page.getByText('Hello World')).toBeVisible();
await page
.locator('.ProseMirror .bn-block')
.getByRole('heading', { name: 'Hello World' })
.getByText('Hello World')
.fill('It will create a version');
await goToGridDoc(page, {
@@ -53,9 +43,7 @@ test.describe('Doc Version', () => {
});
await expect(page.getByText('Hello World')).toBeHidden();
await expect(
page.getByRole('heading', { name: 'It will create a version' }),
).toBeVisible();
await expect(page.getByText('It will create a version')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
@@ -64,15 +52,19 @@ test.describe('Doc Version', () => {
})
.click();
await expect(panel).toBeVisible();
await expect(page.getByText('History', { exact: true })).toBeVisible();
await expect(page.getByRole('status')).toBeHidden();
const items = await panel.locator('.version-item').all();
expect(items.length).toBe(1);
await items[0].click();
await expect(panel.getByText('Current version')).toBeVisible();
expect(await panel.locator('li').count()).toBe(2);
await expect(modal.getByText('Hello World')).toBeVisible();
await expect(modal.getByText('It will create a version')).toBeHidden();
await panel.locator('li').nth(1).click();
await expect(
page.getByText('Read only, you cannot edit document versions.'),
).toBeVisible();
await expect(page.getByText('Hello World')).toBeVisible();
await expect(page.getByText('It will create a version')).toBeHidden();
await panel.getByText('Current version').click();
await expect(page.getByText('Hello World')).toBeHidden();
await expect(page.getByText('It will create a version')).toBeVisible();
});
test('it does not display the doc versions if not allowed', async ({
@@ -87,17 +79,24 @@ test.describe('Doc Version', () => {
await goToGridDoc(page);
await verifyDocName(page, 'Mocked document');
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Version history' }),
).toBeDisabled();
).toBeHidden();
await page.getByRole('button', { name: 'Table of content' }).click();
await expect(
page.getByLabel('Document panel').getByText('Versions'),
).toBeHidden();
});
test('it restores the doc version', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
await verifyDocName(page, randomDoc);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.bn-block-outer').last().click();
await page.locator('.bn-block-outer').last().fill('Hello');
@@ -125,26 +124,84 @@ test.describe('Doc Version', () => {
})
.click();
const modal = page.getByLabel('version history modal');
const panel = modal.getByLabel('version list');
await expect(panel).toBeVisible();
const panel = page.getByLabel('Document panel');
await panel.locator('li').nth(1).click();
await expect(page.getByText('World')).toBeHidden();
await expect(page.getByText('History', { exact: true })).toBeVisible();
await expect(page.getByRole('status')).toBeVisible();
await expect(page.getByRole('status')).toBeHidden();
const items = await panel.locator('.version-item').all();
expect(items.length).toBe(1);
await items[0].click();
await panel.getByLabel('Open the version options').click();
await page.getByText('Restore the version').click();
await expect(modal.getByText('World')).toBeHidden();
await expect(page.getByText('Restore this version?')).toBeVisible();
await page.getByRole('button', { name: 'Restore' }).click();
await expect(page.getByText('Your current document will')).toBeVisible();
await page.getByText('If a member is editing, his').click();
await page
.getByRole('button', {
name: 'Restore',
})
.click();
await page.getByLabel('Restore', { exact: true }).click();
await expect(panel.locator('li')).toHaveCount(3);
await panel.getByText('Current version').click();
await expect(page.getByText('Hello')).toBeVisible();
await expect(page.getByText('World')).toBeHidden();
});
test('it restores the doc version from button title', async ({
page,
browserName,
}) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().click();
await editor.locator('.bn-block-outer').last().fill('Hello');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(editor.getByText('Hello')).toBeVisible();
await editor.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await editor.locator('.bn-block-outer').last().fill('World');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(editor.getByText('World')).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Version history',
})
.click();
const panel = page.getByLabel('Document panel');
await panel.locator('li').nth(1).click();
await expect(editor.getByText('World')).toBeHidden();
await page
.getByRole('button', {
name: 'Restore this version',
})
.click();
await expect(page.getByText('Restore this version?')).toBeVisible();
await page
.getByRole('button', {
name: 'Restore',
})
.click();
await expect(panel.locator('li')).toHaveCount(3);
await panel.getByText('Current version').click();
await expect(editor.getByText('Hello')).toBeVisible();
await expect(editor.getByText('World')).toBeHidden();
});
});

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, keyCloakSignIn, verifyDocName } from './common';
import { createDoc, keyCloakSignIn } from './common';
const browsersName = ['chromium', 'webkit', 'firefox'];
@@ -36,38 +36,42 @@ test.describe('Doc Visibility', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
const selectVisibility = page.getByRole('combobox', {
name: 'Visibility',
});
await expect(selectVisibility.getByText('Private')).toBeVisible();
await expect(selectVisibility.getByText('Restricted')).toBeVisible();
await expect(page.getByLabel('Read only')).toBeHidden();
await expect(page.getByLabel('Can read and edit')).toBeHidden();
await selectVisibility.click();
await page
.getByRole('button', {
name: 'Connected',
.getByRole('option', {
name: 'Authenticated',
})
.click();
await expect(page.getByLabel('Visibility mode')).toBeVisible();
await expect(page.getByLabel('Read only')).toBeVisible();
await expect(page.getByLabel('Can read and edit')).toBeVisible();
await selectVisibility.click();
await page
.getByRole('button', {
.getByRole('option', {
name: 'Public',
})
.click();
await expect(page.getByLabel('Visibility mode')).toBeVisible();
await expect(page.getByLabel('Read only')).toBeVisible();
await expect(page.getByLabel('Can read and edit')).toBeVisible();
});
});
test.describe('Doc Visibility: Restricted', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A doc is not accessible when not authenticated.', async ({
test('A doc is not accessible when not authentified.', async ({
page,
browserName,
}) => {
@@ -81,7 +85,7 @@ test.describe('Doc Visibility: Restricted', () => {
1,
);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
const urlDoc = page.url();
@@ -98,7 +102,7 @@ test.describe('Doc Visibility: Restricted', () => {
await expect(page.getByRole('textbox', { name: 'password' })).toBeVisible();
});
test('A doc is not accessible when authenticated but not member.', async ({
test('A doc is not accessible when authentified but not member.', async ({
page,
browserName,
}) => {
@@ -107,7 +111,7 @@ test.describe('Doc Visibility: Restricted', () => {
const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
const urlDoc = page.url();
@@ -135,13 +139,11 @@ test.describe('Doc Visibility: Restricted', () => {
const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const inputSearch = page.getByLabel(/Find a member to add to the document/);
const otherBrowser = browsersName.find((b) => b !== browserName);
const username = `user@${otherBrowser}.e2e`;
@@ -149,11 +151,14 @@ test.describe('Doc Visibility: Restricted', () => {
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.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`User ${username} added to the document.`),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
@@ -171,8 +176,8 @@ test.describe('Doc Visibility: Restricted', () => {
await page.goto(urlDoc);
await verifyDocName(page, docTitle);
await expect(page.getByLabel('Share button')).toBeVisible();
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
});
@@ -193,14 +198,17 @@ test.describe('Doc Visibility: Public', () => {
1,
);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('button', {
.getByRole('option', {
name: 'Public',
})
.click();
@@ -209,27 +217,20 @@ test.describe('Doc Visibility: Public', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByLabel('Visibility mode').click();
await page
.getByRole('button', {
name: 'Reading',
})
.click();
await page.getByLabel('Read only').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
const cardContainer = page.getByLabel(
'It is the card information about the document.',
);
await expect(cardContainer.getByTestId('public-icon')).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await expect(
cardContainer.getByText('Public document', { exact: true }),
page
.getByLabel('It is the card information about the document.')
.getByText('Public', { exact: true }),
).toBeVisible();
const urlDoc = page.url();
@@ -246,10 +247,9 @@ test.describe('Doc Visibility: Public', () => {
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
const card = page.getByLabel('It is the card information');
await expect(card).toBeVisible();
await expect(card.getByText('Reader')).toBeVisible();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeVisible();
});
test('It checks a public doc in editable mode', async ({
@@ -261,14 +261,17 @@ test.describe('Doc Visibility: Public', () => {
const [docTitle] = await createDoc(page, 'Public editable', browserName, 1);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('button', {
.getByRole('option', {
name: 'Public',
})
.click();
@@ -277,23 +280,20 @@ test.describe('Doc Visibility: Public', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByLabel('Visibility mode').click();
await page.getByLabel('Edition').click();
await page.getByLabel('Can read and edit').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
const cardContainer = page.getByLabel(
'It is the card information about the document.',
);
await expect(cardContainer.getByTestId('public-icon')).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await expect(
cardContainer.getByText('Public document', { exact: true }),
page
.getByLabel('It is the card information about the document.')
.getByText('Public', { exact: true }),
).toBeVisible();
const urlDoc = page.url();
@@ -308,15 +308,18 @@ test.describe('Doc Visibility: Public', () => {
await page.goto(urlDoc);
await verifyDocName(page, docTitle);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeHidden();
});
});
test.describe('Doc Visibility: Authenticated', () => {
test.use({ storageState: { cookies: [], origins: [] } });
test('A doc is not accessible when unauthenticated.', async ({
test('A doc is not accessible when unauthentified.', async ({
page,
browserName,
}) => {
@@ -325,19 +328,22 @@ test.describe('Doc Visibility: Authenticated', () => {
const [docTitle] = await createDoc(
page,
'Authenticated unauthenticated',
'Authenticated unauthentified',
browserName,
1,
);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('button', {
name: 'Connected',
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
@@ -345,7 +351,9 @@ test.describe('Doc Visibility: Authenticated', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
@@ -377,14 +385,17 @@ test.describe('Doc Visibility: Authenticated', () => {
1,
);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('button', {
name: 'Connected',
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
@@ -392,7 +403,9 @@ test.describe('Doc Visibility: Authenticated', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
@@ -409,13 +422,19 @@ test.describe('Doc Visibility: Authenticated', () => {
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeVisible();
await expect(selectVisibility).toBeHidden();
const shareModal = page.getByLabel('Share modal');
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
await expect(inputSearch).toBeHidden();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
await expect(shareModal.getByLabel('List members card')).toBeHidden();
});
test('It checks a authenticated doc in editable mode', async ({
@@ -432,14 +451,17 @@ test.describe('Doc Visibility: Authenticated', () => {
1,
);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('button', {
name: 'Connected',
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
@@ -447,15 +469,23 @@ test.describe('Doc Visibility: Authenticated', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page.getByLabel('Visibility mode').click();
await page.getByLabel('Edition').click();
await page.getByRole('button', { name: 'Share' }).click();
await page.getByLabel('Can read and edit').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await page
.getByRole('button', {
@@ -468,14 +498,20 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.goto(urlDoc);
await verifyDocName(page, docTitle);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeHidden();
await expect(selectVisibility).toBeHidden();
const shareModal = page.getByLabel('Share modal');
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
await expect(inputSearch).toBeHidden();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
await expect(shareModal.getByLabel('List members card')).toBeHidden();
});
});

View File

@@ -75,13 +75,29 @@ test.describe('Header mobile', () => {
test('it checks the header when mobile', async ({ page }) => {
const header = page.locator('header').first();
await expect(header.getByLabel('Open the header menu')).toBeVisible();
await expect(header.getByRole('link', { name: 'Docs Logo' })).toBeVisible();
await expect(
header.getByRole('button', {
name: 'Les services de La Suite numérique',
}),
).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Logout',
}),
).toBeHidden();
await expect(page.getByText('English')).toBeHidden();
await header.getByLabel('Open the header menu').click();
await expect(
page.getByRole('button', {
name: 'Logout',
}),
).toBeVisible();
await expect(page.getByText('English')).toBeVisible();
});
});

View File

@@ -6,7 +6,11 @@ test.beforeEach(async ({ page }) => {
test.describe('Language', () => {
test('checks the language picker', async ({ page }) => {
await expect(page.getByLabel('Logout')).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Create a new document',
}),
).toBeVisible();
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('English').click();
@@ -15,7 +19,11 @@ test.describe('Language', () => {
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
await expect(page.getByLabel('Se déconnecter')).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Créer un nouveau document',
}),
).toBeVisible();
await header.getByRole('combobox').getByText('Français').click();
await header.getByRole('option', { name: 'Deutsch' }).click();
@@ -23,7 +31,11 @@ test.describe('Language', () => {
header.getByRole('combobox').getByText('Deutsch'),
).toBeVisible();
await expect(page.getByLabel('Abmelden')).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Neues Dokument erstellen',
}),
).toBeVisible();
});
test('checks that backend uses the same language as the frontend', async ({

View File

@@ -1,48 +0,0 @@
import { expect, test } from '@playwright/test';
test.describe('Left panel desktop', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the elements are visible', async ({ page }) => {
await expect(page.getByTestId('left-panel-desktop')).toBeVisible();
await expect(page.getByTestId('left-panel-mobile')).toBeHidden();
await expect(page.getByRole('button', { name: 'house' })).toBeVisible();
await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible();
});
});
test.describe('Left panel mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the desktop elements are hidden and all mobile elements are visible', async ({
page,
}) => {
await expect(page.getByTestId('left-panel-desktop')).toBeHidden();
await expect(page.getByTestId('left-panel-mobile')).not.toBeInViewport();
const header = page.locator('header').first();
const homeButton = page.getByRole('button', { name: 'house' });
const newDocButton = page.getByRole('button', { name: 'New doc' });
const languageButton = page.getByRole('combobox', { name: 'Language' });
const logoutButton = page.getByRole('button', { name: 'Logout' });
await expect(homeButton).not.toBeInViewport();
await expect(newDocButton).not.toBeInViewport();
await expect(languageButton).not.toBeInViewport();
await expect(logoutButton).not.toBeInViewport();
await header.getByLabel('Open the header menu').click();
await expect(page.getByTestId('left-panel-mobile')).toBeInViewport();
await expect(homeButton).toBeInViewport();
await expect(newDocButton).toBeInViewport();
await expect(languageButton).toBeInViewport();
await expect(logoutButton).toBeInViewport();
});
});

View File

@@ -16,9 +16,7 @@
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",
"typescript": "*",
"luxon": "3.5.0",
"@types/luxon": "3.4.2"
"typescript": "*"
},
"dependencies": {
"convert-stream": "1.0.2",

View File

@@ -9,6 +9,10 @@ server {
try_files $uri index.html $uri/ =404;
}
location ~ ^/docs/(.*)/versions/(.*)/$ {
error_page 404 /docs/[id]/versions/[versionId]/;
}
location /docs/ {
error_page 404 /docs/[id]/;
}

View File

@@ -5,60 +5,22 @@ const config = {
colors: {
'card-border': '#ededed',
'primary-bg': '#FAFAFA',
'primary-050': '#F5F5FE',
'primary-100': '#EDF5FA',
'primary-150': '#E5EEFA',
'primary-950': '#1B1B35',
'info-150': '#E5EEFA',
'greyscale-000': '#fff',
'greyscale-1000': '#161616',
'blue-400': '#7AB1E8',
'blue-500': '#417DC4',
'blue-600': '#3558A2',
'brown-400': '#E6BE92',
'brown-500': '#BD987A',
'brown-600': '#745B47',
'cyan-400': '#34BAB5',
'cyan-500': '#009099',
'cyan-600': '#006A6F',
'gold-400': '#FFCA00',
'gold-500': '#C3992A',
'gold-600': '#695240',
'green-400': '#34CB6A',
'green-500': '#00A95F',
'green-600': '#297254',
'olive-400': '#99C221',
'olive-500': '#68A532',
'olive-600': '#447049',
'orange-400': '#FF732C',
'orange-500': '#E4794A',
'orange-600': '#755348',
'pink-400': '#FFB7AE',
'pink-500': '#E18B76',
'pink-600': '#8D533E',
'purple-400': '#CE70CC',
'purple-500': '#A558A0',
'purple-600': '#6E445A',
'yellow-400': '#D8C634',
'yellow-500': '#B7A73F',
'yellow-600': '#66673D',
},
font: {
sizes: {
xs: '0.75rem',
sm: '0.875rem',
md: '1rem',
lg: '1.125rem',
ml: '0.938rem',
xl: '1.25rem',
xl: '1.50rem',
t: '0.6875rem',
s: '0.75rem',
h1: '2rem',
h2: '1.75rem',
h3: '1.5rem',
h4: '1.375rem',
h5: '1.25rem',
h6: '1.125rem',
h1: '2.2rem',
h2: '1.7rem',
h3: '1.37rem',
h4: '1.15rem',
h5: '1rem',
h6: '0.87rem',
},
weights: {
thin: 100,
@@ -72,21 +34,6 @@ const config = {
auto: 'auto',
bx: '2.2rem',
full: '100%',
'4xs': '0.125rem',
'3xs': '0.25rem',
'2xs': '0.375rem',
xs: '0.5rem',
sm: '0.75rem',
base: '1rem',
md: '1.5rem',
lg: '2rem',
xl: '2.5rem',
xxl: '3rem',
xxxl: '3.5rem',
'4xl': '4rem',
'5xl': '4.5rem',
'6xl': '6rem',
'7xl': '7.5rem',
},
breakpoints: {
xxs: '320px',
@@ -157,7 +104,7 @@ const config = {
focus: 'var(--c--components--forms-select--border-radius)',
},
'font-size': 'var(--c--theme--font--sizes--ml)',
'menu-background-color': '#fff',
'menu-background-color': '#ffffff',
'item-background-color': {
hover: 'var(--c--theme--colors--primary-300)',
},
@@ -179,7 +126,7 @@ const config = {
},
},
modal: {
'background-color': '#fff',
'background-color': '#ffffff',
},
button: {
'border-radius': {
@@ -200,8 +147,8 @@ const config = {
danger: {
'color-hover': 'white',
background: {
color: 'var(--c--theme--colors--danger-600)',
'color-hover': '#FF2725',
color: 'var(--c--theme--colors--danger-400)',
'color-hover': 'var(--c--theme--colors--danger-500)',
'color-disabled': 'var(--c--theme--colors--danger-100)',
},
},
@@ -231,9 +178,7 @@ const config = {
color: 'var(--c--theme--colors--primary-text)',
'color-disabled': 'var(--c--theme--colors--greyscale-600)',
background: {
color: 'var(--c--theme--colors--primary-100)',
'color-hover': 'var(--c--theme--colors--primary-300)',
'color-active': 'var(--c--theme--colors--primary-100)',
'color-hover': 'var(--c--theme--colors--primary-100)',
'color-disabled': 'var(--c--theme--colors--greyscale-200)',
},
},
@@ -252,19 +197,19 @@ const config = {
dsfr: {
theme: {
colors: {
'card-border': '#E5E5E5',
'card-border': '#ededed',
'primary-text': '#000091',
'primary-100': '#ECECFE',
'primary-100': '#f5f5fe',
'primary-150': '#F4F4FD',
'primary-200': '#E3E3FD',
'primary-300': '#CACAFB',
'primary-400': '#8585F6',
'primary-500': '#6A6AF4',
'primary-600': '#313178',
'primary-200': '#ececfe',
'primary-300': '#e3e3fd',
'primary-400': '#cacafb',
'primary-500': '#6a6af4',
'primary-600': '#000091',
'primary-700': '#272747',
'primary-800': '#000091',
'primary-900': '#21213F',
'secondary-text': '#fff',
'primary-800': '#21213f',
'primary-900': '#1c1a36',
'secondary-text': '#FFFFFF',
'secondary-100': '#fee9ea',
'secondary-200': '#fedfdf',
'secondary-300': '#fdbfbf',
@@ -275,22 +220,16 @@ const config = {
'secondary-800': '#341f1f',
'secondary-900': '#2b1919',
'greyscale-text': '#303C4B',
'greyscale-000': '#fff',
'greyscale-050': '#F6F6F6',
'greyscale-100': '#eee',
'greyscale-200': '#E5E5E5',
'greyscale-250': '#ddd',
'greyscale-300': '#CECECE',
'greyscale-350': '#ddd',
'greyscale-400': '#929292',
'greyscale-500': '#7C7C7C',
'greyscale-600': '#666666',
'greyscale-700': '#3A3A3A',
'greyscale-750': '#353535',
'greyscale-800': '#2A2A2A',
'greyscale-900': '#242424',
'greyscale-950': '#1E1E1E',
'greyscale-1000': '#161616',
'greyscale-000': '#f6f6f6',
'greyscale-100': '#eeeeee',
'greyscale-200': '#e5e5e5',
'greyscale-300': '#e1e1e1',
'greyscale-400': '#dddddd',
'greyscale-500': '#cecece',
'greyscale-600': '#7b7b7b',
'greyscale-700': '#666666',
'greyscale-800': '#2a2a2a',
'greyscale-900': '#1e1e1e',
'success-text': '#1f8d49',
'success-100': '#dffee6',
'success-200': '#b8fec9',
@@ -302,15 +241,15 @@ const config = {
'success-800': '#1e2e22',
'success-900': '#19281d',
'info-text': '#0078f3',
'info-100': '#E8EDFF',
'info-200': '#DDE5FF',
'info-300': '#BCCDFF',
'info-400': '#518FFF',
'info-500': '#0078F3',
'info-600': '#0063CB',
'info-700': '#273961',
'info-800': '#222A3F',
'info-900': '#1D2437',
'info-100': '#f4f6ff',
'info-200': '#e8edff',
'info-300': '#dde5ff',
'info-400': '#bdcdff',
'info-500': '#0078f3',
'info-600': '#0063cb',
'info-700': '#f4f6ff',
'info-800': '#222a3f',
'info-900': '#1d2437',
'warning-text': '#d64d00',
'warning-100': '#fff4f3',
'warning-200': '#ffe9e6',
@@ -321,16 +260,16 @@ const config = {
'warning-700': '#5e2c21',
'warning-800': '#3e241e',
'warning-900': '#361e19',
'danger-text': '#FFF',
'danger-100': '#FFE9E9',
'danger-200': '#FFDDDD',
'danger-300': '#FFBDBD',
'danger-400': '#FF5655',
'danger-500': '#F60700',
'danger-600': '#CE0500',
'danger-700': '#642626',
'danger-text': '#e1000f',
'danger-100': '#fef4f4',
'danger-200': '#fee9e9',
'danger-300': '#fddede',
'danger-400': '#fcbfbf',
'danger-500': '#e1000f',
'danger-600': '#c9191e',
'danger-700': '#642727',
'danger-800': '#412121',
'danger-900': '#391C1C',
'danger-900': '#3a1c1c',
},
font: {
families: {
@@ -349,12 +288,8 @@ const config = {
alert: {
'border-radius': '0',
},
modal: {
'width-small': '342px',
},
button: {
'medium-height': '40px',
'medium-text-height': '40px',
'medium-height': '48px',
'border-radius': '4px',
primary: {
background: {
@@ -362,9 +297,9 @@ const config = {
'color-hover': '#1212ff',
'color-active': '#2323ff',
},
color: '#fff',
'color-hover': '#fff',
'color-active': '#fff',
color: '#ffffff',
'color-hover': '#ffffff',
'color-active': '#ffffff',
},
'primary-text': {
background: {
@@ -386,7 +321,7 @@ const config = {
},
'tertiary-text': {
background: {
'color-hover': 'var(--c--theme--colors--greyscale-100)',
'color-hover': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-600)',
@@ -428,7 +363,7 @@ const config = {
},
'forms-input': {
'border-radius': '4px',
'background-color': '#fff',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
'value-color': 'var(--c--theme--colors--primary-text)',
@@ -446,7 +381,7 @@ const config = {
'item-font-size': '14px',
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#fff',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'border-color-hover': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',

View File

@@ -15,37 +15,34 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@blocknote/core": "0.22.0",
"@blocknote/mantine": "0.22.0",
"@blocknote/react": "0.22.0",
"@blocknote/core": "*",
"@blocknote/mantine": "*",
"@blocknote/react": "*",
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.15.0",
"@openfun/cunningham-react": "2.9.4",
"@sentry/nextjs": "8.47.0",
"@tanstack/react-query": "5.62.11",
"cmdk": "1.0.4",
"@sentry/nextjs": "8.45.1",
"@tanstack/react-query": "5.62.7",
"crisp-sdk-web": "1.0.25",
"i18next": "24.2.0",
"i18next": "24.1.0",
"i18next-browser-languagedetector": "8.0.2",
"idb": "8.0.1",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "15.1.3",
"next": "15.1.0",
"react": "*",
"react-aria-components": "1.5.0",
"react-dom": "*",
"react-i18next": "15.4.0",
"react-intersection-observer": "9.13.1",
"react-i18next": "15.2.0",
"react-select": "5.9.0",
"styled-components": "6.1.13",
"use-debounce": "10.0.4",
"y-protocols": "1.0.6",
"yjs": "13.6.21",
"yjs": "*",
"zustand": "5.0.2"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.62.11",
"@tanstack/react-query-devtools": "5.62.7",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.1.0",
@@ -56,7 +53,7 @@
"@types/node": "*",
"@types/react": "18.3.12",
"@types/react-dom": "*",
"cross-env": "7.0.3",
"cross-env": "*",
"dotenv": "16.4.7",
"eslint-config-impress": "*",
"fetch-mock": "9.11.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 279 KiB

View File

@@ -0,0 +1,32 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { AppWrapper } from '@/tests/utils';
import Page from '../pages';
jest.mock('next/router', () => ({
useRouter() {
return {
push: jest.fn(),
};
},
}));
jest.mock('@sentry/nextjs', () => ({
captureException: jest.fn(),
captureMessage: jest.fn(),
setUser: jest.fn(),
}));
describe('Page', () => {
it('checks Page rendering', () => {
render(<Page />, { wrapper: AppWrapper });
expect(
screen.getByRole('button', {
name: /Create a new document/i,
}),
).toBeInTheDocument();
});
});

View File

@@ -29,7 +29,7 @@ describe('fetchAPI', () => {
});
});
it('check the versioning', () => {
it('check the versionning', () => {
fetchMock.mock('http://test.jest/api/v2.0/some/url', 200);
void fetchAPI('some/url', {}, '2.0');

View File

@@ -1,13 +1,9 @@
import { forwardRef } from 'react';
import { ComponentPropsWithRef, forwardRef } from 'react';
import { css } from 'styled-components';
import { Box, BoxType } from './Box';
export type BoxButtonType = BoxType & {
disabled?: boolean;
};
/**
export type BoxButtonType = ComponentPropsWithRef<typeof BoxButton>;
/**
* Styleless button that extends the Box component.
@@ -22,7 +18,7 @@ export type BoxButtonType = BoxType & {
* </BoxButton>
* ```
*/
const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
const BoxButton = forwardRef<HTMLDivElement, BoxType>(
({ $css, ...props }, ref) => {
return (
<Box
@@ -32,24 +28,14 @@ const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
$margin="none"
$padding="none"
$css={css`
cursor: ${props.disabled ? 'not-allowed' : 'pointer'};
cursor: pointer;
border: none;
outline: none;
transition: all 0.2s ease-in-out;
font-family: inherit;
color: ${props.disabled
? 'var(--c--theme--colors--greyscale-400) !important'
: 'inherit'};
${$css || ''}
`}
{...props}
onClick={(event: React.MouseEvent<HTMLDivElement>) => {
if (props.disabled) {
return;
}
props.onClick?.(event);
}}
/>
);
},

View File

@@ -17,7 +17,8 @@ export const Card = ({
$background="white"
$radius="4px"
$css={css`
border: 1px solid ${colorsTokens()['greyscale-200']};
box-shadow: 2px 2px 5px ${colorsTokens()['greyscale-300']};
border: 1px solid ${colorsTokens()['card-border']};
${$css}
`}
{...props}

View File

@@ -1,20 +1,19 @@
import {
import React, {
PropsWithChildren,
ReactNode,
useEffect,
useRef,
useState,
} from 'react';
import { Button, Popover } from 'react-aria-components';
import { Button, DialogTrigger, Popover } from 'react-aria-components';
import styled from 'styled-components';
const StyledPopover = styled(Popover)`
background-color: white;
border-radius: 4px;
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
padding: 0.5rem;
border: 1px solid #dddddd;
opacity: 0;
transition: opacity 0.2s ease-in-out;
`;
@@ -27,15 +26,13 @@ const StyledButton = styled(Button)`
font-family: Marianne, Arial, serif;
font-weight: 500;
font-size: 0.938rem;
padding: 0;
text-wrap: nowrap;
`;
export interface DropButtonProps {
interface DropButtonProps {
button: ReactNode;
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
label?: string;
}
export const DropButton = ({
@@ -43,12 +40,10 @@ export const DropButton = ({
isOpen = false,
onOpenChange,
children,
label,
}: PropsWithChildren<DropButtonProps>) => {
const [opacity, setOpacity] = useState(false);
const [isLocalOpen, setIsLocalOpen] = useState(isOpen);
const triggerRef = useRef(null);
useEffect(() => {
setIsLocalOpen(isOpen);
}, [isOpen]);
@@ -56,25 +51,21 @@ export const DropButton = ({
const onOpenChangeHandler = (isOpen: boolean) => {
setIsLocalOpen(isOpen);
onOpenChange?.(isOpen);
setTimeout(() => {
setOpacity(isOpen);
}, 10);
};
return (
<>
<StyledButton
ref={triggerRef}
onPress={() => onOpenChangeHandler(true)}
aria-label={label}
>
{button}
</StyledButton>
<DialogTrigger onOpenChange={onOpenChangeHandler} isOpen={isLocalOpen}>
<StyledButton>{button}</StyledButton>
<StyledPopover
triggerRef={triggerRef}
style={{ opacity: opacity ? 1 : 0 }}
isOpen={isLocalOpen}
onOpenChange={onOpenChangeHandler}
>
{children}
</StyledPopover>
</>
</DialogTrigger>
);
};

View File

@@ -1,150 +0,0 @@
import { PropsWithChildren, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
export type DropdownMenuOption = {
icon?: string;
label: string;
testId?: string;
callback?: () => void | Promise<unknown>;
danger?: boolean;
isSelected?: boolean;
disabled?: boolean;
show?: boolean;
};
export type DropdownMenuProps = {
options: DropdownMenuOption[];
showArrow?: boolean;
label?: string;
arrowCss?: BoxProps['$css'];
topMessage?: string;
};
export const DropdownMenu = ({
options,
children,
showArrow = false,
arrowCss,
label,
topMessage,
}: PropsWithChildren<DropdownMenuProps>) => {
const theme = useCunninghamTheme();
const spacings = theme.spacingsTokens();
const colors = theme.colorsTokens();
const [isOpen, setIsOpen] = useState(false);
const onOpenChange = (isOpen: boolean) => {
setIsOpen(isOpen);
};
return (
<DropButton
isOpen={isOpen}
onOpenChange={onOpenChange}
label={label}
button={
showArrow ? (
<Box $direction="row" $align="center">
<div>{children}</div>
<Icon
$variation="600"
$css={
arrowCss ??
css`
color: var(--c--theme--colors--primary-600);
`
}
iconName={isOpen ? 'arrow_drop_up' : 'arrow_drop_down'}
/>
</Box>
) : (
children
)
}
>
<Box $maxWidth="320px">
{topMessage && (
<Text
$variation="700"
$wrap="wrap"
$size="xs"
$weight="bold"
$padding={{ vertical: 'xs', horizontal: 'base' }}
>
{topMessage}
</Text>
)}
{options.map((option, index) => {
if (option.show !== undefined && !option.show) {
return;
}
const isDisabled = option.disabled !== undefined && option.disabled;
return (
<BoxButton
aria-label={option.label}
data-testid={option.testId}
$direction="row"
disabled={isDisabled}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onOpenChange?.(false);
void option.callback?.();
}}
key={option.label}
$align="center"
$justify="space-between"
$background={colors['greyscale-000']}
$color={colors['primary-600']}
$padding={{ vertical: 'xs', horizontal: 'base' }}
$width="100%"
$gap={spacings['base']}
$css={css`
border: none;
${index === 0 &&
css`
border-top-left-radius: 4px;
border-top-right-radius: 4px;
`}
${index === options.length - 1 &&
css`
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
`}
font-size: var(--c--theme--font--sizes--sm);
color: var(--c--theme--colors--greyscale-1000);
font-weight: 500;
cursor: ${isDisabled ? 'not-allowed' : 'pointer'};
user-select: none;
&:hover {
background-color: var(--c--theme--colors--greyscale-050);
}
`}
>
<Box $direction="row" $align="center" $gap={spacings['base']}>
{option.icon && (
<Icon
$size="20px"
$theme="greyscale"
$variation={isDisabled ? '400' : '1000'}
iconName={option.icon}
/>
)}
<Text $variation={isDisabled ? '400' : '1000'}>
{option.label}
</Text>
</Box>
{option.isSelected && (
<Icon iconName="check" $size="20px" $theme="greyscale" />
)}
</BoxButton>
);
})}
</Box>
</DropButton>
);
};

View File

@@ -1,19 +1,6 @@
import { css } from 'styled-components';
import { Text, TextType } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
type IconProps = TextType & {
iconName: string;
};
export const Icon = ({ iconName, ...textProps }: IconProps) => {
return (
<Text $isMaterialIcon {...textProps}>
{iconName}
</Text>
);
};
interface IconBGProps extends TextType {
iconName: string;
}
@@ -42,21 +29,23 @@ export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
);
};
type IconOptionsProps = TextType & {
isHorizontal?: boolean;
};
interface IconOptionsProps {
isOpen: boolean;
'aria-label': string;
}
export const IconOptions = ({ isHorizontal, ...props }: IconOptionsProps) => {
export const IconOptions = ({ isOpen, ...props }: IconOptionsProps) => {
return (
<Text
{...props}
aria-label={props['aria-label']}
$isMaterialIcon
$css={css`
$css={`
transition: all 0.3s ease-in-out;
transform: rotate(${isOpen ? '90' : '0'}deg);
user-select: none;
${props.$css}
`}
>
{isHorizontal ? 'more_horiz' : 'more_vert'}
more_vert
</Text>
);
};

View File

@@ -1,16 +1,12 @@
import { Button } from '@openfun/cunningham-react';
import { PropsWithChildren } from 'react';
import { useTranslation } from 'react-i18next';
import { InView } from 'react-intersection-observer';
import { PropsWithChildren, useEffect, useRef } from 'react';
import { Box, BoxType, Icon } from '@/components';
import { Box, BoxType } from '@/components';
interface InfiniteScrollProps extends BoxType {
hasMore: boolean;
isLoading: boolean;
next: () => void;
scrollContainer?: HTMLElement | null;
buttonLabel?: string;
scrollContainer: HTMLElement | null;
}
export const InfiniteScroll = ({
@@ -18,31 +14,42 @@ export const InfiniteScroll = ({
hasMore,
isLoading,
next,
buttonLabel,
scrollContainer,
...boxProps
}: PropsWithChildren<InfiniteScrollProps>) => {
const { t } = useTranslation();
const loadMore = (inView: boolean) => {
if (!inView || isLoading) {
const timeout = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (!scrollContainer) {
return;
}
void next();
};
return (
<Box {...boxProps}>
{children}
<InView onChange={loadMore}>
{!isLoading && hasMore && (
<Button
onClick={() => void next()}
color="primary-text"
icon={<Icon iconName="arrow_downward" />}
>
{buttonLabel ?? t('Load more')}
</Button>
)}
</InView>
</Box>
);
const nextHandle = () => {
if (!hasMore || isLoading) {
return;
}
// To not wait until the end of the scroll to load more data
const heightFromBottom = 150;
const { scrollTop, clientHeight, scrollHeight } = scrollContainer;
if (scrollTop + clientHeight >= scrollHeight - heightFromBottom) {
next();
}
};
const handleScroll = () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
timeout.current = setTimeout(nextHandle, 50);
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => {
scrollContainer.removeEventListener('scroll', handleScroll);
};
}, [hasMore, isLoading, next, scrollContainer]);
return <Box {...boxProps}>{children}</Box>;
};

View File

@@ -1,35 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Box } from './Box';
import { Icon } from './Icon';
import { Text } from './Text';
type LoadMoreTextProps = {
['data-testid']?: string;
};
export const LoadMoreText = ({
'data-testid': dataTestId,
}: LoadMoreTextProps) => {
const { t } = useTranslation();
return (
<Box
data-testid={dataTestId}
$direction="row"
$align="center"
$gap="0.4rem"
$padding={{ horizontal: '2xs', vertical: 'sm' }}
>
<Icon
$theme="primary"
$variation="800"
iconName="arrow_downward"
$size="md"
/>
<Text $theme="primary" $variation="800">
{t('Load more')}
</Text>
</Box>
);
};

View File

@@ -33,7 +33,6 @@ export interface TextProps extends BoxProps {
| 'greyscale';
$variation?:
| 'text'
| '000'
| '100'
| '200'
| '300'
@@ -42,8 +41,7 @@ export interface TextProps extends BoxProps {
| '600'
| '700'
| '800'
| '900'
| '1000';
| '900';
}
export type TextType = ComponentPropsWithRef<typeof Text>;

View File

@@ -17,8 +17,8 @@ describe('<Box />', () => {
);
expect(screen.getByText('My Box')).toHaveStyle(`
padding-left: 2.5rem;
padding-right: 2.5rem;
padding-left: 4rem;
padding-right: 4rem;
padding-top: 3rem;
padding-bottom: 0.5rem;`);
});

View File

@@ -2,12 +2,9 @@ export * from './Box';
export * from './BoxButton';
export * from './Card';
export * from './DropButton';
export * from './DropdownMenu';
export * from './Icon';
export * from './InfiniteScroll';
export * from './Link';
export * from './LoadMoreText';
export * from './SideModal';
export * from './separators';
export * from './Text';
export * from './TextErrors';

View File

@@ -1,71 +0,0 @@
import { Command } from 'cmdk';
import { ReactNode, useRef } from 'react';
import { hasChildrens } from '@/utils/children';
import { Box } from '../Box';
import { QuickSearchInput } from './QuickSearchInput';
import { QuickSearchStyle } from './QuickSearchStyle';
export type QuickSearchAction = {
onSelect?: () => void;
content: ReactNode;
};
export type QuickSearchData<T> = {
groupName: string;
elements: T[];
emptyString?: string;
startActions?: QuickSearchAction[];
endActions?: QuickSearchAction[];
showWhenEmpty?: boolean;
};
export type QuickSearchProps = {
onFilter?: (str: string) => void;
inputValue?: string;
inputContent?: ReactNode;
showInput?: boolean;
loading?: boolean;
label?: string;
placeholder?: string;
children?: ReactNode;
};
export const QuickSearch = ({
onFilter,
inputContent,
inputValue,
loading,
showInput = true,
label,
placeholder,
children,
}: QuickSearchProps) => {
const ref = useRef<HTMLDivElement | null>(null);
return (
<>
<QuickSearchStyle />
<div className="quick-search-container">
<Command label={label} shouldFilter={false} ref={ref}>
{showInput && (
<QuickSearchInput
loading={loading}
withSeparator={hasChildrens(children)}
inputValue={inputValue}
onFilter={onFilter}
placeholder={placeholder}
>
{inputContent}
</QuickSearchInput>
)}
<Command.List>
<Box>{children}</Box>
</Command.List>
</Command>
</div>
</>
);
};

View File

@@ -1,66 +0,0 @@
import { Command } from 'cmdk';
import { ReactNode } from 'react';
import { Box } from '../Box';
import { QuickSearchData } from './QuickSearch';
import { QuickSearchItem } from './QuickSearchItem';
type Props<T> = {
group: QuickSearchData<T>;
renderElement?: (element: T) => ReactNode;
onSelect?: (element: T) => void;
};
export const QuickSearchGroup = <T,>({
group,
onSelect,
renderElement,
}: Props<T>) => {
return (
<Box $margin={{ top: 'base' }}>
<Command.Group
key={group.groupName}
heading={group.groupName}
forceMount={false}
>
{group.startActions?.map((action, index) => {
return (
<QuickSearchItem
key={`${group.groupName}-action-${index}`}
onSelect={action.onSelect}
>
{action.content}
</QuickSearchItem>
);
})}
{group.elements.map((groupElement, index) => {
return (
<QuickSearchItem
id={`${group.groupName}-element-${index}`}
key={`${group.groupName}-element-${index}`}
onSelect={() => {
onSelect?.(groupElement);
}}
>
{renderElement?.(groupElement)}
</QuickSearchItem>
);
})}
{group.endActions?.map((action, index) => {
return (
<QuickSearchItem
key={`${group.groupName}-action-${index}`}
onSelect={action.onSelect}
>
{action.content}
</QuickSearchItem>
);
})}
{group.emptyString && group.elements.length === 0 && (
<span className="ml-b clr-greyscale-500">{group.emptyString}</span>
)}
</Command.Group>
</Box>
);
};

View File

@@ -1,69 +0,0 @@
import { Loader } from '@openfun/cunningham-react';
import { Command } from 'cmdk';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { HorizontalSeparator } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
import { Icon } from '../Icon';
type Props = {
loading?: boolean;
inputValue?: string;
onFilter?: (str: string) => void;
placeholder?: string;
children?: ReactNode;
withSeparator?: boolean;
};
export const QuickSearchInput = ({
loading,
inputValue,
onFilter,
placeholder,
children,
withSeparator: separator = true,
}: Props) => {
const { t } = useTranslation();
const { spacingsTokens } = useCunninghamTheme();
const spacing = spacingsTokens();
if (children) {
return (
<>
{children}
{separator && <HorizontalSeparator />}
</>
);
}
return (
<>
<Box
$direction="row"
$align="center"
className="quick-search-input"
$gap={spacing['2xs']}
$padding={{ all: 'base' }}
>
{!loading && <Icon iconName="search" $variation="600" />}
{loading && (
<div>
<Loader size="small" />
</div>
)}
<Command.Input
/* eslint-disable-next-line jsx-a11y/no-autofocus */
autoFocus={true}
aria-label={t('Quick search input')}
value={inputValue}
role="combobox"
placeholder={placeholder ?? t('Search')}
onValueChange={onFilter}
/>
</Box>
{separator && <HorizontalSeparator $withPadding={false} />}
</>
);
};

View File

@@ -1,18 +0,0 @@
import { Command } from 'cmdk';
import { PropsWithChildren } from 'react';
type Props = {
onSelect?: (value: string) => void;
id?: string;
};
export const QuickSearchItem = ({
children,
onSelect,
id,
}: PropsWithChildren<Props>) => {
return (
<Command.Item value={id} onSelect={onSelect}>
{children}
</Command.Item>
);
};

View File

@@ -1,52 +0,0 @@
import { ReactNode } from 'react';
import { useCunninghamTheme } from '@/cunningham';
import { useResponsiveStore } from '@/stores';
import { Box } from '../Box';
export type QuickSearchItemContentProps = {
alwaysShowRight?: boolean;
left: ReactNode;
right?: ReactNode;
};
export const QuickSearchItemContent = ({
alwaysShowRight = false,
left,
right,
}: QuickSearchItemContentProps) => {
const { spacingsTokens } = useCunninghamTheme();
const spacings = spacingsTokens();
const { isDesktop } = useResponsiveStore();
return (
<Box
$direction="row"
$align="center"
$padding={{ horizontal: '2xs', vertical: '3xs' }}
$justify="space-between"
$width="100%"
>
<Box
$direction="row"
$align="center"
$gap={spacings['2xs']}
$width="100%"
>
{left}
</Box>
{isDesktop && right && (
<Box
className={!alwaysShowRight ? 'show-right-on-focus' : ''}
$direction="row"
$align="center"
>
{right}
</Box>
)}
</Box>
);
};

View File

@@ -1,143 +0,0 @@
import { createGlobalStyle } from 'styled-components';
export const QuickSearchStyle = createGlobalStyle`
.quick-search-container {
[cmdk-root] {
width: 100%;
background: #ffffff;
border-radius: 12px;
overflow: hidden;
transition: transform 100ms ease;
outline: none;
}
[cmdk-input] {
border: none;
width: 100%;
font-size: 17px;
padding: 8px;
background: white;
outline: none;
color: var(--c--theme--colors--greyscale-1000);
border-radius: 0;
&::placeholder {
color: var(--c--theme--colors--greyscale-500);
}
}
[cmdk-item] {
content-visibility: auto;
cursor: pointer;
border-radius: var(--c--theme--spacings--xs);
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
user-select: none;
will-change: background, color;
transition: all 150ms ease;
transition-property: none;
.show-right-on-focus {
opacity: 0;
}
&:hover,
&[data-selected='true'] {
background: var(--c--theme--colors--greyscale-100);
.show-right-on-focus {
opacity: 1;
}
}
&[data-disabled='true'] {
color: var(--c--theme--colors--greyscale-500);
cursor: not-allowed;
}
& + [cmdk-item] {
margin-top: 4px;
}
}
[cmdk-list] {
padding: 0 var(--c--theme--spacings--base) var(--c--theme--spacings--base)
var(--c--theme--spacings--base);
flex:1;
overflow-y: auto;
overscroll-behavior: contain;
}
[cmdk-vercel-shortcuts] {
display: flex;
margin-left: auto;
gap: 8px;
kbd {
font-size: 12px;
min-width: 20px;
padding: 4px;
height: 20px;
border-radius: 4px;
color: white;
background: var(--c--theme--colors--greyscale-500);
display: inline-flex;
align-items: center;
justify-content: center;
text-transform: uppercase;
}
}
[cmdk-separator] {
height: 1px;
width: 100%;
background: var(--c--theme--colors--greyscale-500);
margin: 4px 0;
}
*:not([hidden]) + [cmdk-group] {
margin-top: 8px;
}
[cmdk-group-heading] {
user-select: none;
font-size: var(--c--theme--font--sizes--sm);
color: var(--c--theme--colors--greyscale-700);
font-weight: bold;
display: flex;
align-items: center;
margin-bottom: var(--c--theme--spacings--xs);
}
[cmdk-empty] {
}
}
.c__modal__scroller:has(.quick-search-container),
.c__modal__scroller:has(.noPadding) {
padding: 0 !important;
.c__modal__close .c__button {
right: 5px;
top: 5px;
padding: 1.5rem 1rem;
}
.c__modal__title {
font-size: var(--c--theme--font--sizes--xs);
padding: var(--c--theme--spacings--base);
margin-bottom: 0;
}
}
`;

View File

@@ -1,4 +0,0 @@
export * from './QuickSearch';
export * from './QuickSearchGroup';
export * from './QuickSearchItem';
export * from './QuickSearchItemContent';

View File

@@ -1,33 +0,0 @@
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
export enum SeparatorVariant {
LIGHT = 'light',
DARK = 'dark',
}
type Props = {
variant?: SeparatorVariant;
$withPadding?: boolean;
};
export const HorizontalSeparator = ({
variant = SeparatorVariant.LIGHT,
$withPadding = true,
}: Props) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Box
$height="1px"
$width="100%"
$margin={{ vertical: $withPadding ? 'base' : 'none' }}
$background={
variant === SeparatorVariant.DARK
? '#e5e5e533'
: colorsTokens()['greyscale-100']
}
/>
);
};

View File

@@ -1,33 +0,0 @@
import { PropsWithChildren } from 'react';
import { css } from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
type Props = {
showSeparator?: boolean;
};
export const SeparatedSection = ({
showSeparator = true,
children,
}: PropsWithChildren<Props>) => {
const theme = useCunninghamTheme();
const colors = theme.colorsTokens();
const spacings = theme.spacingsTokens();
return (
<Box
$css={css`
width: 100%;
padding: ${spacings['sm']} 0;
${showSeparator &&
css`
border-bottom: 1px solid ${colors?.['greyscale-200']};
`}
`}
>
{children}
</Box>
);
};

View File

@@ -1,2 +0,0 @@
export * from './HorizontalSeparator';
export * from './SeparatedSection';

View File

@@ -33,7 +33,7 @@ export const Auth = ({ children }: PropsWithChildren) => {
setPathAllowed(!regexpUrlsAuth.some((regexp) => !!asPath.match(regexp)));
}, [asPath]);
// We force to log in except on allowed paths
// We force to login except on allowed paths
useEffect(() => {
if (!initiated || authenticated || pathAllowed) {
return;

View File

@@ -1,4 +1,5 @@
import { Button } from '@openfun/cunningham-react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/core/auth';
@@ -9,14 +10,24 @@ export const ButtonLogin = () => {
if (!authenticated) {
return (
<Button onClick={login} color="primary-text" aria-label={t('Login')}>
<Button
onClick={login}
color="primary-text"
icon={<span className="material-icons">login</span>}
aria-label={t('Login')}
>
{t('Login')}
</Button>
);
}
return (
<Button onClick={logout} color="primary-text" aria-label={t('Logout')}>
<Button
onClick={logout}
color="primary-text"
icon={<span className="material-icons">logout</span>}
aria-label={t('Logout')}
>
{t('Logout')}
</Button>
);

View File

@@ -46,8 +46,8 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
terminateCrispSession();
window.location.replace(`${baseApiUrl()}logout/`);
},
// If we try to access a specific page, and we are not authenticated
// we store the path in the local storage to redirect to it after log in
// If we try to access a specific page and we are not authenticated
// we store the path in the local storage to redirect to it after login
setAuthUrl() {
if (window.location.pathname !== '/') {
localStorage.setItem(PATH_AUTH_LOCAL_STORAGE, window.location.pathname);

View File

@@ -351,19 +351,6 @@ input:-webkit-autofill:focus {
background-color: transparent;
}
.c__button--nano {
padding: 0 var(--c--theme--spacings--3xs);
gap: var(--c--theme--spacings--3xs);
}
.c__button--nano.c__button--icon-only {
width: auto;
}
.c__button--nano.c__button--icon-only.c__button--full-width {
width: 100%;
}
.c__button--medium {
padding: 0.9rem var(--c--theme--spacings--s);
}
@@ -455,7 +442,6 @@ input:-webkit-autofill:focus {
}
.c__button--tertiary {
background-color: var(--c--components--button--tertiary--background--color);
color: var(--c--components--button--tertiary--color);
border: none;
}
@@ -468,13 +454,6 @@ input:-webkit-autofill:focus {
color: var(--c--components--button--tertiary--color);
}
.c__button--tertiary:active {
background-color: var(
--c--components--button--tertiary--background--color-active
);
color: var(--c--components--button--tertiary--color-active);
}
.c__button--tertiary:disabled {
background-color: var(
--c--components--button--tertiary--background--color-disabled
@@ -533,32 +512,13 @@ input:-webkit-autofill:focus {
}
.c__modal__close button {
padding: 0;
font-size: 88px;
width: 28px !important;
height: 28px;
}
.c__modal__close button .material-icons {
padding: 0;
font-size: 24px;
color: var(--c--theme--colors--greyscale-600);
}
.c__modal__close .c__button {
padding: 0 !important;
padding: 1.5rem 1rem;
}
.c__modal--full .c__modal__content {
overflow-y: auto;
}
.c__modal__title {
padding: 0;
font-size: 1.125rem;
margin-bottom: var(--c--theme--spacings--2xs);
}
@media screen and (width <= 420px) {
.c__modal__scroller {
padding: 0.7rem;

View File

@@ -13,7 +13,7 @@ export const tokens = {
'secondary-700': '#97A3AE',
'secondary-800': '#757E87',
'secondary-900': '#596067',
'info-text': '#fff',
'info-text': '#FFFFFF',
'info-100': '#EBF2FC',
'info-200': '#8CB5EA',
'info-300': '#5894E1',
@@ -32,7 +32,7 @@ export const tokens = {
'greyscale-700': '#555F6B',
'greyscale-800': '#303C4B',
'greyscale-900': '#0C1A2B',
'greyscale-000': '#fff',
'greyscale-000': '#FFFFFF',
'primary-100': '#EDF5FA',
'primary-200': '#8CB5EA',
'primary-300': '#5894E1',
@@ -69,65 +69,28 @@ export const tokens = {
'danger-700': '#9B0000',
'danger-800': '#780000',
'danger-900': '#5C0000',
'primary-text': '#fff',
'success-text': '#fff',
'warning-text': '#fff',
'danger-text': '#fff',
'primary-text': '#FFFFFF',
'success-text': '#FFFFFF',
'warning-text': '#FFFFFF',
'danger-text': '#FFFFFF',
'card-border': '#ededed',
'primary-bg': '#FAFAFA',
'primary-050': '#F5F5FE',
'primary-150': '#E5EEFA',
'primary-950': '#1B1B35',
'info-150': '#E5EEFA',
'greyscale-1000': '#161616',
'blue-400': '#7AB1E8',
'blue-500': '#417DC4',
'blue-600': '#3558A2',
'brown-400': '#E6BE92',
'brown-500': '#BD987A',
'brown-600': '#745B47',
'cyan-400': '#34BAB5',
'cyan-500': '#009099',
'cyan-600': '#006A6F',
'gold-400': '#FFCA00',
'gold-500': '#C3992A',
'gold-600': '#695240',
'green-400': '#34CB6A',
'green-500': '#00A95F',
'green-600': '#297254',
'olive-400': '#99C221',
'olive-500': '#68A532',
'olive-600': '#447049',
'orange-400': '#FF732C',
'orange-500': '#E4794A',
'orange-600': '#755348',
'pink-400': '#FFB7AE',
'pink-500': '#E18B76',
'pink-600': '#8D533E',
'purple-400': '#CE70CC',
'purple-500': '#A558A0',
'purple-600': '#6E445A',
'yellow-400': '#D8C634',
'yellow-500': '#B7A73F',
'yellow-600': '#66673D',
},
font: {
sizes: {
h1: '2rem',
h2: '1.75rem',
h3: '1.5rem',
h4: '1.375rem',
h5: '1.25rem',
h6: '1.125rem',
h1: '2.2rem',
h2: '1.7rem',
h3: '1.37rem',
h4: '1.15rem',
h5: '1rem',
h6: '0.87rem',
l: '1rem',
m: '0.8125rem',
s: '0.75rem',
xs: '0.75rem',
sm: '0.875rem',
md: '1rem',
lg: '1.125rem',
ml: '0.938rem',
xl: '1.25rem',
xl: '1.50rem',
t: '0.6875rem',
},
weights: {
@@ -157,7 +120,7 @@ export const tokens = {
},
spacings: {
'0': '0',
xl: '2.5rem',
xl: '4rem',
l: '3rem',
b: '1.625rem',
s: '1rem',
@@ -167,20 +130,6 @@ export const tokens = {
auto: 'auto',
bx: '2.2rem',
full: '100%',
'4xs': '0.125rem',
'3xs': '0.25rem',
'2xs': '0.375rem',
xs: '0.5rem',
sm: '0.75rem',
base: '1rem',
md: '1.5rem',
lg: '2rem',
xxl: '3rem',
xxxl: '3.5rem',
'4xl': '4rem',
'5xl': '4.5rem',
'6xl': '6rem',
'7xl': '7.5rem',
},
transitions: {
'ease-in': 'cubic-bezier(0.32, 0, 0.67, 0)',
@@ -253,7 +202,7 @@ export const tokens = {
focus: 'var(--c--components--forms-select--border-radius)',
},
'font-size': 'var(--c--theme--font--sizes--ml)',
'menu-background-color': '#fff',
'menu-background-color': '#ffffff',
'item-background-color': {
hover: 'var(--c--theme--colors--primary-300)',
},
@@ -274,7 +223,7 @@ export const tokens = {
'border-color-hover': 'var(--c--theme--colors--greyscale-200)',
},
},
modal: { 'background-color': '#fff' },
modal: { 'background-color': '#ffffff' },
button: {
'border-radius': {
active: 'var(--c--components--button--border-radius)',
@@ -294,8 +243,8 @@ export const tokens = {
danger: {
'color-hover': 'white',
background: {
color: 'var(--c--theme--colors--danger-600)',
'color-hover': '#FF2725',
color: 'var(--c--theme--colors--danger-400)',
'color-hover': 'var(--c--theme--colors--danger-500)',
'color-disabled': 'var(--c--theme--colors--danger-100)',
},
},
@@ -321,9 +270,7 @@ export const tokens = {
color: 'var(--c--theme--colors--primary-text)',
'color-disabled': 'var(--c--theme--colors--greyscale-600)',
background: {
color: 'var(--c--theme--colors--primary-100)',
'color-hover': 'var(--c--theme--colors--primary-300)',
'color-active': 'var(--c--theme--colors--primary-100)',
'color-hover': 'var(--c--theme--colors--primary-100)',
'color-disabled': 'var(--c--theme--colors--greyscale-200)',
},
},
@@ -387,19 +334,19 @@ export const tokens = {
dsfr: {
theme: {
colors: {
'card-border': '#E5E5E5',
'card-border': '#ededed',
'primary-text': '#000091',
'primary-100': '#ECECFE',
'primary-100': '#f5f5fe',
'primary-150': '#F4F4FD',
'primary-200': '#E3E3FD',
'primary-300': '#CACAFB',
'primary-400': '#8585F6',
'primary-500': '#6A6AF4',
'primary-600': '#313178',
'primary-200': '#ececfe',
'primary-300': '#e3e3fd',
'primary-400': '#cacafb',
'primary-500': '#6a6af4',
'primary-600': '#000091',
'primary-700': '#272747',
'primary-800': '#000091',
'primary-900': '#21213F',
'secondary-text': '#fff',
'primary-800': '#21213f',
'primary-900': '#1c1a36',
'secondary-text': '#FFFFFF',
'secondary-100': '#fee9ea',
'secondary-200': '#fedfdf',
'secondary-300': '#fdbfbf',
@@ -410,22 +357,16 @@ export const tokens = {
'secondary-800': '#341f1f',
'secondary-900': '#2b1919',
'greyscale-text': '#303C4B',
'greyscale-000': '#fff',
'greyscale-050': '#F6F6F6',
'greyscale-100': '#eee',
'greyscale-200': '#E5E5E5',
'greyscale-250': '#ddd',
'greyscale-300': '#CECECE',
'greyscale-350': '#ddd',
'greyscale-400': '#929292',
'greyscale-500': '#7C7C7C',
'greyscale-600': '#666666',
'greyscale-700': '#3A3A3A',
'greyscale-750': '#353535',
'greyscale-800': '#2A2A2A',
'greyscale-900': '#242424',
'greyscale-950': '#1E1E1E',
'greyscale-1000': '#161616',
'greyscale-000': '#f6f6f6',
'greyscale-100': '#eeeeee',
'greyscale-200': '#e5e5e5',
'greyscale-300': '#e1e1e1',
'greyscale-400': '#dddddd',
'greyscale-500': '#cecece',
'greyscale-600': '#7b7b7b',
'greyscale-700': '#666666',
'greyscale-800': '#2a2a2a',
'greyscale-900': '#1e1e1e',
'success-text': '#1f8d49',
'success-100': '#dffee6',
'success-200': '#b8fec9',
@@ -437,15 +378,15 @@ export const tokens = {
'success-800': '#1e2e22',
'success-900': '#19281d',
'info-text': '#0078f3',
'info-100': '#E8EDFF',
'info-200': '#DDE5FF',
'info-300': '#BCCDFF',
'info-400': '#518FFF',
'info-500': '#0078F3',
'info-600': '#0063CB',
'info-700': '#273961',
'info-800': '#222A3F',
'info-900': '#1D2437',
'info-100': '#f4f6ff',
'info-200': '#e8edff',
'info-300': '#dde5ff',
'info-400': '#bdcdff',
'info-500': '#0078f3',
'info-600': '#0063cb',
'info-700': '#f4f6ff',
'info-800': '#222a3f',
'info-900': '#1d2437',
'warning-text': '#d64d00',
'warning-100': '#fff4f3',
'warning-200': '#ffe9e6',
@@ -456,16 +397,16 @@ export const tokens = {
'warning-700': '#5e2c21',
'warning-800': '#3e241e',
'warning-900': '#361e19',
'danger-text': '#FFF',
'danger-100': '#FFE9E9',
'danger-200': '#FFDDDD',
'danger-300': '#FFBDBD',
'danger-400': '#FF5655',
'danger-500': '#F60700',
'danger-600': '#CE0500',
'danger-700': '#642626',
'danger-text': '#e1000f',
'danger-100': '#fef4f4',
'danger-200': '#fee9e9',
'danger-300': '#fddede',
'danger-400': '#fcbfbf',
'danger-500': '#e1000f',
'danger-600': '#c9191e',
'danger-700': '#642727',
'danger-800': '#412121',
'danger-900': '#391C1C',
'danger-900': '#3a1c1c',
},
font: { families: { accent: 'Marianne', base: 'Marianne' } },
logo: {
@@ -477,10 +418,8 @@ export const tokens = {
},
components: {
alert: { 'border-radius': '0' },
modal: { 'width-small': '342px' },
button: {
'medium-height': '40px',
'medium-text-height': '40px',
'medium-height': '48px',
'border-radius': '4px',
primary: {
background: {
@@ -488,9 +427,9 @@ export const tokens = {
'color-hover': '#1212ff',
'color-active': '#2323ff',
},
color: '#fff',
'color-hover': '#fff',
'color-active': '#fff',
color: '#ffffff',
'color-hover': '#ffffff',
'color-active': '#ffffff',
},
'primary-text': {
background: {
@@ -509,7 +448,7 @@ export const tokens = {
},
'tertiary-text': {
background: {
'color-hover': 'var(--c--theme--colors--greyscale-100)',
'color-hover': 'var(--c--theme--colors--primary-100)',
},
'color-hover': 'var(--c--theme--colors--primary-text)',
color: 'var(--c--theme--colors--primary-600)',
@@ -547,7 +486,7 @@ export const tokens = {
},
'forms-input': {
'border-radius': '4px',
'background-color': '#fff',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
'value-color': 'var(--c--theme--colors--primary-text)',
@@ -563,7 +502,7 @@ export const tokens = {
'item-font-size': '14px',
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#fff',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'border-color-hover': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',

View File

@@ -5,8 +5,6 @@ import { tokens } from './cunningham-tokens';
type Tokens = typeof tokens.themes.default & Partial<typeof tokens.themes.dsfr>;
type ColorsTokens = Tokens['theme']['colors'];
type FontSizesTokens = Tokens['theme']['font']['sizes'];
type SpacingsTokens = Tokens['theme']['spacings'];
type ComponentTokens = Tokens['components'];
export type Theme = keyof typeof tokens.themes;
@@ -15,8 +13,6 @@ interface AuthStore {
setTheme: (theme: Theme) => void;
themeTokens: () => Partial<Tokens['theme']>;
colorsTokens: () => Partial<ColorsTokens>;
fontSizesTokens: () => Partial<FontSizesTokens>;
spacingsTokens: () => Partial<SpacingsTokens>;
componentTokens: () => ComponentTokens;
}
@@ -32,8 +28,6 @@ export const useCunninghamTheme = create<AuthStore>((set, get) => {
themeTokens: () => currentTheme().theme,
colorsTokens: () => currentTheme().theme.colors,
componentTokens: () => currentTheme().components,
spacingsTokens: () => currentTheme().theme.spacings,
fontSizesTokens: () => currentTheme().theme.font.sizes,
setTheme: (theme: Theme) => {
set({ theme });
},

View File

@@ -4,13 +4,13 @@ import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect } from 'react';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
import { useAuthStore } from '@/core/auth';
import { Doc } from '@/features/docs/doc-management';
import { Doc, Role, currentDocRole } from '@/features/docs/doc-management';
import { useUploadFile } from '../hook';
import { useHeadings } from '../hook/useHeadings';
@@ -22,53 +22,12 @@ import { BlockNoteToolbar } from './BlockNoteToolbar';
const cssEditor = (readonly: boolean) => `
&, & > .bn-container, & .ProseMirror {
height:100%;
.bn-side-menu[data-block-type=heading][data-level="1"] {
height: 50px;
}
.bn-side-menu[data-block-type=heading][data-level="2"] {
height: 43px;
}
.bn-side-menu[data-block-type=heading][data-level="3"] {
height: 35px;
}
h1 {
font-size: 1.875rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.25rem;
}
a {
color: var(--c--theme--colors--greyscale-500);
cursor: pointer;
}
.bn-block-group
.bn-block-group
.bn-block-outer:not([data-prev-depth-changed]):before {
border-left: none;
}
}
.bn-editor {
color: var(--c--theme--colors--greyscale-700);
}
.bn-block-outer:not(:first-child) {
&:has(h1) {
padding-top: 32px;
}
&:has(h2) {
padding-top: 24px;
}
&:has(h3) {
padding-top: 16px;
}
height:100%
};
& .bn-editor {
padding-right: 30px;
${readonly && `padding-left: 30px;`}
};
& .bn-inline-content code {
background-color: gainsboro;
padding: 2px;
@@ -76,7 +35,8 @@ const cssEditor = (readonly: boolean) => `
}
@media screen and (width <= 560px) {
& .bn-editor {
padding-left: 40px;
padding-right: 10px;
${readonly && `padding-left: 10px;`}
};
.bn-side-menu[data-block-type=heading][data-level="1"] {
@@ -167,6 +127,23 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
);
useHeadings(editor);
/**
* With the collaboration it gets complicated to create the initial block
* better to let Blocknote manage, then we update the block with the content.
*/
useEffect(() => {
if (doc.content || currentDocRole(doc.abilities) !== Role.OWNER) {
return;
}
setTimeout(() => {
editor.updateBlock(editor.document[0], {
type: 'heading',
content: '',
});
}, 100);
}, [editor, doc.content, doc.abilities]);
useEffect(() => {
setEditor(editor);

View File

@@ -1,36 +1,40 @@
import { Loader } from '@openfun/cunningham-react';
import { Alert, Loader, VariantType } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { css } from 'styled-components';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { Box, Text, TextErrors } from '@/components';
import { Box, Card, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocHeader, DocVersionHeader } from '@/features/docs/doc-header/';
import { DocHeader } from '@/features/docs/doc-header';
import {
Doc,
base64ToBlocknoteXmlFragment,
useProviderStore,
useDocStore,
} from '@/features/docs/doc-management';
import { TableContent } from '@/features/docs/doc-table-content/';
import { Versions, useDocVersion } from '@/features/docs/doc-versioning/';
import { useResponsiveStore } from '@/stores';
import { BlockNoteEditor, BlockNoteEditorVersion } from './BlockNoteEditor';
import { IconOpenPanelEditor, PanelEditor } from './PanelEditor';
interface DocEditorProps {
doc: Doc;
versionId?: Versions['version_id'];
}
export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
const { isDesktop } = useResponsiveStore();
export const DocEditor = ({ doc }: DocEditorProps) => {
const {
query: { versionId },
} = useRouter();
const { t } = useTranslation();
const { isMobile } = useResponsiveStore();
const isVersion = !!versionId && typeof versionId === 'string';
const isVersion = versionId && typeof versionId === 'string';
const { colorsTokens } = useCunninghamTheme();
const { provider } = useProviderStore();
const { providers } = useDocStore();
const provider = providers?.[doc.id];
if (!provider) {
return null;
@@ -38,41 +42,43 @@ export const DocEditor = ({ doc, versionId }: DocEditorProps) => {
return (
<>
{isDesktop && !isVersion && (
<Box
$position="absolute"
$css={css`
top: 72px;
right: 20px;
`}
>
<TableContent />
<DocHeader doc={doc} />
{!doc.abilities.partial_update && (
<Box $margin={{ all: 'small', top: 'none' }}>
<Alert type={VariantType.WARNING}>
{t(`Read only, you cannot edit this document.`)}
</Alert>
</Box>
)}
<Box $maxWidth="868px" $width="100%" $height="100%">
<Box $padding={{ horizontal: isDesktop ? '54px' : 'base' }}>
{isVersion ? (
<DocVersionHeader title={doc.title} />
) : (
<DocHeader doc={doc} />
)}
{isVersion && (
<Box $margin={{ all: 'small', top: 'none' }}>
<Alert type={VariantType.WARNING}>
{t(`Read only, you cannot edit document versions.`)}
</Alert>
</Box>
<Box
$background={colorsTokens()['primary-bg']}
$direction="row"
$width="100%"
$css="overflow-x: clip; flex: 1;"
)}
<Box
$background={colorsTokens()['primary-bg']}
$height="100%"
$direction="row"
$margin={{ all: isMobile ? 'tiny' : 'small', top: 'none' }}
$css="overflow-x: clip;"
$position="relative"
>
<Card
$padding={isMobile ? 'small' : 'big'}
$css="flex:1;"
$overflow="auto"
$position="relative"
>
<Box $css="flex:1;" $overflow="auto" $position="relative">
{isVersion ? (
<DocVersionEditor docId={doc.id} versionId={versionId} />
) : (
<BlockNoteEditor doc={doc} provider={provider} />
)}
</Box>
</Box>
{isVersion ? (
<DocVersionEditor docId={doc.id} versionId={versionId} />
) : (
<BlockNoteEditor doc={doc} provider={provider} />
)}
{!isMobile && <IconOpenPanelEditor />}
</Card>
<PanelEditor doc={doc} />
</Box>
</>
);

View File

@@ -0,0 +1,197 @@
import React, { PropsWithChildren, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Card, IconBG, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc } from '@/features/docs/doc-management';
import { TableContent } from '@/features/docs/doc-table-content';
import { VersionList } from '@/features/docs/doc-versioning';
import { useResponsiveStore } from '@/stores';
import { useHeadingStore, usePanelEditorStore } from '../stores';
interface PanelProps {
doc: Doc;
}
export const PanelEditor = ({ doc }: PropsWithChildren<PanelProps>) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { isMobile } = useResponsiveStore();
const { isPanelTableContentOpen, setIsPanelTableContentOpen, isPanelOpen } =
usePanelEditorStore();
return (
<Card
$width="100%"
$maxWidth="20rem"
$position={isMobile ? 'absolute' : 'sticky'}
$height="100%"
$hasTransition="slow"
$css={`
top: 0vh;
right: 0;
transform: translateX(0%);
flex: 1;
margin-left: 1rem;
${
!isPanelOpen &&
`
transform: translateX(200%);
opacity: 0;
flex: 0;
margin-left: 0rem;
max-width: 0rem;
`
}
`}
aria-label={t('Document panel')}
aria-hidden={!isPanelOpen}
>
<Box
$overflow="inherit"
$position="sticky"
$hasTransition="slow"
$css={`
top: 0;
opacity: ${isPanelOpen ? '1' : '0'};
`}
$maxHeight="99vh"
>
{isMobile && <IconOpenPanelEditor />}
<Box
$direction="row"
$justify="space-between"
$align="center"
$position="relative"
$background={colorsTokens()['primary-400']}
$margin={{ bottom: 'tiny' }}
$radius="4px 4px 0 0"
>
<Box
$background="white"
$position="absolute"
$height="100%"
$width={doc.abilities.versions_list ? '50%' : '100%'}
$hasTransition="slow"
$css={`
border-top: 2px solid ${colorsTokens()['primary-600']};
border-radius: 0 4px 0 0;
${
isPanelTableContentOpen
? `
transform: translateX(0);
border-radius: 4px 0 0 0;
`
: `transform: translateX(100%);`
}
`}
/>
<BoxButton
$minWidth={doc.abilities.versions_list ? '50%' : '100%'}
onClick={() => setIsPanelTableContentOpen(true)}
$zIndex={1}
>
<Text
$width="100%"
$weight="bold"
$size="m"
$theme="primary"
$variation="600"
$padding={{ vertical: 'small', horizontal: 'small' }}
>
{t('Table of content')}
</Text>
</BoxButton>
{doc.abilities.versions_list && (
<BoxButton
$minWidth="50%"
onClick={() => setIsPanelTableContentOpen(false)}
$zIndex={1}
>
<Text
$width="100%"
$weight="bold"
$size="m"
$theme="primary"
$variation="600"
$padding={{ vertical: 'small', horizontal: 'small' }}
>
{t('Versions')}
</Text>
</BoxButton>
)}
</Box>
{isPanelTableContentOpen && <TableContent />}
{!isPanelTableContentOpen && doc.abilities.versions_list && (
<VersionList doc={doc} />
)}
</Box>
</Card>
);
};
export const IconOpenPanelEditor = () => {
const { headings } = useHeadingStore();
const { t } = useTranslation();
const { setIsPanelOpen, isPanelOpen, setIsPanelTableContentOpen } =
usePanelEditorStore();
const [hasBeenOpen, setHasBeenOpen] = useState(isPanelOpen);
const { isMobile } = useResponsiveStore();
const setClosePanel = () => {
setHasBeenOpen(true);
setIsPanelOpen(!isPanelOpen);
};
// Open the panel if there are more than 1 heading
useEffect(() => {
if (headings?.length && headings.length > 1 && !hasBeenOpen && !isMobile) {
setIsPanelTableContentOpen(true);
setIsPanelOpen(true);
setHasBeenOpen(true);
}
}, [
headings,
setIsPanelTableContentOpen,
setIsPanelOpen,
hasBeenOpen,
isMobile,
]);
// If open from the doc header we set the state as well
useEffect(() => {
if (isPanelOpen && !hasBeenOpen) {
setHasBeenOpen(true);
}
}, [hasBeenOpen, isPanelOpen]);
// Close the panel unmount
useEffect(() => {
return () => {
setIsPanelOpen(false);
};
}, [setIsPanelOpen]);
return (
<IconBG
iconName="menu_open"
aria-label={isPanelOpen ? t('Close the panel') : t('Open the panel')}
$background="transparent"
$size="h2"
$zIndex={10}
$hasTransition="slow"
$css={`
cursor: pointer;
right: 0rem;
top: 0.1rem;
transform: rotate(${isPanelOpen ? '180deg' : '0deg'});
user-select: none;
${hasBeenOpen ? 'display:flex;' : 'display: none;'}
`}
$position="absolute"
onClick={setClosePanel}
$radius="2px"
/>
);
};

View File

@@ -10,7 +10,7 @@ import { toBase64 } from '../utils';
const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
const { mutate: updateDoc } = useUpdateDoc({
listInvalidQueries: [KEY_LIST_DOC_VERSIONS],
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
});
const [initialDoc, setInitialDoc] = useState<string>(
toBase64(Y.encodeStateAsUpdate(doc)),

View File

@@ -1,17 +1,14 @@
import { DateTime } from 'luxon';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, HorizontalSeparator, Icon, Text } from '@/components';
import { Box, Card, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
LinkReach,
currentDocRole,
useTrans,
} from '@/features/docs/doc-management';
import { Doc, currentDocRole, useTrans } from '@/features/docs/doc-management';
import { useDate } from '@/hook';
import { useResponsiveStore } from '@/stores';
import { DocTagPublic } from './DocTagPublic';
import { DocTitle } from './DocTitle';
import { DocToolBox } from './DocToolBox';
@@ -20,86 +17,89 @@ interface DocHeaderProps {
}
export const DocHeader = ({ doc }: DocHeaderProps) => {
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const spacings = spacingsTokens();
const colors = colorsTokens();
const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
const docIsPublic = doc.link_reach === LinkReach.PUBLIC;
const { formatDate } = useDate();
const { transRole } = useTrans();
const { isMobile, isSmallMobile } = useResponsiveStore();
return (
<>
<Box
$width="100%"
$padding={{ top: isDesktop ? '4xl' : 'md' }}
$gap={spacings['base']}
<Card
$margin={isMobile ? 'tiny' : 'small'}
aria-label={t('It is the card information about the document.')}
>
{docIsPublic && (
<Box
aria-label={t('Public document')}
$color={colors['primary-800']}
$background={colors['primary-100']}
$radius={spacings['3xs']}
$direction="row"
$padding="xs"
$flex={1}
$align="center"
$gap={spacings['3xs']}
$css={css`
border: 1px solid var(--c--theme--colors--primary-300, #e3e3fd);
`}
>
<Icon
<Box
$padding={isMobile ? 'tiny' : 'small'}
$direction="row"
$align="center"
>
<StyledLink href="/">
<Text
$isMaterialIcon
$theme="primary"
$variation="800"
data-testid="public-icon"
iconName="public"
/>
<Text $theme="primary" $variation="800">
{t('Public document')}
$variation="600"
$size="2rem"
$css={css`
&:hover {
background-color: ${colorsTokens()['primary-100']};
}
`}
$hasTransition
$radius="5px"
$padding="tiny"
>
home
</Text>
</Box>
)}
<Box $direction="row" $align="center" $width="100%">
</StyledLink>
<Box
$width="1px"
$height="70%"
$background={colorsTokens()['greyscale-100']}
$margin={{ horizontal: 'tiny' }}
/>
<Box
$direction="row"
$justify="space-between"
$css="flex:1;"
$gap="0.5rem 1rem"
$wrap="wrap"
$align="center"
>
<Box $gap={spacings['3xs']}>
<DocTitle doc={doc} />
<Box $direction="row">
{isDesktop && (
<>
<Text $variation="600" $size="s" $weight="bold">
{transRole(currentDocRole(doc.abilities))}&nbsp;·&nbsp;
</Text>
<Text $variation="600" $size="s">
{t('Last update: {{update}}', {
update: DateTime.fromISO(doc.updated_at).toRelative(),
})}
</Text>
</>
)}
{!isDesktop && (
<Text $variation="400" $size="s">
{DateTime.fromISO(doc.updated_at).toRelative()}
</Text>
)}
</Box>
</Box>
<DocTitle doc={doc} />
<DocToolBox doc={doc} />
</Box>
</Box>
<HorizontalSeparator $withPadding={true} />
</Box>
<Box
$direction={isSmallMobile ? 'column' : 'row'}
$align={isSmallMobile ? 'start' : 'center'}
$css="border-top:1px solid #eee"
$padding={{
horizontal: isMobile ? 'tiny' : 'big',
vertical: 'tiny',
}}
$gap="0.5rem 2rem"
$justify="space-between"
$wrap="wrap"
$position="relative"
>
<Box
$direction={isSmallMobile ? 'column' : 'row'}
$align={isSmallMobile ? 'start' : 'center'}
$gap="0.5rem 2rem"
$wrap="wrap"
>
<DocTagPublic doc={doc} />
<Text $size="s" $display="inline">
{t('Created at')} <strong>{formatDate(doc.created_at)}</strong>
</Text>
</Box>
<Text $size="s" $display="inline">
{t('Your role:')}{' '}
<strong>{transRole(currentDocRole(doc.abilities))}</strong>
</Text>
</Box>
</Card>
</>
);
};

View File

@@ -5,12 +5,12 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useHeadingStore } from '@/features/docs/doc-editor';
import {
Doc,
KEY_DOC,
@@ -19,50 +19,45 @@ import {
useUpdateDoc,
} from '@/features/docs/doc-management';
import { useBroadcastStore, useResponsiveStore } from '@/stores';
import { isFirefox } from '@/utils/userAgent';
interface DocTitleProps {
doc: Doc;
}
export const DocTitle = ({ doc }: DocTitleProps) => {
const { isMobile } = useResponsiveStore();
if (!doc.abilities.partial_update) {
return <DocTitleText title={doc.title} />;
return (
<Text
as="h2"
$margin={{ all: 'none', left: 'tiny' }}
$size={isMobile ? 'h4' : 'h2'}
>
{doc.title}
</Text>
);
}
return <DocTitleInput doc={doc} />;
};
interface DocTitleTextProps {
title: string;
}
export const DocTitleText = ({ title }: DocTitleTextProps) => {
const { isMobile } = useResponsiveStore();
return (
<Text
as="h2"
$margin={{ all: 'none', left: 'none' }}
$size={isMobile ? 'h4' : 'h2'}
$variation="1000"
>
{title}
</Text>
);
};
const DocTitleInput = ({ doc }: DocTitleProps) => {
const { isDesktop } = useResponsiveStore();
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [titleDisplay, setTitleDisplay] = useState(doc.title);
const { toast } = useToastProvider();
const { untitledDocument } = useTrans();
const isUntitled = titleDisplay === untitledDocument;
const { headings } = useHeadingStore();
const headingText = headings?.[0]?.contentText;
const debounceRef = useRef<NodeJS.Timeout>();
const { isMobile } = useResponsiveStore();
const { broadcast } = useBroadcastStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalidQueries: [KEY_DOC, KEY_LIST_DOC],
listInvalideQueries: [KEY_LIST_DOC],
onSuccess(data) {
if (data.title !== untitledDocument) {
toast(t('Document title updated successfully'), VariantType.SUCCESS);
@@ -86,7 +81,10 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
// If mutation we update
if (sanitizedTitle !== doc.title) {
setTitleDisplay(sanitizedTitle);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
debounceRef.current = undefined;
}
updateDoc({ id: doc.id, title: sanitizedTitle });
}
},
@@ -100,42 +98,74 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
}
};
const handleOnClick = () => {
if (isUntitled) {
setTitleDisplay('');
}
};
useEffect(() => {
setTitleDisplay(doc.title);
}, [doc]);
}, [doc.title]);
useEffect(() => {
if ((!debounceRef.current && !isUntitled) || !headingText) {
return;
}
setTitleDisplay(headingText);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
handleTitleSubmit(headingText);
debounceRef.current = undefined;
}, 3000);
}, [isUntitled, handleTitleSubmit, headingText]);
return (
<>
<Tooltip content={t('Rename')} placement="top">
<Box
as="span"
role="textbox"
contentEditable
defaultValue={isUntitled ? undefined : titleDisplay}
as="h2"
$radius="4px"
$padding={{ horizontal: 'tiny', vertical: '4px' }}
$margin="none"
$minWidth="200px"
contentEditable={isFirefox() ? 'true' : 'plaintext-only'}
onClick={handleOnClick}
onBlurCapture={(e) =>
handleTitleSubmit(e.currentTarget.textContent || '')
}
onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true}
aria-label="doc title input"
onBlurCapture={(event) =>
handleTitleSubmit(event.target.textContent || '')
$color={
isUntitled
? colorsTokens()['greyscale-200']
: colorsTokens()['greyscale-text']
}
$color={colorsTokens()['greyscale-1000']}
$margin={{ left: '-2px', right: '10px' }}
$css={css`
&[contenteditable='true']:empty:not(:focus):before {
content: '${untitledDocument}';
color: grey;
pointer-events: none;
font-style: italic;
$css={`
${isUntitled && 'font-style: italic;'}
cursor: text;
font-size: ${isMobile ? '1.2rem' : '1.5rem'};
transition: box-shadow 0.5s, border-color 0.5s;
border: 1px dashed transparent;
&:hover {
border-color: rgba(0, 123, 255, 0.25);
border-style: dashed;
}
font-size: ${isDesktop
? css`var(--c--theme--font--sizes--h2)`
: css`var(--c--theme--font--sizes--sm)`};
font-weight: 700;
outline: none;
&:focus {
outline: none;
border-color: transparent;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
`}
>
{isUntitled ? '' : titleDisplay}
{titleDisplay}
</Box>
</Tooltip>
</>

View File

@@ -1,30 +1,24 @@
import {
Button,
VariantType,
useModal,
useToastProvider,
} from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import {
Box,
DropdownMenu,
DropdownMenuOption,
Icon,
IconOptions,
} from '@/components';
import { Box, DropButton, IconOptions } from '@/components';
import { useAuthStore } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import { useEditorStore } from '@/features/docs/doc-editor/';
import { Doc, ModalRemoveDoc } from '@/features/docs/doc-management';
import { DocShareModal } from '@/features/docs/doc-share';
import {
KEY_LIST_DOC_VERSIONS,
ModalSelectVersion,
} from '@/features/docs/doc-versioning';
useEditorStore,
usePanelEditorStore,
} from '@/features/docs/doc-editor/';
import {
Doc,
ModalRemoveDoc,
ModalShare,
} from '@/features/docs/doc-management';
import { ModalVersion } from '@/features/docs/doc-versioning';
import { useResponsiveStore } from '@/stores';
import { ModalPDF } from './ModalExport';
@@ -34,78 +28,21 @@ interface DocToolBoxProps {
}
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const {
query: { versionId },
} = useRouter();
const { t } = useTranslation();
const hasAccesses = doc.nb_accesses > 1;
const queryClient = useQueryClient();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const spacings = spacingsTokens();
const colors = colorsTokens();
const [isModalShareOpen, setIsModalShareOpen] = useState(false);
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isModalPDFOpen, setIsModalPDFOpen] = useState(false);
const selectHistoryModal = useModal();
const modalShare = useModal();
const { isSmallMobile, isDesktop } = useResponsiveStore();
const [isDropOpen, setIsDropOpen] = useState(false);
const { setIsPanelOpen, setIsPanelTableContentOpen } = usePanelEditorStore();
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
const { isSmallMobile } = useResponsiveStore();
const { authenticated } = useAuthStore();
const { editor } = useEditorStore();
const { toast } = useToastProvider();
const options: DropdownMenuOption[] = [
...(isSmallMobile
? [
{
label: t('Share'),
icon: 'upload',
callback: () => {
modalShare.open();
},
},
{
label: t('Export'),
icon: 'download',
callback: () => {
setIsModalPDFOpen(true);
},
},
]
: []),
{
label: t('Version history'),
icon: 'history',
disabled: !doc.abilities.versions_list,
callback: () => {
selectHistoryModal.open();
},
show: isDesktop,
},
{
label: t('Copy as {{format}}', { format: 'Markdown' }),
icon: 'content_copy',
callback: () => {
void copyCurrentEditorToClipboard('markdown');
},
},
{
label: t('Copy as {{format}}', { format: 'HTML' }),
icon: 'content_copy',
callback: () => {
void copyCurrentEditorToClipboard('html');
},
},
{
label: t('Delete document'),
icon: 'delete',
disabled: !doc.abilities.destroy,
callback: () => {
setIsModalRemoveOpen(true);
},
},
];
const copyCurrentEditorToClipboard = async (
asFormat: 'html' | 'markdown',
) => {
@@ -129,16 +66,6 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
}
};
useEffect(() => {
if (selectHistoryModal.isOpen) {
return;
}
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC_VERSIONS],
});
}, [selectHistoryModal.isOpen, queryClient]);
return (
<Box
$margin={{ left: 'auto' }}
@@ -147,88 +74,118 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
$gap="0.5rem 1.5rem"
$wrap={isSmallMobile ? 'wrap' : 'nowrap'}
>
<Box
$direction="row"
$align="center"
$margin={{ left: 'auto' }}
$gap={spacings['2xs']}
>
{authenticated && !isSmallMobile && (
<>
{!hasAccesses && (
<Button
color="tertiary-text"
onClick={() => {
modalShare.open();
}}
size={isSmallMobile ? 'small' : 'medium'}
>
{t('Share')}
</Button>
)}
{hasAccesses && (
<Box
$css={css`
.c__button--medium {
height: 32px;
padding: 10px var(--c--theme--spacings--xs);
gap: 7px;
}
`}
>
<Button
color="tertiary"
aria-label="Share button"
icon={
<Icon iconName="group" $theme="primary" $variation="800" />
}
onClick={() => {
modalShare.open();
}}
size={isSmallMobile ? 'small' : 'medium'}
>
{doc.nb_accesses}
</Button>
</Box>
)}
</>
)}
{!isSmallMobile && (
{versionId && (
<Box $margin={{ left: 'auto' }}>
<Button
color="tertiary-text"
icon={
<Icon iconName="download" $theme="primary" $variation="800" />
}
onClick={() => {
setIsModalPDFOpen(true);
setIsModalVersionOpen(true);
}}
color="secondary"
size={isSmallMobile ? 'small' : 'medium'}
>
{t('Restore this version')}
</Button>
</Box>
)}
<Box $direction="row" $margin={{ left: 'auto' }} $gap="1rem">
{authenticated && (
<Button
onClick={() => {
setIsModalShareOpen(true);
}}
size={isSmallMobile ? 'small' : 'medium'}
/>
>
{t('Share')}
</Button>
)}
<DropdownMenu options={options}>
<IconOptions
isHorizontal
$theme="primary"
$padding={{ all: 'xs' }}
$css={css`
border-radius: 4px;
&:hover {
background-color: ${colors['greyscale-100']};
}
${isSmallMobile
? css`
padding: 10px;
border: 1px solid ${colors['greyscale-300']};
`
: ''}
`}
aria-label={t('Open the document options')}
/>
</DropdownMenu>
<DropButton
button={
<IconOptions
isOpen={isDropOpen}
aria-label={t('Open the document options')}
/>
}
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
isOpen={isDropOpen}
>
<Box>
{doc.abilities.versions_list && (
<Button
onClick={() => {
setIsPanelOpen(true);
setIsPanelTableContentOpen(false);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">history</span>}
size="small"
>
{t('Version history')}
</Button>
)}
<Button
onClick={() => {
setIsPanelOpen(true);
setIsPanelTableContentOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">summarize</span>}
size="small"
>
{t('Table of contents')}
</Button>
<Button
onClick={() => {
setIsModalPDFOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">file_download</span>}
size="small"
>
{t('Export')}
</Button>
{doc.abilities.destroy && (
<Button
onClick={() => {
setIsModalRemoveOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">delete</span>}
size="small"
>
{t('Delete document')}
</Button>
)}
<Button
onClick={() => {
setIsDropOpen(false);
void copyCurrentEditorToClipboard('markdown');
}}
color="primary-text"
icon={<span className="material-icons">content_copy</span>}
size="small"
>
{t('Copy as {{format}}', { format: 'Markdown' })}
</Button>
<Button
onClick={() => {
setIsDropOpen(false);
void copyCurrentEditorToClipboard('html');
}}
color="primary-text"
icon={<span className="material-icons">content_copy</span>}
size="small"
>
{t('Copy as {{format}}', { format: 'HTML' })}
</Button>
</Box>
</DropButton>
</Box>
{modalShare.isOpen && (
<DocShareModal onClose={() => modalShare.close()} doc={doc} />
{isModalShareOpen && (
<ModalShare onClose={() => setIsModalShareOpen(false)} doc={doc} />
)}
{isModalPDFOpen && (
<ModalPDF onClose={() => setIsModalPDFOpen(false)} doc={doc} />
@@ -236,10 +193,11 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
{isModalRemoveOpen && (
<ModalRemoveDoc onClose={() => setIsModalRemoveOpen(false)} doc={doc} />
)}
{selectHistoryModal.isOpen && (
<ModalSelectVersion
onClose={() => selectHistoryModal.close()}
doc={doc}
{isModalVersionOpen && versionId && (
<ModalVersion
onClose={() => setIsModalVersionOpen(false)}
docId={doc.id}
versionId={versionId as string}
/>
)}
</Box>

View File

@@ -1,31 +0,0 @@
import { useTranslation } from 'react-i18next';
import { Box, HorizontalSeparator } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocTitleText } from './DocTitle';
interface DocVersionHeaderProps {
title: string;
}
export const DocVersionHeader = ({ title }: DocVersionHeaderProps) => {
const { spacingsTokens } = useCunninghamTheme();
const spacings = spacingsTokens();
const { t } = useTranslation();
return (
<>
<Box
$width="100%"
$padding={{ vertical: 'base' }}
$gap={spacings['base']}
aria-label={t('It is the document title')}
>
<DocTitleText title={title} />
<HorizontalSeparator />
</Box>
</>
);
};

View File

@@ -1,8 +1,11 @@
import {
Alert,
Button,
Loader,
Modal,
ModalSize,
Radio,
RadioGroup,
Select,
VariantType,
useToastProvider,
@@ -18,11 +21,6 @@ import { useExport } from '../api/useExport';
import { TemplatesOrdering, useTemplates } from '../api/useTemplates';
import { adaptBlockNoteHTML, downloadFile } from '../utils';
export enum DocDownloadFormat {
PDF = 'pdf',
DOCX = 'docx',
}
interface ModalPDFProps {
onClose: () => void;
doc: Doc;
@@ -43,9 +41,7 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
error,
} = useExport();
const [templateIdSelected, setTemplateIdSelected] = useState<string>();
const [format, setFormat] = useState<DocDownloadFormat>(
DocDownloadFormat.PDF,
);
const [format, setFormat] = useState<'pdf' | 'docx'>('pdf');
const templateOptions = useMemo(() => {
if (!templates?.pages) {
@@ -127,48 +123,61 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
return (
<Modal
data-testid="modal-export"
isOpen
closeOnClickOutside
hideCloseButton
leftActions={
<Button
aria-label={t('Close the modal')}
color="secondary"
fullWidth
onClick={() => onClose()}
>
{t('Cancel')}
</Button>
}
onClose={() => onClose()}
rightActions={
<>
<Button
aria-label={t('Close the modal')}
color="secondary"
fullWidth
onClick={() => onClose()}
>
{t('Cancel')}
</Button>
<Button
aria-label={t('Download')}
color="primary"
fullWidth
onClick={() => void onSubmit()}
disabled={isPending || !templateIdSelected}
>
{t('Download')}
</Button>
</>
<Button
aria-label={t('Download')}
color="primary"
fullWidth
onClick={() => void onSubmit()}
disabled={isPending || !templateIdSelected}
>
{t('Download')}
</Button>
}
size={ModalSize.MEDIUM}
title={
<Text $size="h6" $variation="1000" $align="flex-start">
{t('Download')}
</Text>
<Box $align="center" $gap="1rem">
<Text
className="material-icons"
$size="3.5rem"
$theme="primary"
$variation="600"
>
picture_as_pdf
</Text>
<Text as="h2" $size="h3" $margin="none" $theme="primary">
{t('Export')}
</Text>
</Box>
}
>
<Box
$margin={{ bottom: 'xl' }}
aria-label={t('Content modal to export the document')}
$gap="1rem"
$gap="1.5rem"
>
<Text $variation="600" $size="sm">
{t(
'Upload your docs to a Microsoft Word, Open Office or PDF document.',
)}
</Text>
<Alert canClose={false} type={VariantType.INFO}>
<Text>
{t(
'Export your document, it will be inserted in the selected template.',
)}
</Text>
</Alert>
<Select
clearable={false}
label={t('Template')}
@@ -178,19 +187,22 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
setTemplateIdSelected(options.target.value as string)
}
/>
<Select
clearable={false}
fullWidth
label={t('Format')}
options={[
{ label: t('Word / Open Office'), value: DocDownloadFormat.DOCX },
{ label: t('PDF'), value: DocDownloadFormat.PDF },
]}
value={format}
onChange={(options) =>
setFormat(options.target.value as DocDownloadFormat)
}
/>
<RadioGroup>
<Radio
label={t('PDF')}
value="pdf"
name="format"
onChange={(evt) => setFormat(evt.target.value as 'pdf')}
defaultChecked={true}
/>
<Radio
label={t('Docx')}
value="docx"
name="format"
onChange={(evt) => setFormat(evt.target.value as 'docx')}
/>
</RadioGroup>
{isPending && (
<Box $align="center" $margin={{ top: 'big' }}>

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