Compare commits

...

15 Commits

Author SHA1 Message Date
Anthony LC 5e8dc8f6b3 🔖(patch) release 3.8.21
Changed
♻️(backend) increase user short_name field length

Fixed
🐛(frontend) fix duplicate document entries in grid
🐛(frontend) show full nested doc names with ajustable bar
🐛(backend) fix trashbin list
2025-10-23 00:03:39 +02:00
Anthony LC a714d5da4c 🐛(backend) fix trashbin list
Fix listing of deleted documents in trashbin for
users without owner access
2025-10-23 00:03:38 +02:00
Manuel Raynaud 2b5a9e1af8 ♻️(backend) increase user short_name field length
The user's short_name field length was set to 20. This is not enought
and we have some users who cannot register because of that. We changed
this length to a higher one, 100, like the full_name.
2025-10-22 11:44:39 +02:00
Cyril a833fdc7a1 (frontend) add resizable left panel on desktop with persistence
mainlayout and leftpanel updated with resizable panel saved in localstorage

Signed-off-by: Cyril <c.gromoff@gmail.com>

(frontend) show full nested doc names with horizontal scroll support

horizontal overflow enabled and opacity used for sticky actions visibility

Signed-off-by: Cyril <c.gromoff@gmail.com>

(frontend) show full nested doc names with horizontal scroll support

horizontal overflow enabled and opacity used for sticky actions visibility

Signed-off-by: Cyril <c.gromoff@gmail.com>

(frontend) add resizable-panels lib also used in our shared ui kit

needed for adaptable ui consistent with our shared ui kit components

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-21 10:59:24 +02:00
Anthony LC b3cc2bf833 🚨(eslint) add missing rules
We recently upgraded to Eslint v9, it seems that
it is missing some rules that we had previously.
We add them back:
- @typescript-eslint/no-inferrable-types
- @typescript-eslint/no-floating-promises
2025-10-20 21:53:10 +02:00
Anthony LC 18feab10cb (e2e) reduce flakinees
- Because of parallel test execution, some tests
were flaky when using goToGridDoc, the title
changed between the time we got the document list
and the time we clicked on the document.
- Improve addChild function.
2025-10-20 10:17:03 +02:00
Anthony LC 2777488d24 🐛(frontend) fix duplicate document entries in grid
The tests e2e were failing sometimes because
the documents list was containing duplicates.
This was happening when multiple users were
modifying the documents list (creation, update, ...).
We now deduplicate documents by their ID
before displaying them.
2025-10-20 10:17:03 +02:00
Anthony LC a11258f778 🔖(patch) release 3.8.2
Fixed:

- 🐛(service-worker) fix sw registration and page reload
  logic
2025-10-17 15:54:56 +02:00
Anthony LC 33647f124f 🐛(service-worker) fix sw registration and page reload logic
When a new service worker is installed, the page
was reloaded to ensure the new service worker took
control, it is not a big issue in normal browsing mode
because the service worker is only updated once in a
while (every release).
However, in incognito mode, the service worker has to be
re-registered on each new session, which means that
the page was reloading each time the user opened a
new incognito window, creating a bad user experience.
We now take in consideration the case where the
service-worker is installed for the first time, and don't
reload if it is this case.
2025-10-17 15:14:04 +02:00
Anthony LC e339cda5c6 🔖(patch) release 3.8.1
Fixed:
- ️(backend) improve trashbin endpoint performance
- 🐛(backend) manage invitation partial update without email
- (frontend) improve accessibility:
  -  add missing aria-label to add sub-doc button
  for accessibility
  -  add missing aria-label to more options button
  on sub-docs

Removed:
- 🔥(backend) remove treebeard form for the document admin
2025-10-17 10:41:38 +02:00
Manuel Raynaud 4ce65c654f 🔥(backend) remove treebeard form for the document admin
The document change admin page is unusable. The django treebeard library
can change the form used by one provided but this one is really slow.
And it is collapsing the configuration made with the other fields and
readonly fields declared on the DocumentAdmin class. In a first time we
remove the form usage, it seems useless. Later we have to provide more
information on this admin page.
2025-10-17 08:35:22 +00:00
Manuel Raynaud c048b2ae95 🐛(backend) manage invitation partial update without email
An invitation can be updated to change its role. The front use a PATCH
sending only the changed role, so the email is missing in the
InivtationSerializer.validate method. We have to check first if an email
is present before working on it.
2025-10-16 15:26:02 +00:00
Manuel Raynaud 5908afb098 ️(backend) improve trashbin endpoint performance (#1495)
The trashbin endpoint is slow. To filter documents the user has owner
access, we use a subquery to compute the roles and then filter on this
subquery. This is very slow. To improve it, we use the same way to
filter children used in the tree endpoint. First we look for all highest
ancestors the user has access on with the owner role. Then we create one
queryset filtering on all the docs starting by the given path and are
deleted.
2025-10-16 17:06:47 +02:00
Cyril e2298a3658 (frontend) add missing aria-label to more options button on sub-docs
improves accessibility by making the options button screen reader friendly

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-16 15:02:04 +02:00
Cyril 278eb233e9 (frontend) add missing aria-label to add sub-doc button for a11y
improves screen reader support for the add sub-doc action in the document tree

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-10-16 14:18:55 +02:00
44 changed files with 539 additions and 118 deletions
+3
View File
@@ -75,3 +75,6 @@ db.sqlite3
.vscode/
*.iml
.devcontainer
# Cursor rules
.cursorrules
+37 -1
View File
@@ -6,6 +6,36 @@ and this project adheres to
## [Unreleased]
### Changed
- ♻️(backend) increase user short_name field length
### Fixed
- 🐛(frontend) fix duplicate document entries in grid #1479
- 🐛(frontend) show full nested doc names with ajustable bar #1456
- 🐛(backend) fix trashbin list
## [3.8.2] - 2025-10-17
### Fixed
- 🐛(service-worker) fix sw registration and page reload logic #1500
## [3.8.1] - 2025-10-17
### Fixed
- ⚡️(backend) improve trashbin endpoint performance #1495
- 🐛(backend) manage invitation partial update without email #1494
- ♿(frontend) improve accessibility:
- ♿ add missing aria-label to add sub-doc button for accessibility #1480
- ♿ add missing aria-label to more options button on sub-docs #1481
### Removed
- 🔥(backend) remove treebeard form for the document admin #1470
## [3.8.0] - 2025-10-14
### Added
@@ -13,6 +43,10 @@ and this project adheres to
- ✨(frontend) add pdf block to the editor #1293
- ✨List and restore deleted docs #1450
### Fixed
- 🐛(frontend) show full nested doc names with ajustable bar #1456
### Changed
- ♻️(frontend) Refactor Auth component for improved redirection logic #1461
@@ -788,7 +822,9 @@ and this project adheres to
- ✨(frontend) Coming Soon page (#67)
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.8.0...main
[unreleased]: https://github.com/suitenumerique/docs/compare/v3.8.2...main
[v3.8.2]: https://github.com/suitenumerique/docs/releases/v3.8.2
[v3.8.1]: https://github.com/suitenumerique/docs/releases/v3.8.1
[v3.8.0]: https://github.com/suitenumerique/docs/releases/v3.8.0
[v3.7.0]: https://github.com/suitenumerique/docs/releases/v3.7.0
[v3.6.0]: https://github.com/suitenumerique/docs/releases/v3.6.0
-2
View File
@@ -5,7 +5,6 @@ from django.contrib.auth import admin as auth_admin
from django.utils.translation import gettext_lazy as _
from treebeard.admin import TreeAdmin
from treebeard.forms import movenodeform_factory
from . import models
@@ -157,7 +156,6 @@ class DocumentAdmin(TreeAdmin):
},
),
)
form = movenodeform_factory(models.Document)
inlines = (DocumentAccessInline,)
list_display = (
"id",
+2 -1
View File
@@ -749,7 +749,8 @@ class InvitationSerializer(serializers.ModelSerializer):
if self.instance is None:
attrs["issuer"] = user
attrs["email"] = attrs["email"].lower()
if attrs.get("email"):
attrs["email"] = attrs["email"].lower()
return attrs
+21 -1
View File
@@ -623,12 +623,32 @@ class DocumentViewSet(
The selected documents are those deleted within the cutoff period defined in the
settings (see TRASHBIN_CUTOFF_DAYS), before they are considered permanently deleted.
"""
if not request.user.is_authenticated:
return self.get_response_for_queryset(self.queryset.none())
access_documents_paths = (
models.DocumentAccess.objects.select_related("document")
.filter(
db.Q(user=self.request.user) | db.Q(team__in=self.request.user.teams),
role=models.RoleChoices.OWNER,
)
.values_list("document__path", flat=True)
)
if not access_documents_paths:
return self.get_response_for_queryset(self.queryset.none())
children_clause = db.Q()
for path in access_documents_paths:
children_clause |= db.Q(path__startswith=path)
queryset = self.queryset.filter(
children_clause,
deleted_at__isnull=False,
deleted_at__gte=models.get_trashbin_cutoff(),
)
queryset = queryset.annotate_user_roles(self.request.user)
queryset = queryset.filter(user_roles__contains=[models.RoleChoices.OWNER])
return self.get_response_for_queryset(queryset)
@@ -0,0 +1,19 @@
# Generated by Django 5.2.7 on 2025-10-22 06:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0024_add_is_masked_field_to_link_trace"),
]
operations = [
migrations.AlterField(
model_name="user",
name="short_name",
field=models.CharField(
blank=True, max_length=100, null=True, verbose_name="short name"
),
),
]
+3 -1
View File
@@ -148,7 +148,9 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
)
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
short_name = models.CharField(_("short name"), max_length=20, null=True, blank=True)
short_name = models.CharField(
_("short name"), max_length=100, null=True, blank=True
)
email = models.EmailField(_("identity email address"), blank=True, null=True)
@@ -769,6 +769,37 @@ def test_api_document_invitations_update_authenticated_unprivileged(
assert value == old_invitation_values[key]
@pytest.mark.parametrize("via", VIA)
@pytest.mark.parametrize("role", ["administrator", "owner"])
def test_api_document_invitations_patch(via, role, mock_user_teams):
"""Partially updating an invitation should be allowed."""
user = factories.UserFactory()
invitation = factories.InvitationFactory(role="editor")
if via == USER:
factories.UserDocumentAccessFactory(
document=invitation.document, user=user, role=role
)
elif via == TEAM:
mock_user_teams.return_value = ["lasuite", "unknown"]
factories.TeamDocumentAccessFactory(
document=invitation.document, team="lasuite", role=role
)
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/documents/{invitation.document.id!s}/invitations/{invitation.id!s}/",
{"role": "reader"},
format="json",
)
assert response.status_code == 200
invitation.refresh_from_db()
assert invitation.role == "reader"
# Delete
@@ -166,10 +166,10 @@ def test_api_documents_trashbin_authenticated_direct(django_assert_num_queries):
expected_ids = {str(document1.id), str(document2.id), str(document3.id)}
with django_assert_num_queries(10):
with django_assert_num_queries(11):
response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(4):
with django_assert_num_queries(5):
response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
@@ -208,10 +208,10 @@ def test_api_documents_trashbin_authenticated_via_team(
expected_ids = {str(deleted_document_team1.id), str(deleted_document_team2.id)}
with django_assert_num_queries(7):
with django_assert_num_queries(8):
response = client.get("/api/v1.0/documents/trashbin/")
with django_assert_num_queries(3):
with django_assert_num_queries(4):
response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
@@ -293,3 +293,26 @@ def test_api_documents_trashbin_distinct():
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(document.id)
def test_api_documents_trashbin_empty_queryset_bug():
"""
Test that users with no owner role don't see documents.
"""
# Create a new user with no owner access to any document
new_user = factories.UserFactory()
client = APIClient()
client.force_login(new_user)
# Create some deleted documents owned by other users
other_user = factories.UserFactory()
factories.DocumentFactory(users=[(other_user, "owner")], deleted_at=timezone.now())
factories.DocumentFactory(users=[(other_user, "owner")], deleted_at=timezone.now())
factories.DocumentFactory(users=[(other_user, "owner")], deleted_at=timezone.now())
response = client.get("/api/v1.0/documents/trashbin/")
assert response.status_code == 200
content = response.json()
assert content["count"] == 0
assert len(content["results"]) == 0
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "3.8.0"
version = "3.8.21"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -202,9 +202,19 @@ test.describe('Document create member', () => {
);
await expect(userInvitation).toBeVisible();
const responsePromisePatchInvitation = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') &&
response.status() === 200 &&
response.request().method() === 'PATCH',
);
await userInvitation.getByLabel('doc-role-dropdown').click();
await page.getByRole('menuitem', { name: 'Reader' }).click();
const responsePatchInvitation = await responsePromisePatchInvitation;
expect(responsePatchInvitation.ok()).toBeTruthy();
const moreActions = userInvitation.getByRole('button', {
name: 'Open invitation actions menu',
});
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc, verifyDocName } from './utils-common';
import { createDoc, verifyDocName } from './utils-common';
import { addNewMember } from './utils-share';
test.beforeEach(async ({ page }) => {
@@ -8,8 +8,14 @@ test.beforeEach(async ({ page }) => {
});
test.describe('Document list members', () => {
test('it checks a big list of members', async ({ page }) => {
const docTitle = await goToGridDoc(page);
test('it checks a big list of members', async ({ page, browserName }) => {
const [docTitle] = await createDoc(
page,
'members-big-members-list',
browserName,
1,
);
await verifyDocName(page, docTitle);
// Get the current URL and extract the last part
@@ -73,7 +79,7 @@ test.describe('Document list members', () => {
await expect(loadMore).toBeHidden();
});
test('it checks a big list of invitations', async ({ page }) => {
test('it checks a big list of invitations', async ({ page, browserName }) => {
await page.route(
/.*\/documents\/.*\/invitations\/\?page=.*/,
async (route) => {
@@ -108,7 +114,12 @@ test.describe('Document list members', () => {
},
);
const docTitle = await goToGridDoc(page);
const [docTitle] = await createDoc(
page,
'members-big-invitation-list',
browserName,
1,
);
await verifyDocName(page, docTitle);
await page.getByRole('button', { name: 'Share' }).click();
@@ -102,6 +102,7 @@ test.describe('Doc Trashbin', () => {
page,
browserName,
docParent: subDocName,
docName: 'my-trash-editor-subsubdoc',
});
await verifyDocName(page, subsubDocName);
@@ -1,5 +1,7 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './utils-common';
test.describe('Left panel desktop', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -11,6 +13,53 @@ test.describe('Left panel desktop', () => {
await expect(page.getByTestId('home-button')).toBeVisible();
await expect(page.getByTestId('new-doc-button')).toBeVisible();
});
test('checks resize handle is present and functional on document page', async ({
page,
browserName,
}) => {
// On home page, resize handle should NOT be present
let resizeHandle = page.locator('[data-panel-resize-handle-id]');
await expect(resizeHandle).toBeHidden();
// Create and navigate to a document
await createDoc(page, 'doc-resize-test', browserName, 1);
// Now resize handle should be visible on document page
resizeHandle = page.locator('[data-panel-resize-handle-id]').first();
await expect(resizeHandle).toBeVisible();
const leftPanel = page.getByTestId('left-panel-desktop');
await expect(leftPanel).toBeVisible();
// Get initial panel width
const initialBox = await leftPanel.boundingBox();
expect(initialBox).not.toBeNull();
// Get handle position
const handleBox = await resizeHandle.boundingBox();
expect(handleBox).not.toBeNull();
// Test resize by dragging the handle
await page.mouse.move(
handleBox!.x + handleBox!.width / 2,
handleBox!.y + handleBox!.height / 2,
);
await page.mouse.down();
await page.mouse.move(
handleBox!.x + 100,
handleBox!.y + handleBox!.height / 2,
);
await page.mouse.up();
// Wait for resize to complete
await page.waitForTimeout(200);
// Verify the panel has been resized
const newBox = await leftPanel.boundingBox();
expect(newBox).not.toBeNull();
expect(newBox!.width).toBeGreaterThan(initialBox!.width);
});
});
test.describe('Left panel mobile', () => {
@@ -47,4 +96,12 @@ test.describe('Left panel mobile', () => {
await expect(languageButton).toBeInViewport();
await expect(logoutButton).toBeInViewport();
});
test('checks resize handle is not present on mobile', async ({ page }) => {
await page.goto('/');
// Verify the resize handle is NOT present on mobile
const resizeHandle = page.locator('[data-panel-resize-handle-id]');
await expect(resizeHandle).toBeHidden();
});
});
@@ -48,7 +48,7 @@ export const overrideConfig = async (
export const keyCloakSignIn = async (
page: Page,
browserName: string,
fromHome: boolean = true,
fromHome = true,
) => {
if (fromHome) {
await page.getByRole('button', { name: 'Start Writing' }).first().click();
@@ -79,8 +79,8 @@ export const createDoc = async (
page: Page,
docName: string,
browserName: string,
length: number = 1,
isMobile: boolean = false,
length = 1,
isMobile = false,
) => {
const randomDocs = randomName(docName, browserName, length);
@@ -22,6 +22,6 @@ export const writeInEditor = async ({
text: string;
}) => {
const editor = await getEditor({ page });
editor.locator('.bn-block-outer').last().fill(text);
await editor.locator('.bn-block-outer').last().fill(text);
return editor;
};
@@ -15,7 +15,7 @@ export const addNewMember = async (
page: Page,
index: number,
role: 'Administrator' | 'Owner' | 'Editor' | 'Reader',
fillText: string = 'user.test',
fillText = 'user.test',
) => {
const responsePromiseSearchUser = page.waitForResponse(
(response) =>
@@ -12,7 +12,7 @@ export const createRootSubPage = async (
page: Page,
browserName: BrowserName,
docName: string,
isMobile: boolean = false,
isMobile = false,
) => {
if (isMobile) {
await page
@@ -72,10 +72,12 @@ export const addChild = async ({
page,
browserName,
docParent,
docName,
}: {
page: Page;
browserName: BrowserName;
docParent: string;
docName: string;
}) => {
let item = page.getByTestId('doc-tree-root-item');
@@ -99,7 +101,7 @@ export const addChild = async ({
await item.hover();
await item.getByTestId('doc-tree-item-actions-add-child').click();
const [name] = randomName(docParent, browserName, 1);
const [name] = randomName(docName, browserName, 1);
await updateDocTitle(page, name);
return name;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "3.8.0",
"version": "3.8.21",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "3.8.0",
"version": "3.8.21",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -60,6 +60,7 @@
"react-dom": "*",
"react-i18next": "15.7.3",
"react-intersection-observer": "9.16.0",
"react-resizable-panels": "3.0.6",
"react-select": "5.10.2",
"styled-components": "6.1.19",
"use-debounce": "10.0.6",
+1 -1
View File
@@ -18,5 +18,5 @@ export const backendUrl = () =>
* @param apiVersion - The version of the API (defaults to '1.0').
* @returns The full versioned API base URL as a string.
*/
export const baseApiUrl = (apiVersion: string = '1.0') =>
export const baseApiUrl = (apiVersion = '1.0') =>
`${backendUrl()}/api/v${apiVersion}/`;
@@ -1,6 +1,6 @@
import { baseApiUrl } from '@/api';
export const HOME_URL: string = '/home';
export const HOME_URL = '/home';
export const LOGIN_URL = `${baseApiUrl()}authenticate/`;
export const LOGOUT_URL = `${baseApiUrl()}logout/`;
export const PATH_AUTH_LOCAL_STORAGE = 'docs-path-auth';
@@ -22,7 +22,7 @@ function isBlock(block: Block): block is Block {
);
}
const recursiveContent = (content: Block[], base: string = '') => {
const recursiveContent = (content: Block[], base = '') => {
let fullContent = base;
for (const innerContent of content) {
if (innerContent.type === 'text') {
@@ -56,7 +56,7 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
router.push(url);
void router.push(url);
};
return (
@@ -163,6 +163,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
aria-label={`${t('Open document {{title}}', { title: docTitle })}`}
$css={css`
text-align: left;
min-width: 0;
`}
>
<Box $width="16px" $height="16px">
@@ -180,8 +181,10 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
display: flex;
flex-direction: row;
width: 100%;
min-width: 0;
gap: 0.5rem;
align-items: center;
overflow: hidden;
`}
>
<Text $css={ItemTextCss} $size="sm" $variation="1000">
@@ -184,7 +184,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
/* Remove outline from TreeViewItem wrapper elements */
.c__tree-view--row {
outline: none !important;
&:focus-visible {
outline: none !important;
}
@@ -241,7 +240,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
}
}
&:hover,
&:focus-within {
&:focus-visible {
.doc-tree-root-item-actions {
opacity: 1;
}
@@ -152,7 +152,6 @@ export const DocTreeItemActions = ({
options={options}
isOpen={isOpen}
onOpenChange={onOpenChange}
aria-label={t('Open document actions menu')}
>
<Icon
onClick={(e) => {
@@ -164,7 +163,7 @@ export const DocTreeItemActions = ({
variant="filled"
$theme="primary"
$variation="600"
aria-hidden="true"
aria-label={t('More options')}
/>
</DropdownMenu>
{doc.abilities.children_create && (
@@ -178,6 +177,7 @@ export const DocTreeItemActions = ({
});
}}
color="primary"
aria-label={t('Add a sub page')}
data-testid="doc-tree-item-actions-add-child"
>
<Icon
@@ -1,4 +1,5 @@
import { Button } from '@openfun/cunningham-react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { InView } from 'react-intersection-observer';
import { css } from 'styled-components';
@@ -36,7 +37,19 @@ export const DocsGrid = ({
hasNextPage,
} = useDocsQuery(target);
const docs = data?.pages.flatMap((page) => page.results) ?? [];
const docs = useMemo(() => {
const allDocs = data?.pages.flatMap((page) => page.results) ?? [];
// Deduplicate documents by ID to prevent the same doc appearing multiple times
// This can happen when a multiple users are impacting the docs list (creation, update, ...)
const seenIds = new Set<string>();
return allDocs.filter((doc) => {
if (seenIds.has(doc.id)) {
return false;
}
seenIds.add(doc.id);
return true;
});
}, [data?.pages]);
const loading = isFetching || isLoading;
const hasDocs = data?.pages.some((page) => page.results.length > 0);
@@ -64,8 +64,8 @@ describe('DocsGridItemDate', () => {
});
});
it(`should render rendered the updated_at field in the correct language`, () => {
i18next.changeLanguage('fr');
it(`should render rendered the updated_at field in the correct language`, async () => {
await i18next.changeLanguage('fr');
render(
<DocsGridItemDate
@@ -83,7 +83,7 @@ describe('DocsGridItemDate', () => {
expect(screen.getByRole('link')).toBeInTheDocument();
expect(screen.getByText('il y a 5 jours')).toBeInTheDocument();
i18next.changeLanguage('en');
await i18next.changeLanguage('en');
});
[
@@ -39,12 +39,10 @@ export const LeftPanel = () => {
{isDesktop && (
<Box
data-testid="left-panel-desktop"
$css={`
$css={css`
height: calc(100vh - ${HEADER_HEIGHT}px);
width: 300px;
min-width: 300px;
width: 100%;
overflow: hidden;
border-right: 1px solid ${colorsTokens['greyscale-200']};
background-color: ${colorsTokens['greyscale-000']};
`}
className="--docs--left-panel-desktop"
@@ -0,0 +1,110 @@
import { useEffect, useRef, useState } from 'react';
import {
ImperativePanelHandle,
Panel,
PanelGroup,
PanelResizeHandle,
} from 'react-resizable-panels';
import { createGlobalStyle } from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
interface PanelStyleProps {
$isResizing: boolean;
}
const PanelStyle = createGlobalStyle<PanelStyleProps>`
${({ $isResizing }) => $isResizing && `body * { transition: none !important; }`}
`;
// Convert a target pixel width to a percentage of the current viewport width.
// react-resizable-panels expects sizes in %, not px.
const calculateDefaultSize = (targetWidth: number) => {
const windowWidth = window.innerWidth;
return (targetWidth / windowWidth) * 100;
};
type ResizableLeftPanelProps = {
leftPanel: React.ReactNode;
children: React.ReactNode;
minPanelSizePx?: number;
maxPanelSizePx?: number;
};
export const ResizableLeftPanel = ({
leftPanel,
children,
minPanelSizePx = 300,
maxPanelSizePx = 450,
}: ResizableLeftPanelProps) => {
const [isResizing, setIsResizing] = useState(false);
const { colorsTokens } = useCunninghamTheme();
const ref = useRef<ImperativePanelHandle>(null);
const resizeTimeoutRef = useRef<number | undefined>(undefined);
const [minPanelSize, setMinPanelSize] = useState(0);
const [maxPanelSize, setMaxPanelSize] = useState(0);
// Single resize listener that handles both panel size updates and transition disabling
useEffect(() => {
const handleResize = () => {
// Update panel sizes (px -> %)
const min = Math.round(calculateDefaultSize(minPanelSizePx));
const max = Math.round(
Math.min(calculateDefaultSize(maxPanelSizePx), 40),
);
setMinPanelSize(min);
setMaxPanelSize(max);
// Temporarily disable transitions to avoid flicker
setIsResizing(true);
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
resizeTimeoutRef.current = window.setTimeout(() => {
setIsResizing(false);
}, 150);
};
handleResize();
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
};
}, [minPanelSizePx, maxPanelSizePx]);
return (
<>
<PanelStyle $isResizing={isResizing} />
<PanelGroup
autoSaveId="docs-left-panel-persistence"
direction="horizontal"
>
<Panel
ref={ref}
order={0}
defaultSize={minPanelSize}
minSize={minPanelSize}
maxSize={maxPanelSize}
>
{leftPanel}
</Panel>
<PanelResizeHandle
style={{
borderRightWidth: '1px',
borderRightStyle: 'solid',
borderRightColor: colorsTokens['greyscale-200'],
width: '1px',
cursor: 'col-resize',
}}
/>
<Panel order={1}>{children}</Panel>
</PanelGroup>
</>
);
};
@@ -1 +1,2 @@
export * from './LeftPanel';
export * from './ResizableLeftPanel';
@@ -3,51 +3,69 @@ import { useEffect } from 'react';
export const useSWRegister = () => {
useEffect(() => {
if (
'serviceWorker' in navigator &&
process.env.NEXT_PUBLIC_SW_DEACTIVATED !== 'true'
!('serviceWorker' in navigator) ||
process.env.NEXT_PUBLIC_SW_DEACTIVATED === 'true'
) {
navigator.serviceWorker
.register(`/service-worker.js`)
.then((registration) => {
registration.onupdatefound = () => {
const newWorker = registration.installing;
if (!newWorker) {
return;
return;
}
const hadControllerAtStart = !!navigator.serviceWorker.controller;
navigator.serviceWorker
.register(`/service-worker.js`)
.then((registration) => {
registration.onupdatefound = () => {
const newWorker = registration.installing;
if (!newWorker) {
return;
}
newWorker.onstatechange = () => {
if (
newWorker.state === 'installed' &&
navigator.serviceWorker.controller
) {
newWorker.postMessage({ type: 'SKIP_WAITING' });
}
newWorker.onstatechange = () => {
if (
newWorker.state === 'installed' &&
navigator.serviceWorker.controller
) {
newWorker.postMessage({ type: 'SKIP_WAITING' });
}
};
};
})
.catch((err) => {
console.error('Service worker registration failed:', err);
});
};
})
.catch((err) => {
console.error('Service worker registration failed:', err);
});
let refreshing = false;
const onControllerChange = () => {
if (refreshing) {
return;
}
refreshing = true;
let refreshing = false;
const onControllerChange = () => {
if (!hadControllerAtStart || refreshing) {
return;
}
refreshing = true;
if (document.visibilityState === 'visible') {
window.location.reload();
return;
}
const onVisible = () => {
if (document.visibilityState === 'visible') {
window.location.reload();
}
};
navigator.serviceWorker.addEventListener(
document.addEventListener('visibilitychange', onVisible, { once: true });
};
navigator.serviceWorker.addEventListener(
'controllerchange',
onControllerChange,
);
return () => {
navigator.serviceWorker.removeEventListener(
'controllerchange',
onControllerChange,
);
return () => {
navigator.serviceWorker.removeEventListener(
'controllerchange',
onControllerChange,
);
};
}
};
}, []);
};
@@ -6,23 +6,21 @@ import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Header } from '@/features/header';
import { HEADER_HEIGHT } from '@/features/header/conf';
import { LeftPanel } from '@/features/left-panel';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { LeftPanel, ResizableLeftPanel } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
import { MAIN_LAYOUT_ID } from './conf';
type MainLayoutProps = {
backgroundColor?: 'white' | 'grey';
enableResizablePanel?: boolean;
};
export function MainLayout({
children,
backgroundColor = 'white',
enableResizablePanel = false,
}: PropsWithChildren<MainLayoutProps>) {
const { isDesktop } = useResponsiveStore();
const { colorsTokens } = useCunninghamTheme();
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
const { t } = useTranslation();
return (
<Box className="--docs--main-layout">
<Header />
@@ -30,33 +28,90 @@ export function MainLayout({
$direction="row"
$margin={{ top: `${HEADER_HEIGHT}px` }}
$width="100%"
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
>
<LeftPanel />
<Box
as="main"
role="main"
aria-label={t('Main content')}
id={MAIN_LAYOUT_ID}
$align="center"
$flex={1}
$width="100%"
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
$padding={{
all: isDesktop ? 'base' : '0',
}}
$background={
currentBackgroundColor === 'white'
? colorsTokens['greyscale-000']
: colorsTokens['greyscale-050']
}
$css={css`
overflow-y: auto;
overflow-x: clip;
`}
<MainLayoutContent
backgroundColor={backgroundColor}
enableResizablePanel={enableResizablePanel}
>
{children}
</Box>
</MainLayoutContent>
</Box>
</Box>
);
}
export interface MainLayoutContentProps {
backgroundColor: 'white' | 'grey';
enableResizablePanel?: boolean;
}
export function MainLayoutContent({
children,
backgroundColor,
enableResizablePanel = false,
}: PropsWithChildren<MainLayoutContentProps>) {
const { isDesktop } = useResponsiveStore();
const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
const currentBackgroundColor = !isDesktop ? 'white' : backgroundColor;
const mainContent = (
<Box
as="main"
role="main"
aria-label={t('Main content')}
id={MAIN_LAYOUT_ID}
$align="center"
$flex={1}
$width="100%"
$height={`calc(100dvh - ${HEADER_HEIGHT}px)`}
$padding={{
all: isDesktop ? 'base' : '0',
}}
$background={
currentBackgroundColor === 'white'
? colorsTokens['greyscale-000']
: colorsTokens['greyscale-050']
}
$css={css`
overflow-y: auto;
overflow-x: clip;
`}
>
{children}
</Box>
);
if (!isDesktop) {
return (
<>
<LeftPanel />
{mainContent}
</>
);
}
if (enableResizablePanel) {
return (
<ResizableLeftPanel leftPanel={<LeftPanel />}>
{mainContent}
</ResizableLeftPanel>
);
}
return (
<>
<Box
$css={css`
width: 300px;
min-width: 300px;
border-right: 1px solid ${colorsTokens['greyscale-200']};
`}
>
<LeftPanel />
</Box>
{mainContent}
</>
);
}
@@ -47,7 +47,7 @@ export function DocLayout() {
return subPageToTree(doc.results);
}}
>
<MainLayout>
<MainLayout enableResizablePanel={true}>
<DocPage id={id} />
</MainLayout>
</TreeProvider>
@@ -115,7 +115,9 @@ const DocPage = ({ id }: DocProps) => {
// Invalidate when provider store reports a lost connection
useEffect(() => {
if (hasLostConnection && doc?.id) {
queryClient.invalidateQueries({ queryKey: [KEY_DOC, { id: doc.id }] });
void queryClient.invalidateQueries({
queryKey: [KEY_DOC, { id: doc.id }],
});
resetLostConnection();
}
}, [hasLostConnection, doc?.id, queryClient, resetLostConnection]);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "impress",
"version": "3.8.0",
"version": "3.8.21",
"private": true,
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
@@ -1,6 +1,6 @@
{
"name": "eslint-plugin-docs",
"version": "3.8.0",
"version": "3.8.21",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
@@ -22,7 +22,9 @@ const typescriptConfig = {
'@typescript-eslint': typescriptEslint,
},
rules: {
'@typescript-eslint/no-inferrable-types': 'error',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
'@typescript-eslint/no-unsafe-argument': 'error',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "3.8.0",
"version": "3.8.21",
"repository": "https://github.com/suitenumerique/docs",
"author": "DINUM",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "server-y-provider",
"version": "3.8.0",
"version": "3.8.21",
"description": "Y.js provider for docs",
"repository": "https://github.com/suitenumerique/docs",
"license": "MIT",
+5
View File
@@ -13793,6 +13793,11 @@ react-resizable-panels@2.1.7:
resolved "https://registry.yarnpkg.com/react-resizable-panels/-/react-resizable-panels-2.1.7.tgz#afd29d8a3d708786a9f95183a38803c89f13c2e7"
integrity sha512-JtT6gI+nURzhMYQYsx8DKkx6bSoOGFp7A3CwMrOb8y5jFHFyqwo9m68UhmXRw57fRVJksFn1TSlm3ywEQ9vMgA==
react-resizable-panels@3.0.6:
version "3.0.6"
resolved "https://registry.yarnpkg.com/react-resizable-panels/-/react-resizable-panels-3.0.6.tgz#8183132ea13a09821e9c93962ed49f240cdcfd3f"
integrity sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==
react-select@5.10.2:
version "5.10.2"
resolved "https://registry.yarnpkg.com/react-select/-/react-select-5.10.2.tgz#8dffc69dfd7d74684d9613e6eb27204e3b99e127"
+2 -2
View File
@@ -1,10 +1,10 @@
environments:
dev:
values:
- version: 3.8.0
- version: 3.8.21
feature:
values:
- version: 3.8.0
- version: 3.8.21
feature: ci
domain: example.com
imageTag: demo
+1 -1
View File
@@ -1,5 +1,5 @@
apiVersion: v2
type: application
name: docs
version: 3.8.0
version: 3.8.21
appVersion: latest
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "3.8.0",
"version": "3.8.21",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {