mirror of
https://github.com/suitenumerique/docs.git
synced 2026-05-09 00:22:46 +02:00
Compare commits
8 Commits
v3.7.0
...
fix/tree-k
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fb0897ebe | ||
|
|
69e7235f75 | ||
|
|
942c90c29f | ||
|
|
c5f0142671 | ||
|
|
7f37d3bda4 | ||
|
|
7033d0ecf7 | ||
|
|
0dd6818e91 | ||
|
|
eb225fc86f |
10
CHANGELOG.md
10
CHANGELOG.md
@@ -8,6 +8,15 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- #1354
|
||||
- ✨fix tree keyboard toggle when children not yet loaded #1388
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(backend) duplicate sub docs as root for reader users
|
||||
|
||||
## [3.7.0] - 2025-09-12
|
||||
|
||||
@@ -71,6 +80,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
|
||||
|
||||
|
||||
18
README.md
18
README.md
@@ -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.
|
||||
|
||||
10
compose.yml
10
compose.yml
@@ -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,
|
||||
@@ -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,
|
||||
|
||||
@@ -252,6 +252,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', () => {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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,9 @@ 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 { useLoadChildrenOnOpen } from '../hooks/useLoadChildrenOnOpen';
|
||||
|
||||
import SubPageIcon from './../assets/sub-page-logo.svg';
|
||||
import { DocTreeItemActions } from './DocTreeItemActions';
|
||||
|
||||
@@ -38,7 +42,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 +54,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 +89,88 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
|
||||
}
|
||||
};
|
||||
|
||||
useKeyboardActivation(
|
||||
['Enter'],
|
||||
isActive && !menuOpen,
|
||||
handleActivate,
|
||||
true,
|
||||
'.c__tree-view',
|
||||
);
|
||||
|
||||
useLoadChildrenOnOpen(
|
||||
node.data.value.id,
|
||||
node.isOpen,
|
||||
treeContext?.treeData.handleLoadChildren,
|
||||
treeContext?.treeData.setChildren,
|
||||
(doc.children?.length ?? 0) > 0 || doc.childrenCount === 0,
|
||||
);
|
||||
|
||||
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 +196,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]);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Lazily loads children for a tree node the first time it is expanded.
|
||||
*/
|
||||
export const useLoadChildrenOnOpen = <T>(
|
||||
nodeId: string,
|
||||
isOpen: boolean,
|
||||
handleLoadChildren?: (id: string, signal: AbortSignal) => Promise<T[]>,
|
||||
setChildren?: (id: string, children: T[]) => void,
|
||||
isAlreadyLoaded: boolean = false,
|
||||
) => {
|
||||
const hasLoadedRef = useRef(false);
|
||||
|
||||
// Reset only if node changes AND it's not already loaded externally
|
||||
useEffect(() => {
|
||||
hasLoadedRef.current = isAlreadyLoaded;
|
||||
}, [nodeId, isAlreadyLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
if (isAlreadyLoaded || hasLoadedRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!handleLoadChildren || !setChildren) {
|
||||
return;
|
||||
}
|
||||
|
||||
const abortCtrl = new AbortController();
|
||||
hasLoadedRef.current = true; // prevent multiple fetches
|
||||
|
||||
void handleLoadChildren(nodeId, abortCtrl.signal)
|
||||
.then((children) => {
|
||||
if (!abortCtrl.signal.aborted) {
|
||||
setChildren(nodeId, children);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// allow retry on next open
|
||||
if (!abortCtrl.signal.aborted) {
|
||||
hasLoadedRef.current = false;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
abortCtrl.abort();
|
||||
};
|
||||
}, [isOpen, nodeId, handleLoadChildren, setChildren, isAlreadyLoaded]);
|
||||
};
|
||||
Reference in New Issue
Block a user