Compare commits

..

1 Commits

Author SHA1 Message Date
Anthony LC
ab2028c624 📸(helm) production-example
We add a "production-example" environment to the
helm chart. We have the "dev" environment already,
but this one can be mistaken for a production,
so we add a "production-example" to make it clear.
"dev" is for development, it is used by our Tilt
stack.
2024-12-24 10:58:05 +01:00
294 changed files with 6984 additions and 14976 deletions

View File

@@ -1,76 +0,0 @@
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

View File

@@ -1,67 +0,0 @@
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/"

View File

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

View File

@@ -1,36 +0,0 @@
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') }}

View File

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

View File

@@ -9,15 +9,9 @@ on:
- "*"
jobs:
install-front:
uses: ./.github/workflows/front-dependencies-installation.yml
with:
node_version: '20.x'
test-front:
needs: install-front
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -29,10 +23,40 @@ 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') }}
- 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') }}
test-front:
runs-on: ubuntu-latest
needs: install-front
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: Test App
run: cd src/frontend/ && yarn test
@@ -44,39 +68,29 @@ 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
@@ -127,8 +141,12 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install frontend dependencies
uses: ./.github/workflows/front-dependencies-installation.yml
- 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: Set e2e env variables
run: cat env.d/development/common.e2e.dist >> env.d/development/common.dist

View File

@@ -206,11 +206,10 @@ jobs:
- name: Install development dependencies
run: pip install --user .[dev]
- name: Install gettext (required to compile messages) and MIME support
- name: Install gettext (required to compile messages)
run: |
sudo apt-get update
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
sudo apt-get install -y gettext pandoc
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages

View File

@@ -1,34 +0,0 @@
name: Release Chart
run-name: Release Chart
on:
push:
paths:
- src/helm/impress/**
jobs:
release:
# depending on default permission settings for your org (contents being read-only or read-write for workloads), you will have to add permissions
# see: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token
permissions:
contents: write
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cleanup
run: rm -rf ./src/helm/extra
- name: Install Helm
uses: azure/setup-helm@v4
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Publish Helm charts
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
with:
charts_dir: ./src/helm
token: ${{ secrets.GITHUB_TOKEN }}

2
.gitignore vendored
View File

@@ -30,7 +30,6 @@ MANIFEST
.next/
# Translations # Translations
*.mo
*.pot
# Environments
@@ -41,7 +40,6 @@ ENV/
env.bak/
venv.bak/
env.d/development/*
env.d/production/*
!env.d/development/*.dist
env.d/terraform

3
.gitmodules vendored
View File

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

View File

@@ -11,50 +11,9 @@ and this project adheres to
## Added
- github actions to managed Crowdin workflow
- 📈Integrate Posthog #540
- 🏷️(backend) add content-type to uploaded files #552
🔧(helm) add option to disable default tls setting by @dominikkaminski #519
📸(helm) production-example #529
## Changed
- 💄(frontend) add abilities on doc row #581
## [2.0.1] - 2025-01-17
## 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
## 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
@@ -73,11 +32,6 @@ and this project adheres to
- ⚡️(e2e) reduce flakiness on e2e tests #511
## Fixed
- 🐛(frontend) update doc editor height #481
- 💄(frontend) add doc search #485
## [1.9.0] - 2024-12-11
## Added
@@ -372,9 +326,7 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[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
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.10.0...main
[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

View File

@@ -51,7 +51,7 @@ COPY ./src/backend /app/
WORKDIR /app
# collectstatic
RUN DJANGO_CONFIGURATION=Build \
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
python manage.py collectstatic --noinput
# Replace duplicated file by a symlink to decrease the overall size of the
@@ -76,8 +76,6 @@ RUN apk add \
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
@@ -94,11 +92,6 @@ 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.

View File

@@ -38,13 +38,13 @@ DB_PORT = 5432
DOCKER_UID = $(shell id -u)
DOCKER_GID = $(shell id -g)
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
COMPOSE = DOCKER_USER=$(DOCKER_USER) ./bin/compose
COMPOSE_PRODUCTION = DOCKER_USER=$(DOCKER_USER) COMPOSE_FILE=compose.production.yaml ./bin/compose
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
COMPOSE_EXEC = $(COMPOSE) exec
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -65,19 +65,6 @@ data/media:
data/static:
@mkdir -p data/static
# -- production volumes
data/production/media:
@mkdir -p data/production/media
data/production/certs:
@mkdir -p data/production/certs
data/production/databases/backend:
@mkdir -p data/production/databases/backend
data/production/databases/keycloak:
@mkdir -p data/production/databases/keycloak
# -- Project
create-env-files: ## Copy the dist env files to env files
@@ -102,27 +89,6 @@ bootstrap: \
mails-build
.PHONY: bootstrap
bootstrap-production: ## Prepare project to run in production mode using docker compose
bootstrap-production: \
env.d/production \
data/production/media \
data/production/certs \
data/production/databases/backend \
data/production/databases/keycloak
bootstrap-production:
@echo 'Environment files created in env.d/production'
@echo 'Edit them to set good value for your production environment'
.PHONY: bootstrap-production
run-production: ## Run compose project in production mode
@$(COMPOSE_PRODUCTION) up -d ingress
.PHONY: run-production
stop-production: ## Stop compose project in production mode
@$(COMPOSE_PRODUCTION) stop
.PHONY: stop-production
# -- Docker/compose
build: cache ?= --no-cache
build: ## build the project containers
@@ -158,6 +124,8 @@ run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d y-provider
@$(COMPOSE) up --force-recreate -d nginx
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run
run-with-frontend: ## Start all the containers needed (backend to frontend)
@@ -220,12 +188,14 @@ test-back-parallel: ## run all back-end tests in parallel
makemigrations: ## run django makemigrations for the impress project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) makemigrations
.PHONY: makemigrations
migrate: ## run django migrations for the impress project.
@echo "$(BOLD)Running migrations$(RESET)"
@$(COMPOSE) up -d postgresql
@$(WAIT_DB)
@$(MANAGE) migrate
.PHONY: migrate
@@ -259,8 +229,6 @@ resetdb: ## flush database and create a superuser "admin"
@${MAKE} superuser
.PHONY: resetdb
# -- Environment variable files
env.d/development/common:
cp -n env.d/development/common.dist env.d/development/common
@@ -270,9 +238,6 @@ env.d/development/postgresql:
env.d/development/kc_postgresql:
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
env.d/production:
cp -rnf env.d/production.dist env.d/production
# -- Internationalization
env.d/development/crowdin:

160
README.md
View File

@@ -1,171 +1,113 @@
<p align="center">
<a href="https://github.com/suitenumerique/docs">
<img alt="Docs" src="/docs/assets/logo-docs.png" width="300" />
</a>
</p>
# Impress
<p align="center">
Welcome to Docs! The open source document editor where your notes can become knowledge through live collaboration
</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">
<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>
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/)
<img src="/docs/assets/docs_live_collaboration_light.gif" width="100%" align="center"/>
## Getting started
## Why use Docs ❓
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
### Prerequisite
### 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)
Make sure you have a recent version of Docker and [Docker
Compose](https://docs.docker.com/compose/install) installed on your laptop:
### 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
```bash
$ docker -v
Docker version 27.4.1, build b9d17ea
Docker version 20.10.2, build 2291f61
$ docker compose version
Docker Compose version v2.32.1
$ docker compose -v
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.
> ⚠️ 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
**Project bootstrap**
The easiest way to start working on the project is to use GNU Make:
```shellscript
```bash
$ 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:
```shellscript
```bash
username: impress
password: impress
```
📝 Note that if you need to run them afterwards, you can use the eponym Make rule:
```shellscript
```bash
$ 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:
```shellscript
```bash
$ make frontend-install
```
And run the frontend locally in development mode with the following command:
```shellscript
```bash
$ make run-frontend-development
```
To start all the services, except the frontend container, you can use the following command:
```shellscript
```bash
$ make run
```
**Adding content**
---
### Adding content
You can create a basic demo site by running:
```shellscript
$ make demo
```
$ make demo
Finally, you can check all available Make rules using:
```shellscript
```bash
$ make help
```
**Django admin**
You can access the Django admin site at
### Django admin
<http://localhost:8071/admin>.
You can access the Django admin site at
[http://localhost:8071/admin](http://localhost:8071/admin).
You first need to create a superuser account:
```shellscript
```bash
$ make superuser
```
## 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).
## Contributing
## Roadmap
Want to know where the project is headed? [🗺️ Checkout our roadmap](https://github.com/orgs/numerique-gouv/projects/13/views/11)
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.
## Licence 📝
This work is released under the MIT License (see [LICENSE](https://github.com/suitenumerique/docs/blob/main/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.
This work is released under the MIT License (see [LICENSE](./LICENSE)).

View File

@@ -6,9 +6,9 @@ REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
UNSET_USER=0
TERRAFORM_DIRECTORY="./env.d/terraform"
if [ -z ${COMPOSE_FILE+x} ]; then
COMPOSE_FILE="${REPO_DIR}/compose.yaml"
fi
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
COMPOSE_PROJECT="impress"
# _set_user: set (or unset) default user id used to run docker commands
#
@@ -40,8 +40,9 @@ function _set_user() {
# ARGS : docker compose command arguments
function _docker_compose() {
echo "🐳(compose) project, file: '${COMPOSE_FILE}'"
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
docker compose \
-p "${COMPOSE_PROJECT}" \
-f "${COMPOSE_FILE}" \
--project-directory "${REPO_DIR}" \
"$@"

View File

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

View File

@@ -1,17 +0,0 @@
#!/bin/sh
set -o errexit
# The script is pretty simple. It downloads the latest cacert.pem file from the certifi package and appends the root certificate from mkcert to it. Then it copies the updated cacert.pem file to the container.
# The script is executed with the following command:
# $ bin/update_app_cacert.sh docs-production-backend-1
CONTAINER_NAME=${1:-"docs-production-backend-1"}
echo "updating cacert.pem for certifi package in ${CONTAINER_NAME}"
curl --create-dirs https://raw.githubusercontent.com/certifi/python-certifi/refs/heads/master/certifi/cacert.pem -o /tmp/certifi/cacert.pem
cat "$(mkcert -CAROOT)/rootCA.pem" >> /tmp/certifi/cacert.pem
docker cp /tmp/certifi/cacert.pem ${CONTAINER_NAME}:/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
echo "end patching cacert.pem in ${CONTAINER_NAME}"

View File

@@ -1,167 +0,0 @@
name: docs-production
services:
postgresql:
image: postgres:16
healthcheck:
test: ["CMD", "pg_isready", "-q", "-U", "docs", "-d", "docs"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/production/postgresql
environment:
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ./data/production/databases/backend:/var/lib/postgresql/data/pgdata
redis:
image: redis:5
backend-migration:
image: lasuite/impress-backend:latest
user: ${DOCKER_USER:-1000}
command: ["python", "manage.py", "migrate", "--noinput"]
environment:
- DJANGO_CONFIGURATION=Production
env_file:
- env.d/production/backend
- env.d/production/postgresql
- env.d/production/yprovider
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
minio:
condition: service_started
backend:
image: lasuite/impress-backend:latest
user: ${DOCKER_USER:-1000}
restart: always
environment:
- DJANGO_CONFIGURATION=Production
env_file:
- env.d/production/backend
- env.d/production/postgresql
- env.d/production/yprovider
healthcheck:
test: ["CMD", "python", "manage.py", "check"]
interval: 15s
timeout: 30s
retries: 20
start_period: 10s
depends_on:
postgresql:
condition: service_healthy
restart: true
backend-migration:
condition: service_completed_successfully
redis:
condition: service_started
minio:
condition: service_started
minio-bootstrap:
condition: service_completed_successfully
celery:
user: ${DOCKER_USER:-1000}
image: lasuite/impress-backend:latest
command: ["celery", "-A", "impress.celery_app", "worker", "-l", "INFO"]
environment:
- DJANGO_CONFIGURATION=Production
env_file:
- env.d/production/backend
- env.d/production/postgresql
- env.d/production/yprovider
depends_on:
- backend
frontend:
image: lasuite/impress-frontend:latest
user: ${DOCKER_USER:-1000}
y-provider:
image: lasuite/impress-y-provider:latest
user: ${DOCKER_USER:-1000}
env_file:
- env.d/production/yprovider
kc_postgresql:
image: postgres:16
healthcheck:
test: ["CMD", "pg_isready", "-q", "-U", "keycloak", "-d", "keycloak"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/production/kc_postgresql
environment:
- PGDATA=/var/lib/postgresql/data/pgdata
volumes:
- ./data/production/databases/keycloak:/var/lib/postgresql/data/pgdata
keycloak:
image: quay.io/keycloak/keycloak:26.1.0
command: ["start"]
env_file:
- env.d/production/keycloak
- env.d/production/kc_postgresql
ports:
- "8443:8443"
volumes:
- ${DOCS_PROD_KEYCLOAK_CERT_FOLDER:-./data/production/certs}:/etc/ssl/certs:ro
depends_on:
kc_postgresql:
condition: service_healthy
restart: true
minio-bootstrap:
image: minio/mc
env_file:
- env.d/production/minio
depends_on:
minio:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set docs http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD} && \
/usr/bin/mc mb --ignore-existing docs/docs-media-storage && \
/usr/bin/mc version enable docs/docs-media-storage && \
exit 0;"
minio:
user: ${DOCKER_USER:-1000}
image: minio/minio
env_file:
- env.d/production/minio
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server /data
volumes:
- ./data/production/media:/data
ingress:
image: nginx:1.27
ports:
- "${DOCS_PROD_NGING_PORT:-443}:8083"
volumes:
- ./docker/files/production/etc/nginx/conf.d:/etc/nginx/conf.d:ro
- ${DOCS_PROD_NGINX_CERT_FOLDER:-./data/production/certs}:/etc/nginx/ssl:ro
depends_on:
frontend:
condition: service_started
y-provider:
condition: service_started
keycloak:
condition: service_started
backend:
condition: service_healthy
restart: true

View File

@@ -1,7 +1,7 @@
#
# Your crowdin's credentials
#
api_token_env: CROWDIN_PERSONAL_TOKEN
api_token_env: CROWDIN_API_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",

View File

@@ -1,13 +1,6 @@
name: docs
services:
postgresql:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 1s
timeout: 2s
retries: 300
env_file:
- env.d/development/postgresql
ports:
@@ -30,11 +23,6 @@ services:
ports:
- '9000:9000'
- '9001:9001'
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
@@ -43,9 +31,7 @@ services:
createbuckets:
image: minio/mc
depends_on:
minio:
condition: service_healthy
restart: true
- minio
entrypoint: >
sh -c "
/usr/bin/mc alias set impress http://minio:9000 impress password && \
@@ -73,15 +59,10 @@ services:
- ./src/backend:/app
- ./data/static:/data/static
depends_on:
postgresql:
condition: service_healthy
restart: true
mailcatcher:
condition: service_started
redis:
condition: service_started
createbuckets:
condition: service_started
- postgresql
- mailcatcher
- redis
- createbuckets
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -112,13 +93,9 @@ services:
- env.d/development/common
- env.d/development/postgresql
depends_on:
postgresql:
condition: service_healthy
restart: true
redis:
condition: service_started
minio:
condition: service_started
- postgresql
- redis
- minio
celery:
user: ${DOCKER_USER:-1000}
@@ -158,6 +135,9 @@ services:
ports:
- "3000:3000"
dockerize:
image: jwilder/dockerize
crowdin:
image: crowdin/cli:3.16.0
volumes:
@@ -171,7 +151,7 @@ services:
image: node:18
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
HOME: /tmp
volumes:
- ".:/app"
@@ -189,11 +169,6 @@ services:
kc_postgresql:
image: postgres:14.3
healthcheck:
test: ["CMD-SHELL", "pg_isready"]
interval: 1s
timeout: 2s
retries: 300
ports:
- "5433:5432"
env_file:
@@ -225,6 +200,4 @@ services:
ports:
- "8080:8080"
depends_on:
kc_postgresql:
condition: service_healthy
restart: true
- kc_postgresql

View File

@@ -1,132 +0,0 @@
upstream docs_backend {
server backend:8000 fail_timeout=0;
}
upstream docs_frontend {
server frontend:8080 fail_timeout=0;
}
server {
listen 8083 ssl;
server_name localhost;
# Disables server version feedback on pages and in headers
server_tokens off;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
location @proxy_to_docs_backend {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://docs_backend;
}
location @proxy_to_docs_frontend {
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_redirect off;
proxy_pass http://docs_frontend;
}
location / {
try_files $uri @proxy_to_docs_frontend;
}
location /api {
try_files $uri @proxy_to_docs_backend;
}
location /admin {
try_files $uri @proxy_to_docs_backend;
}
# Proxy auth for collaboration server
location /collaboration/ws/ {
# Collaboration Auth request configuration
auth_request /collaboration-auth;
proxy_set_header X-Forwarded-Proto https;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $canEdit $upstream_http_x_can_edit;
auth_request_set $userId $upstream_http_x_user_id;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Can-Edit $canEdit;
proxy_set_header X-User-Id $userId;
# Ensure WebSocket upgrade
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
# Collaboration server
proxy_pass http://y-provider:4444;
# Set appropriate timeout for WebSocket
proxy_read_timeout 86400;
proxy_send_timeout 86400;
# Preserve original host and additional headers
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Origin $http_origin;
proxy_set_header Host $host;
}
location /collaboration-auth {
proxy_pass http://docs_backend/api/v1.0/documents/collaboration-auth/;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
location /collaboration/api/ {
# Collaboration server
proxy_pass http://y-provider:4444;
proxy_set_header Host $host;
}
# Proxy auth for media
location /media/ {
# Auth request configuration
auth_request /media-auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $authDate $upstream_http_x_amz_date;
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass http://minio:9000/docs-media-storage/;
proxy_set_header Host minio:9000;
}
location /media-auth {
proxy_pass http://docs_backend/api/v1.0/documents/media-auth/;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,66 +0,0 @@
# Installation with docker compose
We provide a configuration for running Docs in production using docker compose. This configuration is experimental, the official way to deploy Docs in production is to use [k8s](docs/installation/k8s.md)
## Requirements
- A modern version of Docker and its Compose plugin.
- SSL certificates for Docs domain and Keycloak.
- Two domain name. One for the Docs application and an other one for Keycloak. Both can be a subdomain of a common domain. (example: docs.domain.tld and keycloak.domain.tld)
## Installation
- Clone this repository: `git clone https://github.com/suitenumerique/docs.git`
- Then in the clone directory you can run the following command: `make bootsrap-production`
## Configure your ssl certificates
You have to provide the ssl certificates. The easiest way is to use [certbot](https://certbot.eff.org/), generate the certificates with it (both for Docs and Keycloak) and then mount them in ingress and keycloak containers. Two environment variables can be used for that:
- `DOCS_PROD_NGINX_CERT_FOLDER` path to the folder containing the certificates for Docs. This folder will be mounted in `/etc/nginx/ssl` in the container. You have to adapt the certificates name in the file `docker/files/production/etc/nginx/conf.d/default.conf` accordingly with the certificates name you have (see `ssl_certificate` and `ssl_certificate_key` directives).
- `DOCS_PROD_KEYCLOAK_CERT_FOLDER` path to the folder containing the certificates for Keycloak. This folder will be mounted in `/etc/ssl/certs` in the container. You have to adapt the certificates name in the configuration file in `env.d/production/keycloak` to add the correct path for environment variables `KC_HTTPS_CERTIFICATE_FILE` and `KC_HTTPS_CERTIFICATE_KEY_FILE`.
### Configuration
All the configuration files are in the directory `env.d/production`. You have to edit all the files to complete them. For the OIDC information you will have them once Keycloak will be running and you have configured your own realm on it.
#### env.d/production/minio
All the settings related to Minio. You have to set a username and a password to manage the minio cluster. You will need them later in the `env.d/production/backend` file.
#### env.d/production/postgresql
All the settings related to the Postgresql database used by the Django application.
#### env.d/production/yprovider
All the settings related to the collaboration server. All the secret and api key must be generated.
#### env.d/production/kc_postgresql
All the settings related to the Postgresql database used by keycloak.
#### env.d/production/keycloak
All the settings related to the Keycloak application.
#### env.d/production/backend
All the settings related to the Django application. Only the settings you don't have for now are all the one related to OIDC. You will have them once the compose started and you can access to Keycloak.
## Run the compose configuration
The compose configuration can be run with the following command: `make run-production`. The first start can be a little bit long, lots of things are created. Once started you can check that everything is running with the following command: `COMPOSE_FILE=compose.production.yaml ./bin/compose ps`
## Configure keycloak
You have to create a new realm in your Keycloak and once created you have to create a new OIDC client in it. You will use this client to configure the OIDC part in `env.d/production/backend`. This is the last missing part to complete the Django application configuration.
Once the client information set in `env.d/production/backend` you have to start the containers again by running the commande `make run-production`. The command will recreate the containers with the good configuration.
### Helpers
there is a helper script to control the `docker compose` command. You can export the variable `COMPOSE_FILE` with the compose filename (`export COMPOSE_FILE=compose.production.yaml`). After you can run `./bin/compose` to run the docker compose command line.
Makefile commands available:
- `make bootstrap-production`: create the configuration files in `env.d/production`, create the directories : `data/production`. Both directories must be backup, if you loose them you loose all the data related to the application.
- `make run-production`: up the ingress containers. Will start all the containers needed in cascade.
- `make stop-production`: stop all the containers.

View File

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

View File

@@ -1,3 +1,3 @@
CROWDIN_PERSONAL_TOKEN=Your-Personal-Token
CROWDIN_API_TOKEN=Your-Api-Token
CROWDIN_PROJECT_ID=Your-Project-Id
CROWDIN_BASE_PATH=/app/src

View File

@@ -1,58 +0,0 @@
## Django
DJANGO_ALLOWED_HOSTS=impress.127.0.0.1.nip.io,keycloack.127.0.0.1.nip.io
DJANGO_SECRET_KEY=ThisIsAnExampleKeyForDevPurposeOnly
DJANGO_SETTINGS_MODULE=impress.settings
DJANGO_SUPERUSER_PASSWORD=ThisIsAnExamplePassword
# Logging
# Set to DEBUG level for dev only
LOGGING_LEVEL_HANDLERS_CONSOLE=ERROR
LOGGING_LEVEL_LOGGERS_ROOT=INFO
LOGGING_LEVEL_LOGGERS_APP=INFO
# Python
PYTHONPATH=/app
# impress settings
# Mail
DJANGO_EMAIL_BRAND_NAME="La Suite Numérique"
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_LOGO_IMG="https://impress.127.0.0.1.nip.io/assets/logo-suite-numerique.png"
DJANGO_EMAIL_PORT=1025
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=<minio root user>
AWS_S3_SECRET_ACCESS_KEY=<minio root password>
AWS_STORAGE_BUCKET_NAME=docs-media-storage
MEDIA_BASE_URL=impress.127.0.0.1.nip.io
# OIDC
USER_OIDC_FIELD_TO_SHORTNAME="given_name"
USER_OIDC_FIELDS_TO_FULLNAME="given_name,usual_name"
OIDC_OP_JWKS_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/certs
OIDC_OP_AUTHORIZATION_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/auth
OIDC_OP_TOKEN_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/token
OIDC_OP_USER_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/userinfo
OIDC_OP_LOGOUT_ENDPOINT=https://impress.127.0.0.1.nip.io/realms/impress/protocol/openid-connect/logout
OIDC_RP_CLIENT_ID=impress
OIDC_RP_CLIENT_SECRETThisIsAnExampleKeyForDevPurposeOnly
OIDC_RP_SIGN_ALGO=RS256
OIDC_RP_SCOPES="openid email"
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
OIDC_REDIRECT_ALLOWED_HOSTS=["https://impress.127.0.0.1.nip.io"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# AI
AI_BASE_URL=https://openaiendpoint.com
AI_API_KEY=password
AI_MODEL=llama
# Frontend
FRONTEND_THEME=dsfr

View File

@@ -1,9 +0,0 @@
# Postgresql db container configuration
POSTGRES_DB=keycloak
POSTGRES_USER=keycloak
POSTGRES_PASSWORD=<Set postgresql password>
# Keycloak database configuration
KC_DB_URL_DATABASE=keycloak
KC_DB_USERNAME=keycloak
KC_DB_PASSWORD=<Same password as above>

View File

@@ -1,9 +0,0 @@
KC_BOOTSTRAP_ADMIN_USERNAME=<Change this admin user>
KC_BOOTSTRAP_ADMIN_PASSWORD=<Change this admin password>
KC_DB=postgres
KC_DB_URL_HOST=kc_postgresql
KC_DB_SCHEMA=public
PROXY_ADDRESS_FORWARDING='true'
KC_HOSTNAME=http://localhost:8083
KC_HTTPS_CERTIFICATE_FILE=/etc/ssl/certs/docs.crt
KC_HTTPS_CERTIFICATE_KEY_FILE=/etc/ssl/private/docs.key

View File

@@ -1,2 +0,0 @@
MINIO_ROOT_USER=<Set minio root username>
MINIO_ROOT_PASSWORD=<Set minio root password>

View File

@@ -1,11 +0,0 @@
# Postgresql db container configuration
POSTGRES_DB=docs
POSTGRES_USER=docs
POSTGRES_PASSWORD=<Set postgresql password>
# App database configuration
DB_HOST=postgresql
DB_NAME=docs
DB_USER=docs
DB_PASSWORD=<Same password as above>
DB_PORT=5432

View File

@@ -1,5 +0,0 @@
COLLABORATION_LOGGING=true
Y_PROVIDER_API_KEY=<Set y provider api key>
COLLABORATION_API_URL=https://impress.127.0.0.1.nip.io/collaboration/api/
COLLABORATION_SERVER_ORIGIN=https://impress.127.0.0.1.nip.io
COLLABORATION_SERVER_SECRET=<Set collaboration secret>

1
secrets Submodule

Submodule secrets added at 38594182e8

View File

@@ -201,7 +201,7 @@ class DocumentSerializer(ListDocumentSerializer):
"abilities",
"created_at",
"creator",
"is_favorite",
"is_avorite",
"link_role",
"link_reach",
"nb_accesses",
@@ -264,17 +264,13 @@ 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 on its sub (unique identifier). Default on email if allowed in settings
email = validated_data["email"]
# Get the user based on the sub (unique identifier)
try:
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:
user = models.User.objects.get(sub=validated_data["sub"])
except (models.User.DoesNotExist, KeyError):
user = None
email = validated_data["email"]
else:
email = user.email
language = user.language or language
@@ -283,9 +279,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
validated_data["content"]
)
except ConversionError as err:
raise serializers.ValidationError(
{"content": ["Could not convert content"]}
) from err
raise exceptions.APIException(detail="could not convert content") from err
document = models.Document.objects.create(
title=validated_data["title"],
@@ -308,11 +302,7 @@ class ServerCreateDocumentSerializer(serializers.Serializer):
role=models.RoleChoices.OWNER,
)
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."""
# Notify the user about the newly created document
subject = validated_data.get("subject") or _(
"A new document was created on your behalf!"
)
@@ -323,6 +313,8 @@ 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.
@@ -388,7 +380,6 @@ 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
@@ -396,7 +387,6 @@ 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

View File

@@ -605,10 +605,7 @@ 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)},
"ContentType": serializer.validated_data["content_type"],
}
extra_args = {"Metadata": {"owner": str(request.user.id)}}
if serializer.validated_data["is_unsafe"]:
extra_args["Metadata"]["is_unsafe"] = "true"
@@ -679,7 +676,7 @@ class DocumentViewSet(
# Fetch the document and check if the user has access
try:
document = models.Document.objects.get(pk=pk)
document, _created = models.Document.objects.get_or_create(pk=pk)
except models.Document.DoesNotExist as exc:
logger.debug("Document with ID '%s' does not exist", pk)
raise drf.exceptions.PermissionDenied() from exc
@@ -1127,7 +1124,6 @@ class ConfigView(drf.views.APIView):
"ENVIRONMENT",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
"POSTHOG_KEY",
"LANGUAGES",
"LANGUAGE_CODE",
"SENTRY_DSN",

View File

@@ -1,7 +1,5 @@
"""Authentication Backends for the Impress core app."""
import logging
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _
@@ -11,9 +9,7 @@ from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
)
from core.models import DuplicateEmailError, User
logger = logging.getLogger(__name__)
from core.models import User
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
@@ -63,29 +59,10 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
return userinfo
def verify_claims(self, claims):
"""
Verify the presence of essential claims and the "sub" (which is mandatory as defined
by the OIDC specification) to decide if authentication should be allowed.
"""
essential_claims = settings.USER_OIDC_ESSENTIAL_CLAIMS
missing_claims = [claim for claim in essential_claims if claim not in claims]
if missing_claims:
logger.error("Missing essential claims: %s", missing_claims)
return False
return True
def get_or_create_user(self, access_token, id_token, payload):
"""Return a User based on userinfo. Create a new user if no match is found."""
user_info = self.get_userinfo(access_token, id_token, payload)
if not self.verify_claims(user_info):
raise SuspiciousOperation("Claims verification failed.")
sub = user_info["sub"]
email = user_info.get("email")
# Get user's full name from OIDC fields defined in settings
@@ -98,10 +75,13 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"short_name": short_name,
}
try:
user = User.objects.get_user_by_sub_or_email(sub, email)
except DuplicateEmailError as err:
raise SuspiciousOperation(err.message) from err
sub = user_info.get("sub")
if not sub:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
user = self.get_existing_user(sub, email)
if user:
if not user.is_active:
@@ -120,6 +100,18 @@ 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(
@@ -127,4 +119,4 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
)
if has_changed:
updated_claims = {key: value for key, value in claims.items() if value}
self.UserModel.objects.filter(id=user.id).update(**updated_claims)
self.UserModel.objects.filter(sub=user.sub).update(**updated_claims)

View File

@@ -1,95 +0,0 @@
"""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}."
)

View File

@@ -1,7 +1,6 @@
"""
Declare and configure the models for the impress core application
"""
# pylint: disable=too-many-lines
import hashlib
import smtplib
@@ -90,16 +89,6 @@ 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
@@ -137,35 +126,6 @@ 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."""
@@ -232,7 +192,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
),
)
objects = UserManager()
objects = auth_models.UserManager()
USERNAME_FIELD = "admin_email"
REQUIRED_FIELDS = []
@@ -979,10 +939,7 @@ class Invitation(BaseModel):
super().clean()
# Check if an identity already exists for the provided email
if (
User.objects.filter(email=self.email).exists()
and not settings.OIDC_ALLOW_DUPLICATE_EMAILS
):
if User.objects.filter(email=self.email).exists():
raise exceptions.ValidationError(
{"email": _("This email is already associated to a registered user.")}
)

View File

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

View File

@@ -1,50 +0,0 @@
"""
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"

View File

@@ -64,22 +64,12 @@ def test_api_documents_attachment_upload_anonymous_success():
assert response.status_code == 201
pattern = re.compile(rf"^/media/{document.id!s}/attachments/(.*)\.png")
file_path = response.json()["file"]
match = pattern.search(file_path)
match = pattern.search(response.json()["file"])
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",
@@ -216,7 +206,6 @@ 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):
@@ -258,18 +247,16 @@ def test_api_documents_attachment_upload_size_limit_exceeded(settings):
@pytest.mark.parametrize(
"name,content,extension,content_type",
"name,content,extension",
[
("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"),
("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"),
],
)
def test_api_documents_attachment_upload_fix_extension(
name, content, extension, content_type
):
def test_api_documents_attachment_upload_fix_extension(name, content, extension):
"""
A file with no extension or a wrong extension is accepted and the extension
is corrected in storage.
@@ -300,7 +287,6 @@ def test_api_documents_attachment_upload_fix_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():
@@ -349,4 +335,3 @@ 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"

View File

@@ -13,7 +13,6 @@ 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
@@ -21,7 +20,7 @@ pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_convert_md():
def mock_convert_markdown():
"""Mock YdocConverter.convert_markdown to return a converted content."""
with patch.object(
YdocConverter,
@@ -170,11 +169,8 @@ 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_md):
"""
It should be possible to create a document on behalf of a pre-existing user
by passing their sub and email.
"""
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."""
user = factories.UserFactory(language="en-us")
data = {
@@ -193,7 +189,7 @@ def test_api_documents_create_for_owner_existing(mock_convert_md):
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_markdown.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -217,10 +213,10 @@ def test_api_documents_create_for_owner_existing(mock_convert_md):
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_new_user(mock_convert_md):
def test_api_documents_create_for_owner_new_user(mock_convert_markdown):
"""
It should be possible to create a document on behalf of new users by
passing their unknown sub and email address.
passing only their email address.
"""
data = {
"title": "My Document",
@@ -238,7 +234,7 @@ def test_api_documents_create_for_owner_new_user(mock_convert_md):
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_markdown.assert_called_once_with("Document content")
document = Document.objects.get()
assert response.json() == {"id": str(document.id)}
@@ -268,190 +264,8 @@ def test_api_documents_create_for_owner_new_user(mock_convert_md):
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_md):
def test_api_documents_create_for_owner_with_custom_language(mock_convert_markdown):
"""
Test creating a document with a specific language.
Useful if the remote server knows the user's language.
@@ -473,7 +287,7 @@ def test_api_documents_create_for_owner_with_custom_language(mock_convert_md):
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_markdown.assert_called_once_with("Document content")
assert len(mail.outbox) == 1
email = mail.outbox[0]
@@ -488,7 +302,7 @@ def test_api_documents_create_for_owner_with_custom_language(mock_convert_md):
@override_settings(SERVER_TO_SERVER_API_TOKENS=["DummyToken"])
def test_api_documents_create_for_owner_with_custom_subject_and_message(
mock_convert_md,
mock_convert_markdown,
):
"""It should be possible to customize the subject and message of the invitation email."""
data = {
@@ -509,7 +323,7 @@ def test_api_documents_create_for_owner_with_custom_subject_and_message(
assert response.status_code == 201
mock_convert_md.assert_called_once_with("Document content")
mock_convert_markdown.assert_called_once_with("Document content")
assert len(mail.outbox) == 1
email = mail.outbox[0]
@@ -522,11 +336,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_md,
mock_convert_markdown,
):
"""In case of converter error, a 400 error should be raised."""
"""It should be possible to customize the subject and message of the invitation email."""
mock_convert_md.side_effect = ConversionError("Conversion failed")
mock_convert_markdown.side_effect = ConversionError("Conversion failed")
data = {
"title": "My Document",
@@ -543,33 +357,8 @@ 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")
assert response.status_code == 400
assert response.json() == {"content": ["Could not convert content"]}
mock_convert_markdown.assert_called_once_with("Document 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.",
],
}
assert response.status_code == 500
assert response.json() == {"detail": "could not convert content"}

View File

@@ -20,7 +20,6 @@ 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])
@@ -42,6 +41,5 @@ 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",
}

View File

@@ -1,30 +0,0 @@
"""
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. "
)

View File

@@ -390,11 +390,6 @@ 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
@@ -479,18 +474,6 @@ 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",
@@ -639,15 +622,6 @@ 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.

View File

@@ -1,9 +1,9 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-15 21:00+0000\n"
"PO-Revision-Date: 2025-01-16 19:53\n"
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
"PO-Revision-Date: 2024-12-17 15:53\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Language: de_DE\n"
@@ -11,342 +11,384 @@ 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-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Project: lasuite-people\n"
"X-Crowdin-Project-ID: 637934\n"
"X-Crowdin-Language: de\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
"X-Crowdin-File-ID: 8\n"
#: build/lib/core/admin.py:33 core/admin.py:33
#: core/admin.py:33
msgid "Personal info"
msgstr "Persönliche Daten"
#: build/lib/core/admin.py:46 core/admin.py:46
#: core/admin.py:46
msgid "Permissions"
msgstr "Berechtigungen"
#: build/lib/core/admin.py:58 core/admin.py:58
#: core/admin.py:58
msgid "Important dates"
msgstr "Wichtige Daten"
#: build/lib/core/api/filters.py:16 core/api/filters.py:16
#: core/api/filters.py:16
msgid "Creator is me"
msgstr "Ersteller bin ich"
msgstr ""
#: build/lib/core/api/filters.py:19 core/api/filters.py:19
#: core/api/filters.py:19
msgid "Favorite"
msgstr "Favorit"
msgstr ""
#: build/lib/core/api/filters.py:22 core/api/filters.py:22
#: core/api/filters.py:22
msgid "Title"
msgstr "Titel"
msgstr ""
#: build/lib/core/api/serializers.py:317 core/api/serializers.py:317
#: core/api/serializers.py:307
msgid "A new document was created on your behalf!"
msgstr "Ein neues Dokument wurde in Ihrem Namen erstellt!"
msgstr ""
#: build/lib/core/api/serializers.py:321 core/api/serializers.py:321
#: core/api/serializers.py:311
msgid "You have been granted ownership of a new document:"
msgstr "Sie sind Besitzer eines neuen Dokuments:"
msgstr ""
#: build/lib/core/api/serializers.py:422 core/api/serializers.py:422
#: core/api/serializers.py:414
msgid "Body"
msgstr "Inhalt"
#: build/lib/core/api/serializers.py:425 core/api/serializers.py:425
#: core/api/serializers.py:417
msgid "Body type"
msgstr "Typ"
#: build/lib/core/api/serializers.py:431 core/api/serializers.py:431
#: core/api/serializers.py:423
msgid "Format"
msgstr "Format"
#: core/authentication/backends.py:57
msgid "Invalid response format or token verification failed"
msgstr ""
#: build/lib/core/authentication/backends.py:61
#: core/authentication/backends.py:61
msgid "Invalid response format or token verification failed"
msgstr "Ungültiges Antwortformat oder Token-Verifizierung fehlgeschlagen"
#: core/authentication/backends.py:81
msgid "User info contained no recognizable user identification"
msgstr ""
#: build/lib/core/authentication/backends.py:108
#: core/authentication/backends.py:108
#: core/authentication/backends.py:88
msgid "User account is disabled"
msgstr "Benutzerkonto ist deaktiviert"
msgstr ""
#: build/lib/core/models.py:63 build/lib/core/models.py:70 core/models.py:63
#: core/models.py:70
#: core/models.py:62 core/models.py:69
msgid "Reader"
msgstr "Lesen"
#: build/lib/core/models.py:64 build/lib/core/models.py:71 core/models.py:64
#: core/models.py:71
#: core/models.py:63 core/models.py:70
msgid "Editor"
msgstr "Bearbeiten"
#: build/lib/core/models.py:72 core/models.py:72
#: core/models.py:71
msgid "Administrator"
msgstr ""
msgstr "Administrator"
#: build/lib/core/models.py:73 core/models.py:73
#: core/models.py:72
msgid "Owner"
msgstr "Besitzer"
#: build/lib/core/models.py:84 core/models.py:84
#: core/models.py:83
msgid "Restricted"
msgstr "Beschränkt"
#: build/lib/core/models.py:88 core/models.py:88
#: core/models.py:87
msgid "Authenticated"
msgstr "Authentifiziert"
#: build/lib/core/models.py:90 core/models.py:90
#: core/models.py:89
msgid "Public"
msgstr "Öffentlich"
#: build/lib/core/models.py:112 core/models.py:112
#: core/models.py:101
msgid "id"
msgstr ""
#: build/lib/core/models.py:113 core/models.py:113
#: core/models.py:102
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:119 core/models.py:119
#: core/models.py:108
msgid "created on"
msgstr "Erstellt"
#: build/lib/core/models.py:120 core/models.py:120
#: core/models.py:109
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit, an dem ein Datensatz erstellt wurde"
#: build/lib/core/models.py:125 core/models.py:125
#: core/models.py:114
msgid "updated on"
msgstr "Aktualisiert"
#: build/lib/core/models.py:126 core/models.py:126
#: core/models.py:115
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit, an dem zuletzt aktualisiert wurde"
#: 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."
#: core/models.py:135
msgid "Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_/: characters."
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 "Geben Sie eine gültige Unterseite ein. Dieser Wert darf nur Buchstaben, Zahlen und die @/./+/-/_/: Zeichen enthalten."
#: build/lib/core/models.py:181 core/models.py:181
#: core/models.py:141
msgid "sub"
msgstr "unter"
msgstr ""
#: build/lib/core/models.py:183 core/models.py:183
#: core/models.py:143
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr "Erforderlich. 255 Zeichen oder weniger. Buchstaben, Zahlen und die Zeichen @/./+/-/_/:"
msgstr ""
#: build/lib/core/models.py:192 core/models.py:192
#: core/models.py:152
msgid "full name"
msgstr "Name"
msgstr ""
#: build/lib/core/models.py:193 core/models.py:193
#: core/models.py:153
msgid "short name"
msgstr "Kurzbezeichnung"
msgstr ""
#: build/lib/core/models.py:195 core/models.py:195
#: core/models.py:155
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
msgstr ""
#: build/lib/core/models.py:200 core/models.py:200
#: core/models.py:160
msgid "admin email address"
msgstr "Admin E-Mail-Adresse"
msgstr ""
#: build/lib/core/models.py:207 core/models.py:207
#: core/models.py:167
msgid "language"
msgstr "Sprache"
#: build/lib/core/models.py:208 core/models.py:208
#: core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Benutzeroberfläche sehen möchte."
msgstr ""
#: build/lib/core/models.py:214 core/models.py:214
#: core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Nutzer Zeiten sehen möchte."
msgstr ""
#: build/lib/core/models.py:217 core/models.py:217
#: core/models.py:177
msgid "device"
msgstr "Gerät"
msgstr ""
#: build/lib/core/models.py:219 core/models.py:219
#: core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr "Ob der Benutzer ein Gerät oder ein echter Benutzer ist."
msgstr ""
#: build/lib/core/models.py:222 core/models.py:222
#: core/models.py:182
msgid "staff status"
msgstr "Status des Teammitgliedes"
msgstr ""
#: build/lib/core/models.py:224 core/models.py:224
#: core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr "Gibt an, ob der Benutzer sich in diese Admin-Seite einloggen kann."
msgstr ""
#: build/lib/core/models.py:227 core/models.py:227
#: core/models.py:187
msgid "active"
msgstr "aktiviert"
msgstr ""
#: build/lib/core/models.py:230 core/models.py:230
#: core/models.py:190
msgid "Whether this user should be treated as active. Unselect this instead of deleting accounts."
msgstr "Ob dieser Benutzer als aktiviert behandelt werden soll. Deaktivieren Sie diese Option, anstatt Konten zu löschen."
msgstr ""
#: build/lib/core/models.py:242 core/models.py:242
#: core/models.py:202
msgid "user"
msgstr "Benutzer"
#: build/lib/core/models.py:243 core/models.py:243
#: core/models.py:203
msgid "users"
msgstr "Benutzer"
#: build/lib/core/models.py:382 build/lib/core/models.py:758 core/models.py:382
#: core/models.py:758
#: core/models.py:342 core/models.py:718
msgid "title"
msgstr "Titel"
#: build/lib/core/models.py:404 core/models.py:404
#: core/models.py:364
msgid "Document"
msgstr "Dokument"
#: build/lib/core/models.py:405 core/models.py:405
#: core/models.py:365
msgid "Documents"
msgstr "Dokumente"
#: build/lib/core/models.py:408 core/models.py:408
#: core/models.py:368
msgid "Untitled Document"
msgstr "Unbenanntes Dokument"
#: build/lib/core/models.py:633 core/models.py:633
#: core/models.py:593
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} hat ein Dokument mit Ihnen geteilt!"
msgstr ""
#: build/lib/core/models.py:637 core/models.py:637
#: core/models.py:597
#, python-brace-format
msgid "{name} invited you with the role \"{role}\" on the following document:"
msgstr "{name} hat Sie mit der Rolle \"{role}\" zu folgendem Dokument eingeladen:"
msgstr ""
#: build/lib/core/models.py:640 core/models.py:640
#: core/models.py:600
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} hat ein Dokument mit Ihnen geteilt: {title}"
msgstr ""
#: build/lib/core/models.py:663 core/models.py:663
#: core/models.py:623
msgid "Document/user link trace"
msgstr "Dokument/Benutzer Linkverfolgung"
msgstr ""
#: build/lib/core/models.py:664 core/models.py:664
#: core/models.py:624
msgid "Document/user link traces"
msgstr "Dokument/Benutzer Linkverfolgung"
msgstr ""
#: build/lib/core/models.py:670 core/models.py:670
#: core/models.py:630
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:693 core/models.py:693
#: core/models.py:653
msgid "Document favorite"
msgstr "Dokumentenfavorit"
msgstr ""
#: build/lib/core/models.py:694 core/models.py:694
#: core/models.py:654
msgid "Document favorites"
msgstr "Dokumentfavoriten"
msgstr ""
#: build/lib/core/models.py:700 core/models.py:700
#: core/models.py:660
msgid "This document is already targeted by a favorite relation instance for the same user."
msgstr "Dieses Dokument ist bereits durch den gleichen Benutzer favorisiert worden."
msgstr ""
#: build/lib/core/models.py:722 core/models.py:722
#: core/models.py:682
msgid "Document/user relation"
msgstr "Dokument/Benutzerbeziehung"
msgstr ""
#: build/lib/core/models.py:723 core/models.py:723
#: core/models.py:683
msgid "Document/user relations"
msgstr "Dokument/Benutzerbeziehungen"
msgstr ""
#: build/lib/core/models.py:729 core/models.py:729
#: core/models.py:689
msgid "This user is already in this document."
msgstr "Dieser Benutzer befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:735 core/models.py:735
#: core/models.py:695
msgid "This team is already in this document."
msgstr "Dieses Team befindet sich bereits in diesem Dokument."
#: build/lib/core/models.py:741 build/lib/core/models.py:930 core/models.py:741
#: core/models.py:930
#: core/models.py:701 core/models.py:890
msgid "Either user or team must be set, not both."
msgstr "Benutzer oder Team müssen gesetzt werden, nicht beides."
#: build/lib/core/models.py:759 core/models.py:759
#: core/models.py:719
msgid "description"
msgstr "Beschreibung"
#: build/lib/core/models.py:760 core/models.py:760
#: core/models.py:720
msgid "code"
msgstr "Code"
#: build/lib/core/models.py:761 core/models.py:761
#: core/models.py:721
msgid "css"
msgstr "CSS"
#: build/lib/core/models.py:763 core/models.py:763
#: core/models.py:723
msgid "public"
msgstr "öffentlich"
#: build/lib/core/models.py:765 core/models.py:765
#: core/models.py:725
msgid "Whether this template is public for anyone to use."
msgstr "Ob diese Vorlage für jedermann öffentlich ist."
#: build/lib/core/models.py:771 core/models.py:771
#: core/models.py:731
msgid "Template"
msgstr "Vorlage"
msgstr ""
#: build/lib/core/models.py:772 core/models.py:772
#: core/models.py:732
msgid "Templates"
msgstr "Vorlagen"
msgstr ""
#: build/lib/core/models.py:911 core/models.py:911
#: core/models.py:871
msgid "Template/user relation"
msgstr "Vorlage/Benutzer-Beziehung"
msgstr ""
#: build/lib/core/models.py:912 core/models.py:912
#: core/models.py:872
msgid "Template/user relations"
msgstr "Vorlage/Benutzerbeziehungen"
msgstr ""
#: build/lib/core/models.py:918 core/models.py:918
#: core/models.py:878
msgid "This user is already in this template."
msgstr "Dieser Benutzer ist bereits in dieser Vorlage."
msgstr ""
#: build/lib/core/models.py:924 core/models.py:924
#: core/models.py:884
msgid "This team is already in this template."
msgstr "Dieses Team ist bereits in diesem Template."
msgstr ""
#: build/lib/core/models.py:947 core/models.py:947
#: core/models.py:907
msgid "email address"
msgstr "E-Mail-Adresse"
msgstr ""
#: build/lib/core/models.py:966 core/models.py:966
#: core/models.py:926
msgid "Document invitation"
msgstr "Einladung zum Dokument"
msgstr ""
#: build/lib/core/models.py:967 core/models.py:967
#: core/models.py:927
msgid "Document invitations"
msgstr "Dokumenteinladungen"
msgstr ""
#: build/lib/core/models.py:987 core/models.py:987
#: core/models.py:944
msgid "This email is already associated to a registered user."
msgstr "Diese E-Mail ist bereits einem registrierten Benutzer zugeordnet."
msgstr ""
#: build/lib/impress/settings.py:236 impress/settings.py:236
#: 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
msgid "English"
msgstr "Englisch"
msgstr ""
#: build/lib/impress/settings.py:237 impress/settings.py:237
#: impress/settings.py:237
msgid "French"
msgstr "Französisch"
msgstr ""
#: build/lib/impress/settings.py:238 impress/settings.py:238
#: impress/settings.py:238
msgid "German"
msgstr "Deutsch"
msgstr ""

Binary file not shown.

Binary file not shown.

View File

@@ -1,9 +1,9 @@
msgid ""
msgstr ""
"Project-Id-Version: lasuite-docs\n"
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-15 21:00+0000\n"
"PO-Revision-Date: 2025-01-16 19:53\n"
"POT-Creation-Date: 2024-12-17 15:50+0000\n"
"PO-Revision-Date: 2024-12-17 15:53\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -11,342 +11,384 @@ 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-docs\n"
"X-Crowdin-Project-ID: 754523\n"
"X-Crowdin-Project: lasuite-people\n"
"X-Crowdin-Project-ID: 637934\n"
"X-Crowdin-Language: fr\n"
"X-Crowdin-File: backend-impress.pot\n"
"X-Crowdin-File-ID: 18\n"
"X-Crowdin-File-ID: 8\n"
#: build/lib/core/admin.py:33 core/admin.py:33
#: core/admin.py:33
msgid "Personal info"
msgstr "Infos Personnelles"
#: build/lib/core/admin.py:46 core/admin.py:46
#: core/admin.py:46
msgid "Permissions"
msgstr ""
msgstr "Permissions"
#: build/lib/core/admin.py:58 core/admin.py:58
#: core/admin.py:58
msgid "Important dates"
msgstr "Dates importantes"
#: build/lib/core/api/filters.py:16 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
#: core/api/filters.py:19
msgid "Favorite"
msgstr ""
#: build/lib/core/api/filters.py:22 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
#: core/api/serializers.py:307
msgid "A new document was created on your behalf!"
msgstr "Un nouveau document a été créé pour vous !"
#: build/lib/core/api/serializers.py:321 core/api/serializers.py:321
#: core/api/serializers.py:311
msgid "You have been granted ownership of a new document:"
msgstr "Vous avez été déclaré propriétaire d'un nouveau document :"
#: build/lib/core/api/serializers.py:422 core/api/serializers.py:422
#: core/api/serializers.py:414
msgid "Body"
msgstr ""
#: build/lib/core/api/serializers.py:425 core/api/serializers.py:425
#: core/api/serializers.py:417
msgid "Body type"
msgstr ""
#: build/lib/core/api/serializers.py:431 core/api/serializers.py:431
#: core/api/serializers.py:423
msgid "Format"
msgstr ""
#: build/lib/core/authentication/backends.py:61
#: core/authentication/backends.py:61
#: core/authentication/backends.py:57
msgid "Invalid response format or token verification failed"
msgstr ""
#: build/lib/core/authentication/backends.py:108
#: core/authentication/backends.py:108
#: core/authentication/backends.py:81
msgid "User info contained no recognizable user identification"
msgstr ""
#: core/authentication/backends.py:88
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
#: core/models.py:62 core/models.py:69
msgid "Reader"
msgstr "Lecteur"
#: build/lib/core/models.py:64 build/lib/core/models.py:71 core/models.py:64
#: core/models.py:71
#: core/models.py:63 core/models.py:70
msgid "Editor"
msgstr "Éditeur"
#: build/lib/core/models.py:72 core/models.py:72
#: core/models.py:71
msgid "Administrator"
msgstr "Administrateur"
#: build/lib/core/models.py:73 core/models.py:73
#: core/models.py:72
msgid "Owner"
msgstr "Propriétaire"
#: build/lib/core/models.py:84 core/models.py:84
#: core/models.py:83
msgid "Restricted"
msgstr "Restreint"
#: build/lib/core/models.py:88 core/models.py:88
#: core/models.py:87
msgid "Authenticated"
msgstr "Authentifié"
#: build/lib/core/models.py:90 core/models.py:90
#: core/models.py:89
msgid "Public"
msgstr ""
msgstr "Public"
#: build/lib/core/models.py:112 core/models.py:112
#: core/models.py:101
msgid "id"
msgstr ""
#: build/lib/core/models.py:113 core/models.py:113
#: core/models.py:102
msgid "primary key for the record as UUID"
msgstr ""
#: build/lib/core/models.py:119 core/models.py:119
#: core/models.py:108
msgid "created on"
msgstr ""
#: build/lib/core/models.py:120 core/models.py:120
#: core/models.py:109
msgid "date and time at which a record was created"
msgstr ""
#: build/lib/core/models.py:125 core/models.py:125
#: core/models.py:114
msgid "updated on"
msgstr ""
#: build/lib/core/models.py:126 core/models.py:126
#: core/models.py:115
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
#: core/models.py:135
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
#: core/models.py:141
msgid "sub"
msgstr ""
#: build/lib/core/models.py:183 core/models.py:183
#: core/models.py:143
msgid "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_/: characters only."
msgstr ""
#: build/lib/core/models.py:192 core/models.py:192
#: core/models.py:152
msgid "full name"
msgstr ""
#: build/lib/core/models.py:193 core/models.py:193
#: core/models.py:153
msgid "short name"
msgstr ""
#: build/lib/core/models.py:195 core/models.py:195
#: core/models.py:155
msgid "identity email address"
msgstr ""
#: build/lib/core/models.py:200 core/models.py:200
#: core/models.py:160
msgid "admin email address"
msgstr ""
#: build/lib/core/models.py:207 core/models.py:207
#: core/models.py:167
msgid "language"
msgstr ""
#: build/lib/core/models.py:208 core/models.py:208
#: core/models.py:168
msgid "The language in which the user wants to see the interface."
msgstr ""
#: build/lib/core/models.py:214 core/models.py:214
#: core/models.py:174
msgid "The timezone in which the user wants to see times."
msgstr ""
#: build/lib/core/models.py:217 core/models.py:217
#: core/models.py:177
msgid "device"
msgstr ""
#: build/lib/core/models.py:219 core/models.py:219
#: core/models.py:179
msgid "Whether the user is a device or a real user."
msgstr ""
#: build/lib/core/models.py:222 core/models.py:222
#: core/models.py:182
msgid "staff status"
msgstr ""
#: build/lib/core/models.py:224 core/models.py:224
#: core/models.py:184
msgid "Whether the user can log into this admin site."
msgstr ""
#: build/lib/core/models.py:227 core/models.py:227
#: core/models.py:187
msgid "active"
msgstr ""
#: build/lib/core/models.py:230 core/models.py:230
#: core/models.py:190
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
#: core/models.py:202
msgid "user"
msgstr ""
#: build/lib/core/models.py:243 core/models.py:243
#: core/models.py:203
msgid "users"
msgstr ""
#: build/lib/core/models.py:382 build/lib/core/models.py:758 core/models.py:382
#: core/models.py:758
#: core/models.py:342 core/models.py:718
msgid "title"
msgstr ""
#: build/lib/core/models.py:404 core/models.py:404
#: core/models.py:364
msgid "Document"
msgstr ""
#: build/lib/core/models.py:405 core/models.py:405
#: core/models.py:365
msgid "Documents"
msgstr ""
#: build/lib/core/models.py:408 core/models.py:408
#: core/models.py:368
msgid "Untitled Document"
msgstr ""
#: build/lib/core/models.py:633 core/models.py:633
#: core/models.py:593
#, python-brace-format
msgid "{name} shared a document with you!"
msgstr "{name} a partagé un document avec vous!"
#: build/lib/core/models.py:637 core/models.py:637
#: core/models.py:597
#, 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:"
#: build/lib/core/models.py:640 core/models.py:640
#: core/models.py:600
#, python-brace-format
msgid "{name} shared a document with you: {title}"
msgstr "{name} a partagé un document avec vous: {title}"
#: build/lib/core/models.py:663 core/models.py:663
#: core/models.py:623
msgid "Document/user link trace"
msgstr ""
#: build/lib/core/models.py:664 core/models.py:664
#: core/models.py:624
msgid "Document/user link traces"
msgstr ""
#: build/lib/core/models.py:670 core/models.py:670
#: core/models.py:630
msgid "A link trace already exists for this document/user."
msgstr ""
#: build/lib/core/models.py:693 core/models.py:693
#: core/models.py:653
msgid "Document favorite"
msgstr ""
#: build/lib/core/models.py:694 core/models.py:694
#: core/models.py:654
msgid "Document favorites"
msgstr ""
#: build/lib/core/models.py:700 core/models.py:700
#: core/models.py:660
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
#: core/models.py:682
msgid "Document/user relation"
msgstr ""
#: build/lib/core/models.py:723 core/models.py:723
#: core/models.py:683
msgid "Document/user relations"
msgstr ""
#: build/lib/core/models.py:729 core/models.py:729
#: core/models.py:689
msgid "This user is already in this document."
msgstr ""
#: build/lib/core/models.py:735 core/models.py:735
#: core/models.py:695
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
#: core/models.py:701 core/models.py:890
msgid "Either user or team must be set, not both."
msgstr ""
#: build/lib/core/models.py:759 core/models.py:759
#: core/models.py:719
msgid "description"
msgstr ""
#: build/lib/core/models.py:760 core/models.py:760
#: core/models.py:720
msgid "code"
msgstr ""
#: build/lib/core/models.py:761 core/models.py:761
#: core/models.py:721
msgid "css"
msgstr ""
#: build/lib/core/models.py:763 core/models.py:763
#: core/models.py:723
msgid "public"
msgstr ""
#: build/lib/core/models.py:765 core/models.py:765
#: core/models.py:725
msgid "Whether this template is public for anyone to use."
msgstr ""
#: build/lib/core/models.py:771 core/models.py:771
#: core/models.py:731
msgid "Template"
msgstr ""
#: build/lib/core/models.py:772 core/models.py:772
#: core/models.py:732
msgid "Templates"
msgstr ""
#: build/lib/core/models.py:911 core/models.py:911
#: core/models.py:871
msgid "Template/user relation"
msgstr ""
#: build/lib/core/models.py:912 core/models.py:912
#: core/models.py:872
msgid "Template/user relations"
msgstr ""
#: build/lib/core/models.py:918 core/models.py:918
#: core/models.py:878
msgid "This user is already in this template."
msgstr ""
#: build/lib/core/models.py:924 core/models.py:924
#: core/models.py:884
msgid "This team is already in this template."
msgstr ""
#: build/lib/core/models.py:947 core/models.py:947
#: core/models.py:907
msgid "email address"
msgstr ""
#: build/lib/core/models.py:966 core/models.py:966
#: core/models.py:926
msgid "Document invitation"
msgstr ""
#: build/lib/core/models.py:967 core/models.py:967
#: core/models.py:927
msgid "Document invitations"
msgstr ""
#: build/lib/core/models.py:987 core/models.py:987
#: core/models.py:944
msgid "This email is already associated to a registered user."
msgstr ""
#: build/lib/impress/settings.py:236 impress/settings.py:236
#: 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
msgid "English"
msgstr ""
#: build/lib/impress/settings.py:237 impress/settings.py:237
#: impress/settings.py:237
msgid "French"
msgstr ""
#: build/lib/impress/settings.py:238 impress/settings.py:238
#: impress/settings.py:238
msgid "German"
msgstr ""

View File

@@ -1,352 +0,0 @@
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-16 19:53\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 ""

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "2.0.1"
version = "1.10.0"
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.90",
"boto3==1.35.81",
"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.5",
"django==5.1.4",
"djangorestframework==3.15.2",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
@@ -47,7 +47,7 @@ dependencies = [
"jsonschema==4.23.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"openai==1.58.1",
"openai==1.57.4",
"psycopg[binary]==3.2.3",
"PyJWT==2.10.1",
"pypandoc==1.14",
@@ -73,17 +73,17 @@ dev = [
"drf-spectacular-sidecar==2024.12.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==8.31.0",
"ipython==8.30.0",
"pyfakefs==5.7.3",
"pylint-django==2.6.1",
"pylint==3.3.3",
"pylint==3.3.2",
"pytest-cov==6.0.0",
"pytest-django==4.9.0",
"pytest==8.3.4",
"pytest-icdiff==0.9",
"pytest-xdist==3.6.1",
"responses==0.25.3",
"ruff==0.8.4",
"ruff==0.8.3",
"types-requests==2.32.0.20241016",
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import {
mockedAccesses,
mockedDocument,
mockedInvitations,
verifyDocName,
} from './common';
test.beforeEach(async ({ page }) => {
@@ -47,7 +46,6 @@ test.describe('Doc Header', () => {
versions_list: true,
versions_retrieve: true,
accesses_manage: true,
accesses_view: true,
update: true,
partial_update: true,
retrieve: true,
@@ -61,31 +59,84 @@ test.describe('Doc Header', () => {
const card = page.getByLabel(
'It is the card information about the document.',
);
const docTitle = card.getByRole('textbox', { name: 'doc title input' });
await expect(docTitle).toBeVisible();
await expect(card.getByText('Public document')).toBeVisible();
await expect(card.getByText('Owner ·')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await expect(card.locator('a').getByText('home')).toBeVisible();
await expect(card.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(card.getByText('Public')).toBeVisible();
await expect(
page.getByRole('button', { name: 'Open the document options' }),
card.getByText('Created at 09/01/2021, 11:00 AM'),
).toBeVisible();
await expect(card.getByText('Your role: Owner')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
test('it updates the title doc', async ({ page, browserName }) => {
await createDoc(page, 'doc-update', browserName, 1);
const docTitle = page.getByRole('textbox', { name: 'doc title input' });
await expect(docTitle).toBeVisible();
await docTitle.fill('Hello World');
await docTitle.blur();
await verifyDocName(page, 'Hello World');
const [randomDoc] = await createDoc(page, 'doc-update', browserName, 1);
await page.getByRole('heading', { name: randomDoc }).fill(' ');
await page.getByText('Created at').click();
await expect(
page.getByRole('heading', { name: 'Untitled document' }),
).toBeVisible();
});
test('it updates the title doc from editor heading', async ({ page }) => {
await page
.getByRole('button', {
name: 'Create a new document',
})
.click();
const docHeader = page.getByLabel(
'It is the card information about the document.',
);
await expect(
docHeader.getByRole('heading', { name: 'Untitled document', level: 2 }),
).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.locator('h1').click();
await page.keyboard.type('Hello World', { delay: 100 });
await expect(
docHeader.getByRole('heading', { name: 'Hello World', level: 2 }),
).toBeVisible();
await expect(
page.getByText('Document title updated successfully'),
).toBeVisible();
await docHeader
.getByRole('heading', { name: 'Hello World', level: 2 })
.fill('Top World');
await editor.locator('h1').fill('Super World');
await expect(
docHeader.getByRole('heading', { name: 'Top World', level: 2 }),
).toBeVisible();
await editor.locator('h1').fill('');
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(500);
await docHeader
.getByRole('heading', { name: 'Top World', level: 2 })
.fill(' ');
await page.getByText('Created at').click();
await expect(
docHeader.getByRole('heading', { name: 'Untitled document', level: 2 }),
).toBeVisible();
});
test('it deletes the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
@@ -95,13 +146,7 @@ test.describe('Doc Header', () => {
.click();
await expect(
page.getByRole('heading', { name: 'Delete a doc' }),
).toBeVisible();
await expect(
page.getByText(
`Are you sure you want to delete the document "${randomDoc}"?`,
),
page.locator('h2').getByText(`Deleting the document "${randomDoc}"`),
).toBeVisible();
await page
@@ -114,7 +159,9 @@ test.describe('Doc Header', () => {
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(page.getByRole('button', { name: 'New do' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Create a new document' }),
).toBeVisible();
const row = page
.getByLabel('Datagrid of the documents page 1')
@@ -148,13 +195,16 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await expect(
page.locator('h2').getByText('Mocked document'),
).toHaveAttribute('contenteditable');
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -162,40 +212,34 @@ test.describe('Doc Header', () => {
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share modal');
await expect(shareModal).toBeVisible();
await expect(page.getByText('Share the document')).toBeVisible();
await expect(page.getByPlaceholder('Type a name or email')).toBeVisible();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).not.toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeVisible();
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(invitationCard).toBeVisible();
await expect(
invitationCard.getByText('test@invitation.test').first(),
invitationCard.getByText('test@invitation.test'),
).toBeVisible();
await expect(invitationCard.getByLabel('doc-role-dropdown')).toBeVisible();
await invitationCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(
page.getByRole('button', {
invitationCard.getByRole('combobox', { name: 'Role' }),
).toBeEnabled();
await expect(
invitationCard.getByRole('button', {
name: 'delete',
}),
).toBeEnabled();
await invitationCard.click();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard).toBeVisible();
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(
memberCard.getByText('test@accesses.test').first(),
).toBeVisible();
await expect(memberCard.getByLabel('doc-role-dropdown')).toBeVisible();
memberCard.getByRole('combobox', { name: 'Role' }),
).toBeEnabled();
await expect(
memberCard.getByRole('button', { name: 'more_horiz' }),
).toBeVisible();
await memberCard.getByRole('button', { name: 'more_horiz' }).click();
await expect(
page.getByRole('button', {
memberCard.getByRole('button', {
name: 'delete',
}),
).toBeEnabled();
@@ -229,12 +273,16 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await expect(
page.locator('h2').getByText('Mocked document'),
).toHaveAttribute('contenteditable');
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -242,24 +290,36 @@ test.describe('Doc Header', () => {
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share modal');
await expect(page.getByText('Share the document')).toBeVisible();
await expect(page.getByPlaceholder('Type a name or email')).toBeHidden();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(
invitationCard.getByText('test@invitation.test').first(),
invitationCard.getByText('test@invitation.test'),
).toBeVisible();
await expect(invitationCard.getByLabel('doc-role-text')).toBeVisible();
await expect(
invitationCard.getByRole('button', { name: 'more_horiz' }),
invitationCard.getByRole('combobox', { name: 'Role' }),
).toHaveAttribute('disabled');
await expect(
invitationCard.getByRole('button', {
name: 'delete',
}),
).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(memberCard.getByLabel('doc-role-text')).toBeVisible();
await expect(
memberCard.getByRole('button', { name: 'more_horiz' }),
memberCard.getByRole('combobox', { name: 'Role' }),
).toHaveAttribute('disabled');
await expect(
memberCard.getByRole('button', {
name: 'delete',
}),
).toBeHidden();
});
@@ -291,12 +351,16 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await expect(
page.locator('h2').getByText('Mocked document'),
).not.toHaveAttribute('contenteditable');
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -304,24 +368,36 @@ test.describe('Doc Header', () => {
await page.getByRole('button', { name: 'Share' }).click();
const shareModal = page.getByLabel('Share modal');
await expect(page.getByText('Share the document')).toBeVisible();
await expect(page.getByPlaceholder('Type a name or email')).toBeHidden();
await expect(
shareModal.getByRole('combobox', {
name: 'Visibility',
}),
).toHaveAttribute('disabled');
await expect(shareModal.getByText('Search by email')).toBeHidden();
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(
invitationCard.getByText('test@invitation.test').first(),
invitationCard.getByText('test@invitation.test'),
).toBeVisible();
await expect(invitationCard.getByLabel('doc-role-text')).toBeVisible();
await expect(
invitationCard.getByRole('button', { name: 'more_horiz' }),
invitationCard.getByRole('combobox', { name: 'Role' }),
).toHaveAttribute('disabled');
await expect(
invitationCard.getByRole('button', {
name: 'delete',
}),
).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(memberCard.getByLabel('doc-role-text')).toBeVisible();
await expect(
memberCard.getByRole('button', { name: 'more_horiz' }),
memberCard.getByRole('combobox', { name: 'Role' }),
).toHaveAttribute('disabled');
await expect(
memberCard.getByRole('button', {
name: 'delete',
}),
).toBeHidden();
});
@@ -338,7 +414,7 @@ test.describe('Doc Header', () => {
// create page and navigate to it
await page
.getByRole('button', {
name: 'New doc',
name: 'Create a new document',
})
.click();
@@ -373,7 +449,7 @@ test.describe('Doc Header', () => {
// create page and navigate to it
await page
.getByRole('button', {
name: 'New doc',
name: 'Create a new document',
})
.click();
@@ -396,38 +472,9 @@ 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', () => {
@@ -437,46 +484,6 @@ 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: {
@@ -486,7 +493,6 @@ test.describe('Documents Header mobile', () => {
versions_list: true,
versions_retrieve: true,
accesses_manage: true,
accesses_view: true,
update: true,
partial_update: true,
retrieve: true,
@@ -495,7 +501,6 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Share' }).click();
await expect(page.getByLabel('Share modal')).toBeVisible();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, keyCloakSignIn, verifyDocName } from './common';
import { createDoc, keyCloakSignIn } from './common';
const browsersName = ['chromium', 'webkit', 'firefox'];
@@ -36,31 +36,35 @@ test.describe('Doc Visibility', () => {
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
const selectVisibility = page.getByRole('combobox', {
name: 'Visibility',
});
await expect(selectVisibility.getByText('Private')).toBeVisible();
await expect(selectVisibility.getByText('Restricted')).toBeVisible();
await expect(page.getByLabel('Read only')).toBeHidden();
await expect(page.getByLabel('Can read and edit')).toBeHidden();
await selectVisibility.click();
await page
.getByRole('button', {
name: 'Connected',
.getByRole('option', {
name: 'Authenticated',
})
.click();
await expect(page.getByLabel('Visibility mode')).toBeVisible();
await expect(page.getByLabel('Read only')).toBeVisible();
await expect(page.getByLabel('Can read and edit')).toBeVisible();
await selectVisibility.click();
await page
.getByRole('button', {
.getByRole('option', {
name: 'Public',
})
.click();
await expect(page.getByLabel('Visibility mode')).toBeVisible();
await expect(page.getByLabel('Read only')).toBeVisible();
await expect(page.getByLabel('Can read and edit')).toBeVisible();
});
});
@@ -81,7 +85,7 @@ test.describe('Doc Visibility: Restricted', () => {
1,
);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
const urlDoc = page.url();
@@ -107,7 +111,7 @@ test.describe('Doc Visibility: Restricted', () => {
const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
const urlDoc = page.url();
@@ -135,13 +139,11 @@ test.describe('Doc Visibility: Restricted', () => {
const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const inputSearch = page.getByRole('combobox', {
name: 'Quick search input',
});
const inputSearch = page.getByLabel(/Find a member to add to the document/);
const otherBrowser = browsersName.find((b) => b !== browserName);
const username = `user@${otherBrowser}.e2e`;
@@ -149,11 +151,14 @@ test.describe('Doc Visibility: Restricted', () => {
await page.getByRole('option', { name: username }).click();
// Choose a role
const container = page.getByTestId('doc-share-add-member-list');
await container.getByLabel('doc-role-dropdown').click();
await page.getByRole('button', { name: 'Administrator' }).click();
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Administrator' }).click();
await page.getByRole('button', { name: 'Invite' }).click();
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`User ${username} added to the document.`),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
@@ -171,8 +176,8 @@ test.describe('Doc Visibility: Restricted', () => {
await page.goto(urlDoc);
await verifyDocName(page, docTitle);
await expect(page.getByLabel('Share button')).toBeVisible();
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
});
@@ -193,14 +198,17 @@ test.describe('Doc Visibility: Public', () => {
1,
);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('button', {
.getByRole('option', {
name: 'Public',
})
.click();
@@ -209,32 +217,22 @@ test.describe('Doc Visibility: Public', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByLabel('Visibility mode').click();
await page
.getByRole('button', {
name: 'Reading',
})
.click();
await page.getByLabel('Read only').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
const cardContainer = page.getByLabel(
'It is the card information about the document.',
);
await expect(cardContainer.getByTestId('public-icon')).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await expect(
cardContainer.getByText('Public document', { exact: true }),
page
.getByLabel('It is the card information about the document.')
.getByText('Public', { exact: true }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'search' })).toBeVisible();
await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible();
const urlDoc = page.url();
await page
@@ -248,13 +246,10 @@ test.describe('Doc Visibility: Public', () => {
await page.goto(urlDoc);
await expect(page.locator('h2').getByText(docTitle)).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();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(
page.getByText('Read only, you cannot edit this document'),
).toBeVisible();
});
test('It checks a public doc in editable mode', async ({
@@ -266,14 +261,17 @@ test.describe('Doc Visibility: Public', () => {
const [docTitle] = await createDoc(page, 'Public editable', browserName, 1);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('button', {
.getByRole('option', {
name: 'Public',
})
.click();
@@ -282,23 +280,20 @@ test.describe('Doc Visibility: Public', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByLabel('Visibility mode').click();
await page.getByLabel('Edition').click();
await page.getByLabel('Can read and edit').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
const cardContainer = page.getByLabel(
'It is the card information about the document.',
);
await expect(cardContainer.getByTestId('public-icon')).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await expect(
cardContainer.getByText('Public document', { exact: true }),
page
.getByLabel('It is the card information about the document.')
.getByText('Public', { exact: true }),
).toBeVisible();
const urlDoc = page.url();
@@ -313,8 +308,11 @@ test.describe('Doc Visibility: Public', () => {
await page.goto(urlDoc);
await verifyDocName(page, docTitle);
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
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();
});
});
@@ -335,14 +333,17 @@ test.describe('Doc Visibility: Authenticated', () => {
1,
);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('button', {
name: 'Connected',
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
@@ -350,7 +351,9 @@ test.describe('Doc Visibility: Authenticated', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
@@ -382,14 +385,17 @@ test.describe('Doc Visibility: Authenticated', () => {
1,
);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('button', {
name: 'Connected',
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
@@ -397,7 +403,9 @@ test.describe('Doc Visibility: Authenticated', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
@@ -414,8 +422,19 @@ test.describe('Doc Visibility: Authenticated', () => {
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
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();
});
test('It checks a authenticated doc in editable mode', async ({
@@ -432,14 +451,17 @@ test.describe('Doc Visibility: Authenticated', () => {
1,
);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
const selectVisibility = page.getByLabel('Visibility', { exact: true });
await selectVisibility.click();
await page
.getByRole('button', {
name: 'Connected',
.getByRole('combobox', {
name: 'Visibility',
})
.click();
await page
.getByRole('option', {
name: 'Authenticated',
})
.click();
@@ -447,15 +469,23 @@ test.describe('Doc Visibility: Authenticated', () => {
page.getByText('The document visibility has been updated.'),
).toBeVisible();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
const urlDoc = page.url();
await page.getByLabel('Visibility mode').click();
await page.getByLabel('Edition').click();
await page.getByRole('button', { name: 'Share' }).click();
await page.getByLabel('Can read and edit').click();
await expect(
page.getByText('The document visibility has been updated.').first(),
).toBeVisible();
await page.getByRole('button', { name: 'close' }).click();
await page.locator('.c__modal__backdrop').click({
position: { x: 0, y: 0 },
});
await page
.getByRole('button', {
@@ -468,9 +498,20 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.goto(urlDoc);
await verifyDocName(page, docTitle);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Link Copied !')).toBeVisible();
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();
});
});

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "2.0.1",
"version": "1.10.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -13,11 +13,9 @@
},
"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": {

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 279 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,156 +0,0 @@
import { PropsWithChildren, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
export type DropdownMenuOption = {
icon?: string;
label: string;
testId?: string;
callback?: () => void | Promise<unknown>;
danger?: boolean;
isSelected?: boolean;
disabled?: boolean;
show?: boolean;
};
export type DropdownMenuProps = {
options: DropdownMenuOption[];
showArrow?: boolean;
label?: string;
arrowCss?: BoxProps['$css'];
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>
);
};

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import Link from 'next/link';
import styled, { RuleSet } from 'styled-components';
import styled from 'styled-components';
export interface LinkProps {
$css?: string | RuleSet<object>;
$css?: string;
}
export const StyledLink = styled(Link)<LinkProps>`
@@ -12,5 +12,5 @@ export const StyledLink = styled(Link)<LinkProps>`
color: #ffffff;
}
display: flex;
${({ $css }) => $css && (typeof $css === 'string' ? `${$css};` : $css)}
${({ $css }) => $css && `${$css};`}
`;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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