Compare commits

..

22 Commits

Author SHA1 Message Date
Anthony LC 2035a256f5 🔖(patch) release 1.8.1
Fixed:
🐛(frontend) link not clickable and flickering firefox
2024-11-27 17:17:35 +01:00
Anthony LC c94f26c8b9 ⬇️(SW) workbox-webpack-plugin to 7.1.0
A recent update to the workbox-webpack-plugin
package seems to introduce strange behavior.
Better to downgrade in waiting that it is more stable.
2024-11-27 16:50:11 +01:00
Anthony LC fc2f14b3f4 🐛(frontend) link not clickable and flickering firefox
The link in the read mode was not clickable anymore,
it was due to a attempt to not display the cursor
of anonymous users.
We changes the way to do it by rendering our own cursor,
when a user is anonymous we don't render the cursor.
By rendering our own cursor we fixed another problem,
the cursor was flickering when the user was typing
at the end of the line on the firefox browser.
2024-11-27 16:50:11 +01:00
Anthony LC 6dd1697915 🐛(frontend) use hook useTranslation
Sentry highlitghted a few errors about the
function "t" not being defined. Better to get
it from the hook useTranslation.
2024-11-27 16:50:11 +01:00
Anthony LC 79e899c301 ♻️(frontend) add hooks useUploadFile
Move upload file logic to hooks useUploadFile.
It will be more readable and easy to reuse.
2024-11-27 16:50:11 +01:00
Anthony LC 2194301716 🔖(minor) release 1.8.0
Added:
- 🌐(backend) add german translation
- 🌐(frontend) Add German translation
- (frontend) Add a broadcast store
- (backend) whitelist pod's IP address
- (backend) config endpoint
- (frontend) config endpoint
- (frontend) add sentry
- (frontend) add crisp chatbot

Changed:
- 🚸(backend) improve users similarity search and
sort results
- ♻️(frontend) simplify stores
- (frontend) update $css Box props type to add
styled components RuleSet
- (CI) trivy continue on error

Fixed:
- 🔧(backend) fix logging for docker and make it
configurable by envar
- 🦺(backend) add comma to sub regex
- 🐛(editor) collaborative user tag hidden when
read only
- 🐛(frontend) users have view access when revoked
- 🐛(frontend) fix placeholder editable when double clicks
2024-11-27 09:47:42 +01:00
Anthony LC 0348894ab8 🐛(frontend) fix rerender title with broadcasting
The title was not rerendering on other clients
when the title was updated by one client.
This commit fixes the issue.
We set a min width for the title as well, it
will fix the issue with strange behavior when
people were double clicking.
2024-11-26 18:15:18 +01:00
Anthony LC 9b17d8bea1 🚨(frontend) remove Crisp warning
Remove the Crisp warning that was being displayed
on the console in our environments.
2024-11-26 18:15:18 +01:00
Anthony LC 69d6b6f934 (CI) trivy continue on error
Trivy is extremly flaky,
we need to continue on error to avoid
blocking the pipeline.
We still keep the check, to see if there are any
vulnerabilities, but we don't want to block
the pipeline.
2024-11-26 11:53:11 +01:00
Anthony LC 6c106374fa (frontend) add crisp chatbot
Integrate Crisp chatbot for immediate user support access.

This enables real-time interaction, enhancing user experience
by providing quick assistance.
2024-11-25 17:06:02 +01:00
Anthony LC af039d045d 🔧(backend) add CRISP_WEBSITE_ID setting
Add setting CRISP_WEBSITE_ID. This setting is
used to configure the Crisp chat widget.
It will be available to the conf endpoint, to
be used by the frontend.
2024-11-25 17:06:02 +01:00
Anthony LC 4c9caf09ba ⬆️(CI) upgrade upload-artifact@v3 to v4
Upload artifact v3 is deprecated soon, so we need
to upgrade it to v4.
2024-11-25 13:16:06 +01:00
Anthony LC 3fd02adbec 💄(frontend) remove Blocknote fix
A recent upgrade of Blocknote to 0.19.2 fixed
a issue that we were solving. We removed our
fix as it is no longer needed.
2024-11-25 13:16:06 +01:00
Anthony LC 90dac3cd15 🏷️(frontend) update typescript types
We updated typescript to 5.7.2.
Some types were deprecated and we had to update them.
2024-11-25 13:16:06 +01:00
Anthony LC d0307ee6d9 ⬆️(dependencies) update js dependencies 2024-11-25 13:16:06 +01:00
Anthony LC 09d02b7ced 🚚(frontend) move conf api urls to api folder
Previous refacto let only the api urls in the conf
file, so better to move it to the api folder.
2024-11-25 09:46:14 +01:00
Anthony LC 56a26d9663 🧪(CI) pass trivy security
The trivy security blocked the deploiement.
It says that we have a vulnerability because
we are using the cross-spawn@7.0.3 package, but
we are not, we are using the cross-spawn@7.0.6 package.
We will bypass this security check in the docker-hub.yml
file in waiting for another solution.
2024-11-25 09:46:14 +01:00
Anthony LC 42f809f6d4 ♻️(frontend) get collaboration server url from config endpoint
We centralized the configuration on the backend
side, it is easier to manage and we can change
the configuration without having to rebuild the
frontend.
We now use the config endpoint to get the collaboration
server url, we refacto to remove the frontend env
occurences and to adapt with the new way to get the
collaboration server url.
2024-11-25 09:46:14 +01:00
Anthony LC 7d64c82987 ♻️(frontend) get media url from config endpoint
We centralized the configuration on the backend
side, it is easier to manage and we can change
the configuration without having to rebuild the
frontend.
We now use the config endpoint to get the media url,
we refacto to remove the frontend env occurences
and to adapt with the new way to get the media url.
2024-11-25 09:46:14 +01:00
Anthony LC 6252227bb6 ♻️(frontend) get theme from config endpoint
We centralized the configuration on the backend
side, it is easier to manage and we can change
the configuration without having to rebuild the
frontend.
We now use the config endpoint to get the theme,
we refacto to remove the frontend env occurences
and to adapt with the new way to get the theme.
2024-11-25 09:46:14 +01:00
Anthony LC e9ac393a8f (frontend) add sentry
In order to monitor the frontend, we are adding
sentry.
2024-11-25 09:46:14 +01:00
Anthony LC 5b1745f991 (frontend) add config provider
Add a ConfigProvider to the frontend to provide
configuration to the app.
The configuration is loaded from the config
endpoint, we will use react-query cache capabilities
to store the configuration.
2024-11-25 09:46:14 +01:00
122 changed files with 4466 additions and 4110 deletions
+3 -1
View File
@@ -6,7 +6,6 @@ on:
push:
branches:
- 'main'
- 'main-new-ui'
tags:
- 'v*'
pull_request:
@@ -56,6 +55,7 @@ jobs:
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: 'docker.io/lasuite/impress-backend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
@@ -106,6 +106,7 @@ jobs:
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
@@ -157,6 +158,7 @@ jobs:
with:
docker-build-args: '-f src/frontend/Dockerfile --target y-provider'
docker-image-name: 'docker.io/lasuite/impress-frontend:${{ github.sha }}'
continue-on-error: true
-
name: Build and push
uses: docker/build-push-action@v6
+2 -2
View File
@@ -99,7 +99,7 @@ jobs:
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project='chromium'
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-chromium-report
@@ -133,7 +133,7 @@ jobs:
- name: Run e2e tests
run: cd src/frontend/ && yarn e2e:test --project=firefox --project=webkit
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-other-report
+19 -9
View File
@@ -9,26 +9,33 @@ and this project adheres to
## [Unreleased]
## [1.8.1] - 2024-11-27
## Fixed
🐛(frontend) link not clickable and flickering firefox #457
## [1.8.0] - 2024-11-25
## Added
- 🌐(backend) add german translation #259
- 🌐(frontend) Add German translation #255
- ✨(frontend) Add a broadcast store #387
- ✨(frontend) WIP: New ui
- 💄(frontend) Add left panel #420
- 💄(frontend) updating the header and leftpanel for responsive #421
- ✨(backend) config endpoint #425
- 💄(frontend) update DocsGrid component #431
- ✨(backend) whitelist pod's IP address #443
- 💄(frontend) update DocsGridOptions component #432
- 💄(frontend) update DocHeader ui #446
- ✨(backend) config endpoint #425
- (frontend) config endpoint #424
- ✨(frontend) add sentry #424
- ✨(frontend) add crisp chatbot #450
## Changed
- 🚸(backend) improve users similarity search and sort results #391
- ♻️(frontend) simplify stores #402
- ✨(frontend) update $css Box props type to add styled components RuleSet #423
- ✅(CI) trivy continue on error #453
## Fixed
@@ -36,6 +43,7 @@ and this project adheres to
- 🦺(backend) add comma to sub regex #408
- 🐛(editor) collaborative user tag hidden when read only #385
- 🐛(frontend) users have view access when revoked #387
- 🐛(frontend) fix placeholder editable when double clicks #454
## [1.7.0] - 2024-10-24
@@ -267,7 +275,9 @@ and this project adheres to
- 🚀 Impress, project to manage your documents easily and collaboratively.
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.7.0...main
[unreleased]: https://github.com/numerique-gouv/impress/compare/v1.8.1...main
[v1.8.1]: https://github.com/numerique-gouv/impress/releases/v1.8.1
[v1.8.0]: https://github.com/numerique-gouv/impress/releases/v1.8.0
[v1.7.0]: https://github.com/numerique-gouv/impress/releases/v1.7.0
[v1.6.0]: https://github.com/numerique-gouv/impress/releases/v1.6.0
[1.5.1]: https://github.com/numerique-gouv/impress/releases/v1.5.1
+1
View File
@@ -902,6 +902,7 @@ class ConfigView(views.APIView):
"""
array_settings = [
"COLLABORATION_SERVER_URL",
"CRISP_WEBSITE_ID",
"ENVIRONMENT",
"FRONTEND_THEME",
"MEDIA_BASE_URL",
+9 -27
View File
@@ -17,17 +17,25 @@ pytestmark = pytest.mark.django_db
@override_settings(
COLLABORATION_SERVER_URL="http://testcollab/",
CRISP_WEBSITE_ID="123",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
SENTRY_DSN="https://sentry.test/123",
)
def test_api_config_anonymous():
@pytest.mark.parametrize("is_authenticated", [False, True])
def test_api_config(is_authenticated):
"""Anonymous users should be allowed to get the configuration."""
client = APIClient()
if is_authenticated:
user = factories.UserFactory()
client.force_login(user)
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"COLLABORATION_SERVER_URL": "http://testcollab/",
"CRISP_WEBSITE_ID": "123",
"ENVIRONMENT": "test",
"FRONTEND_THEME": "test-theme",
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
@@ -35,29 +43,3 @@ def test_api_config_anonymous():
"MEDIA_BASE_URL": "http://testserver/",
"SENTRY_DSN": "https://sentry.test/123",
}
@override_settings(
COLLABORATION_SERVER_URL="http://testcollab/",
FRONTEND_THEME="test-theme",
MEDIA_BASE_URL="http://testserver/",
SENTRY_DSN="https://sentry.test/123",
)
def test_api_config_authenticated():
"""Authenticated users should be allowed to get the configuration."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/config/")
assert response.status_code == HTTP_200_OK
assert response.json() == {
"COLLABORATION_SERVER_URL": "http://testcollab/",
"ENVIRONMENT": "test",
"FRONTEND_THEME": "test-theme",
"MEDIA_BASE_URL": "http://testserver/",
"LANGUAGES": [["en-us", "English"], ["fr-fr", "French"], ["de-de", "German"]],
"LANGUAGE_CODE": "en-us",
"SENTRY_DSN": "https://sentry.test/123",
}
+5
View File
@@ -381,6 +381,11 @@ class Base(Configuration):
None, environ_name="FRONTEND_THEME", environ_prefix=None
)
# Crisp
CRISP_WEBSITE_ID = values.Value(
None, environ_name="CRISP_WEBSITE_ID", environ_prefix=None
)
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
THUMBNAIL_TRANSPARENCY_EXTENSION = "webp"
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "impress"
version = "1.7.0"
version = "1.8.1"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
-9
View File
@@ -61,18 +61,9 @@ FROM impress AS impress-builder
WORKDIR /home/frontend/apps/impress
ARG FRONTEND_THEME
ENV NEXT_PUBLIC_THEME=${FRONTEND_THEME}
ARG Y_PROVIDER_URL
ENV NEXT_PUBLIC_Y_PROVIDER_URL=${Y_PROVIDER_URL}
ARG API_ORIGIN
ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
ARG MEDIA_URL
ENV NEXT_PUBLIC_MEDIA_URL=${MEDIA_URL}
ARG SW_DEACTIVATED
ENV NEXT_PUBLIC_SW_DEACTIVATED=${SW_DEACTIVATED}
@@ -36,25 +36,18 @@ export const createDoc = async (
await page
.getByRole('button', {
name: 'New doc',
name: 'Create a new document',
})
.click();
const input = page.getByRole('textbox', { name: 'doc title input' });
await input.click();
await input.fill(randomDocs[i]);
await input.blur();
await page.getByRole('heading', { name: 'Untitled document' }).click();
await page.keyboard.type(randomDocs[i]);
await page.getByText('Created at ').click();
}
return randomDocs;
};
export const verifyDocName = async (page: Page, docName: string) => {
const input = page.getByRole('textbox', { name: 'doc title input' });
await expect(input).toBeVisible();
await expect(input).toHaveValue(docName);
};
export const addNewMember = async (
page: Page,
index: number,
@@ -104,22 +97,24 @@ export const goToGridDoc = async (
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
const rows = docsGrid.getByRole('row');
expect(await rows.count()).toEqual(20);
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
const rows = datagridTable.getByRole('row');
const row = title
? rows.filter({
hasText: title,
})
: rows.nth(nthRow);
await expect(row).toBeVisible();
const docTitleCell = row.getByRole('cell').nth(1);
const docTitle = await docTitleCell.textContent();
const docTitleContent = row.locator('[aria-describedby="doc-title"]').first();
const docTitle = await docTitleContent.textContent();
expect(docTitle).toBeDefined();
await row.getByRole('link').first().click();
@@ -0,0 +1,160 @@
import path from 'path';
import { expect, test } from '@playwright/test';
import { createDoc } from './common';
const config = {
CRISP_WEBSITE_ID: null,
COLLABORATION_SERVER_URL: 'ws://localhost:4444',
ENVIRONMENT: 'development',
FRONTEND_THEME: 'dsfr',
MEDIA_BASE_URL: 'http://localhost:8083',
LANGUAGES: [
['en-us', 'English'],
['fr-fr', 'French'],
['de-de', 'German'],
],
LANGUAGE_CODE: 'en-us',
SENTRY_DSN: null,
};
test.describe('Config', () => {
test('it checks the config api is called', async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/config/') && response.status() === 200,
);
await page.goto('/');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
expect(await response.json()).toStrictEqual(config);
});
test('it checks that sentry is trying to init from config endpoint', async ({
page,
}) => {
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
SENTRY_DSN: 'https://sentry.io/123',
},
});
} else {
await route.continue();
}
});
const invalidMsg = 'Invalid Sentry Dsn: https://sentry.io/123';
const consoleMessage = page.waitForEvent('console', {
timeout: 5000,
predicate: (msg) => msg.text().includes(invalidMsg),
});
await page.goto('/');
expect((await consoleMessage).text()).toContain(invalidMsg);
});
test('it checks that theme is configured from config endpoint', async ({
page,
}) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes('/config/') && response.status() === 200,
);
await page.goto('/');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const jsonResponse = await response.json();
expect(jsonResponse.FRONTEND_THEME).toStrictEqual('dsfr');
const footer = page.locator('footer').first();
// alt 'Gouvernement Logo' comes from the theme
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
});
test('it checks that media server is configured from config endpoint', async ({
page,
browserName,
}) => {
await page.goto('/');
await createDoc(page, 'doc-media', browserName, 1);
const fileChooserPromise = page.waitForEvent('filechooser');
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Resizable image with caption').click();
await page.getByText('Upload image').click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(
path.join(__dirname, 'assets/logo-suite-numerique.png'),
);
const image = page.getByRole('img', { name: 'logo-suite-numerique.png' });
await expect(image).toBeVisible();
// Check src of image
expect(await image.getAttribute('src')).toMatch(
/http:\/\/localhost:8083\/media\/.*\/attachments\/.*.png/,
);
});
test('it checks that collaboration server is configured from config endpoint', async ({
page,
browserName,
}) => {
const webSocketPromise = page.waitForEvent('websocket', (webSocket) => {
return webSocket.url().includes('ws://localhost:4444/');
});
await page.goto('/');
const randomDoc = await createDoc(
page,
'doc-collaboration',
browserName,
1,
);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain('ws://localhost:4444/');
});
test('it checks that Crisp is trying to init from config endpoint', async ({
page,
}) => {
await page.route('**/api/v1.0/config/', async (route) => {
const request = route.request();
if (request.method().includes('GET')) {
await route.fulfill({
json: {
...config,
CRISP_WEBSITE_ID: '1234',
},
});
} else {
await route.continue();
}
});
await page.goto('/');
await expect(
page.locator('#crisp-chatbox').getByText('Invalid website'),
).toBeVisible();
});
});
@@ -18,11 +18,14 @@ test.describe('Doc Create', () => {
const header = page.locator('header').first();
await header.locator('h2').getByText('Docs').click();
await expect(page.getByTestId('docs-grid-loader')).toBeVisible();
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
await expect(docsGrid.getByText(docTitle)).toBeVisible();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(datagridTable.getByText(docTitle)).toBeVisible({
timeout: 5000,
});
});
});
@@ -2,12 +2,7 @@ import path from 'path';
import { expect, test } from '@playwright/test';
import {
createDoc,
goToGridDoc,
mockedDocument,
verifyDocName,
} from './common';
import { createDoc, goToGridDoc, mockedDocument } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -95,7 +90,7 @@ test.describe('Doc Editor', () => {
});
const randomDoc = await createDoc(page, 'doc-editor', browserName, 1);
await verifyDocName(page, randomDoc[0]);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
const webSocket = await webSocketPromise;
expect(webSocket.url()).toContain('ws://localhost:4444/');
@@ -115,7 +110,7 @@ test.describe('Doc Editor', () => {
}) => {
const randomDoc = await createDoc(page, 'doc-markdown', browserName, 1);
await verifyDocName(page, randomDoc[0]);
await expect(page.locator('h2').getByText(randomDoc[0])).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
@@ -140,7 +135,7 @@ test.describe('Doc Editor', () => {
}) => {
// Check the first doc
const [firstDoc] = await createDoc(page, 'doc-switch-1', browserName, 1);
await verifyDocName(page, firstDoc);
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
@@ -149,8 +144,7 @@ test.describe('Doc Editor', () => {
// Check the second doc
const [secondDoc] = await createDoc(page, 'doc-switch-2', browserName, 1);
await verifyDocName(page, secondDoc);
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await expect(editor.getByText('Hello World Doc 1')).toBeHidden();
await editor.click();
await editor.fill('Hello World Doc 2');
@@ -160,7 +154,7 @@ test.describe('Doc Editor', () => {
await goToGridDoc(page, {
title: firstDoc,
});
await verifyDocName(page, firstDoc);
await expect(page.locator('h2').getByText(firstDoc)).toBeVisible();
await expect(editor.getByText('Hello World Doc 2')).toBeHidden();
await expect(editor.getByText('Hello World Doc 1')).toBeVisible();
});
@@ -171,7 +165,7 @@ test.describe('Doc Editor', () => {
}) => {
// Check the first doc
const [doc] = await createDoc(page, 'doc-saves-change', browserName, 1);
await verifyDocName(page, doc);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
@@ -182,7 +176,7 @@ test.describe('Doc Editor', () => {
nthRow: 2,
});
await verifyDocName(page, secondDoc);
await expect(page.locator('h2').getByText(secondDoc)).toBeVisible();
await goToGridDoc(page, {
title: doc,
@@ -197,8 +191,7 @@ test.describe('Doc Editor', () => {
// Check the first doc
const doc = await goToGridDoc(page);
await verifyDocName(page, doc);
await expect(page.locator('h2').getByText(doc)).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.click();
@@ -3,7 +3,7 @@ import cs from 'convert-stream';
import jsdom from 'jsdom';
import pdf from 'pdf-parse';
import { createDoc, verifyDocName } from './common';
import { createDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
@@ -20,7 +20,7 @@ test.describe('Doc Export', () => {
return download.suggestedFilename().includes(`${randomDoc}.pdf`);
});
await verifyDocName(page, randomDoc);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
@@ -57,7 +57,7 @@ test.describe('Doc Export', () => {
return download.suggestedFilename().includes(`${randomDoc}.docx`);
});
await verifyDocName(page, randomDoc);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.ProseMirror.bn-editor').click();
await page.locator('.ProseMirror.bn-editor').fill('Hello World');
@@ -97,7 +97,7 @@ test.describe('Doc Export', () => {
await route.continue();
});
await verifyDocName(page, randomDoc);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.locator('.bn-block-outer').last().fill('Hello World');
await page.locator('.bn-block-outer').last().click();
@@ -1,14 +1,264 @@
import { expect, test } from '@playwright/test';
type SmallDoc = {
id: string;
title: string;
};
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Documents Grid', () => {
test('checks all the elements are visible', async ({ page }) => {
await expect(page.locator('h2').getByText('Documents')).toBeVisible();
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const thead = datagrid.locator('thead');
await expect(thead.getByText(/Document name/i)).toBeVisible();
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(/Members/i)).toBeVisible();
const row1 = datagrid.getByRole('row').nth(1).getByRole('cell');
const docName = await row1.nth(1).textContent();
expect(docName).toBeDefined();
const docCreatedAt = await row1.nth(2).textContent();
expect(docCreatedAt).toBeDefined();
const docUpdatedAt = await row1.nth(3).textContent();
expect(docUpdatedAt).toBeDefined();
const docRole = await row1.nth(4).textContent();
expect(
docRole &&
['Administrator', 'Owner', 'Reader', 'Editor'].includes(docRole),
).toBeTruthy();
const docUserNumber = await row1.nth(5).textContent();
expect(docUserNumber).toBeDefined();
// Open the document
await row1.nth(1).click();
await expect(page.locator('h2').getByText(docName!)).toBeVisible();
});
[
{
nameColumn: 'Document name',
ordering: 'title',
cellNumber: 1,
orderDefault: '',
orderDesc: '&ordering=-title',
orderAsc: '&ordering=title',
defaultColumn: false,
},
{
nameColumn: 'Created at',
ordering: 'created_at',
cellNumber: 2,
orderDefault: '',
orderDesc: '&ordering=-created_at',
orderAsc: '&ordering=created_at',
defaultColumn: false,
},
{
nameColumn: 'Updated at',
ordering: 'updated_at',
cellNumber: 3,
orderDefault: '&ordering=-updated_at',
orderDesc: '&ordering=updated_at',
orderAsc: '',
defaultColumn: true,
},
].forEach(
({
nameColumn,
ordering,
cellNumber,
orderDefault,
orderDesc,
orderAsc,
defaultColumn,
}) => {
test(`checks datagrid ordering ${ordering}`, async ({ page }) => {
const responsePromise = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderDefault}`) &&
response.status() === 200,
);
const responsePromiseOrderingDesc = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderDesc}`) &&
response.status() === 200,
);
const responsePromiseOrderingAsc = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1${orderAsc}`) &&
response.status() === 200,
);
// Checks the initial state
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const datagridTable = datagrid.getByRole('table');
const thead = datagridTable.locator('thead');
const response = await responsePromise;
expect(response.ok()).toBeTruthy();
const docNameRow1 = datagridTable
.getByRole('row')
.nth(1)
.getByRole('cell')
.nth(cellNumber);
const docNameRow2 = datagridTable
.getByRole('row')
.nth(2)
.getByRole('cell')
.nth(cellNumber);
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
// Initial state
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const initialDocNameRow1 = await docNameRow1.textContent();
const initialDocNameRow2 = await docNameRow2.textContent();
expect(initialDocNameRow1).toBeDefined();
expect(initialDocNameRow2).toBeDefined();
// Ordering ASC
await thead.getByText(nameColumn).click();
const responseOrderingAsc = await responsePromiseOrderingAsc;
expect(responseOrderingAsc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Asc = await docNameRow1.textContent();
const textDocNameRow2Asc = await docNameRow2.textContent();
const compare = (comp1: string, comp2: string) => {
const comparisonResult = comp1.localeCompare(comp2, 'en', {
caseFirst: 'false',
ignorePunctuation: true,
});
// eslint-disable-next-line playwright/no-conditional-in-test
return defaultColumn ? comparisonResult >= 0 : comparisonResult <= 0;
};
expect(
textDocNameRow1Asc &&
textDocNameRow2Asc &&
compare(textDocNameRow1Asc, textDocNameRow2Asc),
).toBeTruthy();
// Ordering Desc
await thead.getByText(nameColumn).click();
const responseOrderingDesc = await responsePromiseOrderingDesc;
expect(responseOrderingDesc.ok()).toBeTruthy();
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
await expect(docNameRow1).toHaveText(/.*/);
await expect(docNameRow2).toHaveText(/.*/);
const textDocNameRow1Desc = await docNameRow1.textContent();
const textDocNameRow2Desc = await docNameRow2.textContent();
expect(
textDocNameRow1Desc &&
textDocNameRow2Desc &&
compare(textDocNameRow2Desc, textDocNameRow1Desc),
).toBeTruthy();
});
},
);
test('checks the pagination', async ({ page }) => {
const responsePromisePage1 = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1`) &&
response.status() === 200,
);
const responsePromisePage2 = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=2`) &&
response.status() === 200,
);
const datagridPage1 = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const responsePage1 = await responsePromisePage1;
expect(responsePage1.ok()).toBeTruthy();
await expect(
datagridPage1.getByRole('row').nth(1).getByRole('cell').nth(1),
).toHaveText(/.*/);
await page.getByLabel('Go to page 2').click();
const datagridPage2 = page
.getByLabel('Datagrid of the documents page 2')
.getByRole('table');
const responsePage2 = await responsePromisePage2;
expect(responsePage2.ok()).toBeTruthy();
await expect(
datagridPage2.getByRole('row').nth(1).getByRole('cell').nth(1),
).toHaveText(/.*/);
});
test('it deletes the document', async ({ page }) => {
const datagrid = page
.getByLabel('Datagrid of the documents page 1')
.getByRole('table');
const docRow = datagrid.getByRole('row').nth(1).getByRole('cell');
const docName = await docRow.nth(1).textContent();
await docRow
.getByRole('button', {
name: 'Delete the document',
})
.click();
await expect(
page.locator('h2').getByText(`Deleting the document "${docName}"`),
).toBeVisible();
await page
.getByRole('button', {
name: 'Confirm deletion',
})
.click();
await expect(
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(datagrid.getByText(docName!)).toBeHidden();
});
});
test.describe('Documents Grid mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
@@ -76,183 +326,19 @@ test.describe('Documents Grid mobile', () => {
await page.goto('/');
const docsGrid = page.getByTestId('docs-grid');
await expect(docsGrid).toBeVisible();
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
const datagrid = page.getByLabel('Datagrid of the documents page 1');
const tableDatagrid = datagrid.getByRole('table');
const rows = docsGrid.getByRole('row');
await expect(datagrid.getByLabel('Loading data')).toBeHidden({
timeout: 10000,
});
const rows = tableDatagrid.getByRole('row');
const row = rows.filter({
hasText: 'My mocked document',
});
await expect(
row.locator('[aria-describedby="doc-title"]').nth(0),
).toHaveText('My mocked document');
});
});
test.describe('Document grid item options', () => {
test('it deletes the document', async ({ page }) => {
let docs: SmallDoc[] = [];
const response = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1') &&
response.status() === 200,
);
const result = await response.json();
docs = result.results as SmallDoc[];
const button = page.getByTestId(`docs-grid-actions-button-${docs[0].id}`);
await expect(button).toBeVisible();
await button.click();
const removeButton = page.getByTestId(
`docs-grid-actions-remove-${docs[0].id}`,
);
await expect(removeButton).toBeVisible();
await removeButton.click();
await expect(
page.locator('h2').getByText(`Deleting the document "${docs[0].title}"`),
).toBeVisible();
await page
.getByRole('button', {
name: 'Confirm deletion',
})
.click();
const refetchResponse = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1') &&
response.status() === 200,
);
const resultRefetch = await refetchResponse.json();
expect(resultRefetch.count).toBe(result.count - 1);
await expect(page.getByTestId('main-layout-loader')).toBeHidden();
await expect(
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(button).toBeHidden();
});
test("it checks if the delete option is disabled if we don't have the destroy capability", async ({
page,
}) => {
await page.route('*/**/api/v1.0/documents/?page=1', async (route) => {
await route.fulfill({
json: {
results: [
{
id: 'mocked-document-id',
content: '',
title: 'Mocked document',
accesses: [],
abilities: {
destroy: false, // Means not owner
link_configuration: false,
versions_destroy: false,
versions_list: true,
versions_retrieve: true,
accesses_manage: false, // Means not admin
update: false,
partial_update: false, // Means not editor
retrieve: true,
},
link_reach: 'restricted',
created_at: '2021-09-01T09:00:00Z',
},
],
},
});
});
await page.goto('/');
const button = page.getByTestId(
`docs-grid-actions-button-mocked-document-id`,
);
await expect(button).toBeVisible();
await button.click();
const removeButton = page.getByTestId(
`docs-grid-actions-remove-mocked-document-id`,
);
await expect(removeButton).toBeVisible();
await removeButton.isDisabled();
});
});
test.describe('Documents Grid', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the elements are visible', async ({ page }) => {
let docs: SmallDoc[] = [];
await expect(page.getByTestId('docs-grid-loader')).toBeVisible();
const response = await page.waitForResponse(
(response) =>
response.url().includes('documents/?page=1') &&
response.status() === 200,
);
const result = await response.json();
docs = result.results as SmallDoc[];
await expect(page.getByTestId('docs-grid-loader')).toBeHidden();
await expect(page.locator('h4').getByText('All docs')).toBeVisible();
const thead = page.getByTestId('docs-grid-header');
await expect(thead.getByText(/Name/i)).toBeVisible();
await expect(thead.getByText(/Updated at/i)).toBeVisible();
await Promise.all(
docs.map(async (doc) => {
await expect(
page.getByTestId(`docs-grid-name-${doc.id}`),
).toBeVisible();
}),
);
});
test('checks the infinite scroll', async ({ page }) => {
let docs: SmallDoc[] = [];
const responsePromisePage1 = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=1`) &&
response.status() === 200,
);
const responsePromisePage2 = page.waitForResponse(
(response) =>
response.url().includes(`/documents/?page=2`) &&
response.status() === 200,
);
const responsePage1 = await responsePromisePage1;
expect(responsePage1.ok()).toBeTruthy();
let result = await responsePage1.json();
docs = result.results as SmallDoc[];
await Promise.all(
docs.map(async (doc) => {
await expect(
page.getByTestId(`docs-grid-name-${doc.id}`),
).toBeVisible();
}),
);
await page.getByTestId('infinite-scroll-trigger').scrollIntoViewIfNeeded();
const responsePage2 = await responsePromisePage2;
result = await responsePage2.json();
docs = result.results as SmallDoc[];
await Promise.all(
docs.map(async (doc) => {
await expect(
page.getByTestId(`docs-grid-name-${doc.id}`),
).toBeVisible();
}),
);
await expect(row.getByRole('cell').nth(0)).toHaveText('My mocked document');
await expect(row.getByRole('cell').nth(1)).toHaveText('Public');
});
});
@@ -6,7 +6,6 @@ import {
mockedAccesses,
mockedDocument,
mockedInvitations,
verifyDocName,
} from './common';
test.beforeEach(async ({ page }) => {
@@ -60,30 +59,84 @@ test.describe('Doc Header', () => {
const card = page.getByLabel(
'It is the card information about the document.',
);
await expect(card.getByPlaceholder('Untitled document')).toBeVisible();
await expect(card.getByText('Public document')).toBeVisible();
await expect(card.getByText('Owner ·')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await expect(page.getByRole('button', { name: 'download' })).toBeVisible();
await expect(card.locator('a').getByText('home')).toBeVisible();
await expect(card.locator('h2').getByText('Mocked document')).toBeVisible();
await expect(card.getByText('Public')).toBeVisible();
await expect(
page.getByRole('button', { name: 'Open the document options' }),
card.getByText('Created at 09/01/2021, 11:00 AM'),
).toBeVisible();
await expect(
card.getByText('Owners: Super Owner / super2@owner.com'),
).toBeVisible();
await expect(card.getByText('Your role: Owner')).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
test('it updates the title doc', async ({ page, browserName }) => {
await createDoc(page, 'doc-update', browserName, 1);
const docTitle = page.getByRole('textbox', { name: 'doc title input' });
await expect(docTitle).toBeVisible();
await docTitle.fill('Hello World');
await docTitle.blur();
await verifyDocName(page, 'Hello World');
const [randomDoc] = await createDoc(page, 'doc-update', browserName, 1);
await page.getByRole('heading', { name: randomDoc }).fill(' ');
await page.getByText('Created at').click();
await expect(
page.getByRole('heading', { name: 'Untitled document' }),
).toBeVisible();
});
test('it updates the title doc from editor heading', async ({ page }) => {
await page
.getByRole('button', {
name: 'Create a new document',
})
.click();
const docHeader = page.getByLabel(
'It is the card information about the document.',
);
await expect(
docHeader.getByRole('heading', { name: 'Untitled document', level: 2 }),
).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.locator('h1').click();
await page.keyboard.type('Hello World', { delay: 100 });
await expect(
docHeader.getByRole('heading', { name: 'Hello World', level: 2 }),
).toBeVisible();
await expect(
page.getByText('Document title updated successfully'),
).toBeVisible();
await docHeader
.getByRole('heading', { name: 'Hello World', level: 2 })
.fill('Top World');
await editor.locator('h1').fill('Super World');
await expect(
docHeader.getByRole('heading', { name: 'Top World', level: 2 }),
).toBeVisible();
await editor.locator('h1').fill('');
await docHeader
.getByRole('heading', { name: 'Top World', level: 2 })
.fill(' ');
await page.getByText('Created at').click();
await expect(
docHeader.getByRole('heading', { name: 'Untitled document', level: 2 }),
).toBeVisible();
});
test('it deletes the doc', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
@@ -106,7 +159,9 @@ test.describe('Doc Header', () => {
page.getByText('The document has been deleted.'),
).toBeVisible();
await expect(page.getByRole('button', { name: 'New do' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Create a new document' }),
).toBeVisible();
const row = page
.getByLabel('Datagrid of the documents page 1')
@@ -140,12 +195,16 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(
page.locator('h2').getByText('Mocked document'),
).toHaveAttribute('contenteditable');
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -214,12 +273,16 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(
page.locator('h2').getByText('Mocked document'),
).toHaveAttribute('contenteditable');
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -288,12 +351,16 @@ test.describe('Doc Header', () => {
await goToGridDoc(page);
await expect(
page.locator('h2').getByText('Mocked document'),
).not.toHaveAttribute('contenteditable');
await page.getByLabel('Open the document options').click();
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Delete document' }),
).toBeDisabled();
).toBeHidden();
// Click somewhere else to close the options
await page.click('body', { position: { x: 0, y: 0 } });
@@ -347,7 +414,7 @@ test.describe('Doc Header', () => {
// create page and navigate to it
await page
.getByRole('button', {
name: 'New doc',
name: 'Create a new document',
})
.click();
@@ -382,7 +449,7 @@ test.describe('Doc Header', () => {
// create page and navigate to it
await page
.getByRole('button', {
name: 'New doc',
name: 'Create a new document',
})
.click();
@@ -434,7 +501,6 @@ test.describe('Documents Header mobile', () => {
await goToGridDoc(page);
await page.getByLabel('Open the document options').click();
await page.getByRole('button', { name: 'Share' }).click();
await expect(page.getByLabel('Share modal')).toBeVisible();
@@ -126,9 +126,8 @@ test.describe('Document list members', () => {
test('it checks the role rules', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
const input = page.getByPlaceholder('Untitled document');
await expect(input).toBeVisible();
await expect(input).toHaveValue(docTitle);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
@@ -189,9 +188,7 @@ test.describe('Document list members', () => {
test('it checks the delete members', async ({ page, browserName }) => {
const [docTitle] = await createDoc(page, 'Doc role rules', browserName, 1);
const input = page.getByPlaceholder('Untitled document');
await expect(input).toBeVisible();
await expect(input).toHaveValue(docTitle);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
@@ -9,7 +9,7 @@ test.describe('Doc Routing', () => {
test('Check the presence of the meta tag noindex', async ({ page }) => {
const buttonCreateHomepage = page.getByRole('button', {
name: 'New doc',
name: 'Create a new document',
});
await expect(buttonCreateHomepage).toBeVisible();
@@ -27,7 +27,7 @@ test.describe('Doc Routing', () => {
await expect(page).toHaveURL('/');
const buttonCreateHomepage = page.getByRole('button', {
name: 'New doc',
name: 'Create a new document',
});
await expect(buttonCreateHomepage).toBeVisible();
@@ -17,9 +17,7 @@ test.describe('Doc Table Content', () => {
1,
);
const input = page.getByPlaceholder('Untitled document');
await expect(input).toBeVisible();
await expect(input).toHaveValue(randomDoc);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
@@ -38,7 +36,6 @@ test.describe('Doc Table Content', () => {
await page.getByRole('button', { name: 'Strike' }).click();
await page.locator('.bn-block-outer').first().click();
await editor.click();
await page.locator('.bn-block-outer').last().click();
// Create space to fill the viewport
@@ -110,9 +107,7 @@ test.describe('Doc Table Content', () => {
1,
);
const input = page.getByPlaceholder('Untitled document');
await expect(input).toBeVisible();
await expect(input).toHaveValue(randomDoc);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await expect(page.getByLabel('Open the panel')).toBeHidden();
const editor = page.locator('.ProseMirror');
@@ -10,9 +10,7 @@ test.describe('Doc Version', () => {
test('it displays the doc versions', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
const input = page.getByPlaceholder('Untitled document');
await expect(input).toBeVisible();
await expect(input).toHaveValue(randomDoc);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
await page.getByLabel('Open the document options').click();
await page
@@ -81,14 +79,12 @@ test.describe('Doc Version', () => {
await goToGridDoc(page);
const input = page.getByPlaceholder('Untitled document');
await expect(input).toBeVisible();
await expect(input).toHaveValue('Mocked document');
await expect(page.locator('h2').getByText('Mocked document')).toBeVisible();
await page.getByLabel('Open the document options').click();
await expect(
page.getByRole('button', { name: 'Version history' }),
).toBeDisabled();
).toBeHidden();
await page.getByRole('button', { name: 'Table of content' }).click();
@@ -99,14 +95,12 @@ test.describe('Doc Version', () => {
test('it restores the doc version', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
const input = page.getByPlaceholder('Untitled document');
await expect(input).toBeVisible();
await expect(input).toHaveValue(randomDoc);
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');
expect(true).toBe(true);
await goToGridDoc(page, {
title: randomDoc,
});
@@ -158,9 +152,7 @@ test.describe('Doc Version', () => {
}) => {
const [randomDoc] = await createDoc(page, 'doc-version', browserName, 1);
const input = page.getByPlaceholder('Untitled document');
await expect(input).toBeVisible();
await expect(input).toHaveValue(randomDoc);
await expect(page.locator('h2').getByText(randomDoc)).toBeVisible();
const editor = page.locator('.ProseMirror');
await editor.locator('.bn-block-outer').last().click();
@@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test';
import { createDoc, keyCloakSignIn, verifyDocName } from './common';
import { createDoc, keyCloakSignIn } from './common';
const browsersName = ['chromium', 'webkit', 'firefox'];
@@ -85,7 +85,7 @@ test.describe('Doc Visibility: Restricted', () => {
1,
);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
const urlDoc = page.url();
@@ -111,7 +111,7 @@ test.describe('Doc Visibility: Restricted', () => {
const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
const urlDoc = page.url();
@@ -139,7 +139,7 @@ test.describe('Doc Visibility: Restricted', () => {
const [docTitle] = await createDoc(page, 'Restricted auth', browserName, 1);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
@@ -176,7 +176,7 @@ test.describe('Doc Visibility: Restricted', () => {
await page.goto(urlDoc);
await verifyDocName(page, docTitle);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
});
@@ -198,7 +198,7 @@ test.describe('Doc Visibility: Public', () => {
1,
);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
@@ -227,14 +227,10 @@ test.describe('Doc Visibility: Public', () => {
position: { x: 0, y: 0 },
});
const cardContainer = page.getByLabel(
'It is the card information about the document.',
);
await expect(cardContainer.getByTestId('public-icon')).toBeVisible();
await expect(
cardContainer.getByText('Public document', { exact: true }),
page
.getByLabel('It is the card information about the document.')
.getByText('Public', { exact: true }),
).toBeVisible();
const urlDoc = page.url();
@@ -265,7 +261,7 @@ test.describe('Doc Visibility: Public', () => {
const [docTitle] = await createDoc(page, 'Public editable', browserName, 1);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
@@ -294,14 +290,10 @@ test.describe('Doc Visibility: Public', () => {
position: { x: 0, y: 0 },
});
const cardContainer = page.getByLabel(
'It is the card information about the document.',
);
await expect(cardContainer.getByTestId('public-icon')).toBeVisible();
await expect(
cardContainer.getByText('Public document', { exact: true }),
page
.getByLabel('It is the card information about the document.')
.getByText('Public', { exact: true }),
).toBeVisible();
const urlDoc = page.url();
@@ -316,7 +308,7 @@ test.describe('Doc Visibility: Public', () => {
await page.goto(urlDoc);
await verifyDocName(page, docTitle);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeHidden();
await expect(
page.getByText('Read only, you cannot edit this document'),
@@ -341,7 +333,7 @@ test.describe('Doc Visibility: Authenticated', () => {
1,
);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
@@ -393,7 +385,7 @@ test.describe('Doc Visibility: Authenticated', () => {
1,
);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
@@ -459,7 +451,7 @@ test.describe('Doc Visibility: Authenticated', () => {
1,
);
await verifyDocName(page, docTitle);
await expect(page.getByRole('heading', { name: docTitle })).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await page
@@ -506,7 +498,7 @@ test.describe('Doc Visibility: Authenticated', () => {
await page.goto(urlDoc);
await verifyDocName(page, docTitle);
await expect(page.locator('h2').getByText(docTitle)).toBeVisible();
await page.getByRole('button', { name: 'Share' }).click();
await expect(
page.getByText('Read only, you cannot edit this document'),
@@ -0,0 +1,77 @@
import { expect, test } from '@playwright/test';
import { goToGridDoc } from './common';
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test.describe('Footer', () => {
test('checks all the elements are visible', async ({ page }) => {
const footer = page.locator('footer').first();
await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible();
await expect(
footer.getByRole('link', { name: 'legifrance.gouv.fr' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'info.gouv.fr' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'service-public.fr' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'data.gouv.fr' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'Legal Notice' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'Personal data and cookies' }),
).toBeVisible();
await expect(
footer.getByRole('link', { name: 'Accessibility' }),
).toBeVisible();
await expect(
footer.getByText(
'Unless otherwise stated, all content on this site is under licence',
),
).toBeVisible();
});
test('checks footer is not visible on doc editor', async ({ page }) => {
await expect(page.locator('footer')).toBeVisible();
await goToGridDoc(page);
await expect(page.locator('footer')).toBeHidden();
});
const legalPages = [
{ name: 'Legal Notice', url: '/legal-notice/' },
{ name: 'Personal data and cookies', url: '/personal-data-cookies/' },
{ name: 'Accessibility', url: '/accessibility/' },
];
for (const { name, url } of legalPages) {
test(`checks ${name} page`, async ({ page }) => {
const footer = page.locator('footer').first();
await footer.getByRole('link', { name }).click();
await expect(
page
.getByRole('heading', {
name,
})
.first(),
).toBeVisible();
await expect(page).toHaveURL(url);
});
}
});
@@ -75,15 +75,29 @@ test.describe('Header mobile', () => {
test('it checks the header when mobile', async ({ page }) => {
const header = page.locator('header').first();
await expect(header.getByLabel('Open the header menu')).toBeVisible();
await expect(
header.getByRole('link', { name: 'Docs Logo Docs' }),
).toBeVisible();
await expect(
header.getByRole('button', {
name: 'Les services de La Suite numérique',
}),
).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Logout',
}),
).toBeHidden();
await expect(page.getByText('English')).toBeHidden();
await header.getByLabel('Open the header menu').click();
await expect(
page.getByRole('button', {
name: 'Logout',
}),
).toBeVisible();
await expect(page.getByText('English')).toBeVisible();
});
});
@@ -6,7 +6,11 @@ test.beforeEach(async ({ page }) => {
test.describe('Language', () => {
test('checks the language picker', async ({ page }) => {
await expect(page.getByLabel('Logout')).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Create a new document',
}),
).toBeVisible();
const header = page.locator('header').first();
await header.getByRole('combobox').getByText('English').click();
@@ -15,7 +19,11 @@ test.describe('Language', () => {
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
await expect(page.getByLabel('Se déconnecter')).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Créer un nouveau document',
}),
).toBeVisible();
await header.getByRole('combobox').getByText('Français').click();
await header.getByRole('option', { name: 'Deutsch' }).click();
@@ -23,7 +31,11 @@ test.describe('Language', () => {
header.getByRole('combobox').getByText('Deutsch'),
).toBeVisible();
await expect(page.getByLabel('Abmelden')).toBeVisible();
await expect(
page.getByRole('button', {
name: 'Neues Dokument erstellen',
}),
).toBeVisible();
});
test('checks that backend uses the same language as the frontend', async ({
@@ -1,48 +0,0 @@
import { expect, test } from '@playwright/test';
test.describe('Left panel desktop', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the elements are visible', async ({ page }) => {
await expect(page.getByTestId('left-panel-desktop')).toBeVisible();
await expect(page.getByTestId('left-panel-mobile')).toBeHidden();
await expect(page.getByRole('button', { name: 'house' })).toBeVisible();
await expect(page.getByRole('button', { name: 'New doc' })).toBeVisible();
});
});
test.describe('Left panel mobile', () => {
test.use({ viewport: { width: 500, height: 1200 } });
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('checks all the desktop elements are hidden and all mobile elements are visible', async ({
page,
}) => {
await expect(page.getByTestId('left-panel-desktop')).toBeHidden();
await expect(page.getByTestId('left-panel-mobile')).not.toBeInViewport();
const header = page.locator('header').first();
const homeButton = page.getByRole('button', { name: 'house' });
const newDocButton = page.getByRole('button', { name: 'New doc' });
const languageButton = page.getByRole('combobox', { name: 'Language' });
const logoutButton = page.getByRole('button', { name: 'Logout' });
await expect(homeButton).not.toBeInViewport();
await expect(newDocButton).not.toBeInViewport();
await expect(languageButton).not.toBeInViewport();
await expect(logoutButton).not.toBeInViewport();
await header.getByLabel('Open the header menu').click();
await expect(page.getByTestId('left-panel-mobile')).toBeInViewport();
await expect(homeButton).toBeInViewport();
await expect(newDocButton).toBeInViewport();
await expect(languageButton).toBeInViewport();
await expect(logoutButton).toBeInViewport();
});
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.7.0",
"version": "1.8.1",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
@@ -12,7 +12,7 @@
"test:ui::chromium": "yarn test:ui --project=chromium"
},
"devDependencies": {
"@playwright/test": "1.48.2",
"@playwright/test": "1.49.0",
"@types/node": "*",
"@types/pdf-parse": "1.1.4",
"eslint-config-impress": "*",
-3
View File
@@ -1,5 +1,2 @@
NEXT_PUBLIC_API_ORIGIN=
NEXT_PUBLIC_Y_PROVIDER_URL=
NEXT_PUBLIC_MEDIA_URL=
NEXT_PUBLIC_THEME=dsfr
NEXT_PUBLIC_SW_DEACTIVATED=
@@ -1,4 +1,2 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_Y_PROVIDER_URL=ws://localhost:4444
NEXT_PUBLIC_MEDIA_URL=http://localhost:8083
NEXT_PUBLIC_SW_DEACTIVATED=true
-1
View File
@@ -1,2 +1 @@
NEXT_PUBLIC_API_ORIGIN=http://test.jest
NEXT_PUBLIC_THEME=test-theme
+35 -66
View File
@@ -5,30 +5,22 @@ const config = {
colors: {
'card-border': '#ededed',
'primary-bg': '#FAFAFA',
'primary-050': '#F5F5FE',
'primary-100': '#EDF5FA',
'primary-150': '#E5EEFA',
'primary-950': '#1B1B35',
'info-150': '#E5EEFA',
'greyscale-000': '#fff',
'greyscale-1000': '#161616',
},
font: {
sizes: {
xs: '0.75rem',
sm: '0.875rem',
md: '1rem',
lg: '1.125rem',
ml: '0.938rem',
xl: '1.25rem',
xl: '1.50rem',
t: '0.6875rem',
s: '0.75rem',
h1: '2rem',
h2: '1.75rem',
h3: '1.5rem',
h4: '1.375rem',
h5: '1.25rem',
h6: '1.125rem',
h1: '2.2rem',
h2: '1.7rem',
h3: '1.37rem',
h4: '1.15rem',
h5: '1rem',
h6: '0.87rem',
},
weights: {
thin: 100,
@@ -42,21 +34,6 @@ const config = {
auto: 'auto',
bx: '2.2rem',
full: '100%',
'4xs': '0.125rem',
'3xs': '0.25rem',
'2xs': '0.375rem',
xs: '0.5rem',
sm: '0.75rem',
base: '1rem',
md: '1.5rem',
lg: '2rem',
xl: '2.5rem',
xxl: '3rem',
xxxl: '3.5rem',
'4xl': '4rem',
'5xl': '4.5rem',
'6xl': '6rem',
'7xl': '7.5rem',
},
breakpoints: {
xxs: '320px',
@@ -127,7 +104,7 @@ const config = {
focus: 'var(--c--components--forms-select--border-radius)',
},
'font-size': 'var(--c--theme--font--sizes--ml)',
'menu-background-color': '#fff',
'menu-background-color': '#ffffff',
'item-background-color': {
hover: 'var(--c--theme--colors--primary-300)',
},
@@ -149,7 +126,7 @@ const config = {
},
},
modal: {
'background-color': '#fff',
'background-color': '#ffffff',
},
button: {
'border-radius': {
@@ -201,9 +178,7 @@ const config = {
color: 'var(--c--theme--colors--primary-text)',
'color-disabled': 'var(--c--theme--colors--greyscale-600)',
background: {
color: 'var(--c--theme--colors--primary-100)',
'color-hover': 'var(--c--theme--colors--primary-300)',
'color-active': 'var(--c--theme--colors--primary-100)',
'color-hover': 'var(--c--theme--colors--primary-100)',
'color-disabled': 'var(--c--theme--colors--greyscale-200)',
},
},
@@ -222,19 +197,19 @@ const config = {
dsfr: {
theme: {
colors: {
'card-border': '#E5E5E5',
'card-border': '#ededed',
'primary-text': '#000091',
'primary-100': '#ECECFE',
'primary-100': '#f5f5fe',
'primary-150': '#F4F4FD',
'primary-200': '#E3E3FD',
'primary-300': '#CACAFB',
'primary-400': '#8585F6',
'primary-500': '#6A6AF4',
'primary-600': '#313178',
'primary-200': '#ececfe',
'primary-300': '#e3e3fd',
'primary-400': '#cacafb',
'primary-500': '#6a6af4',
'primary-600': '#000091',
'primary-700': '#272747',
'primary-800': '#000091',
'primary-900': '#21213F',
'secondary-text': '#fff',
'primary-800': '#21213f',
'primary-900': '#1c1a36',
'secondary-text': '#FFFFFF',
'secondary-100': '#fee9ea',
'secondary-200': '#fedfdf',
'secondary-300': '#fdbfbf',
@@ -245,22 +220,16 @@ const config = {
'secondary-800': '#341f1f',
'secondary-900': '#2b1919',
'greyscale-text': '#303C4B',
'greyscale-000': '#fff',
'greyscale-050': '#F6F6F6',
'greyscale-100': '#eee',
'greyscale-200': '#E5E5E5',
'greyscale-250': '#ddd',
'greyscale-300': '#CECECE',
'greyscale-350': '#ddd',
'greyscale-400': '#929292',
'greyscale-500': '#7C7C7C',
'greyscale-600': '#666666',
'greyscale-700': '#3A3A3A',
'greyscale-750': '#353535',
'greyscale-800': '#2A2A2A',
'greyscale-900': '#242424',
'greyscale-950': '#1E1E1E',
'greyscale-1000': '#161616',
'greyscale-000': '#f6f6f6',
'greyscale-100': '#eeeeee',
'greyscale-200': '#e5e5e5',
'greyscale-300': '#e1e1e1',
'greyscale-400': '#dddddd',
'greyscale-500': '#cecece',
'greyscale-600': '#7b7b7b',
'greyscale-700': '#666666',
'greyscale-800': '#2a2a2a',
'greyscale-900': '#1e1e1e',
'success-text': '#1f8d49',
'success-100': '#dffee6',
'success-200': '#b8fec9',
@@ -328,9 +297,9 @@ const config = {
'color-hover': '#1212ff',
'color-active': '#2323ff',
},
color: '#fff',
'color-hover': '#fff',
'color-active': '#fff',
color: '#ffffff',
'color-hover': '#ffffff',
'color-active': '#ffffff',
},
'primary-text': {
background: {
@@ -394,7 +363,7 @@ const config = {
},
'forms-input': {
'border-radius': '4px',
'background-color': '#fff',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
'value-color': 'var(--c--theme--colors--primary-text)',
@@ -412,7 +381,7 @@ const config = {
'item-font-size': '14px',
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#fff',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'border-color-hover': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
+9 -8
View File
@@ -1,6 +1,6 @@
{
"name": "app-impress",
"version": "1.7.0",
"version": "1.8.1",
"private": true,
"scripts": {
"dev": "next dev",
@@ -19,20 +19,21 @@
"@blocknote/mantine": "*",
"@blocknote/react": "*",
"@gouvfr-lasuite/integration": "1.0.2",
"@hocuspocus/provider": "2.13.7",
"@hocuspocus/provider": "2.14.0",
"@openfun/cunningham-react": "2.9.4",
"@tanstack/react-query": "5.60.5",
"i18next": "23.16.5",
"@sentry/nextjs": "8.40.0",
"@tanstack/react-query": "5.61.3",
"crisp-sdk-web": "1.0.25",
"i18next": "24.0.0",
"i18next-browser-languagedetector": "8.0.0",
"idb": "8.0.0",
"lodash": "4.17.21",
"luxon": "3.5.0",
"next": "15.0.3",
"react": "*",
"react-aria-components": "1.4.1",
"react-aria-components": "1.5.0",
"react-dom": "*",
"react-i18next": "15.1.1",
"react-intersection-observer": "9.13.1",
"react-select": "5.8.3",
"styled-components": "6.1.13",
"y-protocols": "1.0.6",
@@ -41,7 +42,7 @@
},
"devDependencies": {
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.60.5",
"@tanstack/react-query-devtools": "5.61.3",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.0.1",
@@ -65,6 +66,6 @@
"stylelint-prettier": "5.0.2",
"typescript": "*",
"webpack": "5.96.1",
"workbox-webpack-plugin": "7.3.0"
"workbox-webpack-plugin": "7.1.0"
}
}
@@ -0,0 +1,32 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import { AppWrapper } from '@/tests/utils';
import Page from '../pages';
jest.mock('next/router', () => ({
useRouter() {
return {
push: jest.fn(),
};
},
}));
jest.mock('@sentry/nextjs', () => ({
captureException: jest.fn(),
captureMessage: jest.fn(),
setUser: jest.fn(),
}));
describe('Page', () => {
it('checks Page rendering', () => {
render(<Page />, { wrapper: AppWrapper });
expect(
screen.getByRole('button', {
name: /Create a new document/i,
}),
).toBeInTheDocument();
});
});
@@ -0,0 +1,6 @@
export const backendUrl = () =>
process.env.NEXT_PUBLIC_API_ORIGIN ||
(typeof window !== 'undefined' ? window.location.origin : '');
export const baseApiUrl = (apiVersion: string = '1.0') =>
`${backendUrl()}/api/v${apiVersion}/`;
@@ -1,5 +1,4 @@
import { baseApiUrl } from '@/core';
import { baseApiUrl } from './config';
import { getCSRFToken } from './utils';
interface FetchAPIInit extends RequestInit {
@@ -1,4 +1,5 @@
export * from './APIError';
export * from './config';
export * from './fetchApi';
export * from './helpers';
export * from './types';
@@ -1,13 +1,9 @@
import { forwardRef } from 'react';
import { ComponentPropsWithRef, forwardRef } from 'react';
import { css } from 'styled-components';
import { Box, BoxType } from './Box';
export type BoxButtonType = BoxType & {
disabled?: boolean;
};
/**
export type BoxButtonType = ComponentPropsWithRef<typeof BoxButton>;
/**
* Styleless button that extends the Box component.
@@ -22,7 +18,7 @@ export type BoxButtonType = BoxType & {
* </BoxButton>
* ```
*/
const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
const BoxButton = forwardRef<HTMLDivElement, BoxType>(
({ $css, ...props }, ref) => {
return (
<Box
@@ -32,24 +28,14 @@ const BoxButton = forwardRef<HTMLDivElement, BoxButtonType>(
$margin="none"
$padding="none"
$css={css`
cursor: ${props.disabled ? 'not-allowed' : 'pointer'};
cursor: pointer;
border: none;
outline: none;
transition: all 0.2s ease-in-out;
font-family: inherit;
color: ${props.disabled
? 'var(--c--theme--colors--greyscale-400) !important'
: 'inherit'};
${$css || ''}
`}
{...props}
onClick={(event: React.MouseEvent<HTMLDivElement>) => {
if (props.disabled) {
return;
}
props.onClick?.(event);
}}
/>
);
},
@@ -17,7 +17,8 @@ export const Card = ({
$background="white"
$radius="4px"
$css={css`
border: 1px solid ${colorsTokens()['greyscale-200']};
box-shadow: 2px 2px 5px ${colorsTokens()['greyscale-300']};
border: 1px solid ${colorsTokens()['card-border']};
${$css}
`}
{...props}
@@ -1,4 +1,9 @@
import { PropsWithChildren, ReactNode, useEffect, useState } from 'react';
import React, {
PropsWithChildren,
ReactNode,
useEffect,
useState,
} from 'react';
import { Button, DialogTrigger, Popover } from 'react-aria-components';
import styled from 'styled-components';
@@ -6,7 +11,7 @@ const StyledPopover = styled(Popover)`
background-color: white;
border-radius: 4px;
box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.1);
padding: 0.5rem;
border: 1px solid #dddddd;
opacity: 0;
transition: opacity 0.2s ease-in-out;
@@ -24,7 +29,7 @@ const StyledButton = styled(Button)`
text-wrap: nowrap;
`;
export interface DropButtonProps {
interface DropButtonProps {
button: ReactNode;
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
@@ -1,112 +0,0 @@
import { PropsWithChildren, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, BoxProps, DropButton, Icon } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
export type DropdownMenuOption = {
icon?: string;
label: string;
testId?: string;
callback?: () => void | Promise<unknown>;
danger?: boolean;
disabled?: boolean;
};
export type DropdownMenuProps = {
options: DropdownMenuOption[];
showArrow?: boolean;
arrowCss?: BoxProps['$css'];
};
export const DropdownMenu = ({
options,
children,
showArrow = false,
arrowCss,
}: PropsWithChildren<DropdownMenuProps>) => {
const theme = useCunninghamTheme();
const spacings = theme.spacingsTokens();
const colors = theme.colorsTokens();
const [isOpen, setIsOpen] = useState(false);
const onOpenChange = (isOpen: boolean) => {
setIsOpen(isOpen);
};
return (
<DropButton
isOpen={isOpen}
onOpenChange={onOpenChange}
button={
showArrow ? (
<Box>
<div>{children}</div>
<Icon
$css={
arrowCss ??
css`
color: var(--c--theme--colors--primary-600);
`
}
iconName={isOpen ? 'arrow_drop_up' : 'arrow_drop_down'}
/>
</Box>
) : (
children
)
}
>
<Box>
{options.map((option, index) => {
const isDisabled = option.disabled !== undefined && option.disabled;
return (
<BoxButton
data-testid={option.testId}
$direction="row"
disabled={isDisabled}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onOpenChange?.(false);
void option.callback?.();
}}
key={option.label}
$align="center"
$background={colors['greyscale-000']}
$color={colors['primary-600']}
$padding={{ vertical: 'xs', horizontal: 'base' }}
$width="100%"
$gap={spacings['base']}
$css={css`
border: none;
font-size: var(--c--theme--font--sizes--sm);
color: var(--c--theme--colors--primary-600);
font-weight: 500;
cursor: ${isDisabled ? 'not-allowed' : 'pointer'};
user-select: none;
border-bottom: ${index !== options.length - 1
? `1px solid var(--c--theme--colors--greyscale-200)`
: 'none'};
&:hover {
background-color: var(--c--theme--colors--greyscale-050);
}
`}
>
{option.icon && (
<Icon
$size="20px"
$theme={!isDisabled ? 'primary' : 'greyscale'}
$variation={!isDisabled ? '600' : '400'}
iconName={option.icon}
/>
)}
{option.label}
</BoxButton>
);
})}
</Box>
</DropButton>
);
};
@@ -1,19 +1,6 @@
import { css } from 'styled-components';
import { Text, TextType } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
type IconProps = TextType & {
iconName: string;
};
export const Icon = ({ iconName, ...textProps }: IconProps) => {
return (
<Text $isMaterialIcon {...textProps}>
{iconName}
</Text>
);
};
interface IconBGProps extends TextType {
iconName: string;
}
@@ -42,21 +29,23 @@ export const IconBG = ({ iconName, ...textProps }: IconBGProps) => {
);
};
type IconOptionsProps = TextType & {
isHorizontal?: boolean;
};
interface IconOptionsProps {
isOpen: boolean;
'aria-label': string;
}
export const IconOptions = ({ isHorizontal, ...props }: IconOptionsProps) => {
export const IconOptions = ({ isOpen, ...props }: IconOptionsProps) => {
return (
<Text
{...props}
aria-label={props['aria-label']}
$isMaterialIcon
$css={css`
$css={`
transition: all 0.3s ease-in-out;
transform: rotate(${isOpen ? '90' : '0'}deg);
user-select: none;
${props.$css}
`}
>
{isHorizontal ? 'more_horiz' : 'more_vert'}
more_vert
</Text>
);
};
@@ -33,7 +33,6 @@ export interface TextProps extends BoxProps {
| 'greyscale';
$variation?:
| 'text'
| '000'
| '100'
| '200'
| '300'
@@ -42,8 +41,7 @@ export interface TextProps extends BoxProps {
| '600'
| '700'
| '800'
| '900'
| '1000';
| '900';
}
export type TextType = ComponentPropsWithRef<typeof Text>;
@@ -17,8 +17,8 @@ describe('<Box />', () => {
);
expect(screen.getByText('My Box')).toHaveStyle(`
padding-left: 2.5rem;
padding-right: 2.5rem;
padding-left: 4rem;
padding-right: 4rem;
padding-top: 3rem;
padding-bottom: 0.5rem;`);
});
@@ -2,11 +2,9 @@ export * from './Box';
export * from './BoxButton';
export * from './Card';
export * from './DropButton';
export * from './DropdownMenu';
export * from './Icon';
export * from './InfiniteScroll';
export * from './Link';
export * from './SideModal';
export * from './separators/SeparatedSection';
export * from './Text';
export * from './TextErrors';
@@ -1,31 +0,0 @@
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
export enum SeparatorVariant {
LIGHT = 'light',
DARK = 'dark',
}
type Props = {
variant?: SeparatorVariant;
};
export const HorizontalSeparator = ({
variant = SeparatorVariant.LIGHT,
}: Props) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Box
$height="1px"
$width="100%"
$margin={{ vertical: 'base' }}
$background={
variant === SeparatorVariant.DARK
? '#e5e5e533'
: colorsTokens()['greyscale-100']
}
/>
);
};
@@ -1,32 +0,0 @@
import { PropsWithChildren } from 'react';
import { css } from 'styled-components';
import { useCunninghamTheme } from '@/cunningham';
import { Box } from '../Box';
type Props = {
showSeparator?: boolean;
};
export const SeparatedSection = ({
showSeparator = true,
children,
}: PropsWithChildren<Props>) => {
const theme = useCunninghamTheme();
const colors = theme.colorsTokens();
const spacings = theme.spacingsTokens();
return (
<Box
$css={css`
padding: ${spacings['base']} 0;
${showSeparator &&
css`
border-bottom: 1px solid ${colors?.['greyscale-200']};
`}
`}
>
{children}
</Box>
);
};
@@ -7,6 +7,7 @@ import '@/i18n/initI18n';
import { useResponsiveStore } from '@/stores/';
import { Auth } from './auth/';
import { ConfigProvider } from './config/';
/**
* QueryClient:
@@ -39,7 +40,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<CunninghamProvider theme={theme}>
<Auth>{children}</Auth>
<ConfigProvider>
<Auth>{children}</Auth>
</ConfigProvider>
</CunninghamProvider>
</QueryClientProvider>
);
@@ -0,0 +1,40 @@
import { Crisp } from 'crisp-sdk-web';
import fetchMock from 'fetch-mock';
import { useAuthStore } from '../useAuthStore';
jest.mock('crisp-sdk-web', () => ({
...jest.requireActual('crisp-sdk-web'),
Crisp: {
isCrispInjected: jest.fn().mockReturnValue(true),
setTokenId: jest.fn(),
user: {
setEmail: jest.fn(),
},
session: {
reset: jest.fn(),
},
},
}));
describe('useAuthStore', () => {
afterEach(() => {
jest.clearAllMocks();
fetchMock.restore();
});
it('checks support session is terminated when logout', () => {
window.$crisp = true;
Object.defineProperty(window, 'location', {
value: {
...window.location,
replace: jest.fn(),
},
writable: true,
});
useAuthStore.getState().logout();
expect(Crisp.session.reset).toHaveBeenCalled();
});
});
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { baseApiUrl } from '@/core/conf';
import { baseApiUrl } from '@/api';
import { terminateCrispSession } from '@/services';
import { User, getMe } from './api';
import { PATH_AUTH_LOCAL_STORAGE } from './conf';
@@ -42,6 +43,7 @@ export const useAuthStore = create<AuthStore>((set, get) => ({
window.location.replace(`${baseApiUrl()}authenticate/`);
},
logout: () => {
terminateCrispSession();
window.location.replace(`${baseApiUrl()}logout/`);
},
// If we try to access a specific page and we are not authenticated
@@ -1,18 +0,0 @@
export const mediaUrl = () =>
process.env.NEXT_PUBLIC_MEDIA_URL ||
(typeof window !== 'undefined' ? window.location.origin : '');
export const backendUrl = () =>
process.env.NEXT_PUBLIC_API_ORIGIN ||
(typeof window !== 'undefined' ? window.location.origin : '');
export const baseApiUrl = (apiVersion: string = '1.0') =>
`${backendUrl()}/api/v${apiVersion}/`;
export const providerUrl = (docId: string) => {
const base =
process.env.NEXT_PUBLIC_Y_PROVIDER_URL ||
(typeof window !== 'undefined' ? `wss://${window.location.host}/ws` : '');
return `${base}/${docId}`;
};
@@ -0,0 +1,49 @@
import { Loader } from '@openfun/cunningham-react';
import { PropsWithChildren, useEffect } from 'react';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { configureCrispSession } from '@/services';
import { useSentryStore } from '@/stores/useSentryStore';
import { useConfig } from './api/useConfig';
export const ConfigProvider = ({ children }: PropsWithChildren) => {
const { data: conf } = useConfig();
const { setSentry } = useSentryStore();
const { setTheme } = useCunninghamTheme();
useEffect(() => {
if (!conf?.SENTRY_DSN) {
return;
}
setSentry(conf.SENTRY_DSN, conf.ENVIRONMENT);
}, [conf?.SENTRY_DSN, conf?.ENVIRONMENT, setSentry]);
useEffect(() => {
if (!conf?.FRONTEND_THEME) {
return;
}
setTheme(conf.FRONTEND_THEME);
}, [conf?.FRONTEND_THEME, setTheme]);
useEffect(() => {
if (!conf?.CRISP_WEBSITE_ID) {
return;
}
configureCrispSession(conf.CRISP_WEBSITE_ID);
}, [conf?.CRISP_WEBSITE_ID]);
if (!conf) {
return (
<Box $height="100vh" $width="100vw" $align="center" $justify="center">
<Loader />
</Box>
);
}
return children;
};
@@ -0,0 +1 @@
export * from './useConfig';
@@ -0,0 +1,35 @@
import { useQuery } from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { Theme } from '@/cunningham/';
interface ConfigResponse {
LANGUAGES: [string, string][];
LANGUAGE_CODE: string;
ENVIRONMENT: string;
COLLABORATION_SERVER_URL?: string;
CRISP_WEBSITE_ID?: string;
FRONTEND_THEME?: Theme;
MEDIA_BASE_URL?: string;
SENTRY_DSN?: string;
}
export const getConfig = async (): Promise<ConfigResponse> => {
const response = await fetchAPI(`config/`);
if (!response.ok) {
throw new APIError('Failed to get the doc', await errorCauses(response));
}
return response.json() as Promise<ConfigResponse>;
};
export const KEY_CONFIG = 'config';
export function useConfig() {
return useQuery<ConfigResponse, APIError, ConfigResponse>({
queryKey: [KEY_CONFIG],
queryFn: () => getConfig(),
staleTime: Infinity,
});
}
@@ -0,0 +1,2 @@
export * from './useMediaUrl';
export * from './useCollaborationUrl';
@@ -0,0 +1,15 @@
import { useConfig } from '../api';
export const useCollaborationUrl = (room?: string) => {
const { data: conf } = useConfig();
if (!room) {
return;
}
const base =
conf?.COLLABORATION_SERVER_URL ||
(typeof window !== 'undefined' ? `wss://${window.location.host}/ws` : '');
return `${base}/${room}`;
};
@@ -0,0 +1,10 @@
import { useConfig } from '../api';
export const useMediaUrl = () => {
const { data: conf } = useConfig();
return (
conf?.MEDIA_BASE_URL ||
(typeof window !== 'undefined' ? window.location.origin : '')
);
};
@@ -0,0 +1,3 @@
export * from './api/';
export * from './ConfigProvider';
export * from './hooks';
+1 -1
View File
@@ -1,3 +1,3 @@
export * from './AppProvider';
export * from './auth';
export * from './conf';
export * from './config';
@@ -1,12 +1,6 @@
import useCunninghamTheme from '../useCunninghamTheme';
import { useCunninghamTheme } from '../useCunninghamTheme';
describe('<useCunninghamTheme />', () => {
it('has the theme from NEXT_PUBLIC_THEME', () => {
const { theme } = useCunninghamTheme.getState();
expect(theme).toBe('test-theme');
});
it('has the dsfr logo correctly set', () => {
const { themeTokens, setTheme } = useCunninghamTheme.getState();
setTheme('dsfr');
@@ -351,11 +351,6 @@ input:-webkit-autofill:focus {
background-color: transparent;
}
.c__button--nano {
padding: 0 var(--c--theme--spacings--2xs);
gap: var(--c--theme--spacings--2xs);
}
.c__button--medium {
padding: 0.9rem var(--c--theme--spacings--s);
}
@@ -447,7 +442,6 @@ input:-webkit-autofill:focus {
}
.c__button--tertiary {
background-color: var(--c--components--button--tertiary--background--color);
color: var(--c--components--button--tertiary--color);
border: none;
}
@@ -460,13 +454,6 @@ input:-webkit-autofill:focus {
color: var(--c--components--button--tertiary--color);
}
.c__button--tertiary:active {
background-color: var(
--c--components--button--tertiary--background--color-active
);
color: var(--c--components--button--tertiary--color-active);
}
.c__button--tertiary:disabled {
background-color: var(
--c--components--button--tertiary--background--color-disabled
@@ -71,26 +71,19 @@
--c--theme--colors--danger-text: var(--c--theme--colors--greyscale-000);
--c--theme--colors--card-border: #ededed;
--c--theme--colors--primary-bg: #fafafa;
--c--theme--colors--primary-050: #f5f5fe;
--c--theme--colors--primary-150: #e5eefa;
--c--theme--colors--primary-950: #1b1b35;
--c--theme--colors--info-150: #e5eefa;
--c--theme--colors--greyscale-1000: #161616;
--c--theme--font--sizes--h1: 2rem;
--c--theme--font--sizes--h2: 1.75rem;
--c--theme--font--sizes--h3: 1.5rem;
--c--theme--font--sizes--h4: 1.375rem;
--c--theme--font--sizes--h5: 1.25rem;
--c--theme--font--sizes--h6: 1.125rem;
--c--theme--font--sizes--h1: 2.2rem;
--c--theme--font--sizes--h2: 1.7rem;
--c--theme--font--sizes--h3: 1.37rem;
--c--theme--font--sizes--h4: 1.15rem;
--c--theme--font--sizes--h5: 1rem;
--c--theme--font--sizes--h6: 0.87rem;
--c--theme--font--sizes--l: 1rem;
--c--theme--font--sizes--m: 0.8125rem;
--c--theme--font--sizes--s: 0.75rem;
--c--theme--font--sizes--xs: 0.75rem;
--c--theme--font--sizes--sm: 0.875rem;
--c--theme--font--sizes--md: 1rem;
--c--theme--font--sizes--lg: 1.125rem;
--c--theme--font--sizes--ml: 0.938rem;
--c--theme--font--sizes--xl: 1.25rem;
--c--theme--font--sizes--xl: 1.5rem;
--c--theme--font--sizes--t: 0.6875rem;
--c--theme--font--weights--thin: 100;
--c--theme--font--weights--light: 300;
@@ -111,7 +104,7 @@
--c--theme--font--letterspacings--m: normal;
--c--theme--font--letterspacings--s: normal;
--c--theme--spacings--0: 0;
--c--theme--spacings--xl: 2.5rem;
--c--theme--spacings--xl: 4rem;
--c--theme--spacings--l: 3rem;
--c--theme--spacings--b: 1.625rem;
--c--theme--spacings--s: 1rem;
@@ -121,20 +114,6 @@
--c--theme--spacings--auto: auto;
--c--theme--spacings--bx: 2.2rem;
--c--theme--spacings--full: 100%;
--c--theme--spacings--4xs: 0.125rem;
--c--theme--spacings--3xs: 0.25rem;
--c--theme--spacings--2xs: 0.375rem;
--c--theme--spacings--xs: 0.5rem;
--c--theme--spacings--sm: 0.75rem;
--c--theme--spacings--base: 1rem;
--c--theme--spacings--md: 1.5rem;
--c--theme--spacings--lg: 2rem;
--c--theme--spacings--xxl: 3rem;
--c--theme--spacings--xxxl: 3.5rem;
--c--theme--spacings--4xl: 4rem;
--c--theme--spacings--5xl: 4.5rem;
--c--theme--spacings--6xl: 6rem;
--c--theme--spacings--7xl: 7.5rem;
--c--theme--transitions--ease-in: cubic-bezier(0.32, 0, 0.67, 0);
--c--theme--transitions--ease-out: cubic-bezier(0.33, 1, 0.68, 1);
--c--theme--transitions--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
@@ -296,13 +275,7 @@
--c--components--button--tertiary--color-disabled: var(
--c--theme--colors--greyscale-600
);
--c--components--button--tertiary--background--color: var(
--c--theme--colors--primary-100
);
--c--components--button--tertiary--background--color-hover: var(
--c--theme--colors--primary-300
);
--c--components--button--tertiary--background--color-active: var(
--c--theme--colors--primary-100
);
--c--components--button--tertiary--background--color-disabled: var(
@@ -363,18 +336,18 @@
}
.cunningham-theme--dsfr {
--c--theme--colors--card-border: #e5e5e5;
--c--theme--colors--card-border: #ededed;
--c--theme--colors--primary-text: #000091;
--c--theme--colors--primary-100: #ececfe;
--c--theme--colors--primary-100: #f5f5fe;
--c--theme--colors--primary-150: #f4f4fd;
--c--theme--colors--primary-200: #e3e3fd;
--c--theme--colors--primary-300: #cacafb;
--c--theme--colors--primary-400: #8585f6;
--c--theme--colors--primary-200: #ececfe;
--c--theme--colors--primary-300: #e3e3fd;
--c--theme--colors--primary-400: #cacafb;
--c--theme--colors--primary-500: #6a6af4;
--c--theme--colors--primary-600: #313178;
--c--theme--colors--primary-600: #000091;
--c--theme--colors--primary-700: #272747;
--c--theme--colors--primary-800: #000091;
--c--theme--colors--primary-900: #21213f;
--c--theme--colors--primary-800: #21213f;
--c--theme--colors--primary-900: #1c1a36;
--c--theme--colors--secondary-text: #fff;
--c--theme--colors--secondary-100: #fee9ea;
--c--theme--colors--secondary-200: #fedfdf;
@@ -386,22 +359,16 @@
--c--theme--colors--secondary-800: #341f1f;
--c--theme--colors--secondary-900: #2b1919;
--c--theme--colors--greyscale-text: #303c4b;
--c--theme--colors--greyscale-000: #fff;
--c--theme--colors--greyscale-050: #f6f6f6;
--c--theme--colors--greyscale-000: #f6f6f6;
--c--theme--colors--greyscale-100: #eee;
--c--theme--colors--greyscale-200: #e5e5e5;
--c--theme--colors--greyscale-250: #ddd;
--c--theme--colors--greyscale-300: #cecece;
--c--theme--colors--greyscale-350: #ddd;
--c--theme--colors--greyscale-400: #929292;
--c--theme--colors--greyscale-500: #7c7c7c;
--c--theme--colors--greyscale-600: #666;
--c--theme--colors--greyscale-700: #3a3a3a;
--c--theme--colors--greyscale-750: #353535;
--c--theme--colors--greyscale-300: #e1e1e1;
--c--theme--colors--greyscale-400: #ddd;
--c--theme--colors--greyscale-500: #cecece;
--c--theme--colors--greyscale-600: #7b7b7b;
--c--theme--colors--greyscale-700: #666;
--c--theme--colors--greyscale-800: #2a2a2a;
--c--theme--colors--greyscale-900: #242424;
--c--theme--colors--greyscale-950: #1e1e1e;
--c--theme--colors--greyscale-1000: #161616;
--c--theme--colors--greyscale-900: #1e1e1e;
--c--theme--colors--success-text: #1f8d49;
--c--theme--colors--success-100: #dffee6;
--c--theme--colors--success-200: #b8fec9;
@@ -844,26 +811,14 @@
color: var(--c--theme--colors--primary-bg);
}
.clr-primary-050 {
color: var(--c--theme--colors--primary-050);
}
.clr-primary-150 {
color: var(--c--theme--colors--primary-150);
}
.clr-primary-950 {
color: var(--c--theme--colors--primary-950);
}
.clr-info-150 {
color: var(--c--theme--colors--info-150);
}
.clr-greyscale-1000 {
color: var(--c--theme--colors--greyscale-1000);
}
.bg-secondary-text {
background-color: var(--c--theme--colors--secondary-text);
}
@@ -1152,26 +1107,14 @@
background-color: var(--c--theme--colors--primary-bg);
}
.bg-primary-050 {
background-color: var(--c--theme--colors--primary-050);
}
.bg-primary-150 {
background-color: var(--c--theme--colors--primary-150);
}
.bg-primary-950 {
background-color: var(--c--theme--colors--primary-950);
}
.bg-info-150 {
background-color: var(--c--theme--colors--info-150);
}
.bg-greyscale-1000 {
background-color: var(--c--theme--colors--greyscale-1000);
}
.fw-thin {
font-weight: var(--c--theme--font--weights--thin);
}
@@ -1245,26 +1188,6 @@
letter-spacing: var(--c--theme--font--letterspacings--s);
}
.fs-xs {
font-size: var(--c--theme--font--sizes--xs);
letter-spacing: var(--c--theme--font--letterspacings--xs);
}
.fs-sm {
font-size: var(--c--theme--font--sizes--sm);
letter-spacing: var(--c--theme--font--letterspacings--sm);
}
.fs-md {
font-size: var(--c--theme--font--sizes--md);
letter-spacing: var(--c--theme--font--letterspacings--md);
}
.fs-lg {
font-size: var(--c--theme--font--sizes--lg);
letter-spacing: var(--c--theme--font--letterspacings--lg);
}
.fs-ml {
font-size: var(--c--theme--font--sizes--ml);
letter-spacing: var(--c--theme--font--letterspacings--ml);
@@ -1508,286 +1431,6 @@
margin-right: var(--c--theme--spacings--full);
}
.m-4xs {
margin: var(--c--theme--spacings--4xs);
}
.mb-4xs {
margin-bottom: var(--c--theme--spacings--4xs);
}
.mt-4xs {
margin-top: var(--c--theme--spacings--4xs);
}
.ml-4xs {
margin-left: var(--c--theme--spacings--4xs);
}
.mr-4xs {
margin-right: var(--c--theme--spacings--4xs);
}
.m-3xs {
margin: var(--c--theme--spacings--3xs);
}
.mb-3xs {
margin-bottom: var(--c--theme--spacings--3xs);
}
.mt-3xs {
margin-top: var(--c--theme--spacings--3xs);
}
.ml-3xs {
margin-left: var(--c--theme--spacings--3xs);
}
.mr-3xs {
margin-right: var(--c--theme--spacings--3xs);
}
.m-2xs {
margin: var(--c--theme--spacings--2xs);
}
.mb-2xs {
margin-bottom: var(--c--theme--spacings--2xs);
}
.mt-2xs {
margin-top: var(--c--theme--spacings--2xs);
}
.ml-2xs {
margin-left: var(--c--theme--spacings--2xs);
}
.mr-2xs {
margin-right: var(--c--theme--spacings--2xs);
}
.m-xs {
margin: var(--c--theme--spacings--xs);
}
.mb-xs {
margin-bottom: var(--c--theme--spacings--xs);
}
.mt-xs {
margin-top: var(--c--theme--spacings--xs);
}
.ml-xs {
margin-left: var(--c--theme--spacings--xs);
}
.mr-xs {
margin-right: var(--c--theme--spacings--xs);
}
.m-sm {
margin: var(--c--theme--spacings--sm);
}
.mb-sm {
margin-bottom: var(--c--theme--spacings--sm);
}
.mt-sm {
margin-top: var(--c--theme--spacings--sm);
}
.ml-sm {
margin-left: var(--c--theme--spacings--sm);
}
.mr-sm {
margin-right: var(--c--theme--spacings--sm);
}
.m-base {
margin: var(--c--theme--spacings--base);
}
.mb-base {
margin-bottom: var(--c--theme--spacings--base);
}
.mt-base {
margin-top: var(--c--theme--spacings--base);
}
.ml-base {
margin-left: var(--c--theme--spacings--base);
}
.mr-base {
margin-right: var(--c--theme--spacings--base);
}
.m-md {
margin: var(--c--theme--spacings--md);
}
.mb-md {
margin-bottom: var(--c--theme--spacings--md);
}
.mt-md {
margin-top: var(--c--theme--spacings--md);
}
.ml-md {
margin-left: var(--c--theme--spacings--md);
}
.mr-md {
margin-right: var(--c--theme--spacings--md);
}
.m-lg {
margin: var(--c--theme--spacings--lg);
}
.mb-lg {
margin-bottom: var(--c--theme--spacings--lg);
}
.mt-lg {
margin-top: var(--c--theme--spacings--lg);
}
.ml-lg {
margin-left: var(--c--theme--spacings--lg);
}
.mr-lg {
margin-right: var(--c--theme--spacings--lg);
}
.m-xxl {
margin: var(--c--theme--spacings--xxl);
}
.mb-xxl {
margin-bottom: var(--c--theme--spacings--xxl);
}
.mt-xxl {
margin-top: var(--c--theme--spacings--xxl);
}
.ml-xxl {
margin-left: var(--c--theme--spacings--xxl);
}
.mr-xxl {
margin-right: var(--c--theme--spacings--xxl);
}
.m-xxxl {
margin: var(--c--theme--spacings--xxxl);
}
.mb-xxxl {
margin-bottom: var(--c--theme--spacings--xxxl);
}
.mt-xxxl {
margin-top: var(--c--theme--spacings--xxxl);
}
.ml-xxxl {
margin-left: var(--c--theme--spacings--xxxl);
}
.mr-xxxl {
margin-right: var(--c--theme--spacings--xxxl);
}
.m-4xl {
margin: var(--c--theme--spacings--4xl);
}
.mb-4xl {
margin-bottom: var(--c--theme--spacings--4xl);
}
.mt-4xl {
margin-top: var(--c--theme--spacings--4xl);
}
.ml-4xl {
margin-left: var(--c--theme--spacings--4xl);
}
.mr-4xl {
margin-right: var(--c--theme--spacings--4xl);
}
.m-5xl {
margin: var(--c--theme--spacings--5xl);
}
.mb-5xl {
margin-bottom: var(--c--theme--spacings--5xl);
}
.mt-5xl {
margin-top: var(--c--theme--spacings--5xl);
}
.ml-5xl {
margin-left: var(--c--theme--spacings--5xl);
}
.mr-5xl {
margin-right: var(--c--theme--spacings--5xl);
}
.m-6xl {
margin: var(--c--theme--spacings--6xl);
}
.mb-6xl {
margin-bottom: var(--c--theme--spacings--6xl);
}
.mt-6xl {
margin-top: var(--c--theme--spacings--6xl);
}
.ml-6xl {
margin-left: var(--c--theme--spacings--6xl);
}
.mr-6xl {
margin-right: var(--c--theme--spacings--6xl);
}
.m-7xl {
margin: var(--c--theme--spacings--7xl);
}
.mb-7xl {
margin-bottom: var(--c--theme--spacings--7xl);
}
.mt-7xl {
margin-top: var(--c--theme--spacings--7xl);
}
.ml-7xl {
margin-left: var(--c--theme--spacings--7xl);
}
.mr-7xl {
margin-right: var(--c--theme--spacings--7xl);
}
.p-0 {
padding: var(--c--theme--spacings--0);
}
@@ -2007,283 +1650,3 @@
.pr-full {
padding-right: var(--c--theme--spacings--full);
}
.p-4xs {
padding: var(--c--theme--spacings--4xs);
}
.pb-4xs {
padding-bottom: var(--c--theme--spacings--4xs);
}
.pt-4xs {
padding-top: var(--c--theme--spacings--4xs);
}
.pl-4xs {
padding-left: var(--c--theme--spacings--4xs);
}
.pr-4xs {
padding-right: var(--c--theme--spacings--4xs);
}
.p-3xs {
padding: var(--c--theme--spacings--3xs);
}
.pb-3xs {
padding-bottom: var(--c--theme--spacings--3xs);
}
.pt-3xs {
padding-top: var(--c--theme--spacings--3xs);
}
.pl-3xs {
padding-left: var(--c--theme--spacings--3xs);
}
.pr-3xs {
padding-right: var(--c--theme--spacings--3xs);
}
.p-2xs {
padding: var(--c--theme--spacings--2xs);
}
.pb-2xs {
padding-bottom: var(--c--theme--spacings--2xs);
}
.pt-2xs {
padding-top: var(--c--theme--spacings--2xs);
}
.pl-2xs {
padding-left: var(--c--theme--spacings--2xs);
}
.pr-2xs {
padding-right: var(--c--theme--spacings--2xs);
}
.p-xs {
padding: var(--c--theme--spacings--xs);
}
.pb-xs {
padding-bottom: var(--c--theme--spacings--xs);
}
.pt-xs {
padding-top: var(--c--theme--spacings--xs);
}
.pl-xs {
padding-left: var(--c--theme--spacings--xs);
}
.pr-xs {
padding-right: var(--c--theme--spacings--xs);
}
.p-sm {
padding: var(--c--theme--spacings--sm);
}
.pb-sm {
padding-bottom: var(--c--theme--spacings--sm);
}
.pt-sm {
padding-top: var(--c--theme--spacings--sm);
}
.pl-sm {
padding-left: var(--c--theme--spacings--sm);
}
.pr-sm {
padding-right: var(--c--theme--spacings--sm);
}
.p-base {
padding: var(--c--theme--spacings--base);
}
.pb-base {
padding-bottom: var(--c--theme--spacings--base);
}
.pt-base {
padding-top: var(--c--theme--spacings--base);
}
.pl-base {
padding-left: var(--c--theme--spacings--base);
}
.pr-base {
padding-right: var(--c--theme--spacings--base);
}
.p-md {
padding: var(--c--theme--spacings--md);
}
.pb-md {
padding-bottom: var(--c--theme--spacings--md);
}
.pt-md {
padding-top: var(--c--theme--spacings--md);
}
.pl-md {
padding-left: var(--c--theme--spacings--md);
}
.pr-md {
padding-right: var(--c--theme--spacings--md);
}
.p-lg {
padding: var(--c--theme--spacings--lg);
}
.pb-lg {
padding-bottom: var(--c--theme--spacings--lg);
}
.pt-lg {
padding-top: var(--c--theme--spacings--lg);
}
.pl-lg {
padding-left: var(--c--theme--spacings--lg);
}
.pr-lg {
padding-right: var(--c--theme--spacings--lg);
}
.p-xxl {
padding: var(--c--theme--spacings--xxl);
}
.pb-xxl {
padding-bottom: var(--c--theme--spacings--xxl);
}
.pt-xxl {
padding-top: var(--c--theme--spacings--xxl);
}
.pl-xxl {
padding-left: var(--c--theme--spacings--xxl);
}
.pr-xxl {
padding-right: var(--c--theme--spacings--xxl);
}
.p-xxxl {
padding: var(--c--theme--spacings--xxxl);
}
.pb-xxxl {
padding-bottom: var(--c--theme--spacings--xxxl);
}
.pt-xxxl {
padding-top: var(--c--theme--spacings--xxxl);
}
.pl-xxxl {
padding-left: var(--c--theme--spacings--xxxl);
}
.pr-xxxl {
padding-right: var(--c--theme--spacings--xxxl);
}
.p-4xl {
padding: var(--c--theme--spacings--4xl);
}
.pb-4xl {
padding-bottom: var(--c--theme--spacings--4xl);
}
.pt-4xl {
padding-top: var(--c--theme--spacings--4xl);
}
.pl-4xl {
padding-left: var(--c--theme--spacings--4xl);
}
.pr-4xl {
padding-right: var(--c--theme--spacings--4xl);
}
.p-5xl {
padding: var(--c--theme--spacings--5xl);
}
.pb-5xl {
padding-bottom: var(--c--theme--spacings--5xl);
}
.pt-5xl {
padding-top: var(--c--theme--spacings--5xl);
}
.pl-5xl {
padding-left: var(--c--theme--spacings--5xl);
}
.pr-5xl {
padding-right: var(--c--theme--spacings--5xl);
}
.p-6xl {
padding: var(--c--theme--spacings--6xl);
}
.pb-6xl {
padding-bottom: var(--c--theme--spacings--6xl);
}
.pt-6xl {
padding-top: var(--c--theme--spacings--6xl);
}
.pl-6xl {
padding-left: var(--c--theme--spacings--6xl);
}
.pr-6xl {
padding-right: var(--c--theme--spacings--6xl);
}
.p-7xl {
padding: var(--c--theme--spacings--7xl);
}
.pb-7xl {
padding-bottom: var(--c--theme--spacings--7xl);
}
.pt-7xl {
padding-top: var(--c--theme--spacings--7xl);
}
.pl-7xl {
padding-left: var(--c--theme--spacings--7xl);
}
.pr-7xl {
padding-right: var(--c--theme--spacings--7xl);
}
@@ -13,7 +13,7 @@ export const tokens = {
'secondary-700': '#97A3AE',
'secondary-800': '#757E87',
'secondary-900': '#596067',
'info-text': '#fff',
'info-text': '#FFFFFF',
'info-100': '#EBF2FC',
'info-200': '#8CB5EA',
'info-300': '#5894E1',
@@ -32,7 +32,7 @@ export const tokens = {
'greyscale-700': '#555F6B',
'greyscale-800': '#303C4B',
'greyscale-900': '#0C1A2B',
'greyscale-000': '#fff',
'greyscale-000': '#FFFFFF',
'primary-100': '#EDF5FA',
'primary-200': '#8CB5EA',
'primary-300': '#5894E1',
@@ -69,35 +69,28 @@ export const tokens = {
'danger-700': '#9B0000',
'danger-800': '#780000',
'danger-900': '#5C0000',
'primary-text': '#fff',
'success-text': '#fff',
'warning-text': '#fff',
'danger-text': '#fff',
'primary-text': '#FFFFFF',
'success-text': '#FFFFFF',
'warning-text': '#FFFFFF',
'danger-text': '#FFFFFF',
'card-border': '#ededed',
'primary-bg': '#FAFAFA',
'primary-050': '#F5F5FE',
'primary-150': '#E5EEFA',
'primary-950': '#1B1B35',
'info-150': '#E5EEFA',
'greyscale-1000': '#161616',
},
font: {
sizes: {
h1: '2rem',
h2: '1.75rem',
h3: '1.5rem',
h4: '1.375rem',
h5: '1.25rem',
h6: '1.125rem',
h1: '2.2rem',
h2: '1.7rem',
h3: '1.37rem',
h4: '1.15rem',
h5: '1rem',
h6: '0.87rem',
l: '1rem',
m: '0.8125rem',
s: '0.75rem',
xs: '0.75rem',
sm: '0.875rem',
md: '1rem',
lg: '1.125rem',
ml: '0.938rem',
xl: '1.25rem',
xl: '1.50rem',
t: '0.6875rem',
},
weights: {
@@ -127,7 +120,7 @@ export const tokens = {
},
spacings: {
'0': '0',
xl: '2.5rem',
xl: '4rem',
l: '3rem',
b: '1.625rem',
s: '1rem',
@@ -137,20 +130,6 @@ export const tokens = {
auto: 'auto',
bx: '2.2rem',
full: '100%',
'4xs': '0.125rem',
'3xs': '0.25rem',
'2xs': '0.375rem',
xs: '0.5rem',
sm: '0.75rem',
base: '1rem',
md: '1.5rem',
lg: '2rem',
xxl: '3rem',
xxxl: '3.5rem',
'4xl': '4rem',
'5xl': '4.5rem',
'6xl': '6rem',
'7xl': '7.5rem',
},
transitions: {
'ease-in': 'cubic-bezier(0.32, 0, 0.67, 0)',
@@ -223,7 +202,7 @@ export const tokens = {
focus: 'var(--c--components--forms-select--border-radius)',
},
'font-size': 'var(--c--theme--font--sizes--ml)',
'menu-background-color': '#fff',
'menu-background-color': '#ffffff',
'item-background-color': {
hover: 'var(--c--theme--colors--primary-300)',
},
@@ -244,7 +223,7 @@ export const tokens = {
'border-color-hover': 'var(--c--theme--colors--greyscale-200)',
},
},
modal: { 'background-color': '#fff' },
modal: { 'background-color': '#ffffff' },
button: {
'border-radius': {
active: 'var(--c--components--button--border-radius)',
@@ -291,9 +270,7 @@ export const tokens = {
color: 'var(--c--theme--colors--primary-text)',
'color-disabled': 'var(--c--theme--colors--greyscale-600)',
background: {
color: 'var(--c--theme--colors--primary-100)',
'color-hover': 'var(--c--theme--colors--primary-300)',
'color-active': 'var(--c--theme--colors--primary-100)',
'color-hover': 'var(--c--theme--colors--primary-100)',
'color-disabled': 'var(--c--theme--colors--greyscale-200)',
},
},
@@ -357,19 +334,19 @@ export const tokens = {
dsfr: {
theme: {
colors: {
'card-border': '#E5E5E5',
'card-border': '#ededed',
'primary-text': '#000091',
'primary-100': '#ECECFE',
'primary-100': '#f5f5fe',
'primary-150': '#F4F4FD',
'primary-200': '#E3E3FD',
'primary-300': '#CACAFB',
'primary-400': '#8585F6',
'primary-500': '#6A6AF4',
'primary-600': '#313178',
'primary-200': '#ececfe',
'primary-300': '#e3e3fd',
'primary-400': '#cacafb',
'primary-500': '#6a6af4',
'primary-600': '#000091',
'primary-700': '#272747',
'primary-800': '#000091',
'primary-900': '#21213F',
'secondary-text': '#fff',
'primary-800': '#21213f',
'primary-900': '#1c1a36',
'secondary-text': '#FFFFFF',
'secondary-100': '#fee9ea',
'secondary-200': '#fedfdf',
'secondary-300': '#fdbfbf',
@@ -380,22 +357,16 @@ export const tokens = {
'secondary-800': '#341f1f',
'secondary-900': '#2b1919',
'greyscale-text': '#303C4B',
'greyscale-000': '#fff',
'greyscale-050': '#F6F6F6',
'greyscale-100': '#eee',
'greyscale-200': '#E5E5E5',
'greyscale-250': '#ddd',
'greyscale-300': '#CECECE',
'greyscale-350': '#ddd',
'greyscale-400': '#929292',
'greyscale-500': '#7C7C7C',
'greyscale-600': '#666666',
'greyscale-700': '#3A3A3A',
'greyscale-750': '#353535',
'greyscale-800': '#2A2A2A',
'greyscale-900': '#242424',
'greyscale-950': '#1E1E1E',
'greyscale-1000': '#161616',
'greyscale-000': '#f6f6f6',
'greyscale-100': '#eeeeee',
'greyscale-200': '#e5e5e5',
'greyscale-300': '#e1e1e1',
'greyscale-400': '#dddddd',
'greyscale-500': '#cecece',
'greyscale-600': '#7b7b7b',
'greyscale-700': '#666666',
'greyscale-800': '#2a2a2a',
'greyscale-900': '#1e1e1e',
'success-text': '#1f8d49',
'success-100': '#dffee6',
'success-200': '#b8fec9',
@@ -456,9 +427,9 @@ export const tokens = {
'color-hover': '#1212ff',
'color-active': '#2323ff',
},
color: '#fff',
'color-hover': '#fff',
'color-active': '#fff',
color: '#ffffff',
'color-hover': '#ffffff',
'color-active': '#ffffff',
},
'primary-text': {
background: {
@@ -515,7 +486,7 @@ export const tokens = {
},
'forms-input': {
'border-radius': '4px',
'background-color': '#fff',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
'value-color': 'var(--c--theme--colors--primary-text)',
@@ -531,7 +502,7 @@ export const tokens = {
'item-font-size': '14px',
'border-radius': '4px',
'border-radius-hover': '4px',
'background-color': '#fff',
'background-color': '#ffffff',
'border-color': 'var(--c--theme--colors--primary-text)',
'border-color-hover': 'var(--c--theme--colors--primary-text)',
'box-shadow-color': 'var(--c--theme--colors--primary-text)',
@@ -1,4 +1,2 @@
import { tokens } from './cunningham-tokens';
import useCunninghamTheme from './useCunninghamTheme';
export { tokens, useCunninghamTheme };
export * from './cunningham-tokens';
export * from './useCunninghamTheme';
@@ -5,36 +5,31 @@ import { tokens } from './cunningham-tokens';
type Tokens = typeof tokens.themes.default & Partial<typeof tokens.themes.dsfr>;
type ColorsTokens = Tokens['theme']['colors'];
type FontSizesTokens = Tokens['theme']['font']['sizes'];
type SpacingsTokens = Tokens['theme']['spacings'];
type ComponentTokens = Tokens['components'];
type Theme = 'default' | 'dsfr';
export type Theme = keyof typeof tokens.themes;
interface AuthStore {
theme: Theme;
theme: string;
setTheme: (theme: Theme) => void;
themeTokens: () => Partial<Tokens['theme']>;
colorsTokens: () => Partial<ColorsTokens>;
fontSizesTokens: () => Partial<FontSizesTokens>;
spacingsTokens: () => Partial<SpacingsTokens>;
componentTokens: () => ComponentTokens;
}
const useCunninghamTheme = create<AuthStore>((set, get) => {
export const useCunninghamTheme = create<AuthStore>((set, get) => {
const currentTheme = () =>
merge(tokens.themes['default'], tokens.themes[get().theme]) as Tokens;
merge(
tokens.themes['default'],
tokens.themes[get().theme as keyof typeof tokens.themes],
) as Tokens;
return {
theme: (process.env.NEXT_PUBLIC_THEME as Theme) || 'dsfr',
theme: 'dsfr',
themeTokens: () => currentTheme().theme,
colorsTokens: () => currentTheme().theme.colors,
componentTokens: () => currentTheme().components,
spacingsTokens: () => currentTheme().theme.spacings,
fontSizesTokens: () => currentTheme().theme.font.sizes,
setTheme: (theme: Theme) => {
set({ theme });
},
};
});
export default useCunninghamTheme;
-3
View File
@@ -20,9 +20,6 @@ declare module '*.svg?url' {
namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_API_ORIGIN?: string;
NEXT_PUBLIC_MEDIA_URL?: string;
NEXT_PUBLIC_Y_PROVIDER_URL?: string;
NEXT_PUBLIC_SW_DEACTIVATED?: string;
NEXT_PUBLIC_THEME?: string;
}
}
@@ -4,16 +4,14 @@ import { BlockNoteView } from '@blocknote/mantine';
import '@blocknote/mantine/style.css';
import { useCreateBlockNote } from '@blocknote/react';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { t } from 'i18next';
import React, { useCallback, useEffect } from 'react';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, TextErrors } from '@/components';
import { mediaUrl } from '@/core';
import { useAuthStore } from '@/core/auth';
import { Doc } from '@/features/docs/doc-management';
import { useCreateDocAttachment } from '../api/useCreateDocUpload';
import { useUploadFile } from '../hook';
import useSaveDoc from '../hook/useSaveDoc';
import { useEditorStore, useHeadingStore } from '../stores';
import { randomColor } from '../utils';
@@ -26,27 +24,13 @@ const cssEditor = (readonly: boolean) => `
};
& .bn-editor {
padding-right: 30px;
${
readonly &&
`
padding-left: 30px;
pointer-events: none;
`
}
${readonly && `padding-left: 30px;`}
};
& .collaboration-cursor__caret.ProseMirror-widget{
word-wrap: initial;
}
& .bn-inline-content code {
background-color: gainsboro;
padding: 2px;
border-radius: 4px;
}
/* @TODO: A fix is made in v0.19.1 - This code can be removed after the upgrade */
.bn-block-content[data-content-type=codeBlock]>div>select option{
color: black;
background-color: white;
}
@media screen and (width <= 560px) {
& .bn-editor {
padding-left: 40px;
@@ -92,32 +76,19 @@ export const BlockNoteEditor = ({
const isVersion = doc.id !== storeId;
const { userData } = useAuthStore();
const { setEditor } = useEditorStore();
const { t } = useTranslation();
const readOnly = !doc.abilities.partial_update || isVersion;
useSaveDoc(doc.id, provider.document, !readOnly);
const {
mutateAsync: createDocAttachment,
isError: isErrorAttachment,
error: errorAttachment,
} = useCreateDocAttachment();
const { setHeadings, resetHeadings } = useHeadingStore();
const { i18n } = useTranslation();
const lang = i18n.language;
const uploadFile = useCallback(
async (file: File) => {
const body = new FormData();
body.append('file', file);
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
const ret = await createDocAttachment({
docId: doc.id,
body,
});
return `${mediaUrl()}${ret.file}`;
},
[createDocAttachment, doc.id],
);
const collabName =
userData?.full_name ||
userData?.email ||
(readOnly ? 'Reader' : t('Anonymous'));
const editor = useCreateBlockNote(
{
@@ -125,14 +96,39 @@ export const BlockNoteEditor = ({
provider,
fragment: provider.document.getXmlFragment('document-store'),
user: {
name: userData?.full_name || userData?.email || t('Anonymous'),
name: collabName,
color: randomColor(),
},
/**
* We re-use the blocknote code to render the cursor but we:
* - fix rendering issue with Firefox
* - We don't want to show the cursor when anonymous users
*/
renderCursor: (user: { color: string; name: string }) => {
const cursor = document.createElement('span');
if (user.name === 'Reader') {
return cursor;
}
cursor.classList.add('collaboration-cursor__caret');
cursor.setAttribute('style', `border-color: ${user.color}`);
const label = document.createElement('span');
label.classList.add('collaboration-cursor__label');
label.setAttribute('style', `background-color: ${user.color}`);
label.insertBefore(document.createTextNode(user.name), null);
cursor.insertBefore(label, null);
return cursor;
},
},
dictionary: locales[lang as keyof typeof locales] as Dictionary,
uploadFile,
},
[lang, provider, uploadFile, userData?.email, userData?.full_name],
[collabName, lang, provider, uploadFile],
);
useEffect(() => {
@@ -157,7 +153,7 @@ export const BlockNoteEditor = ({
return (
<Box $css={cssEditor(readOnly)}>
{isErrorAttachment && (
{errorAttachment && (
<Box $margin={{ bottom: 'big' }}>
<TextErrors
causes={errorAttachment.cause}
@@ -1,9 +1,10 @@
import { Alert, Loader, VariantType } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Card, Text, TextErrors } from '@/components';
import { useCollaborationUrl } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import { DocHeader } from '@/features/docs/doc-header';
import { Doc, useDocStore } from '@/features/docs/doc-management';
@@ -42,7 +43,7 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
<>
<DocHeader doc={doc} versionId={versionId as Versions['version_id']} />
{!doc.abilities.partial_update && (
<Box $width="100%" $margin={{ all: 'small', top: 'none' }}>
<Box $margin={{ all: 'small', top: 'none' }}>
<Alert type={VariantType.WARNING}>
{t(`Read only, you cannot edit this document.`)}
</Alert>
@@ -57,10 +58,10 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
)}
<Box
$background={colorsTokens()['primary-bg']}
$height="100%"
$direction="row"
$width="100%"
$margin={{ all: isMobile ? 'tiny' : 'small', top: 'none' }}
$css="overflow-x: clip; flex: 1;"
$css="overflow-x: clip;"
$position="relative"
>
<Card
@@ -98,19 +99,20 @@ export const DocVersionEditor = ({ doc, versionId }: DocVersionEditorProps) => {
versionId,
});
const { createProvider, providers } = useDocStore();
const collaborationUrl = useCollaborationUrl(versionId);
const { replace } = useRouter();
useEffect(() => {
if (!version?.id) {
if (!version?.id || !collaborationUrl) {
return;
}
const provider = providers?.[version.id];
if (!provider || provider.document.guid !== version.id) {
createProvider(version.id, version.content);
createProvider(collaborationUrl, version.id, version.content);
}
}, [createProvider, providers, version]);
}, [createProvider, providers, version, collaborationUrl]);
if (isError && error) {
if (error.status === 404) {
@@ -0,0 +1,2 @@
export * from './useSaveDoc';
export * from './useUploadFile';
@@ -0,0 +1,35 @@
import { useCallback } from 'react';
import { useMediaUrl } from '@/core/config';
import { useCreateDocAttachment } from '../api';
export const useUploadFile = (docId: string) => {
const mediaUrl = useMediaUrl();
const {
mutateAsync: createDocAttachment,
isError: isErrorAttachment,
error: errorAttachment,
} = useCreateDocAttachment();
const uploadFile = useCallback(
async (file: File) => {
const body = new FormData();
body.append('file', file);
const ret = await createDocAttachment({
docId,
body,
});
return `${mediaUrl}${ret.file}`;
},
[createDocAttachment, docId, mediaUrl],
);
return {
uploadFile,
isErrorAttachment,
errorAttachment,
};
};
@@ -23,6 +23,5 @@ function hslToHex(h: number, s: number, l: number) {
return `#${f(0)}${f(8)}${f(4)}`;
}
export const toBase64 = (
str: WithImplicitCoercion<ArrayBuffer | SharedArrayBuffer>,
) => Buffer.from(str).toString('base64');
export const toBase64 = (str: Uint8Array) =>
Buffer.from(str).toString('base64');
@@ -1,18 +1,20 @@
import { DateTime } from 'luxon';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { HorizontalSeparator } from '@/components/separators/HorizontalSeparator';
import { Box, Card, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
LinkReach,
Role,
currentDocRole,
useTrans,
} from '@/features/docs/doc-management';
import { Versions } from '@/features/docs/doc-versioning';
import { useDate } from '@/hook';
import { useResponsiveStore } from '@/stores';
import { DocTagPublic } from './DocTagPublic';
import { DocTitle } from './DocTitle';
import { DocToolBox } from './DocToolBox';
@@ -22,43 +24,47 @@ interface DocHeaderProps {
}
export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
const { colorsTokens, spacingsTokens } = useCunninghamTheme();
const spacings = spacingsTokens();
const colors = colorsTokens();
const { colorsTokens } = useCunninghamTheme();
const { t } = useTranslation();
const docIsPublic = doc.link_reach === LinkReach.PUBLIC;
const { formatDate } = useDate();
const { transRole } = useTrans();
const { isMobile, isSmallMobile } = useResponsiveStore();
return (
<>
<Box
$width="100%"
$padding={{ vertical: 'base' }}
$gap={spacings['base']}
<Card
$margin={isMobile ? 'tiny' : 'small'}
aria-label={t('It is the card information about the document.')}
>
{docIsPublic && (
<Box
$padding={isMobile ? 'tiny' : 'small'}
$direction="row"
$align="center"
>
<StyledLink href="/">
<Text
$isMaterialIcon
$theme="primary"
$variation="600"
$size="2rem"
$css={css`
&:hover {
background-color: ${colorsTokens()['primary-100']};
}
`}
$hasTransition
$radius="5px"
$padding="tiny"
>
home
</Text>
</StyledLink>
<Box
aria-label={t('Public document')}
$color={colors['primary-600']}
$background={colors['primary-100']}
$radius={spacings['3xs']}
$direction="row"
$padding="xs"
$flex={1}
$align="center"
$gap={spacings['3xs']}
$css={css`
border: 1px solid var(--c--theme--colors--primary-300, #e3e3fd);
`}
>
<Icon data-testid="public-icon" iconName="public" />
<Text>{t('Public document')}</Text>
</Box>
)}
<Box $direction="row" $align="center" $width="100%">
$width="1px"
$height="70%"
$background={colorsTokens()['greyscale-100']}
$margin={{ horizontal: 'tiny' }}
/>
<Box
$direction="row"
$justify="space-between"
@@ -67,24 +73,55 @@ export const DocHeader = ({ doc, versionId }: DocHeaderProps) => {
$wrap="wrap"
$align="center"
>
<Box $flex={1} $width="100%" $gap={spacings['3xs']}>
<DocTitle doc={doc} />
<Box $direction="row">
<Text $variation="400" $size="s" $weight="bold">
{transRole(currentDocRole(doc.abilities))}&nbsp;·&nbsp;
</Text>
<Text $variation="400" $size="s">
{t('Last update: {{update}}', {
update: DateTime.fromISO(doc.updated_at).toRelative(),
})}
</Text>
</Box>
</Box>
<DocTitle doc={doc} />
<DocToolBox doc={doc} versionId={versionId} />
</Box>
</Box>
<HorizontalSeparator />
</Box>
<Box
$direction={isSmallMobile ? 'column' : 'row'}
$align={isSmallMobile ? 'start' : 'center'}
$css="border-top:1px solid #eee"
$padding={{
horizontal: isMobile ? 'tiny' : 'big',
vertical: 'tiny',
}}
$gap="0.5rem 2rem"
$justify="space-between"
$wrap="wrap"
$position="relative"
>
<Box
$direction={isSmallMobile ? 'column' : 'row'}
$align={isSmallMobile ? 'start' : 'center'}
$gap="0.5rem 2rem"
$wrap="wrap"
>
<DocTagPublic doc={doc} />
<Text $size="s" $display="inline">
{t('Created at')} <strong>{formatDate(doc.created_at)}</strong>
</Text>
<Text $size="s" $display="inline" $elipsis $maxWidth="60vw">
{t('Owners:')}{' '}
<strong>
{doc.accesses
.filter(
(access) => access.role === Role.OWNER && access.user.email,
)
.map((access, index, accesses) => (
<Fragment key={`access-${index}`}>
{access.user.full_name || access.user.email}{' '}
{index < accesses.length - 1 ? ' / ' : ''}
</Fragment>
))}
</strong>
</Text>
</Box>
<Text $size="s" $display="inline">
{t('Your role:')}{' '}
<strong>{transRole(currentDocRole(doc.abilities))}</strong>
</Text>
</Box>
</Card>
</>
);
};
@@ -5,12 +5,12 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import React, { useCallback, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useHeadingStore } from '@/features/docs/doc-editor';
import {
Doc,
KEY_DOC,
@@ -19,6 +19,7 @@ import {
useUpdateDoc,
} from '@/features/docs/doc-management';
import { useBroadcastStore, useResponsiveStore } from '@/stores';
import { isFirefox } from '@/utils/userAgent';
interface DocTitleProps {
doc: Doc;
@@ -31,7 +32,7 @@ export const DocTitle = ({ doc }: DocTitleProps) => {
return (
<Text
as="h2"
$margin={{ all: 'none', left: 'none' }}
$margin={{ all: 'none', left: 'tiny' }}
$size={isMobile ? 'h4' : 'h2'}
>
{doc.title}
@@ -49,11 +50,14 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
const { toast } = useToastProvider();
const { untitledDocument } = useTrans();
const isUntitled = titleDisplay === untitledDocument;
const { headings } = useHeadingStore();
const headingText = headings?.[0]?.contentText;
const debounceRef = useRef<NodeJS.Timeout>();
const { isMobile } = useResponsiveStore();
const { broadcast } = useBroadcastStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
listInvalideQueries: [KEY_LIST_DOC],
onSuccess(data) {
if (data.title !== untitledDocument) {
toast(t('Document title updated successfully'), VariantType.SUCCESS);
@@ -77,6 +81,10 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
// If mutation we update
if (sanitizedTitle !== doc.title) {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
debounceRef.current = undefined;
}
updateDoc({ id: doc.id, title: sanitizedTitle });
}
},
@@ -90,30 +98,75 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
}
};
const handleOnClick = () => {
if (isUntitled) {
setTitleDisplay('');
}
};
useEffect(() => {
setTitleDisplay(doc.title);
}, [doc.title]);
useEffect(() => {
if ((!debounceRef.current && !isUntitled) || !headingText) {
return;
}
setTitleDisplay(headingText);
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
handleTitleSubmit(headingText);
debounceRef.current = undefined;
}, 3000);
}, [isUntitled, handleTitleSubmit, headingText]);
return (
<>
<Tooltip content={t('Rename')} placement="top">
<Box
as="input"
defaultValue={isUntitled ? '' : titleDisplay}
onKeyDownCapture={handleKeyDown}
placeholder={untitledDocument}
aria-label={t('doc title input')}
name="doc-title"
as="h2"
$radius="4px"
onBlurCapture={(event) => handleTitleSubmit(event.target.value)}
$color={colorsTokens()['greyscale-text']}
$flex={1}
$width="100%"
$padding={{ left: '0' }}
$margin={{ left: '-2px' }}
$background="transparent"
$css={css`
font-size: var(--c--theme--font--sizes--h2);
font-weight: 700;
border: none;
$padding={{ horizontal: 'tiny', vertical: '4px' }}
$margin="none"
$minWidth="200px"
contentEditable={isFirefox() ? 'true' : 'plaintext-only'}
onClick={handleOnClick}
onBlurCapture={(e) =>
handleTitleSubmit(e.currentTarget.textContent || '')
}
onKeyDownCapture={handleKeyDown}
suppressContentEditableWarning={true}
$color={
isUntitled
? colorsTokens()['greyscale-200']
: colorsTokens()['greyscale-text']
}
$css={`
${isUntitled && 'font-style: italic;'}
cursor: text;
font-size: ${isMobile ? '1.2rem' : '1.5rem'};
transition: box-shadow 0.5s, border-color 0.5s;
border: 1px dashed transparent;
&:hover {
border-color: rgba(0, 123, 255, 0.25);
border-style: dashed;
}
&:focus {
outline: none;
border-color: transparent;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
`}
/>
>
{titleDisplay}
</Box>
</Tooltip>
</>
);
@@ -3,19 +3,11 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { useState } from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import {
Box,
DropdownMenu,
DropdownMenuOption,
Icon,
IconOptions,
} from '@/components';
import { Box, DropButton, IconOptions } from '@/components';
import { useAuthStore } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import {
useEditorStore,
usePanelEditorStore,
@@ -37,15 +29,10 @@ interface DocToolBoxProps {
export const DocToolBox = ({ doc, versionId }: DocToolBoxProps) => {
const { t } = useTranslation();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const spacings = spacingsTokens();
const colors = colorsTokens();
const [isModalShareOpen, setIsModalShareOpen] = useState(false);
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isModalPDFOpen, setIsModalPDFOpen] = useState(false);
const [isDropOpen, setIsDropOpen] = useState(false);
const { setIsPanelOpen, setIsPanelTableContentOpen } = usePanelEditorStore();
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
const { isSmallMobile } = useResponsiveStore();
@@ -53,74 +40,6 @@ export const DocToolBox = ({ doc, versionId }: DocToolBoxProps) => {
const { editor } = useEditorStore();
const { toast } = useToastProvider();
const options: DropdownMenuOption[] = [
...(isSmallMobile
? [
{
label: t('Share'),
icon: 'upload',
callback: () => {
setIsModalShareOpen(true);
},
},
{
label: t('Export'),
icon: 'download',
callback: () => {
setIsModalPDFOpen(true);
},
},
]
: []),
{
label: t('Version history'),
icon: 'history',
disabled: !doc.abilities.versions_list,
callback: () => {
setIsPanelOpen(true);
setIsPanelTableContentOpen(false);
},
},
{
label: t('Table of contents'),
icon: 'summarize',
callback: () => {
setIsPanelOpen(true);
setIsPanelTableContentOpen(true);
},
},
{
label: t('Export'),
icon: 'file_download',
callback: () => {
setIsModalPDFOpen(true);
},
},
{
label: t('Copy as {{format}}', { format: 'Markdown' }),
icon: 'content_copy',
callback: () => {
void copyCurrentEditorToClipboard('markdown');
},
},
{
label: t('Copy as {{format}}', { format: 'HTML' }),
icon: 'content_copy',
callback: () => {
void copyCurrentEditorToClipboard('html');
},
},
{
label: t('Delete document'),
icon: 'delete',
disabled: !doc.abilities.destroy,
callback: () => {
setIsModalRemoveOpen(true);
},
},
];
const copyCurrentEditorToClipboard = async (
asFormat: 'html' | 'markdown',
) => {
@@ -165,10 +84,9 @@ export const DocToolBox = ({ doc, versionId }: DocToolBoxProps) => {
</Button>
</Box>
)}
<Box $direction="row" $margin={{ left: 'auto' }} $gap={spacings['2xs']}>
{authenticated && !isSmallMobile && (
<Box $direction="row" $margin={{ left: 'auto' }} $gap="1rem">
{authenticated && (
<Button
color="primary-text"
onClick={() => {
setIsModalShareOpen(true);
}}
@@ -177,34 +95,91 @@ export const DocToolBox = ({ doc, versionId }: DocToolBoxProps) => {
{t('Share')}
</Button>
)}
{!isSmallMobile && (
<Button
color="primary-text"
icon={
<Icon iconName="download" $theme="primary" $variation="800" />
}
onClick={() => {
setIsModalPDFOpen(true);
}}
size={isSmallMobile ? 'small' : 'medium'}
/>
)}
<DropdownMenu options={options}>
<IconOptions
isHorizontal
$theme="primary"
$radius={spacings['3xs']}
$css={
isSmallMobile
? css`
padding: 10px;
border: 1px solid ${colors['greyscale-300']};
`
: ''
}
aria-label={t('Open the document options')}
/>
</DropdownMenu>
<DropButton
button={
<IconOptions
isOpen={isDropOpen}
aria-label={t('Open the document options')}
/>
}
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
isOpen={isDropOpen}
>
<Box>
{doc.abilities.versions_list && (
<Button
onClick={() => {
setIsPanelOpen(true);
setIsPanelTableContentOpen(false);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">history</span>}
size="small"
>
{t('Version history')}
</Button>
)}
<Button
onClick={() => {
setIsPanelOpen(true);
setIsPanelTableContentOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">summarize</span>}
size="small"
>
{t('Table of contents')}
</Button>
<Button
onClick={() => {
setIsModalPDFOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">file_download</span>}
size="small"
>
{t('Export')}
</Button>
{doc.abilities.destroy && (
<Button
onClick={() => {
setIsModalRemoveOpen(true);
setIsDropOpen(false);
}}
color="primary-text"
icon={<span className="material-icons">delete</span>}
size="small"
>
{t('Delete document')}
</Button>
)}
<Button
onClick={() => {
setIsDropOpen(false);
void copyCurrentEditorToClipboard('markdown');
}}
color="primary-text"
icon={<span className="material-icons">content_copy</span>}
size="small"
>
{t('Copy as {{format}}', { format: 'Markdown' })}
</Button>
<Button
onClick={() => {
setIsDropOpen(false);
void copyCurrentEditorToClipboard('html');
}}
color="primary-text"
icon={<span className="material-icons">content_copy</span>}
size="small"
>
{t('Copy as {{format}}', { format: 'HTML' })}
</Button>
</Box>
</DropButton>
</Box>
{isModalShareOpen && (
<ModalShare onClose={() => setIsModalShareOpen(false)} doc={doc} />
@@ -10,8 +10,8 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { t } from 'i18next';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from '@/components';
import { useEditorStore } from '@/features/docs/doc-editor';
@@ -27,6 +27,7 @@ interface ModalPDFProps {
}
export const ModalPDF = ({ onClose, doc }: ModalPDFProps) => {
const { t } = useTranslation();
const { data: templates } = useTemplates({
ordering: TemplatesOrdering.BY_CREATED_ON_DESC,
});
@@ -1,12 +1,6 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import {
APIError,
APIList,
errorCauses,
fetchAPI,
useAPIInfiniteQuery,
} from '@/api';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import { Doc } from '../types';
@@ -58,7 +52,3 @@ export function useDocs(
...queryConfig,
});
}
export const useInfiniteDocs = (params: DocsParams) => {
return useAPIInfiniteQuery(KEY_LIST_DOC, getDocs, params);
};
@@ -30,7 +30,7 @@ export const useRemoveDoc = (options?: UseRemoveDocOptions) => {
mutationFn: removeDoc,
...options,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC],
});
if (options?.onSuccess) {
@@ -1,9 +0,0 @@
<svg width="32" height="36" viewBox="0 0 32 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.01394" y="1.23611" width="25.9722" height="33.5278" rx="3.54167" fill="white"/>
<rect x="2.01394" y="1.23611" width="25.9722" height="33.5278" rx="3.54167" stroke="#DCDCFC" stroke-width="0.472222"/>
<path d="M6.5 8.55556H15" stroke="#6A6AF4" stroke-width="1.88889" stroke-linecap="round"/>
<path d="M6.5 11.3889H23.5M6.5 14.2222H23.5M6.5 17.0556H23.5M6.5 19.8889H23.5M6.5 22.7222H20.6667" stroke="#CACAFB" stroke-width="1.88889" stroke-linecap="round"/>
<rect x="7" y="10" width="16" height="16" rx="8" fill="#6A6AF4"/>
<rect x="7" y="10" width="16" height="16" rx="8" stroke="white" stroke-width="1.5"/>
<path d="M16.8 18L18 19.2V20.1H15.45V22.95L15 23.4L14.55 22.95V20.1H12V19.2L13.2 18V14.7H12.6V13.8H17.4V14.7H16.8V18Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 853 B

@@ -1,6 +0,0 @@
<svg width="28" height="34" viewBox="0 0 28 34" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.01394" y="0.236111" width="25.9722" height="33.5278" rx="3.54167" fill="white"/>
<rect x="1.01394" y="0.236111" width="25.9722" height="33.5278" rx="3.54167" stroke="#DCDCFC" stroke-width="0.472222"/>
<path d="M5.5 7.55554H14" stroke="#6A6AF4" stroke-width="1.88889" stroke-linecap="round"/>
<path d="M5.5 10.3889H22.5M5.5 13.2222H22.5M5.5 16.0556H22.5M5.5 18.8889H22.5M5.5 21.7222H22.5M5.5 24.5556H22.5M5.5 27.3889H22.5M5.5 30.2222H22.5M5.5 33.0556H22.5" stroke="#CACAFB" stroke-width="1.88889" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 635 B

@@ -6,13 +6,14 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { t } from 'i18next';
import { usePathname } from 'next/navigation';
import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';
import { Box, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham/';
import { useRemoveDoc } from '../api/useRemoveDoc';
import IconDoc from '../assets/icon-doc.svg';
import { Doc } from '../types';
interface ModalRemoveDocProps {
@@ -21,13 +22,13 @@ interface ModalRemoveDocProps {
}
export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const { toast } = useToastProvider();
const { push } = useRouter();
const pathname = usePathname();
const {
mutate: removeDoc,
isError,
error,
} = useRemoveDoc({
@@ -35,11 +36,7 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
toast(t('The document has been deleted.'), VariantType.SUCCESS, {
duration: 4000,
});
if (pathname === '/') {
onClose();
} else {
void push('/');
}
void push('/');
},
});
@@ -62,7 +59,7 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
rightActions={
<Button
aria-label={t('Confirm deletion')}
color="danger"
color="primary"
fullWidth
onClick={() =>
removeDoc({
@@ -100,6 +97,32 @@ export const ModalRemoveDoc = ({ onClose, doc }: ModalRemoveDocProps) => {
)}
{isError && <TextErrors causes={error.cause} />}
<Text
as="p"
$padding="small"
$direction="row"
$gap="0.5rem"
$background={colorsTokens()['primary-150']}
$theme="primary"
$align="center"
$radius="2px"
>
<IconDoc
className="p-t"
aria-label={t(`Document icon`)}
color={colorsTokens()['primary-500']}
width={58}
style={{
borderRadius: '8px',
backgroundColor: '#ffffff',
border: `1px solid ${colorsTokens()['primary-300']}`,
}}
/>
<Text $theme="primary" $weight="bold" $size="l">
{doc.title}
</Text>
</Text>
</Box>
</Modal>
);
@@ -3,7 +3,7 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { t } from 'i18next';
import { useTranslation } from 'react-i18next';
import { createGlobalStyle } from 'styled-components';
import { Box, Card, IconBG, SideModal, Text } from '@/components';
@@ -44,6 +44,7 @@ interface ModalShareProps {
}
export const ModalShare = ({ onClose, doc }: ModalShareProps) => {
const { t } = useTranslation();
const { isMobile, isSmallMobile } = useResponsiveStore();
const width = isSmallMobile ? '100vw' : isMobile ? '90vw' : '70vw';
const { toast } = useToastProvider();
@@ -2,7 +2,6 @@ import { HocuspocusProvider } from '@hocuspocus/provider';
import * as Y from 'yjs';
import { create } from 'zustand';
import { providerUrl } from '@/core';
import { Base64, Doc, blocksToYDoc } from '@/features/docs/doc-management';
export interface UseDocStore {
@@ -10,7 +9,11 @@ export interface UseDocStore {
providers: {
[storeId: string]: HocuspocusProvider;
};
createProvider: (storeId: string, initialDoc: Base64) => HocuspocusProvider;
createProvider: (
providerUrl: string,
storeId: string,
initialDoc: Base64,
) => HocuspocusProvider;
setProviders: (storeId: string, providers: HocuspocusProvider) => void;
setCurrentDoc: (doc: Doc | undefined) => void;
}
@@ -18,7 +21,7 @@ export interface UseDocStore {
export const useDocStore = create<UseDocStore>((set, get) => ({
currentDoc: undefined,
providers: {},
createProvider: (storeId: string, initialDoc: Base64) => {
createProvider: (providerUrl, storeId, initialDoc) => {
const doc = new Y.Doc({
guid: storeId,
});
@@ -37,7 +40,7 @@ export const useDocStore = create<UseDocStore>((set, get) => ({
}
const provider = new HocuspocusProvider({
url: providerUrl(storeId),
url: providerUrl,
name: storeId,
document: doc,
});
@@ -1,9 +1,8 @@
import { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, BoxButton, Text } from '@/components';
import { HeadingBlock, useEditorStore } from '@/features/docs/doc-editor';
import { MAIN_LAYOUT_ID } from '@/layouts/conf';
import { useResponsiveStore } from '@/stores';
import { Heading } from './Heading';
@@ -47,7 +46,7 @@ export const TableContent = ({ headings }: TableContentProps) => {
}
};
document.getElementById(MAIN_LAYOUT_ID)?.addEventListener('scroll', () => {
window.addEventListener('scroll', () => {
setTimeout(() => {
handleScroll();
}, 300);
@@ -6,8 +6,8 @@ import {
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import { t } from 'i18next';
import { useRouter } from 'next/router';
import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { Box, Text } from '@/components';
@@ -30,6 +30,7 @@ export const ModalVersion = ({
docId,
versionId,
}: ModalVersionProps) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const { push } = useRouter();
const { providers } = useDocStore();
@@ -1,6 +1,6 @@
import { Button } from '@openfun/cunningham-react';
import { t } from 'i18next';
import { PropsWithChildren, useState } from 'react';
import React, { PropsWithChildren, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, DropButton, IconOptions, StyledLink, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
@@ -25,6 +25,7 @@ export const VersionItem = ({
link,
isActive,
}: VersionItemProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [isDropOpen, setIsDropOpen] = useState(false);
const [isModalVersionOpen, setIsModalVersionOpen] = useState(false);
@@ -69,7 +70,10 @@ export const VersionItem = ({
{isActive && versionId && (
<DropButton
button={
<IconOptions aria-label={t('Open the version options')} />
<IconOptions
isOpen={isDropOpen}
aria-label={t('Open the version options')}
/>
}
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
isOpen={isDropOpen}
@@ -1,103 +1,220 @@
import { Button, Loader } from '@openfun/cunningham-react';
import {
Column,
DataGrid,
SortModel,
usePagination,
} from '@openfun/cunningham-react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { InView } from 'react-intersection-observer';
import { createGlobalStyle } from 'styled-components';
import { Box, Card, Text } from '@/components';
import { Card, StyledLink, Text, TextErrors } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
DocsOrdering,
LinkReach,
currentDocRole,
isDocsOrdering,
useDocs,
useTrans,
} from '@/features/docs/doc-management';
import { useDate } from '@/hook/';
import { useResponsiveStore } from '@/stores';
import { useInfiniteDocs } from '../../doc-management';
import { PAGE_SIZE } from '../conf';
import { DocsGridItem } from './DocsGridItem';
import { DocsGridLoader } from './DocsGridLoader';
import { DocsGridActions } from './DocsGridActions';
const DocsGridStyle = createGlobalStyle`
& .c__datagrid thead{
position: sticky;
top: 0;
background: #fff;
z-index: 1;
}
& .c__pagination__goto{
display:none;
}
`;
type SortModelItem = {
field: string;
sort: 'asc' | 'desc' | null;
};
function formatSortModel(sortModel: SortModelItem): DocsOrdering | undefined {
const { field, sort } = sortModel;
const orderingField = sort === 'desc' ? `-${field}` : field;
if (isDocsOrdering(orderingField)) {
return orderingField;
}
}
export const DocsGrid = () => {
const { colorsTokens } = useCunninghamTheme();
const { transRole } = useTrans();
const { t } = useTranslation();
const { isDesktop } = useResponsiveStore();
const {
data,
isFetching,
isRefetching,
isLoading,
fetchNextPage,
hasNextPage,
} = useInfiniteDocs({
page: 1,
const { formatDate } = useDate();
const pagination = usePagination({
pageSize: PAGE_SIZE,
});
const loading = isFetching || isLoading;
const [sortModel, setSortModel] = useState<SortModel>([
{
field: 'updated_at',
sort: 'desc',
},
]);
const { page, pageSize, setPagesCount } = pagination;
const [docs, setDocs] = useState<Doc[]>([]);
const { isMobile } = useResponsiveStore();
const loadMore = (inView: boolean) => {
if (!inView || loading) {
const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined;
const { data, isLoading, error } = useDocs({
page,
ordering,
});
useEffect(() => {
if (isLoading) {
return;
}
void fetchNextPage();
};
setDocs(data?.results || []);
}, [data?.results, t, isLoading]);
useEffect(() => {
setPagesCount(data?.count ? Math.ceil(data.count / pageSize) : 0);
}, [data?.count, pageSize, setPagesCount]);
const columns: Column<Doc>[] = [
{
headerName: '',
id: 'visibility',
size: 95,
renderCell: ({ row }) => {
return (
row.link_reach === LinkReach.PUBLIC && (
<StyledLink href={`/docs/${row.id}`}>
<Text
$weight="bold"
$background={colorsTokens()['primary-600']}
$color="white"
$padding="xtiny"
$radius="3px"
>
{t('Public')}
</Text>
</StyledLink>
)
);
},
},
{
headerName: t('Document name'),
field: 'title',
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
<Text $weight="bold" $theme="primary">
{row.title}
</Text>
</StyledLink>
);
},
},
{
headerName: t('Created at'),
field: 'created_at',
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
<Text $weight="bold">{formatDate(row.created_at)}</Text>
</StyledLink>
);
},
},
{
headerName: t('Updated at'),
field: 'updated_at',
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
<Text $weight="bold">{formatDate(row.updated_at)}</Text>
</StyledLink>
);
},
},
{
headerName: t('Your role'),
id: 'your_role',
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
<Text $weight="bold">
{transRole(currentDocRole(row.abilities))}
</Text>
</StyledLink>
);
},
},
{
headerName: t('Members'),
id: 'users_number',
renderCell: ({ row }) => {
return (
<StyledLink href={`/docs/${row.id}`}>
<Text $weight="bold">{row.accesses.length}</Text>
</StyledLink>
);
},
},
{
id: 'column-actions',
renderCell: ({ row }) => {
return <DocsGridActions doc={row} />;
},
},
];
// Inverse columns for mobile to have the most important information first
if (isMobile) {
const tmpCol = columns[0];
columns[0] = columns[1];
columns[1] = tmpCol;
}
return (
<Box $position="relative" $width="100%" $maxWidth="960px">
<DocsGridLoader isLoading={isRefetching} />
<Card data-testid="docs-grid" $padding="md">
<Text
as="h4"
$size="h4"
$weight="700"
$margin={{ top: '0px', bottom: 'xs' }}
>
{t('All docs')}
</Text>
<Card
$padding={{ bottom: 'small', horizontal: 'big' }}
$margin={{ all: isMobile ? 'small' : 'big', top: 'none' }}
$overflow="auto"
aria-label={t(`Datagrid of the documents page {{page}}`, { page })}
$height="100%"
>
<DocsGridStyle />
<Text
$weight="bold"
as="h2"
$theme="primary"
$margin={{ bottom: 'small' }}
>
{t('Documents')}
</Text>
<Box>
<Box $direction="row" $padding="xs" data-testid="docs-grid-header">
<Box $flex={6} $padding="3xs">
<Text $size="xs" $variation="600">
{t('Name')}
</Text>
</Box>
{isDesktop && (
<Box $flex={1} $padding="3xs">
<Text $size="xs" $variation="600">
{t('Updated at')}
</Text>
</Box>
)}
{error && <TextErrors causes={error.cause} />}
<Box $flex={1} $align="flex-end" $padding="3xs" />
</Box>
{/* Body */}
{data?.pages.map((currentPage) => {
return currentPage.results.map((doc) => (
<DocsGridItem doc={doc} key={doc.id} />
));
})}
</Box>
{loading && (
<Box
data-testid="docs-grid-loader"
$padding="md"
$align="center"
$justify="center"
$width="100%"
>
<Loader />
</Box>
)}
{hasNextPage && !loading && (
<InView
data-testid="infinite-scroll-trigger"
as="div"
onChange={loadMore}
>
{!isFetching && hasNextPage && (
<Button onClick={() => void fetchNextPage()} color="primary-text">
{t('More docs')}
</Button>
)}
</InView>
)}
</Card>
</Box>
<DataGrid
columns={columns}
rows={docs}
isLoading={isLoading}
pagination={pagination}
onSortModelChange={setSortModel}
sortModel={sortModel}
emptyPlaceholderLabel={t("You don't have any document yet.")}
/>
</Card>
);
};
@@ -1,7 +1,7 @@
import { useModal } from '@openfun/cunningham-react';
import { Button } from '@openfun/cunningham-react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DropdownMenu, DropdownMenuOption, Icon } from '@/components';
import { Doc, ModalRemoveDoc } from '@/features/docs/doc-management';
interface DocsGridActionsProps {
@@ -10,31 +10,26 @@ interface DocsGridActionsProps {
export const DocsGridActions = ({ doc }: DocsGridActionsProps) => {
const { t } = useTranslation();
const deleteModal = useModal();
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const options: DropdownMenuOption[] = [
{
label: t('Remove'),
icon: 'delete',
callback: () => deleteModal.open(),
disabled: !doc.abilities.destroy,
testId: `docs-grid-actions-remove-${doc.id}`,
},
];
if (!doc.abilities.destroy) {
return null;
}
return (
<>
<DropdownMenu options={options}>
<Icon
data-testid={`docs-grid-actions-button-${doc.id}`}
iconName="more_horiz"
$theme="primary"
$variation="600"
/>
</DropdownMenu>
{deleteModal.isOpen && (
<ModalRemoveDoc onClose={deleteModal.onClose} doc={doc} />
<Button
onClick={() => {
setIsModalRemoveOpen(true);
}}
color="tertiary-text"
icon={<span className="material-icons">delete</span>}
size="small"
style={{ padding: '0rem' }}
aria-label={t('Delete the document')}
/>
{isModalRemoveOpen && (
<ModalRemoveDoc onClose={() => setIsModalRemoveOpen(false)} doc={doc} />
)}
</>
);
@@ -0,0 +1,40 @@
import { Button } from '@openfun/cunningham-react';
import { useRouter } from 'next/router';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
import { useCreateDoc, useTrans } from '@/features/docs/doc-management/';
import { useResponsiveStore } from '@/stores';
import { DocsGrid } from './DocsGrid';
export const DocsGridContainer = () => {
const { t } = useTranslation();
const { untitledDocument } = useTrans();
const { push } = useRouter();
const { isMobile } = useResponsiveStore();
const { mutate: createDoc } = useCreateDoc({
onSuccess: (doc) => {
void push(`/docs/${doc.id}`);
},
});
const handleCreateDoc = () => {
createDoc({ title: untitledDocument });
};
return (
<Box $overflow="auto">
<Box
$align="flex-end"
$justify="center"
$margin={isMobile ? 'small' : 'big'}
>
<Button onClick={handleCreateDoc}>{t('Create a new document')}</Button>
</Box>
<DocsGrid />
</Box>
);
};
@@ -1,104 +0,0 @@
import { Button } from '@openfun/cunningham-react';
import { DateTime } from 'luxon';
import { css } from 'styled-components';
import { Box, Icon, StyledLink, Text } from '@/components';
import { useResponsiveStore } from '@/stores';
import { Doc, LinkReach } from '../../doc-management';
import { DocsGridActions } from './DocsGridActions';
import { SimpleDocItem } from './SimpleDocItem';
type DocsGridItemProps = {
doc: Doc;
};
export const DocsGridItem = ({ doc }: DocsGridItemProps) => {
const { isDesktop } = useResponsiveStore();
const isPublic = doc.link_reach === LinkReach.PUBLIC;
const isAuthenticated = doc.link_reach === LinkReach.AUTHENTICATED;
const isRestricted = doc.link_reach === LinkReach.RESTRICTED;
const sharedCount = doc.accesses.length - 1;
const isShared = sharedCount > 0;
return (
<Box
$direction="row"
$width="100%"
$align="center"
role="row"
$padding={{ vertical: 'xs', horizontal: 'sm' }}
$css={css`
cursor: pointer;
border-radius: 4px;
&:hover {
background-color: var(--c--theme--colors--greyscale-100);
}
`}
>
<StyledLink $css="flex: 7; align-items: center;" href={`/docs/${doc.id}`}>
<Box
data-testid={`docs-grid-name-${doc.id}`}
$flex={6}
$padding={{ right: 'base' }}
>
<SimpleDocItem doc={doc} />
</Box>
{isDesktop && (
<Box $flex={1}>
<Text $variation="500" $size="xs">
{DateTime.fromISO(doc.updated_at).toRelative()}
</Text>
</Box>
)}
</StyledLink>
<Box
$flex={1}
$direction="row"
$align="center"
$justify="flex-end"
$gap="10px"
>
{isDesktop && isPublic && (
<Button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
size="nano"
icon={<Icon $variation="000" iconName="public" />}
>
{isShared ? sharedCount : undefined}
</Button>
)}
{isDesktop && !isPublic && isRestricted && isShared && (
<Button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
color="tertiary"
size="nano"
icon={<Icon $variation="800" $theme="primary" iconName="group" />}
>
{sharedCount}
</Button>
)}
{isDesktop && !isPublic && isAuthenticated && (
<Button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
}}
size="nano"
icon={<Icon $variation="000" iconName="corporate_fare" />}
>
{sharedCount}
</Button>
)}
<DocsGridActions doc={doc} />
</Box>
</Box>
);
};
@@ -1,44 +0,0 @@
import { Loader } from '@openfun/cunningham-react';
import { createGlobalStyle, css } from 'styled-components';
import { Box } from '@/components';
import { HEADER_HEIGHT } from '@/features/header/conf';
const DocsGridLoaderStyle = createGlobalStyle`
body, main {
overflow: hidden!important;
overflow-y: hidden!important;
}
`;
type DocsGridLoaderProps = {
isLoading: boolean;
};
export const DocsGridLoader = ({ isLoading }: DocsGridLoaderProps) => {
if (!isLoading) {
return null;
}
return (
<>
<DocsGridLoaderStyle />
<Box
data-testid="grid-loader"
$align="center"
$justify="center"
$height="calc(100vh - 50px)"
$width="100%"
$maxWidth="960px"
$background="rgba(255, 255, 255, 0.3)"
$zIndex={998}
$position="fixed"
$css={css`
top: ${HEADER_HEIGHT}px;
`}
>
<Loader />
</Box>
</>
);
};
@@ -1,83 +0,0 @@
import { css } from 'styled-components';
import { Box, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { Doc, LinkReach } from '@/features/docs';
import PinnedDocumentIcon from '@/features/docs/doc-management/assets/pinned-document.svg';
import SimpleFileIcon from '@/features/docs/doc-management/assets/simple-document.svg';
import { useResponsiveStore } from '@/stores';
const ItemTextCss = css`
overflow: hidden;
text-overflow: ellipsis;
white-space: initial;
display: -webkit-box;
line-clamp: 1;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
`;
type SimpleDocItemProps = {
doc: Doc;
isPinned?: boolean;
subText?: string;
};
export const SimpleDocItem = ({
doc,
isPinned = false,
subText,
}: SimpleDocItemProps) => {
const { spacingsTokens } = useCunninghamTheme();
const { isDesktop } = useResponsiveStore();
const spacings = spacingsTokens();
const isPublic = doc?.link_reach === LinkReach.PUBLIC;
const isShared = !isPublic && doc.accesses.length > 1;
const accessCount = doc.accesses.length - 1;
const isSharedOrPublic = isShared || isPublic;
return (
<Box $direction="row" $gap={spacings.sm}>
<Box
$direction="row"
$align="center"
$css={css`
background-color: transparent;
filter: drop-shadow(0px 2px 2px rgba(0, 0, 0, 0.05));
`}
>
{isPinned ? <PinnedDocumentIcon /> : <SimpleFileIcon />}
</Box>
<Box>
<Text
aria-describedby="doc-title"
aria-label={doc.title}
$size="sm"
$variation="1000"
$weight="500"
$css={ItemTextCss}
>
{doc.title}
</Text>
<Box $direction="row" $align="center" $gap={spacings['3xs']}>
{!isDesktop && (
<>
{isPublic && <Icon iconName="public" $size="16px" />}
{isShared && <Icon iconName="group" $size="16px" />}
{isSharedOrPublic && accessCount > 0 && (
<Text $size="12px">{accessCount}</Text>
)}
{isSharedOrPublic && <Text $size="12px">·</Text>}
</>
)}
<Text $size="xs" $variation="500" $weight="500" $css={ItemTextCss}>
{subText ??
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi vel ante libero. Interdum et malesuada fames ac ante ipsum primis in faucibus. Sed imperdiet neque quam, sed euismod metus mollis ut. '}
</Text>
</Box>
</Box>
</Box>
);
};
@@ -0,0 +1 @@
export * from './DocsGridContainer';
@@ -1,2 +1 @@
export * from './components';
export * from './stores';
@@ -1,4 +1,31 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.6305 28.8312C22.7983 28.5038 23.9166 27.9062 24.6505 26.8503C25.3749 25.8163 25.5789 24.5047 25.5789 23.2425V4.75099C25.5789 4.42358 25.5611 4.09557 25.5216 3.77148C26.1016 3.99961 26.5486 4.37658 26.8626 4.90239C27.2331 5.50024 27.4184 6.28757 27.4184 7.26435V26.0464C27.4184 27.3684 27.0942 28.3578 26.4458 29.0146C25.7974 29.6714 24.8207 29.9998 23.5155 29.9998H16.4209C16.5889 29.9704 16.7574 29.9401 16.9262 29.909C18.4067 29.6444 19.9713 29.2854 21.6185 28.8346L21.6305 28.8312Z" fill="#C9191E"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.58203 25.655V6.8477C4.58203 5.70251 4.88938 4.83519 5.50408 4.24575C6.1272 3.65631 6.95242 3.33212 7.97972 3.27318C9.49542 3.18055 10.9311 3.05425 12.2868 2.89425C13.6425 2.72584 14.9393 2.53217 16.1771 2.31324C17.4234 2.0943 18.6359 1.85011 19.8148 1.58065C21.0274 1.29435 21.9578 1.4375 22.6062 2.0101C23.2546 2.58269 23.5788 3.49632 23.5788 4.75099V23.2425C23.5788 24.3456 23.3893 25.1666 23.0104 25.7055C22.6315 26.2529 21.9915 26.6528 21.0905 26.9054C19.4906 27.3433 17.9833 27.6886 16.5687 27.9412C15.154 28.2022 13.7731 28.4001 12.4258 28.5348C11.0785 28.6696 9.69751 28.7748 8.28286 28.8506C7.11241 28.918 6.20299 28.6738 5.5546 28.118C4.90622 27.5707 4.58203 26.7497 4.58203 25.655ZM9.20865 10.2624C11.0635 10.1444 12.7632 9.96305 14.3075 9.71831C14.6822 9.65722 15.0564 9.5936 15.4291 9.52759C15.8192 9.45851 16.1013 9.11859 16.1013 8.72337C16.1013 8.21154 15.638 7.82609 15.135 7.91189C14.846 7.96118 14.5555 8.00909 14.2635 8.05562C12.7346 8.29923 11.0452 8.47998 9.19523 8.5977C8.91819 8.61558 8.69776 8.70188 8.55608 8.87391C8.42209 9.03661 8.35645 9.23229 8.35645 9.45535C8.35645 9.68212 8.43296 9.87951 8.58568 10.0418L8.58783 10.0439C8.75336 10.2095 8.96369 10.2811 9.20865 10.2624ZM9.20801 14.456C11.0631 14.338 12.763 14.1566 14.3075 13.9119C15.8588 13.6589 17.3936 13.3638 18.9112 13.0266C19.2191 12.9581 19.4498 12.8503 19.5652 12.683C19.6786 12.5221 19.7347 12.3376 19.7347 12.1332C19.7347 11.9026 19.6469 11.704 19.476 11.5426C19.2921 11.3689 19.0348 11.3284 18.7304 11.3911L18.7285 11.3915C17.2823 11.7194 15.794 12.0053 14.2635 12.2492C12.7346 12.4928 11.0452 12.6735 9.19523 12.7913C8.91819 12.8091 8.69776 12.8954 8.55608 13.0675C8.42276 13.2294 8.35645 13.4205 8.35645 13.6363C8.35645 13.8703 8.43209 14.0723 8.58558 14.2354L8.59 14.2396C8.75499 14.3949 8.96316 14.4655 9.20551 14.4562L9.20801 14.456ZM9.20847 18.6494C11.0634 18.5229 12.7631 18.3374 14.3075 18.0927C15.8589 17.8482 17.3934 17.5573 18.9112 17.22C19.2199 17.1514 19.4508 17.0391 19.566 16.8627C19.6783 16.7029 19.7347 16.5233 19.7347 16.3266C19.7347 16.0961 19.6469 15.8974 19.476 15.7361C19.2921 15.5623 19.0348 15.5218 18.7304 15.5845L18.729 15.5848C17.2827 15.9043 15.7942 16.1861 14.2635 16.43C12.7345 16.6736 11.045 16.8586 9.19495 16.9847C8.91804 17.0026 8.69771 17.0889 8.55608 17.2609C8.42276 17.4228 8.35645 17.6139 8.35645 17.8297C8.35645 18.0637 8.43209 18.2658 8.58558 18.4289L8.59 18.433C8.75499 18.5883 8.96316 18.6589 9.20551 18.6496L9.20847 18.6494ZM14.3075 22.257C12.7632 22.5018 11.0635 22.6831 9.20867 22.8012C8.9637 22.8198 8.75337 22.7482 8.58783 22.5826L8.58572 22.5805C8.433 22.4182 8.35645 22.2208 8.35645 21.9941C8.35645 21.771 8.42209 21.5753 8.55608 21.4126C8.69776 21.2406 8.91827 21.1543 9.19531 21.1364C11.0453 21.0187 12.7346 20.838 14.2635 20.5943C14.5555 20.5478 14.846 20.4999 15.135 20.4506C15.638 20.3648 16.1013 20.7503 16.1013 21.2621C16.1013 21.6573 15.8192 21.9972 15.4291 22.0663C15.0564 22.1323 14.6822 22.1959 14.3075 22.257Z" fill="#000091"/>
<svg viewBox="0 0 36 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_5_830)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M26.5706 16.681V20.756H9.59985V16.681H26.5706ZM26.5706 23.0467V27.1215H9.59985V23.0467H26.5706ZM19.5375 29.2926V33.3674H9.59985V29.2926H19.5375Z"
fill="#E1000F"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M35.6982 16.7351V33.2911C35.6826 33.2845 35.667 33.2756 35.6492 33.269V37.4663C35.6492 39.8874 33.641 41.8683 31.1867 41.8683H4.461C2.00664 41.8683 -0.00146484 39.8874 -0.00146484 37.4663V4.45137C-0.00146484 2.03028 2.00664 0.0493774 4.461 0.0493774H18.723V4.45137H4.461V37.4663H31.1867V16.7351H35.6982Z"
fill="#000091"
/>
<path
d="M33.9524 0.0360107H23.626C22.6794 0.0360107 21.9049 0.799997 21.9049 1.73376V11.9202C21.9049 12.854 22.6794 13.618 23.626 13.618H33.9524C34.899 13.618 35.6735 12.854 35.6735 11.9202V1.73376C35.6735 0.799997 34.899 0.0360107 33.9524 0.0360107ZM26.6378 6.40257C26.6378 7.10713 26.0613 7.67588 25.347 7.67588H24.4865V8.73697C24.4865 9.08501 24.1939 9.37362 23.8411 9.37362C23.4883 9.37362 23.1957 9.08501 23.1957 8.73697V5.12925C23.1957 4.66237 23.5829 4.28038 24.0562 4.28038H25.347C26.0613 4.28038 26.6378 4.84913 26.6378 5.55369V6.40257ZM30.9405 8.10031C30.9405 8.80488 30.364 9.37362 29.6497 9.37362H27.9286C27.6877 9.37362 27.4984 9.18687 27.4984 8.94919V4.70482C27.4984 4.46713 27.6877 4.28038 27.9286 4.28038H29.6497C30.364 4.28038 30.9405 4.84913 30.9405 5.55369V8.10031ZM34.3827 4.91704C34.3827 5.26507 34.0901 5.55369 33.7373 5.55369H33.0919V6.40257H33.7373C34.0901 6.40257 34.3827 6.69118 34.3827 7.03922C34.3827 7.38726 34.0901 7.67588 33.7373 7.67588H33.0919V8.73697C33.0919 9.08501 32.7993 9.37362 32.4465 9.37362C32.0936 9.37362 31.8011 9.08501 31.8011 8.73697V5.12925C31.8011 4.66237 32.1883 4.28038 32.6616 4.28038H33.7373C34.0901 4.28038 34.3827 4.569 34.3827 4.91704ZM24.4865 6.40257H25.347V5.55369H24.4865V6.40257ZM28.7892 8.10031H29.6497V5.55369H28.7892V8.10031Z"
fill="#000091"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M35.6734 10.0666V13.6574H32.4942V10.0666H35.6734ZM25.0866 0.0441895V3.59171H21.9041V0.0441895H25.0866Z"
fill="#000091"
/>
</g>
<defs>
<clipPath id="clip0_5_830">
<rect width="36" height="42" fill="white" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,32 @@
.burgerIcon {
cursor: pointer;
transform: translate(-20%, 0%);
}
.burgerIcon path {
stroke-width: 40;
stroke-linecap: round;
fill: none;
transition: all 0.5s ease-in-out;
}
/* In menu form */
.burgerIcon path:first-child,
.burgerIcon path:last-child {
stroke-dasharray: 240px 910px;
}
.burgerIcon .middle_bar {
stroke-dasharray: 240px 240px;
}
/* In cross form */
.open path:first-child,
.open path:last-child {
stroke-dashoffset: -650px;
}
.open :nth-child(2) {
stroke-dasharray: 0 220px;
stroke-dashoffset: -120px;
}
@@ -0,0 +1,18 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { Burger } from './Burger';
describe('<Burger />', () => {
test('Burger interactions', () => {
const { rerender } = render(<Burger isOpen={true} />);
const burger = screen.getByRole('img');
expect(burger).toBeInTheDocument();
expect(burger.classList.contains('open')).toBeTruthy();
rerender(<Burger isOpen={false} />);
expect(burger.classList.contains('open')).not.toBeTruthy();
});
});
@@ -0,0 +1,31 @@
import { SVGProps } from 'react';
import { Box } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import styles from './Burger.module.css';
import BurgerIcon from './burger.svg';
type BurgerProps = SVGProps<SVGSVGElement> & {
isOpen: boolean;
};
export const Burger = ({ isOpen, ...props }: BurgerProps) => {
const { colorsTokens } = useCunninghamTheme();
return (
<Box
$color={colorsTokens()['primary-text']}
$padding="none"
$justify="center"
>
<BurgerIcon
role="img"
className={`${styles.burgerIcon} ${isOpen ? styles.open : ''}`}
{...props}
/>
</Box>
);
};
export default Burger;
@@ -0,0 +1,10 @@
<svg viewBox="280 230 400 200" stroke="currentColor">
<path
d="M300,220 C300,220 520,220 540,220 C740,220 640,540 520,420 C440,340 300,200 300,200"
/>
<path d="M300,320 L540,320" />
<path
d="M300,210 C300,210 520,210 540,210 C740,210 640,530 520,410 C440,330 300,190 300,190"
transform="translate(480, 320) scale(1, -1) translate(-480, -318)"
/>
</svg>

After

Width:  |  Height:  |  Size: 375 B

@@ -0,0 +1,46 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, DropButton } from '@/components';
import { ButtonLogin } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import { LanguagePicker } from '@/features/language';
import { Burger } from './Burger/Burger';
export const DropdownMenu = () => {
const { colorsTokens } = useCunninghamTheme();
const [isDropOpen, setIsDropOpen] = useState(false);
const { t } = useTranslation();
return (
<DropButton
button={
<Burger
isOpen={isDropOpen}
width={30}
height={30}
aria-controls="menu"
aria-label={t('Open the header menu')}
/>
}
onOpenChange={(isOpen) => setIsDropOpen(isOpen)}
isOpen={isDropOpen}
>
<Box $align="center" $direction="column">
<Box
$width="100%"
$align="center"
$height="36px"
$justify="center"
$css={`&:hover{background:${colorsTokens()['primary-150']}}`}
$hasTransition
$radius="2px"
>
<LanguagePicker />
</Box>
<ButtonLogin />
</Box>
</DropButton>
);
};
@@ -1,106 +1,90 @@
import { Button } from '@openfun/cunningham-react';
import Image from 'next/image';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { Box, Icon, StyledLink, Text } from '@/components/';
import { Box, StyledLink, Text } from '@/components/';
import { ButtonLogin } from '@/core/auth';
import { useCunninghamTheme } from '@/cunningham';
import { LanguagePicker } from '@/features/language';
import { useLeftPanelStore } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
import { default as IconDocs } from '../assets/icon-docs.svg?url';
import { HEADER_HEIGHT } from '../conf';
import { DropdownMenu } from './DropdownMenu';
import { LaGaufre } from './LaGaufre';
export const Header = () => {
const { t } = useTranslation();
const theme = useCunninghamTheme();
const { isPanelOpen, togglePanel } = useLeftPanelStore();
const { isDesktop } = useResponsiveStore();
const spacings = theme.spacingsTokens();
const colors = theme.colorsTokens();
const { isSmallMobile } = useResponsiveStore();
return (
<Box
as="header"
$css={css`
display: flex;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
flex-direction: row;
align-items: center;
justify-content: space-between;
height: ${HEADER_HEIGHT}px;
min-height: ${HEADER_HEIGHT}px;
padding: 0 ${spacings['base']};
background-color: ${colors['greyscale-000']};
border-bottom: 1px solid ${colors['greyscale-200']};
`}
$justify="center"
$width="100%"
$zIndex="100"
$padding={{ vertical: 'xtiny' }}
$css="box-shadow: 0 1px 4px #00000040;"
>
{!isDesktop && (
<Button
size="medium"
onClick={togglePanel}
aria-label={t('Open the header menu')}
color="primary-text"
icon={<Icon iconName={isPanelOpen ? 'close' : 'menu'} />}
/>
)}
<StyledLink href="/">
<Box
$align="center"
$gap={spacings['3xs']}
$direction="row"
$position="relative"
$height="fit-content"
$margin={{ top: 'auto' }}
>
<Image priority src={IconDocs} alt={t('Docs Logo')} width={25} />
<Text
$margin="none"
as="h2"
$color="#000091"
$zIndex={1}
$size="1.30rem"
>
{t('Docs')}
</Text>
<Text
$padding="2px 3px"
$size="8px"
$background="#368bd6"
$color="white"
$position="absolute"
$radius="5px"
$css={`
<Box
$margin={{
left: 'big',
right: isSmallMobile ? 'none' : 'big',
}}
$align="center"
$justify="space-between"
$direction="row"
>
<Box>
<StyledLink href="/">
<Box
$align="center"
$gap="0.8rem"
$direction="row"
$position="relative"
$height="fit-content"
$margin={{ top: 'auto' }}
>
<Image priority src={IconDocs} alt={t('Docs Logo')} width={25} />
<Text
$padding="2px 3px"
$size="8px"
$background="#368bd6"
$color="white"
$position="absolute"
$radius="5px"
$css={`
bottom: 13px;
right: -17px;
`}
>
BETA
</Text>
>
BETA
</Text>
<Text
$margin="none"
as="h2"
$color="#000091"
$zIndex={1}
$size="1.30rem"
$css="font-family: 'Marianne'"
>
{t('Docs')}
</Text>
</Box>
</StyledLink>
</Box>
</StyledLink>
{!isDesktop ? (
<Box $direction="row" $gap={spacings['sm']}>
<LaGaufre />
</Box>
) : (
<Box $align="center" $gap={spacings['sm']} $direction="row">
<ButtonLogin />
<LanguagePicker />
<LaGaufre />
</Box>
)}
{isSmallMobile ? (
<Box $direction="row" $gap="2rem">
<LaGaufre />
<DropdownMenu />
</Box>
) : (
<Box $align="center" $gap="2vw" $direction="row">
<ButtonLogin />
<LanguagePicker />
<LaGaufre />
</Box>
)}
</Box>
</Box>
);
};
@@ -1 +0,0 @@
export const HEADER_HEIGHT = 52;
@@ -1,5 +1,4 @@
import { Select } from '@openfun/cunningham-react';
import { Settings } from 'luxon';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
@@ -35,7 +34,6 @@ const SelectStyled = styled(Select)<{ $isSmall?: boolean }>`
export const LanguagePicker = () => {
const { t, i18n } = useTranslation();
const { preload: languages } = i18n.options;
Settings.defaultLocale = i18n.language;
const optionsPicker = useMemo(() => {
return (languages || []).map((lang) => ({
@@ -1,81 +0,0 @@
import { PropsWithChildren } from 'react';
import { createGlobalStyle, css } from 'styled-components';
import { Box, SeparatedSection } from '@/components';
import { ButtonLogin } from '@/core';
import { useCunninghamTheme } from '@/cunningham';
import { HEADER_HEIGHT } from '@/features/header/conf';
import { LanguagePicker } from '@/features/language';
import { useResponsiveStore } from '@/stores';
import { useLeftPanelStore } from '../stores';
import { LeftPanelHeader } from './LeftPanelHeader';
const MobileLeftPanelStyle = createGlobalStyle`
body {
overflow: hidden;
}
`;
export const LeftPanel = ({ children }: PropsWithChildren) => {
const { isDesktop } = useResponsiveStore();
const { isPanelOpen } = useLeftPanelStore();
const theme = useCunninghamTheme();
const colors = theme.colorsTokens();
const spacings = theme.spacingsTokens();
return (
<>
{isDesktop && (
<Box
data-testid="left-panel-desktop"
$css={`
height: calc(100vh - ${HEADER_HEIGHT}px);
width: 300px;
min-width: 300px;
border-right: 1px solid ${colors['greyscale-200']};
`}
>
<LeftPanelHeader>{children}</LeftPanelHeader>
</Box>
)}
{!isDesktop && (
<>
{isPanelOpen && <MobileLeftPanelStyle />}
<Box
$hasTransition
$css={css`
z-index: 999;
width: 100dvw;
height: calc(100dvh - 52px);
border-right: 1px solid var(--c--theme--colors--greyscale-200);
position: fixed;
transform: translateX(${isPanelOpen ? '0' : '-100dvw'});
background-color: var(--c--theme--colors--greyscale-000);
`}
>
<Box
data-testid="left-panel-mobile"
$css={css`
width: 100%;
justify-content: center;
align-items: center;
gap: ${spacings['base']};
`}
>
<LeftPanelHeader>{children}</LeftPanelHeader>
<SeparatedSection showSeparator={false}>
<Box $justify="center" $align="center" $gap={spacings['sm']}>
<ButtonLogin />
<LanguagePicker />
</Box>
</SeparatedSection>
</Box>
</Box>
</>
)}
</>
);
};

Some files were not shown because too many files have changed in this diff Show More