Compare commits

...

5 Commits

Author SHA1 Message Date
charles c0ecf53fbf fixup! 🚨(backend) fix search without refresh token
Signed-off-by: charles <charles.englebert@protonmail.com>
2026-03-20 11:57:10 +01:00
charles b4eb54305a 🚨(backend) fix search without refresh token
i remove the decorator on the search function
and refresh the token in the function so
i can try catch it.

Signed-off-by: charles <charles.englebert@protonmail.com>
2026-03-19 19:21:23 +01:00
Anthony LC 9991820cb1 🔊(CHANGELOG) fix entries changelog
The changelog was not updated correctly.
By not updating correctly, the changelog was not
showing the correct entries for the release,
leading to a patch release instead of a minor
release.
2026-03-19 13:36:48 +01:00
Anthony LC 2801ece358 ️(frontend) change aria-label for help menu button
The help menu button's aria-label was
previously "Open onboarding menu", which was not
accurate and could be confusing for screen reader
users. This commit updates the aria-label to
"Open help menu" to better reflect the button's
purpose and improve accessibility.
2026-03-19 13:31:03 +01:00
Anthony LC 0b37996899 💫(frontend) fix the help button to the bottom in tree
The tree take a bit of time to load, during this
time the help button was not at the bottom of
the left panel. To fix this issue, we addded a
skeleton for the tree in wait for the tree to
load, by doing this, the help button
is always at the bottom.
2026-03-19 13:28:22 +01:00
17 changed files with 351 additions and 157 deletions
+18 -5
View File
@@ -6,12 +6,24 @@ and this project adheres to
## [Unreleased]
### Changed
- 💫(frontend) fix the help button to the bottom in tree #2073
## [v4.8.2] - 2026-03-19
### Added
- ✨(backend) add resource server api #1923
- ✨(frontend) activate Find search #1834
- ✨ handle searching on subdocuments #1834
- ✨(backend) add search feature flags #1897
### Changed
- ♿️(frontend) ensure doc title is h1 for accessibility #2006
- ♿️(frontend) add nb accesses in share button aria-label #2017
- ✨(backend) improve fallback logic on search endpoint #1834
### Fixed
@@ -24,6 +36,12 @@ and this project adheres to
- ♿️(frontend) fix waffle aria-label spacing for new-window links #2030
- 🐛(backend) stop using add_sibling method to create sandbox document #2084
- 🐛(backend) duplicate a document as last-sibling #2084
- 🐛(backend) fix search without refresh token #2090
### Removed
- 🔥(api) remove `documents/<document_id>/descendants/` endpoint #1834
- 🔥(api) remove pagination on `documents/search/` endpoint #1834
## [v4.8.1] - 2026-03-17
@@ -41,7 +59,6 @@ and this project adheres to
- ✨(backend) add a is_first_connection flag to the User model #1938
- ✨(frontend) add onboarding modal with help menu button #1868
- ✨(backend) add resource server api #1923
### Changed
@@ -127,16 +144,12 @@ and this project adheres to
- ✨(frontend) Add stat for Crisp #1824
- ✨(auth) add silent login #1690
- 🔧(project) add DJANGO_EMAIL_URL_APP environment variable #1825
- ✨(frontend) activate Find search #1834
- ✨ handle searching on subdocuments #1834
- ✨(backend) add search feature flags #1897
### Changed
- ♿(frontend) improve accessibility:
- ♿️(frontend) fix subdoc opening and emoji pick focus #1745
- ✨(backend) add field for button label in email template #1817
- ✨(backend) improve fallback logic on search endpoint #1834
### Fixed
+31
View File
@@ -8,6 +8,9 @@ from django.core.cache import cache
from django.core.files.storage import default_storage
import botocore
import requests
from lasuite.oidc_login.backends import get_oidc_refresh_token, store_tokens
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.throttling import BaseThrottle
@@ -179,3 +182,31 @@ class AIUserRateThrottle(AIBaseRateThrottle):
if x_forwarded_for
else request.META.get("REMOTE_ADDR")
)
def refresh_access_token(session):
"""Refresh the OIDC access token using the refresh token."""
refresh_token = get_oidc_refresh_token(session)
if not refresh_token:
raise AuthenticationFailed({"error": "Refresh token is missing from session"})
response = requests.post(
settings.OIDC_OP_TOKEN_ENDPOINT,
data={
"grant_type": "refresh_token",
"client_id": settings.OIDC_RP_CLIENT_ID,
"client_secret": settings.OIDC_RP_CLIENT_SECRET,
"refresh_token": refresh_token,
},
timeout=settings.OIDC_TIMEOUT,
)
response.raise_for_status()
token_info = response.json()
store_tokens(
session,
access_token=token_info.get("access_token"),
id_token=None,
refresh_token=token_info.get("refresh_token"),
)
return session
+8 -5
View File
@@ -25,7 +25,6 @@ from django.db.models.functions import Greatest, Left, Length
from django.http import Http404, StreamingHttpResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.utils.http import content_disposition_header
from django.utils.text import capfirst, slugify
@@ -38,11 +37,11 @@ from botocore.exceptions import ClientError
from csp.constants import NONE
from csp.decorators import csp_update
from lasuite.malware_detection import malware_detection
from lasuite.oidc_login.decorators import refresh_oidc_access_token
from lasuite.tools.email import get_domain_from_email
from pydantic import ValidationError as PydanticValidationError
from rest_framework import filters, status, viewsets
from rest_framework import response as drf_response
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.permissions import AllowAny
from rest_framework.views import APIView
@@ -84,6 +83,7 @@ from .throttling import (
UserListThrottleBurst,
UserListThrottleSustained,
)
from .utils import refresh_access_token
logger = logging.getLogger(__name__)
@@ -1415,7 +1415,6 @@ class DocumentViewSet(
return duplicated_document
@drf.decorators.action(detail=False, methods=["get"], url_path="search")
@method_decorator(refresh_oidc_access_token)
def search(self, request, *args, **kwargs):
"""
Returns an ordered list of documents best matching the search query parameter 'q'.
@@ -1439,8 +1438,11 @@ class DocumentViewSet(
return self._search_with_indexer(
indexer, request, params=params, search_type=search_type
)
except requests.exceptions.RequestException as e:
logger.error("Error while searching documents with indexer: %s", e)
except (requests.exceptions.RequestException, AuthenticationFailed) as e:
logger.error(
"Error while searching documents with indexer \n%s \nfall back on title search",
e,
)
# fallback on title search if the indexer is not reached
return self._title_search(request, params.validated_data, *args, **kwargs)
@@ -1462,6 +1464,7 @@ class DocumentViewSet(
"""
Returns a list of documents matching the query (q) according to the configured indexer.
"""
request.session = refresh_access_token(request.session)
queryset = models.Document.objects.all()
results = indexer.search(
+27
View File
@@ -7,6 +7,8 @@ from django.core.cache import cache
import pytest
import responses
from cryptography.fernet import Fernet
from lasuite.oidc_login.backends import get_cipher_suite
from core import factories
from core.tests.utils.urls import reload_urls
@@ -143,3 +145,28 @@ def user_token():
A fixture to create a user token for testing.
"""
return build_authorization_bearer("some_token")
@pytest.fixture
def oidc_settings(settings):
"""Fixture to configure OIDC settings for the tests."""
settings.OIDC_OP_TOKEN_ENDPOINT = "https://auth.example.com/token"
settings.OIDC_OP_AUTHORIZATION_ENDPOINT = "https://auth.example.com/authorize"
settings.OIDC_RP_CLIENT_ID = "client_id"
settings.OIDC_RP_CLIENT_SECRET = "client_secret"
settings.OIDC_AUTHENTICATION_CALLBACK_URL = "oidc_authentication_callback"
settings.OIDC_RP_SCOPES = "openid email"
settings.OIDC_USE_NONCE = True
settings.OIDC_STATE_SIZE = 32
settings.OIDC_NONCE_SIZE = 32
settings.OIDC_VERIFY_SSL = True
settings.OIDC_TOKEN_USE_BASIC_AUTH = False
settings.OIDC_STORE_ACCESS_TOKEN = True
settings.OIDC_STORE_REFRESH_TOKEN = True
settings.OIDC_STORE_REFRESH_TOKEN_KEY = Fernet.generate_key()
get_cipher_suite.cache_clear()
yield settings
get_cipher_suite.cache_clear()
@@ -3,6 +3,7 @@ Tests for Documents API endpoint in impress's core app: search
"""
from unittest import mock
from unittest.mock import patch
import pytest
import responses
@@ -12,7 +13,7 @@ from rest_framework.test import APIClient
from waffle.testutils import override_flag
from core import factories
from core.enums import FeatureFlag, SearchType
from core.enums import FeatureFlag
from core.services.search_indexers import get_document_indexer
fake = Faker()
@@ -26,47 +27,29 @@ def enable_flag_find_hybrid_search():
yield
@mock.patch("core.services.search_indexers.FindDocumentIndexer.search_query")
@mock.patch("core.api.viewsets.DocumentViewSet.list")
@responses.activate
def test_api_documents_search_anonymous(search_query, indexer_settings):
def test_api_documents_search_anonymous(mock_list, indexer_settings):
"""
Anonymous users should be allowed to search documents with Find.
Anonymous users should not be allowed to search documents with Find.
they should fall back on title search.
"""
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
# mock Find response
responses.add(
responses.POST,
"http://find/api/v1.0/search",
json=[],
status=200,
)
mocked_response = {
"count": 0,
"next": None,
"previous": None,
"results": [{"title": "mocked list result"}],
}
mock_list.return_value = drf_response.Response(mocked_response)
q = "alpha"
response = APIClient().get("/api/v1.0/documents/search/", data={"q": q})
assert search_query.call_count == 1
assert search_query.call_args[1] == {
"data": {
"q": q,
"visited": [],
"services": ["docs"],
"nb_results": 50,
"order_by": "updated_at",
"order_direction": "desc",
"path": None,
"search_type": SearchType.HYBRID,
},
"token": None,
}
assert response.status_code == 200
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
}
assert mock_list.call_count == 1
assert mock_list.call_args[0][0].GET.get("q") == q
assert response.json() == mocked_response
@mock.patch("core.api.viewsets.DocumentViewSet.list")
@@ -194,8 +177,13 @@ def test_api_documents_search_invalid_params(indexer_settings):
@responses.activate
def test_api_documents_search_success(indexer_settings):
@patch("core.api.viewsets.refresh_access_token")
def test_api_documents_search_success(
mocked_refresh_access_token, indexer_settings, oidc_settings, settings
): # pylint: disable=unused-argument
"""Validate the format of documents as returned by the search view."""
mocked_refresh_access_token.side_effect = lambda session: session
indexer_settings.SEARCH_URL = "http://find/api/v1.0/search"
assert get_document_indexer() is not None
@@ -204,7 +192,7 @@ def test_api_documents_search_success(indexer_settings):
# Find response
responses.add(
responses.POST,
"http://find/api/v1.0/search",
indexer_settings.SEARCH_URL,
json=[
{
"_id": str(document["id"]),
@@ -213,7 +201,11 @@ def test_api_documents_search_success(indexer_settings):
],
status=200,
)
response = APIClient().get("/api/v1.0/documents/search/", data={"q": "alpha"})
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
content = response.json()
@@ -11,6 +11,7 @@ import responses
from rest_framework.test import APIClient
from waffle.testutils import override_flag
from core import factories
from core.enums import FeatureFlag, SearchType
from core.services.search_indexers import get_document_indexer
@@ -18,6 +19,7 @@ pytestmark = pytest.mark.django_db
@responses.activate
@mock.patch("core.api.viewsets.refresh_access_token")
@mock.patch("core.api.viewsets.DocumentViewSet._title_search")
@mock.patch("core.api.viewsets.DocumentViewSet._search_with_indexer")
@pytest.mark.parametrize(
@@ -44,12 +46,14 @@ pytestmark = pytest.mark.django_db
def test_api_documents_search_success( # noqa : PLR0913
mock_search_with_indexer,
mock_title_search,
mocked_refresh_access_token,
activated_flags,
expected_search_type,
expected_search_with_indexer_called,
expected_title_search_called,
indexer_settings,
):
oidc_settings,
): # pylint: disable=unused-argument
"""
Test that the API endpoint for searching documents returns a successful response
with the expected search type according to the activated feature flags,
@@ -59,7 +63,11 @@ def test_api_documents_search_success( # noqa : PLR0913
mock_search_with_indexer.return_value = HttpResponse()
mock_title_search.return_value = HttpResponse()
mocked_refresh_access_token.side_effect = lambda session: session
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
with override_flag(
FeatureFlag.FLAG_FIND_HYBRID_SEARCH,
active=FeatureFlag.FLAG_FIND_HYBRID_SEARCH in activated_flags,
@@ -68,9 +76,7 @@ def test_api_documents_search_success( # noqa : PLR0913
FeatureFlag.FLAG_FIND_FULL_TEXT_SEARCH,
active=FeatureFlag.FLAG_FIND_FULL_TEXT_SEARCH in activated_flags,
):
response = APIClient().get(
"/api/v1.0/documents/search/", data={"q": "alpha"}
)
response = client.get("/api/v1.0/documents/search/", data={"q": "alpha"})
assert response.status_code == 200
@@ -0,0 +1,82 @@
"""Unit tests for the refresh_access_token utility function."""
import pytest
import responses
from cryptography.fernet import Fernet
from lasuite.oidc_login.backends import get_oidc_refresh_token, store_tokens
from requests import HTTPError
from rest_framework.exceptions import AuthenticationFailed
from core.api.utils import refresh_access_token
pytestmark = pytest.mark.django_db
@pytest.fixture(name="mock_oidc_settings")
def mock_oidc_settings_fixture(settings):
"""Fixture to mock OIDC settings."""
settings.OIDC_OP_TOKEN_ENDPOINT = "https://example.com/token"
settings.OIDC_RP_CLIENT_ID = "test-client-id"
settings.OIDC_RP_CLIENT_SECRET = "test-client-secret"
settings.OIDC_STORE_REFRESH_TOKEN = True
settings.OIDC_STORE_REFRESH_TOKEN_KEY = Fernet.generate_key()
yield settings
@responses.activate
def test_refresh_access_token_success(mock_oidc_settings): # pylint: disable=unused-argument
"""Test successful token refresh."""
session = {}
store_tokens(
session,
access_token="old-access-token",
id_token=None,
refresh_token="valid-refresh-token",
)
responses.add(
responses.POST,
"https://example.com/token",
json={
"access_token": "new-access-token",
"refresh_token": "new-refresh-token",
},
status=200,
)
result = refresh_access_token(session)
assert result == session
assert get_oidc_refresh_token(session) == "new-refresh-token"
def test_refresh_access_token_missing_refresh_token(mock_oidc_settings): # pylint: disable=unused-argument
"""Test that AuthenticationFailed is raised when refresh token is missing."""
session = {}
with pytest.raises(AuthenticationFailed) as exc_info:
refresh_access_token(session)
assert exc_info.value.detail == {"error": "Refresh token is missing from session"}
@responses.activate
def test_refresh_access_token_http_error(mock_oidc_settings): # pylint: disable=unused-argument
"""Test that HTTP errors are propagated when token endpoint fails."""
session = {}
store_tokens(
session,
access_token="old-access-token",
id_token=None,
refresh_token="valid-refresh-token",
)
responses.add(
responses.POST,
"https://example.com/token",
json={"error": "invalid_grant"},
status=401,
)
with pytest.raises(HTTPError):
refresh_access_token(session)
@@ -299,7 +299,7 @@ test.describe('Doc Tree', () => {
await page.keyboard.press('Tab');
await expect(page.getByLabel('Open onboarding menu')).toBeFocused();
await expect(page.getByLabel('Open help menu')).toBeFocused();
await page.keyboard.press('Tab');
@@ -309,7 +309,7 @@ test.describe('Doc Tree', () => {
await page.keyboard.press('Shift+Tab');
await expect(page.getByLabel('Open onboarding menu')).toBeFocused();
await expect(page.getByLabel('Open help menu')).toBeFocused();
await page.keyboard.press('Shift+Tab');
@@ -27,7 +27,7 @@ test.describe('Help feature', () => {
await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Open onboarding menu' }),
page.getByRole('button', { name: 'Open help menu' }),
).toBeHidden();
});
@@ -43,7 +43,7 @@ test.describe('Help feature', () => {
},
});
await page.getByRole('button', { name: 'Open onboarding menu' }).click();
await page.getByRole('button', { name: 'Open help menu' }).click();
await getMenuItem(page, 'Onboarding').click();
@@ -87,7 +87,7 @@ test.describe('Help feature', () => {
});
test('closes modal with Skip button', async ({ page }) => {
await page.getByRole('button', { name: 'Open onboarding menu' }).click();
await page.getByRole('button', { name: 'Open help menu' }).click();
await getMenuItem(page, 'Onboarding').click();
const modal = page.getByTestId('onboarding-modal');
@@ -107,9 +107,7 @@ test.describe('Help feature', () => {
// switch to french
await waitForLanguageSwitch(page, TestLanguage.French);
await page
.getByRole('button', { name: "Ouvrir le menu d'embarquement" })
.click();
await page.getByRole('button', { name: "Ouvrir le menu d'aide" }).click();
await getMenuItem(page, 'Premiers pas').click();
@@ -18,6 +18,7 @@ import {
useMoveDoc,
useTrans,
} from '@/docs/doc-management';
import { TreeSkeleton } from '@/features/skeletons/components/TreeSkeleton';
import { CLASS_DOC_TITLE } from '../../doc-header';
import { KEY_DOC_TREE, useDocTree } from '../api/useDocTree';
@@ -257,7 +258,7 @@ export const DocTree = ({ currentDoc }: DocTreeProps) => {
}, [currentDoc, treeContext]);
if (!treeContext || !treeContext.root) {
return null;
return <TreeSkeleton />;
}
return (
@@ -60,7 +60,7 @@ export const HelpMenu = ({
>
<Box $direction="row" $align="center">
<Button
aria-label={t('Open onboarding menu')}
aria-label={t('Open help menu')}
color={colorButton || 'neutral'}
variant="tertiary"
iconPosition="left"
@@ -2,7 +2,6 @@ import { useRouter } from 'next/router';
import { css } from 'styled-components';
import { Box, SeparatedSection } from '@/components';
import { useDocStore } from '@/docs/doc-management';
import { LeftPanelTargetFilters } from './LefPanelTargetFilters';
import { LeftPanelDocContent } from './LeftPanelDocContent';
@@ -12,33 +11,35 @@ export const LeftPanelContent = () => {
const router = useRouter();
const isHome = router.pathname === '/';
const isDoc = router.pathname === '/docs/[id]';
const { currentDoc } = useDocStore();
return (
<>
{isHome && (
<>
<Box
$width="100%"
$css={css`
flex: 0 0 auto;
`}
className="--docs--home-left-panel-content"
>
<SeparatedSection showSeparator={false}>
<LeftPanelTargetFilters />
</SeparatedSection>
</Box>
<Box
$flex={1}
$width="100%"
$css="overflow-y: auto; overflow-x: hidden;"
>
<LeftPanelFavorites />
</Box>
</>
)}
{isDoc && currentDoc && <LeftPanelDocContent doc={currentDoc} />}
</>
);
if (isHome) {
return (
<>
<Box
$width="100%"
$css={css`
flex: 0 0 auto;
`}
className="--docs--home-left-panel-content"
>
<SeparatedSection showSeparator={false}>
<LeftPanelTargetFilters />
</SeparatedSection>
</Box>
<Box
$flex={1}
$width="100%"
$css="overflow-y: auto; overflow-x: hidden;"
>
<LeftPanelFavorites />
</Box>
</>
);
}
if (isDoc) {
return <LeftPanelDocContent />;
}
return null;
};
@@ -1,15 +1,13 @@
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { Box } from '@/components';
import { Doc } from '@/docs/doc-management';
import { Doc, useDocStore } from '@/docs/doc-management';
import { DocTree } from '@/docs/doc-tree/';
import { TreeSkeleton } from '@/features/skeletons/components/TreeSkeleton';
export const LeftPanelDocContent = ({ doc }: { doc: Doc }) => {
export const LeftPanelDocContent = () => {
const tree = useTreeContext<Doc>();
if (!tree) {
return null;
}
const { currentDoc } = useDocStore();
return (
<Box
@@ -18,7 +16,11 @@ export const LeftPanelDocContent = ({ doc }: { doc: Doc }) => {
$css="width: 100%; overflow-y: auto; overflow-x: hidden;"
className="--docs--left-panel-doc-content"
>
<DocTree currentDoc={doc} />
{tree && currentDoc ? (
<DocTree currentDoc={currentDoc} />
) : (
<TreeSkeleton />
)}
</Box>
);
};
@@ -1,69 +1,12 @@
import { css, keyframes } from 'styled-components';
import { Box, BoxType } from '@/components';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useResponsiveStore } from '@/stores';
const shimmer = keyframes`
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
`;
type SkeletonLineProps = Partial<BoxType>;
type SkeletonCircleProps = Partial<BoxType>;
import { SkeletonCircle, SkeletonLine } from './SkeletionUI';
export const DocEditorSkeleton = () => {
const { isDesktop } = useResponsiveStore();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const SkeletonLine = ({ $css, ...props }: SkeletonLineProps) => {
return (
<Box
$width="100%"
$height="16px"
$css={css`
background: linear-gradient(
90deg,
${colorsTokens['black-050']} 0%,
${colorsTokens['black-100']} 50%,
${colorsTokens['black-050']} 100%
);
background-size: 1000px 100%;
animation: ${shimmer} 2s infinite linear;
border-radius: 4px;
${$css}
`}
{...props}
/>
);
};
const SkeletonCircle = ({ $css, ...props }: SkeletonCircleProps) => {
return (
<Box
$width="32px"
$height="32px"
$css={css`
background: linear-gradient(
90deg,
${colorsTokens['black-050']} 0%,
${colorsTokens['black-100']} 50%,
${colorsTokens['black-050']} 100%
);
background-size: 1000px 100%;
animation: ${shimmer} 2s infinite linear;
border-radius: 50%;
${$css}
`}
{...props}
/>
);
};
const { spacingsTokens } = useCunninghamTheme();
return (
<>
@@ -0,0 +1,65 @@
import { css, keyframes } from 'styled-components';
import { Box, BoxType } from '@/components/Box';
import { useCunninghamTheme } from '@/cunningham/useCunninghamTheme';
const shimmer = keyframes`
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
`;
type SkeletonLineProps = Partial<BoxType>;
type SkeletonCircleProps = Partial<BoxType>;
export const SkeletonLine = ({ $css, ...props }: SkeletonLineProps) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Box
$width="100%"
$height="16px"
$css={css`
background: linear-gradient(
90deg,
${colorsTokens['black-050']} 0%,
${colorsTokens['black-100']} 50%,
${colorsTokens['black-050']} 100%
);
background-size: 1000px 100%;
animation: ${shimmer} 2s infinite linear;
border-radius: 4px;
${$css}
`}
{...props}
/>
);
};
export const SkeletonCircle = ({ $css, ...props }: SkeletonCircleProps) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Box
$width="32px"
$height="32px"
$css={css`
background: linear-gradient(
90deg,
${colorsTokens['black-050']} 0%,
${colorsTokens['black-100']} 50%,
${colorsTokens['black-050']} 100%
);
background-size: 1000px 100%;
animation: ${shimmer} 2s infinite linear;
border-radius: 50%;
${$css}
`}
{...props}
/>
);
};
@@ -0,0 +1,30 @@
import { Box } from '@/components';
import { SkeletonLine } from './SkeletionUI';
export const TreeSkeleton = () => {
return (
<Box className="--docs--tree-skeleton">
<SkeletonLine
$width="92%"
$height="40px"
$margin={{ left: 'sm', top: 'sm' }}
/>
<SkeletonLine
$width="92%"
$height="30px"
$margin={{ left: 'sm', top: 'sm' }}
/>
<SkeletonLine
$width="92%"
$height="30px"
$margin={{ left: 'sm', top: 'sm' }}
/>
<SkeletonLine
$width="92%"
$height="30px"
$margin={{ left: 'sm', top: 'sm' }}
/>
</Box>
);
};
@@ -905,7 +905,7 @@
"Open Source": "Open Source",
"Open document {{title}}": "Ouvrir le document {{title}}",
"Open document: {{title}}": "Ouvrir le document : {{title}}",
"Open onboarding menu": "Ouvrir le menu d'embarquement",
"Open help menu": "Ouvrir le menu d'aide",
"Open root document": "Ouvrir le document racine",
"Open the document options": "Ouvrir les options du document",
"Open the menu of actions for the document: {{title}}": "Ouvrir le menu des actions du document : {{title}}",