Compare commits

...

10 Commits

Author SHA1 Message Date
Zorin95670 29ea6b8ef7 (frontend) Improve test coverage
Improve the test coverage of the "api" modules.

Signed-off-by: Zorin95670 <moittie.vincent@gmail.com>
2025-05-12 14:07:08 +02:00
Zorin95670 a692fa6f39 📝(frontend) Update documentation
Improve and add jsdoc.

Signed-off-by: Zorin95670 <moittie.vincent@gmail.com>
2025-05-12 14:07:08 +02:00
Zorin95670 4d541c5d52 🎨(frontend) Minor refactoring
- improve condition statements
- add "no-var" rule in eslint
- remove some unnecessary variables

Signed-off-by: Zorin95670 <moittie.vincent@gmail.com>
2025-05-12 14:07:08 +02:00
Anthony LC e5f029ad1d 🚩(frontend) version MIT only
We have some packages that are not MIT compatible,
so if the env var MIT_ONLY is set to true,
we don't build the application with features
that are not MIT compatible.
For the moment, it concerns only the export packages.
2025-05-12 12:00:59 +02:00
ZouicheOmar bd79f84e07 (frontend) adapt export to callout block
Adapt modal export to include PDF and Docx export
for the callout block.
2025-05-12 09:30:17 +02:00
ZouicheOmar a070f56339 (frontend) add custom callout block to editor
Add a custom block to the editor, the callout block.
2025-05-12 09:30:17 +02:00
ZouicheOmar 02478acb3f (frontend) add emoji picker component
Add a custom emoji picker component to use in the editor
2025-05-12 09:30:17 +02:00
ZouicheOmar 23aa497db0 (frontend) add emoji-mart packages
We need functionalities and data to implement a custom emoji picker
component, as blocknote's emojipicker component triggers and uses cases
are limited.
add to package.json the following packages:
- "emoji-mart": provides functions and components for
displaying, searching and selecting emojis.
- @emoji-mart-data: offers pre-configured sets of emojis.
- @emoji-mart/react: React Picker component
2025-05-12 09:29:04 +02:00
virgile-dev d48436bffb 📝(doc) complete contributing policy (#895)
We made mandatory signing commits.
Provided warnings for common gitmoji errors

Signed-off-by: virgile-deville <virgile.deville@beta.gouv.fr>
2025-05-09 20:40:44 +00:00
renovate[bot] 41e4c45934 ⬆️(dependencies) update django to v5.1.9 [SECURITY] (#953) 2025-05-09 16:26:57 +02:00
55 changed files with 1176 additions and 245 deletions
+3 -1
View File
@@ -79,7 +79,9 @@ jobs:
context: .
file: ./src/frontend/Dockerfile
target: frontend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
build-args: |
DOCKER_USER=${{ env.DOCKER_USER }}:-1000
PUBLISH_AS_MIT=false
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+13
View File
@@ -8,11 +8,23 @@ and this project adheres to
## [Unreleased]
## Added
- ✨ Add a custom callout block to the editor #892
- 🚩(frontend) version MIT only #911
## Changed
- 📝(frontend) Update documentation
- ✅(frontend) Improve tests coverage
## [3.2.1] - 2025-05-06
## Fixed
- 🐛(frontend) fix list copy paste #943
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
## [3.2.0] - 2025-05-05
@@ -20,6 +32,7 @@ and this project adheres to
- 🚸(backend) make document search on title accent-insensitive #874
- 🚩 add homepage feature flag #861
- 📝(doc) update contributing policy (commit signatures are now mandatory) #895
- ✨(settings) Allow configuring PKCE for the SSO #886
- 🌐(i18n) activate chinese and spanish languages #884
- 🔧(backend) allow overwriting the data directory #893
+21 -7
View File
@@ -2,16 +2,16 @@
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions.
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/docs/blob/main/README.md) for detailed instructions on how to run Docs locally.
Contributors are required to sign off their commits with `git commit --sign-off`: this confirms that they have read and accepted the [Developer's Certificate of Origin 1.1](https://developercertificate.org/).
Contributors are required to sign off their commits with `git commit --signoff`: this confirms that they have read and accepted the [Developer's Certificate of Origin 1.1](https://developercertificate.org/). For security reasons we also require [signing your commits with your SSH or GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification) with `git commit -S`.
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
## Help us with translations
You can help us with translations on [Crowdin](https://crowdin.com/project/lasuite-docs).
Your language is not there? Request it on our Crowdin page 😊.
Your language is not there? Request it on our Crowdin page 😊 or ping us on [Matrix](https://matrix.to/#/#docs-official:matrix.org) and let us know if you can help with translations and/or proofreading.
## Creating an Issue
@@ -35,10 +35,14 @@ All commit messages must adhere to the following format:
`<gitmoji>(type) title description`
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list [here](https://gitmoji.dev/).
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
* **title**: A short, descriptive title for the change.
* **description**: Include additional details about what was changed and why.
* **title**: A short, descriptive title for the change (*)
* **blank line after the commit title
* **description**: Include additional details on why you made the changes (**).
(*) ⚠️ **Make sure you add no space between the emoji and the (type) but add a space after the closing parenthesis of the type and use no caps!**
(**) ⚠️ **Commit description message is mandatory and shouldn't be too long**
### Example Commit Message
@@ -66,7 +70,9 @@ Please add a line to the changelog describing your development. The changelog en
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
### Don't forget to:
- check your commits
- signoff your commits
- sign your commits with your key (SSH, GPG etc.)
- check your commits (see warnings above)
- check the linting: `make lint && make frontend-lint`
- check the tests: `make test`
- add a changelog entry
@@ -86,3 +92,11 @@ Make sure that all new features or fixes have corresponding tests. Run the test
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
Thank you for your contributions! 👍
## Contribute to BlockNote
We use [BlockNote](https://www.blocknotejs.org/) for the text editing features of Docs.
If you find and issue with the editor you can [report it](https://github.com/TypeCellOS/BlockNote/issues) directly on their repository.
Please consider contributing to BlockNotejs, as a library, it's useful to many projects not just Docs.
The project is licended with Mozilla Public License Version 2.0 but be aware that [XL packages](https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-docx-exporter/LICENSE) are dual licenced with GNU AFFERO GENERAL PUBLIC LICENCE Version 3 and proprietary licence if you are [sponsor](https://www.blocknotejs.org/pricing).
+1 -2
View File
@@ -155,8 +155,7 @@ services:
target: frontend-production
args:
API_ORIGIN: "http://localhost:8071"
Y_PROVIDER_URL: "ws://localhost:4444"
MEDIA_URL: "http://localhost:8083"
PUBLISH_AS_MIT: "false"
SW_DEACTIVATED: "true"
image: impress:frontend-development
ports:
+1 -1
View File
@@ -38,7 +38,7 @@ dependencies = [
"django-redis==5.4.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django==5.1.8",
"django==5.1.9",
"django-treebeard==4.7.1",
"djangorestframework==3.16.0",
"drf_spectacular==0.28.0",
+3
View File
@@ -39,6 +39,9 @@ ENV NEXT_PUBLIC_API_ORIGIN=${API_ORIGIN}
ARG SW_DEACTIVATED
ENV NEXT_PUBLIC_SW_DEACTIVATED=${SW_DEACTIVATED}
ARG PUBLISH_AS_MIT
ENV NEXT_PUBLIC_PUBLISH_AS_MIT=${PUBLISH_AS_MIT}
RUN yarn build
# ---- Front-end image ----
@@ -452,4 +452,41 @@ test.describe('Doc Editor', () => {
const svgBuffer = await cs.toBuffer(await download.createReadStream());
expect(svgBuffer.toString()).toContain('Hello svg');
});
test('it checks if callout custom block', async ({ page, browserName }) => {
await createDoc(page, 'doc-toolbar', browserName, 1);
const editor = page.locator('.ProseMirror');
await editor.click();
await page.locator('.bn-block-outer').last().fill('/');
await page.getByText('Add a callout block').click();
const calloutBlock = page
.locator('div[data-content-type="callout"]')
.first();
await expect(calloutBlock).toBeVisible();
await calloutBlock.locator('.inline-content').fill('example text');
await expect(page.locator('.bn-block').first()).toHaveAttribute(
'data-background-color',
'yellow',
);
const emojiButton = calloutBlock.getByRole('button');
await expect(emojiButton).toHaveText('💡');
await emojiButton.click();
await page.locator('button[aria-label="⚠️"]').click();
await expect(emojiButton).toHaveText('⚠️');
await page.locator('.bn-side-menu > button').last().click();
await page.locator('.mantine-Menu-dropdown > button').last().click();
await page.locator('.bn-color-picker-dropdown > button').last().click();
await expect(page.locator('.bn-block').first()).toHaveAttribute(
'data-background-color',
'pink',
);
});
});
+1
View File
@@ -1,2 +1,3 @@
NEXT_PUBLIC_API_ORIGIN=
NEXT_PUBLIC_SW_DEACTIVATED=
NEXT_PUBLIC_PUBLISH_AS_MIT=true
@@ -1,2 +1,3 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_PUBLISH_AS_MIT=false
NEXT_PUBLIC_SW_DEACTIVATED=true
+1 -4
View File
@@ -48,10 +48,7 @@ const nextConfig = {
swDest: '../public/service-worker.js',
include: [
({ asset }) => {
if (asset.name.match(/.*(static).*/)) {
return true;
}
return false;
return !!asset.name.match(/.*(static).*/);
},
],
}),
+3
View File
@@ -22,6 +22,8 @@
"@blocknote/react": "0.29.1",
"@blocknote/xl-docx-exporter": "0.29.1",
"@blocknote/xl-pdf-exporter": "0.29.1",
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@fontsource/material-icons": "5.2.5",
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "0.4.1",
@@ -35,6 +37,7 @@
"cmdk": "1.1.1",
"crisp-sdk-web": "1.0.25",
"docx": "9.4.1",
"emoji-mart": "5.6.0",
"i18next": "25.1.1",
"i18next-browser-languagedetector": "8.1.0",
"idb": "8.0.2",
+29 -1
View File
@@ -1,17 +1,39 @@
/**
* Generic interface for representing an API error structure.
*
* @template T - Optional type of additional data returned with the error.
*/
interface IAPIError<T = unknown> {
/** HTTP status code or API-defined error code */
status: number;
/** Optional list of error causes (e.g., validation issues) */
cause?: string[];
/** Optional extra data provided with the error */
data?: T;
}
/**
* Custom error class for representing API errors.
* Extends the native Error object with additional context such as HTTP status,
* causes, and extra data returned by the API.
*
* @template T - Optional type of the `data` field
*/
export class APIError<T = unknown> extends Error implements IAPIError<T> {
public status: IAPIError['status'];
public cause?: IAPIError['cause'];
public data?: IAPIError<T>['data'];
/**
* Constructs a new APIError instance.
*
* @param message - The human-readable error message.
* @param status - The HTTP status code or equivalent.
* @param cause - (Optional) List of strings describing error causes.
* @param data - (Optional) Any additional data returned by the API.
*/
constructor(message: string, { status, cause, data }: IAPIError<T>) {
super(message);
this.name = 'APIError';
this.status = status;
this.cause = cause;
@@ -19,6 +41,12 @@ export class APIError<T = unknown> extends Error implements IAPIError<T> {
}
}
/**
* Type guard for checking if a value is an instance of APIError.
*
* @param error - The value to check.
* @returns True if the value is an instance of APIError.
*/
export const isAPIError = (error: unknown): error is APIError => {
return error instanceof APIError;
};
@@ -0,0 +1,36 @@
import { APIError, isAPIError } from '@/api';
describe('APIError', () => {
it('should correctly instantiate with required fields', () => {
const error = new APIError('Something went wrong', { status: 500 });
expect(error).toBeInstanceOf(Error);
expect(error).toBeInstanceOf(APIError);
expect(error.message).toBe('Something went wrong');
expect(error.status).toBe(500);
expect(error.cause).toBeUndefined();
expect(error.data).toBeUndefined();
});
it('should correctly instantiate with all fields', () => {
const details = { field: 'email' };
const error = new APIError('Validation failed', {
status: 400,
cause: ['Invalid email format'],
data: details,
});
expect(error.name).toBe('APIError');
expect(error.status).toBe(400);
expect(error.cause).toEqual(['Invalid email format']);
expect(error.data).toEqual(details);
});
it('should be detected by isAPIError type guard', () => {
const error = new APIError('Unauthorized', { status: 401 });
const notAnError = { message: 'Fake error' };
expect(isAPIError(error)).toBe(true);
expect(isAPIError(notAnError)).toBe(false);
});
});
@@ -0,0 +1,16 @@
import { baseApiUrl } from '@/api';
describe('config', () => {
it('constructs URL with default version', () => {
expect(baseApiUrl()).toBe('http://test.jest/api/v1.0/');
});
it('constructs URL with custom version', () => {
expect(baseApiUrl('2.0')).toBe('http://test.jest/api/v2.0/');
});
it('uses env origin if available', () => {
process.env.NEXT_PUBLIC_API_ORIGIN = 'https://env.example.com';
expect(baseApiUrl('3.0')).toBe('https://env.example.com/api/v3.0/');
});
});
@@ -36,4 +36,13 @@ describe('fetchAPI', () => {
expect(fetchMock.lastUrl()).toEqual('http://test.jest/api/v2.0/some/url');
});
it('removes Content-Type header when withoutContentType is true', async () => {
fetchMock.mock('http://test.jest/api/v1.0/some/url', 200);
await fetchAPI('some/url', { withoutContentType: true });
const options = fetchMock.lastOptions();
expect(options?.headers).not.toHaveProperty('Content-Type');
});
});
@@ -0,0 +1,59 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { useAPIInfiniteQuery } from '@/api';
interface DummyItem {
id: number;
}
interface DummyResponse {
results: DummyItem[];
next?: string;
}
const createWrapper = () => {
const queryClient = new QueryClient();
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
describe('helpers', () => {
it('fetches and paginates correctly', async () => {
const mockAPI = jest
.fn<Promise<DummyResponse>, [{ page: number; query: string }]>()
.mockResolvedValueOnce({
results: [{ id: 1 }],
next: 'url?page=2',
})
.mockResolvedValueOnce({
results: [{ id: 2 }],
next: undefined,
});
const { result } = renderHook(
() => useAPIInfiniteQuery('test-key', mockAPI, { query: 'test' }),
{ wrapper: createWrapper() },
);
// Wait for first page
await waitFor(() => {
expect(result.current.data?.pages[0].results[0].id).toBe(1);
});
// Fetch next page
await result.current.fetchNextPage();
await waitFor(() => {
expect(result.current.data?.pages.length).toBe(2);
});
await waitFor(() => {
expect(result.current.data?.pages[1].results[0].id).toBe(2);
});
expect(mockAPI).toHaveBeenCalledWith({ query: 'test', page: 1 });
expect(mockAPI).toHaveBeenCalledWith({ query: 'test', page: 2 });
});
});
@@ -0,0 +1,57 @@
import { errorCauses, getCSRFToken } from '@/api';
describe('utils', () => {
describe('errorCauses', () => {
const createMockResponse = (jsonData: any, status = 400): Response => {
return {
status,
json: () => jsonData,
} as unknown as Response;
};
it('parses multiple string causes from error body', async () => {
const mockResponse = createMockResponse(
{
field: ['error message 1', 'error message 2'],
},
400,
);
const result = await errorCauses(mockResponse, { context: 'login' });
expect(result.status).toBe(400);
expect(result.cause).toEqual(['error message 1', 'error message 2']);
expect(result.data).toEqual({ context: 'login' });
});
it('returns undefined causes if no JSON body', async () => {
const mockResponse = createMockResponse(null, 500);
const result = await errorCauses(mockResponse);
expect(result.status).toBe(500);
expect(result.cause).toBeUndefined();
expect(result.data).toBeUndefined();
});
});
describe('getCSRFToken', () => {
it('extracts csrftoken from document.cookie', () => {
Object.defineProperty(document, 'cookie', {
writable: true,
value: 'sessionid=xyz; csrftoken=abc123; theme=dark',
});
expect(getCSRFToken()).toBe('abc123');
});
it('returns undefined if csrftoken is not present', () => {
Object.defineProperty(document, 'cookie', {
writable: true,
value: 'sessionid=xyz; theme=dark',
});
expect(getCSRFToken()).toBeUndefined();
});
});
});
@@ -1,6 +1,22 @@
/**
* Returns the base URL for the backend API.
*
* Priority:
* 1. Uses NEXT_PUBLIC_API_ORIGIN from environment variables if defined.
* 2. Falls back to the browser's window.location.origin if in a browser environment.
* 3. Defaults to an empty string if executed in a non-browser environment without the env variable.
*
* @returns The backend base URL as a string.
*/
export const backendUrl = () =>
process.env.NEXT_PUBLIC_API_ORIGIN ||
(typeof window !== 'undefined' ? window.location.origin : '');
/**
* Constructs the full base API URL, including the versioned path (e.g., `/api/v1.0/`).
*
* @param apiVersion - The version of the API (defaults to '1.0').
* @returns The full versioned API base URL as a string.
*/
export const baseApiUrl = (apiVersion: string = '1.0') =>
`${backendUrl()}/api/v${apiVersion}/`;
@@ -23,11 +23,9 @@ export const fetchAPI = async (
delete headers?.['Content-Type' as keyof typeof headers];
}
const response = await fetch(apiUrl, {
return await fetch(apiUrl, {
...init,
credentials: 'include',
headers,
});
return response;
};
+11 -3
View File
@@ -22,9 +22,17 @@ export type DefinedInitialDataInfiniteOptionsAPI<
>;
/**
* @param param Used for infinite scroll pagination
* @param queryConfig
* @returns
* Custom React hook that wraps React Query's `useInfiniteQuery` for paginated API requests.
*
* @template T - Type of the request parameters.
* @template Q - Type of the API response, which must include an optional `next` field for pagination.
*
* @param {string} key - Unique key to identify the query in the cache.
* @param {(props: T & { page: number }) => Promise<Q>} api - Function that fetches paginated data from the API. It receives the params merged with a page number.
* @param {T} param - Static parameters to send with every API request (excluding the page number).
* @param {DefinedInitialDataInfiniteOptionsAPI<Q>} [queryConfig] - Optional configuration passed to `useInfiniteQuery` (e.g., stale time, cache time).
*
* @returns Return value of `useInfiniteQuery`, including data, loading state, fetchNextPage, etc.
*/
export const useAPIInfiniteQuery = <T, Q extends { next?: APIList<Q>['next'] }>(
key: string,
@@ -1,6 +1,20 @@
/**
* Generic interface representing a paginated API response.
*
* Commonly used for endpoints that return list results with pagination metadata.
*
* @template T - The type of items in the `results` array.
*/
export interface APIList<T> {
/** Total number of items across all pages */
count: number;
/** URL to the next page of results, if available (can be null or undefined) */
next?: string | null;
/** URL to the previous page of results, if available (can be null or undefined) */
previous?: string | null;
/** The list of items for the current page */
results: T[];
}
+18 -1
View File
@@ -1,3 +1,16 @@
/**
* Extracts error information from an HTTP `Response` object.
*
* This is typically used to parse structured error responses from an API
* and normalize them into a consistent format with `status`, `cause`, and optional `data`.
*
* @param response - The HTTP response object from `fetch()`.
* @param data - Optional custom data to include with the error output.
* @returns An object containing:
* - `status`: HTTP status code from the response
* - `cause`: A flattened list of error messages, or undefined if no body
* - `data`: The optional data passed in
*/
export const errorCauses = async (response: Response, data?: unknown) => {
const errorsBody = (await response.json()) as Record<
string,
@@ -18,7 +31,11 @@ export const errorCauses = async (response: Response, data?: unknown) => {
};
/**
* Retrieves the CSRF token from the document's cookies.
* Retrieves the CSRF token from the browser's cookies.
*
* Assumes the CSRF token is stored as a cookie named "csrftoken".
*
* @returns The CSRF token string if found, otherwise `undefined`.
*/
export function getCSRFToken() {
return document.cookie
@@ -35,8 +35,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
);
useEffect(() => {
const cleanupResizeListener = initializeResizeListener();
return cleanupResizeListener;
return initializeResizeListener();
}, [initializeResizeListener]);
useEffect(() => {
@@ -53,13 +53,11 @@ export function useConfig() {
const cachedData = getCachedTranslation();
const oneHour = 1000 * 60 * 60;
const response = useQuery<ConfigResponse, APIError, ConfigResponse>({
return useQuery<ConfigResponse, APIError, ConfigResponse>({
queryKey: [KEY_CONFIG],
queryFn: () => getConfig(),
initialData: cachedData,
staleTime: oneHour,
initialDataUpdatedAt: Date.now() - oneHour, // Force initial data to be considered stale
});
return response;
}
+1
View File
@@ -20,6 +20,7 @@ declare module '*.svg?url' {
namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_API_ORIGIN?: string;
NEXT_PUBLIC_PUBLISH_AS_MIT?: string;
NEXT_PUBLIC_SW_DEACTIVATED?: string;
}
}
@@ -27,12 +27,13 @@ import { randomColor } from '../utils';
import { BlockNoteSuggestionMenu } from './BlockNoteSuggestionMenu';
import { BlockNoteToolbar } from './BlockNoteToolBar/BlockNoteToolbar';
import { DividerBlock } from './custom-blocks';
import { CalloutBlock, DividerBlock } from './custom-blocks';
export const blockNoteSchema = withPageBreak(
BlockNoteSchema.create({
blockSpecs: {
...defaultBlockSpecs,
callout: CalloutBlock,
divider: DividerBlock,
},
}),
@@ -11,7 +11,10 @@ import { useTranslation } from 'react-i18next';
import { DocsBlockSchema } from '../types';
import { getDividerReactSlashMenuItems } from './custom-blocks';
import {
getCalloutReactSlashMenuItems,
getDividerReactSlashMenuItems,
} from './custom-blocks';
export const BlockNoteSuggestionMenu = () => {
const editor = useBlockNoteEditor<DocsBlockSchema>();
@@ -25,6 +28,7 @@ export const BlockNoteSuggestionMenu = () => {
combineByGroup(
getDefaultReactSlashMenuItems(editor),
getPageBreakReactSlashMenuItems(editor),
getCalloutReactSlashMenuItems(editor, t, basicBlocksName),
getDividerReactSlashMenuItems(editor, t, basicBlocksName),
),
query,
@@ -6,9 +6,12 @@ import {
useDictionary,
} from '@blocknote/react';
import React, { JSX, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useConfig } from '@/core/config/api';
import { getCalloutFormattingToolbarItems } from '../custom-blocks';
import { AIGroupButton } from './AIButton';
import { FileDownloadButton } from './FileDownloadButton';
import { MarkdownButton } from './MarkdownButton';
@@ -18,11 +21,13 @@ export const BlockNoteToolbar = () => {
const dict = useDictionary();
const [confirmOpen, setIsConfirmOpen] = useState(false);
const [onConfirm, setOnConfirm] = useState<() => void | Promise<void>>();
const { t } = useTranslation();
const { data: conf } = useConfig();
const toolbarItems = useMemo(() => {
const toolbarItems = getFormattingToolbarItems([
...blockTypeSelectItems(dict),
getCalloutFormattingToolbarItems(t),
]);
const fileDownloadButtonIndex = toolbarItems.findIndex(
(item) =>
@@ -46,7 +51,7 @@ export const BlockNoteToolbar = () => {
}
return toolbarItems as JSX.Element[];
}, [dict]);
}, [dict, t]);
const formattingToolbar = useCallback(() => {
return (
@@ -0,0 +1,43 @@
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box } from '@/components';
interface EmojiPickerProps {
categories: string[];
custom: {
name: string;
id: string;
emojis: string[];
}[];
onClickOutside: () => void;
onEmojiSelect: ({ native }: { native: string }) => void;
}
export const EmojiPicker = ({
categories,
custom,
onClickOutside,
onEmojiSelect,
}: EmojiPickerProps) => {
const { i18n } = useTranslation();
return (
<Box $position="absolute" $zIndex={1000} $margin="2rem 0 0 0">
<Picker
categories={categories}
custom={custom}
data={data}
locale={i18n.resolvedLanguage}
navPosition="none"
onClickOutside={onClickOutside}
onEmojiSelect={onEmojiSelect}
previewPosition="none"
skinTonePosition="none"
theme="light"
/>
</Box>
);
};
@@ -0,0 +1,166 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { defaultProps, insertOrUpdateBlock } from '@blocknote/core';
import { BlockTypeSelectItem, createReactBlockSpec } from '@blocknote/react';
import { TFunction } from 'i18next';
import React, { useEffect, useState } from 'react';
import { css } from 'styled-components';
import { Box, BoxButton, Icon } from '@/components';
import { DocsBlockNoteEditor } from '../../types';
import { EmojiPicker } from '../EmojiPicker';
const calloutCustom = [
{
name: 'Callout',
id: 'callout',
emojis: [
'bulb',
'point_right',
'point_up',
'ok_hand',
'key',
'construction',
'warning',
'fire',
'pushpin',
'scissors',
'question',
'no_entry',
'no_entry_sign',
'alarm_clock',
'phone',
'rotating_light',
'recycle',
'white_check_mark',
'lock',
'paperclip',
'book',
'speaking_head_in_silhouette',
'arrow_right',
'loudspeaker',
'hammer_and_wrench',
'gear',
],
},
];
const calloutCategories = [
'callout',
'people',
'nature',
'foods',
'activity',
'places',
'flags',
'objects',
'symbols',
];
export const CalloutBlock = createReactBlockSpec(
{
type: 'callout',
propSchema: {
textAlignment: defaultProps.textAlignment,
backgroundColor: defaultProps.backgroundColor,
emoji: { default: '💡' },
},
content: 'inline',
},
{
render: ({ block, editor, contentRef }) => {
const [openEmojiPicker, setOpenEmojiPicker] = useState(false);
const toggleEmojiPicker = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setOpenEmojiPicker(!openEmojiPicker);
};
const onClickOutside = () => setOpenEmojiPicker(false);
const onEmojiSelect = ({ native }: { native: string }) => {
editor.updateBlock(block, { props: { emoji: native } });
setOpenEmojiPicker(false);
};
// Temporary: sets a yellow background color to a callout block when added by
// the user, while keeping the colors menu on the drag handler usable for
// this custom block.
useEffect(() => {
if (
!block.content.length &&
block.props.backgroundColor === 'default'
) {
editor.updateBlock(block, { props: { backgroundColor: 'yellow' } });
}
}, [block, editor]);
return (
<Box
$padding="1rem"
$gap="0.625rem"
style={{
flexGrow: 1,
flexDirection: 'row',
}}
>
<BoxButton
contentEditable={false}
onClick={toggleEmojiPicker}
$css={css`
font-size: 1.125rem;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
`}
$align="center"
$height="28px"
$width="28px"
$radius="4px"
>
{block.props.emoji}
</BoxButton>
{openEmojiPicker && (
<EmojiPicker
categories={calloutCategories}
custom={calloutCustom}
onClickOutside={onClickOutside}
onEmojiSelect={onEmojiSelect}
/>
)}
<Box as="p" className="inline-content" ref={contentRef} />
</Box>
);
},
},
);
export const getCalloutReactSlashMenuItems = (
editor: DocsBlockNoteEditor,
t: TFunction<'translation', undefined>,
group: string,
) => [
{
title: t('Callout'),
onItemClick: () => {
insertOrUpdateBlock(editor, {
type: 'callout',
});
},
aliases: ['callout', 'encadré', 'hervorhebung', 'benadrukken'],
group,
icon: <Icon iconName="lightbulb" $size="18px" />,
subtext: t('Add a callout block'),
},
];
export const getCalloutFormattingToolbarItems = (
t: TFunction<'translation', undefined>,
): BlockTypeSelectItem => ({
name: t('Callout'),
type: 'callout',
icon: () => <Icon iconName="lightbulb" $size="16px" />,
isSelected: (block) => block.type === 'callout',
});
@@ -1 +1,2 @@
export * from './CalloutBlock';
export * from './DividerBlock';
@@ -31,7 +31,7 @@ const useSaveDoc = (docId: string, yDoc: Y.Doc, canSave: boolean) => {
_updatedDoc: Y.Doc,
transaction: Y.Transaction,
) => {
setIsLocalChange(transaction.local ? true : false);
setIsLocalChange(transaction.local);
};
yDoc.on('update', onUpdate);
@@ -61,6 +61,23 @@ export const cssEditor = (readonly: boolean) => css`
height: 38px;
}
/**
* Callout, Paragraph and Heading blocks
*/
.bn-block {
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block-outer {
border-radius: var(--c--theme--spacings--3xs);
}
.bn-block-content[data-content-type='paragraph'],
.bn-block-content[data-content-type='heading'] {
padding: var(--c--theme--spacings--3xs) var(--c--theme--spacings--3xs);
border-radius: var(--c--theme--spacings--3xs);
}
h1 {
font-size: 1.875rem;
}
@@ -0,0 +1,24 @@
import { Paragraph, TextRun } from 'docx';
import { DocsExporterDocx } from '../types';
import { docxBlockPropsToStyles } from '../utils';
export const blockMappingCalloutDocx: DocsExporterDocx['mappings']['blockMapping']['callout'] =
(block, exporter) => {
return new Paragraph({
...docxBlockPropsToStyles(block.props, exporter.options.colors),
spacing: { before: 10, after: 10 },
children: [
new TextRun({
text: ' ',
break: 1,
}),
new TextRun(' ' + block.props.emoji + ' '),
...exporter.transformInlineContent(block.content),
new TextRun({
text: ' ',
break: 1,
}),
],
});
};
@@ -0,0 +1,35 @@
import { StyleSheet, Text, View } from '@react-pdf/renderer';
import { DocsExporterPDF } from '../types';
const styles = StyleSheet.create({
wrapper: {
width: '100%',
display: 'flex',
flexDirection: 'row',
padding: 8,
gap: 4,
},
emoji: {
fontSize: 16,
},
text: {
maxWidth: '94%',
paddingTop: 2,
},
});
export const blockMappingCalloutPDF: DocsExporterPDF['mappings']['blockMapping']['callout'] =
(block, exporter) => {
return (
<View wrap={false} style={styles.wrapper}>
<Text debug={false} style={styles.emoji}>
{block.props.emoji}
</Text>
<Text debug={false} style={styles.text}>
{' '}
{exporter.transformInlineContent(block.content)}{' '}
</Text>
</View>
);
};
@@ -1,3 +1,5 @@
export * from './calloutDocx';
export * from './calloutPDF';
export * from './dividerDocx';
export * from './dividerPDF';
export * from './headingPDF';
@@ -1,6 +1,7 @@
import { docxDefaultSchemaMappings } from '@blocknote/xl-docx-exporter';
import {
blockMappingCalloutDocx,
blockMappingDividerDocx,
blockMappingImageDocx,
blockMappingQuoteDocx,
@@ -11,6 +12,7 @@ export const docxDocsSchemaMappings: DocsExporterDocx['mappings'] = {
...docxDefaultSchemaMappings,
blockMapping: {
...docxDefaultSchemaMappings.blockMapping,
callout: blockMappingCalloutDocx,
divider: blockMappingDividerDocx,
quote: blockMappingQuoteDocx,
image: blockMappingImageDocx,
@@ -1,6 +1,7 @@
import { pdfDefaultSchemaMappings } from '@blocknote/xl-pdf-exporter';
import {
blockMappingCalloutPDF,
blockMappingDividerPDF,
blockMappingHeadingPDF,
blockMappingImagePDF,
@@ -14,6 +15,7 @@ export const pdfDocsSchemaMappings: DocsExporterPDF['mappings'] = {
...pdfDefaultSchemaMappings,
blockMapping: {
...pdfDefaultSchemaMappings.blockMapping,
callout: blockMappingCalloutPDF,
heading: blockMappingHeadingPDF,
image: blockMappingImagePDF,
paragraph: blockMappingParagraphPDF,
@@ -19,9 +19,16 @@ export function downloadFile(blob: Blob, filename: string) {
}
/**
* Convert SVG to PNG
* @param svgText - The SVG text to convert
* @returns The PNG data URL
* Converts an SVG string into a PNG image and returns it as a data URL.
*
* This function creates a canvas, parses the SVG, calculates the appropriate height
* to preserve the aspect ratio, and renders the SVG onto the canvas using Canvg.
*
* @param {string} svgText - The raw SVG markup to convert.
* @param {number} width - The desired width of the output PNG (height is auto-calculated to preserve aspect ratio).
* @returns {Promise<string>} A Promise that resolves to a PNG image encoded as a base64 data URL.
*
* @throws Will throw an error if the canvas context cannot be initialized.
*/
export async function convertSvgToPng(svgText: string, width: number) {
// Create a canvas and render the SVG onto it
@@ -42,6 +42,7 @@ const doc = {
beforeEach(() => {
Analytics.clearAnalytics();
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
});
describe('DocToolBox "Copy as HTML" option', () => {
@@ -51,7 +52,9 @@ describe('DocToolBox "Copy as HTML" option', () => {
render(<DocToolBox doc={doc as any} />, {
wrapper: AppWrapper,
});
const optionsButton = screen.getByLabelText('Open the document options');
const optionsButton = await screen.findByLabelText(
'Open the document options',
);
await userEvent.click(optionsButton);
expect(await screen.findByText('Copy as HTML')).toBeInTheDocument();
});
@@ -0,0 +1,28 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { AppWrapper } from '@/tests/utils';
import { DocToolBox } from '../components/DocToolBox';
const doc = {
nb_accesses: 1,
abilities: {
versions_list: true,
destroy: true,
},
};
jest.mock('@/features/docs/doc-export/', () => ({
ModalExport: () => <span>ModalExport</span>,
}));
it('DocToolBox dynamic import: loads DocToolBox when NEXT_PUBLIC_PUBLISH_AS_MIT is false', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'false';
render(<DocToolBox doc={doc as any} />, {
wrapper: AppWrapper,
});
expect(await screen.findByText('download')).toBeInTheDocument();
});
@@ -0,0 +1,35 @@
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { AppWrapper } from '@/tests/utils';
const doc = {
nb_accesses: 1,
abilities: {
versions_list: true,
destroy: true,
},
};
jest.mock('@/features/docs/doc-export/', () => ({
ModalExport: () => <span>ModalExport</span>,
}));
it('DocToolBox dynamic import: loads DocToolBox when NEXT_PUBLIC_PUBLISH_AS_MIT is true', async () => {
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT = 'true';
const { DocToolBox } = await import('../components/DocToolBox');
render(<DocToolBox doc={doc as any} />, {
wrapper: AppWrapper,
});
await waitFor(
() => {
expect(screen.queryByText('download')).not.toBeInTheDocument();
},
{
timeout: 1000,
},
);
});
@@ -1,170 +1,47 @@
import {
Button,
VariantType,
useModal,
useToastProvider,
} from '@openfun/cunningham-react';
import { Button, useModal } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import {
Box,
DropdownMenu,
DropdownMenuOption,
Icon,
IconOptions,
} from '@/components';
import { Box, Icon } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useEditorStore } from '@/docs/doc-editor/';
import { ModalExport } from '@/docs/doc-export/';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
ModalRemoveDoc,
useCopyDocLink,
useCreateFavoriteDoc,
useDeleteFavoriteDoc,
} from '@/docs/doc-management';
import { DocShareModal } from '@/docs/doc-share';
import {
KEY_LIST_DOC_VERSIONS,
ModalSelectVersion,
} from '@/docs/doc-versioning';
import { useAnalytics } from '@/libs';
import { Doc } from '@/docs/doc-management';
import { KEY_LIST_DOC_VERSIONS } from '@/docs/doc-versioning';
import { useResponsiveStore } from '@/stores';
interface DocToolBoxProps {
doc: Doc;
}
const DocToolBoxLicence = dynamic(() =>
process.env.NEXT_PUBLIC_PUBLISH_AS_MIT === 'false'
? import('./DocToolBoxLicenceAGPL').then((mod) => mod.DocToolBoxLicenceAGPL)
: import('./DocToolBoxLicenceMIT').then((mod) => mod.DocToolBoxLicenceMIT),
);
export const DocToolBox = ({ doc }: DocToolBoxProps) => {
const { t } = useTranslation();
const hasAccesses = doc.nb_accesses_direct > 1 && doc.abilities.accesses_view;
const queryClient = useQueryClient();
const { spacingsTokens, colorsTokens } = useCunninghamTheme();
const { spacingsTokens } = useCunninghamTheme();
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isModalExportOpen, setIsModalExportOpen] = useState(false);
const selectHistoryModal = useModal();
const modalHistory = useModal();
const modalShare = useModal();
const { isSmallMobile, isDesktop } = useResponsiveStore();
const { editor } = useEditorStore();
const { toast } = useToastProvider();
const copyDocLink = useCopyDocLink(doc.id);
const { isFeatureFlagActivated } = useAnalytics();
const removeFavoriteDoc = useDeleteFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const makeFavoriteDoc = useCreateFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const options: DropdownMenuOption[] = [
...(isSmallMobile
? [
{
label: t('Share'),
icon: 'group',
callback: modalShare.open,
},
{
label: t('Export'),
icon: 'download',
callback: () => {
setIsModalExportOpen(true);
},
},
{
label: t('Copy link'),
icon: 'add_link',
callback: copyDocLink,
},
]
: []),
{
label: doc.is_favorite ? t('Unpin') : t('Pin'),
icon: 'push_pin',
callback: () => {
if (doc.is_favorite) {
removeFavoriteDoc.mutate({ id: doc.id });
} else {
makeFavoriteDoc.mutate({ id: doc.id });
}
},
testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
},
{
label: t('Version history'),
icon: 'history',
disabled: !doc.abilities.versions_list,
callback: () => {
selectHistoryModal.open();
},
show: isDesktop,
},
{
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');
},
show: isFeatureFlagActivated('CopyAsHTML'),
},
{
label: t('Delete document'),
icon: 'delete',
disabled: !doc.abilities.destroy,
callback: () => {
setIsModalRemoveOpen(true);
},
},
];
const copyCurrentEditorToClipboard = async (
asFormat: 'html' | 'markdown',
) => {
if (!editor) {
toast(t('Editor unavailable'), VariantType.ERROR, { duration: 3000 });
return;
}
try {
const editorContentFormatted =
asFormat === 'html'
? await editor.blocksToHTMLLossy()
: await editor.blocksToMarkdownLossy();
await navigator.clipboard.writeText(editorContentFormatted);
toast(t('Copied to clipboard'), VariantType.SUCCESS, { duration: 3000 });
} catch (error) {
console.error(error);
toast(t('Failed to copy to clipboard'), VariantType.ERROR, {
duration: 3000,
});
}
};
const { isSmallMobile } = useResponsiveStore();
useEffect(() => {
if (selectHistoryModal.isOpen) {
if (modalHistory.isOpen) {
return;
}
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC_VERSIONS],
});
}, [selectHistoryModal.isOpen, queryClient]);
}, [modalHistory.isOpen, queryClient]);
return (
<Box
@@ -222,55 +99,12 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => {
</>
)}
{!isSmallMobile && (
<Button
color="tertiary-text"
icon={
<Icon iconName="download" $theme="primary" $variation="800" />
}
onClick={() => {
setIsModalExportOpen(true);
}}
size={isSmallMobile ? 'small' : 'medium'}
/>
)}
<DropdownMenu options={options}>
<IconOptions
isHorizontal
$theme="primary"
$padding={{ all: 'xs' }}
$css={css`
border-radius: 4px;
&:hover {
background-color: ${colorsTokens['greyscale-100']};
}
${isSmallMobile
? css`
padding: 10px;
border: 1px solid ${colorsTokens['greyscale-300']};
`
: ''}
`}
aria-label={t('Open the document options')}
/>
</DropdownMenu>
</Box>
{modalShare.isOpen && (
<DocShareModal onClose={() => modalShare.close()} doc={doc} />
)}
{isModalExportOpen && (
<ModalExport onClose={() => setIsModalExportOpen(false)} doc={doc} />
)}
{isModalRemoveOpen && (
<ModalRemoveDoc onClose={() => setIsModalRemoveOpen(false)} doc={doc} />
)}
{selectHistoryModal.isOpen && (
<ModalSelectVersion
onClose={() => selectHistoryModal.close()}
<DocToolBoxLicence
doc={doc}
modalHistory={modalHistory}
modalShare={modalShare}
/>
)}
</Box>
</Box>
);
};
@@ -0,0 +1,192 @@
import { Button, useModal } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import {
DropdownMenu,
DropdownMenuOption,
Icon,
IconOptions,
} from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { ModalExport } from '@/docs/doc-export/';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
ModalRemoveDoc,
useCopyDocLink,
useCreateFavoriteDoc,
useDeleteFavoriteDoc,
} from '@/docs/doc-management';
import {
KEY_LIST_DOC_VERSIONS,
ModalSelectVersion,
} from '@/docs/doc-versioning';
import { useAnalytics } from '@/libs';
import { useResponsiveStore } from '@/stores';
import { DocShareModal } from '../../doc-share';
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
type ModalType = ReturnType<typeof useModal>;
interface DocToolBoxLicenceProps {
doc: Doc;
modalHistory: ModalType;
modalShare: ModalType;
}
export const DocToolBoxLicenceAGPL = ({
doc,
modalHistory,
modalShare,
}: DocToolBoxLicenceProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { colorsTokens } = useCunninghamTheme();
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const [isModalExportOpen, setIsModalExportOpen] = useState(false);
const { isSmallMobile, isDesktop } = useResponsiveStore();
const copyDocLink = useCopyDocLink(doc.id);
const { isFeatureFlagActivated } = useAnalytics();
const removeFavoriteDoc = useDeleteFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const makeFavoriteDoc = useCreateFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const copyCurrentEditorToClipboard = useCopyCurrentEditorToClipboard();
const options: DropdownMenuOption[] = [
...(isSmallMobile
? [
{
label: t('Share'),
icon: 'group',
callback: modalShare.open,
},
{
label: t('Export'),
icon: 'download',
callback: () => {
setIsModalExportOpen(true);
},
},
{
label: t('Copy link'),
icon: 'add_link',
callback: copyDocLink,
},
]
: []),
{
label: doc.is_favorite ? t('Unpin') : t('Pin'),
icon: 'push_pin',
callback: () => {
if (doc.is_favorite) {
removeFavoriteDoc.mutate({ id: doc.id });
} else {
makeFavoriteDoc.mutate({ id: doc.id });
}
},
testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
},
{
label: t('Version history'),
icon: 'history',
disabled: !doc.abilities.versions_list,
callback: () => {
modalHistory.open();
},
show: isDesktop,
},
{
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');
},
show: isFeatureFlagActivated('CopyAsHTML'),
},
{
label: t('Delete document'),
icon: 'delete',
disabled: !doc.abilities.destroy,
callback: () => {
setIsModalRemoveOpen(true);
},
},
];
useEffect(() => {
if (modalHistory.isOpen) {
return;
}
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC_VERSIONS],
});
}, [modalHistory.isOpen, queryClient]);
return (
<>
{!isSmallMobile && (
<Button
color="tertiary-text"
icon={<Icon iconName="download" $theme="primary" $variation="800" />}
onClick={() => {
setIsModalExportOpen(true);
}}
size={isSmallMobile ? 'small' : 'medium'}
/>
)}
<DropdownMenu options={options}>
<IconOptions
isHorizontal
$theme="primary"
$padding={{ all: 'xs' }}
$css={css`
border-radius: 4px;
&:hover {
background-color: ${colorsTokens['greyscale-100']};
}
${isSmallMobile
? css`
padding: 10px;
border: 1px solid ${colorsTokens['greyscale-300']};
`
: ''}
`}
aria-label={t('Open the document options')}
/>
</DropdownMenu>
{modalShare.isOpen && (
<DocShareModal onClose={() => modalShare.close()} doc={doc} />
)}
{isModalExportOpen && (
<ModalExport onClose={() => setIsModalExportOpen(false)} doc={doc} />
)}
{isModalRemoveOpen && (
<ModalRemoveDoc onClose={() => setIsModalRemoveOpen(false)} doc={doc} />
)}
{modalHistory.isOpen && (
<ModalSelectVersion onClose={() => modalHistory.close()} doc={doc} />
)}
</>
);
};
@@ -0,0 +1,165 @@
import { useModal } from '@openfun/cunningham-react';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from 'styled-components';
import { DropdownMenu, DropdownMenuOption, IconOptions } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
ModalRemoveDoc,
useCopyDocLink,
useCreateFavoriteDoc,
useDeleteFavoriteDoc,
} from '@/docs/doc-management';
import {
KEY_LIST_DOC_VERSIONS,
ModalSelectVersion,
} from '@/docs/doc-versioning';
import { useAnalytics } from '@/libs';
import { useResponsiveStore } from '@/stores';
import { DocShareModal } from '../../doc-share';
import { useCopyCurrentEditorToClipboard } from '../hooks/useCopyCurrentEditorToClipboard';
type ModalType = ReturnType<typeof useModal>;
interface DocToolBoxLicenceProps {
doc: Doc;
modalHistory: ModalType;
modalShare: ModalType;
}
export const DocToolBoxLicenceMIT = ({
doc,
modalHistory,
modalShare,
}: DocToolBoxLicenceProps) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { colorsTokens } = useCunninghamTheme();
const [isModalRemoveOpen, setIsModalRemoveOpen] = useState(false);
const { isSmallMobile, isDesktop } = useResponsiveStore();
const copyDocLink = useCopyDocLink(doc.id);
const { isFeatureFlagActivated } = useAnalytics();
const removeFavoriteDoc = useDeleteFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const makeFavoriteDoc = useCreateFavoriteDoc({
listInvalideQueries: [KEY_LIST_DOC, KEY_DOC],
});
const copyCurrentEditorToClipboard = useCopyCurrentEditorToClipboard();
const options: DropdownMenuOption[] = [
...(isSmallMobile
? [
{
label: t('Share'),
icon: 'group',
callback: modalShare.open,
},
{
label: t('Copy link'),
icon: 'add_link',
callback: copyDocLink,
},
]
: []),
{
label: doc.is_favorite ? t('Unpin') : t('Pin'),
icon: 'push_pin',
callback: () => {
if (doc.is_favorite) {
removeFavoriteDoc.mutate({ id: doc.id });
} else {
makeFavoriteDoc.mutate({ id: doc.id });
}
},
testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`,
},
{
label: t('Version history'),
icon: 'history',
disabled: !doc.abilities.versions_list,
callback: () => {
modalHistory.open();
},
show: isDesktop,
},
{
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');
},
show: isFeatureFlagActivated('CopyAsHTML'),
},
{
label: t('Delete document'),
icon: 'delete',
disabled: !doc.abilities.destroy,
callback: () => {
setIsModalRemoveOpen(true);
},
},
];
useEffect(() => {
if (modalHistory.isOpen) {
return;
}
void queryClient.resetQueries({
queryKey: [KEY_LIST_DOC_VERSIONS],
});
}, [modalHistory.isOpen, queryClient]);
return (
<>
<DropdownMenu options={options}>
<IconOptions
isHorizontal
$theme="primary"
$padding={{ all: 'xs' }}
$css={css`
border-radius: 4px;
&:hover {
background-color: ${colorsTokens['greyscale-100']};
}
${isSmallMobile
? css`
padding: 10px;
border: 1px solid ${colorsTokens['greyscale-300']};
`
: ''}
`}
aria-label={t('Open the document options')}
/>
</DropdownMenu>
{modalShare.isOpen && (
<DocShareModal onClose={() => modalShare.close()} doc={doc} />
)}
{isModalRemoveOpen && (
<ModalRemoveDoc onClose={() => setIsModalRemoveOpen(false)} doc={doc} />
)}
{modalHistory.isOpen && (
<ModalSelectVersion onClose={() => modalHistory.close()} doc={doc} />
)}
</>
);
};
@@ -0,0 +1,31 @@
import { VariantType, useToastProvider } from '@openfun/cunningham-react';
import { useTranslation } from 'react-i18next';
import { useEditorStore } from '../../doc-editor';
export const useCopyCurrentEditorToClipboard = () => {
const { editor } = useEditorStore();
const { toast } = useToastProvider();
const { t } = useTranslation();
return async (asFormat: 'html' | 'markdown') => {
if (!editor) {
toast(t('Editor unavailable'), VariantType.ERROR, { duration: 3000 });
return;
}
try {
const editorContentFormatted =
asFormat === 'html'
? await editor.blocksToHTMLLossy()
: await editor.blocksToMarkdownLossy();
await navigator.clipboard.writeText(editorContentFormatted);
toast(t('Copied to clipboard'), VariantType.SUCCESS, { duration: 3000 });
} catch (error) {
console.error(error);
toast(t('Failed to copy to clipboard'), VariantType.ERROR, {
duration: 3000,
});
}
};
};
@@ -38,11 +38,15 @@ setCacheNameDetails({
});
/**
* In development, use NetworkFirst strategy, in production use CacheFirst strategy
* We will be able to test the application in development without having to clear the cache.
* @param url
* @param options
* @returns strategy
* Chooses the appropriate caching strategy based on the environment and request context.
*
* - In **development**, or for **API requests**, or **HTML pages**, it returns a `NetworkFirst` strategy
* to prioritize fresh responses and ease debugging without needing to clear caches.
* - In **production** (for non-API, non-HTML content), it returns a `CacheFirst` strategy
* to favor performance and offline access.
*
* @param {NetworkFirstOptions | StrategyOptions} [options] - Configuration options for the caching strategy.
* @returns {NetworkFirst | CacheFirst} The selected Workbox strategy instance.
*/
const getStrategy = (
options?: NetworkFirstOptions | StrategyOptions,
@@ -53,6 +53,7 @@
"Accessible to anyone": "Für alle zugänglich",
"Accessible to authenticated users": "Für authentifizierte Benutzer zugänglich",
"Add": "Hinzufügen",
"Add a callout block": "Hebt schrift hervor",
"Add a horizontal line": "Waagerechte Linie einfügen",
"Address:": "Anschrift:",
"Administrator": "Administrator",
@@ -67,6 +68,7 @@
"Available soon": "Bald verfügbar",
"Banner image": "Bannerbild",
"Beautify": "Verschönern",
"Callout": "Hervorhebung",
"Can't load this page, please check your internet connection.": "Diese Seite kann nicht geladen werden. Bitte überprüfen Sie Ihre Internetverbindung.",
"Cancel": "Abbrechen",
"Close the modal": "Pop up schliessen",
@@ -284,6 +286,7 @@
"Accessible to anyone": "Accesible para todos",
"Accessible to authenticated users": "Accesible a usuarios autenticados",
"Add": "Añadir",
"Add a callout block": "Resaltar el texto para que destaque",
"Add a horizontal line": "Añadir una línea horizontal",
"Address:": "Dirección:",
"Administrator": "Administrador",
@@ -298,6 +301,7 @@
"Available soon": "Próximamente disponible",
"Banner image": "Imagen de portada",
"Beautify": "Embellecer",
"Callout": "Destacado",
"Can't load this page, please check your internet connection.": "No se puede cargar esta página, por favor compruebe su conexión a Internet.",
"Cancel": "Cancelar",
"Close the modal": "Cerrar modal",
@@ -508,6 +512,7 @@
"Accessible to authenticated users": "Accessible aux utilisateurs authentifiés",
"Add": "Ajouter",
"Add a horizontal line": "Ajouter une ligne horizontale",
"Add a callout block": "Faites ressortir du texte pour le mettre en évidence",
"Address:": "Adresse :",
"Administrator": "Administrateur",
"All docs": "Tous les documents",
@@ -521,6 +526,7 @@
"Available soon": "Disponible prochainement",
"Banner image": "Image de la bannière",
"Beautify": "Embellir",
"Callout": "Encadré",
"Can't load this page, please check your internet connection.": "Impossible de charger cette page, veuillez vérifier votre connexion Internet.",
"Cancel": "Annuler",
"Close the modal": "Fermer la modale",
@@ -914,6 +920,7 @@
"Accessible to anyone": "Toegankelijk voor iedereen",
"Accessible to authenticated users": "Toegankelijk voor geauthentiseerde gebruikers",
"Add": "Voeg toe",
"Add a callout block": "Laat je tekst opvallen",
"Add a horizontal line": "Voeg horizontale lijn toe",
"Address:": "Adres:",
"Administrator": "Administrator",
@@ -928,6 +935,7 @@
"Available soon": "Binnenkort beschikbaar",
"Banner image": "Banner afbeelding",
"Beautify": "Maak mooier",
"Callout": "Benadrukken",
"Can't load this page, please check your internet connection.": "Kan deze pagina niet laden. Controleer je internetverbinding.",
"Cancel": "Breek af",
"Close the modal": "Sluit het venster",
@@ -25,14 +25,10 @@ export class PostHogAnalytic extends AbstractAnalytic {
}
public isFeatureFlagActivated(flagName: string): boolean {
if (
return !(
posthog.featureFlags.getFlags().includes(flagName) &&
posthog.isFeatureEnabled(flagName) === false
) {
return false;
}
return true;
);
}
}
+1 -5
View File
@@ -40,11 +40,7 @@ export function isSafeUrl(url: string): boolean {
}
// Check for directory traversal attempts
if (url.includes('..') || url.includes('../') || url.includes('..\\')) {
return false;
}
return true;
return !(url.includes('..') || url.includes('../') || url.includes('..\\'));
} catch {
return false;
}
@@ -56,6 +56,7 @@ const globalRules = {
'error',
{ varsIgnorePattern: '^_', argsIgnorePattern: '^_' },
],
'no-var': 'error',
'react/jsx-curly-brace-presence': [
'error',
{ props: 'never', children: 'never', propElementValues: 'always' },
@@ -13,5 +13,5 @@ module.exports = {
rules: {
'@next/next/no-html-link-for-pages': 'off',
},
ignorePatterns: ['node_modules', '.eslintrc.js'],
ignorePatterns: ['node_modules'],
};
@@ -1,4 +1,4 @@
var config = {
const config = {
rootDir: './__tests__',
testEnvironment: 'node',
transform: {
@@ -17,8 +17,6 @@ import { logger } from '../utils';
/**
* init the collaboration server.
*
* @param port - The port on which the server listens.
* @param serverSecret - The secret key for API authentication.
* @returns An object containing the Express app, Hocuspocus server, and HTTP server instance.
*/
export const initServer = () => {
+7 -2
View File
@@ -1238,11 +1238,16 @@
dependencies:
tslib "^2.4.0"
"@emoji-mart/data@^1.2.1":
"@emoji-mart/data@1.2.1", "@emoji-mart/data@^1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.2.1.tgz#0ad70c662e3bc603e23e7d98413bd1e64c4fcb6c"
integrity sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==
"@emoji-mart/react@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@emoji-mart/react/-/react-1.1.1.tgz#ddad52f93a25baf31c5383c3e7e4c6e05554312a"
integrity sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==
"@emotion/babel-plugin@^11.13.5":
version "11.13.5"
resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz#eab8d65dbded74e0ecfd28dc218e75607c4e7bc0"
@@ -7462,7 +7467,7 @@ emittery@^0.13.1:
resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad"
integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==
emoji-mart@^5.6.0:
emoji-mart@5.6.0, emoji-mart@^5.6.0:
version "5.6.0"
resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-5.6.0.tgz#71b3ed0091d3e8c68487b240d9d6d9a73c27f023"
integrity sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==