mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-06 15:12:27 +02:00
Compare commits
93 Commits
docs-0.0.1
...
feature/ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef77889d25 | ||
|
|
b93b43abe8 | ||
|
|
dd8bb18f69 | ||
|
|
545e8b2a3c | ||
|
|
81837aff2b | ||
|
|
40c1107959 | ||
|
|
0d7d42254b | ||
|
|
67dc7feb98 | ||
|
|
5b4b100e90 | ||
|
|
b8be010389 | ||
|
|
97cfa2c1ad | ||
|
|
c018c6fcf5 | ||
|
|
70048328d1 | ||
|
|
55ddfe9181 | ||
|
|
ee41d156c7 | ||
|
|
5be2bc7360 | ||
|
|
e46ba4f506 | ||
|
|
7c8b969fa9 | ||
|
|
95515fd460 | ||
|
|
ce6cfc22ef | ||
|
|
4b3b441fc3 | ||
|
|
9194bf5a90 | ||
|
|
dc63a5839e | ||
|
|
d406846986 | ||
|
|
e85b07021e | ||
|
|
282200ac3d | ||
|
|
de8dea20d5 | ||
|
|
342fc2ab59 | ||
|
|
b8132ef393 | ||
|
|
2ede746d8a | ||
|
|
5bd0764bdd | ||
|
|
610948cd16 | ||
|
|
96bb99d6ec | ||
|
|
a090f180f4 | ||
|
|
c7e543d459 | ||
|
|
23e6b508f8 | ||
|
|
49a3989977 | ||
|
|
8eb2b60937 | ||
|
|
f02dcae52a | ||
|
|
098df5c0b5 | ||
|
|
684b77cbe6 | ||
|
|
81e9fc49fe | ||
|
|
7c696fc1ec | ||
|
|
fc27043e9e | ||
|
|
6ad1e27acf | ||
|
|
899047d9a2 | ||
|
|
78b5e2c1cc | ||
|
|
72f234027c | ||
|
|
730efe7b74 | ||
|
|
63885117e1 | ||
|
|
4f4c8905ff | ||
|
|
a5f6cb542d | ||
|
|
8456f47260 | ||
|
|
eb35fdc7a9 | ||
|
|
ceaf1e28f9 | ||
|
|
c3da23f5d3 | ||
|
|
44784b2236 | ||
|
|
157f6200f2 | ||
|
|
2882348547 | ||
|
|
e016cfab70 | ||
|
|
23b11e4096 | ||
|
|
7696872416 | ||
|
|
42d9fa70a2 | ||
|
|
a8a89def98 | ||
|
|
5bcce0c64a | ||
|
|
3a738fe701 | ||
|
|
d5670640f5 | ||
|
|
1d85eee78f | ||
|
|
5a46ab0055 | ||
|
|
3d5ff93a51 | ||
|
|
b9c66c7c2a | ||
|
|
68a390ef59 | ||
|
|
192ab1121c | ||
|
|
83eb33d54a | ||
|
|
ee937de2c4 | ||
|
|
8d514bd571 | ||
|
|
e83c404e21 | ||
|
|
945f55f50d | ||
|
|
9f83ea7111 | ||
|
|
f12c06e975 | ||
|
|
bbb176e153 | ||
|
|
02793040fd | ||
|
|
0773e83149 | ||
|
|
21205b4d19 | ||
|
|
60dbf6c11d | ||
|
|
2491ad7142 | ||
|
|
3b2834cf6d | ||
|
|
7ed2b23ea3 | ||
|
|
c879f82114 | ||
|
|
02a4740c66 | ||
|
|
6cb2702e6b | ||
|
|
94a9f7a84e | ||
|
|
e53465ce11 |
76
.github/workflows/crowdin_download.yml
vendored
Normal file
76
.github/workflows/crowdin_download.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
name: Download translations from Crowdin
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- 'release/**'
|
||||
|
||||
jobs:
|
||||
install-front:
|
||||
uses: ./.github/workflows/front-dependencies-installation.yml
|
||||
with:
|
||||
node_version: '20.x'
|
||||
|
||||
synchronize-with-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Create empty source files
|
||||
run: |
|
||||
touch src/backend/locale/django.pot
|
||||
mkdir -p src/frontend/packages/i18n/locales/impress/
|
||||
touch src/frontend/packages/i18n/locales/impress/translations-crowdin.json
|
||||
# crowdin workflow
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
config: crowdin/config.yml
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
create_pull_request: false
|
||||
push_translations: false
|
||||
push_sources: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
|
||||
# Visit https://crowdin.com/settings#api-key to create this token
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
CROWDIN_BASE_PATH: "../src/"
|
||||
# frontend i18n
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: generate translations files
|
||||
working-directory: src/frontend
|
||||
run: yarn i18n:deploy
|
||||
# Create a new PR
|
||||
- name: Create a new Pull Request with new translated strings
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
commit-message: |
|
||||
🌐(i18n) update translated strings
|
||||
|
||||
Update translated files with new translations
|
||||
title: 🌐(i18n) update translated strings
|
||||
body: |
|
||||
## Purpose
|
||||
|
||||
update translated strings
|
||||
|
||||
## Proposal
|
||||
|
||||
- [x] update translated strings
|
||||
branch: i18n/update-translations
|
||||
labels: i18n
|
||||
67
.github/workflows/crowdin_upload.yml
vendored
Normal file
67
.github/workflows/crowdin_upload.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
name: Update crowdin sources
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
install-front:
|
||||
uses: ./.github/workflows/front-dependencies-installation.yml
|
||||
with:
|
||||
node_version: '20.x'
|
||||
|
||||
synchronize-with-crowdin:
|
||||
needs: install-front
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
# Backend i18n
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: "3.12.6"
|
||||
- name: Upgrade pip and setuptools
|
||||
run: pip install --upgrade pip setuptools
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .
|
||||
working-directory: src/backend
|
||||
- name: Install gettext
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext pandoc
|
||||
- name: generate pot files
|
||||
working-directory: src/backend
|
||||
run: |
|
||||
DJANGO_CONFIGURATION=Build python manage.py makemessages -a --keep-pot
|
||||
# frontend i18n
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
- name: generate source translation file
|
||||
working-directory: src/frontend
|
||||
run: yarn i18n:extract
|
||||
# crowdin workflow
|
||||
- name: crowdin action
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
config: crowdin/config.yml
|
||||
upload_sources: true
|
||||
upload_translations: false
|
||||
download_translations: false
|
||||
create_pull_request: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# A numeric ID, found at https://crowdin.com/project/<projectName>/tools/api
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
|
||||
# Visit https://crowdin.com/settings#api-key to create this token
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
CROWDIN_BASE_PATH: "../src/"
|
||||
86
.github/workflows/docker-hub.yml
vendored
86
.github/workflows/docker-hub.yml
vendored
@@ -19,26 +19,9 @@ 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@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 }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -48,7 +31,7 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
@@ -70,26 +53,9 @@ 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@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 }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -99,7 +65,7 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
@@ -122,26 +88,9 @@ 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@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 }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
@@ -151,7 +100,7 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
@@ -179,29 +128,12 @@ 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@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 }}
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
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 ''${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
|
||||
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 }}
|
||||
|
||||
36
.github/workflows/front-dependencies-installation.yml
vendored
Normal file
36
.github/workflows/front-dependencies-installation.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Install frontend installation reusable workflow
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
node_version:
|
||||
required: false
|
||||
default: '20.x'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
front-dependencies-installation:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
- name: Setup Node.js
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node_version }}
|
||||
- name: Install dependencies
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
run: cd src/frontend/ && yarn install --frozen-lockfile
|
||||
- name: Cache install frontend
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
24
.github/workflows/helmfile-linter.yaml
vendored
24
.github/workflows/helmfile-linter.yaml
vendored
@@ -2,6 +2,7 @@ name: Helmfile lint
|
||||
run-name: Helmfile lint
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
@@ -12,11 +13,18 @@ jobs:
|
||||
container:
|
||||
image: ghcr.io/helmfile/helmfile:latest
|
||||
steps:
|
||||
-
|
||||
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"
|
||||
-
|
||||
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
|
||||
|
||||
58
.github/workflows/impress-frontend.yml
vendored
58
.github/workflows/impress-frontend.yml
vendored
@@ -9,39 +9,15 @@ on:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
|
||||
install-front:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
run: cd src/frontend/ && yarn install --frozen-lockfile
|
||||
|
||||
- name: Cache install frontend
|
||||
if: steps.front-node_modules.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
uses: ./.github/workflows/front-dependencies-installation.yml
|
||||
with:
|
||||
node_version: '20.x'
|
||||
|
||||
test-front:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -53,10 +29,10 @@ jobs:
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Test App
|
||||
run: cd src/frontend/ && yarn test
|
||||
@@ -68,29 +44,39 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Check linting
|
||||
run: cd src/frontend/ && yarn lint
|
||||
|
||||
test-e2e-chromium:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install-front
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Set e2e env variables
|
||||
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
||||
@@ -141,12 +127,8 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore the frontend cache
|
||||
uses: actions/cache@v4
|
||||
id: front-node_modules
|
||||
with:
|
||||
path: "src/frontend/**/node_modules"
|
||||
key: front-node_modules-${{ hashFiles('src/frontend/**/yarn.lock') }}
|
||||
- name: Install frontend dependencies
|
||||
uses: ./.github/workflows/front-dependencies-installation.yml
|
||||
|
||||
- name: Set e2e env variables
|
||||
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist
|
||||
|
||||
5
.github/workflows/impress.yml
vendored
5
.github/workflows/impress.yml
vendored
@@ -206,10 +206,11 @@ jobs:
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
|
||||
- name: Install gettext (required to compile messages)
|
||||
- name: Install gettext (required to compile messages) and MIME support
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext pandoc
|
||||
sudo apt-get install -y gettext pandoc shared-mime-info
|
||||
sudo wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
|
||||
|
||||
- name: Generate a MO file from strings extracted from the project
|
||||
run: python manage.py compilemessages
|
||||
|
||||
12
.github/workflows/release-helm-chart.yaml
vendored
12
.github/workflows/release-helm-chart.yaml
vendored
@@ -3,6 +3,8 @@ run-name: Release Chart
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- src/helm/impress/**
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -25,12 +27,8 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Run chart-releaser
|
||||
uses: helm/chart-releaser-action@v1.6.0
|
||||
- name: Publish Helm charts
|
||||
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
|
||||
with:
|
||||
charts_dir: ./src/helm
|
||||
skip_existing: True
|
||||
mark_as_latest: False
|
||||
env:
|
||||
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
CR_GIT_REPO: numerique-gouv/helm-repo
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -30,6 +30,7 @@ MANIFEST
|
||||
.next/
|
||||
|
||||
# Translations # Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Environments
|
||||
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
||||
[submodule "secrets"]
|
||||
path = secrets
|
||||
url = ../secrets
|
||||
|
||||
61
CHANGELOG.md
61
CHANGELOG.md
@@ -11,8 +11,58 @@ and this project adheres to
|
||||
|
||||
## Added
|
||||
|
||||
🔧(helm) add option to disable default tls setting by @dominikkaminski #519
|
||||
- github actions to managed Crowdin workflow
|
||||
- 📈Integrate Posthog #540
|
||||
- 🏷️(backend) add content-type to uploaded files #552
|
||||
- ✨(frontend) export pdf docx front side #537
|
||||
|
||||
## Changed
|
||||
|
||||
- 💄(frontend) add abilities on doc row #581
|
||||
- 💄(frontend) improve DocsGridItem responsive padding #582
|
||||
|
||||
|
||||
## [2.0.1] - 2025-01-17
|
||||
|
||||
## Added
|
||||
|
||||
✨(frontend) add multi columns support for editor #533
|
||||
|
||||
## Fixed
|
||||
|
||||
-🐛(frontend) share modal is shown when you don't have the abilities #557
|
||||
-🐛(frontend) title copy break app #564
|
||||
|
||||
## [2.0.0] - 2025-01-13
|
||||
|
||||
## 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
|
||||
- 📝(documentation) Documentation about self-hosted installation #530
|
||||
- ✨(helm) helm versioning #530
|
||||
|
||||
## 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 #448
|
||||
- 💄(frontend) update doc versioning ui #463
|
||||
- 💄(frontend) update doc summary ui #473
|
||||
- 📝(doc) update readme.md to match V2 changes #558
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(backend) fix create document via s2s if sub unknown but email found #543
|
||||
- 🐛(frontend) hide search and create doc button if not authenticated #555
|
||||
- 🐛(backend) race condition creation issue #556
|
||||
|
||||
## [1.10.0] - 2024-12-17
|
||||
|
||||
@@ -31,6 +81,11 @@ 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
|
||||
@@ -325,7 +380,9 @@ and this project adheres to
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.10.0...main
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.0.1...main
|
||||
[v2.0.1]: https://github.com/numerique-gouv/impress/releases/v2.0.1
|
||||
[v2.0.0]: https://github.com/numerique-gouv/impress/releases/v2.0.0
|
||||
[v1.10.0]: https://github.com/numerique-gouv/impress/releases/v1.10.0
|
||||
[v1.9.0]: https://github.com/numerique-gouv/impress/releases/v1.9.0
|
||||
[v1.8.2]: https://github.com/numerique-gouv/impress/releases/v1.8.2
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -51,7 +51,7 @@ COPY ./src/backend /app/
|
||||
WORKDIR /app
|
||||
|
||||
# collectstatic
|
||||
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
||||
RUN DJANGO_CONFIGURATION=Build \
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
# Replace duplicated file by a symlink to decrease the overall size of the
|
||||
@@ -72,10 +72,11 @@ RUN apk add \
|
||||
gettext \
|
||||
gdk-pixbuf \
|
||||
libffi-dev \
|
||||
pandoc \
|
||||
pango \
|
||||
shared-mime-info
|
||||
|
||||
RUN wget https://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types -O /etc/mime.types
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
|
||||
@@ -92,6 +93,11 @@ COPY ./src/backend /app/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Generate compiled translation messages
|
||||
RUN DJANGO_CONFIGURATION=Build \
|
||||
python manage.py compilemessages
|
||||
|
||||
|
||||
# We wrap commands run in this container by the following entrypoint that
|
||||
# creates a user on-the-fly with the container user ID (see USER) and root group
|
||||
# ID.
|
||||
|
||||
160
README.md
160
README.md
@@ -1,113 +1,173 @@
|
||||
# Impress
|
||||
<p align="center">
|
||||
<a href="https://github.com/suitenumerique/docs">
|
||||
<img alt="Docs" src="/docs/assets/logo-docs.png" width="300" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Impress is a web application for real-time collaborative text editing with user and role based access rights.
|
||||
Features include :
|
||||
- User authentication through OIDC
|
||||
- BlocNote.js text editing experience (markdown support, dynamic conversion, block structure, slash commands for block creation)
|
||||
- Document export to pdf and docx from predefined templates
|
||||
- Granular document permissions
|
||||
- Public link sharing
|
||||
- Offline mode
|
||||
<p align="center">
|
||||
Welcome to Docs! The open source document editor where your notes can become knowledge through live collaboration
|
||||
</p>
|
||||
|
||||
Impress is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/) and [BlocNote.js](https://www.blocknotejs.org/)
|
||||
<p align="center">
|
||||
<a href="https://matrix.to/#/#docs-official:matrix.org">
|
||||
Chat on Matrix
|
||||
</a> - <a href="/docs/">
|
||||
Documentation
|
||||
</a> - <a href="#getting-started">
|
||||
Getting started
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Getting started
|
||||
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
|
||||
|
||||
### Prerequisite
|
||||
## Why use Docs ❓
|
||||
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
|
||||
|
||||
Make sure you have a recent version of Docker and [Docker
|
||||
Compose](https://docs.docker.com/compose/install) installed on your laptop:
|
||||
### Write
|
||||
* 😌 Simple collaborative editing without the formatting complexity of markdown
|
||||
* 🔌 Offline? No problem, keep writing, your edits will get synced when back online
|
||||
* 💅 Create clean documents with limited but beautiful formatting options and focus on content
|
||||
* 🧱 Built for productivity (markdown support, many block types, slash commands, markdown support, keyboard shortcuts) (page in french sorry 😅).
|
||||
* ✨ Save time thanks to our AI actions (generate, sum up, correct, translate)
|
||||
|
||||
```bash
|
||||
### Collaborate
|
||||
* 🤝 Collaborate in realtime with your team mates
|
||||
* 🔒 Granular access control to keep your information secure and shared with the right people
|
||||
* 📑 Professional document exports in multiple formats (.odt, .doc, .pdf) with customizable templates
|
||||
* 📚 Built-in wiki functionality to transform your team's collaborative work into organized knowledge `ETA 02/2025`
|
||||
|
||||
### Self-host
|
||||
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
|
||||
|
||||
## Getting started 🔧
|
||||
### Test it
|
||||
Test Docs on your browser by logging in on this [environment](https://impress-preprod.beta.numerique.gouv.fr/docs/0aa856e9-da41-4d59-b73d-a61cb2c1245f/)
|
||||
```
|
||||
email: test.docs@yopmail.com
|
||||
password: I'd<3ToTestDocs
|
||||
```
|
||||
### Run it locally
|
||||
**Prerequisite**
|
||||
Make sure you have a recent version of Docker and [Docker Compose](https://docs.docker.com/compose/install) installed on your laptop:
|
||||
|
||||
```shellscript
|
||||
$ docker -v
|
||||
Docker version 20.10.2, build 2291f61
|
||||
|
||||
Docker version 20.10.2, build 2291f61
|
||||
|
||||
$ docker compose -v
|
||||
docker compose version 1.27.4, build 40524192
|
||||
|
||||
docker compose version 1.27.4, build 40524192
|
||||
```
|
||||
|
||||
> ⚠️ You may need to run the following commands with `sudo` but this can be
|
||||
> avoided by assigning your user to the `docker` group.
|
||||
|
||||
### Project bootstrap
|
||||
> ⚠️ You may need to run the following commands with sudo but this can be avoided by assigning your user to the `docker` group.
|
||||
|
||||
**Project bootstrap**
|
||||
The easiest way to start working on the project is to use GNU Make:
|
||||
|
||||
```bash
|
||||
```shellscript
|
||||
$ make bootstrap FLUSH_ARGS='--no-input'
|
||||
```
|
||||
|
||||
This command builds the `app` container, installs dependencies, performs
|
||||
database migrations and compile translations. It's a good idea to use this
|
||||
command each time you are pulling code from the project repository to avoid
|
||||
dependency-releated or migration-releated issues.
|
||||
This command builds the `app` container, installs dependencies, performs database migrations and compile translations. It's a good idea to use this
|
||||
|
||||
command each time you are pulling code from the project repository to avoid dependency-releated or migration-releated issues.
|
||||
|
||||
Your Docker services should now be up and running 🎉
|
||||
|
||||
You can access to the project by going to http://localhost:3000.
|
||||
You can access to the project by going to <http://localhost:3000>.
|
||||
|
||||
You will be prompted to log in, the default credentials are:
|
||||
```bash
|
||||
|
||||
```shellscript
|
||||
username: impress
|
||||
|
||||
password: impress
|
||||
```
|
||||
|
||||
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
|
||||
|
||||
```bash
|
||||
```shellscript
|
||||
$ make run-with-frontend
|
||||
```
|
||||
|
||||
---
|
||||
⚠️ For the frontend developper, it is often better to run the frontend in development mode locally.
|
||||
|
||||
⚠️ For the frontend developper, it is often better to run the frontend in development mode locally.
|
||||
To do so, install the frontend dependencies with the following command:
|
||||
|
||||
```bash
|
||||
```shellscript
|
||||
$ make frontend-install
|
||||
```
|
||||
|
||||
And run the frontend locally in development mode with the following command:
|
||||
|
||||
```bash
|
||||
```shellscript
|
||||
$ make run-frontend-development
|
||||
```
|
||||
|
||||
To start all the services, except the frontend container, you can use the following command:
|
||||
|
||||
```bash
|
||||
```shellscript
|
||||
$ make run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Adding content
|
||||
|
||||
**Adding content**
|
||||
You can create a basic demo site by running:
|
||||
|
||||
$ make demo
|
||||
```shellscript
|
||||
$ make demo
|
||||
```
|
||||
|
||||
Finally, you can check all available Make rules using:
|
||||
|
||||
```bash
|
||||
```shellscript
|
||||
$ make help
|
||||
```
|
||||
|
||||
### Django admin
|
||||
|
||||
**Django admin**
|
||||
You can access the Django admin site at
|
||||
[http://localhost:8071/admin](http://localhost:8071/admin).
|
||||
|
||||
<http://localhost:8071/admin>.
|
||||
|
||||
You first need to create a superuser account:
|
||||
|
||||
```bash
|
||||
```shellscript
|
||||
$ make superuser
|
||||
```
|
||||
|
||||
## Contributing
|
||||
## Feedback 🙋♂️🙋♀️
|
||||
We'd love to hear your thoughts and hear about your experiments, so come and say hi on [Matrix](https://matrix.to/#/#docs-official:matrix.org).
|
||||
|
||||
This project is intended to be community-driven, so please, do not hesitate to
|
||||
get in touch if you have any question related to our implementation or design
|
||||
decisions.
|
||||
## Roadmap
|
||||
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
|
||||
|
||||
## License
|
||||
## Licence 📝
|
||||
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/LICENSE)).
|
||||
|
||||
This work is released under the MIT License (see [LICENSE](./LICENSE)).
|
||||
While Docs is public driven initiative our licence choice is an invitation for private sector actors to use, sell and contribute to the project.
|
||||
|
||||
## Contributing 🙌
|
||||
This project is intended to be community-driven, so please, do not hesitate to get in touch if you have any question related to our implementation or design decisions.
|
||||
|
||||
If you intend to make pull requests see CONTRIBUTING for guidelines.
|
||||
|
||||
Directory structure:
|
||||
|
||||
```markdown
|
||||
docs
|
||||
├── bin - executable scripts or binaries that are used for various tasks, such as setup scripts, utility scripts, or custom commands.
|
||||
├── crowdin - for crowdin translations, a tool or service that helps manage translations for the project.
|
||||
├── docker - Dockerfiles and related configuration files used to build Docker images for the project. These images can be used for development, testing, or production environments.
|
||||
├── docs - documentation for the project, including user guides, API documentation, and other helpful resources.
|
||||
├── env.d/development - environment-specific configuration files for the development environment. These files might include environment variables, configuration settings, or other setup files needed for development.
|
||||
├── gitlint - configuration files for `gitlint`, a tool that enforces commit message guidelines to ensure consistency and quality in commit messages.
|
||||
├── playground - experimental or temporary code, where developers can test new features or ideas without affecting the main codebase.
|
||||
└── src - main source code directory, containing the core application code, libraries, and modules of the project.
|
||||
```
|
||||
|
||||
## Credits ❤️
|
||||
### Stack
|
||||
Impress is built on top of [Django Rest Framework](https://www.django-rest-framework.org/), [Next.js](https://nextjs.org/), [MinIO](https://min.io/) and [BlocNote.js](https://www.blocknotejs.org/)
|
||||
|
||||
### States ❤️ open source
|
||||
Docs is the result of a joint effort lead by the French 🇫🇷🥖 ([DINUM](https://www.numerique.gouv.fr/dinum/)) and German 🇩🇪🥨 government ([ZenDiS](https://zendis.de/)). We are always looking for new public partners feel free to reach out if you are interested in using or contributing to docs.
|
||||
@@ -7,7 +7,7 @@ UNSET_USER=0
|
||||
|
||||
TERRAFORM_DIRECTORY="./env.d/terraform"
|
||||
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
|
||||
COMPOSE_PROJECT="impress"
|
||||
COMPOSE_PROJECT="docs"
|
||||
|
||||
|
||||
# _set_user: set (or unset) default user id used to run docker commands
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
#!/bin/sh
|
||||
|
||||
curl https://raw.githubusercontent.com/numerique-gouv/tools/refs/heads/main/kind/create_cluster.sh | bash -s -- impress
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#
|
||||
# Your crowdin's credentials
|
||||
#
|
||||
api_token_env: CROWDIN_API_TOKEN
|
||||
api_token_env: CROWDIN_PERSONAL_TOKEN
|
||||
project_id_env: CROWDIN_PROJECT_ID
|
||||
base_path_env: CROWDIN_BASE_PATH
|
||||
|
||||
@@ -15,11 +15,11 @@ preserve_hierarchy: true
|
||||
# Files configuration
|
||||
#
|
||||
files: [
|
||||
{
|
||||
source : "/backend/locale/django.pot",
|
||||
dest: "/backend-impress.pot",
|
||||
translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po"
|
||||
},
|
||||
{
|
||||
source : "/backend/locale/django.pot",
|
||||
dest: "/backend-impress.pot",
|
||||
translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po"
|
||||
},
|
||||
{
|
||||
source: "/frontend/packages/i18n/locales/impress/translations-crowdin.json",
|
||||
dest: "/frontend-impress.json",
|
||||
|
||||
@@ -151,7 +151,7 @@ services:
|
||||
image: node:18
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
HOME: /tmp
|
||||
volumes:
|
||||
- ".:/app"
|
||||
|
||||
|
||||
BIN
docs/assets/docs_live_collaboration_light.gif
Normal file
BIN
docs/assets/docs_live_collaboration_light.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 MiB |
BIN
docs/assets/logo-docs.png
Normal file
BIN
docs/assets/logo-docs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.3 KiB |
@@ -40,6 +40,7 @@ backend:
|
||||
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
|
||||
POSTHOG_KEY: "{'id': 'posthog_key', 'host': 'https://product.impress.127.0.0.1.nip.io'}"
|
||||
DB_HOST: postgresql
|
||||
DB_NAME: impress
|
||||
DB_USER: dinum
|
||||
@@ -121,6 +122,12 @@ yProvider:
|
||||
COLLABORATION_SERVER_SECRET: my-secret
|
||||
Y_PROVIDER_API_KEY: my-secret
|
||||
|
||||
posthog:
|
||||
ingress:
|
||||
enabled: false
|
||||
ingressAssets:
|
||||
enabled: false
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
host: impress.127.0.0.1.nip.io
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
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
|
||||
@@ -194,7 +194,7 @@ redis-master-0 1/1 Running 0 10m
|
||||
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://numerique-gouv.github.io/impress/
|
||||
$ 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
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
CROWDIN_API_TOKEN=Your-Api-Token
|
||||
CROWDIN_PERSONAL_TOKEN=Your-Personal-Token
|
||||
CROWDIN_PROJECT_ID=Your-Project-Id
|
||||
CROWDIN_BASE_PATH=/app/src
|
||||
|
||||
1
secrets
1
secrets
Submodule secrets deleted from 38594182e8
@@ -201,7 +201,7 @@ class DocumentSerializer(ListDocumentSerializer):
|
||||
"abilities",
|
||||
"created_at",
|
||||
"creator",
|
||||
"is_avorite",
|
||||
"is_favorite",
|
||||
"link_role",
|
||||
"link_reach",
|
||||
"nb_accesses",
|
||||
@@ -264,13 +264,17 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
|
||||
"""Create the document and associate it with the user or send an invitation."""
|
||||
language = validated_data.get("language", settings.LANGUAGE_CODE)
|
||||
|
||||
# Get the user based on the sub (unique identifier)
|
||||
# Get the user on its sub (unique identifier). Default on email if allowed in settings
|
||||
email = validated_data["email"]
|
||||
|
||||
try:
|
||||
user = models.User.objects.get(sub=validated_data["sub"])
|
||||
except (models.User.DoesNotExist, KeyError):
|
||||
user = None
|
||||
email = validated_data["email"]
|
||||
else:
|
||||
user = models.User.objects.get_user_by_sub_or_email(
|
||||
validated_data["sub"], email
|
||||
)
|
||||
except models.DuplicateEmailError as err:
|
||||
raise serializers.ValidationError({"email": [err.message]}) from err
|
||||
|
||||
if user:
|
||||
email = user.email
|
||||
language = user.language or language
|
||||
|
||||
@@ -279,7 +283,9 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
|
||||
validated_data["content"]
|
||||
)
|
||||
except ConversionError as err:
|
||||
raise exceptions.APIException(detail="could not convert content") from err
|
||||
raise serializers.ValidationError(
|
||||
{"content": ["Could not convert content"]}
|
||||
) from err
|
||||
|
||||
document = models.Document.objects.create(
|
||||
title=validated_data["title"],
|
||||
@@ -302,7 +308,11 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
# Notify the user about the newly created document
|
||||
self._send_email_notification(document, validated_data, email, language)
|
||||
return document
|
||||
|
||||
def _send_email_notification(self, document, validated_data, email, language):
|
||||
"""Notify the user about the newly created document."""
|
||||
subject = validated_data.get("subject") or _(
|
||||
"A new document was created on your behalf!"
|
||||
)
|
||||
@@ -313,8 +323,6 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
|
||||
}
|
||||
document.send_email(subject, [email], context, language)
|
||||
|
||||
return document
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""
|
||||
This serializer does not support updates.
|
||||
@@ -380,6 +388,7 @@ class FileUploadSerializer(serializers.Serializer):
|
||||
raise serializers.ValidationError("Could not determine file extension.")
|
||||
|
||||
self.context["expected_extension"] = extension
|
||||
self.context["content_type"] = magic_mime_type
|
||||
|
||||
return file
|
||||
|
||||
@@ -387,6 +396,7 @@ class FileUploadSerializer(serializers.Serializer):
|
||||
"""Override validate to add the computed extension to validated_data."""
|
||||
attrs["expected_extension"] = self.context["expected_extension"]
|
||||
attrs["is_unsafe"] = self.context["is_unsafe"]
|
||||
attrs["content_type"] = self.context["content_type"]
|
||||
return attrs
|
||||
|
||||
|
||||
|
||||
@@ -605,7 +605,10 @@ class DocumentViewSet(
|
||||
key = f"{document.key_base}/{ATTACHMENTS_FOLDER:s}/{file_id!s}.{extension:s}"
|
||||
|
||||
# Prepare metadata for storage
|
||||
extra_args = {"Metadata": {"owner": str(request.user.id)}}
|
||||
extra_args = {
|
||||
"Metadata": {"owner": str(request.user.id)},
|
||||
"ContentType": serializer.validated_data["content_type"],
|
||||
}
|
||||
if serializer.validated_data["is_unsafe"]:
|
||||
extra_args["Metadata"]["is_unsafe"] = "true"
|
||||
|
||||
@@ -676,7 +679,7 @@ class DocumentViewSet(
|
||||
|
||||
# Fetch the document and check if the user has access
|
||||
try:
|
||||
document, _created = models.Document.objects.get_or_create(pk=pk)
|
||||
document = models.Document.objects.get(pk=pk)
|
||||
except models.Document.DoesNotExist as exc:
|
||||
logger.debug("Document with ID '%s' does not exist", pk)
|
||||
raise drf.exceptions.PermissionDenied() from exc
|
||||
@@ -936,40 +939,6 @@ class TemplateViewSet(
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
@drf.decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="generate-document",
|
||||
permission_classes=[permissions.AccessPermission],
|
||||
)
|
||||
# pylint: disable=unused-argument
|
||||
def generate_document(self, request, pk=None):
|
||||
"""
|
||||
Generate and return a document for this template around the
|
||||
body passed as argument.
|
||||
|
||||
2 types of body are accepted:
|
||||
- HTML: body_type = "html"
|
||||
- Markdown: body_type = "markdown"
|
||||
|
||||
2 types of documents can be generated:
|
||||
- PDF: format = "pdf"
|
||||
- Docx: format = "docx"
|
||||
"""
|
||||
serializer = serializers.DocumentGenerationSerializer(data=request.data)
|
||||
|
||||
if not serializer.is_valid():
|
||||
return drf.response.Response(
|
||||
serializer.errors, status=drf.status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
body = serializer.validated_data["body"]
|
||||
body_type = serializer.validated_data["body_type"]
|
||||
export_format = serializer.validated_data["format"]
|
||||
|
||||
template = self.get_object()
|
||||
return template.generate_document(body, body_type, export_format)
|
||||
|
||||
|
||||
class TemplateAccessViewSet(
|
||||
ResourceAccessViewsetMixin,
|
||||
@@ -1124,6 +1093,7 @@ class ConfigView(drf.views.APIView):
|
||||
"ENVIRONMENT",
|
||||
"FRONTEND_THEME",
|
||||
"MEDIA_BASE_URL",
|
||||
"POSTHOG_KEY",
|
||||
"LANGUAGES",
|
||||
"LANGUAGE_CODE",
|
||||
"SENTRY_DSN",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""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 _
|
||||
@@ -9,7 +11,9 @@ from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.models import User
|
||||
from core.models import DuplicateEmailError, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
@@ -59,10 +63,29 @@ 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
|
||||
@@ -75,13 +98,10 @@ 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)
|
||||
try:
|
||||
user = User.objects.get_user_by_sub_or_email(sub, email)
|
||||
except DuplicateEmailError as err:
|
||||
raise SuspiciousOperation(err.message) from err
|
||||
|
||||
if user:
|
||||
if not user.is_active:
|
||||
@@ -100,18 +120,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
)
|
||||
return full_name or None
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""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:
|
||||
try:
|
||||
return User.objects.get(email=email)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
return None
|
||||
|
||||
def update_user_if_needed(self, user, claims):
|
||||
"""Update user claims if they have changed."""
|
||||
has_changed = any(
|
||||
@@ -119,4 +127,4 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
)
|
||||
if has_changed:
|
||||
updated_claims = {key: value for key, value in claims.items() if value}
|
||||
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)
|
||||
self.UserModel.objects.filter(id=user.id).update(**updated_claims)
|
||||
|
||||
0
src/backend/core/management/__init__.py
Normal file
0
src/backend/core/management/__init__.py
Normal file
0
src/backend/core/management/commands/__init__.py
Normal file
0
src/backend/core/management/commands/__init__.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Management command updating the metadata for all the files in the MinIO bucket."""
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
import magic
|
||||
|
||||
from core.models import Document
|
||||
|
||||
# pylint: disable=too-many-locals, broad-exception-caught
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Update the metadata for all the files in the MinIO bucket."""
|
||||
|
||||
help = __doc__
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Execute management command."""
|
||||
s3_client = default_storage.connection.meta.client
|
||||
bucket_name = default_storage.bucket_name
|
||||
|
||||
mime_detector = magic.Magic(mime=True)
|
||||
|
||||
documents = Document.objects.all()
|
||||
self.stdout.write(
|
||||
f"[INFO] Found {documents.count()} documents. Starting ContentType fix..."
|
||||
)
|
||||
|
||||
for doc in documents:
|
||||
doc_id_str = str(doc.id)
|
||||
prefix = f"{doc_id_str}/attachments/"
|
||||
self.stdout.write(
|
||||
f"[INFO] Processing attachments under prefix '{prefix}' ..."
|
||||
)
|
||||
|
||||
continuation_token = None
|
||||
total_updated = 0
|
||||
|
||||
while True:
|
||||
list_kwargs = {"Bucket": bucket_name, "Prefix": prefix}
|
||||
if continuation_token:
|
||||
list_kwargs["ContinuationToken"] = continuation_token
|
||||
|
||||
response = s3_client.list_objects_v2(**list_kwargs)
|
||||
|
||||
# If no objects found under this prefix, break out of the loop
|
||||
if "Contents" not in response:
|
||||
break
|
||||
|
||||
for obj in response["Contents"]:
|
||||
key = obj["Key"]
|
||||
|
||||
# Skip if it's a folder
|
||||
if key.endswith("/"):
|
||||
continue
|
||||
|
||||
try:
|
||||
# Get existing metadata
|
||||
head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
|
||||
|
||||
# Read first ~1KB for MIME detection
|
||||
partial_obj = s3_client.get_object(
|
||||
Bucket=bucket_name, Key=key, Range="bytes=0-1023"
|
||||
)
|
||||
partial_data = partial_obj["Body"].read()
|
||||
|
||||
# Detect MIME type
|
||||
magic_mime_type = mime_detector.from_buffer(partial_data)
|
||||
|
||||
# Update ContentType
|
||||
s3_client.copy_object(
|
||||
Bucket=bucket_name,
|
||||
CopySource={"Bucket": bucket_name, "Key": key},
|
||||
Key=key,
|
||||
ContentType=magic_mime_type,
|
||||
Metadata=head_resp.get("Metadata", {}),
|
||||
MetadataDirective="REPLACE",
|
||||
)
|
||||
total_updated += 1
|
||||
|
||||
except Exception as exc: # noqa
|
||||
self.stderr.write(
|
||||
f"[ERROR] Could not update ContentType for {key}: {exc}"
|
||||
)
|
||||
|
||||
if response.get("IsTruncated"):
|
||||
continuation_token = response.get("NextContinuationToken")
|
||||
else:
|
||||
break
|
||||
|
||||
if total_updated > 0:
|
||||
self.stdout.write(
|
||||
f"[INFO] -> Updated {total_updated} objects for Document {doc_id_str}."
|
||||
)
|
||||
@@ -1,14 +1,12 @@
|
||||
"""
|
||||
Declare and configure the models for the impress core application
|
||||
"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import hashlib
|
||||
import smtplib
|
||||
import tempfile
|
||||
import textwrap
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from io import BytesIO
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
@@ -20,19 +18,12 @@ from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.mail import send_mail
|
||||
from django.db import models
|
||||
from django.http import FileResponse
|
||||
from django.template.base import Template as DjangoTemplate
|
||||
from django.template.context import Context
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import html, timezone
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import cached_property, lazy
|
||||
from django.utils.translation import get_language, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import frontmatter
|
||||
import markdown
|
||||
import pypandoc
|
||||
import weasyprint
|
||||
from botocore.exceptions import ClientError
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
@@ -89,6 +80,16 @@ class LinkReachChoices(models.TextChoices):
|
||||
PUBLIC = "public", _("Public") # Even anonymous users can access the document
|
||||
|
||||
|
||||
class DuplicateEmailError(Exception):
|
||||
"""Raised when an email is already associated with a pre-existing user."""
|
||||
|
||||
def __init__(self, message=None, email=None):
|
||||
"""Set message and email to describe the exception."""
|
||||
self.message = message
|
||||
self.email = email
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
"""
|
||||
Serves as an abstract base model for other models, ensuring that records are validated
|
||||
@@ -126,6 +127,35 @@ class BaseModel(models.Model):
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class UserManager(auth_models.UserManager):
|
||||
"""Custom manager for User model with additional methods."""
|
||||
|
||||
def get_user_by_sub_or_email(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
try:
|
||||
return self.get(sub=sub)
|
||||
except self.model.DoesNotExist as err:
|
||||
if not email:
|
||||
return None
|
||||
|
||||
if settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return self.get(email=email)
|
||||
except self.model.DoesNotExist:
|
||||
pass
|
||||
elif (
|
||||
self.filter(email=email).exists()
|
||||
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||
):
|
||||
raise DuplicateEmailError(
|
||||
_(
|
||||
"We couldn't find a user with this sub but the email is already "
|
||||
"associated with a registered user."
|
||||
)
|
||||
) from err
|
||||
return None
|
||||
|
||||
|
||||
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""User model to work with OIDC only authentication."""
|
||||
|
||||
@@ -192,7 +222,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
),
|
||||
)
|
||||
|
||||
objects = auth_models.UserManager()
|
||||
objects = UserManager()
|
||||
|
||||
USERNAME_FIELD = "admin_email"
|
||||
REQUIRED_FIELDS = []
|
||||
@@ -754,107 +784,6 @@ class Template(BaseModel):
|
||||
"retrieve": can_get,
|
||||
}
|
||||
|
||||
def generate_pdf(self, body_html, metadata):
|
||||
"""
|
||||
Generate and return a pdf document wrapped around the current template
|
||||
"""
|
||||
document_html = weasyprint.HTML(
|
||||
string=DjangoTemplate(self.code).render(
|
||||
Context({"body": html.format_html(body_html), **metadata})
|
||||
)
|
||||
)
|
||||
css = weasyprint.CSS(
|
||||
string=self.css,
|
||||
font_config=weasyprint.text.fonts.FontConfiguration(),
|
||||
)
|
||||
|
||||
pdf_content = document_html.write_pdf(stylesheets=[css], zoom=1)
|
||||
response = FileResponse(BytesIO(pdf_content), content_type="application/pdf")
|
||||
response["Content-Disposition"] = f"attachment; filename={self.title}.pdf"
|
||||
|
||||
return response
|
||||
|
||||
def generate_word(self, body_html, metadata):
|
||||
"""
|
||||
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})
|
||||
)
|
||||
|
||||
html_string = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
{self.css}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{template_string}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
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:
|
||||
output_path = tmp_file.name
|
||||
|
||||
pypandoc.convert_text(
|
||||
html_string,
|
||||
"docx",
|
||||
format="html",
|
||||
outputfile=output_path,
|
||||
extra_args=["--reference-doc", reference_docx],
|
||||
)
|
||||
|
||||
# Create a BytesIO object to store the output of the temporary docx file
|
||||
with open(output_path, "rb") as f:
|
||||
output = BytesIO(f.read())
|
||||
|
||||
# Ensure the pointer is at the beginning
|
||||
output.seek(0)
|
||||
|
||||
response = FileResponse(
|
||||
output,
|
||||
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
)
|
||||
response["Content-Disposition"] = f"attachment; filename={self.title}.docx"
|
||||
|
||||
return response
|
||||
|
||||
def generate_document(self, body, body_type, export_format):
|
||||
"""
|
||||
Generate and return a document for this template around the
|
||||
body passed as argument.
|
||||
|
||||
2 types of body are accepted:
|
||||
- HTML: body_type = "html"
|
||||
- Markdown: body_type = "markdown"
|
||||
|
||||
2 types of documents can be generated:
|
||||
- PDF: export_format = "pdf"
|
||||
- Docx: export_format = "docx"
|
||||
"""
|
||||
document = frontmatter.loads(body)
|
||||
metadata = document.metadata
|
||||
strip_body = document.content.strip()
|
||||
|
||||
if body_type == "html":
|
||||
body_html = strip_body
|
||||
else:
|
||||
body_html = (
|
||||
markdown.markdown(textwrap.dedent(strip_body)) if strip_body else ""
|
||||
)
|
||||
|
||||
if export_format == "pdf":
|
||||
return self.generate_pdf(body_html, metadata)
|
||||
|
||||
return self.generate_word(body_html, metadata)
|
||||
|
||||
|
||||
class TemplateAccess(BaseAccess):
|
||||
"""Relation model to give access to a template for a user or a team with a role."""
|
||||
@@ -939,7 +868,10 @@ class Invitation(BaseModel):
|
||||
super().clean()
|
||||
|
||||
# Check if an identity already exists for the provided email
|
||||
if User.objects.filter(email=self.email).exists():
|
||||
if (
|
||||
User.objects.filter(email=self.email).exists()
|
||||
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||
):
|
||||
raise exceptions.ValidationError(
|
||||
{"email": _("This email is already associated to a registered user.")}
|
||||
)
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,9 @@
|
||||
"""Unit tests for the Authentication Backends."""
|
||||
|
||||
import random
|
||||
import re
|
||||
from logging import Logger
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test.utils import override_settings
|
||||
@@ -62,7 +65,33 @@ def test_authentication_getter_existing_user_via_email(
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_fallback_to_email(
|
||||
def test_authentication_getter_email_none(monkeypatch):
|
||||
"""
|
||||
If no user is found with the sub and no email is provided, a new user should be created.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email=None)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
user_info = {"sub": "123"}
|
||||
if random.choice([True, False]):
|
||||
user_info["email"] = None
|
||||
return user_info
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
# Since the sub and email didn't match, it should create a new user
|
||||
assert models.User.objects.count() == 2
|
||||
assert user != db_user
|
||||
assert user.sub == "123"
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_fallback_to_email_allow_duplicate(
|
||||
settings, monkeypatch
|
||||
):
|
||||
"""
|
||||
@@ -75,6 +104,7 @@ def test_authentication_getter_existing_user_no_fallback_to_email(
|
||||
|
||||
# Set the setting to False
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = True
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": db_user.email}
|
||||
@@ -91,6 +121,39 @@ def test_authentication_getter_existing_user_no_fallback_to_email(
|
||||
assert user.sub == "123"
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_fallback_to_email_no_duplicate(
|
||||
settings, monkeypatch
|
||||
):
|
||||
"""
|
||||
When the "OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION" setting is set to False,
|
||||
the system should not match users by email, even if the email matches.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory()
|
||||
|
||||
# Set the setting to False
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
settings.OIDC_ALLOW_DUPLICATE_EMAILS = False
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": db_user.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match=(
|
||||
"We couldn't find a user with this sub but the email is already associated "
|
||||
"with a registered user."
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
# Since the sub doesn't match, it should not create a new user
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_with_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
@@ -128,11 +191,12 @@ def test_authentication_getter_existing_user_with_email(
|
||||
("Jack", "Duy", "jack.duy@example.com"),
|
||||
],
|
||||
)
|
||||
def test_authentication_getter_existing_user_change_fields(
|
||||
def test_authentication_getter_existing_user_change_fields_sub(
|
||||
first_name, last_name, email, django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
It should update the email or name fields on the user when they change.
|
||||
It should update the email or name fields on the user when they change
|
||||
and the user was identified by its "sub".
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = UserFactory(
|
||||
@@ -162,6 +226,48 @@ def test_authentication_getter_existing_user_change_fields(
|
||||
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.
|
||||
@@ -213,29 +319,6 @@ 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():
|
||||
@@ -341,7 +424,7 @@ def test_authentication_getter_existing_disabled_user_via_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""
|
||||
If an existing user does not matches the sub but matches the email and is disabled,
|
||||
If an existing user does not match the sub but matches the email and is disabled,
|
||||
an error should be raised and a user should not be created.
|
||||
"""
|
||||
|
||||
@@ -365,3 +448,102 @@ 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"
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Unit test for `update_files_content_type_metadata` command.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.management import call_command
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_update_files_content_type_metadata():
|
||||
"""
|
||||
Test that the command `update_files_content_type_metadata`
|
||||
fixes the ContentType of attachment in the storage.
|
||||
"""
|
||||
s3_client = default_storage.connection.meta.client
|
||||
bucket_name = default_storage.bucket_name
|
||||
|
||||
# Create files with a wrong ContentType
|
||||
keys = []
|
||||
for _ in range(10):
|
||||
doc_id = uuid.uuid4()
|
||||
factories.DocumentFactory(id=doc_id)
|
||||
key = f"{doc_id}/attachments/testfile.png"
|
||||
keys.append(key)
|
||||
fake_png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR..."
|
||||
s3_client.put_object(
|
||||
Bucket=bucket_name,
|
||||
Key=key,
|
||||
Body=fake_png,
|
||||
ContentType="text/plain",
|
||||
Metadata={"owner": "None"},
|
||||
)
|
||||
|
||||
# Call the command that fixes the ContentType
|
||||
call_command("update_files_content_type_metadata")
|
||||
|
||||
for key in keys:
|
||||
head_resp = s3_client.head_object(Bucket=bucket_name, Key=key)
|
||||
assert (
|
||||
head_resp["ContentType"] == "image/png"
|
||||
), f"ContentType not fixed, got {head_resp['ContentType']!r}"
|
||||
|
||||
# Check that original metadata was preserved
|
||||
assert head_resp["Metadata"].get("owner") == "None"
|
||||
@@ -64,12 +64,22 @@ def test_api_documents_attachment_upload_anonymous_success():
|
||||
assert response.status_code == 201
|
||||
|
||||
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
|
||||
match = pattern.search(response.json()["file"])
|
||||
file_path = response.json()["file"]
|
||||
match = pattern.search(file_path)
|
||||
file_id = match.group(1)
|
||||
|
||||
# Validate that file_id is a valid UUID
|
||||
uuid.UUID(file_id)
|
||||
|
||||
# Now, check the metadata of the uploaded file
|
||||
key = file_path.replace("/media", "")
|
||||
file_head = default_storage.connection.meta.client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=key
|
||||
)
|
||||
|
||||
assert file_head["Metadata"] == {"owner": "None"}
|
||||
assert file_head["ContentType"] == "image/png"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"reach, role",
|
||||
@@ -206,6 +216,7 @@ def test_api_documents_attachment_upload_success(via, role, mock_user_teams):
|
||||
Bucket=default_storage.bucket_name, Key=key
|
||||
)
|
||||
assert file_head["Metadata"] == {"owner": str(user.id)}
|
||||
assert file_head["ContentType"] == "image/png"
|
||||
|
||||
|
||||
def test_api_documents_attachment_upload_invalid(client):
|
||||
@@ -247,16 +258,18 @@ def test_api_documents_attachment_upload_size_limit_exceeded(settings):
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name,content,extension",
|
||||
"name,content,extension,content_type",
|
||||
[
|
||||
("test.exe", b"text", "exe"),
|
||||
("test", b"text", "txt"),
|
||||
("test.aaaaaa", b"test", "txt"),
|
||||
("test.txt", PIXEL, "txt"),
|
||||
("test.py", b"#!/usr/bin/python", "py"),
|
||||
("test.exe", b"text", "exe", "text/plain"),
|
||||
("test", b"text", "txt", "text/plain"),
|
||||
("test.aaaaaa", b"test", "txt", "text/plain"),
|
||||
("test.txt", PIXEL, "txt", "image/png"),
|
||||
("test.py", b"#!/usr/bin/python", "py", "text/plain"),
|
||||
],
|
||||
)
|
||||
def test_api_documents_attachment_upload_fix_extension(name, content, extension):
|
||||
def test_api_documents_attachment_upload_fix_extension(
|
||||
name, content, extension, content_type
|
||||
):
|
||||
"""
|
||||
A file with no extension or a wrong extension is accepted and the extension
|
||||
is corrected in storage.
|
||||
@@ -287,6 +300,7 @@ def test_api_documents_attachment_upload_fix_extension(name, content, extension)
|
||||
Bucket=default_storage.bucket_name, Key=key
|
||||
)
|
||||
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
|
||||
assert file_head["ContentType"] == content_type
|
||||
|
||||
|
||||
def test_api_documents_attachment_upload_empty_file():
|
||||
@@ -335,3 +349,4 @@ def test_api_documents_attachment_upload_unsafe():
|
||||
Bucket=default_storage.bucket_name, Key=key
|
||||
)
|
||||
assert file_head["Metadata"] == {"owner": str(user.id), "is_unsafe": "true"}
|
||||
assert file_head["ContentType"] == "application/octet-stream"
|
||||
|
||||
@@ -13,6 +13,7 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api.serializers import ServerCreateDocumentSerializer
|
||||
from core.models import Document, Invitation, User
|
||||
from core.services.converter_services import ConversionError, YdocConverter
|
||||
|
||||
@@ -20,7 +21,7 @@ pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_convert_markdown():
|
||||
def mock_convert_md():
|
||||
"""Mock YdocConverter.convert_markdown to return a converted content."""
|
||||
with patch.object(
|
||||
YdocConverter,
|
||||
@@ -169,8 +170,11 @@ def test_api_documents_create_for_owner_invalid_sub():
|
||||
|
||||
|
||||
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
|
||||
def test_api_documents_create_for_owner_existing(mock_convert_markdown):
|
||||
"""It should be possible to create a document on behalf of a pre-existing user."""
|
||||
def test_api_documents_create_for_owner_existing(mock_convert_md):
|
||||
"""
|
||||
It should be possible to create a document on behalf of a pre-existing user
|
||||
by passing their sub and email.
|
||||
"""
|
||||
user = factories.UserFactory(language="en-us")
|
||||
|
||||
data = {
|
||||
@@ -189,7 +193,7 @@ def test_api_documents_create_for_owner_existing(mock_convert_markdown):
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_markdown.assert_called_once_with("Document content")
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
|
||||
document = Document.objects.get()
|
||||
assert response.json() == {"id": str(document.id)}
|
||||
@@ -213,10 +217,10 @@ def test_api_documents_create_for_owner_existing(mock_convert_markdown):
|
||||
|
||||
|
||||
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
|
||||
def test_api_documents_create_for_owner_new_user(mock_convert_markdown):
|
||||
def test_api_documents_create_for_owner_new_user(mock_convert_md):
|
||||
"""
|
||||
It should be possible to create a document on behalf of new users by
|
||||
passing only their email address.
|
||||
passing their unknown sub and email address.
|
||||
"""
|
||||
data = {
|
||||
"title": "My Document",
|
||||
@@ -234,7 +238,7 @@ def test_api_documents_create_for_owner_new_user(mock_convert_markdown):
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_markdown.assert_called_once_with("Document content")
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
|
||||
document = Document.objects.get()
|
||||
assert response.json() == {"id": str(document.id)}
|
||||
@@ -264,8 +268,190 @@ def test_api_documents_create_for_owner_new_user(mock_convert_markdown):
|
||||
assert document.creator == user
|
||||
|
||||
|
||||
@override_settings(
|
||||
SERVER_TO_SERVER_API_TOKENS=["DummyToken"],
|
||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=True,
|
||||
)
|
||||
def test_api_documents_create_for_owner_existing_user_email_no_sub_with_fallback(
|
||||
mock_convert_md,
|
||||
):
|
||||
"""
|
||||
It should be possible to create a document on behalf of a pre-existing user for
|
||||
who the sub was not found if the settings allow it. This edge case should not
|
||||
happen in a healthy OIDC federation but can be usefull if an OIDC provider modifies
|
||||
users sub on each login for example...
|
||||
"""
|
||||
user = factories.UserFactory(language="en-us")
|
||||
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": "Document content",
|
||||
"sub": "123",
|
||||
"email": user.email,
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/create-for-owner/",
|
||||
data,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
|
||||
document = Document.objects.get()
|
||||
assert response.json() == {"id": str(document.id)}
|
||||
|
||||
assert document.title == "My Document"
|
||||
assert document.content == "Converted document content"
|
||||
assert document.creator == user
|
||||
assert document.accesses.filter(user=user, role="owner").exists()
|
||||
|
||||
assert Invitation.objects.exists() is False
|
||||
|
||||
assert len(mail.outbox) == 1
|
||||
email = mail.outbox[0]
|
||||
assert email.to == [user.email]
|
||||
assert email.subject == "A new document was created on your behalf!"
|
||||
email_content = " ".join(email.body.split())
|
||||
assert "A new document was created on your behalf!" in email_content
|
||||
assert (
|
||||
"You have been granted ownership of a new document: My Document"
|
||||
) in email_content
|
||||
|
||||
|
||||
@override_settings(
|
||||
SERVER_TO_SERVER_API_TOKENS=["DummyToken"],
|
||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=False,
|
||||
OIDC_ALLOW_DUPLICATE_EMAILS=False,
|
||||
)
|
||||
def test_api_documents_create_for_owner_existing_user_email_no_sub_no_fallback(
|
||||
mock_convert_md,
|
||||
):
|
||||
"""
|
||||
When a user does not match an existing sub and fallback to matching on email is
|
||||
not allowed in settings, it should raise an error if the email is already used by
|
||||
a registered user and duplicate emails are not allowed.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": "Document content",
|
||||
"sub": "123",
|
||||
"email": user.email,
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/create-for-owner/",
|
||||
data,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"email": [
|
||||
(
|
||||
"We couldn't find a user with this sub but the email is already "
|
||||
"associated with a registered user."
|
||||
)
|
||||
]
|
||||
}
|
||||
assert mock_convert_md.called is False
|
||||
assert Document.objects.exists() is False
|
||||
assert Invitation.objects.exists() is False
|
||||
assert len(mail.outbox) == 0
|
||||
|
||||
|
||||
@override_settings(
|
||||
SERVER_TO_SERVER_API_TOKENS=["DummyToken"],
|
||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION=False,
|
||||
OIDC_ALLOW_DUPLICATE_EMAILS=True,
|
||||
)
|
||||
def test_api_documents_create_for_owner_new_user_no_sub_no_fallback_allow_duplicate(
|
||||
mock_convert_md,
|
||||
):
|
||||
"""
|
||||
When a user does not match an existing sub and fallback to matching on email is
|
||||
not allowed in settings, it should be possible to create a new user with the same
|
||||
email as an existing user if the settings allow it (identification is still done
|
||||
via the sub in this case).
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": "Document content",
|
||||
"sub": "123",
|
||||
"email": user.email,
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/create-for-owner/",
|
||||
data,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
|
||||
document = Document.objects.get()
|
||||
assert response.json() == {"id": str(document.id)}
|
||||
|
||||
assert document.title == "My Document"
|
||||
assert document.content == "Converted document content"
|
||||
assert document.creator is None
|
||||
assert document.accesses.exists() is False
|
||||
|
||||
invitation = Invitation.objects.get()
|
||||
assert invitation.email == user.email
|
||||
assert invitation.role == "owner"
|
||||
|
||||
assert len(mail.outbox) == 1
|
||||
email = mail.outbox[0]
|
||||
assert email.to == [user.email]
|
||||
assert email.subject == "A new document was created on your behalf!"
|
||||
email_content = " ".join(email.body.split())
|
||||
assert "A new document was created on your behalf!" in email_content
|
||||
assert (
|
||||
"You have been granted ownership of a new document: My Document"
|
||||
) in email_content
|
||||
|
||||
# The creator field on the document should be set when the user is created
|
||||
user = User.objects.create(email=user.email, password="!")
|
||||
document.refresh_from_db()
|
||||
assert document.creator == user
|
||||
|
||||
|
||||
@patch.object(ServerCreateDocumentSerializer, "_send_email_notification")
|
||||
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"], LANGUAGE_CODE="de-de")
|
||||
def test_api_documents_create_for_owner_with_default_language(
|
||||
mock_send, mock_convert_md
|
||||
):
|
||||
"""The default language from settings should apply by default."""
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": "Document content",
|
||||
"sub": "123",
|
||||
"email": "john.doe@example.com",
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/create-for-owner/",
|
||||
data,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
assert mock_send.call_args[0][3] == "de-de"
|
||||
|
||||
|
||||
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
|
||||
def test_api_documents_create_for_owner_with_custom_language(mock_convert_markdown):
|
||||
def test_api_documents_create_for_owner_with_custom_language(mock_convert_md):
|
||||
"""
|
||||
Test creating a document with a specific language.
|
||||
Useful if the remote server knows the user's language.
|
||||
@@ -287,7 +473,7 @@ def test_api_documents_create_for_owner_with_custom_language(mock_convert_markdo
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_markdown.assert_called_once_with("Document content")
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
|
||||
assert len(mail.outbox) == 1
|
||||
email = mail.outbox[0]
|
||||
@@ -302,7 +488,7 @@ def test_api_documents_create_for_owner_with_custom_language(mock_convert_markdo
|
||||
|
||||
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
|
||||
def test_api_documents_create_for_owner_with_custom_subject_and_message(
|
||||
mock_convert_markdown,
|
||||
mock_convert_md,
|
||||
):
|
||||
"""It should be possible to customize the subject and message of the invitation email."""
|
||||
data = {
|
||||
@@ -323,7 +509,7 @@ def test_api_documents_create_for_owner_with_custom_subject_and_message(
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_convert_markdown.assert_called_once_with("Document content")
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
|
||||
assert len(mail.outbox) == 1
|
||||
email = mail.outbox[0]
|
||||
@@ -336,11 +522,11 @@ def test_api_documents_create_for_owner_with_custom_subject_and_message(
|
||||
|
||||
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
|
||||
def test_api_documents_create_for_owner_with_converter_exception(
|
||||
mock_convert_markdown,
|
||||
mock_convert_md,
|
||||
):
|
||||
"""It should be possible to customize the subject and message of the invitation email."""
|
||||
"""In case of converter error, a 400 error should be raised."""
|
||||
|
||||
mock_convert_markdown.side_effect = ConversionError("Conversion failed")
|
||||
mock_convert_md.side_effect = ConversionError("Conversion failed")
|
||||
|
||||
data = {
|
||||
"title": "My Document",
|
||||
@@ -357,8 +543,33 @@ def test_api_documents_create_for_owner_with_converter_exception(
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
mock_convert_md.assert_called_once_with("Document content")
|
||||
|
||||
mock_convert_markdown.assert_called_once_with("Document content")
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"content": ["Could not convert content"]}
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"detail": "could not convert content"}
|
||||
|
||||
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
|
||||
def test_api_documents_create_for_owner_with_empty_content():
|
||||
"""The content should not be empty or a 400 error should be raised."""
|
||||
|
||||
data = {
|
||||
"title": "My Document",
|
||||
"content": " ",
|
||||
"sub": "123",
|
||||
"email": "john.doe@example.com",
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/documents/create-for-owner/",
|
||||
data,
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION="Bearer DummyToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"content": [
|
||||
"This field may not be blank.",
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
"""
|
||||
Test users API endpoints in the impress core app.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.tests.conftest import TEAM, USER, VIA
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_templates_generate_document_anonymous_public():
|
||||
"""Anonymous users can generate pdf document with public templates."""
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
data = {
|
||||
"body": "# Test markdown body",
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/templates/{template.id!s}/generate-document/",
|
||||
data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/pdf"
|
||||
|
||||
|
||||
def test_api_templates_generate_document_anonymous_not_public():
|
||||
"""
|
||||
Anonymous users should not be allowed to generate pdf document with templates
|
||||
that are not marked as public.
|
||||
"""
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
data = {
|
||||
"body": "# Test markdown body",
|
||||
}
|
||||
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/templates/{template.id!s}/generate-document/",
|
||||
data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_templates_generate_document_authenticated_public():
|
||||
"""Authenticated users can generate pdf document with public templates."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
data = {"body": "# Test markdown body"}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/generate-document/",
|
||||
data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/pdf"
|
||||
|
||||
|
||||
def test_api_templates_generate_document_authenticated_not_public():
|
||||
"""
|
||||
Authenticated users should not be allowed to generate pdf document with templates
|
||||
that are not marked as public.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=False)
|
||||
data = {"body": "# Test markdown body"}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/generate-document/",
|
||||
data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("via", VIA)
|
||||
def test_api_templates_generate_document_related(via, mock_user_teams):
|
||||
"""Users related to a template can generate pdf document."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
access = None
|
||||
if via == USER:
|
||||
access = factories.UserTemplateAccessFactory(user=user)
|
||||
elif via == TEAM:
|
||||
mock_user_teams.return_value = ["lasuite", "unknown"]
|
||||
access = factories.TeamTemplateAccessFactory(team="lasuite")
|
||||
|
||||
data = {"body": "# Test markdown body"}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{access.template_id!s}/generate-document/",
|
||||
data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/pdf"
|
||||
|
||||
|
||||
def test_api_templates_generate_document_type_html():
|
||||
"""Generate pdf document with the body type html."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
data = {"body": "<p>Test body</p>", "body_type": "html"}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/generate-document/",
|
||||
data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/pdf"
|
||||
|
||||
|
||||
def test_api_templates_generate_document_type_markdown():
|
||||
"""Generate pdf document with the body type markdown."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
data = {"body": "# Test markdown body", "body_type": "markdown"}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/generate-document/",
|
||||
data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/pdf"
|
||||
|
||||
|
||||
def test_api_templates_generate_document_type_unknown():
|
||||
"""Generate pdf document with the body type unknown."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
data = {"body": "# Test markdown body", "body_type": "unknown"}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/generate-document/",
|
||||
data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"body_type": [
|
||||
'"unknown" is not a valid choice.',
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_api_templates_generate_document_export_docx():
|
||||
"""Generate pdf document with the body type html."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
template = factories.TemplateFactory(is_public=True)
|
||||
data = {"body": "<p>Test body</p>", "body_type": "html", "format": "docx"}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/templates/{template.id!s}/generate-document/",
|
||||
data,
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert (
|
||||
response.headers["content-type"]
|
||||
== "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
)
|
||||
@@ -20,6 +20,7 @@ pytestmark = pytest.mark.django_db
|
||||
CRISP_WEBSITE_ID="123",
|
||||
FRONTEND_THEME="test-theme",
|
||||
MEDIA_BASE_URL="http://testserver/",
|
||||
POSTHOG_KEY={"id": "132456", "host": "https://eu.i.posthog-test.com"},
|
||||
SENTRY_DSN="https://sentry.test/123",
|
||||
)
|
||||
@pytest.mark.parametrize("is_authenticated", [False, True])
|
||||
@@ -41,5 +42,6 @@ def test_api_config(is_authenticated):
|
||||
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
|
||||
"LANGUAGE_CODE": "en-us",
|
||||
"MEDIA_BASE_URL": "http://testserver/",
|
||||
"POSTHOG_KEY": {"id": "132456", "host": "https://eu.i.posthog-test.com"},
|
||||
"SENTRY_DSN": "https://sentry.test/123",
|
||||
}
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
Unit tests for the Template model
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
@@ -189,31 +185,3 @@ def test_models_templates_get_abilities_preset_role(django_assert_num_queries):
|
||||
"partial_update": False,
|
||||
"generate_document": True,
|
||||
}
|
||||
|
||||
|
||||
def test_models_templates__generate_word():
|
||||
"""Generate word document and assert no tmp files are left in /tmp folder."""
|
||||
template = factories.TemplateFactory()
|
||||
response = template.generate_word("<p>Test body</p>", {})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len([f for f in os.listdir("/tmp") if f.startswith("docx_")]) == 0
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"pypandoc.convert_text",
|
||||
side_effect=RuntimeError("Conversion failed"),
|
||||
)
|
||||
def test_models_templates__generate_word__raise_error(_mock_pypandoc):
|
||||
"""
|
||||
Generate word document and assert no tmp files are left in /tmp folder
|
||||
even when the conversion fails.
|
||||
"""
|
||||
template = factories.TemplateFactory()
|
||||
|
||||
try:
|
||||
template.generate_word("<p>Test body</p>", {})
|
||||
except RuntimeError as e:
|
||||
assert str(e) == "Conversion failed"
|
||||
time.sleep(0.5)
|
||||
assert len([f for f in os.listdir("/tmp") if f.startswith("docx_")]) == 0
|
||||
|
||||
30
src/backend/core/tests/test_settings.py
Normal file
30
src/backend/core/tests/test_settings.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Unit tests for the User model
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from impress.settings import Base
|
||||
|
||||
|
||||
def test_invalid_settings_oidc_email_configuration():
|
||||
"""
|
||||
The OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and OIDC_ALLOW_DUPLICATE_EMAILS settings
|
||||
should not be both set to True simultaneously.
|
||||
"""
|
||||
|
||||
class TestSettings(Base):
|
||||
"""Fake test settings."""
|
||||
|
||||
OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
OIDC_ALLOW_DUPLICATE_EMAILS = True
|
||||
|
||||
# The validation is performed during post_setup
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
TestSettings().post_setup()
|
||||
|
||||
# Check the exception message
|
||||
assert str(excinfo.value) == (
|
||||
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
|
||||
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
|
||||
)
|
||||
@@ -1,10 +1,2 @@
|
||||
<page size="A4">
|
||||
<div class="header">
|
||||
<img width="200"
|
||||
src="https://impress-staging.beta.numerique.gouv.fr/assets/logo-gouv.png"
|
||||
/>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="body">{{ body }}</div>
|
||||
</div>
|
||||
</page>
|
||||
<img width="200" src="https://impress-staging.beta.numerique.gouv.fr/assets/logo-gouv.png" />
|
||||
<br/>
|
||||
@@ -1,20 +0,0 @@
|
||||
body {
|
||||
background: white;
|
||||
font-family: arial;
|
||||
}
|
||||
.header img {
|
||||
width: 5cm;
|
||||
margin-left: -0.4cm;
|
||||
}
|
||||
.body{
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
[custom-style="center"] {
|
||||
text-align: center;
|
||||
}
|
||||
[custom-style="right"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -390,6 +390,11 @@ class Base(Configuration):
|
||||
None, environ_name="FRONTEND_THEME", environ_prefix=None
|
||||
)
|
||||
|
||||
# Posthog
|
||||
POSTHOG_KEY = values.DictValue(
|
||||
None, environ_name="POSTHOG_KEY", environ_prefix=None
|
||||
)
|
||||
|
||||
# Crisp
|
||||
CRISP_WEBSITE_ID = values.Value(
|
||||
None, environ_name="CRISP_WEBSITE_ID", environ_prefix=None
|
||||
@@ -474,6 +479,18 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# WARNING: Enabling this setting allows multiple user accounts to share the same email
|
||||
# address. This may cause security issues and is not recommended for production use when
|
||||
# email is activated as fallback for identification (see previous setting).
|
||||
OIDC_ALLOW_DUPLICATE_EMAILS = values.BooleanValue(
|
||||
default=False,
|
||||
environ_name="OIDC_ALLOW_DUPLICATE_EMAILS",
|
||||
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,6 +639,15 @@ class Base(Configuration):
|
||||
with sentry_sdk.configure_scope() as scope:
|
||||
scope.set_extra("application", "backend")
|
||||
|
||||
if (
|
||||
cls.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
|
||||
and cls.OIDC_ALLOW_DUPLICATE_EMAILS
|
||||
):
|
||||
raise ValueError(
|
||||
"Both OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION and "
|
||||
"OIDC_ALLOW_DUPLICATE_EMAILS cannot be set to True simultaneously. "
|
||||
)
|
||||
|
||||
|
||||
class Build(Base):
|
||||
"""Settings used when the application is built.
|
||||
|
||||
Binary file not shown.
@@ -1,9 +1,9 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
|
||||
"PO-Revision-Date: 2024-12-17 15:53\n"
|
||||
"POT-Creation-Date: 2025-01-15 21:00+0000\n"
|
||||
"PO-Revision-Date: 2025-01-27 09:27\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: German\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -11,384 +11,342 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Crowdin-Project: lasuite-people\n"
|
||||
"X-Crowdin-Project-ID: 637934\n"
|
||||
"X-Crowdin-Project: lasuite-docs\n"
|
||||
"X-Crowdin-Project-ID: 754523\n"
|
||||
"X-Crowdin-Language: de\n"
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 8\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: core/admin.py:33
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr "Persönliche Daten"
|
||||
|
||||
#: core/admin.py:46
|
||||
#: build/lib/core/admin.py:46 core/admin.py:46
|
||||
msgid "Permissions"
|
||||
msgstr "Berechtigungen"
|
||||
|
||||
#: core/admin.py:58
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr "Wichtige Daten"
|
||||
|
||||
#: core/api/filters.py:16
|
||||
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
|
||||
msgid "Creator is me"
|
||||
msgstr ""
|
||||
msgstr "Ersteller bin ich"
|
||||
|
||||
#: core/api/filters.py:19
|
||||
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
msgstr "Favorit"
|
||||
|
||||
#: core/api/filters.py:22
|
||||
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
msgstr "Titel"
|
||||
|
||||
#: core/api/serializers.py:307
|
||||
#: build/lib/core/api/serializers.py:317 core/api/serializers.py:317
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr ""
|
||||
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
|
||||
|
||||
#: core/api/serializers.py:311
|
||||
#: build/lib/core/api/serializers.py:321 core/api/serializers.py:321
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr ""
|
||||
msgstr "Sie sind Besitzer eines neuen Dokuments:"
|
||||
|
||||
#: core/api/serializers.py:414
|
||||
#: build/lib/core/api/serializers.py:422 core/api/serializers.py:422
|
||||
msgid "Body"
|
||||
msgstr "Inhalt"
|
||||
|
||||
#: core/api/serializers.py:417
|
||||
#: build/lib/core/api/serializers.py:425 core/api/serializers.py:425
|
||||
msgid "Body type"
|
||||
msgstr "Typ"
|
||||
|
||||
#: core/api/serializers.py:423
|
||||
#: build/lib/core/api/serializers.py:431 core/api/serializers.py:431
|
||||
msgid "Format"
|
||||
msgstr "Format"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:57
|
||||
#: build/lib/core/authentication/backends.py:61
|
||||
#: core/authentication/backends.py:61
|
||||
msgid "Invalid response format or token verification failed"
|
||||
msgstr ""
|
||||
msgstr "Ungültiges Antwortformat oder Token-Verifizierung fehlgeschlagen"
|
||||
|
||||
#: core/authentication/backends.py:81
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:88
|
||||
#: build/lib/core/authentication/backends.py:108
|
||||
#: core/authentication/backends.py:108
|
||||
msgid "User account is disabled"
|
||||
msgstr ""
|
||||
msgstr "Benutzerkonto ist deaktiviert"
|
||||
|
||||
#: core/models.py:62 core/models.py:69
|
||||
#: build/lib/core/models.py:63 build/lib/core/models.py:70 core/models.py:63
|
||||
#: core/models.py:70
|
||||
msgid "Reader"
|
||||
msgstr "Lesen"
|
||||
|
||||
#: core/models.py:63 core/models.py:70
|
||||
#: build/lib/core/models.py:64 build/lib/core/models.py:71 core/models.py:64
|
||||
#: core/models.py:71
|
||||
msgid "Editor"
|
||||
msgstr "Bearbeiten"
|
||||
|
||||
#: core/models.py:71
|
||||
#: build/lib/core/models.py:72 core/models.py:72
|
||||
msgid "Administrator"
|
||||
msgstr "Administrator"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:72
|
||||
#: build/lib/core/models.py:73 core/models.py:73
|
||||
msgid "Owner"
|
||||
msgstr "Besitzer"
|
||||
|
||||
#: core/models.py:83
|
||||
#: build/lib/core/models.py:84 core/models.py:84
|
||||
msgid "Restricted"
|
||||
msgstr "Beschränkt"
|
||||
|
||||
#: core/models.py:87
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "Authenticated"
|
||||
msgstr "Authentifiziert"
|
||||
|
||||
#: core/models.py:89
|
||||
#: build/lib/core/models.py:90 core/models.py:90
|
||||
msgid "Public"
|
||||
msgstr "Öffentlich"
|
||||
|
||||
#: core/models.py:101
|
||||
#: build/lib/core/models.py:112 core/models.py:112
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:102
|
||||
#: build/lib/core/models.py:113 core/models.py:113
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:108
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
msgid "created on"
|
||||
msgstr "Erstellt"
|
||||
|
||||
#: core/models.py:109
|
||||
#: build/lib/core/models.py:120 core/models.py:120
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "Datum und Uhrzeit, an dem ein Datensatz erstellt wurde"
|
||||
|
||||
#: core/models.py:114
|
||||
#: build/lib/core/models.py:125 core/models.py:125
|
||||
msgid "updated on"
|
||||
msgstr "Aktualisiert"
|
||||
|
||||
#: core/models.py:115
|
||||
#: build/lib/core/models.py:126 core/models.py:126
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "Datum und Uhrzeit, an dem zuletzt aktualisiert wurde"
|
||||
|
||||
#: core/models.py:135
|
||||
#: build/lib/core/models.py:162 core/models.py:162
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:175 core/models.py:175
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr ""
|
||||
msgstr "Geben Sie eine gültige Unterseite ein. Dieser Wert darf nur Buchstaben, Zahlen und die @/./+/-/_/: Zeichen enthalten."
|
||||
|
||||
#: core/models.py:141
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
msgstr "unter"
|
||||
|
||||
#: core/models.py:143
|
||||
#: build/lib/core/models.py:183 core/models.py:183
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr ""
|
||||
msgstr "Erforderlich. 255 Zeichen oder weniger. Buchstaben, Zahlen und die Zeichen @/./+/-/_/:"
|
||||
|
||||
#: core/models.py:152
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
msgstr "Name"
|
||||
|
||||
#: core/models.py:153
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
msgstr "Kurzbezeichnung"
|
||||
|
||||
#: core/models.py:155
|
||||
#: build/lib/core/models.py:195 core/models.py:195
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
msgstr "Identitäts-E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:160
|
||||
#: build/lib/core/models.py:200 core/models.py:200
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
msgstr "Admin E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:167
|
||||
#: build/lib/core/models.py:207 core/models.py:207
|
||||
msgid "language"
|
||||
msgstr "Sprache"
|
||||
|
||||
#: core/models.py:168
|
||||
#: build/lib/core/models.py:208 core/models.py:208
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
msgstr "Die Sprache, in der der Benutzer die Benutzeroberfläche sehen möchte."
|
||||
|
||||
#: core/models.py:174
|
||||
#: build/lib/core/models.py:214 core/models.py:214
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
msgstr "Die Zeitzone, in der der Nutzer Zeiten sehen möchte."
|
||||
|
||||
#: core/models.py:177
|
||||
#: build/lib/core/models.py:217 core/models.py:217
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
msgstr "Gerät"
|
||||
|
||||
#: core/models.py:179
|
||||
#: build/lib/core/models.py:219 core/models.py:219
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
msgstr "Ob der Benutzer ein Gerät oder ein echter Benutzer ist."
|
||||
|
||||
#: core/models.py:182
|
||||
#: build/lib/core/models.py:222 core/models.py:222
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
msgstr "Status des Teammitgliedes"
|
||||
|
||||
#: core/models.py:184
|
||||
#: build/lib/core/models.py:224 core/models.py:224
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
msgstr "Gibt an, ob der Benutzer sich in diese Admin-Seite einloggen kann."
|
||||
|
||||
#: core/models.py:187
|
||||
#: build/lib/core/models.py:227 core/models.py:227
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
msgstr "aktiviert"
|
||||
|
||||
#: core/models.py:190
|
||||
#: build/lib/core/models.py:230 core/models.py:230
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
msgstr "Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie diese Option, anstatt Konten zu löschen."
|
||||
|
||||
#: core/models.py:202
|
||||
#: build/lib/core/models.py:242 core/models.py:242
|
||||
msgid "user"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:203
|
||||
#: build/lib/core/models.py:243 core/models.py:243
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:342 core/models.py:718
|
||||
#: build/lib/core/models.py:382 build/lib/core/models.py:758 core/models.py:382
|
||||
#: core/models.py:758
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: core/models.py:364
|
||||
#: build/lib/core/models.py:404 core/models.py:404
|
||||
msgid "Document"
|
||||
msgstr "Dokument"
|
||||
|
||||
#: core/models.py:365
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "Documents"
|
||||
msgstr "Dokumente"
|
||||
|
||||
#: core/models.py:368
|
||||
#: build/lib/core/models.py:408 core/models.py:408
|
||||
msgid "Untitled Document"
|
||||
msgstr "Unbenanntes Dokument"
|
||||
|
||||
#: core/models.py:593
|
||||
#: build/lib/core/models.py:633 core/models.py:633
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
|
||||
|
||||
#: core/models.py:597
|
||||
#: build/lib/core/models.py:637 core/models.py:637
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
|
||||
|
||||
#: core/models.py:600
|
||||
#: build/lib/core/models.py:640 core/models.py:640
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
|
||||
|
||||
#: core/models.py:623
|
||||
#: build/lib/core/models.py:663 core/models.py:663
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: core/models.py:624
|
||||
#: build/lib/core/models.py:664 core/models.py:664
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
msgstr "Dokument/Benutzer Linkverfolgung"
|
||||
|
||||
#: core/models.py:630
|
||||
#: build/lib/core/models.py:670 core/models.py:670
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:653
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
msgstr "Dokumentenfavorit"
|
||||
|
||||
#: core/models.py:654
|
||||
#: build/lib/core/models.py:694 core/models.py:694
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
msgstr "Dokumentfavoriten"
|
||||
|
||||
#: core/models.py:660
|
||||
#: build/lib/core/models.py:700 core/models.py:700
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
|
||||
|
||||
#: core/models.py:682
|
||||
#: build/lib/core/models.py:722 core/models.py:722
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
msgstr "Dokument/Benutzerbeziehung"
|
||||
|
||||
#: core/models.py:683
|
||||
#: build/lib/core/models.py:723 core/models.py:723
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
msgstr "Dokument/Benutzerbeziehungen"
|
||||
|
||||
#: core/models.py:689
|
||||
#: build/lib/core/models.py:729 core/models.py:729
|
||||
msgid "This user is already in this document."
|
||||
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: core/models.py:695
|
||||
#: build/lib/core/models.py:735 core/models.py:735
|
||||
msgid "This team is already in this document."
|
||||
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
|
||||
|
||||
#: core/models.py:701 core/models.py:890
|
||||
#: build/lib/core/models.py:741 build/lib/core/models.py:930 core/models.py:741
|
||||
#: core/models.py:930
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
|
||||
|
||||
#: core/models.py:719
|
||||
#: build/lib/core/models.py:759 core/models.py:759
|
||||
msgid "description"
|
||||
msgstr "Beschreibung"
|
||||
|
||||
#: core/models.py:720
|
||||
#: build/lib/core/models.py:760 core/models.py:760
|
||||
msgid "code"
|
||||
msgstr "Code"
|
||||
|
||||
#: core/models.py:721
|
||||
#: build/lib/core/models.py:761 core/models.py:761
|
||||
msgid "css"
|
||||
msgstr "CSS"
|
||||
|
||||
#: core/models.py:723
|
||||
#: build/lib/core/models.py:763 core/models.py:763
|
||||
msgid "public"
|
||||
msgstr "öffentlich"
|
||||
|
||||
#: core/models.py:725
|
||||
#: build/lib/core/models.py:765 core/models.py:765
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
|
||||
|
||||
#: core/models.py:731
|
||||
#: build/lib/core/models.py:771 core/models.py:771
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
msgstr "Vorlage"
|
||||
|
||||
#: core/models.py:732
|
||||
#: build/lib/core/models.py:772 core/models.py:772
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
msgstr "Vorlagen"
|
||||
|
||||
#: core/models.py:871
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
msgstr "Vorlage/Benutzer-Beziehung"
|
||||
|
||||
#: core/models.py:872
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
msgstr "Vorlage/Benutzerbeziehungen"
|
||||
|
||||
#: core/models.py:878
|
||||
#: build/lib/core/models.py:918 core/models.py:918
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
|
||||
|
||||
#: core/models.py:884
|
||||
#: build/lib/core/models.py:924 core/models.py:924
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
msgstr "Dieses Team ist bereits in diesem Template."
|
||||
|
||||
#: core/models.py:907
|
||||
#: build/lib/core/models.py:947 core/models.py:947
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
msgstr "E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:926
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
msgstr "Einladung zum Dokument"
|
||||
|
||||
#: core/models.py:927
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
msgstr "Dokumenteinladungen"
|
||||
|
||||
#: core/models.py:944
|
||||
#: build/lib/core/models.py:987 core/models.py:987
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
|
||||
|
||||
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
|
||||
msgid "Company logo"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
#, python-format
|
||||
msgid "Hello %(name)s"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
msgid "Hello"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
|
||||
msgid "Thank you very much for your visit!"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:221
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:162
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:209
|
||||
#: core/templates/mail/text/invitation.txt:10
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:233
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/text/hello.txt:8
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:236
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
msgstr "Englisch"
|
||||
|
||||
#: impress/settings.py:237
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
msgstr "Französisch"
|
||||
|
||||
#: impress/settings.py:238
|
||||
#: build/lib/impress/settings.py:238 impress/settings.py:238
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
msgstr "Deutsch"
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,9 +1,9 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
|
||||
"PO-Revision-Date: 2024-12-17 15:53\n"
|
||||
"POT-Creation-Date: 2025-01-15 21:00+0000\n"
|
||||
"PO-Revision-Date: 2025-01-27 09:27\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
"Language: en_US\n"
|
||||
@@ -11,384 +11,342 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Crowdin-Project: lasuite-people\n"
|
||||
"X-Crowdin-Project-ID: 637934\n"
|
||||
"X-Crowdin-Project: lasuite-docs\n"
|
||||
"X-Crowdin-Project-ID: 754523\n"
|
||||
"X-Crowdin-Language: en\n"
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 8\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: core/admin.py:33
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:46
|
||||
#: build/lib/core/admin.py:46 core/admin.py:46
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:58
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/filters.py:16
|
||||
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
|
||||
msgid "Creator is me"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/filters.py:19
|
||||
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/filters.py:22
|
||||
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:307
|
||||
#: build/lib/core/api/serializers.py:317 core/api/serializers.py:317
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:311
|
||||
#: build/lib/core/api/serializers.py:321 core/api/serializers.py:321
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:414
|
||||
#: build/lib/core/api/serializers.py:422 core/api/serializers.py:422
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:417
|
||||
#: build/lib/core/api/serializers.py:425 core/api/serializers.py:425
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:423
|
||||
#: build/lib/core/api/serializers.py:431 core/api/serializers.py:431
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:57
|
||||
#: build/lib/core/authentication/backends.py:61
|
||||
#: core/authentication/backends.py:61
|
||||
msgid "Invalid response format or token verification failed"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:81
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:88
|
||||
#: build/lib/core/authentication/backends.py:108
|
||||
#: core/authentication/backends.py:108
|
||||
msgid "User account is disabled"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:62 core/models.py:69
|
||||
#: build/lib/core/models.py:63 build/lib/core/models.py:70 core/models.py:63
|
||||
#: core/models.py:70
|
||||
msgid "Reader"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:63 core/models.py:70
|
||||
#: build/lib/core/models.py:64 build/lib/core/models.py:71 core/models.py:64
|
||||
#: core/models.py:71
|
||||
msgid "Editor"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:71
|
||||
#: build/lib/core/models.py:72 core/models.py:72
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:72
|
||||
#: build/lib/core/models.py:73 core/models.py:73
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:83
|
||||
#: build/lib/core/models.py:84 core/models.py:84
|
||||
msgid "Restricted"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:87
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "Authenticated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:89
|
||||
#: build/lib/core/models.py:90 core/models.py:90
|
||||
msgid "Public"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:101
|
||||
#: build/lib/core/models.py:112 core/models.py:112
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:102
|
||||
#: build/lib/core/models.py:113 core/models.py:113
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:108
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:109
|
||||
#: build/lib/core/models.py:120 core/models.py:120
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:114
|
||||
#: build/lib/core/models.py:125 core/models.py:125
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:115
|
||||
#: build/lib/core/models.py:126 core/models.py:126
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:135
|
||||
#: build/lib/core/models.py:162 core/models.py:162
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:175 core/models.py:175
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:141
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:143
|
||||
#: build/lib/core/models.py:183 core/models.py:183
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:152
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:153
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:155
|
||||
#: build/lib/core/models.py:195 core/models.py:195
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:160
|
||||
#: build/lib/core/models.py:200 core/models.py:200
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:167
|
||||
#: build/lib/core/models.py:207 core/models.py:207
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:168
|
||||
#: build/lib/core/models.py:208 core/models.py:208
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:174
|
||||
#: build/lib/core/models.py:214 core/models.py:214
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:177
|
||||
#: build/lib/core/models.py:217 core/models.py:217
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:179
|
||||
#: build/lib/core/models.py:219 core/models.py:219
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:182
|
||||
#: build/lib/core/models.py:222 core/models.py:222
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:184
|
||||
#: build/lib/core/models.py:224 core/models.py:224
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:187
|
||||
#: build/lib/core/models.py:227 core/models.py:227
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:190
|
||||
#: build/lib/core/models.py:230 core/models.py:230
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:202
|
||||
#: build/lib/core/models.py:242 core/models.py:242
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:203
|
||||
#: build/lib/core/models.py:243 core/models.py:243
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:342 core/models.py:718
|
||||
#: build/lib/core/models.py:382 build/lib/core/models.py:758 core/models.py:382
|
||||
#: core/models.py:758
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:364
|
||||
#: build/lib/core/models.py:404 core/models.py:404
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:365
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:368
|
||||
#: build/lib/core/models.py:408 core/models.py:408
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:593
|
||||
#: build/lib/core/models.py:633 core/models.py:633
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:597
|
||||
#: build/lib/core/models.py:637 core/models.py:637
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:600
|
||||
#: build/lib/core/models.py:640 core/models.py:640
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:623
|
||||
#: build/lib/core/models.py:663 core/models.py:663
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:624
|
||||
#: build/lib/core/models.py:664 core/models.py:664
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:630
|
||||
#: build/lib/core/models.py:670 core/models.py:670
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:653
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:654
|
||||
#: build/lib/core/models.py:694 core/models.py:694
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:660
|
||||
#: build/lib/core/models.py:700 core/models.py:700
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:682
|
||||
#: build/lib/core/models.py:722 core/models.py:722
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:683
|
||||
#: build/lib/core/models.py:723 core/models.py:723
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:689
|
||||
#: build/lib/core/models.py:729 core/models.py:729
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:695
|
||||
#: build/lib/core/models.py:735 core/models.py:735
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:701 core/models.py:890
|
||||
#: build/lib/core/models.py:741 build/lib/core/models.py:930 core/models.py:741
|
||||
#: core/models.py:930
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:719
|
||||
#: build/lib/core/models.py:759 core/models.py:759
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:720
|
||||
#: build/lib/core/models.py:760 core/models.py:760
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:721
|
||||
#: build/lib/core/models.py:761 core/models.py:761
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:723
|
||||
#: build/lib/core/models.py:763 core/models.py:763
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:725
|
||||
#: build/lib/core/models.py:765 core/models.py:765
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:731
|
||||
#: build/lib/core/models.py:771 core/models.py:771
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:732
|
||||
#: build/lib/core/models.py:772 core/models.py:772
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:871
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:872
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:878
|
||||
#: build/lib/core/models.py:918 core/models.py:918
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:884
|
||||
#: build/lib/core/models.py:924 core/models.py:924
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:907
|
||||
#: build/lib/core/models.py:947 core/models.py:947
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:926
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:927
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:944
|
||||
#: build/lib/core/models.py:987 core/models.py:987
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
|
||||
msgid "Company logo"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
#, python-format
|
||||
msgid "Hello %(name)s"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
msgid "Hello"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
|
||||
msgid "Thank you very much for your visit!"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:221
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:162
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:209
|
||||
#: core/templates/mail/text/invitation.txt:10
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:233
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/text/hello.txt:8
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:236
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:237
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:238
|
||||
#: build/lib/impress/settings.py:238 impress/settings.py:238
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1,9 +1,9 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-people\n"
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
|
||||
"PO-Revision-Date: 2024-12-17 15:53\n"
|
||||
"POT-Creation-Date: 2025-01-15 21:00+0000\n"
|
||||
"PO-Revision-Date: 2025-01-27 09:27\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr_FR\n"
|
||||
@@ -11,384 +11,342 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
"X-Crowdin-Project: lasuite-people\n"
|
||||
"X-Crowdin-Project-ID: 637934\n"
|
||||
"X-Crowdin-Project: lasuite-docs\n"
|
||||
"X-Crowdin-Project-ID: 754523\n"
|
||||
"X-Crowdin-Language: fr\n"
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 8\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: core/admin.py:33
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr "Infos Personnelles"
|
||||
|
||||
#: core/admin.py:46
|
||||
#: build/lib/core/admin.py:46 core/admin.py:46
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:58
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: core/api/filters.py:16
|
||||
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
|
||||
msgid "Creator is me"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/filters.py:19
|
||||
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/filters.py:22
|
||||
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:307
|
||||
#: build/lib/core/api/serializers.py:317 core/api/serializers.py:317
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr "Un nouveau document a été créé pour vous !"
|
||||
|
||||
#: core/api/serializers.py:311
|
||||
#: build/lib/core/api/serializers.py:321 core/api/serializers.py:321
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr "Vous avez été déclaré propriétaire d'un nouveau document :"
|
||||
|
||||
#: core/api/serializers.py:414
|
||||
#: build/lib/core/api/serializers.py:422 core/api/serializers.py:422
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:417
|
||||
#: build/lib/core/api/serializers.py:425 core/api/serializers.py:425
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:423
|
||||
#: build/lib/core/api/serializers.py:431 core/api/serializers.py:431
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:57
|
||||
#: build/lib/core/authentication/backends.py:61
|
||||
#: core/authentication/backends.py:61
|
||||
msgid "Invalid response format or token verification failed"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:81
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:88
|
||||
#: build/lib/core/authentication/backends.py:108
|
||||
#: core/authentication/backends.py:108
|
||||
msgid "User account is disabled"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:62 core/models.py:69
|
||||
#: build/lib/core/models.py:63 build/lib/core/models.py:70 core/models.py:63
|
||||
#: core/models.py:70
|
||||
msgid "Reader"
|
||||
msgstr "Lecteur"
|
||||
|
||||
#: core/models.py:63 core/models.py:70
|
||||
#: build/lib/core/models.py:64 build/lib/core/models.py:71 core/models.py:64
|
||||
#: core/models.py:71
|
||||
msgid "Editor"
|
||||
msgstr "Éditeur"
|
||||
|
||||
#: core/models.py:71
|
||||
#: build/lib/core/models.py:72 core/models.py:72
|
||||
msgid "Administrator"
|
||||
msgstr "Administrateur"
|
||||
|
||||
#: core/models.py:72
|
||||
#: build/lib/core/models.py:73 core/models.py:73
|
||||
msgid "Owner"
|
||||
msgstr "Propriétaire"
|
||||
|
||||
#: core/models.py:83
|
||||
#: build/lib/core/models.py:84 core/models.py:84
|
||||
msgid "Restricted"
|
||||
msgstr "Restreint"
|
||||
|
||||
#: core/models.py:87
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "Authenticated"
|
||||
msgstr "Authentifié"
|
||||
|
||||
#: core/models.py:89
|
||||
#: build/lib/core/models.py:90 core/models.py:90
|
||||
msgid "Public"
|
||||
msgstr "Public"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:101
|
||||
#: build/lib/core/models.py:112 core/models.py:112
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:102
|
||||
#: build/lib/core/models.py:113 core/models.py:113
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:108
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:109
|
||||
#: build/lib/core/models.py:120 core/models.py:120
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:114
|
||||
#: build/lib/core/models.py:125 core/models.py:125
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:115
|
||||
#: build/lib/core/models.py:126 core/models.py:126
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:135
|
||||
#: build/lib/core/models.py:162 core/models.py:162
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:175 core/models.py:175
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:141
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:143
|
||||
#: build/lib/core/models.py:183 core/models.py:183
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:152
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:153
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:155
|
||||
#: build/lib/core/models.py:195 core/models.py:195
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:160
|
||||
#: build/lib/core/models.py:200 core/models.py:200
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:167
|
||||
#: build/lib/core/models.py:207 core/models.py:207
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:168
|
||||
#: build/lib/core/models.py:208 core/models.py:208
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:174
|
||||
#: build/lib/core/models.py:214 core/models.py:214
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:177
|
||||
#: build/lib/core/models.py:217 core/models.py:217
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:179
|
||||
#: build/lib/core/models.py:219 core/models.py:219
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:182
|
||||
#: build/lib/core/models.py:222 core/models.py:222
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:184
|
||||
#: build/lib/core/models.py:224 core/models.py:224
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:187
|
||||
#: build/lib/core/models.py:227 core/models.py:227
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:190
|
||||
#: build/lib/core/models.py:230 core/models.py:230
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:202
|
||||
#: build/lib/core/models.py:242 core/models.py:242
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:203
|
||||
#: build/lib/core/models.py:243 core/models.py:243
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:342 core/models.py:718
|
||||
#: build/lib/core/models.py:382 build/lib/core/models.py:758 core/models.py:382
|
||||
#: core/models.py:758
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:364
|
||||
#: build/lib/core/models.py:404 core/models.py:404
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:365
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:368
|
||||
#: build/lib/core/models.py:408 core/models.py:408
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:593
|
||||
#: build/lib/core/models.py:633 core/models.py:633
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr "{name} a partagé un document avec vous!"
|
||||
|
||||
#: core/models.py:597
|
||||
#: build/lib/core/models.py:637 core/models.py:637
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr "{name} vous a invité avec le rôle \"{role}\" sur le document suivant:"
|
||||
|
||||
#: core/models.py:600
|
||||
#: build/lib/core/models.py:640 core/models.py:640
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr "{name} a partagé un document avec vous: {title}"
|
||||
|
||||
#: core/models.py:623
|
||||
#: build/lib/core/models.py:663 core/models.py:663
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:624
|
||||
#: build/lib/core/models.py:664 core/models.py:664
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:630
|
||||
#: build/lib/core/models.py:670 core/models.py:670
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:653
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:654
|
||||
#: build/lib/core/models.py:694 core/models.py:694
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:660
|
||||
#: build/lib/core/models.py:700 core/models.py:700
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:682
|
||||
#: build/lib/core/models.py:722 core/models.py:722
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:683
|
||||
#: build/lib/core/models.py:723 core/models.py:723
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:689
|
||||
#: build/lib/core/models.py:729 core/models.py:729
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:695
|
||||
#: build/lib/core/models.py:735 core/models.py:735
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:701 core/models.py:890
|
||||
#: build/lib/core/models.py:741 build/lib/core/models.py:930 core/models.py:741
|
||||
#: core/models.py:930
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:719
|
||||
#: build/lib/core/models.py:759 core/models.py:759
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:720
|
||||
#: build/lib/core/models.py:760 core/models.py:760
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:721
|
||||
#: build/lib/core/models.py:761 core/models.py:761
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:723
|
||||
#: build/lib/core/models.py:763 core/models.py:763
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:725
|
||||
#: build/lib/core/models.py:765 core/models.py:765
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:731
|
||||
#: build/lib/core/models.py:771 core/models.py:771
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:732
|
||||
#: build/lib/core/models.py:772 core/models.py:772
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:871
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:872
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:878
|
||||
#: build/lib/core/models.py:918 core/models.py:918
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:884
|
||||
#: build/lib/core/models.py:924 core/models.py:924
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:907
|
||||
#: build/lib/core/models.py:947 core/models.py:947
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:926
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:927
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:944
|
||||
#: build/lib/core/models.py:987 core/models.py:987
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:159 core/templates/mail/text/hello.txt:3
|
||||
msgid "Company logo"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
#, python-format
|
||||
msgid "Hello %(name)s"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:188 core/templates/mail/text/hello.txt:5
|
||||
msgid "Hello"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:189 core/templates/mail/text/hello.txt:6
|
||||
msgid "Thank you very much for your visit!"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/hello.html:221
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:162
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:209
|
||||
#: core/templates/mail/text/invitation.txt:10
|
||||
msgid "Open"
|
||||
msgstr "Ouvrir"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid " Docs, your new essential tool for organizing, sharing and collaborating on your documents as a team. "
|
||||
msgstr " Docs, votre nouvel outil incontournable pour organiser, partager et collaborer sur vos documents en équipe. "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:233
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
#, python-format
|
||||
msgid " Brought to you by %(brandname)s "
|
||||
msgstr " Proposé par %(brandname)s "
|
||||
|
||||
#: core/templates/mail/text/hello.txt:8
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:236
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:237
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: impress/settings.py:238
|
||||
#: build/lib/impress/settings.py:238 impress/settings.py:238
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
|
||||
352
src/backend/locale/nl_NL/LC_MESSAGES/django.po
Normal file
352
src/backend/locale/nl_NL/LC_MESSAGES/django.po
Normal file
@@ -0,0 +1,352 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: lasuite-docs\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-01-15 21:00+0000\n"
|
||||
"PO-Revision-Date: 2025-01-27 09:27\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Dutch\n"
|
||||
"Language: nl_NL\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Crowdin-Project: lasuite-docs\n"
|
||||
"X-Crowdin-Project-ID: 754523\n"
|
||||
"X-Crowdin-Language: nl\n"
|
||||
"X-Crowdin-File: backend-impress.pot\n"
|
||||
"X-Crowdin-File-ID: 18\n"
|
||||
|
||||
#: build/lib/core/admin.py:33 core/admin.py:33
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:46 core/admin.py:46
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/admin.py:58 core/admin.py:58
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
|
||||
msgid "Creator is me"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
|
||||
msgid "Favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
|
||||
msgid "Title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:317 core/api/serializers.py:317
|
||||
msgid "A new document was created on your behalf!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:321 core/api/serializers.py:321
|
||||
msgid "You have been granted ownership of a new document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:422 core/api/serializers.py:422
|
||||
msgid "Body"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:425 core/api/serializers.py:425
|
||||
msgid "Body type"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/api/serializers.py:431 core/api/serializers.py:431
|
||||
msgid "Format"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/authentication/backends.py:61
|
||||
#: core/authentication/backends.py:61
|
||||
msgid "Invalid response format or token verification failed"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/authentication/backends.py:108
|
||||
#: core/authentication/backends.py:108
|
||||
msgid "User account is disabled"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:63 build/lib/core/models.py:70 core/models.py:63
|
||||
#: core/models.py:70
|
||||
msgid "Reader"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:64 build/lib/core/models.py:71 core/models.py:64
|
||||
#: core/models.py:71
|
||||
msgid "Editor"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:72 core/models.py:72
|
||||
msgid "Administrator"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:73 core/models.py:73
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:84 core/models.py:84
|
||||
msgid "Restricted"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:88 core/models.py:88
|
||||
msgid "Authenticated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:90 core/models.py:90
|
||||
msgid "Public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:112 core/models.py:112
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:113 core/models.py:113
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:119 core/models.py:119
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:120 core/models.py:120
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:125 core/models.py:125
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:126 core/models.py:126
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:162 core/models.py:162
|
||||
msgid "We couldn't find a user with this sub but the email is already associated with a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:175 core/models.py:175
|
||||
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:181 core/models.py:181
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:183 core/models.py:183
|
||||
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:192 core/models.py:192
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:193 core/models.py:193
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:195 core/models.py:195
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:200 core/models.py:200
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:207 core/models.py:207
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:208 core/models.py:208
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:214 core/models.py:214
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:217 core/models.py:217
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:219 core/models.py:219
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:222 core/models.py:222
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:224 core/models.py:224
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:227 core/models.py:227
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:230 core/models.py:230
|
||||
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:242 core/models.py:242
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:243 core/models.py:243
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:382 build/lib/core/models.py:758 core/models.py:382
|
||||
#: core/models.py:758
|
||||
msgid "title"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:404 core/models.py:404
|
||||
msgid "Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:405 core/models.py:405
|
||||
msgid "Documents"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:408 core/models.py:408
|
||||
msgid "Untitled Document"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:633 core/models.py:633
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you!"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:637 core/models.py:637
|
||||
#, python-brace-format
|
||||
msgid "{name} invited you with the role \"{role}\" on the following document:"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:640 core/models.py:640
|
||||
#, python-brace-format
|
||||
msgid "{name} shared a document with you: {title}"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:663 core/models.py:663
|
||||
msgid "Document/user link trace"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:664 core/models.py:664
|
||||
msgid "Document/user link traces"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:670 core/models.py:670
|
||||
msgid "A link trace already exists for this document/user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:693 core/models.py:693
|
||||
msgid "Document favorite"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:694 core/models.py:694
|
||||
msgid "Document favorites"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:700 core/models.py:700
|
||||
msgid "This document is already targeted by a favorite relation instance for the same user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:722 core/models.py:722
|
||||
msgid "Document/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:723 core/models.py:723
|
||||
msgid "Document/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:729 core/models.py:729
|
||||
msgid "This user is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:735 core/models.py:735
|
||||
msgid "This team is already in this document."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:741 build/lib/core/models.py:930 core/models.py:741
|
||||
#: core/models.py:930
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:759 core/models.py:759
|
||||
msgid "description"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:760 core/models.py:760
|
||||
msgid "code"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:761 core/models.py:761
|
||||
msgid "css"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:763 core/models.py:763
|
||||
msgid "public"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:765 core/models.py:765
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:771 core/models.py:771
|
||||
msgid "Template"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:772 core/models.py:772
|
||||
msgid "Templates"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:911 core/models.py:911
|
||||
msgid "Template/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:912 core/models.py:912
|
||||
msgid "Template/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:918 core/models.py:918
|
||||
msgid "This user is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:924 core/models.py:924
|
||||
msgid "This team is already in this template."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:947 core/models.py:947
|
||||
msgid "email address"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:966 core/models.py:966
|
||||
msgid "Document invitation"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:967 core/models.py:967
|
||||
msgid "Document invitations"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/core/models.py:987 core/models.py:987
|
||||
msgid "This email is already associated to a registered user."
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:236 impress/settings.py:236
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:237 impress/settings.py:237
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
#: build/lib/impress/settings.py:238 impress/settings.py:238
|
||||
msgid "German"
|
||||
msgstr ""
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "1.10.0"
|
||||
version = "2.0.1"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,7 +25,7 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"boto3==1.35.81",
|
||||
"boto3==1.35.90",
|
||||
"Brotli==1.1.0",
|
||||
"celery[redis]==5.4.0",
|
||||
"django-configurations==2.5.1",
|
||||
@@ -37,7 +37,7 @@ dependencies = [
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.4",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.4",
|
||||
"django==5.1.5",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.28.0",
|
||||
"dockerflow==2024.4.2",
|
||||
@@ -47,16 +47,13 @@ dependencies = [
|
||||
"jsonschema==4.23.0",
|
||||
"markdown==3.7",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"openai==1.57.4",
|
||||
"openai==1.58.1",
|
||||
"psycopg[binary]==3.2.3",
|
||||
"PyJWT==2.10.1",
|
||||
"pypandoc==1.14",
|
||||
"python-frontmatter==1.1.0",
|
||||
"python-magic==0.4.27",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.19.2",
|
||||
"url-normalize==1.4.3",
|
||||
"WeasyPrint>=60.2",
|
||||
"whitenoise==6.8.2",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
]
|
||||
@@ -73,17 +70,17 @@ dev = [
|
||||
"drf-spectacular-sidecar==2024.12.1",
|
||||
"freezegun==1.5.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.30.0",
|
||||
"ipython==8.31.0",
|
||||
"pyfakefs==5.7.3",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.2",
|
||||
"pylint==3.3.3",
|
||||
"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.3",
|
||||
"ruff==0.8.4",
|
||||
"types-requests==2.32.0.20241016",
|
||||
]
|
||||
|
||||
|
||||
@@ -36,18 +36,25 @@ export const createDoc = async (
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Create a new document',
|
||||
name: 'New doc',
|
||||
})
|
||||
.click();
|
||||
|
||||
await page.getByRole('heading', { name: 'Untitled document' }).click();
|
||||
await page.keyboard.type(randomDocs[i]);
|
||||
await page.getByText('Created at ').click();
|
||||
const input = page.getByRole('textbox', { name: 'doc title input' });
|
||||
await input.click();
|
||||
await input.fill(randomDocs[i]);
|
||||
await input.blur();
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -60,7 +67,9 @@ export const addNewMember = async (
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
|
||||
// Select a new user
|
||||
await inputSearch.fill(fillText);
|
||||
@@ -75,13 +84,9 @@ export const addNewMember = async (
|
||||
await page.getByRole('option', { name: users[index].email }).click();
|
||||
|
||||
// Choose a role
|
||||
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();
|
||||
await page.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: role }).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
return users[index].email;
|
||||
};
|
||||
@@ -97,24 +102,22 @@ export const goToGridDoc = async (
|
||||
const header = page.locator('header').first();
|
||||
await header.locator('h2').getByText('Docs').click();
|
||||
|
||||
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(docsGrid.getByTestId('grid-loader')).toBeHidden();
|
||||
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
const rows = docsGrid.getByRole('row');
|
||||
|
||||
const rows = datagridTable.getByRole('row');
|
||||
const row = title
|
||||
? rows.filter({
|
||||
hasText: title,
|
||||
})
|
||||
: rows.nth(nthRow);
|
||||
|
||||
const docTitleCell = row.getByRole('cell').nth(1);
|
||||
|
||||
const docTitle = await docTitleCell.textContent();
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
const docTitleContent = row.locator('[aria-describedby="doc-title"]').first();
|
||||
const docTitle = await docTitleContent.textContent();
|
||||
expect(docTitle).toBeDefined();
|
||||
|
||||
await row.getByRole('link').first().click();
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc } from './common';
|
||||
import { createDoc, verifyDocName } from './common';
|
||||
|
||||
const config = {
|
||||
CRISP_WEBSITE_ID: null,
|
||||
@@ -16,6 +16,7 @@ const config = {
|
||||
['de-de', 'German'],
|
||||
],
|
||||
LANGUAGE_CODE: 'en-us',
|
||||
POSTHOG_KEY: {},
|
||||
SENTRY_DSN: null,
|
||||
};
|
||||
|
||||
@@ -129,7 +130,8 @@ test.describe('Config', () => {
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
|
||||
|
||||
await verifyDocName(page, randomDoc[0]);
|
||||
|
||||
const webSocket = await webSocketPromise;
|
||||
expect(webSocket.url()).toContain('ws://localhost:8083/collaboration/ws/');
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, goToGridDoc, keyCloakSignIn, randomName } from './common';
|
||||
import {
|
||||
createDoc,
|
||||
goToGridDoc,
|
||||
keyCloakSignIn,
|
||||
randomName,
|
||||
verifyDocName,
|
||||
} from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -18,15 +24,12 @@ test.describe('Doc Create', () => {
|
||||
const header = page.locator('header').first();
|
||||
await header.locator('h2').getByText('Docs').click();
|
||||
|
||||
const datagrid = page.getByLabel('Datagrid of the documents page 1');
|
||||
const datagridTable = datagrid.getByRole('table');
|
||||
await expect(page.getByTestId('grid-loader')).toBeVisible();
|
||||
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(datagridTable.getByText(docTitle)).toBeVisible({
|
||||
timeout: 5000,
|
||||
});
|
||||
const docsGrid = page.getByTestId('docs-grid');
|
||||
await expect(docsGrid).toBeVisible();
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
await expect(docsGrid.getByText(docTitle)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,7 +67,7 @@ test.describe('Doc Create: Not loggued', () => {
|
||||
|
||||
await goToGridDoc(page, { title });
|
||||
|
||||
await expect(page.getByRole('heading', { name: title })).toBeVisible();
|
||||
await verifyDocName(page, title);
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await expect(editor.getByText('This is a normal text')).toBeVisible();
|
||||
|
||||
@@ -2,7 +2,12 @@ import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, goToGridDoc, mockedDocument } from './common';
|
||||
import {
|
||||
createDoc,
|
||||
goToGridDoc,
|
||||
mockedDocument,
|
||||
verifyDocName,
|
||||
} from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -98,7 +103,7 @@ test.describe('Doc Editor', () => {
|
||||
});
|
||||
|
||||
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
|
||||
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
|
||||
await verifyDocName(page, randomDoc[0]);
|
||||
|
||||
let webSocket = await webSocketPromise;
|
||||
expect(webSocket.url()).toContain(
|
||||
@@ -116,17 +121,15 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const selectVisibility = page.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
});
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
|
||||
// When the visibility is changed, the ws should closed the connection (backend signal)
|
||||
const wsClosePromise = webSocket.waitForEvent('close');
|
||||
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('option', {
|
||||
name: 'Authenticated',
|
||||
.getByRole('button', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -153,7 +156,7 @@ test.describe('Doc Editor', () => {
|
||||
}) => {
|
||||
const randomDoc = await createDoc(page, 'doc-markdown', browserName, 1);
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
|
||||
await verifyDocName(page, randomDoc[0]);
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
@@ -178,7 +181,7 @@ test.describe('Doc Editor', () => {
|
||||
}) => {
|
||||
// Check the first doc
|
||||
const [firstDoc] = await createDoc(page, 'doc-switch-1', browserName, 1);
|
||||
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
|
||||
await verifyDocName(page, firstDoc);
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
@@ -187,7 +190,8 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
// Check the second doc
|
||||
const [secondDoc] = await createDoc(page, 'doc-switch-2', browserName, 1);
|
||||
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
|
||||
await verifyDocName(page, secondDoc);
|
||||
|
||||
await expect(editor.getByText('Hello World Doc 1')).toBeHidden();
|
||||
await editor.click();
|
||||
await editor.fill('Hello World Doc 2');
|
||||
@@ -197,9 +201,18 @@ test.describe('Doc Editor', () => {
|
||||
await goToGridDoc(page, {
|
||||
title: firstDoc,
|
||||
});
|
||||
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
|
||||
await verifyDocName(page, firstDoc);
|
||||
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 ({
|
||||
@@ -208,7 +221,7 @@ test.describe('Doc Editor', () => {
|
||||
}) => {
|
||||
// Check the first doc
|
||||
const [doc] = await createDoc(page, 'doc-saves-change', browserName, 1);
|
||||
await expect(page.locator('h2').getByText(doc)).toBeVisible();
|
||||
await verifyDocName(page, doc);
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
@@ -219,7 +232,7 @@ test.describe('Doc Editor', () => {
|
||||
nthRow: 2,
|
||||
});
|
||||
|
||||
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
|
||||
await verifyDocName(page, secondDoc);
|
||||
|
||||
await goToGridDoc(page, {
|
||||
title: doc,
|
||||
@@ -233,8 +246,9 @@ test.describe('Doc Editor', () => {
|
||||
test.skip(browserName === 'webkit', 'This test is very flaky with webkit');
|
||||
|
||||
// Check the first doc
|
||||
const [doc] = await createDoc(page, 'doc-quit-1', browserName, 1);
|
||||
await expect(page.locator('h2').getByText(doc)).toBeVisible();
|
||||
const doc = await goToGridDoc(page);
|
||||
|
||||
await verifyDocName(page, doc);
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
@@ -267,9 +281,10 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(
|
||||
page.getByText('Read only, you cannot edit this document.'),
|
||||
).toBeVisible();
|
||||
const card = page.getByLabel('It is the card information');
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
await expect(card.getByText('Reader')).toBeVisible();
|
||||
});
|
||||
|
||||
test('it adds an image to the doc editor', async ({ page, browserName }) => {
|
||||
@@ -351,4 +366,27 @@ test.describe('Doc Editor', () => {
|
||||
|
||||
await expect(editor.getByText('Bonjour le monde')).toBeVisible();
|
||||
});
|
||||
|
||||
test('it checks the multi columns', async ({ page, browserName }) => {
|
||||
await createDoc(page, 'doc-multi-columns', browserName, 1);
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
|
||||
await page.getByText('Three Columns', { exact: true }).click();
|
||||
|
||||
await page.locator('.bn-block-column').first().fill('Column 1');
|
||||
await page.locator('.bn-block-column').nth(1).fill('Column 2');
|
||||
await page.locator('.bn-block-column').last().fill('Column 3');
|
||||
|
||||
expect(await page.locator('.bn-block-column').count()).toBe(3);
|
||||
await expect(
|
||||
page.locator('.bn-block-column[data-node-type="column"]').first(),
|
||||
).toHaveText('Column 1');
|
||||
await expect(
|
||||
page.locator('.bn-block-column[data-node-type="column"]').nth(1),
|
||||
).toHaveText('Column 2');
|
||||
await expect(
|
||||
page.locator('.bn-block-column[data-node-type="column"]').last(),
|
||||
).toHaveText('Column 3');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,34 +1,63 @@
|
||||
import path from 'path';
|
||||
|
||||
import { expect, test } from '@playwright/test';
|
||||
import cs from 'convert-stream';
|
||||
import jsdom from 'jsdom';
|
||||
import pdf from 'pdf-parse';
|
||||
|
||||
import { createDoc } from './common';
|
||||
import { createDoc, verifyDocName } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.describe('Doc Export', () => {
|
||||
test('it converts the doc to pdf with a template integrated', async ({
|
||||
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 exports the doc to pdf', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
|
||||
});
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
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: 'Export',
|
||||
name: 'download',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -47,29 +76,26 @@ test.describe('Doc Export', () => {
|
||||
expect(pdfText).toContain('Hello World'); // This is the doc text
|
||||
});
|
||||
|
||||
test('it converts the doc to docx with a template integrated', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
test('it exports the doc to docx', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
|
||||
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.docx`);
|
||||
});
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
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: 'Export',
|
||||
name: 'download',
|
||||
})
|
||||
.click();
|
||||
|
||||
await page.getByText('Docx').click();
|
||||
await page.getByRole('combobox', { name: 'Format' }).click();
|
||||
await page.getByRole('option', { name: 'Word / Open Office' }).click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
@@ -81,121 +107,61 @@ test.describe('Doc Export', () => {
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.docx`);
|
||||
});
|
||||
|
||||
test('it converts the blocknote json in correct html for the export', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
test.setTimeout(60000);
|
||||
|
||||
/**
|
||||
* This test tell us that the export to pdf is working with images
|
||||
* but it does not tell us if the images are beeing displayed correctly
|
||||
* in the pdf.
|
||||
*
|
||||
* TODO: Check if the images are displayed correctly in the pdf
|
||||
*/
|
||||
test('it exports the docs with images', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-editor', browserName, 1);
|
||||
let body = '';
|
||||
|
||||
await page.route('**/templates/*/generate-document/', async (route) => {
|
||||
const request = route.request();
|
||||
body = request.postDataJSON().body;
|
||||
|
||||
await route.continue();
|
||||
const fileChooserPromise = page.waitForEvent('filechooser');
|
||||
const downloadPromise = page.waitForEvent('download', (download) => {
|
||||
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
|
||||
});
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.locator('.bn-block-outer').last().fill('Hello World');
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('.bn-block-outer').last().fill('Break');
|
||||
await expect(page.getByText('Break')).toBeVisible();
|
||||
await page.locator('.ProseMirror.bn-editor').click();
|
||||
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
|
||||
|
||||
// Center the text
|
||||
await page.getByText('Break').dblclick();
|
||||
await page.locator('button[data-test="alignTextCenter"]').click();
|
||||
|
||||
// Change the background color
|
||||
await page.locator('button[data-test="colors"]').click();
|
||||
await page.locator('button[data-test="background-color-brown"]').click();
|
||||
|
||||
// Change the text color
|
||||
await page.getByText('Break').dblclick();
|
||||
await page.locator('button[data-test="colors"]').click();
|
||||
await page.locator('button[data-test="text-color-orange"]').click();
|
||||
|
||||
// Add a list
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Bullet List').click();
|
||||
await page
|
||||
.locator('.bn-block-content[data-content-type="bulletListItem"] p')
|
||||
.last()
|
||||
.fill('Test List 1');
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Enter');
|
||||
await page
|
||||
.locator('.bn-block-content[data-content-type="bulletListItem"] p')
|
||||
.last()
|
||||
.fill('Test List 2');
|
||||
await page.keyboard.press('Enter');
|
||||
await page
|
||||
.locator('.bn-block-content[data-content-type="bulletListItem"] p')
|
||||
.last()
|
||||
.fill('Test List 3');
|
||||
await page.getByText('Resizable image with caption').click();
|
||||
await page.getByText('Upload image').click();
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await page.keyboard.press('Backspace');
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(
|
||||
path.join(__dirname, 'assets/logo-suite-numerique.png'),
|
||||
);
|
||||
|
||||
// Add a number list
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Numbered List').click();
|
||||
await page
|
||||
.locator('.bn-block-content[data-content-type="numberedListItem"] p')
|
||||
.last()
|
||||
.fill('Test Number 1');
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(300);
|
||||
await page.keyboard.press('Enter');
|
||||
await page
|
||||
.locator('.bn-block-content[data-content-type="numberedListItem"] p')
|
||||
.last()
|
||||
.fill('Test Number 2');
|
||||
await page.keyboard.press('Enter');
|
||||
await page
|
||||
.locator('.bn-block-content[data-content-type="numberedListItem"] p')
|
||||
.last()
|
||||
.fill('Test Number 3');
|
||||
const image = page.getByRole('img', { name: 'logo-suite-numerique.png' });
|
||||
|
||||
await expect(image).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'download',
|
||||
})
|
||||
.click();
|
||||
|
||||
await page
|
||||
.getByRole('combobox', {
|
||||
name: 'Template',
|
||||
})
|
||||
.click();
|
||||
|
||||
// Add img
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.locator('.bn-block-outer').last().fill('/');
|
||||
await page
|
||||
.getByRole('option', {
|
||||
name: 'Image',
|
||||
name: 'Demo Template',
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByRole('tab', {
|
||||
name: 'Embed',
|
||||
})
|
||||
.click();
|
||||
await page
|
||||
.getByPlaceholder('Enter URL')
|
||||
.fill('https://example.com/image.jpg');
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Embed image',
|
||||
})
|
||||
.click();
|
||||
.click({
|
||||
delay: 100,
|
||||
});
|
||||
|
||||
// Download
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Export',
|
||||
})
|
||||
.click();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
@@ -203,31 +169,13 @@ test.describe('Doc Export', () => {
|
||||
})
|
||||
.click();
|
||||
|
||||
// Empty paragraph should be replaced by a <br/>
|
||||
expect(body.match(/<br>/g)?.length).toBeGreaterThanOrEqual(2);
|
||||
expect(body).toContain('style="color: orange;"');
|
||||
expect(body).toContain('custom-style="center"');
|
||||
expect(body).toContain('style="background-color: brown;"');
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBe(`${randomDoc}.pdf`);
|
||||
|
||||
const { JSDOM } = jsdom;
|
||||
const DOMParser = new JSDOM().window.DOMParser;
|
||||
const parser = new DOMParser();
|
||||
const html = parser.parseFromString(body, 'text/html');
|
||||
const pdfBuffer = await cs.toBuffer(await download.createReadStream());
|
||||
const pdfExport = await pdf(pdfBuffer);
|
||||
const pdfText = pdfExport.text;
|
||||
|
||||
const ulLis = html.querySelectorAll('ul li');
|
||||
expect(ulLis.length).toBe(3);
|
||||
expect(ulLis[0].textContent).toBe('Test List 1');
|
||||
expect(ulLis[1].textContent).toBe('Test List 2');
|
||||
expect(ulLis[2].textContent).toBe('Test List 3');
|
||||
|
||||
const olLis = html.querySelectorAll('ol li');
|
||||
expect(olLis.length).toBe(3);
|
||||
expect(olLis[0].textContent).toBe('Test Number 1');
|
||||
expect(olLis[1].textContent).toBe('Test Number 2');
|
||||
expect(olLis[2].textContent).toBe('Test Number 3');
|
||||
|
||||
const img = html.querySelectorAll('img');
|
||||
expect(img.length).toBe(1);
|
||||
expect(img[0].src).toBe('https://example.com/image.jpg');
|
||||
expect(pdfText).toContain('Hello World');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,264 +1,14 @@
|
||||
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 } });
|
||||
|
||||
@@ -326,19 +76,256 @@ test.describe('Documents Grid mobile', () => {
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
const datagrid = page.getByLabel('Datagrid of the documents page 1');
|
||||
const tableDatagrid = datagrid.getByRole('table');
|
||||
const docsGrid = page.getByTestId('docs-grid');
|
||||
await expect(docsGrid).toBeVisible();
|
||||
await expect(page.getByTestId('grid-loader')).toBeHidden();
|
||||
|
||||
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const rows = tableDatagrid.getByRole('row');
|
||||
const rows = docsGrid.getByRole('row');
|
||||
const row = rows.filter({
|
||||
hasText: 'My mocked document',
|
||||
});
|
||||
|
||||
await expect(row.getByRole('cell').nth(0)).toHaveText('My mocked document');
|
||||
await expect(row.getByRole('cell').nth(1)).toHaveText('Public');
|
||||
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();
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
mockedAccesses,
|
||||
mockedDocument,
|
||||
mockedInvitations,
|
||||
verifyDocName,
|
||||
} from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -46,6 +47,7 @@ test.describe('Doc Header', () => {
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
accesses_manage: true,
|
||||
accesses_view: true,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
@@ -59,84 +61,31 @@ test.describe('Doc Header', () => {
|
||||
const card = page.getByLabel(
|
||||
'It is the card information about the document.',
|
||||
);
|
||||
await expect(card.locator('a').getByText('home')).toBeVisible();
|
||||
await expect(card.locator('h2').getByText('Mocked document')).toBeVisible();
|
||||
await expect(card.getByText('Public')).toBeVisible();
|
||||
await expect(
|
||||
card.getByText('Created at 09/01/2021, 11:00 AM'),
|
||||
).toBeVisible();
|
||||
await expect(card.getByText('Your role: Owner')).toBeVisible();
|
||||
|
||||
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(
|
||||
page.getByRole('button', { name: 'Open the document options' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('it updates the title doc', async ({ page, browserName }) => {
|
||||
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();
|
||||
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');
|
||||
});
|
||||
|
||||
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
|
||||
@@ -146,7 +95,13 @@ test.describe('Doc Header', () => {
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.locator('h2').getByText(`Deleting the document "${randomDoc}"`),
|
||||
page.getByRole('heading', { name: 'Delete a doc' }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
`Are you sure you want to delete the document "${randomDoc}"?`,
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
@@ -159,9 +114,7 @@ test.describe('Doc Header', () => {
|
||||
page.getByText('The document has been deleted.'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Create a new document' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'New do' })).toBeVisible();
|
||||
|
||||
const row = page
|
||||
.getByLabel('Datagrid of the documents page 1')
|
||||
@@ -195,16 +148,13 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(
|
||||
page.locator('h2').getByText('Mocked document'),
|
||||
).toHaveAttribute('contenteditable');
|
||||
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Delete document' }),
|
||||
).toBeHidden();
|
||||
).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
@@ -212,34 +162,40 @@ 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(
|
||||
shareModal.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
}),
|
||||
).not.toHaveAttribute('disabled');
|
||||
await expect(shareModal.getByText('Search by email')).toBeVisible();
|
||||
await expect(page.getByPlaceholder('Type a name or email')).toBeVisible();
|
||||
|
||||
const invitationCard = shareModal.getByLabel('List invitation card');
|
||||
await expect(invitationCard).toBeVisible();
|
||||
await expect(
|
||||
invitationCard.getByText('test@invitation.test'),
|
||||
invitationCard.getByText('test@invitation.test').first(),
|
||||
).toBeVisible();
|
||||
await expect(invitationCard.getByLabel('doc-role-dropdown')).toBeVisible();
|
||||
|
||||
await invitationCard.getByRole('button', { name: 'more_horiz' }).click();
|
||||
|
||||
await expect(
|
||||
invitationCard.getByRole('combobox', { name: 'Role' }),
|
||||
).toBeEnabled();
|
||||
await expect(
|
||||
invitationCard.getByRole('button', {
|
||||
page.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeEnabled();
|
||||
await invitationCard.click();
|
||||
|
||||
const memberCard = shareModal.getByLabel('List members card');
|
||||
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
|
||||
await expect(memberCard).toBeVisible();
|
||||
await expect(
|
||||
memberCard.getByRole('combobox', { name: 'Role' }),
|
||||
).toBeEnabled();
|
||||
memberCard.getByText('test@accesses.test').first(),
|
||||
).toBeVisible();
|
||||
await expect(memberCard.getByLabel('doc-role-dropdown')).toBeVisible();
|
||||
await expect(
|
||||
memberCard.getByRole('button', {
|
||||
memberCard.getByRole('button', { name: 'more_horiz' }),
|
||||
).toBeVisible();
|
||||
await memberCard.getByRole('button', { name: 'more_horiz' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
).toBeEnabled();
|
||||
@@ -273,16 +229,12 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(
|
||||
page.locator('h2').getByText('Mocked document'),
|
||||
).toHaveAttribute('contenteditable');
|
||||
|
||||
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Delete document' }),
|
||||
).toBeHidden();
|
||||
).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
@@ -290,36 +242,24 @@ 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(
|
||||
shareModal.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
}),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(shareModal.getByText('Search by email')).toBeHidden();
|
||||
await expect(page.getByPlaceholder('Type a name or email')).toBeHidden();
|
||||
|
||||
const invitationCard = shareModal.getByLabel('List invitation card');
|
||||
await expect(
|
||||
invitationCard.getByText('test@invitation.test'),
|
||||
invitationCard.getByText('test@invitation.test').first(),
|
||||
).toBeVisible();
|
||||
await expect(invitationCard.getByLabel('doc-role-text')).toBeVisible();
|
||||
await expect(
|
||||
invitationCard.getByRole('combobox', { name: 'Role' }),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(
|
||||
invitationCard.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
invitationCard.getByRole('button', { name: 'more_horiz' }),
|
||||
).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('combobox', { name: 'Role' }),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(
|
||||
memberCard.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
memberCard.getByRole('button', { name: 'more_horiz' }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
@@ -351,16 +291,12 @@ test.describe('Doc Header', () => {
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(
|
||||
page.locator('h2').getByText('Mocked document'),
|
||||
).not.toHaveAttribute('contenteditable');
|
||||
|
||||
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Delete document' }),
|
||||
).toBeHidden();
|
||||
).toBeDisabled();
|
||||
|
||||
// Click somewhere else to close the options
|
||||
await page.click('body', { position: { x: 0, y: 0 } });
|
||||
@@ -368,36 +304,24 @@ 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(
|
||||
shareModal.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
}),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(shareModal.getByText('Search by email')).toBeHidden();
|
||||
await expect(page.getByPlaceholder('Type a name or email')).toBeHidden();
|
||||
|
||||
const invitationCard = shareModal.getByLabel('List invitation card');
|
||||
await expect(
|
||||
invitationCard.getByText('test@invitation.test'),
|
||||
invitationCard.getByText('test@invitation.test').first(),
|
||||
).toBeVisible();
|
||||
await expect(invitationCard.getByLabel('doc-role-text')).toBeVisible();
|
||||
await expect(
|
||||
invitationCard.getByRole('combobox', { name: 'Role' }),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(
|
||||
invitationCard.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
invitationCard.getByRole('button', { name: 'more_horiz' }),
|
||||
).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('combobox', { name: 'Role' }),
|
||||
).toHaveAttribute('disabled');
|
||||
await expect(
|
||||
memberCard.getByRole('button', {
|
||||
name: 'delete',
|
||||
}),
|
||||
memberCard.getByRole('button', { name: 'more_horiz' }),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
@@ -414,7 +338,7 @@ test.describe('Doc Header', () => {
|
||||
// create page and navigate to it
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Create a new document',
|
||||
name: 'New doc',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -449,7 +373,7 @@ test.describe('Doc Header', () => {
|
||||
// create page and navigate to it
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Create a new document',
|
||||
name: 'New doc',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -472,9 +396,38 @@ test.describe('Doc Header', () => {
|
||||
);
|
||||
const clipboardContent = await handle.jsonValue();
|
||||
expect(clipboardContent.trim()).toBe(
|
||||
`<h1 data-level="1">Hello World</h1><p></p>`,
|
||||
`<h1 data-level=\"1\">Hello World</h1><p></p>`,
|
||||
);
|
||||
});
|
||||
|
||||
test('it checks the copy link button', async ({ page }) => {
|
||||
await mockedDocument(page, {
|
||||
abilities: {
|
||||
destroy: false, // Means owner
|
||||
link_configuration: true,
|
||||
versions_destroy: true,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
accesses_manage: false,
|
||||
accesses_view: false,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
},
|
||||
});
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
const shareButton = page.getByRole('button', {
|
||||
name: 'Share',
|
||||
exact: true,
|
||||
});
|
||||
await expect(shareButton).toBeVisible();
|
||||
|
||||
await shareButton.click();
|
||||
await page.getByRole('button', { name: 'Copy link' }).click();
|
||||
await expect(page.getByText('Link Copied !')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Documents Header mobile', () => {
|
||||
@@ -484,6 +437,46 @@ test.describe('Documents Header mobile', () => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('it checks the copy link button', async ({ page, browserName }) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(
|
||||
browserName === 'webkit',
|
||||
'navigator.clipboard is not working with webkit and playwright',
|
||||
);
|
||||
await mockedDocument(page, {
|
||||
abilities: {
|
||||
destroy: false,
|
||||
link_configuration: true,
|
||||
versions_destroy: true,
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
accesses_manage: false,
|
||||
accesses_view: false,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
},
|
||||
});
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Copy link' })).toBeHidden();
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await page.getByRole('button', { name: 'Copy link' }).click();
|
||||
await expect(page.getByText('Link Copied !')).toBeVisible();
|
||||
// Test that clipboard is in HTML format
|
||||
const handle = await page.evaluateHandle(() =>
|
||||
navigator.clipboard.readText(),
|
||||
);
|
||||
const clipboardContent = await handle.jsonValue();
|
||||
|
||||
const origin = await page.evaluate(() => window.location.origin);
|
||||
expect(clipboardContent.trim()).toMatch(
|
||||
`${origin}/docs/mocked-document-id/`,
|
||||
);
|
||||
});
|
||||
|
||||
test('it checks the close button on Share modal', async ({ page }) => {
|
||||
await mockedDocument(page, {
|
||||
abilities: {
|
||||
@@ -493,6 +486,7 @@ test.describe('Documents Header mobile', () => {
|
||||
versions_list: true,
|
||||
versions_retrieve: true,
|
||||
accesses_manage: true,
|
||||
accesses_view: true,
|
||||
update: true,
|
||||
partial_update: true,
|
||||
retrieve: true,
|
||||
@@ -501,6 +495,7 @@ 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();
|
||||
|
||||
@@ -16,163 +16,82 @@ test.describe('Document create member', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
await expect(inputSearch).toBeVisible();
|
||||
|
||||
// Select user 1
|
||||
// Select user 1 and verify tag
|
||||
await inputSearch.fill('user');
|
||||
|
||||
const response = await responsePromise;
|
||||
const users = (await response.json()).results as {
|
||||
email: string;
|
||||
full_name: string;
|
||||
}[];
|
||||
|
||||
await page.getByRole('option', { name: users[0].email }).click();
|
||||
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();
|
||||
|
||||
// Select user 2
|
||||
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
|
||||
await inputSearch.fill('user');
|
||||
await page.getByRole('option', { name: users[1].email }).click();
|
||||
await quickSearchContent
|
||||
.getByTestId(`search-user-row-${users[1].email}`)
|
||||
.click();
|
||||
|
||||
// Select email
|
||||
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
|
||||
const email = randomName('test@test.fr', browserName, 1)[0];
|
||||
await inputSearch.fill(email);
|
||||
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();
|
||||
await quickSearchContent.getByText(email).click();
|
||||
await expect(list.getByText(email)).toBeVisible();
|
||||
|
||||
// Check roles are displayed
|
||||
await page.getByRole('combobox', { name: /Choose a role/ }).click();
|
||||
|
||||
await expect(page.getByRole('option', { name: 'Reader' })).toBeVisible();
|
||||
await expect(page.getByRole('option', { name: 'Editor' })).toBeVisible();
|
||||
await 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('option', { name: 'Administrator' }),
|
||||
page.getByRole('button', { name: 'Administrator' }),
|
||||
).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,
|
||||
);
|
||||
// Validate
|
||||
await page.getByRole('button', { name: 'Administrator' }).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
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 invitation added
|
||||
await expect(
|
||||
quickSearchContent.getByText('Pending invitations'),
|
||||
).toBeVisible();
|
||||
await expect(quickSearchContent.getByText(email).first()).toBeVisible();
|
||||
|
||||
// Check user added
|
||||
await expect(page.getByText('Share with 3 users')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(`User ${user.email} added to the document.`),
|
||||
quickSearchContent.getByText(users[0].full_name).first(),
|
||||
).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(
|
||||
listInvitation.locator('li').getByText('Invited'),
|
||||
quickSearchContent.getByText(users[0].email).first(),
|
||||
).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(
|
||||
page.getByText(`User ${user.email} added to the document.`),
|
||||
quickSearchContent.getByText(users[1].email).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
quickSearchContent.getByText(users[1].full_name).first(),
|
||||
).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 ({
|
||||
@@ -183,40 +102,43 @@ test.describe('Document create member', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
|
||||
const [email] = randomName('test@test.fr', browserName, 1);
|
||||
await inputSearch.fill(email);
|
||||
await page.getByRole('option', { name: email }).click();
|
||||
await page.getByTestId(`search-user-row-${email}`).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByRole('combobox', { name: /Choose a role/ }).click();
|
||||
await page.getByRole('option', { name: 'Owner' }).click();
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Owner' }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations/') && response.status() === 201,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Validate' }).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).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.getByRole('option', { name: email }).click();
|
||||
await page.getByTestId(`search-user-row-${email}`).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByRole('combobox', { name: /Choose a role/ }).click();
|
||||
await page.getByRole('option', { name: 'Owner' }).click();
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Owner' }).click();
|
||||
|
||||
const responsePromiseCreateInvitationFail = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations/') && response.status() === 400,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Validate' }).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
await expect(
|
||||
page.getByText(`"${email}" is already invited to the document.`),
|
||||
).toBeVisible();
|
||||
@@ -233,31 +155,32 @@ test.describe('Document create member', () => {
|
||||
|
||||
const header = page.locator('header').first();
|
||||
await header.getByRole('combobox').getByText('EN').click();
|
||||
await header.getByRole('option', { name: 'FR' }).click();
|
||||
await header.getByRole('option', { name: 'translate Français' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Partager' }).click();
|
||||
|
||||
const inputSearch = page.getByLabel(
|
||||
/Trouver un membre à ajouter au document/,
|
||||
);
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Saisie de recherche rapide',
|
||||
});
|
||||
|
||||
const email = randomName('test@test.fr', browserName, 1)[0];
|
||||
await inputSearch.fill(email);
|
||||
await page.getByRole('option', { name: email }).click();
|
||||
await page.getByTestId(`search-user-row-${email}`).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByRole('combobox', { name: /Choisissez un rôle/ }).click();
|
||||
await page.getByRole('option', { name: 'Administrateur' }).click();
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Administrateur' }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations/') && response.status() === 201,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Valider' }).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
// Check invitation sent
|
||||
await expect(page.getByText(`Invitation envoyée à ${email}`)).toBeVisible();
|
||||
|
||||
const responseCreateInvitation = await responsePromiseCreateInvitation;
|
||||
expect(responseCreateInvitation.ok()).toBeTruthy();
|
||||
expect(
|
||||
@@ -270,41 +193,46 @@ test.describe('Document create member', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
|
||||
const email = randomName('test@test.fr', browserName, 1)[0];
|
||||
await inputSearch.fill(email);
|
||||
await page.getByRole('option', { name: email }).click();
|
||||
await page.getByTestId(`search-user-row-${email}`).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByRole('combobox', { name: /Choose a role/ }).click();
|
||||
await page.getByRole('option', { name: 'Administrator' }).click();
|
||||
const container = page.getByTestId('doc-share-add-member-list');
|
||||
await container.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Administrator' }).click();
|
||||
|
||||
const responsePromiseCreateInvitation = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/invitations/') && response.status() === 201,
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Validate' }).click();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
// Check invitation sent
|
||||
await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible();
|
||||
const responseCreateInvitation = await responsePromiseCreateInvitation;
|
||||
expect(responseCreateInvitation.ok()).toBeTruthy();
|
||||
|
||||
const listInvitation = page.getByLabel('List invitation card');
|
||||
const li = listInvitation.locator('li').filter({
|
||||
hasText: email,
|
||||
});
|
||||
await expect(li.getByText(email)).toBeVisible();
|
||||
const listInvitation = page.getByTestId('doc-share-quick-search');
|
||||
const userInvitation = listInvitation.getByTestId(
|
||||
`doc-share-invitation-row-${email}`,
|
||||
);
|
||||
await expect(userInvitation).toBeVisible();
|
||||
|
||||
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();
|
||||
await userInvitation.getByLabel('doc-role-dropdown').click();
|
||||
await page.getByRole('button', { name: 'Reader' }).click();
|
||||
|
||||
const moreActions = userInvitation.getByRole('button', {
|
||||
name: 'more_horiz',
|
||||
});
|
||||
await moreActions.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await expect(userInvitation).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { waitForElementCount } from '../helpers';
|
||||
|
||||
import { addNewMember, createDoc, goToGridDoc } from './common';
|
||||
import { addNewMember, createDoc, goToGridDoc, verifyDocName } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -15,10 +13,11 @@ test.describe('Document list members', () => {
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pageId = url.searchParams.get('page');
|
||||
const pageId = url.searchParams.get('page') ?? '1';
|
||||
|
||||
const accesses = {
|
||||
count: 100,
|
||||
next: 'http://anything/?page=2',
|
||||
count: 40,
|
||||
next: +pageId < 2 ? 'http://anything/?page=2' : undefined,
|
||||
previous: null,
|
||||
results: Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
|
||||
@@ -47,26 +46,23 @@ test.describe('Document list members', () => {
|
||||
},
|
||||
);
|
||||
|
||||
await goToGridDoc(page);
|
||||
const docTitle = await goToGridDoc(page);
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const list = page.getByLabel('List members card').locator('ul');
|
||||
await expect(list.locator('li')).toHaveCount(20);
|
||||
await list.getByText(`impress@impress.world-page-${1}-18`).hover();
|
||||
await page.mouse.wheel(0, 10);
|
||||
const prefix = 'doc-share-member-row';
|
||||
const elements = page.locator(`[data-testid^="${prefix}"]`);
|
||||
const loadMore = page.getByTestId('load-more-members');
|
||||
|
||||
await waitForElementCount(list.locator('li'), 21, 10000);
|
||||
await expect(elements).toHaveCount(20);
|
||||
await expect(page.getByText(`Impress World Page 1-16`)).toBeVisible();
|
||||
|
||||
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();
|
||||
await loadMore.click();
|
||||
await expect(elements).toHaveCount(40);
|
||||
await expect(page.getByText(`Impress World Page 2-15`)).toBeVisible();
|
||||
|
||||
await expect(loadMore).toBeHidden();
|
||||
});
|
||||
|
||||
test('it checks a big list of invitations', async ({ page }) => {
|
||||
@@ -75,10 +71,10 @@ test.describe('Document list members', () => {
|
||||
async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const pageId = url.searchParams.get('page');
|
||||
const pageId = url.searchParams.get('page') ?? '1';
|
||||
const accesses = {
|
||||
count: 100,
|
||||
next: 'http://anything/?page=2',
|
||||
count: 40,
|
||||
next: +pageId < 2 ? 'http://anything/?page=2' : null,
|
||||
previous: null,
|
||||
results: Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `2ff1ec07-86c1-4534-a643-f41824a6c53a-${pageId}-${i}`,
|
||||
@@ -104,131 +100,128 @@ test.describe('Document list members', () => {
|
||||
},
|
||||
);
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
const docTitle = await goToGridDoc(page);
|
||||
await verifyDocName(page, docTitle);
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
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);
|
||||
const prefix = 'doc-share-invitation';
|
||||
const elements = page.locator(`[data-testid^="${prefix}"]`);
|
||||
const loadMore = page.getByTestId('load-more-invitations');
|
||||
|
||||
await waitForElementCount(list.locator('li'), 21, 10000);
|
||||
await expect(elements).toHaveCount(20);
|
||||
await expect(
|
||||
page.getByText(`impress@impress.world-page-1-16`).first(),
|
||||
).toBeVisible();
|
||||
|
||||
expect(await list.locator('li').count()).toBeGreaterThan(20);
|
||||
await loadMore.click();
|
||||
await expect(elements).toHaveCount(40);
|
||||
await expect(
|
||||
list.getByText(`impress@impress.world-page-1-16`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
list.getByText(`impress@impress.world-page-2-15`),
|
||||
page.getByText(`impress@impress.world-page-2-16`).first(),
|
||||
).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 expect(page.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const list = page.getByLabel('List members card').locator('ul');
|
||||
|
||||
await expect(list.getByText(`user@${browserName}.e2e`)).toBeVisible();
|
||||
|
||||
const soleOwner = list.getByText(
|
||||
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(
|
||||
`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(soleOwner).toBeVisible();
|
||||
await expect(newUser).toBeVisible();
|
||||
|
||||
const username = await addNewMember(page, 0, 'Owner');
|
||||
await currentUserRole.click();
|
||||
await expect(soloOwner).toBeHidden();
|
||||
await list.click();
|
||||
|
||||
await expect(list.getByText(username)).toBeVisible();
|
||||
|
||||
await expect(soleOwner).toBeHidden();
|
||||
|
||||
const otherOwner = list.getByText(
|
||||
const otherOwner = page.getByText(
|
||||
`You cannot update the role or remove other owner.`,
|
||||
);
|
||||
|
||||
await newUserRoles.click();
|
||||
await expect(otherOwner).toBeVisible();
|
||||
await list.click();
|
||||
|
||||
const SelectRoleCurrentUser = list
|
||||
.locator('li')
|
||||
.filter({
|
||||
hasText: `user@${browserName}.e2e`,
|
||||
})
|
||||
.getByRole('combobox', { name: 'Role' });
|
||||
await currentUserRole.click();
|
||||
await page.getByRole('button', { name: 'Administrator' }).click();
|
||||
await list.click();
|
||||
await expect(currentUserRole).toBeVisible();
|
||||
|
||||
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');
|
||||
await currentUserRole.click();
|
||||
await page.getByRole('button', { name: 'Reader' }).click();
|
||||
await list.click();
|
||||
await expect(currentUserRole).toBeHidden();
|
||||
});
|
||||
|
||||
test('it checks the delete members', async ({ page, browserName }) => {
|
||||
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
|
||||
|
||||
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const list = page.getByLabel('List members card').locator('ul');
|
||||
const list = page.getByTestId('doc-share-quick-search');
|
||||
|
||||
const nameMyself = `user@${browserName}.e2e`;
|
||||
await expect(list.getByText(nameMyself)).toBeVisible();
|
||||
const emailMyself = `user@${browserName}.e2e`;
|
||||
const mySelf = list.getByTestId(`doc-share-member-row-${emailMyself}`);
|
||||
const mySelfMoreActions = mySelf.getByRole('button', {
|
||||
name: 'more_horiz',
|
||||
});
|
||||
|
||||
const userOwner = await addNewMember(page, 0, 'Owner');
|
||||
await expect(list.getByText(userOwner)).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 userReader = await addNewMember(page, 0, 'Reader');
|
||||
await expect(list.getByText(userReader)).toBeVisible();
|
||||
await page.getByRole('button', { name: 'close' }).first().click();
|
||||
await page.getByRole('button', { name: 'Share' }).first().click();
|
||||
|
||||
await list
|
||||
.locator('li')
|
||||
.filter({
|
||||
hasText: userReader,
|
||||
})
|
||||
.getByText('delete')
|
||||
.click();
|
||||
const userReaderEmail = await addNewMember(page, 0, 'Reader');
|
||||
|
||||
await expect(list.getByText(userReader)).toBeHidden();
|
||||
const userReader = list.getByTestId(
|
||||
`doc-share-member-row-${userReaderEmail}`,
|
||||
);
|
||||
const userReaderMoreActions = userReader.getByRole('button', {
|
||||
name: 'more_horiz',
|
||||
});
|
||||
|
||||
await list
|
||||
.locator('li')
|
||||
.filter({
|
||||
hasText: nameMyself,
|
||||
})
|
||||
.getByText('delete')
|
||||
.click();
|
||||
await expect(mySelf).toBeVisible();
|
||||
await expect(userOwner).toBeVisible();
|
||||
await expect(userReader).toBeVisible();
|
||||
|
||||
await expect(list.getByText(nameMyself)).toBeHidden();
|
||||
await expect(userOwnerMoreActions).toBeVisible();
|
||||
await expect(userReaderMoreActions).toBeVisible();
|
||||
await expect(mySelfMoreActions).toBeVisible();
|
||||
|
||||
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('The member has been removed from the document').first(),
|
||||
page.getByText('You do not have permission to perform this action.'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Share', level: 3 }),
|
||||
).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: 'Create a new document',
|
||||
name: 'New doc',
|
||||
});
|
||||
|
||||
await expect(buttonCreateHomepage).toBeVisible();
|
||||
@@ -27,7 +27,7 @@ test.describe('Doc Routing', () => {
|
||||
await expect(page).toHaveURL('/');
|
||||
|
||||
const buttonCreateHomepage = page.getByRole('button', {
|
||||
name: 'Create a new document',
|
||||
name: 'New doc',
|
||||
});
|
||||
|
||||
await expect(buttonCreateHomepage).toBeVisible();
|
||||
|
||||
114
src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts
Normal file
114
src/frontend/apps/e2e/__tests__/app-impress/doc-search.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, goToGridDoc } from './common';
|
||||
import { createDoc, verifyDocName } from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -17,123 +17,29 @@ test.describe('Doc Table Content', () => {
|
||||
1,
|
||||
);
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Table of contents',
|
||||
})
|
||||
.click();
|
||||
await page.locator('.ProseMirror').click();
|
||||
|
||||
const panel = page.getByLabel('Document panel');
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await page.keyboard.type('# Level 1\n## Level 2\n### Level 3');
|
||||
|
||||
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 summaryContainer = page.locator('#summaryContainer');
|
||||
await summaryContainer.click();
|
||||
|
||||
await page.locator('.bn-block-outer').first().click();
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
const level1 = summaryContainer.getByText('Level 1');
|
||||
const level2 = summaryContainer.getByText('Level 2');
|
||||
const level3 = summaryContainer.getByText('Level 3');
|
||||
|
||||
// Create space to fill the viewport
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
await expect(level1).toBeVisible();
|
||||
await expect(level1).toHaveCSS('padding', /4px 0px/);
|
||||
await expect(level1).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
await editor.locator('.bn-block-outer').last().fill('/');
|
||||
await page.getByText('Heading 2').click();
|
||||
await page.keyboard.type('Super World', { delay: 100 });
|
||||
await expect(level2).toBeVisible();
|
||||
await expect(level2).toHaveCSS('padding-left', /14.4px/);
|
||||
await expect(level2).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');
|
||||
await expect(level3).toBeVisible();
|
||||
await expect(level3).toHaveCSS('padding-left', /24px/);
|
||||
await expect(level3).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, goToGridDoc, mockedDocument } from './common';
|
||||
import {
|
||||
createDoc,
|
||||
goToGridDoc,
|
||||
mockedDocument,
|
||||
verifyDocName,
|
||||
} from './common';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
@@ -10,7 +15,7 @@ test.describe('Doc Version', () => {
|
||||
test('it displays the doc versions', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
@@ -18,24 +23,29 @@ test.describe('Doc Version', () => {
|
||||
name: 'Version history',
|
||||
})
|
||||
.click();
|
||||
await expect(page.getByText('History', { exact: true })).toBeVisible();
|
||||
|
||||
const panel = page.getByLabel('Document panel');
|
||||
const modal = page.getByLabel('version history modal');
|
||||
const panel = modal.getByLabel('version list');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(modal.getByText('No versions')).toBeVisible();
|
||||
|
||||
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');
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await modal.getByRole('button', { name: 'close' }).click();
|
||||
await editor.click();
|
||||
await page.keyboard.type('# Hello World');
|
||||
|
||||
await goToGridDoc(page, {
|
||||
title: randomDoc,
|
||||
});
|
||||
|
||||
await expect(page.getByText('Hello World')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Hello World' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.locator('.ProseMirror .bn-block')
|
||||
.getByText('Hello World')
|
||||
.getByRole('heading', { name: 'Hello World' })
|
||||
.fill('It will create a version');
|
||||
|
||||
await goToGridDoc(page, {
|
||||
@@ -43,7 +53,9 @@ test.describe('Doc Version', () => {
|
||||
});
|
||||
|
||||
await expect(page.getByText('Hello World')).toBeHidden();
|
||||
await expect(page.getByText('It will create a version')).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'It will create a version' }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await page
|
||||
@@ -52,19 +64,15 @@ test.describe('Doc Version', () => {
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(panel.getByText('Current version')).toBeVisible();
|
||||
expect(await panel.locator('li').count()).toBe(2);
|
||||
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 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();
|
||||
await expect(modal.getByText('Hello World')).toBeVisible();
|
||||
await expect(modal.getByText('It will create a version')).toBeHidden();
|
||||
});
|
||||
|
||||
test('it does not display the doc versions if not allowed', async ({
|
||||
@@ -79,24 +87,17 @@ test.describe('Doc Version', () => {
|
||||
|
||||
await goToGridDoc(page);
|
||||
|
||||
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
|
||||
await verifyDocName(page, 'Mocked document');
|
||||
|
||||
await page.getByLabel('Open the document options').click();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Version history' }),
|
||||
).toBeHidden();
|
||||
|
||||
await page.getByRole('button', { name: 'Table of content' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByLabel('Document panel').getByText('Versions'),
|
||||
).toBeHidden();
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('it restores the doc version', async ({ page, browserName }) => {
|
||||
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
|
||||
|
||||
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
|
||||
await verifyDocName(page, randomDoc);
|
||||
|
||||
await page.locator('.bn-block-outer').last().click();
|
||||
await page.locator('.bn-block-outer').last().fill('Hello');
|
||||
@@ -124,84 +125,26 @@ test.describe('Doc Version', () => {
|
||||
})
|
||||
.click();
|
||||
|
||||
const panel = page.getByLabel('Document panel');
|
||||
await panel.locator('li').nth(1).click();
|
||||
await expect(page.getByText('World')).toBeHidden();
|
||||
const modal = page.getByLabel('version history modal');
|
||||
const panel = modal.getByLabel('version list');
|
||||
await expect(panel).toBeVisible();
|
||||
|
||||
await panel.getByLabel('Open the version options').click();
|
||||
await page.getByText('Restore the version').click();
|
||||
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 expect(page.getByText('Restore this version?')).toBeVisible();
|
||||
await expect(modal.getByText('World')).toBeHidden();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Restore',
|
||||
})
|
||||
.click();
|
||||
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 expect(panel.locator('li')).toHaveCount(3);
|
||||
await page.getByLabel('Restore', { exact: true }).click();
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { createDoc, keyCloakSignIn } from './common';
|
||||
import { createDoc, keyCloakSignIn, verifyDocName } from './common';
|
||||
|
||||
const browsersName = ['chromium', 'webkit', 'firefox'];
|
||||
|
||||
@@ -36,35 +36,31 @@ test.describe('Doc Visibility', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const selectVisibility = page.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
});
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
|
||||
await expect(selectVisibility.getByText('Restricted')).toBeVisible();
|
||||
await expect(selectVisibility.getByText('Private')).toBeVisible();
|
||||
|
||||
await expect(page.getByLabel('Read only')).toBeHidden();
|
||||
await expect(page.getByLabel('Can read and edit')).toBeHidden();
|
||||
|
||||
await selectVisibility.click();
|
||||
await page
|
||||
.getByRole('option', {
|
||||
name: 'Authenticated',
|
||||
.getByRole('button', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByLabel('Read only')).toBeVisible();
|
||||
await expect(page.getByLabel('Can read and edit')).toBeVisible();
|
||||
await expect(page.getByLabel('Visibility mode')).toBeVisible();
|
||||
|
||||
await selectVisibility.click();
|
||||
|
||||
await page
|
||||
.getByRole('option', {
|
||||
.getByRole('button', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page.getByLabel('Read only')).toBeVisible();
|
||||
await expect(page.getByLabel('Can read and edit')).toBeVisible();
|
||||
await expect(page.getByLabel('Visibility mode')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +81,7 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
1,
|
||||
);
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
@@ -111,7 +107,7 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
|
||||
const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
@@ -139,11 +135,13 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
|
||||
const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
const inputSearch = page.getByLabel(/Find a member to add to the document/);
|
||||
const inputSearch = page.getByRole('combobox', {
|
||||
name: 'Quick search input',
|
||||
});
|
||||
|
||||
const otherBrowser = browsersName.find((b) => b !== browserName);
|
||||
const username = `user@${otherBrowser}.e2e`;
|
||||
@@ -151,14 +149,11 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
await page.getByRole('option', { name: username }).click();
|
||||
|
||||
// Choose a role
|
||||
await page.getByRole('combobox', { name: /Choose a role/ }).click();
|
||||
await page.getByRole('option', { name: 'Administrator' }).click();
|
||||
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('button', { name: 'Validate' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(`User ${username} added to the document.`),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Invite' }).click();
|
||||
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
@@ -176,8 +171,8 @@ test.describe('Doc Visibility: Restricted', () => {
|
||||
|
||||
await page.goto(urlDoc);
|
||||
|
||||
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
await expect(page.getByLabel('Share button')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,17 +193,14 @@ test.describe('Doc Visibility: Public', () => {
|
||||
1,
|
||||
);
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await page
|
||||
.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
})
|
||||
.click();
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
await selectVisibility.click();
|
||||
|
||||
await page
|
||||
.getByRole('option', {
|
||||
.getByRole('button', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
@@ -217,22 +209,32 @@ test.describe('Doc Visibility: Public', () => {
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByLabel('Read only').click();
|
||||
await page.getByLabel('Visibility mode').click();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Reading',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.').first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
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 expect(
|
||||
page
|
||||
.getByLabel('It is the card information about the document.')
|
||||
.getByText('Public', { exact: true }),
|
||||
cardContainer.getByText('Public document', { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'search' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible();
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
await page
|
||||
@@ -246,10 +248,13 @@ test.describe('Doc Visibility: Public', () => {
|
||||
await page.goto(urlDoc);
|
||||
|
||||
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'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'search' })).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'New doc' })).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||
const card = page.getByLabel('It is the card information');
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
await expect(card.getByText('Reader')).toBeVisible();
|
||||
});
|
||||
|
||||
test('It checks a public doc in editable mode', async ({
|
||||
@@ -261,17 +266,14 @@ test.describe('Doc Visibility: Public', () => {
|
||||
|
||||
const [docTitle] = await createDoc(page, 'Public editable', browserName, 1);
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await page
|
||||
.getByRole('combobox', {
|
||||
name: 'Visibility',
|
||||
})
|
||||
.click();
|
||||
const selectVisibility = page.getByLabel('Visibility', { exact: true });
|
||||
await selectVisibility.click();
|
||||
|
||||
await page
|
||||
.getByRole('option', {
|
||||
.getByRole('button', {
|
||||
name: 'Public',
|
||||
})
|
||||
.click();
|
||||
@@ -280,20 +282,23 @@ test.describe('Doc Visibility: Public', () => {
|
||||
page.getByText('The document visibility has been updated.'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByLabel('Can read and edit').click();
|
||||
await page.getByLabel('Visibility mode').click();
|
||||
await page.getByLabel('Edition').click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.').first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
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 expect(
|
||||
page
|
||||
.getByLabel('It is the card information about the document.')
|
||||
.getByText('Public', { exact: true }),
|
||||
cardContainer.getByText('Public document', { exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
const urlDoc = page.url();
|
||||
@@ -308,11 +313,8 @@ test.describe('Doc Visibility: Public', () => {
|
||||
|
||||
await page.goto(urlDoc);
|
||||
|
||||
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();
|
||||
await verifyDocName(page, docTitle);
|
||||
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -333,17 +335,14 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
1,
|
||||
);
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
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('option', {
|
||||
name: 'Authenticated',
|
||||
.getByRole('button', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -351,9 +350,7 @@ 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 },
|
||||
});
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
@@ -385,17 +382,14 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
1,
|
||||
);
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
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('option', {
|
||||
name: 'Authenticated',
|
||||
.getByRole('button', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -403,9 +397,7 @@ 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 },
|
||||
});
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
const urlDoc = page.url();
|
||||
|
||||
@@ -422,19 +414,8 @@ 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();
|
||||
|
||||
const shareModal = page.getByLabel('Share modal');
|
||||
|
||||
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();
|
||||
await page.getByRole('button', { name: 'Copy link' }).click();
|
||||
await expect(page.getByText('Link Copied !')).toBeVisible();
|
||||
});
|
||||
|
||||
test('It checks a authenticated doc in editable mode', async ({
|
||||
@@ -451,17 +432,14 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
1,
|
||||
);
|
||||
|
||||
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
|
||||
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('option', {
|
||||
name: 'Authenticated',
|
||||
.getByRole('button', {
|
||||
name: 'Connected',
|
||||
})
|
||||
.click();
|
||||
|
||||
@@ -469,23 +447,15 @@ 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.getByRole('button', { name: 'Share' }).click();
|
||||
|
||||
await page.getByLabel('Can read and edit').click();
|
||||
await page.getByLabel('Visibility mode').click();
|
||||
await page.getByLabel('Edition').click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The document visibility has been updated.').first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator('.c__modal__backdrop').click({
|
||||
position: { x: 0, y: 0 },
|
||||
});
|
||||
await page.getByRole('button', { name: 'close' }).click();
|
||||
|
||||
await page
|
||||
.getByRole('button', {
|
||||
@@ -498,20 +468,9 @@ test.describe('Doc Visibility: Authenticated', () => {
|
||||
|
||||
await page.goto(urlDoc);
|
||||
|
||||
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
|
||||
await verifyDocName(page, docTitle);
|
||||
await page.getByRole('button', { name: 'Share' }).click();
|
||||
await expect(
|
||||
page.getByText('Read only, you cannot edit this document'),
|
||||
).toBeHidden();
|
||||
|
||||
const shareModal = page.getByLabel('Share modal');
|
||||
|
||||
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();
|
||||
await page.getByRole('button', { name: 'Copy link' }).click();
|
||||
await expect(page.getByText('Link Copied !')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,29 +75,13 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,11 +6,7 @@ test.beforeEach(async ({ page }) => {
|
||||
|
||||
test.describe('Language', () => {
|
||||
test('checks the language picker', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Create a new document',
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel('Logout')).toBeVisible();
|
||||
|
||||
const header = page.locator('header').first();
|
||||
await header.getByRole('combobox').getByText('English').click();
|
||||
@@ -19,11 +15,7 @@ test.describe('Language', () => {
|
||||
header.getByRole('combobox').getByText('Français'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Créer un nouveau document',
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel('Se déconnecter')).toBeVisible();
|
||||
|
||||
await header.getByRole('combobox').getByText('Français').click();
|
||||
await header.getByRole('option', { name: 'Deutsch' }).click();
|
||||
@@ -31,11 +23,7 @@ test.describe('Language', () => {
|
||||
header.getByRole('combobox').getByText('Deutsch'),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', {
|
||||
name: 'Neues Dokument erstellen',
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel('Abmelden')).toBeVisible();
|
||||
});
|
||||
|
||||
test('checks that backend uses the same language as the frontend', async ({
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "1.10.0",
|
||||
"version": "2.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts",
|
||||
@@ -13,14 +13,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.49.1",
|
||||
"@types/luxon": "3.4.2",
|
||||
"@types/node": "*",
|
||||
"@types/pdf-parse": "1.1.4",
|
||||
"eslint-config-impress": "*",
|
||||
"luxon": "3.5.0",
|
||||
"typescript": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"convert-stream": "1.0.2",
|
||||
"jsdom": "25.0.1",
|
||||
"pdf-parse": "1.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,6 @@ 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]/;
|
||||
}
|
||||
|
||||
@@ -5,22 +5,60 @@ 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.50rem',
|
||||
xl: '1.25rem',
|
||||
t: '0.6875rem',
|
||||
s: '0.75rem',
|
||||
h1: '2.2rem',
|
||||
h2: '1.7rem',
|
||||
h3: '1.37rem',
|
||||
h4: '1.15rem',
|
||||
h5: '1rem',
|
||||
h6: '0.87rem',
|
||||
h1: '2rem',
|
||||
h2: '1.75rem',
|
||||
h3: '1.5rem',
|
||||
h4: '1.375rem',
|
||||
h5: '1.25rem',
|
||||
h6: '1.125rem',
|
||||
},
|
||||
weights: {
|
||||
thin: 100,
|
||||
@@ -34,6 +72,21 @@ 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',
|
||||
@@ -104,7 +157,7 @@ const config = {
|
||||
focus: 'var(--c--components--forms-select--border-radius)',
|
||||
},
|
||||
'font-size': 'var(--c--theme--font--sizes--ml)',
|
||||
'menu-background-color': '#ffffff',
|
||||
'menu-background-color': '#fff',
|
||||
'item-background-color': {
|
||||
hover: 'var(--c--theme--colors--primary-300)',
|
||||
},
|
||||
@@ -126,7 +179,7 @@ const config = {
|
||||
},
|
||||
},
|
||||
modal: {
|
||||
'background-color': '#ffffff',
|
||||
'background-color': '#fff',
|
||||
},
|
||||
button: {
|
||||
'border-radius': {
|
||||
@@ -147,8 +200,8 @@ const config = {
|
||||
danger: {
|
||||
'color-hover': 'white',
|
||||
background: {
|
||||
color: 'var(--c--theme--colors--danger-400)',
|
||||
'color-hover': 'var(--c--theme--colors--danger-500)',
|
||||
color: 'var(--c--theme--colors--danger-600)',
|
||||
'color-hover': '#FF2725',
|
||||
'color-disabled': 'var(--c--theme--colors--danger-100)',
|
||||
},
|
||||
},
|
||||
@@ -178,7 +231,9 @@ const config = {
|
||||
color: 'var(--c--theme--colors--primary-text)',
|
||||
'color-disabled': 'var(--c--theme--colors--greyscale-600)',
|
||||
background: {
|
||||
'color-hover': 'var(--c--theme--colors--primary-100)',
|
||||
color: 'var(--c--theme--colors--primary-100)',
|
||||
'color-hover': 'var(--c--theme--colors--primary-300)',
|
||||
'color-active': 'var(--c--theme--colors--primary-100)',
|
||||
'color-disabled': 'var(--c--theme--colors--greyscale-200)',
|
||||
},
|
||||
},
|
||||
@@ -197,19 +252,19 @@ const config = {
|
||||
dsfr: {
|
||||
theme: {
|
||||
colors: {
|
||||
'card-border': '#ededed',
|
||||
'card-border': '#E5E5E5',
|
||||
'primary-text': '#000091',
|
||||
'primary-100': '#f5f5fe',
|
||||
'primary-100': '#ECECFE',
|
||||
'primary-150': '#F4F4FD',
|
||||
'primary-200': '#ececfe',
|
||||
'primary-300': '#e3e3fd',
|
||||
'primary-400': '#cacafb',
|
||||
'primary-500': '#6a6af4',
|
||||
'primary-600': '#000091',
|
||||
'primary-200': '#E3E3FD',
|
||||
'primary-300': '#CACAFB',
|
||||
'primary-400': '#8585F6',
|
||||
'primary-500': '#6A6AF4',
|
||||
'primary-600': '#313178',
|
||||
'primary-700': '#272747',
|
||||
'primary-800': '#21213f',
|
||||
'primary-900': '#1c1a36',
|
||||
'secondary-text': '#FFFFFF',
|
||||
'primary-800': '#000091',
|
||||
'primary-900': '#21213F',
|
||||
'secondary-text': '#fff',
|
||||
'secondary-100': '#fee9ea',
|
||||
'secondary-200': '#fedfdf',
|
||||
'secondary-300': '#fdbfbf',
|
||||
@@ -220,16 +275,22 @@ const config = {
|
||||
'secondary-800': '#341f1f',
|
||||
'secondary-900': '#2b1919',
|
||||
'greyscale-text': '#303C4B',
|
||||
'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',
|
||||
'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',
|
||||
'success-text': '#1f8d49',
|
||||
'success-100': '#dffee6',
|
||||
'success-200': '#b8fec9',
|
||||
@@ -241,15 +302,15 @@ const config = {
|
||||
'success-800': '#1e2e22',
|
||||
'success-900': '#19281d',
|
||||
'info-text': '#0078f3',
|
||||
'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',
|
||||
'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',
|
||||
'warning-text': '#d64d00',
|
||||
'warning-100': '#fff4f3',
|
||||
'warning-200': '#ffe9e6',
|
||||
@@ -260,16 +321,16 @@ const config = {
|
||||
'warning-700': '#5e2c21',
|
||||
'warning-800': '#3e241e',
|
||||
'warning-900': '#361e19',
|
||||
'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-text': '#FFF',
|
||||
'danger-100': '#FFE9E9',
|
||||
'danger-200': '#FFDDDD',
|
||||
'danger-300': '#FFBDBD',
|
||||
'danger-400': '#FF5655',
|
||||
'danger-500': '#F60700',
|
||||
'danger-600': '#CE0500',
|
||||
'danger-700': '#642626',
|
||||
'danger-800': '#412121',
|
||||
'danger-900': '#3a1c1c',
|
||||
'danger-900': '#391C1C',
|
||||
},
|
||||
font: {
|
||||
families: {
|
||||
@@ -288,8 +349,12 @@ const config = {
|
||||
alert: {
|
||||
'border-radius': '0',
|
||||
},
|
||||
modal: {
|
||||
'width-small': '342px',
|
||||
},
|
||||
button: {
|
||||
'medium-height': '48px',
|
||||
'medium-height': '40px',
|
||||
'medium-text-height': '40px',
|
||||
'border-radius': '4px',
|
||||
primary: {
|
||||
background: {
|
||||
@@ -297,9 +362,9 @@ const config = {
|
||||
'color-hover': '#1212ff',
|
||||
'color-active': '#2323ff',
|
||||
},
|
||||
color: '#ffffff',
|
||||
'color-hover': '#ffffff',
|
||||
'color-active': '#ffffff',
|
||||
color: '#fff',
|
||||
'color-hover': '#fff',
|
||||
'color-active': '#fff',
|
||||
},
|
||||
'primary-text': {
|
||||
background: {
|
||||
@@ -321,7 +386,7 @@ const config = {
|
||||
},
|
||||
'tertiary-text': {
|
||||
background: {
|
||||
'color-hover': 'var(--c--theme--colors--primary-100)',
|
||||
'color-hover': 'var(--c--theme--colors--greyscale-100)',
|
||||
},
|
||||
'color-hover': 'var(--c--theme--colors--primary-text)',
|
||||
color: 'var(--c--theme--colors--primary-600)',
|
||||
@@ -363,7 +428,7 @@ const config = {
|
||||
},
|
||||
'forms-input': {
|
||||
'border-radius': '4px',
|
||||
'background-color': '#ffffff',
|
||||
'background-color': '#fff',
|
||||
'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)',
|
||||
@@ -381,7 +446,7 @@ const config = {
|
||||
'item-font-size': '14px',
|
||||
'border-radius': '4px',
|
||||
'border-radius-hover': '4px',
|
||||
'background-color': '#ffffff',
|
||||
'background-color': '#fff',
|
||||
'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)',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "1.10.0",
|
||||
"version": "2.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -15,34 +15,43 @@
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blocknote/core": "*",
|
||||
"@blocknote/mantine": "*",
|
||||
"@blocknote/react": "*",
|
||||
"@blocknote/core": "0.21.0",
|
||||
"@blocknote/mantine": "0.21.0",
|
||||
"@blocknote/react": "0.21.0",
|
||||
"@blocknote/xl-multi-column": "0.21.0",
|
||||
"@blocknote/xl-docx-exporter": "0.21.0",
|
||||
"@blocknote/xl-pdf-exporter": "0.21.0",
|
||||
"@gouvfr-lasuite/integration": "1.0.2",
|
||||
"@hocuspocus/provider": "2.15.0",
|
||||
"@openfun/cunningham-react": "2.9.4",
|
||||
"@sentry/nextjs": "8.45.1",
|
||||
"@tanstack/react-query": "5.62.7",
|
||||
"@react-pdf/renderer": "4.1.6",
|
||||
"@sentry/nextjs": "8.47.0",
|
||||
"@tanstack/react-query": "5.62.11",
|
||||
"cmdk": "1.0.4",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"i18next": "24.1.0",
|
||||
"docx": "9.1.0",
|
||||
"i18next": "24.2.0",
|
||||
"i18next-browser-languagedetector": "8.0.2",
|
||||
"idb": "8.0.1",
|
||||
"lodash": "4.17.21",
|
||||
"luxon": "3.5.0",
|
||||
"next": "15.1.0",
|
||||
"next": "15.1.3",
|
||||
"posthog-js": "1.204.0",
|
||||
"react": "*",
|
||||
"react-aria-components": "1.5.0",
|
||||
"react-dom": "*",
|
||||
"react-i18next": "15.2.0",
|
||||
"react-i18next": "15.4.0",
|
||||
"react-intersection-observer": "9.13.1",
|
||||
"react-select": "5.9.0",
|
||||
"styled-components": "6.1.13",
|
||||
"use-debounce": "10.0.4",
|
||||
"y-protocols": "1.0.6",
|
||||
"yjs": "*",
|
||||
"yjs": "13.6.21",
|
||||
"zustand": "5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"@tanstack/react-query-devtools": "5.62.7",
|
||||
"@tanstack/react-query-devtools": "5.62.11",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "6.6.3",
|
||||
"@testing-library/react": "16.1.0",
|
||||
@@ -53,7 +62,7 @@
|
||||
"@types/node": "*",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "*",
|
||||
"cross-env": "*",
|
||||
"cross-env": "7.0.3",
|
||||
"dotenv": "16.4.7",
|
||||
"eslint-config-impress": "*",
|
||||
"fetch-mock": "9.11.0",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 6.4 KiB |
@@ -1,32 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,9 +1,13 @@
|
||||
import { ComponentPropsWithRef, forwardRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import { css } from 'styled-components';
|
||||
|
||||
import { Box, BoxType } from './Box';
|
||||
|
||||
export type BoxButtonType = ComponentPropsWithRef<typeof BoxButton>;
|
||||
export type BoxButtonType = BoxType & {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
/**
|
||||
* Styleless button that extends the Box component.
|
||||
@@ -18,7 +22,7 @@ export type BoxButtonType = ComponentPropsWithRef<typeof BoxButton>;
|
||||
* </BoxButton>
|
||||
* ```
|
||||
*/
|
||||
const BoxButton = forwardRef<HTMLDivElement, BoxType>(
|
||||
const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
|
||||
({ $css, ...props }, ref) => {
|
||||
return (
|
||||
<Box
|
||||
@@ -28,14 +32,24 @@ const BoxButton = forwardRef<HTMLDivElement, BoxType>(
|
||||
$margin="none"
|
||||
$padding="none"
|
||||
$css={css`
|
||||
cursor: pointer;
|
||||
cursor: ${props.disabled ? 'not-allowed' : '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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -17,8 +17,7 @@ export const Card = ({
|
||||
$background="white"
|
||||
$radius="4px"
|
||||
$css={css`
|
||||
box-shadow: 2px 2px 5px ${colorsTokens()['greyscale-300']};
|
||||
border: 1px solid ${colorsTokens()['card-border']};
|
||||
border: 1px solid ${colorsTokens()['greyscale-200']};
|
||||
${$css}
|
||||
`}
|
||||
{...props}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import React, {
|
||||
import {
|
||||
PropsWithChildren,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Button, DialogTrigger, Popover } from 'react-aria-components';
|
||||
import { Button, 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;
|
||||
`;
|
||||
|
||||
@@ -26,13 +27,15 @@ const StyledButton = styled(Button)`
|
||||
font-family: Marianne, Arial, serif;
|
||||
font-weight: 500;
|
||||
font-size: 0.938rem;
|
||||
padding: 0;
|
||||
text-wrap: nowrap;
|
||||
`;
|
||||
|
||||
interface DropButtonProps {
|
||||
export interface DropButtonProps {
|
||||
button: ReactNode;
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (isOpen: boolean) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const DropButton = ({
|
||||
@@ -40,10 +43,12 @@ 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]);
|
||||
@@ -51,21 +56,25 @@ export const DropButton = ({
|
||||
const onOpenChangeHandler = (isOpen: boolean) => {
|
||||
setIsLocalOpen(isOpen);
|
||||
onOpenChange?.(isOpen);
|
||||
setTimeout(() => {
|
||||
setOpacity(isOpen);
|
||||
}, 10);
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogTrigger onOpenChange={onOpenChangeHandler} isOpen={isLocalOpen}>
|
||||
<StyledButton>{button}</StyledButton>
|
||||
<>
|
||||
<StyledButton
|
||||
ref={triggerRef}
|
||||
onPress={() => onOpenChangeHandler(true)}
|
||||
aria-label={label}
|
||||
>
|
||||
{button}
|
||||
</StyledButton>
|
||||
|
||||
<StyledPopover
|
||||
style={{ opacity: opacity ? 1 : 0 }}
|
||||
triggerRef={triggerRef}
|
||||
isOpen={isLocalOpen}
|
||||
onOpenChange={onOpenChangeHandler}
|
||||
>
|
||||
{children}
|
||||
</StyledPopover>
|
||||
</DialogTrigger>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
156
src/frontend/apps/impress/src/components/DropdownMenu.tsx
Normal file
156
src/frontend/apps/impress/src/components/DropdownMenu.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
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'];
|
||||
disabled?: boolean;
|
||||
topMessage?: string;
|
||||
};
|
||||
|
||||
export const DropdownMenu = ({
|
||||
options,
|
||||
children,
|
||||
disabled = false,
|
||||
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);
|
||||
};
|
||||
|
||||
if (disabled) {
|
||||
return children;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,19 @@
|
||||
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;
|
||||
}
|
||||
@@ -29,23 +42,21 @@ export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface IconOptionsProps {
|
||||
isOpen: boolean;
|
||||
'aria-label': string;
|
||||
}
|
||||
type IconOptionsProps = TextType & {
|
||||
isHorizontal?: boolean;
|
||||
};
|
||||
|
||||
export const IconOptions = ({ isOpen, ...props }: IconOptionsProps) => {
|
||||
export const IconOptions = ({ isHorizontal, ...props }: IconOptionsProps) => {
|
||||
return (
|
||||
<Text
|
||||
aria-label={props['aria-label']}
|
||||
{...props}
|
||||
$isMaterialIcon
|
||||
$css={`
|
||||
transition: all 0.3s ease-in-out;
|
||||
transform: rotate(${isOpen ? '90' : '0'}deg);
|
||||
$css={css`
|
||||
user-select: none;
|
||||
${props.$css}
|
||||
`}
|
||||
>
|
||||
more_vert
|
||||
{isHorizontal ? 'more_horiz' : 'more_vert'}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { PropsWithChildren, useEffect, useRef } from 'react';
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { InView } from 'react-intersection-observer';
|
||||
|
||||
import { Box, BoxType } from '@/components';
|
||||
import { Box, BoxType, Icon } from '@/components';
|
||||
|
||||
interface InfiniteScrollProps extends BoxType {
|
||||
hasMore: boolean;
|
||||
isLoading: boolean;
|
||||
next: () => void;
|
||||
scrollContainer: HTMLElement | null;
|
||||
scrollContainer?: HTMLElement | null;
|
||||
buttonLabel?: string;
|
||||
}
|
||||
|
||||
export const InfiniteScroll = ({
|
||||
@@ -14,42 +18,31 @@ export const InfiniteScroll = ({
|
||||
hasMore,
|
||||
isLoading,
|
||||
next,
|
||||
scrollContainer,
|
||||
buttonLabel,
|
||||
...boxProps
|
||||
}: PropsWithChildren<InfiniteScrollProps>) => {
|
||||
const timeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollContainer) {
|
||||
const { t } = useTranslation();
|
||||
const loadMore = (inView: boolean) => {
|
||||
if (!inView || isLoading) {
|
||||
return;
|
||||
}
|
||||
void next();
|
||||
};
|
||||
|
||||
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>;
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Link from 'next/link';
|
||||
import styled from 'styled-components';
|
||||
import styled, { RuleSet } from 'styled-components';
|
||||
|
||||
export interface LinkProps {
|
||||
$css?: string;
|
||||
$css?: string | RuleSet<object>;
|
||||
}
|
||||
|
||||
export const StyledLink = styled(Link)<LinkProps>`
|
||||
@@ -12,5 +12,5 @@ export const StyledLink = styled(Link)<LinkProps>`
|
||||
color: #ffffff;
|
||||
}
|
||||
display: flex;
|
||||
${({ $css }) => $css && `${$css};`}
|
||||
${({ $css }) => $css && (typeof $css === 'string' ? `${$css};` : $css)}
|
||||
`;
|
||||
|
||||
35
src/frontend/apps/impress/src/components/LoadMoreText.tsx
Normal file
35
src/frontend/apps/impress/src/components/LoadMoreText.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -33,6 +33,7 @@ export interface TextProps extends BoxProps {
|
||||
| 'greyscale';
|
||||
$variation?:
|
||||
| 'text'
|
||||
| '000'
|
||||
| '100'
|
||||
| '200'
|
||||
| '300'
|
||||
@@ -41,7 +42,8 @@ export interface TextProps extends BoxProps {
|
||||
| '600'
|
||||
| '700'
|
||||
| '800'
|
||||
| '900';
|
||||
| '900'
|
||||
| '1000';
|
||||
}
|
||||
|
||||
export type TextType = ComponentPropsWithRef<typeof Text>;
|
||||
|
||||
@@ -17,8 +17,8 @@ describe('<Box />', () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText('My Box')).toHaveStyle(`
|
||||
padding-left: 4rem;
|
||||
padding-right: 4rem;
|
||||
padding-left: 2.5rem;
|
||||
padding-right: 2.5rem;
|
||||
padding-top: 3rem;
|
||||
padding-bottom: 0.5rem;`);
|
||||
});
|
||||
|
||||
@@ -2,9 +2,12 @@ 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';
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
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} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
`;
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './QuickSearch';
|
||||
export * from './QuickSearchGroup';
|
||||
export * from './QuickSearchItem';
|
||||
export * from './QuickSearchItemContent';
|
||||
@@ -0,0 +1,33 @@
|
||||
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']
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './HorizontalSeparator';
|
||||
export * from './SeparatedSection';
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Button } from '@openfun/cunningham-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAuthStore } from '@/core/auth';
|
||||
@@ -10,24 +9,14 @@ export const ButtonLogin = () => {
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<Button
|
||||
onClick={login}
|
||||
color="primary-text"
|
||||
icon={<span className="material-icons">login</span>}
|
||||
aria-label={t('Login')}
|
||||
>
|
||||
<Button onClick={login} color="primary-text" aria-label={t('Login')}>
|
||||
{t('Login')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={logout}
|
||||
color="primary-text"
|
||||
icon={<span className="material-icons">logout</span>}
|
||||
aria-label={t('Logout')}
|
||||
>
|
||||
<Button onClick={logout} color="primary-text" aria-label={t('Logout')}>
|
||||
{t('Logout')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PropsWithChildren, useEffect } from 'react';
|
||||
|
||||
import { Box } from '@/components';
|
||||
import { useCunninghamTheme } from '@/cunningham';
|
||||
import { configureCrispSession } from '@/services';
|
||||
import { PostHogProvider, configureCrispSession } from '@/services';
|
||||
import { useSentryStore } from '@/stores/useSentryStore';
|
||||
|
||||
import { useConfig } from './api/useConfig';
|
||||
@@ -45,5 +45,5 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => {
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
return <PostHogProvider conf={conf.POSTHOG_KEY}>{children}</PostHogProvider>;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user