Compare commits

...

21 Commits

Author SHA1 Message Date
Anthony LC 50dfb53a17 tag-v1.2.0-preprod 2024-08-06 16:41:26 +02:00
Anthony LC 6eac20da61 🔖(minor) minor release to 1.2.0
Added:
- 🎨(frontend) better conversion editor to pdf
- (frontend) Versioning

Fixed:
- 🐛(y-webrtc) fix prob connection
- ️(frontend) improve select share stability

Changed:
- 🎨(frontend) stop limit layout height to screen size
2024-08-06 16:24:34 +02:00
Anthony LC c42fd9be9c 🌐(frontend) add translations
Add french translations.
2024-08-06 16:23:07 +02:00
Anthony LC 2b824c0862 ️(service-worker) manage document versioning
- Cache correctly the document version to work
in offline mode.
- Restoring a version is possible even if offline.
- Better sync triggering.
2024-08-06 16:23:07 +02:00
Anthony LC fb494c8c71 ️(frontend) improve select share stability
- keep email in search input after unfocus
- keep search in memory after unfocus
- fixed width to reduce flickering
- empty states after validation
2024-08-06 16:23:07 +02:00
Anthony LC 8d5648005f 🎨(frontend) stop limit layout height to screen size
The app was limiting the layout height to the screen size,
which was a bit annoying when the content was
bigger than the screen.
We stop doing that, and now the layout
will grow as needed.
2024-08-06 12:29:06 +02:00
Anthony LC 713d9e48c8 🐛(y-webrtc) fix prob connection
Sometimes the connection was not established correctly,
because multiple connections were created at the
same time. This commit fixes this issue by ensuring
that only one connection is created at a time.
2024-08-06 09:28:12 +02:00
Anthony LC bef541f956 🧑‍💻(y-webrtc) use correctly the conf nodemon
The conf was not used correctly, the nodemon.json
was not used.
This commit fix this issue.
2024-08-06 09:28:12 +02:00
Anthony LC 4b61ffce01 🗃️(frontend) replace main version per another version
We can now replace the main version by another version.
Usefull either to come back to a previous version
or to update the main version with a new one.
2024-08-06 09:28:12 +02:00
Anthony LC a9383212a3 (frontend) feature versioning
We can now see the version of the document
and navigate to different versions.
Collaboration is possible per version.
2024-08-06 09:28:12 +02:00
Anthony LC 1ed20c3896 🌐(frontend) make empty doc grid message translatable
We personalize the empty doc grid message and
make it translatable.
2024-08-06 09:28:12 +02:00
Anthony LC b5443676c1 ♻️(frontend) replace setEditor per setStore
setStore is more generic and can be used to set
any store.
2024-08-06 09:28:12 +02:00
Anthony LC 424c100eeb 👔(frontend) create versions api endpoints
Create versions api endpoints:
- Add useDocVersion hook - to retrieve a version
- Add useDocVersions hook - to list versions with
pagination

We add an helper to type more easily the react-query
hooks.
2024-08-06 09:28:12 +02:00
Anthony LC 91be4f5a21 👔(backend) add document version serializer
Add document version serializer to get the pagination
with the document version list.
2024-08-06 09:28:12 +02:00
Anthony LC 6eb0ac99e4 🎨(frontend) better conversion editor to pdf
A recent update from Blocknote provides us the
alignment, the color and the background color of
the different editor texts. We adapt our converter
to adapt these new features to the pdf.
2024-08-02 17:34:02 +02:00
renovate[bot] e79a74083a ⬆️(dependencies) update sentry-sdk to v2 [SECURITY] 2024-08-02 10:21:13 +02:00
Anthony LC 905000550d ⬇️(frontend) downgrade @typescript-eslint/eslint-plugin to 7.13.1
@typescript-eslint/eslint-plugin released the
version 8, but it is causing some issues
(@typescript-eslint/no-duplicate-enum-values).
We downgrade it to 7.13.1 in waiting for a fix.
2024-08-01 10:52:01 +02:00
renovate[bot] 76d623a89b ⬆️(dependencies) update js dependencies 2024-08-01 10:52:01 +02:00
Anthony LC 61cf8aae74 🔖(minor) minor release to 1.1.0
Added:
- 🤡(demo) generate dummy documents on dev users
- (frontend) create side modal component
- (frontend) Doc grid actions (update / delete)
- (frontend) Doc editor header information

Changed:
- ♻️(frontend) replace docs panel with docs grid
- ♻️(frontend) create a doc from a modal
- ♻️(frontend) manage members from the share modal
2024-07-15 18:13:28 +02:00
renovate[bot] ccadd9567a ⬆️(dependencies) update django to v5.0.7 [SECURITY] 2024-07-15 17:24:18 +02:00
Anthony LC 2c9758eb30 🌐(frontend) translate last features
Translate:
- doc datagrid
- share feature
- remove translations not used anymore
2024-07-15 17:01:09 +02:00
59 changed files with 3051 additions and 839 deletions
+21 -1
View File
@@ -8,6 +8,24 @@ and this project adheres to
## [Unreleased]
## [1.2.0] - 2024-08-06
## Added
- 🎨(frontend) better conversion editor to pdf #151
- ✨(frontend) Versioning #147
## Fixed
- 🐛(y-webrtc) fix prob connection #147
- ⚡️(frontend) improve select share stability #159
## Changed
- 🎨(frontend) stop limit layout height to screen size #158
## [1.1.0] - 2024-07-15
## Added
- 🤡(demo) generate dummy documents on dev users #120
@@ -67,6 +85,8 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.0.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.2.0...main
[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
+9
View File
@@ -259,3 +259,12 @@ class InvitationSerializer(serializers.ModelSerializer):
attrs["document_id"] = document_id
attrs["issuer"] = user
return attrs
class DocumentVersionSerializer(serializers.Serializer):
"""Serialize Versions."""
etag = serializers.CharField()
is_latest = serializers.BooleanField()
last_modified = serializers.DateTimeField()
version_id = serializers.CharField()
+11 -2
View File
@@ -337,10 +337,18 @@ class DocumentViewSet(
Q(user=request.user) | Q(team__in=request.user.get_teams()),
)
)
return drf_response.Response(
document.get_versions_slice(from_datetime=from_datetime)
versions_data = document.get_versions_slice(from_datetime=from_datetime)[
"versions"
]
paginator = pagination.PageNumberPagination()
paginated_versions = paginator.paginate_queryset(versions_data, request)
serialized_versions = serializers.DocumentVersionSerializer(
paginated_versions, many=True
)
return paginator.get_paginated_response(serialized_versions.data)
@decorators.action(
detail=True,
methods=["get", "delete"],
@@ -377,6 +385,7 @@ class DocumentViewSet(
{
"content": response["Body"].read().decode("utf-8"),
"last_modified": response["LastModified"],
"id": version_id,
}
)
@@ -125,7 +125,8 @@ def test_api_document_versions_list_authenticated_related(via, mock_user_get_tea
assert response.status_code == 200
content = response.json()
assert len(content["versions"]) == 0
assert len(content["results"]) == 0
assert content["count"] == 0
# Add a new version to the document
document.content = "new content"
@@ -137,9 +138,8 @@ def test_api_document_versions_list_authenticated_related(via, mock_user_get_tea
assert response.status_code == 200
content = response.json()
assert len(content["versions"]) == 1
assert content["next_version_id_marker"] == ""
assert content["is_truncated"] is False
assert len(content["results"]) == 1
assert content["count"] == 1
def test_api_document_versions_retrieve_anonymous_public():
+3 -3
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.0.0"
version = "1.2.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -36,7 +36,7 @@ dependencies = [
"django-redis==5.4.0",
"django-storages[s3]==1.14.2",
"django-timezone-field>=5.1",
"django==5.0.3",
"django==5.0.7",
"djangorestframework==3.14.0",
"drf_spectacular==0.26.5",
"dockerflow==2022.8.0",
@@ -51,7 +51,7 @@ dependencies = [
"PyJWT==2.8.0",
"python-frontmatter==1.0.1",
"requests==2.32.2",
"sentry-sdk==1.38.0",
"sentry-sdk==2.8.0",
"url-normalize==1.4.3",
"WeasyPrint>=60.2",
"whitenoise==6.6.0",
@@ -140,3 +140,34 @@ export const goToGridDoc = async (
return docTitle as string;
};
export const mockedDocument = async (page: Page, json: object) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (request.method().includes('GET') && !request.url().includes('page=')) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
is_public: false,
created_at: '2021-09-01T09:00:00Z',
...json,
},
});
} else {
await route.continue();
}
});
};
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc } from './common';
import { createDoc, goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -153,42 +153,23 @@ test.describe('Doc Editor', () => {
});
test('it cannot edit if viewer', async ({ page }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
await mockedDocument(page, {
abilities: {
destroy: false, // Means not owner
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
});
await goToGridDoc(page);
await expect(
page.getByText(
"Read only, you don't have the right to update this document.",
),
page.getByText('Read only, you cannot edit this document.'),
).toBeVisible();
});
});
@@ -17,7 +17,7 @@ test.describe('Documents Grid', () => {
await expect(thead.getByText(/Created at/i)).toBeVisible();
await expect(thead.getByText(/Updated at/i)).toBeVisible();
await expect(thead.getByText(/Your role/i)).toBeVisible();
await expect(thead.getByText(/Users number/i)).toBeVisible();
await expect(thead.getByText(/Members/i)).toBeVisible();
const row1 = datagrid.getByRole('row').nth(1).getByRole('cell');
const docName = await row1.nth(1).textContent();
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { goToGridDoc } from './common';
import { goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -8,57 +8,42 @@ test.beforeEach(async ({ page }) => {
test.describe('Doc Header', () => {
test('it checks the element are correctly displayed', async ({ page }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super@owner.com',
},
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'admin',
user: {
email: 'super@admin.com',
},
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super2@owner.com',
},
},
],
abilities: {
destroy: true, // Means owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
update: true,
partial_update: true,
retrieve: true,
},
is_public: true,
created_at: '2021-09-01T09:00:00Z',
await mockedDocument(page, {
accesses: [
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super@owner.com',
},
});
} else {
await route.continue();
}
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'admin',
user: {
email: 'super@admin.com',
},
},
{
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
role: 'owner',
user: {
email: 'super2@owner.com',
},
},
],
abilities: {
destroy: true, // Means owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true,
update: true,
partial_update: true,
retrieve: true,
},
is_public: true,
created_at: '2021-09-01T09:00:00Z',
});
await goToGridDoc(page);
@@ -170,7 +170,7 @@ test.describe('Document create member', () => {
const inputSearch = page.getByLabel(/Find a member to add to the document/);
const email = randomName('test@test.fr', browserName, 1)[0];
const [email] = randomName('test@test.fr', browserName, 1);
await inputSearch.fill(email);
await page.getByRole('option', { name: email }).click();
@@ -191,7 +191,22 @@ test.describe('Document create member', () => {
expect(responseCreateInvitation.ok()).toBeTruthy();
await inputSearch.fill(email);
await expect(page.getByText('Loading...')).toBeHidden();
await expect(page.getByRole('option', { name: email })).toBeHidden();
await page.getByRole('option', { name: email }).click();
// Choose a role
await page.getByRole('combobox', { name: /Choose a role/ }).click();
await page.getByRole('option', { name: 'Owner' }).click();
const responsePromiseCreateInvitationFail = page.waitForResponse(
(response) =>
response.url().includes('/invitations/') && response.status() === 400,
);
await page.getByRole('button', { name: 'Validate' }).click();
await expect(
page.getByText(`"${email}" is already invited to the document.`),
).toBeVisible();
const responseCreateInvitationFail =
await responsePromiseCreateInvitationFail;
expect(responseCreateInvitationFail.ok()).toBeFalsy();
});
});
@@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test';
import cs from 'convert-stream';
import pdf from 'pdf-parse';
import { createDoc, goToGridDoc } from './common';
import { createDoc, goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -67,6 +67,21 @@ test.describe('Doc Tools', () => {
await page.keyboard.press('Enter');
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('Break');
await expect(page.getByText('Break')).toBeVisible();
// Center the text
await page.getByText('Break').dblclick();
await page.locator('button[data-test="alignTextCenter"]').click();
// Change the background color
await page.getByText('Break').dblclick();
await page.locator('button[data-test="colors"]').click();
await page.locator('button[data-test="background-color-brown"]').click();
// Change the text color
await page.getByText('Break').dblclick();
await page.locator('button[data-test="colors"]').click();
await page.locator('button[data-test="text-color-orange"]').click();
await page.getByLabel('Open the document options').click();
await page
@@ -82,7 +97,10 @@ test.describe('Doc Tools', () => {
.click();
// Empty paragraph should be replaced by a <br/>
expect(body).toContain('<br/><br/>');
expect(body.match(/<br\/>/g)?.length).toBeGreaterThanOrEqual(2);
expect(body).toContain('style="color: orange;"');
expect(body).toContain('style="text-align: center;"');
expect(body).toContain('style="background-color: brown;"');
});
test('it updates the doc', async ({ page, browserName }) => {
@@ -182,34 +200,17 @@ test.describe('Doc Tools', () => {
});
test('it checks the options available if administrator', async ({ page }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true, // Means admin
update: true,
partial_update: true,
retrieve: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
await mockedDocument(page, {
abilities: {
destroy: false, // Means not owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: true, // Means admin
update: true,
partial_update: true,
retrieve: true,
},
});
await goToGridDoc(page);
@@ -232,34 +233,17 @@ test.describe('Doc Tools', () => {
});
test('it checks the options available if editor', async ({ page }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: true,
partial_update: true, // Means editor
retrieve: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
await mockedDocument(page, {
abilities: {
destroy: false, // Means not owner
versions_destroy: true,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: true,
partial_update: true, // Means editor
retrieve: true,
},
});
await goToGridDoc(page);
@@ -282,34 +266,17 @@ test.describe('Doc Tools', () => {
});
test('it checks the options available if reader', async ({ page }) => {
await page.route('**/documents/**/', async (route) => {
const request = route.request();
if (
request.method().includes('GET') &&
!request.url().includes('page=')
) {
await route.fulfill({
json: {
id: 'b0df4343-c8bd-4c20-9ff6-fbf94fc94egg',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
is_public: false,
},
});
} else {
await route.continue();
}
await mockedDocument(page, {
abilities: {
destroy: false, // Means not owner
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
manage_accesses: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
});
await goToGridDoc(page);
@@ -0,0 +1,109 @@
import { expect, test } from '@playwright/test';
import { createDoc, goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Doc Version', () => {
test('it displays the doc versions', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
const panel = page.getByLabel('Document version panel');
await expect(panel.getByText('Current version')).toBeVisible();
expect(await panel.locator('li').count()).toBe(1);
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').last().fill('Hello World');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByText('Hello World')).toBeVisible();
await page
.locator('.ProseMirror.bn-editor')
.last()
.fill('It will create a version');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByText('Hello World')).toBeHidden();
await expect(page.getByText('It will create a version')).toBeVisible();
await expect(panel.getByText('Current version')).toBeVisible();
expect(await panel.locator('li').count()).toBe(2);
await panel.locator('li').nth(1).click();
await expect(
page.getByText('Read only, you cannot edit document versions.'),
).toBeVisible();
await expect(page.getByText('Hello World')).toBeVisible();
await expect(page.getByText('It will create a version')).toBeHidden();
await panel.getByText('Current version').click();
await expect(page.getByText('Hello World')).toBeHidden();
await expect(page.getByText('It will create a version')).toBeVisible();
});
test('it does not display the doc versions if not allowed', async ({
page,
}) => {
await mockedDocument(page, {
abilities: {
versions_list: false,
partial_update: true,
},
});
await goToGridDoc(page);
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(page.getByLabel('Document version panel')).toBeHidden();
});
test('it restores the doc version', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.bn-block-outer').last().click();
await page.locator('.bn-block-outer').last().fill('Hello');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByText('Hello')).toBeVisible();
await page.locator('.bn-block-outer').last().click();
await page.keyboard.press('Enter');
await page.locator('.bn-block-outer').last().fill('World');
await goToGridDoc(page, {
title: randomDoc,
});
await expect(page.getByText('World')).toBeVisible();
const panel = page.getByLabel('Document version panel');
await panel.locator('li').nth(1).click();
await expect(page.getByText('World')).toBeHidden();
await panel.getByLabel('Open the version options').click();
await page.getByText('Restore the version').click();
await expect(panel.locator('li')).toHaveCount(3);
await panel.getByText('Current version').click();
await expect(page.getByText('Hello')).toBeVisible();
await expect(page.getByText('World')).toBeHidden();
});
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.0.0",
"version": "1.2.0",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -9,7 +9,7 @@
"test:ui": "yarn test --ui"
},
"devDependencies": {
"@playwright/test": "1.45.0",
"@playwright/test": "1.45.3",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",
@@ -8,6 +8,10 @@ server {
try_files $uri index.html $uri/ =404;
}
location ~ ^/docs/(.*)/versions/(.*)/$ {
error_page 404 /docs/[id]/versions/[versionId]/;
}
location /docs/ {
error_page 404 /docs/[id]/;
}
+15 -15
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "1.0.0",
"version": "1.2.0",
"private": true,
"scripts": {
"dev": "next dev",
@@ -18,33 +18,33 @@
"@blocknote/core": "*",
"@blocknote/mantine": "*",
"@blocknote/react": "*",
"@gouvfr-lasuite/integration": "1.0.1",
"@gouvfr-lasuite/integration": "1.0.2",
"@openfun/cunningham-react": "2.9.3",
"@tanstack/react-query": "5.48.0",
"i18next": "23.11.5",
"@tanstack/react-query": "5.51.16",
"i18next": "23.12.2",
"idb": "8.0.0",
"lodash": "4.17.21",
"luxon": "3.4.4",
"next": "14.2.4",
"next": "14.2.5",
"react": "*",
"react-aria-components": "1.2.1",
"react-aria-components": "1.3.1",
"react-dom": "*",
"react-i18next": "14.1.2",
"react-i18next": "15.0.0",
"react-select": "5.8.0",
"styled-components": "6.1.11",
"styled-components": "6.1.12",
"y-webrtc": "10.3.0",
"yjs": "*",
"zustand": "4.5.4"
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.48.0",
"@testing-library/dom": "10.2.0",
"@testing-library/jest-dom": "6.4.6",
"@tanstack/react-query-devtools": "5.51.16",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.4.8",
"@testing-library/react": "16.0.0",
"@testing-library/user-event": "14.5.2",
"@types/jest": "29.5.12",
"@types/lodash": "4.17.5",
"@types/lodash": "4.17.7",
"@types/luxon": "3.4.2",
"@types/node": "*",
"@types/react": "18.3.3",
@@ -56,10 +56,10 @@
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"node-fetch": "2.7.0",
"prettier": "3.3.2",
"stylelint": "16.6.1",
"prettier": "3.3.3",
"stylelint": "16.8.1",
"stylelint-config-standard": "36.0.1",
"stylelint-prettier": "5.0.0",
"stylelint-prettier": "5.0.2",
"typescript": "*",
"workbox-webpack-plugin": "7.1.0"
}
@@ -0,0 +1,46 @@
import {
DefinedInitialDataInfiniteOptions,
InfiniteData,
QueryKey,
UseQueryOptions,
useInfiniteQuery,
} from '@tanstack/react-query';
import { APIError } from './APIError';
import { APIList } from './types';
export type UseQueryOptionsAPI<Q> = UseQueryOptions<Q, APIError, Q>;
export type DefinedInitialDataInfiniteOptionsAPI<Q> =
DefinedInitialDataInfiniteOptions<
Q,
APIError,
InfiniteData<Q>,
QueryKey,
number
>;
/**
* @param param Used for infinite scroll pagination
* @param queryConfig
* @returns
*/
export const useAPIInfiniteQuery = <T, Q extends { next?: APIList<Q>['next'] }>(
key: string,
api: (props: T & { page: number }) => Promise<Q>,
param: T,
queryConfig?: DefinedInitialDataInfiniteOptionsAPI<Q>,
) => {
return useInfiniteQuery<Q, APIError, InfiniteData<Q>, QueryKey, number>({
initialPageParam: 1,
queryKey: [key, param],
queryFn: ({ pageParam }) =>
api({
...param,
page: pageParam,
}),
getNextPageParam(lastPage, allPages) {
return lastPage.next ? allPages.length + 1 : undefined;
},
...queryConfig,
});
};
@@ -1,4 +1,5 @@
export * from './APIError';
export * from './fetchApi';
export * from './helpers';
export * from './types';
export * from './utils';
@@ -8,6 +8,7 @@ import { WebrtcProvider } from 'y-webrtc';
import { Box } from '@/components';
import { useAuthStore } from '@/core/auth';
import { Doc } from '@/features/docs/doc-management';
import { Version } from '@/features/docs/doc-versioning/';
import useSaveDoc from '../hook/useSaveDoc';
import { useDocStore } from '../stores';
@@ -17,31 +18,46 @@ import { BlockNoteToolbar } from './BlockNoteToolbar';
interface BlockNoteEditorProps {
doc: Doc;
version?: Version;
}
export const BlockNoteEditor = ({ doc }: BlockNoteEditorProps) => {
export const BlockNoteEditor = ({ doc, version }: BlockNoteEditorProps) => {
const { createProvider, docsStore } = useDocStore();
const provider = docsStore?.[doc.id]?.provider;
const storeId = version?.id || doc.id;
const initialContent = version?.content || doc.content;
const provider = docsStore?.[storeId]?.provider;
useEffect(() => {
if (!provider || provider.doc.guid !== storeId) {
createProvider(storeId, initialContent);
}
}, [createProvider, initialContent, provider, storeId]);
if (!provider) {
createProvider(doc.id, doc.content);
return null;
}
return <BlockNoteContent doc={doc} provider={provider} />;
return <BlockNoteContent doc={doc} provider={provider} storeId={storeId} />;
};
interface BlockNoteContentProps {
doc: Doc;
provider: WebrtcProvider;
storeId: string;
}
export const BlockNoteContent = ({ doc, provider }: BlockNoteContentProps) => {
export const BlockNoteContent = ({
doc,
provider,
storeId,
}: BlockNoteContentProps) => {
const isVersion = doc.id !== storeId;
const { userData } = useAuthStore();
const { setEditor, docsStore } = useDocStore();
useSaveDoc(doc.id, provider.doc, doc.abilities.partial_update);
const { setStore, docsStore } = useDocStore();
const canSave = doc.abilities.partial_update && !isVersion;
useSaveDoc(doc.id, provider.doc, canSave);
const storedEditor = docsStore?.[doc.id]?.editor;
const storedEditor = docsStore?.[storeId]?.editor;
const editor = useMemo(() => {
if (storedEditor) {
return storedEditor;
@@ -60,8 +76,8 @@ export const BlockNoteContent = ({ doc, provider }: BlockNoteContentProps) => {
}, [provider, storedEditor, userData?.email]);
useEffect(() => {
setEditor(doc.id, editor);
}, [setEditor, doc.id, editor]);
setStore(storeId, { editor });
}, [setStore, storeId, editor]);
return (
<Box
@@ -77,7 +93,7 @@ export const BlockNoteContent = ({ doc, provider }: BlockNoteContentProps) => {
<BlockNoteView
editor={editor}
formattingToolbar={false}
editable={doc.abilities.partial_update}
editable={doc.abilities.partial_update && !isVersion}
theme="light"
>
<BlockNoteToolbar />
@@ -1,9 +1,18 @@
import { Alert, VariantType } from '@openfun/cunningham-react';
import { Alert, Loader, VariantType } from '@openfun/cunningham-react';
import { useRouter as useNavigate } from 'next/navigation';
import { useRouter } from 'next/router';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card } from '@/components';
import { Box, Card, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { DocHeader } from '@/features/docs/doc-header';
import { Doc } from '@/features/docs/doc-management';
import {
Panel,
Versions,
useDocVersion,
} from '@/features/docs/doc-versioning/';
import { BlockNoteEditor } from './BlockNoteEditor';
@@ -12,24 +21,100 @@ interface DocEditorProps {
}
export const DocEditor = ({ doc }: DocEditorProps) => {
const {
query: { versionId },
} = useRouter();
const { t } = useTranslation();
const isVersion = versionId && typeof versionId === 'string';
const { colorsTokens } = useCunninghamTheme();
return (
<>
<DocHeader doc={doc} />
{!doc.abilities.partial_update && (
<Box $margin={{ all: 'small', top: 'none' }}>
<Alert
type={VariantType.WARNING}
>{`Read only, you don't have the right to update this document.`}</Alert>
<Alert type={VariantType.WARNING}>
{t(`Read only, you cannot edit this document.`)}
</Alert>
</Box>
)}
<Card
$margin={{ all: 'big', top: 'none' }}
$padding="big"
$css="flex:1;"
$overflow="auto"
{isVersion && (
<Box $margin={{ all: 'small', top: 'none' }}>
<Alert type={VariantType.WARNING}>
{t(`Read only, you cannot edit document versions.`)}
</Alert>
</Box>
)}
<Box
$background={colorsTokens()['primary-bg']}
$height="100%"
$direction="row"
$margin={{ all: 'small', top: 'none' }}
$gap="1rem"
>
<BlockNoteEditor doc={doc} />
</Card>
<Card $padding="big" $css="flex:1;" $overflow="auto">
{isVersion ? (
<DocVersionEditor doc={doc} versionId={versionId} />
) : (
<BlockNoteEditor doc={doc} />
)}
</Card>
{doc.abilities.versions_list && <Panel doc={doc} />}
</Box>
</>
);
};
interface DocVersionEditorProps {
doc: Doc;
versionId: Versions['version_id'];
}
export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
const {
data: version,
isLoading,
isError,
error,
} = useDocVersion({
docId: doc.id,
versionId,
});
const navigate = useNavigate();
if (isError && error) {
if (error.status === 404) {
navigate.replace(`/404`);
return null;
}
return (
<Box $margin="large">
<TextErrors
causes={error.cause}
icon={
error.status === 502 ? (
<Text className="material-icons" $theme="danger">
wifi_off
</Text>
) : undefined
}
/>
</Box>
);
}
if (isLoading || !version) {
return (
<Box $align="center" $justify="center" $height="100%">
<Loader />
</Box>
);
}
return <BlockNoteEditor doc={doc} version={version} />;
};
@@ -5,12 +5,15 @@ import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { useUpdateDoc } from '@/features/docs/doc-management/';
import { KEY_LIST_DOC_VERSIONS } from '@/features/docs/doc-versioning';
import { useDocStore } from '../stores';
import { toBase64 } from '../utils';
const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
const { toast } = useToastProvider();
const { t } = useTranslation();
const { forceSave, setForceSave } = useDocStore();
const { mutate: updateDoc } = useUpdateDoc({
onSuccess: (data) => {
@@ -19,8 +22,12 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
docTitle: data.title,
}),
VariantType.SUCCESS,
{
duration: 1500,
},
);
},
listInvalideQueries: [KEY_LIST_DOC_VERSIONS],
});
const [initialDoc, setInitialDoc] = useState<string>(
toBase64(Y.encodeStateAsUpdate(doc)),
@@ -57,10 +64,14 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
/**
* Check if the doc has been updated and can be saved.
*/
const shouldSave = useCallback(() => {
const hasChanged = useCallback(() => {
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
return initialDoc !== newDoc && canSave;
}, [canSave, doc, initialDoc]);
return initialDoc !== newDoc;
}, [doc, initialDoc]);
const shouldSave = useCallback(() => {
return hasChanged() && canSave;
}, [canSave, hasChanged]);
const saveDoc = useCallback(() => {
const newDoc = toBase64(Y.encodeStateAsUpdate(doc));
@@ -72,6 +83,18 @@ const useSaveDoc = (docId: string, doc: Y.Doc, canSave: boolean) => {
});
}, [doc, docId, updateDoc]);
useEffect(() => {
if (forceSave === 'false') {
return;
}
setForceSave('false');
if ((forceSave === 'current' && hasChanged()) || forceSave === 'version') {
saveDoc();
}
}, [forceSave, hasChanged, saveDoc, setForceSave]);
const timeout = useRef<NodeJS.Timeout>();
const router = useRouter();
@@ -4,60 +4,57 @@ import * as Y from 'yjs';
import { create } from 'zustand';
import { signalingUrl } from '@/core';
import { Base64, Doc } from '@/features/docs/doc-management';
import { Base64 } from '@/features/docs/doc-management';
export interface DocStore {
docsStore: {
[docId: Doc['id']]: {
provider: WebrtcProvider;
editor?: BlockNoteEditor;
};
};
createProvider: (docId: Doc['id'], initialDoc: Base64) => WebrtcProvider;
setEditor: (docId: Doc['id'], editor: BlockNoteEditor) => void;
interface DocStore {
provider: WebrtcProvider;
editor?: BlockNoteEditor;
}
const initialState = {
docsStore: {},
};
type ForceSaveState = 'false' | 'version' | 'current';
export const useDocStore = create<DocStore>((set) => ({
docsStore: initialState.docsStore,
createProvider: (docId: string, initialDoc: Base64) => {
export interface UseDocStore {
docsStore: {
[storeId: string]: DocStore;
};
createProvider: (storeId: string, initialDoc: Base64) => WebrtcProvider;
setStore: (storeId: string, props: Partial<DocStore>) => void;
forceSave: ForceSaveState;
setForceSave: (forceSave: ForceSaveState) => void;
}
export const useDocStore = create<UseDocStore>((set, get) => ({
docsStore: {},
forceSave: 'false',
setForceSave: (forceSave) => {
set(() => ({ forceSave }));
},
createProvider: (storeId: string, initialDoc: Base64) => {
const doc = new Y.Doc({
guid: docId,
guid: storeId,
});
if (initialDoc) {
Y.applyUpdate(doc, Buffer.from(initialDoc, 'base64'));
}
const provider = new WebrtcProvider(docId, doc, {
signaling: [signalingUrl(docId)],
const provider = new WebrtcProvider(storeId, doc, {
signaling: [signalingUrl(storeId)],
maxConns: 5,
});
set(({ docsStore }) => {
return {
docsStore: {
...docsStore,
[docId]: {
provider,
},
},
};
});
get().setStore(storeId, { provider });
return provider;
},
setEditor: (docId, editor) => {
setStore: (storeId, props) => {
set(({ docsStore }) => {
return {
docsStore: {
...docsStore,
[docId]: {
...docsStore[docId],
editor,
[storeId]: {
...docsStore[storeId],
...props,
},
},
};
@@ -25,7 +25,7 @@ export const DocHeader = ({ doc }: DocHeaderProps) => {
return (
<Card
$margin="big"
$margin="small"
aria-label={t('It is the card information about the document.')}
>
<Box $padding="small" $direction="row" $align="center">
@@ -104,7 +104,7 @@ export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
return;
}
let body = await editor.blocksToHTMLLossy(editor.document);
let body = await editor.blocksToFullHTML(editor.document);
body = adaptBlockNoteHTML(body);
createPdf({
@@ -11,5 +11,15 @@ export function downloadFile(blob: Blob, filename: string) {
}
export const adaptBlockNoteHTML = (html: string) => {
return html.replaceAll('<p class="bn-inline-content"></p>', '<br/>');
html = html.replaceAll('<p class="bn-inline-content"></p>', '<br/>');
html = html.replaceAll(
/data-text-alignment=\"([a-z]+)\"/g,
'style="text-align: $1;"',
);
html = html.replaceAll(/data-text-color=\"([a-z]+)\"/g, 'style="color: $1;"');
html = html.replaceAll(
/data-background-color=\"([a-z]+)\"/g,
'style="background-color: $1;"',
);
return html;
};
@@ -0,0 +1,2 @@
export * from './useDocVersions';
export * from './useDocVersion';
@@ -0,0 +1,41 @@
import { useQuery } from '@tanstack/react-query';
import { APIError, UseQueryOptionsAPI, errorCauses, fetchAPI } from '@/api';
import { Version } from '../types';
export type DocVersionParam = {
docId: string;
versionId: string;
};
const getDocVersion = async ({
versionId,
docId,
}: DocVersionParam): Promise<Version> => {
const url = `documents/${docId}/versions/${versionId}/`;
const response = await fetchAPI(url);
if (!response.ok) {
throw new APIError(
'Failed to get the doc version',
await errorCauses(response),
);
}
return response.json() as Promise<Version>;
};
export const KEY_DOC_VERSION = 'doc-version';
export function useDocVersion(
params: DocVersionParam,
queryConfig?: UseQueryOptionsAPI<Version>,
) {
return useQuery<Version, APIError, Version>({
queryKey: [KEY_DOC_VERSION, params],
queryFn: () => getDocVersion(params),
...queryConfig,
});
}
@@ -0,0 +1,66 @@
import { useQuery } from '@tanstack/react-query';
import {
APIError,
APIList,
DefinedInitialDataInfiniteOptionsAPI,
UseQueryOptionsAPI,
errorCauses,
fetchAPI,
useAPIInfiniteQuery,
} from '@/api';
import { Versions } from '../types';
export type DocVersionsParam = {
docId: string;
};
export type DocVersionsAPIParams = DocVersionsParam & {
page: number;
};
type VersionsResponse = APIList<Versions>;
const getDocVersions = async ({
page,
docId,
}: DocVersionsAPIParams): Promise<VersionsResponse> => {
const url = `documents/${docId}/versions/?page=${page}`;
const response = await fetchAPI(url);
if (!response.ok) {
throw new APIError(
'Failed to get the doc versions',
await errorCauses(response),
);
}
return response.json() as Promise<VersionsResponse>;
};
export const KEY_LIST_DOC_VERSIONS = 'doc-versions';
export function useDocVersions(
params: DocVersionsAPIParams,
queryConfig?: UseQueryOptionsAPI<VersionsResponse>,
) {
return useQuery<VersionsResponse, APIError, VersionsResponse>({
queryKey: [KEY_LIST_DOC_VERSIONS, params],
queryFn: () => getDocVersions(params),
...queryConfig,
});
}
export function useDocVersionsInfiniteQuery(
param: DocVersionsParam,
queryConfig?: DefinedInitialDataInfiniteOptionsAPI<VersionsResponse>,
) {
return useAPIInfiniteQuery<DocVersionsParam, VersionsResponse>(
KEY_LIST_DOC_VERSIONS,
getDocVersions,
param,
queryConfig,
);
}
@@ -0,0 +1,93 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, IconBG, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc } from '@/features/docs/doc-management';
import { VersionList } from './VersionList';
interface PanelProps {
doc: Doc;
}
export const Panel = ({ doc }: PanelProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [isOpen, setIsOpen] = useState(true);
const closedOverridingStyles = !isOpen && {
$width: '0',
$maxWidth: '0',
$minWidth: '0',
};
const transition = 'all 0.5s ease-in-out';
return (
<Card
$width="100%"
$maxWidth="20rem"
$position="sticky"
$maxHeight="96vh"
$height="100%"
$css={`
top: 2vh;
transition: ${transition};
${
!isOpen &&
`
box-shadow: none;
border: none;
`
}
`}
aria-label={t('Document version panel')}
{...closedOverridingStyles}
>
<IconBG
iconName="menu_open"
aria-label={
isOpen
? t('Close the document version panel')
: t('Open the document version panel')
}
$background="transparent"
$size="h2"
$zIndex={1}
$css={`
cursor: pointer;
left: ${isOpen ? '0' : '-3.3'}rem;
top: 0.1rem;
transform: rotate(${isOpen ? '180' : '0'}deg);
transition: ${transition};
user-select: none;
`}
$position="absolute"
onClick={() => setIsOpen(!isOpen)}
$radius="2px"
/>
<Box
$overflow="hidden"
$css={`
opacity: ${isOpen ? '1' : '0'};
transition: ${transition};
`}
>
<Box
$padding={{ all: 'small' }}
$direction="row"
$align="center"
$justify="center"
$css={`border-top: 2px solid ${colorsTokens()['primary-600']};`}
>
<Text $weight="bold" $size="l" $theme="primary">
{t('VERSIONS')}
</Text>
</Box>
<VersionList doc={doc} />
</Box>
</Card>
);
};
@@ -0,0 +1,123 @@
import { Button } from '@openfun/cunningham-react';
import { t } from 'i18next';
import React, { PropsWithChildren, useState } from 'react';
import { Box, DropButton, IconOptions, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useDocStore } from '@/features/docs/doc-editor';
import { Doc } from '@/features/docs/doc-management';
import { Versions } from '../types';
import { revertUpdate } from '../utils';
interface VersionItemProps {
docId: Doc['id'];
text: string;
link: string;
versionId?: Versions['version_id'];
isActive: boolean;
}
export const VersionItem = ({
docId,
versionId,
text,
link,
isActive,
}: VersionItemProps) => {
const { setForceSave, docsStore, setStore } = useDocStore();
const { colorsTokens } = useCunninghamTheme();
const [isDropOpen, setIsDropOpen] = useState(false);
return (
<Box
as="li"
$background={isActive ? colorsTokens()['primary-300'] : 'transparent'}
$css={`
border-left: 4px solid transparent;
border-bottom: 1px solid ${colorsTokens()['primary-100']};
&:hover{
border-left: 4px solid ${colorsTokens()['primary-400']};
background: ${colorsTokens()['primary-300']};
}
`}
$hasTransition
$minWidth="13rem"
>
<Link href={link} isActive={isActive}>
<Box
$padding={{ vertical: '0.7rem', horizontal: 'small' }}
$align="center"
$direction="row"
$justify="space-between"
$width="100%"
>
<Box $direction="row" $gap="0.5rem" $align="center">
<Text $isMaterialIcon $size="24px" $theme="primary">
description
</Text>
<Text $weight="bold" $theme="primary" $size="m">
{text}
</Text>
</Box>
{isActive && versionId && (
<DropButton
button={
<IconOptions
isOpen={isDropOpen}
aria-label={t('Open the version options')}
/>
}
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
isOpen={isDropOpen}
>
<Box>
<Button
onClick={() => {
setIsDropOpen(false);
setForceSave(versionId ? 'version' : 'current');
if (
!docsStore?.[docId]?.provider ||
!docsStore?.[versionId]?.provider
) {
return;
}
setStore(docId, {
editor: undefined,
});
revertUpdate(
docsStore[docId].provider.doc,
docsStore[docId].provider.doc,
docsStore[versionId].provider.doc,
);
}}
color="primary-text"
icon={<span className="material-icons">save</span>}
size="small"
>
<Text $theme="primary">{t('Restore the version')}</Text>
</Button>
</Box>
</DropButton>
)}
</Box>
</Link>
</Box>
);
};
interface LinkProps {
href: string;
isActive: boolean;
}
const Link = ({ href, children, isActive }: PropsWithChildren<LinkProps>) => {
return isActive ? (
<>{children}</>
) : (
<StyledLink href={href}>{children}</StyledLink>
);
};
@@ -0,0 +1,131 @@
import { Loader } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import React, { useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { APIError } from '@/api';
import { Box, InfiniteScroll, Text, TextErrors } from '@/components';
import { Doc } from '@/features/docs/doc-management';
import { useDate } from '@/hook';
import { useDocVersionsInfiniteQuery } from '../api/useDocVersions';
import { Versions } from '../types';
import { VersionItem } from './VersionItem';
interface VersionListStateProps {
isLoading: boolean;
error: APIError<unknown> | null;
versions?: Versions[];
doc: Doc;
}
const VersionListState = ({
isLoading,
error,
versions,
doc,
}: VersionListStateProps) => {
const { t } = useTranslation();
const { formatDate } = useDate();
const {
query: { versionId },
} = useRouter();
if (isLoading) {
return (
<Box $align="center" $margin="large">
<Loader />
</Box>
);
}
return (
<>
<VersionItem
text={t('Current version')}
versionId={undefined}
link={`/docs/${doc.id}/`}
docId={doc.id}
isActive={!versionId}
/>
{versions?.map((version) => (
<VersionItem
key={version.version_id}
versionId={version.version_id}
text={formatDate(version.last_modified, {
dateStyle: 'long',
timeStyle: 'short',
})}
link={`/docs/${doc.id}/versions/${version.version_id}`}
docId={doc.id}
isActive={version.version_id === versionId}
/>
))}
{error && (
<Box
$justify="center"
$margin={{ vertical: 'small', horizontal: 'small' }}
>
<TextErrors
causes={error.cause}
icon={
error.status === 502 ? (
<Text $isMaterialIcon $theme="danger">
wifi_off
</Text>
) : undefined
}
/>
</Box>
)}
</>
);
};
interface VersionListProps {
doc: Doc;
}
export const VersionList = ({ doc }: VersionListProps) => {
const {
data,
error,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useDocVersionsInfiniteQuery({
docId: doc.id,
});
const containerRef = useRef<HTMLDivElement>(null);
const versions = useMemo(() => {
return data?.pages.reduce((acc, page) => {
return acc.concat(page.results);
}, [] as Versions[]);
}, [data?.pages]);
return (
<Box $css="overflow-y: auto; overflow-x: hidden;" ref={containerRef}>
<InfiniteScroll
hasMore={hasNextPage}
isLoading={isFetchingNextPage}
next={() => {
void fetchNextPage();
}}
scrollContainer={containerRef.current}
as="ul"
$padding="none"
$margin={{ top: 'none' }}
role="listbox"
>
<VersionListState
isLoading={isLoading}
error={error}
versions={versions}
doc={doc}
/>
</InfiniteScroll>
</Box>
);
};
@@ -0,0 +1 @@
export * from './Panel';
@@ -0,0 +1,3 @@
export * from './api';
export * from './components';
export * from './types';
@@ -0,0 +1,14 @@
import { Doc } from '../doc-management';
export interface Versions {
etag: string;
is_latest: boolean;
last_modified: string;
version_id: string;
}
export interface Version {
content: Doc['content'];
last_modified: string;
id: string;
}
@@ -0,0 +1,53 @@
import * as Y from 'yjs';
/**
* Revert the doc to a previous state.
*
* We cannot simply replace a doc with another previous doc,
* because Y.js will act as if the previous doc is a new doc and so
* merge it with the current doc, so we need to revert the doc (undo).
*
* To do so we simulate a history of the doc by saving snapshots of the doc
* and then revert the doc to a previous snapshot.
*
* @param doc
* @param snapshotOrigin
* @param snapshotUpdate
*/
export function revertUpdate(
doc: Y.Doc,
snapshotOrigin: Y.Doc,
snapshotUpdate: Y.Doc,
) {
try {
const snapshotDoc = new Y.Doc();
Y.applyUpdate(
snapshotDoc,
Y.encodeStateAsUpdate(snapshotUpdate),
snapshotOrigin,
);
const currentStateVector = Y.encodeStateVector(doc);
const snapshotStateVector = Y.encodeStateVector(snapshotDoc);
const changesSinceSnapshotUpdate = Y.encodeStateAsUpdate(
doc,
snapshotStateVector,
);
const undoManager = new Y.UndoManager(
[snapshotDoc.getMap('document-store')],
{
trackedOrigins: new Set([snapshotOrigin]),
},
);
Y.applyUpdate(snapshotDoc, changesSinceSnapshotUpdate, snapshotOrigin);
undoManager.undo();
const revertChangesSinceSnapshotUpdate = Y.encodeStateAsUpdate(
snapshotDoc,
currentStateVector,
);
Y.applyUpdate(doc, revertChangesSinceSnapshotUpdate, snapshotOrigin);
} catch (e) {}
}
@@ -20,9 +20,6 @@ import { PAGE_SIZE } from '../conf';
import { DocsGridActions } from './DocsGridActions';
const DocsGridStyle = createGlobalStyle`
& .c__datagrid{
max-height: 91%;
}
& .c__datagrid thead{
position: sticky;
top: 0;
@@ -171,7 +168,7 @@ export const DocsGrid = () => {
},
},
{
headerName: t('Users number'),
headerName: t('Members'),
id: 'users_number',
renderCell: ({ row }) => {
return (
@@ -193,6 +190,7 @@ export const DocsGrid = () => {
pagination={pagination}
onSortModelChange={setSortModel}
sortModel={sortModel}
emptyPlaceholderLabel={t("You don't have any document yet.")}
/>
</Card>
);
@@ -70,14 +70,23 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
duration: 4000,
};
const onError = (dataError: APIErrorUser['data']) => {
const messageError =
dataError?.type === OptionType.INVITATION
const onError = (dataError: APIErrorUser) => {
let messageError =
dataError['data']?.type === OptionType.INVITATION
? t(`Failed to create the invitation for {{email}}.`, {
email: dataError?.value,
email: dataError['data']?.value,
})
: t(`Failed to add the member in the document.`);
if (
dataError.cause?.[0] ===
'Document invitation with this Email address and Document already exists.'
) {
messageError = t('"{{email}}" is already invited to the document.', {
email: dataError['data']?.value,
});
}
toast(messageError, VariantType.ERROR, toastOptions);
};
@@ -106,11 +115,12 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
setIsPending(false);
setResetKey(resetKey + 1);
setSelectedUsers([]);
settledPromises.forEach((settledPromise) => {
switch (settledPromise.status) {
case 'rejected':
onError((settledPromise.reason as APIErrorUser).data);
onError(settledPromise.reason as APIErrorUser);
break;
case 'fulfilled':
@@ -132,7 +142,7 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
<IconBG iconName="group_add" />
<Box $gap="0.7rem" $direction="row" $wrap="wrap" $css="flex: 70%;">
<Box $gap="0.7rem" $direction="row" $wrap="wrap" $css="flex: 80%;">
<Box $css="flex: auto;">
<Box $css="flex: auto;" $width="15rem">
<SearchUsers
key={resetKey + 1}
doc={doc}
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Options } from 'react-select';
import { InputActionMeta, Options } from 'react-select';
import AsyncSelect from 'react-select/async';
import { useCunninghamTheme } from '@/cunningham';
@@ -42,7 +42,7 @@ export const SearchUsers = ({
const options = data?.results;
useEffect(() => {
const optionsSelect = useMemo(() => {
if (!resolveOptionsRef.current || !options) {
return;
}
@@ -81,6 +81,8 @@ export const SearchUsers = ({
resolveOptionsRef.current(users);
resolveOptionsRef.current = null;
return users;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options, selectedUsers]);
@@ -91,16 +93,26 @@ export const SearchUsers = ({
};
const timeout = useRef<NodeJS.Timeout | null>(null);
const onInputChangeHandle = useCallback((newValue: string) => {
setInput(newValue);
if (timeout.current) {
clearTimeout(timeout.current);
}
const onInputChangeHandle = useCallback(
(newValue: string, actionMeta: InputActionMeta) => {
if (
actionMeta.action === 'input-blur' ||
actionMeta.action === 'menu-close'
) {
return;
}
timeout.current = setTimeout(() => {
setUserQuery(newValue);
}, 1000);
}, []);
setInput(newValue);
if (timeout.current) {
clearTimeout(timeout.current);
}
timeout.current = setTimeout(() => {
setUserQuery(newValue);
}, 1000);
},
[],
);
return (
<AsyncSelect
@@ -125,10 +137,10 @@ export const SearchUsers = ({
aria-label={t('Find a member to add to the document')}
isMulti
loadOptions={loadOptions}
defaultOptions={[]}
defaultOptions={optionsSelect}
onInputChange={onInputChangeHandle}
inputValue={input}
placeholder={t('Search new members by email')}
placeholder={t('Search by email')}
noOptionsMessage={() =>
input
? t("We didn't find a mail matching, try to be more accurate")
@@ -5,7 +5,12 @@ import {
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Access, KEY_DOC, Role } from '@/features/docs/doc-management';
import {
Access,
KEY_DOC,
KEY_LIST_DOC,
Role,
} from '@/features/docs/doc-management';
import { KEY_LIST_DOC_ACCESSES } from './useDocAccesses';
@@ -54,6 +59,9 @@ export const useUpdateDocAccess = (options?: UseUpdateDocAccessOptions) => {
void queryClient.invalidateQueries({
queryKey: [KEY_DOC],
});
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_DOC],
});
if (options?.onSuccess) {
options.onSuccess(data, variables, context);
}
@@ -15,7 +15,13 @@ interface OptionsMutate {
type: 'update' | 'delete' | 'create';
}
type Options = (OptionsReadonly | OptionsMutate) & { syncManager: SyncManager };
interface OptionsSync {
type: 'synch';
}
type Options = (OptionsReadonly | OptionsMutate | OptionsSync) & {
syncManager: SyncManager;
};
export class ApiPlugin implements WorkboxPlugin {
private readonly options: Options;
@@ -28,7 +28,8 @@ export const isApiUrl = (href: string) => {
*/
registerRoute(
({ url }) =>
isApiUrl(url.href) && url.href.match(/.*\/documents\/\?(page|ordering)=.*/),
isApiUrl(url.href) &&
url.href.match(/.*\/documents\/\?(page|ordering)=.*/g),
new NetworkOnly({
plugins: [
new ApiPlugin({
@@ -42,7 +43,8 @@ registerRoute(
);
registerRoute(
({ url }) => isApiUrl(url.href) && url.href.match(/.*\/documents\/.*\//),
({ url }) =>
isApiUrl(url.href) && url.href.match(/.*\/documents\/([a-z0-9\-]+)\/$/g),
new NetworkOnly({
plugins: [
new ApiPlugin({
@@ -56,7 +58,8 @@ registerRoute(
);
registerRoute(
({ url }) => isApiUrl(url.href) && url.href.match(/.*\/documents\/.*\//),
({ url }) =>
isApiUrl(url.href) && url.href.match(/.*\/documents\/([a-z0-9\-]+)\/$/g),
new NetworkOnly({
plugins: [
new ApiPlugin({
@@ -69,7 +72,7 @@ registerRoute(
);
registerRoute(
({ url }) => isApiUrl(url.href) && url.href.match(/.*\/documents\//),
({ url }) => isApiUrl(url.href) && url.href.match(/.*\/documents\/$/g),
new NetworkOnly({
plugins: [
new ApiPlugin({
@@ -82,7 +85,8 @@ registerRoute(
);
registerRoute(
({ url }) => isApiUrl(url.href) && url.href.match(/.*\/documents\/.*\//),
({ url }) =>
isApiUrl(url.href) && url.href.match(/.*\/documents\/([a-z0-9\-]+)\/$/g),
new NetworkOnly({
plugins: [
new ApiPlugin({
@@ -103,6 +107,10 @@ registerRoute(
new ExpirationPlugin({
maxAgeSeconds: 24 * 60 * 60 * DAYS_EXP,
}),
new ApiPlugin({
type: 'synch',
syncManager,
}),
],
}),
'GET',
@@ -87,6 +87,7 @@ self.addEventListener('activate', function (event) {
const FALLBACK = {
offline: '/offline/',
docs: '/docs/[id]/',
versions: '/docs/[id]/versions/[versionId]/',
images: '/assets/img-not-found.svg',
};
const precacheResources = [
@@ -99,6 +100,7 @@ const precacheResources = [
FALLBACK.offline,
FALLBACK.images,
FALLBACK.docs,
FALLBACK.versions,
];
const precacheStrategy = getStrategy({
@@ -117,8 +119,14 @@ setCatchHandler(async ({ request, url, event }) => {
return ApiPlugin.getApiCatchHandler();
case request.destination === 'document':
if (url.pathname.match(/^\/docs\/.*\//)) {
if (url.pathname.match(/^\/docs\/([a-z0-9\-]+)\/$/g)) {
return precacheStrategy.handle({ event, request: FALLBACK.docs });
} else if (
url.pathname.match(
/^\/docs\/([a-z0-9\-]+)\/versions\/([a-z0-9\-]+)\/$/g,
)
) {
return precacheStrategy.handle({ event, request: FALLBACK.versions });
}
return precacheStrategy.handle({ event, request: FALLBACK.offline });
@@ -1,7 +1,7 @@
import { DateTime, DateTimeFormatOptions } from 'luxon';
import { useTranslation } from 'react-i18next';
const format: DateTimeFormatOptions = {
const formatDefault: DateTimeFormatOptions = {
month: '2-digit',
day: '2-digit',
year: 'numeric',
@@ -12,7 +12,10 @@ const format: DateTimeFormatOptions = {
export const useDate = () => {
const { i18n } = useTranslation();
const formatDate = (date: string): string => {
const formatDate = (
date: string,
format: DateTimeFormatOptions = formatDefault,
): string => {
return DateTime.fromISO(date)
.setLocale(i18n.language)
.toLocaleString(format);
@@ -2,50 +2,42 @@
"en": { "translation": {} },
"fr": {
"translation": {
"0 group to display.": "0 groupe à afficher.",
"\"{{email}}\" is already invited to the document.": "\"{{email}}\" est déjà invité à accéder au document.",
"Accessibility": "Accessibilité",
"Accessibility statement": "Déclaration d'accessibilité",
"Add a document": "Ajouter un document",
"Add document icon": "Icône ajouter un document",
"Add members": "Ajouter des membres",
"Add members to the document": "Ajouter des membres au document",
"Address:": "Adresse :",
"Administrator": "Administrateur",
"Are you sure you want to delete the document \"{{title}}\"?": "Êtes-vous sûr de vouloir supprimer le document \"{{title}}\" ?",
"Are you sure you want to remove this member from the document?": "Êtes-vous sûr de vouloir supprimer ce membre du document ?",
"Back to home page": "Retour à l'accueil",
"Can't load this page, please check your internet connection.": "Impossible de charger cette page, veuillez vérifier votre connexion Internet.",
"Cancel": "Annuler",
"Choose a role": "Choisissez un rôle",
"Close the documents panel": "Fermer le panneau des documents",
"Close the document version panel": "Fermer le panneau des versions du document",
"Close the modal": "Fermer la modale",
"Coming soon ...": "Coming soon ...",
"Compliance status": "État de conformité",
"Confirm deletion": "Confirmer la suppression",
"Content modal to delete document": "Contenu modal pour supprimer le document",
"Content modal to generate a PDF": "Contenu modal pour générer un PDF",
"Content modal to update the document": "Contenu modal pour mettre à jour le document",
"Cookies placed": "Cookies déposés",
"Copyright": "Copyright",
"Create a new document": "Créer un nouveau document",
"Create new document card": "Carte créer un nouveau document",
"Create the document": "Créer le document",
"Create your first document by clicking on the \"Create a new document\" button.": "Créez votre premier document en cliquant sur le bouton \"Créer un nouveau document\".",
"Created at": "Créé le",
"Current version": "Version en cours",
"Datagrid of the documents page {{page}}": "Page {{page}} de la grille des données des documents",
"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}}\"",
"Docs": "Docs",
"Docs Description": "Description de Docs",
"Docs Logo": "Logo Docs",
"Docs icon": "Icône Docs",
"Document icon": "Icône du document",
"Document name": "Nom du document",
"Document version panel": "Panneau des versions du document",
"Documents": "Documents",
"Download": "Télécharger",
"E-mail:": "E-mail:",
"Editor": "Éditeur",
"Emails": "Emails",
"Empty docs icon": "Icône de docs vide",
"Enter the new name of the selected document.": "Entrez le nouveau nom du document sélectionné.",
"Established on December 20, 2023.": "Établi le 20 décembre 2023.",
"Failed to add the member in the document.": "Impossible d'ajouter le membre dans le document.",
@@ -61,6 +53,7 @@
"Invitation sent to {{email}}.": "Invitation envoyée à {{email}}.",
"Invite new members to {{title}}": "Invitez de nouveaux membres à rejoindre {{title}}",
"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 !",
"Language": "Langue",
@@ -69,37 +62,33 @@
"Legal notice": "Mention légale",
"List members card": "Carte liste des membres",
"Logout": "Se déconnecter",
"Manage members": "Gérer les membres",
"Marianne Logo": "Logo Marianne",
"Members of the document": "Membres du document",
"Members": "Membres",
"More info?": "Plus d'infos ?",
"My account": "Mon compte",
"Name the document": "Nommer le document",
"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 ?!",
"Open the document options": "Ouvrir les options du document",
"Open the documents panel": "Ouvrir le panneau des documents",
"Open the member options modal": "Ouvrir les options de membre dans la fenêtre modale",
"Open the modal to delete this member": "Ouvrir la fenêtre modale pour supprimer ce membre",
"Open the modal to update the role of this member": "Ouvrir la fenêtre modale pour mettre à jour le rôle de ce membre",
"Open the document version panel": "Ouvrir le panneau des versions du document",
"Open the version options": "Ouvrir les options de version",
"Ouch !": "Aïe !",
"Owner": "Propriétaire",
"Owners:": "Propriétaires:",
"Personal data and cookies": "Données personnelles et cookies",
"Public": "Public",
"Publication Director": "Directeur de la publication",
"Publisher": "Éditeur",
"Radio buttons to update the roles": "Boutons radio pour mettre à jour les rôles",
"Read only, you cannot edit document versions.": "En lecture seule, vous ne pouvez pas éditer les versions du document.",
"Read only, you cannot edit this document.": "En lecture seule, vous ne pouvez pas éditer ce document.",
"Reader": "Lecteur",
"Remedies": "Voie de recours",
"Remove from group": "Retirer du groupe",
"Remove the member": "Retirer le membre",
"Roles": "Rôles",
"Search new members by email": "Rechercher les nouveaux membres par e-mail",
"Restore the version": "Restaurer la version",
"Role": "Rôle",
"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.",
"Sort documents icon": "Icône trier les documents",
"Sort the documents by creation date ascendent": "Trier les documents par date de création ascendante",
"Sort the documents by creation date descendent": "Trier les documents par date de création descendante",
"Stéphanie Schaer: Interministerial Digital Director (DINUM).": "Stéphanie Schaer: Directrice numérique interministériel (DINUM).",
"Template": "Template",
"The document has been deleted.": "Le document a bien été supprimé.",
@@ -116,26 +105,27 @@
"Unless otherwise stated, all content on this site is under": "Sauf mention contraire, tout le contenu de ce site est sous",
"Update document": "Mettre à jour le document",
"Update document \"{{documentTitle}}\"": "Mettre à jour le document \"{{documentTitle}}\"",
"Update role": "Mettre à jour le rôle",
"Update the role": "Mettre à jour ce rôle",
"Updated at": "Mise à jour le",
"User {{email}} added to the document.": "L'utilisateur {{email}} a été ajouté au document.",
"VERSIONS": "VERSIONS",
"Validate": "Valider",
"Validate the modification": "Valider les modifications",
"We didn't find a mail matching, try to be more accurate": "Nous n'avons pas trouvé de correspondance par mail, essayez d'être plus précis",
"We simply comply with the law, which states that certain audience measurement tools, properly configured to respect privacy, are exempt from prior authorization.": "Nous nous conformons simplement à la loi, qui stipule que certains outils de mesure daudience, correctement configurés pour respecter la vie privée, sont exemptés de toute autorisation préalable.",
"We try to respond within 2 working days.": "Nous essayons de répondre dans les 2 jours ouvrables.",
"You are the last owner, you cannot be removed from your document.": "Vous êtes le dernier propriétaire, vous ne pouvez pas être retiré de votre document.",
"You are the sole owner of this group, make another member the group owner, before you can change your own role.": "Vous êtes le seul propriétaire de ce groupe, faites d'un autre membre le propriétaire du groupe, avant de pouvoir modifier votre propre rôle.",
"You are the sole owner of this group, make another member the group owner before you can change your own role or be removed from your document.": "Vous êtes le seul propriétaire de ce groupe, faites d'un autre membre le propriétaire du groupe, avant de pouvoir modifier votre propre rôle ou vous supprimer du document.",
"You can oppose the tracking of your browsing on this website.": "Vous pouvez vous opposer au suivi de votre navigation sur ce site.",
"You can:": "Vous pouvez:",
"You cannot remove other owner.": "Vous ne pouvez pas retirer un autre propriétaire.",
"You cannot update the role of other owner.": "Vous ne pouvez pas mettre à jour le rôle d'un autre propriétaire.",
"You cannot update the role or remove other owner.": "Vous ne pouvez pas mettre à jour le rôle ou supprimer un autre propriétaire.",
"You don't have any document yet.": "Vous n'avez pas encore de document.",
"Your document \"{{docTitle}}\" has been saved.": "Votre document \"{{docTitle}}\" a été enregistré.",
"Your pdf was downloaded succesfully": "Votre pdf a été téléchargé avec succès",
"Your role": "Votre rôle",
"Your role:": "Votre rôle:",
"accessibility-contact-defenseurdesdroits": "Contacter le délégué du<1>Défenseur des droits dans votre région</1>",
"accessibility-dinum-services": "<strong>DINUM</strong> s'engage à rendre accessibles ses services numériques, conformément à l'article 47 de la loi n° 2005-102 du 11 février 2005.",
"accessibility-form-defenseurdesdroits": "Écrire un message au<1>Défenseur des droits</1>",
"accessibility-not-audit": "<strong>docs.numerique.gouv.fr</strong> n'est pas en conformité avec le RGAA 4.1. Le site n'a <strong>pas encore été audité.</strong>",
"icon group": "icône groupe",
"you have reported to the website manager a lack of accessibility that prevents you from accessing content or one of the services of the portal and you have not received a satisfactory response.": "vous avez signalé au responsable du site internet un défaut d'accessibilité qui vous empêche d'accéder à un contenu ou à un des services du portail et vous n'avez pas obtenu de réponse satisfaisante."
}
}
@@ -8,12 +8,12 @@ export function MainLayout({ children }: { children: React.ReactNode }) {
return (
<Box>
<Box $height="100vh">
<Box $minHeight="100vh">
<Header />
<Box $css="flex: 1;" $direction="row">
<Box
as="main"
$height={`calc(100vh - ${HEADER_HEIGHT})`}
$minHeight={`calc(100vh - ${HEADER_HEIGHT})`}
$width="100%"
$background={colorsTokens()['primary-bg']}
>
@@ -1,15 +1,15 @@
import { Loader } from '@openfun/cunningham-react';
import { useRouter as useNavigate } from 'next/navigation';
import { useRouter } from 'next/router';
import { ReactElement } from 'react';
import { Box, Text, TextErrors } from '@/components/';
import { DocEditor } from '@/features/docs/doc-editor';
import { Box, Text } from '@/components';
import { TextErrors } from '@/components/TextErrors';
import { DocEditor } from '@/features/docs';
import { useDoc } from '@/features/docs/doc-management';
import { MainLayout } from '@/layouts';
import { NextPageWithLayout } from '@/types/next';
const Page: NextPageWithLayout = () => {
export function DocLayout() {
const {
query: { id },
} = useRouter();
@@ -18,14 +18,18 @@ const Page: NextPageWithLayout = () => {
return null;
}
return <Doc id={id} />;
};
return (
<MainLayout>
<DocPage id={id} />
</MainLayout>
);
}
interface DocProps {
id: string;
}
const Doc = ({ id }: DocProps) => {
const DocPage = ({ id }: DocProps) => {
const { data: doc, isLoading, isError, error } = useDoc({ id });
const navigate = useNavigate();
@@ -41,7 +45,7 @@ const Doc = ({ id }: DocProps) => {
causes={error.cause}
icon={
error.status === 502 ? (
<Text className="material-icons" $theme="danger">
<Text $isMaterialIcon $theme="danger">
wifi_off
</Text>
) : undefined
@@ -62,8 +66,12 @@ const Doc = ({ id }: DocProps) => {
return <DocEditor doc={doc} />;
};
Page.getLayout = function getLayout(page: ReactElement) {
return <MainLayout>{page}</MainLayout>;
const Page: NextPageWithLayout = () => {
return null;
};
Page.getLayout = function getLayout() {
return <DocLayout />;
};
export default Page;
@@ -0,0 +1,13 @@
import { NextPageWithLayout } from '@/types/next';
import { DocLayout } from '../index';
const Page: NextPageWithLayout = () => {
return null;
};
Page.getLayout = function getLayout() {
return <DocLayout />;
};
export default Page;
@@ -0,0 +1,13 @@
import { NextPageWithLayout } from '@/types/next';
import { DocLayout } from '../index';
const Page: NextPageWithLayout = () => {
return null;
};
Page.getLayout = function getLayout() {
return <DocLayout />;
};
export default Page;
@@ -31,3 +31,13 @@ main ::-webkit-scrollbar-thumb:hover,
.ReactModalPortal ::-webkit-scrollbar-thumb:hover {
background-color: #a8bbbf;
}
.btn-unstyled {
background: none;
color: inherit;
border: none;
padding: 0;
font: inherit;
cursor: pointer;
outline: inherit;
}
@@ -1,5 +1,5 @@
{
"watch": ["src"],
"ext": "ts",
"exec": "ts-node --esm ./src/server.ts"
"exec": "yarn build"
}
@@ -1,13 +1,13 @@
{
"name": "y-webrtc-signaling",
"version": "1.0.0",
"version": "1.2.0",
"description": "WebRTC server for Yjs",
"repository": "https://github.com/numerique-gouv/impress",
"license": "MIT",
"type": "module",
"scripts": {
"build": "tsc -p .",
"dev": "nodemon --exec src/server.ts",
"dev": "nodemon --config nodemon.json",
"start": "node ./dist/server.js",
"lint": "eslint . --ext .ts"
},
@@ -15,15 +15,15 @@
"node": ">=18"
},
"dependencies": {
"lib0": "0.2.94",
"ws": "8.17.1"
"lib0": "0.2.95",
"ws": "8.18.0"
},
"devDependencies": {
"@types/node": "*",
"@types/ws": "8.5.10",
"@types/ws": "8.5.12",
"eslint-config-impress": "*",
"nodemon": "3.1.4",
"ts-jest": "29.1.5",
"ts-jest": "29.2.3",
"ts-node": "10.9.2",
"typescript": "*"
}
@@ -68,7 +68,7 @@ const onconnection = (conn: WebSocket, url: string) => {
subscribedTopics.forEach((topicName) => {
const subs = topics.get(topicName) || new Set();
subs.forEach((sub) => {
if (sub.conn === conn) {
if (sub.url === url && sub.conn === conn) {
subs.delete(sub);
}
});
@@ -96,9 +96,18 @@ const onconnection = (conn: WebSocket, url: string) => {
topicName,
() => new Set(),
);
topic.add({ url, conn });
// add topic to conn
subscribedTopics.add(topicName);
let isAlreadyAdded = false;
topic.forEach((sub) => {
if (sub.url === url && sub.conn === conn) {
isAlreadyAdded = true;
}
});
if (!isAlreadyAdded) {
topic.add({ url, conn });
subscribedTopics.add(topicName);
}
}
});
break;
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "impress",
"version": "1.0.0",
"version": "1.2.0",
"private": true,
"workspaces": {
"packages": [
@@ -24,15 +24,15 @@
"i18n:test": "yarn I18N run test"
},
"resolutions": {
"@blocknote/core": "0.14.2",
"@blocknote/mantine": "0.14.2",
"@blocknote/react": "0.14.2",
"@types/node": "20.14.9",
"@blocknote/core": "0.15.3",
"@blocknote/mantine": "0.15.3",
"@blocknote/react": "0.15.3",
"@types/node": "20.14.13",
"@types/react-dom": "18.3.0",
"cross-env": "7.0.3",
"react": "18.3.1",
"react-dom": "18.3.1",
"typescript": "5.5.2",
"typescript": "5.5.4",
"yjs": "13.6.18"
}
}
@@ -1,23 +1,23 @@
{
"name": "eslint-config-impress",
"version": "1.0.0",
"version": "1.2.0",
"license": "MIT",
"scripts": {
"lint": "eslint --ext .js ."
},
"dependencies": {
"@next/eslint-plugin-next": "14.2.4",
"@tanstack/eslint-plugin-query": "5.47.0",
"@typescript-eslint/eslint-plugin": "7.14.1",
"@next/eslint-plugin-next": "14.2.5",
"@tanstack/eslint-plugin-query": "5.51.15",
"@typescript-eslint/eslint-plugin": "7.13.1",
"eslint": "8.57.0",
"eslint-config-next": "14.2.4",
"eslint-config-next": "14.2.5",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-import": "2.29.1",
"eslint-plugin-jest": "28.6.0",
"eslint-plugin-jsx-a11y": "6.9.0",
"eslint-plugin-playwright": "1.6.2",
"eslint-plugin-prettier": "5.1.3",
"eslint-plugin-react": "7.34.3",
"eslint-plugin-prettier": "5.2.1",
"eslint-plugin-react": "7.35.0",
"eslint-plugin-testing-library": "6.2.2"
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "1.0.0",
"version": "1.2.0",
"private": true,
"scripts": {
"extract-translation": "yarn extract-translation:impress",
@@ -17,7 +17,7 @@
"eslint-plugin-import": "2.29.1",
"i18next-parser": "8.8.0",
"jest": "29.7.0",
"ts-jest": "29.1.5",
"ts-jest": "29.2.3",
"typescript": "*",
"yargs": "17.7.2"
}
+1742 -461
View File
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
image:
repository: lasuite/impress-backend
pullPolicy: Always
tag: "v1.0.0"
tag: "v1.2.0-preprod"
backend:
migrateJobAnnotations:
@@ -115,13 +115,13 @@ frontend:
image:
repository: lasuite/impress-frontend
pullPolicy: Always
tag: "v1.0.0"
tag: "v1.2.0-preprod"
webrtc:
image:
repository: lasuite/impress-y-webrtc-signaling
pullPolicy: Always
tag: "v1.0.0"
tag: "v1.2.0-preprod"
ingress:
enabled: true
@@ -1,7 +1,7 @@
image:
repository: lasuite/impress-backend
pullPolicy: Always
tag: "v1.0.0"
tag: "v1.2.0"
backend:
migrateJobAnnotations:
@@ -115,13 +115,13 @@ frontend:
image:
repository: lasuite/impress-frontend
pullPolicy: Always
tag: "v1.0.0"
tag: "v1.2.0"
webrtc:
image:
repository: lasuite/impress-y-webrtc-signaling
pullPolicy: Always
tag: "v1.0.0"
tag: "v1.2.0"
ingress:
enabled: true
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.0.0",
"version": "1.2.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "impress-openapi-client-ts",
"version": "1.0.0",
"version": "1.2.0",
"private": true,
"description": "Tool to generate Typescript API client for the impress application.",
"scripts": {