From b667200ebdc41bb07c159796e931789d90492068 Mon Sep 17 00:00:00 2001 From: Olivier Laurendeau Date: Mon, 15 Sep 2025 15:26:55 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20an=20EmojiPicker=20?= =?UTF-8?q?in=20the=20document=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This allows users to easily add emojis easily to their documents from the tree, enhancing the overall user experience. --- .../__tests__/app-impress/doc-tree.spec.ts | 36 ++- .../doc-editor/components/EmojiPicker.tsx | 2 +- .../components/custom-blocks/index.ts | 1 + .../docs/doc-editor/components/index.ts | 1 + .../docs/doc-header/components/DocTitle.tsx | 45 +-- .../doc-management/components/DocIcon.tsx | 115 +++++++- .../docs/doc-management/components/index.ts | 1 + .../__tests__/useDocTitleUpdate.test.tsx | 274 ++++++++++++++++++ .../docs/doc-management/hooks/index.ts | 1 + .../hooks/useDocTitleUpdate.tsx | 76 +++++ .../doc-tree/components/DocSubPageItem.tsx | 7 +- .../impress/src/stores/useBroadcastStore.tsx | 6 +- 12 files changed, 506 insertions(+), 59 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/hooks/__tests__/useDocTitleUpdate.test.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/hooks/useDocTitleUpdate.tsx diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts index 6247cbc6..7a66acdb 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts @@ -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', () => { diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/EmojiPicker.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/EmojiPicker.tsx index de4a5c90..7469b2a0 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/EmojiPicker.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/EmojiPicker.tsx @@ -19,7 +19,7 @@ export const EmojiPicker = ({ const { i18n } = useTranslation(); return ( - + { const { t } = useTranslation(); const { colorsTokens } = useCunninghamTheme(); const [titleDisplay, setTitleDisplay] = useState(doc.title); - const treeContext = useTreeContext(); 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) => { diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/DocIcon.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/DocIcon.tsx index 559b6320..1c769d6d 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/DocIcon.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/DocIcon.tsx @@ -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(null); + + const [openEmojiPicker, setOpenEmojiPicker] = useState(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 ( - + <> + + {!emoji ? ( + defaultIcon + ) : ( + + )} + + {openEmojiPicker && + createPortal( +
+ +
, + document.body, + )} + ); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/index.ts b/src/frontend/apps/impress/src/features/docs/doc-management/components/index.ts index a3a6a6ce..58c03fd1 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/index.ts @@ -1,3 +1,4 @@ +export * from './DocIcon'; export * from './DocPage403'; export * from './ModalRemoveDoc'; export * from './SimpleDocItem'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/__tests__/useDocTitleUpdate.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/__tests__/useDocTitleUpdate.test.tsx new file mode 100644 index 00000000..cde76d95 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/__tests__/useDocTitleUpdate.test.tsx @@ -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')); + } + }); + }); +}); diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts index 930e2d49..fbcfdc6f 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/index.ts @@ -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'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useDocTitleUpdate.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useDocTitleUpdate.tsx new file mode 100644 index 00000000..1cbee30b --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/hooks/useDocTitleUpdate.tsx @@ -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(); + + 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, + }; +}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx index 83948e0a..7e07f8b7 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocSubPageItem.tsx @@ -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) => { min-width: 0; `} > - + } $size="sm" + docId={doc.id} + title={doc.title} /> diff --git a/src/frontend/apps/impress/src/stores/useBroadcastStore.tsx b/src/frontend/apps/impress/src/stores/useBroadcastStore.tsx index 7e8812f7..2406d42d 100644 --- a/src/frontend/apps/impress/src/stores/useBroadcastStore.tsx +++ b/src/frontend/apps/impress/src/stores/useBroadcastStore.tsx @@ -78,11 +78,11 @@ export const useBroadcastStore = create((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}`]); }, }));