Compare commits

...

19 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
59 changed files with 3037 additions and 804 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():
+2 -2
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",
@@ -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,6 +2,7 @@
"en": { "translation": {} },
"fr": {
"translation": {
"\"{{email}}\" is already invited to the document.": "\"{{email}}\" est déjà invité à accéder au document.",
"Accessibility": "Accessibilité",
"Accessibility statement": "Déclaration d'accessibilité",
"Address:": "Adresse :",
@@ -11,6 +12,7 @@
"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 document version panel": "Fermer le panneau des versions du document",
"Close the modal": "Fermer la modale",
"Compliance status": "État de conformité",
"Confirm deletion": "Confirmer la suppression",
@@ -21,6 +23,7 @@
"Create a new document": "Créer un nouveau document",
"Create the document": "Créer le 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",
@@ -30,6 +33,7 @@
"Docs Logo": "Logo 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:",
@@ -59,12 +63,15 @@
"List members card": "Carte liste des membres",
"Logout": "Se déconnecter",
"Marianne Logo": "Logo Marianne",
"Members": "Membres",
"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 ?!",
"Open the document options": "Ouvrir les options du document",
"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:",
@@ -72,10 +79,13 @@
"Public": "Public",
"Publication Director": "Directeur de la publication",
"Publisher": "Éditeur",
"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",
"Restore the version": "Restaurer la version",
"Role": "Rôle",
"Search new members by email": "Rechercher les nouveaux membres par e-mail",
"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.",
@@ -97,7 +107,7 @@
"Update document \"{{documentTitle}}\"": "Mettre à jour le document \"{{documentTitle}}\"",
"Updated at": "Mise à jour le",
"User {{email}} added to the document.": "L'utilisateur {{email}} a été ajouté au document.",
"Users number": "Nombre d'utilisateurs",
"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",
@@ -107,6 +117,7 @@
"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 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",
@@ -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": {