(frontend) add an EmojiPicker in the document tree

This allows users to easily add emojis easily to
their documents from the tree, enhancing the
overall user experience.
This commit is contained in:
Olivier Laurendeau
2025-09-15 15:26:55 +02:00
committed by Anthony LC
parent 294922f966
commit b667200ebd
12 changed files with 506 additions and 59 deletions

View File

@@ -9,7 +9,11 @@ import {
verifyDocName,
} from './utils-common';
import { addNewMember } from './utils-share';
import { clickOnAddRootSubPage, createRootSubPage } from './utils-sub-pages';
import {
clickOnAddRootSubPage,
createRootSubPage,
getTreeRow,
} from './utils-sub-pages';
test.describe('Doc Tree', () => {
test.beforeEach(async ({ page }) => {
@@ -298,6 +302,36 @@ test.describe('Doc Tree', () => {
// Now test keyboard navigation on sub-document
await expect(docTree.getByText(docChild)).toBeVisible();
});
test('it updates the child icon from the tree', async ({
page,
browserName,
}) => {
const [docParent] = await createDoc(
page,
'doc-child-emoji',
browserName,
1,
);
await verifyDocName(page, docParent);
const { name: docChild } = await createRootSubPage(
page,
browserName,
'doc-child-emoji-child',
);
// Update the emoji from the tree
const row = await getTreeRow(page, docChild);
await row.locator('.--docs--doc-icon').click();
await page.getByRole('button', { name: '😀' }).first().click();
// Verify the emoji is updated in the tree and in the document title
await expect(row.getByText('😀')).toBeVisible();
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toContainText('😀');
});
});
test.describe('Doc Tree: Inheritance', () => {

View File

@@ -19,7 +19,7 @@ export const EmojiPicker = ({
const { i18n } = useTranslation();
return (
<Box $position="absolute" $zIndex={1000} $margin="2rem 0 0 0">
<Box>
<Picker
data={emojiData}
locale={i18n.resolvedLanguage}

View File

@@ -1,4 +1,5 @@
export * from './AccessibleImageBlock';
export * from './CalloutBlock';
export { default as emojidata } from './initEmojiCallout';
export * from './PdfBlock';
export * from './UploadLoaderBlock';

View File

@@ -1,2 +1,3 @@
export * from './DocEditor';
export * from './EmojiPicker';
export * from './custom-blocks/';

View File

@@ -1,4 +1,3 @@
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { Tooltip } from '@openfun/cunningham-react';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -8,14 +7,12 @@ import { Box, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
useDocStore,
useDocTitleUpdate,
useIsCollaborativeEditable,
useTrans,
useUpdateDoc,
} from '@/docs/doc-management';
import { useBroadcastStore, useResponsiveStore } from '@/stores';
import { useResponsiveStore } from '@/stores';
interface DocTitleProps {
doc: Doc;
@@ -54,47 +51,17 @@ const DocTitleInput = ({ doc }: DocTitleProps) => {
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const [titleDisplay, setTitleDisplay] = useState(doc.title);
const treeContext = useTreeContext<Doc>();
const { untitledDocument } = useTrans();
const { broadcast } = useBroadcastStore();
const { mutate: updateDoc } = useUpdateDoc({
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
onSuccess(updatedDoc) {
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${updatedDoc.id}`);
if (!treeContext) {
return;
}
if (treeContext.root?.id === updatedDoc.id) {
treeContext?.setRoot(updatedDoc);
} else {
treeContext?.treeData.updateNode(updatedDoc.id, updatedDoc);
}
},
});
const { updateDocTitle } = useDocTitleUpdate();
const handleTitleSubmit = useCallback(
(inputText: string) => {
let sanitizedTitle = inputText.trim();
sanitizedTitle = sanitizedTitle.replace(/(\r\n|\n|\r)/gm, '');
// When blank we set to untitled
if (!sanitizedTitle) {
setTitleDisplay('');
}
// If mutation we update
if (sanitizedTitle !== doc.title) {
setTitleDisplay(sanitizedTitle);
updateDoc({ id: doc.id, title: sanitizedTitle });
}
const sanitizedTitle = updateDocTitle(doc, inputText.trim());
setTitleDisplay(sanitizedTitle);
},
[doc.id, doc.title, updateDoc],
[doc, updateDocTitle],
);
const handleKeyDown = (e: React.KeyboardEvent) => {

View File

@@ -1,8 +1,18 @@
import { Text, TextType } from '@/components';
import { MouseEvent, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { BoxButton, Icon, TextType } from '@/components';
import { EmojiPicker, emojidata } from '@/docs/doc-editor/';
import { useDocTitleUpdate } from '../hooks/useDocTitleUpdate';
type DocIconProps = TextType & {
emoji?: string | null;
defaultIcon: React.ReactNode;
docId?: string;
title?: string;
onEmojiUpdate?: (emoji: string) => void;
withEmojiPicker?: boolean;
};
export const DocIcon = ({
@@ -11,22 +21,101 @@ export const DocIcon = ({
$size = 'sm',
$variation = '1000',
$weight = '400',
docId,
title,
onEmojiUpdate,
withEmojiPicker = false,
...textProps
}: DocIconProps) => {
if (!emoji) {
return <>{defaultIcon}</>;
const { updateDocEmoji } = useDocTitleUpdate();
const iconRef = useRef<HTMLDivElement>(null);
const [openEmojiPicker, setOpenEmojiPicker] = useState<boolean>(false);
const [pickerPosition, setPickerPosition] = useState<{
top: number;
left: number;
}>({ top: 0, left: 0 });
if (!withEmojiPicker && !emoji) {
return defaultIcon;
}
const toggleEmojiPicker = (e: MouseEvent) => {
if (withEmojiPicker) {
e.stopPropagation();
e.preventDefault();
if (!openEmojiPicker && iconRef.current) {
const rect = iconRef.current.getBoundingClientRect();
setPickerPosition({
top: rect.bottom + window.scrollY + 8,
left: rect.left + window.scrollX,
});
}
setOpenEmojiPicker(!openEmojiPicker);
}
};
const handleEmojiSelect = ({ native }: { native: string }) => {
setOpenEmojiPicker(false);
// Update document emoji if docId is provided
if (docId && title !== undefined) {
updateDocEmoji(docId, title ?? '', native);
}
// Call the optional callback
onEmojiUpdate?.(native);
};
const handleClickOutside = () => {
setOpenEmojiPicker(false);
};
return (
<Text
{...textProps}
$size={$size}
$variation={$variation}
$weight={$weight}
aria-hidden="true"
data-testid="doc-emoji-icon"
>
{emoji}
</Text>
<>
<BoxButton
ref={iconRef}
onClick={toggleEmojiPicker}
$position="relative"
className="--docs--doc-icon"
>
{!emoji ? (
defaultIcon
) : (
<Icon
{...textProps}
iconName={emoji}
$size={$size}
$variation={$variation}
$weight={$weight}
aria-hidden="true"
data-testid="doc-emoji-icon"
>
{emoji}
</Icon>
)}
</BoxButton>
{openEmojiPicker &&
createPortal(
<div
style={{
position: 'absolute',
top: pickerPosition.top,
left: pickerPosition.left,
zIndex: 1000,
}}
>
<EmojiPicker
emojiData={emojidata}
onEmojiSelect={handleEmojiSelect}
onClickOutside={handleClickOutside}
/>
</div>,
document.body,
)}
</>
);
};

View File

@@ -1,3 +1,4 @@
export * from './DocIcon';
export * from './DocPage403';
export * from './ModalRemoveDoc';
export * from './SimpleDocItem';

View File

@@ -0,0 +1,274 @@
import { renderHook, waitFor } from '@testing-library/react';
import fetchMock from 'fetch-mock';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AppWrapper } from '@/tests/utils';
import { Doc } from '../../types';
import { useDocTitleUpdate } from '../useDocTitleUpdate';
// Mock useBroadcastStore
vi.mock('@/stores', () => ({
useBroadcastStore: () => ({
broadcast: vi.fn(),
}),
}));
describe('useDocTitleUpdate', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchMock.restore();
});
it('should return the correct functions and state', () => {
const { result } = renderHook(() => useDocTitleUpdate(), {
wrapper: AppWrapper,
});
expect(result.current.updateDocTitle).toBeDefined();
expect(result.current.updateDocEmoji).toBeDefined();
expect(typeof result.current.updateDocTitle).toBe('function');
expect(typeof result.current.updateDocEmoji).toBe('function');
});
describe('updateDocTitle', () => {
it('should call updateDoc with sanitized title', async () => {
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
title: 'My Document',
}),
});
const { result } = renderHook(() => useDocTitleUpdate(), {
wrapper: AppWrapper,
});
const sanitizedTitle = result.current.updateDocTitle(
{ id: 'test-doc-id', title: '' } as Doc,
' My Document \n\r',
);
expect(sanitizedTitle).toBe('My Document');
await waitFor(() => {
expect(fetchMock.calls().length).toBe(1);
});
expect(fetchMock.calls()[0][0]).toBe(
'http://test.jest/api/v1.0/documents/test-doc-id/',
);
expect(fetchMock.calls()[0][1]).toEqual({
method: 'PATCH',
credentials: 'include',
body: JSON.stringify({ title: 'My Document' }),
headers: { 'Content-Type': 'application/json' },
});
});
it('should handle empty title and not call updateDoc', async () => {
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
title: 'My Document',
}),
});
const { result } = renderHook(() => useDocTitleUpdate(), {
wrapper: AppWrapper,
});
const sanitizedTitle = result.current.updateDocTitle(
{ id: 'test-doc-id', title: '' } as Doc,
'',
);
expect(sanitizedTitle).toBe('');
await waitFor(() => {
expect(fetchMock.calls().length).toBe(0);
});
});
it('should remove newlines and carriage returns', async () => {
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
title: 'My Document',
}),
});
const { result } = renderHook(() => useDocTitleUpdate(), {
wrapper: AppWrapper,
});
const sanitizedTitle = result.current.updateDocTitle(
{ id: 'test-doc-id', title: '' } as Doc,
'Title\nwith\r\nnewlines',
);
expect(sanitizedTitle).toBe('Titlewithnewlines');
await waitFor(() => {
expect(fetchMock.calls().length).toBe(1);
});
});
});
describe('updateDocEmoji', () => {
it('should call updateDoc with emoji and title without existing emoji', async () => {
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
title: 'My Document',
}),
});
const { result } = renderHook(() => useDocTitleUpdate(), {
wrapper: AppWrapper,
});
result.current.updateDocEmoji('test-doc-id', 'My Document', '🚀');
await waitFor(() => {
expect(fetchMock.calls().length).toBe(1);
});
expect(fetchMock.calls()[0][0]).toBe(
'http://test.jest/api/v1.0/documents/test-doc-id/',
);
expect(fetchMock.calls()[0][1]).toEqual({
method: 'PATCH',
credentials: 'include',
body: JSON.stringify({ title: '🚀 My Document' }),
headers: { 'Content-Type': 'application/json' },
});
});
it('should replace existing emoji with new one', async () => {
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
title: 'My Document',
}),
});
const { result } = renderHook(() => useDocTitleUpdate(), {
wrapper: AppWrapper,
});
result.current.updateDocEmoji('test-doc-id', '📝 My Document', '🚀');
await waitFor(() => {
expect(fetchMock.calls().length).toBe(1);
});
expect(fetchMock.calls()[0][1]).toEqual({
method: 'PATCH',
credentials: 'include',
body: JSON.stringify({ title: '🚀 My Document' }),
headers: { 'Content-Type': 'application/json' },
});
});
it('should handle title with only emoji', async () => {
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
title: 'My Document',
}),
});
const { result } = renderHook(() => useDocTitleUpdate(), {
wrapper: AppWrapper,
});
result.current.updateDocEmoji('test-doc-id', '📝', '🚀');
await waitFor(() => {
expect(fetchMock.calls().length).toBe(1);
});
expect(fetchMock.calls()[0][1]).toEqual({
method: 'PATCH',
credentials: 'include',
body: JSON.stringify({ title: '🚀 ' }),
headers: { 'Content-Type': 'application/json' },
});
});
it('should handle empty title', async () => {
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
title: 'My Document',
}),
});
const { result } = renderHook(() => useDocTitleUpdate(), {
wrapper: AppWrapper,
});
result.current.updateDocEmoji('test-doc-id', '', '🚀');
await waitFor(() => {
expect(fetchMock.calls().length).toBe(1);
});
expect(fetchMock.calls()[0][1]).toEqual({
method: 'PATCH',
credentials: 'include',
body: JSON.stringify({ title: '🚀 ' }),
headers: { 'Content-Type': 'application/json' },
});
});
});
describe('onSuccess callback', () => {
it('should call onSuccess when provided', async () => {
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
title: 'Updated Document',
}),
});
const onSuccess = vi.fn();
const { result } = renderHook(() => useDocTitleUpdate({ onSuccess }), {
wrapper: AppWrapper,
});
result.current.updateDocTitle(
{ id: 'test-doc-id', title: 'Old Document' } as Doc,
'Updated Document',
);
await waitFor(() => {
expect(fetchMock.calls().length).toBe(1);
});
expect(onSuccess).toHaveBeenCalledWith({
id: 'test-doc-id',
title: 'Updated Document',
});
});
});
describe('onError callback', () => {
it('should call onError when provided', async () => {
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
throws: new Error('Update failed'),
});
const onError = vi.fn();
const { result } = renderHook(() => useDocTitleUpdate({ onError }), {
wrapper: AppWrapper,
});
try {
result.current.updateDocTitle(
{ id: 'test-doc-id', title: 'Old Document' } as Doc,
'Updated Document',
);
} catch {
expect(fetchMock.calls().length).toBe(1);
expect(onError).toHaveBeenCalledWith(new Error('Update failed'));
}
});
});
});

View File

@@ -1,6 +1,7 @@
export * from './useCollaboration';
export * from './useCopyDocLink';
export * from './useCreateChildDocTree';
export * from './useDocTitleUpdate';
export * from './useDocUtils';
export * from './useIsCollaborativeEditable';
export * from './useTrans';

View File

@@ -0,0 +1,76 @@
import { useTreeContext } from '@gouvfr-lasuite/ui-kit';
import { useCallback } from 'react';
import {
Doc,
KEY_DOC,
KEY_LIST_DOC,
getEmojiAndTitle,
useUpdateDoc,
} from '@/docs/doc-management';
import { useBroadcastStore } from '@/stores';
interface UseDocUpdateOptions {
onSuccess?: (updatedDoc: Doc) => void;
onError?: (error: Error) => void;
}
export const useDocTitleUpdate = (options?: UseDocUpdateOptions) => {
const { broadcast } = useBroadcastStore();
const treeContext = useTreeContext<Doc>();
const { mutate: updateDoc, ...mutationResult } = useUpdateDoc({
listInvalideQueries: [KEY_DOC, KEY_LIST_DOC],
onSuccess: (updatedDoc) => {
// Broadcast to every user connected to the document
broadcast(`${KEY_DOC}-${updatedDoc.id}`);
if (treeContext) {
if (treeContext.root?.id === updatedDoc.id) {
treeContext?.setRoot(updatedDoc);
} else {
treeContext?.treeData.updateNode(updatedDoc.id, updatedDoc);
}
}
options?.onSuccess?.(updatedDoc);
},
onError: (error) => {
options?.onError?.(error);
},
});
const updateDocTitle = useCallback(
(doc: Doc, title: string) => {
const sanitizedTitle = title.trim().replace(/(\r\n|\n|\r)/gm, '');
// When blank we set to untitled
if (!sanitizedTitle) {
updateDoc({ id: doc.id, title: '' });
return '';
}
// If mutation we update
if (sanitizedTitle !== doc.title) {
updateDoc({ id: doc.id, title: sanitizedTitle });
}
return sanitizedTitle;
},
[updateDoc],
);
const updateDocEmoji = useCallback(
(docId: string, title: string, emoji: string) => {
const { titleWithoutEmoji } = getEmojiAndTitle(title);
updateDoc({ id: docId, title: `${emoji} ${titleWithoutEmoji}` });
},
[updateDoc],
);
return {
...mutationResult,
updateDocTitle,
updateDocEmoji,
};
};

View File

@@ -12,10 +12,10 @@ import { Box, BoxButton, Icon, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import {
Doc,
DocIcon,
getEmojiAndTitle,
useTrans,
} from '@/features/docs/doc-management';
import { DocIcon } from '@/features/docs/doc-management/components/DocIcon';
import { useLeftPanelStore } from '@/features/left-panel';
import { useResponsiveStore } from '@/stores';
@@ -166,11 +166,14 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
min-width: 0;
`}
>
<Box $width="16px" $height="16px">
<Box>
<DocIcon
emoji={emoji}
withEmojiPicker={doc.abilities.partial_update}
defaultIcon={<SubPageIcon color={colorsTokens['primary-400']} />}
$size="sm"
docId={doc.id}
title={doc.title}
/>
</Box>

View File

@@ -78,11 +78,11 @@ export const useBroadcastStore = create<BroadcastState>((set, get) => ({
}));
},
broadcast: (taskLabel) => {
const { task } = get().tasks[taskLabel];
if (!task) {
const obTask = get().tasks?.[taskLabel];
if (!obTask || !obTask.task) {
console.warn(`Task ${taskLabel} is not defined`);
return;
}
task.push([`broadcast: ${taskLabel}`]);
obTask.task.push([`broadcast: ${taskLabel}`]);
},
}));