mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-06 15:12:27 +02:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14b2adedfb | ||
|
|
a7edb382a7 | ||
|
|
fb5400c26b | ||
|
|
8473facbee | ||
|
|
5db446e8a8 | ||
|
|
34dfb3fd66 | ||
|
|
f9a91eda2d | ||
|
|
eba926dea4 | ||
|
|
3839a2e8b1 | ||
|
|
a88d62e07d | ||
|
|
b61a7a4961 | ||
|
|
20d32ecc4e | ||
|
|
313acf4f78 | ||
|
|
3a6105cc7e |
22
CHANGELOG.md
22
CHANGELOG.md
@@ -8,6 +8,25 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.6.0] - 2025-03-21
|
||||
|
||||
## Added
|
||||
|
||||
- 📝(doc) add publiccode.yml #770
|
||||
|
||||
## Changed
|
||||
|
||||
- 🚸(frontend) ctrl+k modal not when editor is focused #712
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(back) allow only images to be used with the cors-proxy #781
|
||||
- 🐛(backend) stop returning inactive users on the list endpoint #636
|
||||
- 🔒️(backend) require at least 5 characters to search for users #636
|
||||
- 🔒️(back) throttle user list endpoint #636
|
||||
- 🔒️(back) remove pagination and limit to 5 for user list endpoint #636
|
||||
|
||||
|
||||
## [2.5.0] - 2025-03-18
|
||||
|
||||
## Added
|
||||
@@ -468,7 +487,8 @@ and this project adheres to
|
||||
- ✨(frontend) Coming Soon page (#67)
|
||||
- 🚀 Impress, project to manage your documents easily and collaboratively.
|
||||
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.5.0...main
|
||||
[unreleased]: https://github.com/numerique-gouv/impress/compare/v2.6.0...main
|
||||
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.6.0
|
||||
[v2.5.0]: https://github.com/numerique-gouv/impress/releases/v2.5.0
|
||||
[v2.4.0]: https://github.com/numerique-gouv/impress/releases/v2.4.0
|
||||
[v2.3.0]: https://github.com/numerique-gouv/impress/releases/v2.3.0
|
||||
|
||||
27
publiccode.yml
Normal file
27
publiccode.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
publiccodeYmlVersion: "2.4.0"
|
||||
name: Docs
|
||||
url: https://github.com/suitenumerique/docs
|
||||
landingURL: https://github.com/suitenumerique/docs
|
||||
creationDate: 2023-12-10
|
||||
logo: https://raw.githubusercontent.com/suitenumerique/docs/main/docs/assets/docs-logo.png
|
||||
usedBy:
|
||||
- Direction interministériel du numérique (DINUM)
|
||||
fundedBy:
|
||||
- name: Direction interministériel du numérique (DINUM)
|
||||
url: https://www.numerique.gouv.fr
|
||||
roadmap: "https://github.com/orgs/suitenumerique/projects/2/views/1"
|
||||
softwareType: "standalone/other"
|
||||
description:
|
||||
en:
|
||||
shortDescription: "The open source document editor where your notes can become knowledge through live collaboration"
|
||||
fr:
|
||||
shortDescription: "L'éditeur de documents open source où vos notes peuvent devenir des connaissances grâce à la collaboration en direct."
|
||||
legal:
|
||||
license: MIT
|
||||
maintenance:
|
||||
type: internal
|
||||
contacts:
|
||||
- name: "Virgile Deville"
|
||||
email: "virgile.deville@numerique.gouv.fr"
|
||||
- name: "samuel.paccoud"
|
||||
email: "samuel.paccoud@numerique.gouv.fr"
|
||||
@@ -24,6 +24,7 @@ from botocore.exceptions import ClientError
|
||||
from rest_framework import filters, status, viewsets
|
||||
from rest_framework import response as drf_response
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.throttling import UserRateThrottle
|
||||
|
||||
from core import authentication, enums, models
|
||||
from core.services.ai_services import AIService
|
||||
@@ -135,14 +136,35 @@ class Pagination(drf.pagination.PageNumberPagination):
|
||||
page_size_query_param = "page_size"
|
||||
|
||||
|
||||
class UserListThrottleBurst(UserRateThrottle):
|
||||
"""Throttle for the user list endpoint."""
|
||||
|
||||
scope = "user_list_burst"
|
||||
|
||||
|
||||
class UserListThrottleSustained(UserRateThrottle):
|
||||
"""Throttle for the user list endpoint."""
|
||||
|
||||
scope = "user_list_sustained"
|
||||
|
||||
|
||||
class UserViewSet(
|
||||
drf.mixins.UpdateModelMixin, viewsets.GenericViewSet, drf.mixins.ListModelMixin
|
||||
):
|
||||
"""User ViewSet"""
|
||||
|
||||
permission_classes = [permissions.IsSelf]
|
||||
queryset = models.User.objects.all()
|
||||
queryset = models.User.objects.filter(is_active=True)
|
||||
serializer_class = serializers.UserSerializer
|
||||
pagination_class = None
|
||||
throttle_classes = []
|
||||
|
||||
def get_throttles(self):
|
||||
self.throttle_classes = []
|
||||
if self.action == "list":
|
||||
self.throttle_classes = [UserListThrottleBurst, UserListThrottleSustained]
|
||||
|
||||
return super().get_throttles()
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
@@ -157,11 +179,11 @@ class UserViewSet(
|
||||
return queryset
|
||||
|
||||
# Exclude all users already in the given document
|
||||
if document_id := self.request.GET.get("document_id", ""):
|
||||
if document_id := self.request.query_params.get("document_id", ""):
|
||||
queryset = queryset.exclude(documentaccess__document_id=document_id)
|
||||
|
||||
if not (query := self.request.GET.get("q", "")):
|
||||
return queryset
|
||||
if not (query := self.request.query_params.get("q", "")) or len(query) < 5:
|
||||
return queryset.none()
|
||||
|
||||
# For emails, match emails by Levenstein distance to prevent typing errors
|
||||
if "@" in query:
|
||||
@@ -170,7 +192,7 @@ class UserViewSet(
|
||||
distance=RawSQL("levenshtein(email::text, %s::text)", (query,))
|
||||
)
|
||||
.filter(distance__lte=3)
|
||||
.order_by("distance", "email")
|
||||
.order_by("distance", "email")[: settings.API_USERS_LIST_LIMIT]
|
||||
)
|
||||
|
||||
# Use trigram similarity for non-email-like queries
|
||||
@@ -180,7 +202,7 @@ class UserViewSet(
|
||||
queryset.filter(email__trigram_word_similar=query)
|
||||
.annotate(similarity=TrigramSimilarity("email", query))
|
||||
.filter(similarity__gt=0.2)
|
||||
.order_by("-similarity", "email")
|
||||
.order_by("-similarity", "email")[: settings.API_USERS_LIST_LIMIT]
|
||||
)
|
||||
|
||||
@drf.decorators.action(
|
||||
@@ -1271,13 +1293,21 @@ class DocumentViewSet(
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
|
||||
if not content_type.startswith("image/"):
|
||||
return drf.response.Response(
|
||||
status=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
|
||||
)
|
||||
|
||||
# Use StreamingHttpResponse with the response's iter_content to properly stream the data
|
||||
proxy_response = StreamingHttpResponse(
|
||||
streaming_content=response.iter_content(chunk_size=8192),
|
||||
content_type=response.headers.get(
|
||||
"Content-Type", "application/octet-stream"
|
||||
),
|
||||
content_type=content_type,
|
||||
headers={
|
||||
"Content-Disposition": "attachment;",
|
||||
"Content-Security-Policy": "default-src 'none'; img-src 'none' data:;",
|
||||
},
|
||||
status=response.status_code,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Test on the CORS proxy API for documents."""
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
@@ -8,17 +9,24 @@ from core import factories
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_valid_url():
|
||||
"""Test the CORS proxy API for documents with a valid URL."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://docs.numerique.gouv.fr/assets/logo-gouv.png"
|
||||
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
|
||||
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["Content-Type"] == "image/png"
|
||||
assert response.headers["Content-Disposition"] == "attachment;"
|
||||
assert (
|
||||
response.headers["Content-Security-Policy"]
|
||||
== "default-src 'none'; img-src 'none' data:;"
|
||||
)
|
||||
assert response.streaming_content
|
||||
|
||||
|
||||
@@ -32,12 +40,14 @@ def test_api_docs_cors_proxy_without_url_query_string():
|
||||
assert response.json() == {"detail": "Missing 'url' query parameter"}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_anonymous_document_not_public():
|
||||
"""Test the CORS proxy API for documents with an anonymous user and a non-public document."""
|
||||
document = factories.DocumentFactory(link_reach="authenticated")
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://docs.numerique.gouv.fr/assets/logo-gouv.png"
|
||||
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
|
||||
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
@@ -47,6 +57,7 @@ def test_api_docs_cors_proxy_anonymous_document_not_public():
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc():
|
||||
"""
|
||||
Test the CORS proxy API for documents with an authenticated user accessing a protected
|
||||
@@ -58,15 +69,22 @@ def test_api_docs_cors_proxy_authenticated_user_accessing_protected_doc():
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
url_to_fetch = "https://docs.numerique.gouv.fr/assets/logo-gouv.png"
|
||||
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
|
||||
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["Content-Type"] == "image/png"
|
||||
assert response.headers["Content-Disposition"] == "attachment;"
|
||||
assert (
|
||||
response.headers["Content-Security-Policy"]
|
||||
== "default-src 'none'; img-src 'none' data:;"
|
||||
)
|
||||
assert response.streaming_content
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc():
|
||||
"""
|
||||
Test the CORS proxy API for documents with an authenticated user not accessing a restricted
|
||||
@@ -78,7 +96,8 @@ def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc():
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
url_to_fetch = "https://docs.numerique.gouv.fr/assets/logo-gouv.png"
|
||||
url_to_fetch = "https://external-url.com/assets/logo-gouv.png"
|
||||
responses.get(url_to_fetch, body=b"", status=200, content_type="image/png")
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
@@ -86,3 +105,17 @@ def test_api_docs_cors_proxy_authenticated_not_accessing_restricted_doc():
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_docs_cors_proxy_unsupported_media_type():
|
||||
"""Test the CORS proxy API for documents with an unsupported media type."""
|
||||
document = factories.DocumentFactory(link_reach="public")
|
||||
|
||||
client = APIClient()
|
||||
url_to_fetch = "https://external-url.com/assets/index.html"
|
||||
responses.get(url_to_fetch, body=b"", status=200, content_type="text/html")
|
||||
response = client.get(
|
||||
f"/api/v1.0/documents/{document.id!s}/cors-proxy/?url={url_to_fetch}"
|
||||
)
|
||||
assert response.status_code == 415
|
||||
|
||||
@@ -24,7 +24,7 @@ def test_api_users_list_anonymous():
|
||||
|
||||
def test_api_users_list_authenticated():
|
||||
"""
|
||||
Authenticated users should be able to list users.
|
||||
Authenticated users should not be able to list users without a query.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -37,7 +37,7 @@ def test_api_users_list_authenticated():
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 3
|
||||
assert content == []
|
||||
|
||||
|
||||
def test_api_users_list_query_email():
|
||||
@@ -58,24 +58,76 @@ def test_api_users_list_query_email():
|
||||
"/api/v1.0/users/?q=david.bowman@work.com",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(dave.id)]
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=davig.bovman@worm.com",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(dave.id)]
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=davig.bovman@worm.cop",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == []
|
||||
|
||||
|
||||
def test_api_users_list_limit(settings):
|
||||
"""
|
||||
Authenticated users should be able to list users and the number of results
|
||||
should be limited to 10.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Use a base name with a length equal 5 to test that the limit is applied
|
||||
base_name = "alice"
|
||||
for i in range(15):
|
||||
factories.UserFactory(email=f"{base_name}.{i}@example.com")
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=alice",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 5
|
||||
|
||||
# if the limit is changed, all users should be returned
|
||||
settings.API_USERS_LIST_LIMIT = 100
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=alice",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 15
|
||||
|
||||
|
||||
def test_api_users_list_throttling_authenticated(settings):
|
||||
"""
|
||||
Authenticated users should be throttled.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["user_list_burst"] = "3/minute"
|
||||
|
||||
for _i in range(3):
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=alice",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/users/?q=alice",
|
||||
)
|
||||
assert response.status_code == 429
|
||||
|
||||
|
||||
def test_api_users_list_query_email_matching():
|
||||
"""While filtering by email, results should be filtered and sorted by Levenstein distance."""
|
||||
user = factories.UserFactory()
|
||||
@@ -94,13 +146,13 @@ def test_api_users_list_query_email_matching():
|
||||
"/api/v1.0/users/?q=alice.johnson@example.gouv.fr",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(user1.id), str(user2.id), str(user3.id), str(user4.id)]
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=alicia.johnnson@example.gouv.fr")
|
||||
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(user4.id), str(user2.id), str(user1.id), str(user5.id)]
|
||||
|
||||
|
||||
@@ -126,10 +178,50 @@ def test_api_users_list_query_email_exclude_doc_user():
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(nicole_fool.id)]
|
||||
|
||||
|
||||
def test_api_users_list_query_short_queries():
|
||||
"""
|
||||
Queries shorter than 5 characters should return an empty result set.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.UserFactory(email="john.doe@example.com")
|
||||
factories.UserFactory(email="john.lennon@example.com")
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=jo")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=john")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=john.")
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 2
|
||||
|
||||
|
||||
def test_api_users_list_query_inactive():
|
||||
"""Inactive users should not be listed."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.UserFactory(email="john.doe@example.com", is_active=False)
|
||||
lennon = factories.UserFactory(email="john.lennon@example.com")
|
||||
|
||||
response = client.get("/api/v1.0/users/?q=john.")
|
||||
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()]
|
||||
assert user_ids == [str(lennon.id)]
|
||||
|
||||
|
||||
def test_api_users_retrieve_me_anonymous():
|
||||
"""Anonymous users should not be allowed to list users."""
|
||||
factories.UserFactory.create_batch(2)
|
||||
|
||||
@@ -337,6 +337,18 @@ class Base(Configuration):
|
||||
"PAGE_SIZE": 20,
|
||||
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
"user_list_sustained": values.Value(
|
||||
default="180/hour",
|
||||
environ_name="API_USERS_LIST_THROTTLE_RATE_SUSTAINED",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"user_list_burst": values.Value(
|
||||
default="30/minute",
|
||||
environ_name="API_USERS_LIST_THROTTLE_RATE_BURST",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
@@ -604,6 +616,12 @@ class Base(Configuration):
|
||||
},
|
||||
}
|
||||
|
||||
API_USERS_LIST_LIMIT = values.PositiveIntegerValue(
|
||||
default=5,
|
||||
environ_name="API_USERS_LIST_LIMIT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "impress"
|
||||
version = "2.5.0"
|
||||
version = "2.6.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
@@ -80,11 +80,11 @@ export const addNewMember = async (
|
||||
page: Page,
|
||||
index: number,
|
||||
role: 'Administrator' | 'Owner' | 'Member' | 'Editor' | 'Reader',
|
||||
fillText: string = 'user',
|
||||
fillText: string = 'user ',
|
||||
) => {
|
||||
const responsePromiseSearchUser = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes(`/users/?q=${fillText}`) &&
|
||||
response.url().includes(`/users/?q=${encodeURIComponent(fillText)}`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
@@ -97,7 +97,7 @@ export const addNewMember = async (
|
||||
|
||||
// Intercept response
|
||||
const responseSearchUser = await responsePromiseSearchUser;
|
||||
const users = (await responseSearchUser.json()).results as {
|
||||
const users = (await responseSearchUser.json()) as {
|
||||
email: string;
|
||||
}[];
|
||||
|
||||
|
||||
@@ -8,9 +8,11 @@ test.beforeEach(async ({ page }) => {
|
||||
|
||||
test.describe('Document create member', () => {
|
||||
test('it selects 2 users and 1 invitation', async ({ page, browserName }) => {
|
||||
const inputFill = 'user ';
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/users/?q=user') && response.status() === 200,
|
||||
response.url().includes(`/users/?q=${encodeURIComponent(inputFill)}`) &&
|
||||
response.status() === 200,
|
||||
);
|
||||
await createDoc(page, 'select-multi-users', browserName, 1);
|
||||
|
||||
@@ -22,9 +24,9 @@ test.describe('Document create member', () => {
|
||||
await expect(inputSearch).toBeVisible();
|
||||
|
||||
// Select user 1 and verify tag
|
||||
await inputSearch.fill('user');
|
||||
await inputSearch.fill(inputFill);
|
||||
const response = await responsePromise;
|
||||
const users = (await response.json()).results as {
|
||||
const users = (await response.json()) as {
|
||||
email: string;
|
||||
full_name?: string | null;
|
||||
}[];
|
||||
@@ -45,7 +47,7 @@ test.describe('Document create member', () => {
|
||||
).toBeVisible();
|
||||
|
||||
// Select user 2 and verify tag
|
||||
await inputSearch.fill('user');
|
||||
await inputSearch.fill(inputFill);
|
||||
await quickSearchContent
|
||||
.getByTestId(`search-user-row-${users[1].email}`)
|
||||
.click();
|
||||
|
||||
@@ -63,4 +63,35 @@ test.describe('Document search', () => {
|
||||
listSearch.getByRole('option').getByText(doc2Title),
|
||||
).toBeHidden();
|
||||
});
|
||||
|
||||
test('it checks cmd+k modal search interaction', async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const [doc1Title] = await createDoc(
|
||||
page,
|
||||
'Doc seack ctrl k',
|
||||
browserName,
|
||||
1,
|
||||
);
|
||||
await verifyDocName(page, doc1Title);
|
||||
|
||||
await page.keyboard.press('Control+k');
|
||||
await expect(
|
||||
page.getByLabel('Search modal').getByText('search'),
|
||||
).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const editor = page.locator('.ProseMirror');
|
||||
await editor.click();
|
||||
await editor.fill('Hello world');
|
||||
await editor.getByText('Hello world').dblclick();
|
||||
|
||||
await page.keyboard.press('Control+k');
|
||||
await expect(page.getByRole('textbox', { name: 'Edit URL' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByLabel('Search modal').getByText('search'),
|
||||
).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-impress",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
|
||||
import { APIError, errorCauses, fetchAPI } from '@/api';
|
||||
import { Doc } from '@/docs/doc-management';
|
||||
import { User } from '@/features/auth';
|
||||
|
||||
@@ -9,14 +9,14 @@ export type UsersParams = {
|
||||
docId: Doc['id'];
|
||||
};
|
||||
|
||||
type UsersResponse = APIList<User>;
|
||||
type UsersResponse = User[];
|
||||
|
||||
export const getUsers = async ({
|
||||
query,
|
||||
docId,
|
||||
}: UsersParams): Promise<UsersResponse> => {
|
||||
const queriesParams = [];
|
||||
queriesParams.push(query ? `q=${query}` : '');
|
||||
queriesParams.push(query ? `q=${encodeURIComponent(query)}` : '');
|
||||
queriesParams.push(docId ? `document_id=${docId}` : '');
|
||||
const queryParams = queriesParams.filter(Boolean).join('&');
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
|
||||
const canViewAccesses = doc.abilities.accesses_view;
|
||||
const showMemberSection = inputValue === '' && selectedUsers.length === 0;
|
||||
const showFooter = selectedUsers.length === 0 && !inputValue;
|
||||
const MIN_CHARACTERS_FOR_SEARCH = 4;
|
||||
|
||||
const onSelect = (user: User) => {
|
||||
setSelectedUsers((prev) => [...prev, user]);
|
||||
@@ -76,7 +77,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
|
||||
const searchUsersQuery = useUsers(
|
||||
{ query: userQuery, docId: doc.id },
|
||||
{
|
||||
enabled: !!userQuery,
|
||||
enabled: userQuery?.length > MIN_CHARACTERS_FOR_SEARCH,
|
||||
queryKey: [KEY_LIST_USER, { query: userQuery }],
|
||||
},
|
||||
);
|
||||
@@ -125,7 +126,7 @@ export const DocShareModal = ({ doc, onClose }: Props) => {
|
||||
}, [invitationQuery, t]);
|
||||
|
||||
const searchUserData: QuickSearchData<User> = useMemo(() => {
|
||||
const users = searchUsersQuery.data?.results || [];
|
||||
const users = searchUsersQuery.data || [];
|
||||
const isEmail = isValidEmail(userQuery);
|
||||
const newUser: User = {
|
||||
id: userQuery,
|
||||
|
||||
@@ -8,7 +8,6 @@ export const Title = () => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useCunninghamTheme();
|
||||
const spacings = theme.spacingsTokens();
|
||||
const colors = theme.colorsTokens();
|
||||
|
||||
return (
|
||||
<Box $direction="row" $align="center" $gap={spacings['2xs']}>
|
||||
@@ -36,7 +35,8 @@ export const Title = () => {
|
||||
`}
|
||||
$width="40px"
|
||||
$height="16px"
|
||||
$background={colors['primary-200']}
|
||||
$background="#ECECFF"
|
||||
$color="#5958D3"
|
||||
>
|
||||
BETA
|
||||
</Text>
|
||||
|
||||
@@ -104,7 +104,7 @@ export function HomeContent() {
|
||||
</Text>
|
||||
<Text as="p" $display="inline">
|
||||
<Trans t={t} i18nKey="home-content-open-source-part2">
|
||||
You can easily self-hosted Docs (check our installation{' '}
|
||||
You can easily self-host Docs (check our installation{' '}
|
||||
<a
|
||||
href="https://github.com/suitenumerique/docs/tree/main/docs"
|
||||
target="_blank"
|
||||
|
||||
@@ -15,7 +15,15 @@ export const LeftPanelHeader = ({ children }: PropsWithChildren) => {
|
||||
const router = useRouter();
|
||||
const searchModal = useModal();
|
||||
const { authenticated } = useAuth();
|
||||
useCmdK(searchModal.open);
|
||||
useCmdK(() => {
|
||||
const isEditorToolbarOpen =
|
||||
document.getElementsByClassName('bn-formatting-toolbar').length > 0;
|
||||
if (isEditorToolbarOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
searchModal.open();
|
||||
});
|
||||
const { togglePanel } = useLeftPanelStore();
|
||||
|
||||
const { mutate: createDoc } = useCreateDoc({
|
||||
|
||||
@@ -5,6 +5,7 @@ export const useCmdK = (callback: () => void) => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import Head from 'next/head';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Box, Text, TextErrors } from '@/components';
|
||||
import { DocEditor } from '@/docs/doc-editor';
|
||||
@@ -64,14 +65,15 @@ const DocPage = ({ id }: DocProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { replace } = useRouter();
|
||||
useCollaboration(doc?.id, doc?.content);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
if (doc?.title) {
|
||||
setTimeout(() => {
|
||||
document.title = `${doc.title} - Docs`;
|
||||
document.title = `${doc.title} - ${t('Docs')}`;
|
||||
}, 100);
|
||||
}
|
||||
}, [doc?.title]);
|
||||
}, [doc?.title, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!docQuery || isFetching) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "impress",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"private": true,
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eslint-config-impress",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"lint": "eslint --ext .js ."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "packages-i18n",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"extract-translation": "yarn extract-translation:impress",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server-y-provider",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"description": "Y.js provider for docs",
|
||||
"repository": "https://github.com/numerique-gouv/impress",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
environments:
|
||||
dev:
|
||||
values:
|
||||
- version: 2.5.0
|
||||
- version: 2.6.0
|
||||
---
|
||||
repositories:
|
||||
- name: bitnami
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
type: application
|
||||
name: docs
|
||||
version: 2.5.0
|
||||
version: 2.6.0
|
||||
appVersion: latest
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Reference in New Issue
Block a user