Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e39d17914 | |||
| ecd2f97cf5 | |||
| 90624e83f5 | |||
| 5fc002658c | |||
| dfd5dc1545 | |||
| 69e7235f75 | |||
| 942c90c29f | |||
| c5f0142671 | |||
| 7f37d3bda4 | |||
| 7033d0ecf7 | |||
| 0dd6818e91 | |||
| eb225fc86f |
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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": []
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
·
|
||||
|
||||
+1
-52
@@ -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
|
||||
|
||||
+4
@@ -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');
|
||||
|
||||
+66
-34
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user