Compare commits

..

19 Commits

Author SHA1 Message Date
Anthony LC
ac86a4e7f7 🔖(minor) release 1.4.0
Added:
- (backend) Add link public/authenticated/restricted
access with read/editor roles
- (frontend) add copy link button
- 🛂(frontend) access public docs without being logged

Changed:
- ♻️(backend) Allow null titles on documents
for easier creation
- 🛂(backend) stop to list public doc to everyone
- 🚚(frontend) change visibility in share modal
- ️(frontend) Improve summary

Fixed:
- 🐛(backend) Fix forcing ID when creating a
document via API endpoint
- 🐛 Rebuild frontend dev container from makefile
2024-09-18 12:01:52 +02:00
Anthony LC
bbe5501297 🌐(frontend) translate last features
Translate:
- doc visibility
- doc table of contents
2024-09-18 11:18:29 +02:00
Anthony LC
b37acf3138 💄(frontend) improve ui table of contents
- keep correctly the text on the left side
- improve accuracy highlightment heading when scrolling
- display full heading text when text transform is applied
- fix typo
2024-09-18 11:18:29 +02:00
Anthony LC
5bd78b8068 🚚(frontend) rename feature summary to table of content
We rename the feature summary to table of content
to better reflect the feature purpose.
2024-09-17 15:06:37 +02:00
Anthony LC
ed39c01608 ♻️(frontent) improve summary feature
- Change Summary to Table of content
- No dash before the title
- Change font-size depend the type of heading
- If more than 2 headings the panel is open
by default
- improve sticky
- highligth the title where you are in the page
2024-09-17 15:06:37 +02:00
Anthony LC
748ebc8f26 🔧(helm) change conf helm dev
Some frontend env vars were added on the frontend
side, we need to add them to the dev helm chart.
2024-09-17 15:06:37 +02:00
renovate[bot]
03262878c4 ⬆️(dependencies) update js dependencies 2024-09-16 14:30:29 +02:00
Anthony LC
97fa5b8532 🧑‍💻(makefile) add build frontend dev
docker build frontend dev was lacking.
If dependencies were updated, the change were
not reflected in the frontend container.
2024-09-12 08:06:17 +02:00
Anthony LC
a092c2915b ♻️(frontend) adapt doc visibility to new api
We updated the way we handle the visibility of a doc
in the backend. Now we use a new api to update
the visibility (documents/{id}/link-configuration/)
of a doc. We adapted the frontend to use this new api.
We changed the types to reflect the new api and
to keep the same logic.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM
9b44e021fd ♻️(models) allow null titles on documents
We want to make it as fast as possible to create a new document.
We should not have any modal asking the title before creating the
document but rather show an "untitle document" title and let the
owner set it on the already created document.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM
2c3eef4dd9 (api) allow forcing ID when creating a document via API endpoint
We need to be able to force the ID when creating a document via
the API endpoint. This is usefull for documents that are created
offline as synchronization is achieved by replaying stacked requests.

We do it via the serializer, making sure that we don't override an
existing document.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM
dec1a1a870 🔥(api) remove possibility to force document id on creation
This feature poses security issues in the way it is implemented.
We decide to remove it while clarifying the use case.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM
1e432cfdc2 (api) allow updating link configuration for a document
We open a specific endpoint to update documents link configuration
because it makes it more secure and simple to limit access rights
to administrators/owners whereas other document fields like title
and content can be edited by anonymous or authenticated users with
much less access rights.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM
f5c4106547 🐛(api) fix randomly failing test on document list ordering via API
The test was randomly failing because postgresql and python sorting
was not 100% consistent e.g "treatment" vs "treat them" were not
ordered the same.

Comparing each field value insteat of relying on "sort" solves the
issue and makes the test simpler.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM
494638d306 (models/api) add link access reach and role
Link access was either public or private and was only allowing readers.

This commit makes link access more powerful:
- link reach can be private (users need to obtain specific access by
  document's administrators), restricted (any authenticated user) or
  public (anybody including anonymous users)
- link role can be reader or editor.

It is thus now possible to give editor access to an anonymous user or
any authenticated user.
2024-09-11 22:31:30 +02:00
Samuel Paccoud - DINUM
41260de1c3 🔥(compose) remove docker compose version
The version is now automatically guessed by Docker Compose. This
commit will fix the warning raised about the uselessness of this
setting.
2024-09-11 22:31:30 +02:00
Anthony LC
140a630a6e 🛂(backend) stop to list public doc to everyone
Everybody could see the full list of public docs.
Now only members can see their public docs.
They can still access to any specific public doc.
2024-09-11 22:31:30 +02:00
Anthony LC
b716881d50 📌(frontend) add lacking peerdependencies
- Add some lacking peerdependencies to the packages.
- Resolve conflicts eslint dependencies.
2024-09-11 11:39:03 +02:00
renovate[bot]
fa8466d44d ⬆️(dependencies) update js dependencies 2024-09-11 11:39:03 +02:00
36 changed files with 1059 additions and 700 deletions

View File

@@ -9,14 +9,26 @@ and this project adheres to
## [Unreleased]
## [1.4.0] - 2024-09-17
## Added
- ✨Add link public/authenticated/restricted access with read/editor roles #234
- ✨(frontend) add copy link button #235
- 🛂(frontend) access public docs without being logged #235
## Changed
- ♻️(backend) Allow null titles on documents for easier creation #234
- 🛂(backend) stop to list public doc to everyone #234
- 🚚(frontend) change visibility in share modal #235
- ⚡️(frontend) Improve summary #244
## Fixed
- 🐛(backend) Fix forcing ID when creating a document via API endpoint #234
- 🐛 Rebuild frontend dev container from makefile #248
## [1.3.0] - 2024-09-05
@@ -32,7 +44,6 @@ and this project adheres to
- 💄(frontend) code background darkened on editor #214
- 🔥(frontend) hide markdown button if not text #213
- 🛂(backend) stop to list public doc to everyone #234
## Fixed
@@ -142,10 +153,11 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.3.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.4.0...main
[1.4.0]: https://github.com/numerique-gouv/impress/releases/v1.4.0
[1.3.0]: https://github.com/numerique-gouv/impress/releases/v1.3.0
[1.2.1]: https://github.com/numerique-gouv/impress/releases/v1.2.1
[1.2.0]: https://github.com/numerique-gouv/impress/releases/v1.2.0
[1.1.0]: https://github.com/numerique-gouv/impress/releases/v1.1.0
[1.0.0]: https://github.com/numerique-gouv/impress/releases/v1.0.0
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0
[0.1.0]: https://github.com/numerique-gouv/impress/releases/v0.1.0

View File

@@ -92,6 +92,7 @@ bootstrap: \
# -- Docker/compose
build: ## build the app-dev container
@$(COMPOSE) build app-dev --no-cache
@$(COMPOSE) build frontend-dev --no-cache
.PHONY: build
down: ## stop and remove containers, networks, images, and volumes

View File

@@ -7,8 +7,8 @@ from urllib.parse import urlparse
from django.conf import settings
from django.contrib.postgres.aggregates import ArrayAgg
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.db import IntegrityError
from django.db.models import (
OuterRef,
Q,
@@ -358,11 +358,11 @@ class DocumentViewSet(
try:
# Add a trace that the user visited the document (this is needed to include
# the document in the user's list view)
models.LinkTrace.objects.update_or_create(
models.LinkTrace.objects.create(
document=instance,
user=self.request.user,
)
except IntegrityError:
except ValidationError:
# The trace already exists, so we just pass without doing anything
pass
@@ -375,7 +375,7 @@ class DocumentViewSet(
to the document
"""
if not request.user.is_authenticated:
return drf_response.Response([])
raise exceptions.PermissionDenied("Authentication required.")
document = self.get_object()
user = request.user

View File

@@ -16,7 +16,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='document',
name='link_reach',
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='restricted', max_length=20),
field=models.CharField(choices=[('restricted', 'Restricted'), ('authenticated', 'Authenticated'), ('public', 'Public')], default='authenticated', max_length=20),
),
migrations.AddField(
model_name='document',

View File

@@ -324,7 +324,7 @@ class Document(BaseModel):
link_reach = models.CharField(
max_length=20,
choices=LinkReachChoices.choices,
default=LinkReachChoices.RESTRICTED,
default=LinkReachChoices.AUTHENTICATED,
)
link_role = models.CharField(
max_length=20, choices=LinkRoleChoices.choices, default=LinkRoleChoices.READER

View File

@@ -29,8 +29,8 @@ def test_api_document_versions_list_anonymous(role, reach):
response = APIClient().get(f"/api/v1.0/documents/{document.id!s}/versions/")
assert response.status_code == 200
assert response.json() == []
assert response.status_code == 403
assert response.json() == {"detail": "Authentication required."}
@pytest.mark.parametrize("reach", models.LinkReachChoices.values)

View File

@@ -5,7 +5,7 @@ Tests for Documents API endpoint in impress's core app: retrieve
import pytest
from rest_framework.test import APIClient
from core import factories
from core import factories, models
from core.api import serializers
pytestmark = pytest.mark.django_db
@@ -94,6 +94,38 @@ def test_api_documents_retrieve_authenticated_unrelated_public_or_authenticated(
"created_at": document.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": document.updated_at.isoformat().replace("+00:00", "Z"),
}
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
)
@pytest.mark.parametrize("reach", ["public", "authenticated"])
def test_api_documents_retrieve_authenticated_trace_twice(reach):
"""
Accessing a document several times should not raise any error even though the
trace already exists for this document and user.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
document = factories.DocumentFactory(link_reach=reach)
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is False
)
client.get(
f"/api/v1.0/documents/{document.id!s}/",
)
assert (
models.LinkTrace.objects.filter(document=document, user=user).exists() is True
)
# A second visit should not raise any error
response = client.get(f"/api/v1.0/documents/{document.id!s}/")
assert response.status_code == 200
def test_api_documents_retrieve_authenticated_unrelated_restricted():

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.3.0"
version = "1.4.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",

View File

@@ -1,64 +0,0 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Summary', () => {
test('it checks the doc summary', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-summary', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Summary',
})
.click();
const panel = page.getByLabel('Document panel');
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 1').click();
await page.keyboard.type('Hello World');
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 6; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 2').click();
await page.keyboard.type('Super World');
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 4; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 3').click();
await page.keyboard.type('Another World');
await expect(panel.getByText('Hello World')).toBeVisible();
await expect(panel.getByText('Super World')).toBeVisible();
await panel.getByText('Another World').click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await panel.getByText('Back to top').click();
await expect(editor.getByText('Hello World')).toBeInViewport();
await panel.getByText('Go to bottom').click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
});
});

View File

@@ -0,0 +1,95 @@
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Table Content', () => {
test('it checks the doc table content', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(
page,
'doc-table-content',
browserName,
1,
);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
.getByRole('button', {
name: 'Table of contents',
})
.click();
const panel = page.getByLabel('Document panel');
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 1').click();
await page.keyboard.type('Hello World');
await editor.getByText('Hello').dblclick();
await page.getByRole('button', { name: 'Strike' }).click();
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 2').click();
await page.keyboard.type('Super World', { delay: 100 });
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Enter');
}
await editor.locator('.bn-block-outer').last().fill('/');
await page.getByText('Heading 3').click();
await page.keyboard.type('Another World');
const hello = panel.getByText('Hello World');
const superW = panel.getByText('Super World');
const another = panel.getByText('Another World');
await expect(hello).toBeVisible();
await expect(hello).toHaveCSS('font-size', /19/);
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toBeVisible();
await expect(superW).toHaveCSS('font-size', /16/);
await expect(superW).toHaveAttribute('aria-selected', 'false');
await expect(another).toBeVisible();
await expect(another).toHaveCSS('font-size', /12/);
await expect(another).toHaveAttribute('aria-selected', 'false');
await hello.click();
await expect(editor.getByText('Hello World')).toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toHaveAttribute('aria-selected', 'false');
await another.click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'false');
await expect(superW).toHaveAttribute('aria-selected', 'true');
await panel.getByText('Back to top').click();
await expect(editor.getByText('Hello World')).toBeInViewport();
await expect(hello).toHaveAttribute('aria-selected', 'true');
await expect(superW).toHaveAttribute('aria-selected', 'false');
await panel.getByText('Go to bottom').click();
await expect(editor.getByText('Hello World')).not.toBeInViewport();
await expect(superW).toHaveAttribute('aria-selected', 'true');
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.3.0",
"version": "1.4.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -12,7 +12,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.46.1",
"@playwright/test": "1.47.1",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",

View File

@@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information.

View File

@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "1.3.0",
"version": "1.4.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -21,33 +21,34 @@
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.13.5",
"@openfun/cunningham-react": "2.9.4",
"@tanstack/react-query": "5.53.2",
"i18next": "23.14.0",
"@tanstack/react-query": "5.56.2",
"i18next": "23.15.1",
"idb": "8.0.0",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "14.2.7",
"next": "14.2.11",
"react": "*",
"react-aria-components": "1.3.3",
"react-dom": "*",
"react-i18next": "15.0.1",
"react-i18next": "15.0.2",
"react-select": "5.8.0",
"styled-components": "6.1.13",
"y-protocols": "1.0.6",
"yjs": "*",
"zustand": "4.5.5"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.53.2",
"@tanstack/react-query-devtools": "5.56.2",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.5.0",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.12",
"@types/jest": "29.5.13",
"@types/lodash": "4.17.7",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.3.5",
"@types/react": "18.3.6",
"@types/react-dom": "*",
"cross-env": "*",
"dotenv": "16.4.5",
@@ -61,6 +62,7 @@
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.2",
"typescript": "*",
"webpack": "5.94.0",
"workbox-webpack-plugin": "7.1.0"
}
}

View File

@@ -5,7 +5,7 @@ import { Box, Card, IconBG, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
interface PanelProps {
title: string;
title?: string;
setIsPanelOpen: (isOpen: boolean) => void;
}
@@ -36,10 +36,10 @@ export const Panel = ({
$width="100%"
$maxWidth="20rem"
$position="sticky"
$maxHeight="96vh"
$maxHeight="99vh"
$height="100%"
$css={`
top: 2vh;
top: 0vh;
transition: ${transition};
${
!isOpen &&
@@ -53,11 +53,14 @@ export const Panel = ({
{...closedOverridingStyles}
>
<Box
$overflow="hidden"
$overflow="inherit"
$position="sticky"
$css={`
top: 0;
opacity: ${isOpen ? '1' : '0'};
transition: ${transition};
`}
$maxHeight="100%"
>
<Box
$padding={{ all: 'small' }}
@@ -90,9 +93,11 @@ export const Panel = ({
}}
$radius="2px"
/>
<Text $weight="bold" $size="l" $theme="primary">
{title}
</Text>
{title && (
<Text $weight="bold" $size="l" $theme="primary">
{title}
</Text>
)}
</Box>
{children}
</Box>

View File

@@ -9,7 +9,7 @@ import { Panel } from '@/components/Panel';
import { useCunninghamTheme } from '@/cunningham';
import { DocHeader } from '@/features/docs/doc-header';
import { Doc } from '@/features/docs/doc-management';
import { Summary, useDocSummaryStore } from '@/features/docs/doc-summary';
import { TableContent } from '@/features/docs/doc-table-content';
import {
VersionList,
Versions,
@@ -28,8 +28,6 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
query: { versionId },
} = useRouter();
const { isPanelVersionOpen, setIsPanelVersionOpen } = useDocVersionStore();
const { isPanelSummaryOpen, setIsPanelSummaryOpen } = useDocSummaryStore();
const { t } = useTranslation();
const isVersion = versionId && typeof versionId === 'string';
@@ -72,11 +70,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
<VersionList doc={doc} />
</Panel>
)}
{isPanelSummaryOpen && (
<Panel title={t('SUMMARY')} setIsPanelOpen={setIsPanelSummaryOpen}>
<Summary doc={doc} />
</Panel>
)}
<TableContent doc={doc} />
</Box>
</>
);

View File

@@ -9,7 +9,7 @@ import {
ModalShare,
ModalUpdateDoc,
} from '@/features/docs/doc-management';
import { useDocSummaryStore } from '@/features/docs/doc-summary';
import { useDocTableContentStore } from '@/features/docs/doc-table-content';
import { useDocVersionStore } from '@/features/docs/doc-versioning';
import { ModalPDF } from './ModalExport';
@@ -26,7 +26,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const [isModalPDFOpen, setIsModalPDFOpen] = useState(false);
const [isDropOpen, setIsDropOpen] = useState(false);
const { setIsPanelVersionOpen } = useDocVersionStore();
const { setIsPanelSummaryOpen } = useDocSummaryStore();
const { setIsPanelTableContentOpen } = useDocTableContentStore();
return (
<Box
@@ -83,7 +83,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
)}
<Button
onClick={() => {
setIsPanelSummaryOpen(true);
setIsPanelTableContentOpen(true);
setIsPanelVersionOpen(false);
setIsDropOpen(false);
}}
@@ -91,7 +91,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
icon={<span className="material-icons">summarize</span>}
size="small"
>
<Text $theme="primary">{t('Summary')}</Text>
<Text $theme="primary">{t('Table of contents')}</Text>
</Button>
<Button
onClick={() => {

View File

@@ -1,104 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Text } from '@/components';
import { useDocStore } from '../../doc-editor';
import { Doc } from '../../doc-management';
import { useDocSummaryStore } from '../stores';
interface SummaryProps {
doc: Doc;
}
export const Summary = ({ doc }: SummaryProps) => {
const { docsStore } = useDocStore();
const { t } = useTranslation();
const editor = docsStore?.[doc.id]?.editor;
const headingFiltering = useCallback(
() => editor?.document.filter((block) => block.type === 'heading'),
[editor?.document],
);
const [headings, setHeadings] = useState(headingFiltering());
const { setIsPanelSummaryOpen } = useDocSummaryStore();
useEffect(() => {
return () => {
setIsPanelSummaryOpen(false);
};
}, [setIsPanelSummaryOpen]);
if (!editor) {
return null;
}
editor.onEditorContentChange(() => {
setHeadings(headingFiltering());
});
return (
<Box $overflow="auto" $padding="small">
{headings?.map((heading) => (
<BoxButton
key={heading.id}
onClick={() => {
editor.focus();
editor?.setTextCursorPosition(heading.id, 'end');
document
.querySelector(`[data-id="${heading.id}"]`)
?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}}
style={{ textAlign: 'left' }}
>
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
{heading.content?.[0]?.type === 'text' && heading.content?.[0]?.text
? `- ${heading.content[0].text}`
: ''}
</Text>
</BoxButton>
))}
<Box
$height="1px"
$width="auto"
$background="#e5e5e5"
$margin={{ vertical: 'small' }}
$css="flex: none;"
/>
<BoxButton
onClick={() => {
editor.focus();
document.querySelector(`.bn-editor`)?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}}
>
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
{t('Back to top')}
</Text>
</BoxButton>
<BoxButton
onClick={() => {
editor.focus();
document
.querySelector(
`.bn-editor > .bn-block-group > .bn-block-outer:last-child`,
)
?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}}
>
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
{t('Go to bottom')}
</Text>
</BoxButton>
</Box>
);
};

View File

@@ -1 +0,0 @@
export * from './Summary';

View File

@@ -1 +0,0 @@
export * from './useDocSummaryStore';

View File

@@ -1,13 +0,0 @@
import { create } from 'zustand';
export interface UseDocSummaryStore {
isPanelSummaryOpen: boolean;
setIsPanelSummaryOpen: (isOpen: boolean) => void;
}
export const useDocSummaryStore = create<UseDocSummaryStore>((set) => ({
isPanelSummaryOpen: false,
setIsPanelSummaryOpen: (isPanelSummaryOpen) => {
set(() => ({ isPanelSummaryOpen }));
},
}));

View File

@@ -0,0 +1,67 @@
import { BlockNoteEditor } from '@blocknote/core';
import { useState } from 'react';
import { BoxButton, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
const sizeMap: { [key: number]: string } = {
1: '1.2rem',
2: '1rem',
3: '0.8rem',
};
export type HeadingsHighlight = {
headingId: string;
isVisible: boolean;
}[];
interface HeadingProps {
editor: BlockNoteEditor;
level: number;
text: string;
headingId: string;
isHighlight: boolean;
}
export const Heading = ({
headingId,
editor,
isHighlight,
level,
text,
}: HeadingProps) => {
const [isHover, setIsHover] = useState(isHighlight);
const { colorsTokens } = useCunninghamTheme();
return (
<BoxButton
key={headingId}
onMouseOver={() => setIsHover(true)}
onMouseLeave={() => setIsHover(false)}
onClick={() => {
editor.focus();
editor.setTextCursorPosition(headingId, 'end');
document.querySelector(`[data-id="${headingId}"]`)?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}}
$css="text-align: left;"
>
<Text
$theme="primary"
$padding={{ vertical: 'xtiny', left: 'tiny' }}
$size={sizeMap[level]}
$hasTransition
$css={
isHover || isHighlight
? `box-shadow: -2px 0px 0px ${colorsTokens()[isHighlight ? 'primary-500' : 'primary-400']};`
: ''
}
aria-selected={isHighlight}
>
{text}
</Text>
</BoxButton>
);
};

View File

@@ -0,0 +1,197 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Text } from '@/components';
import { Panel } from '@/components/Panel';
import { useDocStore } from '@/features/docs/doc-editor';
import { Doc } from '@/features/docs/doc-management';
import { useDocTableContentStore } from '../stores';
import { Heading } from './Heading';
const recursiveTextContent = (content: HeadingBlock['content']): string => {
if (!content) {
return '';
}
return content.reduce((acc, content) => {
if (content.type === 'text') {
return acc + content.text;
} else if (content.type === 'link') {
return acc + recursiveTextContent(content.content);
}
return acc;
}, '');
};
type HeadingBlock = {
id: string;
type: string;
text: string;
content: HeadingBlock[];
contentText: string;
props: {
level: number;
};
};
interface TableContentProps {
doc: Doc;
}
export const TableContent = ({ doc }: TableContentProps) => {
const { docsStore } = useDocStore();
const { t } = useTranslation();
const editor = docsStore?.[doc.id]?.editor;
const headingFiltering = useCallback(
() =>
editor?.document
.filter((block) => block.type === 'heading')
.map((block) => ({
...block,
contentText: recursiveTextContent(
block.content as unknown as HeadingBlock['content'],
),
})) as unknown as HeadingBlock[],
[editor?.document],
);
const [headings, setHeadings] = useState<HeadingBlock[]>();
const { setIsPanelTableContentOpen, isPanelTableContentOpen } =
useDocTableContentStore();
const [hasBeenClose, setHasBeenClose] = useState(false);
const setClosePanel = () => {
setHasBeenClose(true);
setIsPanelTableContentOpen(false);
};
const [headingIdHighlight, setHeadingIdHighlight] = useState<string>();
// Open the panel if there are more than 1 heading
useEffect(() => {
if (headings?.length && headings.length > 1 && !hasBeenClose) {
setIsPanelTableContentOpen(true);
}
}, [setIsPanelTableContentOpen, headings, hasBeenClose]);
// Close the panel unmount
useEffect(() => {
return () => {
setIsPanelTableContentOpen(false);
};
}, [setIsPanelTableContentOpen]);
// To highlight the first heading in the viewport
useEffect(() => {
const handleScroll = () => {
if (!headings) {
return;
}
for (const heading of headings) {
const elHeading = document.body.querySelector(
`.bn-block-outer[data-id="${heading.id}"] [data-content-type="heading"]:first-child`,
);
if (!elHeading) {
return;
}
const rect = elHeading.getBoundingClientRect();
const isVisible =
rect.top + rect.height >= 1 &&
rect.bottom <=
(window.innerHeight || document.documentElement.clientHeight);
if (isVisible) {
setHeadingIdHighlight(heading.id);
break;
}
}
};
window.addEventListener('scroll', () => {
setTimeout(() => {
handleScroll();
}, 300);
});
handleScroll();
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [headings, setHeadingIdHighlight]);
if (!editor) {
return null;
}
// Update the headings when the editor content changes
editor?.onEditorContentChange(() => {
setHeadings(headingFiltering());
});
if (!isPanelTableContentOpen) {
return null;
}
return (
<Panel setIsPanelOpen={setClosePanel}>
<Box $padding="small" $maxHeight="95%">
<Box $overflow="auto">
{headings?.map((heading) => (
<Heading
editor={editor}
headingId={heading.id}
level={heading.props.level}
text={heading.contentText}
key={heading.id}
isHighlight={headingIdHighlight === heading.id}
/>
))}
</Box>
<Box
$height="1px"
$width="auto"
$background="#e5e5e5"
$margin={{ vertical: 'small' }}
$css="flex: none;"
/>
<BoxButton
onClick={() => {
editor.focus();
document.querySelector(`.bn-editor`)?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}}
>
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
{t('Back to top')}
</Text>
</BoxButton>
<BoxButton
onClick={() => {
editor.focus();
document
.querySelector(
`.bn-editor > .bn-block-group > .bn-block-outer:last-child`,
)
?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}}
>
<Text $theme="primary" $padding={{ vertical: 'xtiny' }}>
{t('Go to bottom')}
</Text>
</BoxButton>
</Box>
</Panel>
);
};

View File

@@ -0,0 +1 @@
export * from './TableContent';

View File

@@ -0,0 +1 @@
export * from './useDocTableContentStore';

View File

@@ -0,0 +1,15 @@
import { create } from 'zustand';
export interface UseDocTableContentStore {
isPanelTableContentOpen: boolean;
setIsPanelTableContentOpen: (isOpen: boolean) => void;
}
export const useDocTableContentStore = create<UseDocTableContentStore>(
(set) => ({
isPanelTableContentOpen: false,
setIsPanelTableContentOpen: (isPanelTableContentOpen) => {
set(() => ({ isPanelTableContentOpen }));
},
}),
);

View File

@@ -20,6 +20,7 @@
"Content modal to delete document": "Contenu modal pour supprimer le document",
"Content modal to export the document": "Contenu modal pour exporter le document",
"Cookies placed": "Cookies déposés",
"Copy link": "Copier le lien",
"Copyright": "Copyright",
"Create a new document": "Créer un nouveau document",
"Create the document": "Créer le document",
@@ -29,9 +30,10 @@
"Defender of Rights - Free response - 71120 75342 Paris CEDEX 07": "Défenseur des droits - Réponse gratuite - 71120 75342 Paris CEDEX 07",
"Delete document": "Supprimer le document",
"Deleting the document \"{{title}}\"": "Suppression du document \"{{title}}\"",
"Doc visibility card": "Carte de visibilité du doc",
"Docs": "Docs",
"Docs Description": "Description de Docs",
"Docs Logo": "Logo Docs",
"Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Docs : Votre nouveau compagnon pour collaborer sur des documents efficacement, intuitivement et en toute sécurité.",
"Document icon": "Icône du document",
"Document name": "Nom du document",
"Document panel": "Panneau du document",
@@ -45,6 +47,7 @@
"Export": "Exporter",
"Export your document, it will be inserted in the selected template.": "Exportez votre document, il sera inséré dans le modèle sélectionné.",
"Failed to add the member in the document.": "Impossible d'ajouter le membre dans le document.",
"Failed to copy link": "Échec de la copie du lien",
"Failed to create the invitation for {{email}}.": "Impossible de créer l'invitation pour {{email}}.",
"Find a member to add to the document": "Trouver un membre à ajouter au document",
"French Interministerial Directorate for Digital Affairs (DINUM), 20 avenue de Ségur 75007 Paris.": "Direction interministérielle des affaires numériques (DINUM), 20 avenue de Segur 75007 Paris.",
@@ -56,7 +59,6 @@
"Invitation sent to {{email}}.": "Invitation envoyée à {{email}}.",
"Invite new members to {{title}}": "Invitez de nouveaux membres à rejoindre {{title}}",
"Invited": "Invité",
"Is it public ?": "Est-ce public?",
"It is the card information about the document.": "Il s'agit de la carte d'information du document.",
"It seems that the page you are looking for does not exist or cannot be displayed correctly.": "Il semble que la page que vous cherchez n'existe pas ou ne puisse pas être affichée correctement.",
"It's true, you didn't have to click on a block that covers half the page to say you agree to the placement of cookies — even if you don't know what it means!": "C'est vrai, vous n'avez pas à cliquer sur un bloc qui couvre la moitié de la page pour dire que vous acceptez le placement de cookies — même si vous ne savez pas ce que cela signifie !",
@@ -64,13 +66,14 @@
"Language Icon": "Icône de langue",
"Legal Notice": "Mentions Legales",
"Legal notice": "Mention légale",
"Link Copied !": "Lien copié !",
"List invitation card": "Carte de liste d'invitation",
"List members card": "Carte liste des membres",
"Login": "Connexion",
"Logout": "Se déconnecter",
"Members": "Membres",
"Modal confirmation to restore the version": "Modale de confirmation pour restaurer la version",
"More info?": "Plus d'infos ?",
"My account": "Mon compte",
"No editor found": "Pas d'éditeur trouvé",
"Nothing exceptional, no special privileges related to a .gouv.fr.": "Rien d'exceptionnel, pas de privilèges spéciaux liés à un .gouv.fr.",
"Offline ?!": "Hors-ligne ?!",
@@ -94,16 +97,16 @@
"Restore this version": "Restaurer cette version",
"Restore this version?": "Restaurer cette version?",
"Role": "Rôle",
"SUMMARY": "RÉSUMÉ",
"Search by email": "Recherche par email",
"Send a letter by post (free of charge, no stamp needed):": "Envoyer un courrier par la poste (gratuit, ne pas mettre de timbre):",
"Share": "Partager",
"Something bad happens, please retry.": "Une erreur inattendue s'est produite, veuillez réessayer.",
"Stéphanie Schaer: Interministerial Digital Director (DINUM).": "Stéphanie Schaer: Directrice numérique interministériel (DINUM).",
"Summary": "Résumé",
"Table of contents": "Table des matières",
"Template": "Template",
"The document has been deleted.": "Le document a bien été supprimé.",
"The document has been updated.": "Le document a été mis à jour.",
"The document visiblitity has been updated.": "La visibilité du document a été mise à jour.",
"The invitation has been removed.": "L'invitation a été supprimée.",
"The member has been removed from the document": "Le membre a été retiré du document",
"The role has been updated": "Le rôle a été mis à jour",

View File

@@ -1,6 +1,6 @@
{
"name": "impress",
"version": "1.3.0",
"version": "1.4.0",
"private": true,
"workspaces": {
"packages": [
@@ -25,15 +25,18 @@
"i18n:test": "yarn I18N run test"
},
"resolutions": {
"@blocknote/core": "0.15.6",
"@blocknote/mantine": "0.15.6",
"@blocknote/react": "0.15.6",
"@types/node": "20.16.3",
"@blocknote/core": "0.15.7",
"@blocknote/mantine": "0.15.7",
"@blocknote/react": "0.15.7",
"@types/node": "20.16.5",
"@types/react-dom": "18.3.0",
"@typescript-eslint/eslint-plugin": "8.5.0",
"@typescript-eslint/parser": "8.5.0",
"cross-env": "7.0.3",
"eslint": "8.57.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"typescript": "5.5.4",
"yjs": "13.6.18"
"typescript": "5.6.2",
"yjs": "13.6.19"
}
}

View File

@@ -1,24 +1,25 @@
{
"name": "eslint-config-impress",
"version": "1.3.0",
"version": "1.4.0",
"license": "MIT",
"scripts": {
"lint": "eslint --ext .js ."
},
"dependencies": {
"@next/eslint-plugin-next": "14.2.7",
"@tanstack/eslint-plugin-query": "5.53.0",
"@typescript-eslint/eslint-plugin": "8.3.0",
"@typescript-eslint/parser": "8.3.0",
"eslint": "8.57.0",
"eslint-config-next": "14.2.7",
"@next/eslint-plugin-next": "14.2.11",
"@tanstack/eslint-plugin-query": "5.56.1",
"@typescript-eslint/eslint-plugin": "*",
"@typescript-eslint/parser": "*",
"eslint": "*",
"eslint-config-next": "14.2.11",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-jest": "28.8.2",
"eslint-plugin-jsx-a11y": "6.9.0",
"eslint-plugin-import": "2.30.0",
"eslint-plugin-jest": "28.8.3",
"eslint-plugin-jsx-a11y": "6.10.0",
"eslint-plugin-playwright": "1.6.2",
"eslint-plugin-prettier": "5.2.1",
"eslint-plugin-react": "7.35.0",
"eslint-plugin-testing-library": "6.3.0"
"eslint-plugin-react": "7.36.1",
"eslint-plugin-testing-library": "6.3.0",
"prettier": "3.3.3"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "1.3.0",
"version": "1.4.0",
"private": true,
"scripts": {
"extract-translation": "yarn extract-translation:impress",
@@ -11,10 +11,10 @@
"test": "jest"
},
"dependencies": {
"@types/jest": "29.5.12",
"@types/jest": "29.5.13",
"@types/node": "*",
"eslint-config-impress": "*",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-import": "2.30.0",
"i18next-parser": "9.0.2",
"jest": "29.7.0",
"ts-jest": "29.2.5",

View File

@@ -1,6 +1,6 @@
{
"name": "server-y-provider",
"version": "1.3.0",
"version": "1.4.0",
"description": "Y.js provider for docs",
"repository": "https://github.com/numerique-gouv/impress",
"license": "MIT",
@@ -15,7 +15,8 @@
"node": ">=18"
},
"dependencies": {
"@hocuspocus/server": "2.13.5"
"@hocuspocus/server": "2.13.5",
"y-protocols": "1.0.6"
},
"devDependencies": {
"@types/node": "*",

File diff suppressed because it is too large Load Diff

View File

@@ -72,7 +72,8 @@ frontend:
envVars:
PORT: 8080
NEXT_PUBLIC_API_ORIGIN: https://impress.127.0.0.1.nip.io
NEXT_PUBLIC_SIGNALING_URL: wss://impress.127.0.0.1.nip.io/ws
NEXT_PUBLIC_Y_PROVIDER_URL: wss://impress.127.0.0.1.nip.io/ws
NEXT_PUBLIC_MEDIA_URL: https://impress.127.0.0.1.nip.io
replicas: 1
command:

View File

@@ -1,7 +1,7 @@
image:
repository: lasuite/impress-backend
pullPolicy: Always
tag: "v1.3.0-preprod"
tag: "v1.4.0-preprod"
backend:
migrateJobAnnotations:
@@ -124,13 +124,13 @@ frontend:
image:
repository: lasuite/impress-frontend
pullPolicy: Always
tag: "v1.3.0-preprod"
tag: "v1.4.0-preprod"
yProvider:
image:
repository: lasuite/impress-y-provider
pullPolicy: Always
tag: "v1.3.0-preprod"
tag: "v1.4.0-preprod"
ingress:
enabled: true

View File

@@ -1,7 +1,7 @@
image:
repository: lasuite/impress-backend
pullPolicy: Always
tag: "v1.3.0"
tag: "v1.4.0"
backend:
migrateJobAnnotations:
@@ -124,13 +124,13 @@ frontend:
image:
repository: lasuite/impress-frontend
pullPolicy: Always
tag: "v1.3.0"
tag: "v1.4.0"
yProvider:
image:
repository: lasuite/impress-y-provider
pullPolicy: Always
tag: "v1.3.0"
tag: "v1.4.0"
ingress:
enabled: true

View File

@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.3.0",
"version": "1.4.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {