Compare commits

...

12 Commits

Author SHA1 Message Date
Cyril 1e39d17914 (frontend) improve accessibility by adding landmark roles to layout
landmark roles help assistive tech users navigate quickly across the page

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-17 08:24:21 +02:00
Anthony LC ecd2f97cf5 🐛(frontend) fix legacy role computation
Before the subpages feature, the user_role was
computed thanks to the abilities.
This is not the correct way to do it anymore,
the abilities are now different.
We now have "user_role" in the doc response
which is the correct way to get the user role
for the current document.
2025-09-16 17:23:26 +02:00
Anthony LC 90624e83f5 🩹(demo) update the email in realm.json
We updated the email addresses for the demo users
but forgot to change them in the realm.json file.
This commit fixes that oversight.
2025-09-16 17:23:26 +02:00
Cyril 5fc002658c (frontend) add pdf outline property to enable bookmarks display
allows pdf viewers like adobe reader to display bookmarks in the sidebar

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-16 14:29:53 +02:00
Cyril dfd5dc1545 (frontend) document visible in list are now openable via enter key
the document now appears in the list and can be opened using the enter key

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-16 12:51:23 +02:00
Cyril 69e7235f75 (frontend) refine focus outline with shadow for visual consistency
aligns focus state with app style by adding background shadow to outline

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-16 10:56:34 +02:00
Cyril 942c90c29f (frontend) enable enter key to open documents and subdocuments
added keyboard support to open docs and subdocs using the enter key

Signed-off-by: Cyril <c.gromoff@gmail.com>
2025-09-16 10:26:49 +02:00
virgile-dev c5f0142671 📝 (doc) add mosa.cloud docs instance (#1334)
## Purpose

So that users have more options to choose from


## Proposal
Add mosa.cloud docs instance url

Please ensure the following items are checked before submitting your
pull request:
- [x] I have read and followed the [contributing
guidelines](https://github.com/suitenumerique/docs/blob/main/CONTRIBUTING.md)
- [x] I have read and agreed to the [Code of
Conduct](https://github.com/suitenumerique/docs/blob/main/CODE_OF_CONDUCT.md)
- [x] I have signed off my commits with `git commit --signoff` (DCO
compliance)
- [x] I have signed my commits with my SSH or GPG key (`git commit -S`)
- [x] My commit messages follow the required format: `<gitmoji>(type)
title description`
- [ ] I have added a changelog entry under `## [Unreleased]` section (if
noticeable change)
- [ ] I have added corresponding tests for new features or bug fixes (if
applicable)

Signed-off-by: virgile-deville <virgile.deville@beta.gouv.fr>
2025-09-16 07:01:10 +00:00
Manuel Raynaud 7f37d3bda4 🐛(backend) duplicate sub docs as root for reader user
Reader user should be able to duplicate a doc in the doc tree. It should
be created a new doc at the root level.
2025-09-15 20:44:58 +00:00
Manuel Raynaud 7033d0ecf7 🐛(backend) cast DOCUMENT_IMAGE_MAX_SIZE in integer
The expected type for the settings DOCUMENT_IMAGE_MAX_SIZE is an
integer. By not using django configurations IntegerValue, the value is
used as it and most of the time will be a string. We must use the
IntegerValue in order to cast the value in string.
2025-09-15 17:47:43 +02:00
Fabre Florian 0dd6818e91 (frontend) Adapt e2e test utils to the Keycloak 26.3 login page
Fix the keyCloakSignIn() function for the new login page.

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-09-15 11:19:42 +02:00
Fabre Florian eb225fc86f 🔧(keycloak) Fix https required issue in dev mode
On some environments keycloak returns a 'HTTPS required' message on login.
The same issue was fixed in drive by changing the 'sslRequired' value
from 'external' to 'none'.
Also upgrade keycloak up to 26.3.2

Signed-off-by: Fabre Florian <ffabre@hybird.org>
2025-09-15 11:19:41 +02:00
34 changed files with 397 additions and 179 deletions
+21
View File
@@ -8,6 +8,25 @@ and this project adheres to
## [Unreleased]
### Changed
- ♿(frontend) improve accessibility:
- #1354
- ♿ improve accessibility by adding landmark roles to layout #1394
### Fixed
- 🐛(backend) duplicate sub docs as root for reader users
### Changed
- ♿(frontend) improve accessibility:
- ✨ add document visible in list and openable via enter key #1365
### Changed
- ♿(frontend) improve accessibility:
- ♿ add pdf outline property to enable bookmarks display #1368
## [3.7.0] - 2025-09-12
@@ -35,6 +54,7 @@ and this project adheres to
### Fixed
- 🐛(frontend) fix callout emoji list #1366
- 🐛(frontend) fix legacy role computation #1376
## [3.6.0] - 2025-09-04
@@ -71,6 +91,7 @@ and this project adheres to
- 🐛(frontend) fix dnd conflict with tree and Blocknote #1328
- 🐛(frontend) fix display bug on homepage #1332
- 🐛link role update #1287
- 🔧(keycloak) Fix https required issue in dev mode #1286
## [3.5.0] - 2025-07-31
+9 -9
View File
@@ -54,16 +54,16 @@ Docs is a collaborative text editor designed to address common challenges in kno
We use Kubernetes for our [production instance](https://docs.numerique.gouv.fr/) but also support Docker Compose. The community contributed a couple other methods (Nix, YunoHost etc.) check out the [docs](/docs/installation/README.md) to get detailed instructions and examples.
#### 🌍 Known instances
We hope to see many more, here is an incomplete list of public Docs instances (urls listed in alphabetical order). Feel free to make a PR to add ones that are not listed below🙏
| | | |
| --- | --- | ------- |
We hope to see many more, here is an incomplete list of public Docs instances. Feel free to make a PR to add ones that are not listed below🙏
| Url | Org | Public |
| docs.numerique.gouv.fr | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| docs.suite.anct.gouv.fr | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| notes.demo.opendesk.eu | ZenDiS | Demo instance of OpenDesk. Request access to get credentials |
| notes.liiib.re | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| docs.federated.nexus | federated.nexus | Public instance, but you have to [sign up for a Federated Nexus account](https://federated.nexus/register/). |
| --- | --- | ------- |
| [docs.numerique.gouv.fr](https://docs.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
| [docs.suite.anct.gouv.fr](https://docs.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
| [notes.demo.opendesk.eu](https://notes.demo.opendesk.eu) | ZenDiS | Demo instance of OpenDesk. Request access to get credentials |
| [notes.liiib.re](https://notes.liiib.re/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
| [docs.federated.nexus](https://docs.federated.nexus/) | federated.nexus | Public instance, but you have to [sign up for a Federated Nexus account](https://federated.nexus/register/). |
| [docs.demo.mosacloud.eu](https://docs.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
#### ⚠️ Advanced features
For some advanced features (ex: Export as PDF) Docs relies on XL packages from BlockNote. These are licenced under GPL and are not MIT compatible. You can perfectly use Docs without these packages by setting the environment variable `PUBLISH_AS_MIT` to true. That way you'll build an image of the application without the features that are not MIT compatible. Read the [environment variables documentation](/docs/env.md) for more information.
+4 -6
View File
@@ -184,22 +184,20 @@ services:
- env.d/development/kc_postgresql.local
keycloak:
image: quay.io/keycloak/keycloak:20.0.1
image: quay.io/keycloak/keycloak:26.3
volumes:
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
command:
- start-dev
- --features=preview
- --import-realm
- --proxy=edge
- --hostname-url=http://localhost:8083
- --hostname-admin-url=http://localhost:8083/
- --hostname=http://localhost:8083
- --hostname-strict=false
- --hostname-strict-https=false
- --health-enabled=true
- --metrics-enabled=true
healthcheck:
test: ["CMD", "curl", "--head", "-fsS", "http://localhost:8080/health/ready"]
test: ['CMD-SHELL', 'exec 3<>/dev/tcp/localhost/9000; echo -e "GET /health/live HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" >&3; grep "HTTP/1.1 200 OK" <&3']
start_period: 5s
interval: 1s
timeout: 2s
retries: 300
+5 -5
View File
@@ -26,7 +26,7 @@
"oauth2DeviceCodeLifespan": 600,
"oauth2DevicePollingInterval": 5,
"enabled": true,
"sslRequired": "external",
"sslRequired": "none",
"registrationAllowed": true,
"registrationEmailAsUsername": false,
"rememberMe": true,
@@ -60,7 +60,7 @@
},
{
"username": "user-e2e-chromium",
"email": "user@chromium.test",
"email": "user.test@chromium.test",
"firstName": "E2E",
"lastName": "Chromium",
"enabled": true,
@@ -74,7 +74,7 @@
},
{
"username": "user-e2e-webkit",
"email": "user@webkit.test",
"email": "user.test@webkit.test",
"firstName": "E2E",
"lastName": "Webkit",
"enabled": true,
@@ -88,7 +88,7 @@
},
{
"username": "user-e2e-firefox",
"email": "user@firefox.test",
"email": "user.test@firefox.test",
"firstName": "E2E",
"lastName": "Firefox",
"enabled": true,
@@ -2270,7 +2270,7 @@
"cibaInterval": "5",
"realmReusableOtpCode": "false"
},
"keycloakVersion": "20.0.1",
"keycloakVersion": "26.3.2",
"userManagedAccessAllowed": false,
"clientProfiles": {
"profiles": []
+38 -11
View File
@@ -941,37 +941,64 @@ class DocumentViewSet(
in the payload.
"""
# Get document while checking permissions
document = self.get_object()
document_to_duplicate = self.get_object()
serializer = serializers.DocumentDuplicationSerializer(
data=request.data, partial=True
)
serializer.is_valid(raise_exception=True)
with_accesses = serializer.validated_data.get("with_accesses", False)
is_owner_or_admin = document.get_role(request.user) in models.PRIVILEGED_ROLES
user_role = document_to_duplicate.get_role(request.user)
is_owner_or_admin = user_role in models.PRIVILEGED_ROLES
base64_yjs_content = document.content
base64_yjs_content = document_to_duplicate.content
# Duplicate the document instance
link_kwargs = (
{"link_reach": document.link_reach, "link_role": document.link_role}
{
"link_reach": document_to_duplicate.link_reach,
"link_role": document_to_duplicate.link_role,
}
if with_accesses
else {}
)
extracted_attachments = set(extract_attachments(document.content))
attachments = list(extracted_attachments & set(document.attachments))
duplicated_document = document.add_sibling(
extracted_attachments = set(extract_attachments(document_to_duplicate.content))
attachments = list(
extracted_attachments & set(document_to_duplicate.attachments)
)
title = capfirst(_("copy of {title}").format(title=document_to_duplicate.title))
if not document_to_duplicate.is_root() and choices.RoleChoices.get_priority(
user_role
) < choices.RoleChoices.get_priority(models.RoleChoices.EDITOR):
duplicated_document = models.Document.add_root(
creator=self.request.user,
title=title,
content=base64_yjs_content,
attachments=attachments,
duplicated_from=document_to_duplicate,
**link_kwargs,
)
models.DocumentAccess.objects.create(
document=duplicated_document,
user=self.request.user,
role=models.RoleChoices.OWNER,
)
return drf_response.Response(
{"id": str(duplicated_document.id)}, status=status.HTTP_201_CREATED
)
duplicated_document = document_to_duplicate.add_sibling(
"right",
title=capfirst(_("copy of {title}").format(title=document.title)),
title=title,
content=base64_yjs_content,
attachments=attachments,
duplicated_from=document,
duplicated_from=document_to_duplicate,
creator=request.user,
**link_kwargs,
)
# Always add the logged-in user as OWNER for root documents
if document.is_root():
if document_to_duplicate.is_root():
accesses_to_create = [
models.DocumentAccess(
document=duplicated_document,
@@ -983,7 +1010,7 @@ class DocumentViewSet(
# If accesses should be duplicated, add other users' accesses as per original document
if with_accesses and is_owner_or_admin:
original_accesses = models.DocumentAccess.objects.filter(
document=document
document=document_to_duplicate
).exclude(user=request.user)
accesses_to_create.extend(
@@ -293,3 +293,28 @@ def test_api_documents_duplicate_non_root_document(role):
assert duplicated_accesses.count() == 0
assert duplicated_document.is_sibling_of(child)
assert duplicated_document.is_child_of(document)
def test_api_documents_duplicate_reader_non_root_document():
"""
Reader users should be able to duplicate non-root documents but will be
created as a root document.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(users=[(user, "reader")])
child = factories.DocumentFactory(parent=document)
assert child.get_role(user) == "reader"
response = client.post(
f"/api/v1.0/documents/{child.id!s}/duplicate/", format="json"
)
assert response.status_code == 201
duplicated_document = models.Document.objects.get(id=response.json()["id"])
assert duplicated_document.is_root()
assert duplicated_document.accesses.count() == 1
assert duplicated_document.accesses.get(user=user).role == "owner"
+1 -1
View File
@@ -142,7 +142,7 @@ class Base(Configuration):
)
# Document images
DOCUMENT_IMAGE_MAX_SIZE = values.Value(
DOCUMENT_IMAGE_MAX_SIZE = values.IntegerValue(
10 * (2**20), # 10MB
environ_name="DOCUMENT_IMAGE_MAX_SIZE",
environ_prefix=None,
@@ -89,8 +89,8 @@ test.describe('Doc Create: Not logged', () => {
const data = {
title,
content: markdown,
sub: `user@${browserName}.test`,
email: `user@${browserName}.test`,
sub: `user.test@${browserName}.test`,
email: `user.test@${browserName}.test`,
};
const newDoc = await request.post(
@@ -238,17 +238,7 @@ test.describe('Doc Editor', () => {
test('it cannot edit if viewer', async ({ page }) => {
await mockedDocument(page, {
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,
},
user_role: 'reader',
});
await goToGridDoc(page);
@@ -257,6 +247,9 @@ test.describe('Doc Editor', () => {
await expect(card).toBeVisible();
await expect(card.getByText('Reader')).toBeVisible();
const editor = page.locator('.ProseMirror');
await expect(editor).toHaveAttribute('contenteditable', 'false');
});
test('it adds an image to the doc editor', async ({ page, browserName }) => {
@@ -28,7 +28,7 @@ test.describe('Documents Grid mobile', () => {
id: '8c1e047a-24e7-4a80-942b-8e9c7ab43e1f',
user: {
id: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
email: 'test@test.test',
email: 'test.test@test.test',
full_name: 'John Doe',
short_name: 'John',
},
@@ -164,7 +164,7 @@ test.describe('Doc Header', () => {
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(invitationCard).toBeVisible();
await expect(
invitationCard.getByText('test@invitation.test').first(),
invitationCard.getByText('test.test@invitation.test').first(),
).toBeVisible();
const invitationRole = invitationCard.getByLabel('doc-role-dropdown');
await expect(invitationRole).toBeVisible();
@@ -178,7 +178,7 @@ test.describe('Doc Header', () => {
const roles = memberCard.getByLabel('doc-role-dropdown');
await expect(memberCard).toBeVisible();
await expect(
memberCard.getByText('test@accesses.test').first(),
memberCard.getByText('test.test@accesses.test').first(),
).toBeVisible();
await expect(roles).toBeVisible();
@@ -239,7 +239,7 @@ test.describe('Doc Header', () => {
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(invitationCard).toBeVisible();
await expect(
invitationCard.getByText('test@invitation.test').first(),
invitationCard.getByText('test.test@invitation.test').first(),
).toBeVisible();
await expect(invitationCard.getByLabel('Document role text')).toBeVisible();
await expect(
@@ -247,7 +247,7 @@ test.describe('Doc Header', () => {
).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(memberCard.getByText('test.test@accesses.test')).toBeVisible();
await expect(memberCard.getByLabel('Document role text')).toBeVisible();
await expect(
memberCard.getByRole('button', { name: 'more_horiz' }),
@@ -302,7 +302,7 @@ test.describe('Doc Header', () => {
const invitationCard = shareModal.getByLabel('List invitation card');
await expect(invitationCard).toBeVisible();
await expect(
invitationCard.getByText('test@invitation.test').first(),
invitationCard.getByText('test.test@invitation.test').first(),
).toBeVisible();
await expect(invitationCard.getByLabel('Document role text')).toBeVisible();
await expect(
@@ -310,7 +310,7 @@ test.describe('Doc Header', () => {
).toBeHidden();
const memberCard = shareModal.getByLabel('List members card');
await expect(memberCard.getByText('test@accesses.test')).toBeVisible();
await expect(memberCard.getByText('test.test@accesses.test')).toBeVisible();
await expect(memberCard.getByLabel('Document role text')).toBeVisible();
await expect(
memberCard.getByRole('button', { name: 'more_horiz' }),
@@ -18,7 +18,7 @@ test.describe('Inherited share accesses', () => {
).toBeVisible();
const user = page.getByTestId(
`doc-share-member-row-user@${browserName}.test`,
`doc-share-member-row-user.test@${browserName}.test`,
);
await expect(user).toBeVisible();
await expect(user.getByText('E2E Chromium')).toBeVisible();
@@ -280,7 +280,7 @@ test.describe('Document create member: Multiple login', () => {
await expect(page.getByText('Access Requests')).toBeVisible();
await expect(page.getByText(`E2E ${otherBrowser}`)).toBeVisible();
const emailRequest = `user@${otherBrowser}.test`;
const emailRequest = `user.test@${otherBrowser}.test`;
await expect(page.getByText(emailRequest)).toBeVisible();
const container = page.getByTestId(
`doc-share-access-request-row-${emailRequest}`,
@@ -139,7 +139,7 @@ test.describe('Document list members', () => {
const list = page.getByTestId('doc-share-quick-search');
await expect(list).toBeVisible();
const currentUser = list.getByTestId(
`doc-share-member-row-user@${browserName}.test`,
`doc-share-member-row-user.test@${browserName}.test`,
);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
await expect(currentUser).toBeVisible();
@@ -190,7 +190,7 @@ test.describe('Document list members', () => {
const list = page.getByTestId('doc-share-quick-search');
const emailMyself = `user@${browserName}.test`;
const emailMyself = `user.test@${browserName}.test`;
const mySelf = list.getByTestId(`doc-share-member-row-${emailMyself}`);
const mySelfRole = mySelf.getByRole('button', {
name: 'doc-role-dropdown',
@@ -220,7 +220,7 @@ test.describe('Doc Tree', () => {
const list = page.getByTestId('doc-share-quick-search');
const currentUser = list.getByTestId(
`doc-share-member-row-user@${browserName}.test`,
`doc-share-member-row-user.test@${browserName}.test`,
);
const currentUserRole = currentUser.getByLabel('doc-role-dropdown');
await currentUserRole.click();
@@ -235,6 +235,12 @@ test.describe('Doc Tree', () => {
'doc-tree-detach-child',
);
await expect(
page
.getByLabel('It is the card information about the document.')
.getByText('Administrator ·'),
).toBeVisible();
const docTree = page.getByTestId('doc-tree');
await expect(docTree.getByText(docChild)).toBeVisible();
await docTree.click();
@@ -252,6 +258,46 @@ test.describe('Doc Tree', () => {
page.getByRole('menuitem', { name: 'Move to my docs' }),
).toHaveAttribute('aria-disabled', 'true');
});
test('keyboard navigation with Enter key opens documents', async ({
page,
browserName,
}) => {
// Create a parent document
const [docParent] = await createDoc(
page,
'doc-tree-keyboard-nav',
browserName,
1,
);
await verifyDocName(page, docParent);
// Create a sub-document
const { name: docChild } = await createRootSubPage(
page,
browserName,
'doc-tree-keyboard-child',
);
const docTree = page.getByTestId('doc-tree');
await expect(docTree).toBeVisible();
// Test keyboard navigation on root document
const rootItem = page.getByTestId('doc-tree-root-item');
await expect(rootItem).toBeVisible();
// Focus on the root item and press Enter
await rootItem.focus();
await expect(rootItem).toBeFocused();
await page.keyboard.press('Enter');
// Verify we navigated to the root document
await verifyDocName(page, docParent);
await expect(page).toHaveURL(/\/docs\/[^/]+\/?$/);
// Now test keyboard navigation on sub-document
await expect(docTree.getByText(docChild)).toBeVisible();
});
});
test.describe('Doc Tree: Inheritance', () => {
@@ -156,7 +156,7 @@ test.describe('Doc Visibility: Restricted', () => {
if (!otherBrowser) {
throw new Error('No alternative browser found');
}
const username = `user@${otherBrowser}.test`;
const username = `user.test@${otherBrowser}.test`;
await inputSearch.fill(username);
await page.getByRole('option', { name: username }).click();
@@ -131,7 +131,7 @@ test.describe('Home page', () => {
// Keyclock login page
await expect(
page.locator('.login-pf-page-header').getByText('impress'),
page.locator('.login-pf #kc-header-wrapper').getByText('impress'),
).toBeVisible();
});
});
@@ -56,7 +56,7 @@ export const keyCloakSignIn = async (
const password = `password-e2e-${browserName}`;
await expect(
page.locator('.login-pf-page-header').getByText('impress'),
page.locator('.login-pf #kc-header-wrapper').getByText('impress'),
).toBeVisible();
if (await page.getByLabel('Restart login').isVisible()) {
@@ -65,7 +65,7 @@ export const keyCloakSignIn = async (
await page.getByRole('textbox', { name: 'username' }).fill(login);
await page.getByRole('textbox', { name: 'password' }).fill(password);
await page.click('input[type="submit"]', { force: true });
await page.click('button[type="submit"]', { force: true });
};
export const randomName = (name: string, browserName: string, length: number) =>
@@ -72,7 +72,7 @@ export const mockedInvitations = async (page: Page, json?: object) => {
retrieve: true,
},
created_at: '2024-10-03T12:19:26.107687Z',
email: 'test@invitation.test',
email: 'test.test@invitation.test',
document: '4888c328-8406-4412-9b0b-c0ba5b9e5fb6',
role: 'editor',
issuer: '7380f42f-02eb-4ad5-b8f0-037a0e66066d',
@@ -129,7 +129,7 @@ export const mockedAccesses = async (page: Page, json?: object) => {
id: 'bc8bbbc5-a635-4f65-9817-fd1e9ec8ef87',
user: {
id: 'b4a21bb3-722e-426c-9f78-9d190eda641c',
email: 'test@accesses.test',
email: 'test.test@accesses.test',
},
team: '',
max_ancestors_role: null,
@@ -2,6 +2,26 @@ import { Text } from '@react-pdf/renderer';
import { DocsExporterPDF } from '../types';
// Helper function to extract plain text from block content
function extractTextFromBlockContent(content: unknown[]): string {
return content
.map((item) => {
if (
typeof item === 'object' &&
item !== null &&
'type' in item &&
'text' in item
) {
if (item.type === 'text' && typeof item.text === 'string') {
return item.text;
}
}
return '';
})
.join('')
.trim();
}
export const blockMappingHeadingPDF: DocsExporterPDF['mappings']['blockMapping']['heading'] =
(block, exporter) => {
const PIXELS_PER_POINT = 0.75;
@@ -9,9 +29,18 @@ export const blockMappingHeadingPDF: DocsExporterPDF['mappings']['blockMapping']
const FONT_SIZE = 16;
const fontSizeEM =
block.props.level === 1 ? 2 : block.props.level === 2 ? 1.5 : 1.17;
// Extract plain text for bookmark title
const bookmarkTitle =
extractTextFromBlockContent(block.content) || 'Untitled';
return (
<Text
key={block.id}
// @ts-expect-error: bookmark is supported by react-pdf but not typed
bookmark={{
title: bookmarkTitle,
}}
style={{
fontSize: fontSizeEM * FONT_SIZE * PIXELS_PER_POINT,
fontWeight: 700,
@@ -98,9 +98,12 @@ export const ModalExport = ({ onClose, doc }: ModalExportProps) => {
exportDocument,
)) as React.ReactElement<DocumentProps>;
// Inject language for screen reader support
// Inject language for screen reader support and enable outlines (bookmarks)
const pdfDocument = isValidElement(rawPdfDocument)
? cloneElement(rawPdfDocument, { language: i18next.language })
? cloneElement(rawPdfDocument, {
language: i18next.language,
pageMode: 'useOutlines',
})
: rawPdfDocument;
blobExport = await pdf(pdfDocument).toBlob();
@@ -7,7 +7,6 @@ import {
Doc,
LinkReach,
Role,
currentDocRole,
getDocLinkReach,
useIsCollaborativeEditable,
useTrans,
@@ -73,7 +72,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
>
{transRole(
isEditable
? currentDocRole(doc.abilities)
? doc.user_role || doc.link_role
: Role.READER,
)}
&nbsp;·&nbsp;
@@ -1,11 +1,10 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as Y from 'yjs';
import { LinkReach, LinkRole, Role } from '../types';
import { LinkReach, LinkRole } from '../types';
import {
base64ToBlocknoteXmlFragment,
base64ToYDoc,
currentDocRole,
getDocLinkReach,
getDocLinkRole,
getEmojiAndTitle,
@@ -24,56 +23,6 @@ describe('doc-management utils', () => {
vi.clearAllMocks();
});
describe('currentDocRole', () => {
it('should return OWNER when destroy ability is true', () => {
const abilities = {
destroy: true,
accesses_manage: false,
partial_update: false,
} as any;
const result = currentDocRole(abilities);
expect(result).toBe(Role.OWNER);
});
it('should return ADMIN when accesses_manage ability is true and destroy is false', () => {
const abilities = {
destroy: false,
accesses_manage: true,
partial_update: false,
} as any;
const result = currentDocRole(abilities);
expect(result).toBe(Role.ADMIN);
});
it('should return EDITOR when partial_update ability is true and higher abilities are false', () => {
const abilities = {
destroy: false,
accesses_manage: false,
partial_update: true,
} as any;
const result = currentDocRole(abilities);
expect(result).toBe(Role.EDITOR);
});
it('should return READER when no higher abilities are true', () => {
const abilities = {
destroy: false,
accesses_manage: false,
partial_update: false,
} as any;
const result = currentDocRole(abilities);
expect(result).toBe(Role.READER);
});
});
describe('base64ToYDoc', () => {
it('should convert base64 string to Y.Doc', () => {
const base64String = 'dGVzdA=='; // "test" in base64
@@ -49,6 +49,8 @@ export const SimpleDocItem = ({
$overflow="auto"
$width="100%"
className="--docs--simple-doc-item"
role="presentation"
aria-label={`${t('Open document {{title}}', { title: doc.title || untitledDocument })}`}
>
<Box
$direction="row"
@@ -59,6 +61,7 @@ export const SimpleDocItem = ({
`}
$padding={`${spacingsTokens['3xs']} 0`}
data-testid={isPinned ? `doc-pinned-${doc.id}` : undefined}
aria-hidden="true"
>
{isPinned ? (
<PinnedDocumentIcon
@@ -96,6 +99,7 @@ export const SimpleDocItem = ({
$align="center"
$gap={spacingsTokens['3xs']}
$margin={{ top: '-2px' }}
aria-hidden="true"
>
<Text $variation="600" $size="xs">
{DateTime.fromISO(doc.updated_at).toRelative()}
@@ -1,17 +1,7 @@
import emojiRegex from 'emoji-regex';
import * as Y from 'yjs';
import { Doc, LinkReach, LinkRole, Role } from './types';
export const currentDocRole = (abilities: Doc['abilities']): Role => {
return abilities.destroy
? Role.OWNER
: abilities.accesses_manage
? Role.ADMIN
: abilities.partial_update
? Role.EDITOR
: Role.READER;
};
import { Doc, LinkReach, LinkRole } from './types';
export const base64ToYDoc = (base64: string) => {
const uint8Array = Buffer.from(base64, 'base64');
@@ -5,9 +5,10 @@ import {
} from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { Box, BoxButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
@@ -18,6 +19,8 @@ import { DocIcon } from '@/features/docs/doc-management/components/DocIcon';
import { useLeftPanelStore } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
import { useKeyboardActivation } from '../hooks/useKeyboardActivation';
import SubPageIcon from './../assets/sub-page-logo.svg';
import { DocTreeItemActions } from './DocTreeItemActions';
@@ -38,7 +41,11 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
const { node } = props;
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const [actionsOpen, setActionsOpen] = useState(false);
const { t } = useTranslation();
const [menuOpen, setMenuOpen] = useState(false);
const isSelectedNow = treeContext?.treeData.selectedNode?.id === doc.id;
const isActive = node.isFocused || menuOpen || isSelectedNow;
const router = useRouter();
const { togglePanel } = useLeftPanelStore();
@@ -46,6 +53,11 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(doc.title || '');
const displayTitle = titleWithoutEmoji || untitledDocument;
const handleActivate = () => {
treeContext?.treeData.setSelectedNode(doc);
router.push(`/docs/${doc.id}`);
};
const afterCreate = (createdDoc: Doc) => {
const actualChildren = node.data.children ?? [];
@@ -76,62 +88,80 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
}
};
useKeyboardActivation(
['Enter'],
isActive && !menuOpen,
handleActivate,
true,
'.c__tree-view',
);
const docTitle = doc.title || untitledDocument;
const hasChildren = (doc.children?.length || 0) > 0;
const isExpanded = node.isOpen;
const isSelected = isSelectedNow;
const ariaLabel = docTitle;
return (
<Box
className="--docs-sub-page-item"
draggable={doc.abilities.move && isDesktop}
$position="relative"
role="treeitem"
aria-label={ariaLabel}
aria-selected={isSelected}
aria-expanded={hasChildren ? isExpanded : undefined}
$css={css`
background-color: ${actionsOpen
background-color: ${menuOpen
? 'var(--c--theme--colors--greyscale-100)'
: 'var(--c--theme--colors--greyscale-000)'};
.light-doc-item-actions {
display: ${actionsOpen || !isDesktop ? 'flex' : 'none'};
display: ${menuOpen || !isDesktop ? 'flex' : 'none'};
position: absolute;
right: 0;
background: ${isDesktop
? 'var(--c--theme--colors--greyscale-100)'
: 'var(--c--theme--colors--greyscale-000)'};
}
.c__tree-view--node.isSelected {
.light-doc-item-actions {
background: var(--c--theme--colors--greyscale-100);
}
}
.c__tree-view--node.isFocused {
outline: none !important;
box-shadow: 0 0 0 2px var(--c--theme--colors--primary-500) !important;
border-radius: 4px;
}
&:hover {
background-color: var(--c--theme--colors--greyscale-100);
border-radius: 4px;
.light-doc-item-actions {
display: flex;
background: var(--c--theme--colors--greyscale-100);
}
}
.row.preview & {
background-color: inherit;
}
`}
>
<TreeViewItem
{...props}
onClick={() => {
treeContext?.treeData.setSelectedNode(props.node.data.value as Doc);
router.push(`/docs/${props.node.data.value.id}`);
}}
>
<Box
data-testid={`doc-sub-page-item-${props.node.data.value.id}`}
<TreeViewItem {...props} onClick={handleActivate}>
<BoxButton
onClick={(e) => {
e.stopPropagation();
handleActivate();
}}
$width="100%"
$direction="row"
$gap={spacingsTokens['xs']}
role="button"
tabIndex={0}
$align="center"
$minHeight="24px"
data-testid={`doc-sub-page-item-${doc.id}`}
aria-label={`${t('Open document {{title}}', { title: docTitle })}`}
$css={css`
text-align: left;
`}
>
<Box $width="16px" $height="16px">
<DocIcon emoji={emoji} defaultIcon={<SubPageIcon />} $size="sm" />
@@ -157,23 +187,25 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
iconName="group"
$size="16px"
$variation="400"
aria-hidden="true"
/>
)}
</Box>
<Box
$direction="row"
$align="center"
className="light-doc-item-actions"
>
<DocTreeItemActions
doc={doc}
isOpen={actionsOpen}
onOpenChange={setActionsOpen}
parentId={node.data.parentKey}
onCreateSuccess={afterCreate}
/>
</Box>
</BoxButton>
<Box
$direction="row"
$align="center"
className="light-doc-item-actions"
role="toolbar"
aria-label={`${t('Actions for {{title}}', { title: docTitle })}`}
>
<DocTreeItemActions
doc={doc}
isOpen={menuOpen}
onOpenChange={setMenuOpen}
parentId={node.data.parentKey}
onCreateSuccess={afterCreate}
/>
</Box>
</TreeViewItem>
</Box>
@@ -7,6 +7,7 @@ import {
} from '@gouvfr-lasuite/ui-kit';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, StyledLink } from '@/components';
@@ -26,11 +27,16 @@ type DocTreeProps = {
export const DocTree = ({ currentDoc }: DocTreeProps) => {
const { spacingsTokens } = useCunninghamTheme();
const [rootActionsOpen, setRootActionsOpen] = useState(false);
const treeContext = useTreeContext<Doc | null>();
const router = useRouter();
const { isDesktop } = useResponsive();
const [treeRoot, setTreeRoot] = useState<HTMLElement | null>(null);
const treeContext = useTreeContext<Doc | null>();
const router = useRouter();
const [rootActionsOpen, setRootActionsOpen] = useState(false);
const rootIsSelected =
!!treeContext?.root?.id &&
treeContext?.treeData.selectedNode?.id === treeContext.root.id;
const { t } = useTranslation();
const [initialOpenState, setInitialOpenState] = useState<OpenMap | undefined>(
undefined,
@@ -39,9 +45,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
const { mutate: moveDoc } = useMoveDoc();
const { data: tree, isFetching } = useDocTree(
{
docId: currentDoc.id,
},
{ docId: currentDoc.id },
{
enabled: !treeContext?.root?.id,
queryKey: [KEY_DOC_TREE, { id: currentDoc.id }],
@@ -56,7 +60,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
});
treeContext?.treeData.handleMove(result);
};
/**
* This function resets the tree states.
*/
@@ -64,11 +67,39 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
if (!treeContext?.root?.id) {
return;
}
treeContext?.setRoot(null);
setInitialOpenState(undefined);
}, [treeContext]);
const selectRoot = useCallback(() => {
if (treeContext?.root) {
treeContext.treeData.setSelectedNode(treeContext.root);
}
}, [treeContext]);
const navigateToRoot = useCallback(() => {
const id = treeContext?.root?.id;
if (id) {
router.push(`/docs/${id}`);
}
}, [router, treeContext?.root?.id]);
const handleRootFocus = useCallback(() => {
selectRoot();
}, [selectRoot]);
// activate root document with enter or space
const handleRootKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectRoot();
navigateToRoot();
}
},
[selectRoot, navigateToRoot],
);
/**
* This effect is used to reset the tree when a new document
* that is not part of the current tree is loaded.
@@ -77,7 +108,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
if (!treeContext?.root?.id) {
return;
}
const index = findIndexInTree(treeContext.treeData.nodes, currentDoc.id);
if (index === -1 && currentDoc.id !== treeContext.root?.id) {
resetStateTree();
@@ -92,7 +122,6 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
return () => {
resetStateTree();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -144,15 +173,23 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
return null;
}
const rootIsSelected =
treeContext.treeData.selectedNode?.id === treeContext.root.id;
return (
<Box
ref={setTreeRoot}
data-testid="doc-tree"
$height="100%"
role="tree"
aria-label={t('Document tree')}
$css={css`
/* Remove outline from TreeViewItem wrapper elements */
.c__tree-view--row {
outline: none !important;
&:focus-visible {
outline: none !important;
}
}
.c__tree-view--container {
z-index: 1;
margin-top: -10px;
@@ -171,6 +208,12 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
>
<Box
data-testid="doc-tree-root-item"
role="treeitem"
aria-label={`${t('Root document {{title}}', { title: treeContext.root?.title || t('Untitled document') })}`}
aria-selected={rootIsSelected}
tabIndex={0}
onFocus={handleRootFocus}
onKeyDown={handleRootKeyDown}
$css={css`
padding: ${spacingsTokens['2xs']};
border-radius: 4px;
@@ -183,6 +226,12 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
background-color: var(--c--theme--colors--greyscale-100);
}
&:focus-visible {
outline: none !important;
box-shadow: 0 0 0 2px var(--c--theme--colors--primary-500) !important;
border-radius: 4px;
}
.doc-tree-root-item-actions {
display: 'flex';
opacity: ${rootActionsOpen ? '1' : '0'};
@@ -191,7 +240,8 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
opacity: 1;
}
}
&:hover {
&:hover,
&:focus-within {
.doc-tree-root-item-actions {
opacity: 1;
}
@@ -211,6 +261,8 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
);
router.push(`/docs/${treeContext?.root?.id}`);
}}
aria-label={`${t('Open root document')}: ${treeContext.root?.title || t('Untitled document')}`}
tabIndex={-1} // avoid double tabstop
>
<Box $direction="row" $align="center" $width="100%">
<SimpleDocItem doc={treeContext.root} showAccesses={true} />
@@ -0,0 +1,29 @@
import { useEffect } from 'react';
export const useKeyboardActivation = (
keys: string[],
enabled: boolean,
action: () => void,
capture = false,
selector: string,
) => {
useEffect(() => {
if (!enabled) {
return;
}
const onKeyDown = (e: KeyboardEvent) => {
if (keys.includes(e.key)) {
e.preventDefault();
action();
}
};
const treeEl = document.querySelector<HTMLElement>(selector);
if (!treeEl) {
return;
}
treeEl.addEventListener('keydown', onKeyDown, capture);
return () => {
treeEl.removeEventListener('keydown', onKeyDown, capture);
};
}, [keys, enabled, action, capture, selector]);
};
@@ -1,5 +1,6 @@
import { Tooltip, useModal } from '@openfun/cunningham-react';
import { DateTime } from 'luxon';
import { KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
@@ -33,6 +34,13 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
shareModal.open();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
(e.target as HTMLAnchorElement).click();
}
};
return (
<>
<Box
@@ -52,6 +60,9 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
}
`}
className="--docs--doc-grid-item"
aria-label={t('Open document: {{title}}', {
title: doc.title || t('Untitled document'),
})}
>
<Box
$flex={flexLeft}
@@ -68,6 +79,7 @@ export const DocsGridItem = ({ doc, dragMode = false }: DocsGridItemProps) => {
min-width: 0;
`}
href={`/docs/${doc.id}`}
onKeyDown={handleKeyDown}
>
<Box
data-testid={`docs-grid-name-${doc.id}`}
@@ -56,7 +56,12 @@ export const Footer = () => {
}
return (
<Box $position="relative" as="footer" className="--docs--footer">
<Box
$position="relative"
as="footer"
role="contentinfo"
className="--docs--footer"
>
<BlueStripe />
<Box $padding={{ top: 'large', horizontal: 'big', bottom: 'small' }}>
<Box
@@ -22,6 +22,7 @@ export const Header = () => {
return (
<Box
as="header"
role="banner"
$css={css`
position: fixed;
top: 0;
@@ -35,6 +35,7 @@ export function HomeContent() {
return (
<Box
as="main"
role="main"
className="--docs--home-content"
aria-label={t('Main content')}
>
@@ -34,6 +34,7 @@ export function MainLayout({
<LeftPanel />
<Box
as="main"
role="main"
aria-label={t('Main content')}
id={MAIN_LAYOUT_ID}
$align="center"
@@ -26,6 +26,7 @@ export function PageLayout({
<Header />
<Box
as="main"
role="main"
$width="100%"
$css="flex-grow:1;"
aria-label={t('Main content')}