From 294922f966d43723aa08451fae4ef1bfc6718cf2 Mon Sep 17 00:00:00 2001 From: Olivier Laurendeau Date: Mon, 15 Sep 2025 12:34:38 +0200 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=A9=B9(frontend)=20do=20not=20display?= =?UTF-8?q?=20emoji=20as=20page=20icon=20on=20main=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We decided to not display the leading emoji as page icon on the main pages to keep consistency in the document list. --- .../__tests__/app-impress/doc-header.spec.ts | 15 ++----- .../__tests__/app-impress/utils-sub-pages.ts | 14 ++++++ .../components/SimpleDocItem.tsx | 43 ++++++------------- 3 files changed, 29 insertions(+), 43 deletions(-) diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts index ff2754434e..2e5a187eb1 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts @@ -8,7 +8,7 @@ import { verifyDocName, } from './utils-common'; import { mockedAccesses, mockedInvitations } from './utils-share'; -import { createRootSubPage } from './utils-sub-pages'; +import { createRootSubPage, getTreeRow } from './utils-sub-pages'; test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -73,17 +73,8 @@ test.describe('Doc Header', () => { await verifyDocName(page, '👍 Hello Emoji World'); // Check the tree - const docTree = page.getByTestId('doc-tree'); - await expect(docTree.getByText('Hello Emoji World')).toBeVisible(); - await expect(docTree.getByTestId('doc-emoji-icon')).toBeVisible(); - await expect(docTree.getByTestId('doc-simple-icon')).toBeHidden(); - - await page.getByTestId('home-button').click(); - - // Check the documents grid - const gridRow = await getGridRow(page, 'Hello Emoji World'); - await expect(gridRow.getByTestId('doc-emoji-icon')).toBeVisible(); - await expect(gridRow.getByTestId('doc-simple-icon')).toBeHidden(); + const row = await getTreeRow(page, 'Hello Emoji World'); + await expect(row.getByText('👍')).toBeVisible(); }); test('it deletes the doc', async ({ page, browserName }) => { diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-sub-pages.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-sub-pages.ts index 236b455ecd..9ba3829851 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-sub-pages.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-sub-pages.ts @@ -107,6 +107,20 @@ export const addChild = async ({ return name; }; +export const getTreeRow = async (page: Page, title: string) => { + const docTree = page.getByTestId('doc-tree'); + const row = docTree + .getByRole('treeitem') + .filter({ + hasText: title, + }) + .first(); + + await expect(row).toBeVisible(); + + return row; +}; + export const navigateToTopParentFromTree = async ({ page }: { page: Page }) => { await page.getByRole('link', { name: /Open root document/ }).click(); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx index 27b31cd1ab..8a390e10f3 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx @@ -4,20 +4,13 @@ import { css } from 'styled-components'; import { Box, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { - Doc, - getEmojiAndTitle, - useDocUtils, - useTrans, -} from '@/docs/doc-management'; +import { Doc, useDocUtils, useTrans } from '@/docs/doc-management'; import { useResponsiveStore } from '@/stores'; import ChildDocument from '../assets/child-document.svg'; import PinnedDocumentIcon from '../assets/pinned-document.svg'; import SimpleFileIcon from '../assets/simple-document.svg'; -import { DocIcon } from './DocIcon'; - const ItemTextCss = css` overflow: hidden; text-overflow: ellipsis; @@ -45,10 +38,6 @@ export const SimpleDocItem = ({ const { untitledDocument } = useTrans(); const { isChild } = useDocUtils(doc); - const { emoji, titleWithoutEmoji: displayTitle } = getEmojiAndTitle( - doc.title || untitledDocument, - ); - return ( + ) : isChild ? ( + @@ -106,7 +87,7 @@ export const SimpleDocItem = ({ $css={ItemTextCss} data-testid="doc-title" > - {displayTitle} + {doc.title || untitledDocument} {(!isDesktop || showAccesses) && ( Date: Mon, 15 Sep 2025 15:26:55 +0200 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20an=20EmojiPicke?= =?UTF-8?q?r=20in=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 6247cbc64e..7a66acdb84 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 de4a5c90a2..7469b2a093 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 559b63200f..1c769d6d62 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 a3a6a6cef4..58c03fd14c 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 0000000000..cde76d95f8 --- /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 930e2d4984..fbcfdc6fba 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 0000000000..1cbee30b74 --- /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 83948e0a14..7e07f8b7aa 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 7e8812f768..2406d42d2a 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}`]); }, })); From 192fa76b5477bdce2e0494316419526e265c963b Mon Sep 17 00:00:00 2001 From: Olivier Laurendeau Date: Mon, 15 Sep 2025 15:43:11 +0200 Subject: [PATCH 3/6] =?UTF-8?q?=E2=9C=A8(frontend)=20can=20remove=20emoji?= =?UTF-8?q?=20in=20the=20tree=20item=20actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add action button to remove emoji from a document title from the document tree. --- .../__tests__/app-impress/doc-tree.spec.ts | 24 ++++++++++++++++++- .../components/DocTreeItemActions.tsx | 19 +++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) 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 7a66acdb84..8b9ce9319f 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 @@ -321,8 +321,20 @@ test.describe('Doc Tree', () => { 'doc-child-emoji-child', ); - // Update the emoji from the tree const row = await getTreeRow(page, docChild); + + // Check Remove emoji is not present initially + await row.hover(); + const menu = row.getByText(`more_horiz`); + await menu.click(); + await expect( + page.getByRole('menuitem', { name: 'Remove emoji' }), + ).toBeHidden(); + + // Close the menu + await page.keyboard.press('Escape'); + + // Update the emoji from the tree await row.locator('.--docs--doc-icon').click(); await page.getByRole('button', { name: '😀' }).first().click(); @@ -331,6 +343,16 @@ test.describe('Doc Tree', () => { await expect( page.getByRole('textbox', { name: 'Document title' }), ).toContainText('😀'); + + // Now remove the emoji using the new action + await row.hover(); + await menu.click(); + await page.getByRole('menuitem', { name: 'Remove emoji' }).click(); + + await expect(row.getByText('😀')).toBeHidden(); + await expect( + page.getByRole('textbox', { name: 'Document title' }), + ).not.toContainText('😀'); }); }); diff --git a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx index 0ed7b8b703..da05630130 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-tree/components/DocTreeItemActions.tsx @@ -13,8 +13,10 @@ import { Doc, ModalRemoveDoc, Role, + getEmojiAndTitle, useCopyDocLink, useCreateChildDoc, + useDocTitleUpdate, useDuplicateDoc, } from '@/docs/doc-management'; @@ -44,6 +46,7 @@ export const DocTreeItemActions = ({ const copyLink = useCopyDocLink(doc.id); const { mutate: detachDoc } = useDetachDoc(); const treeContext = useTreeContext(); + const { mutate: duplicateDoc } = useDuplicateDoc({ onSuccess: (duplicatedDoc) => { // Reset the tree context root will reset the full tree view. @@ -52,6 +55,13 @@ export const DocTreeItemActions = ({ }, }); + // Emoji Management + const { emoji } = getEmojiAndTitle(doc.title ?? ''); + const { updateDocEmoji } = useDocTitleUpdate(); + const removeEmoji = () => { + updateDocEmoji(doc.id, doc.title ?? '', ''); + }; + const handleDetachDoc = () => { if (!treeContext?.root) { return; @@ -82,6 +92,15 @@ export const DocTreeItemActions = ({ }, ...(!isRoot ? [ + ...(emoji && doc.abilities.partial_update + ? [ + { + label: t('Remove emoji'), + icon: , + callback: removeEmoji, + }, + ] + : []), { label: t('Move to my docs'), isDisabled: doc.user_role !== Role.OWNER, From b1d033edc9da255cf807ebd408fb03e0cf4ed8cc Mon Sep 17 00:00:00 2001 From: Olivier Laurendeau Date: Mon, 15 Sep 2025 15:54:17 +0200 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=A9=B9(frontend)=20handle=20properly?= =?UTF-8?q?=20emojis=20in=20interlinking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emoji in interlinking were not replacing the default icon when present. --- .../__tests__/app-impress/doc-editor.spec.ts | 54 ++++++++----- .../docs/doc-editor/assets/doc-found.svg | 8 +- .../InterlinkingLinkInlineContent.tsx | 22 +++++- .../Interlinking/SearchPage.tsx | 78 ++++++++++++------- 4 files changed, 105 insertions(+), 57 deletions(-) diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index 46722adce4..ccc358141b 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -13,7 +13,11 @@ import { } from './utils-common'; import { getEditor, openSuggestionMenu, writeInEditor } from './utils-editor'; import { connectOtherUserToDoc, updateShareLink } from './utils-share'; -import { createRootSubPage, navigateToPageFromTree } from './utils-sub-pages'; +import { + createRootSubPage, + getTreeRow, + navigateToPageFromTree, +} from './utils-sub-pages'; test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -728,7 +732,13 @@ test.describe('Doc Editor', () => { await verifyDocName(page, docChild2); - await page.locator('.bn-block-outer').last().fill('/'); + const treeRow = await getTreeRow(page, docChild2); + await treeRow.locator('.--docs--doc-icon').click(); + await page.getByRole('button', { name: '😀' }).first().click(); + + await navigateToPageFromTree({ page, title: docChild1 }); + + await openSuggestionMenu({ page }); await page.getByText('Link a doc').first().click(); const input = page.locator( @@ -742,6 +752,16 @@ test.describe('Doc Editor', () => { await expect(searchContainer.getByText(docChild1)).toBeVisible(); await expect(searchContainer.getByText(docChild2)).toBeVisible(); + const searchContainerRow = searchContainer + .getByRole('option') + .filter({ + hasText: docChild2, + }) + .first(); + + await expect(searchContainerRow).toContainText('😀'); + await expect(searchContainerRow.locator('svg').first()).toBeHidden(); + await input.pressSequentially('-child'); await expect(searchContainer.getByText(docChild1)).toBeVisible(); @@ -756,32 +776,30 @@ test.describe('Doc Editor', () => { await expect(searchContainer).toBeHidden(); // Wait for the interlink to be created and rendered - const editor = page.locator('.ProseMirror.bn-editor'); + const editor = await getEditor({ page }); - const interlink = editor.getByRole('button', { + const interlinkChild2 = editor.getByRole('button', { name: docChild2, }); - await expect(interlink).toBeVisible({ timeout: 10000 }); - await interlink.click(); + await expect(interlinkChild2).toBeVisible({ timeout: 10000 }); + await expect(interlinkChild2).toContainText('😀'); + await expect(interlinkChild2.locator('svg').first()).toBeHidden(); + await interlinkChild2.click(); await verifyDocName(page, docChild2); - }); - - test('it checks interlink shortcut @', async ({ page, browserName }) => { - const [randomDoc] = await createDoc(page, 'doc-interlink', browserName, 1); - - await verifyDocName(page, randomDoc); - const editor = page.locator('.bn-block-outer').last(); await editor.click(); + await page.keyboard.press('@'); + await input.fill(docChild1); + await searchContainer.getByText(docChild1).click(); - await expect( - page.locator( - "span[data-inline-content-type='interlinkingSearchInline'] input", - ), - ).toBeVisible(); + const interlinkChild1 = editor.getByRole('button', { + name: docChild1, + }); + await expect(interlinkChild1).toBeVisible({ timeout: 10000 }); + await expect(interlinkChild1.locator('svg').first()).toBeVisible(); }); test('it checks multiple big doc scroll to the top', async ({ diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-found.svg b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-found.svg index 246b090ea4..81f8629bc7 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-found.svg +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/assets/doc-found.svg @@ -1,10 +1,4 @@ - + { const { colorsTokens } = useCunninghamTheme(); + + const { emoji, titleWithoutEmoji } = getEmojiAndTitle(title); const router = useRouter(); const handleClick = (e: React.MouseEvent) => { @@ -78,9 +80,21 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => { transition: background-color 0.2s ease-in-out; `} > - - - {title} + {emoji ? ( + {emoji} + ) : ( + + )} + + {titleWithoutEmoji} ); diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx index 757579e53a..3af7f51116 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx @@ -25,6 +25,7 @@ import { import FoundPageIcon from '@/docs/doc-editor/assets/doc-found.svg'; import AddPageIcon from '@/docs/doc-editor/assets/doc-plus.svg'; import { + getEmojiAndTitle, useCreateChildDocTree, useDocStore, useTrans, @@ -236,35 +237,56 @@ export const SearchPage = ({ editor.focus(); }} - renderElement={(doc) => ( - - - { + const { emoji, titleWithoutEmoji } = getEmojiAndTitle( + doc.title || untitledDocument, + ); + + return ( + - {doc.title} - - - } - right={ - - } - /> - )} + + {emoji ? ( + {emoji} + ) : ( + + )} + + + + {titleWithoutEmoji} + +
+ } + right={ + + } + /> + ); + }} /> Date: Mon, 15 Sep 2025 18:55:35 +0200 Subject: [PATCH 5/6] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20EmojiPicker=20i?= =?UTF-8?q?n=20DocumentTitle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We can now add emojis to the document title using the EmojiPicker component. --- CHANGELOG.md | 6 + .../__tests__/app-impress/doc-header.spec.ts | 30 +++- .../__tests__/app-impress/doc-tree.spec.ts | 12 +- .../doc-editor/components/EmojiPicker.tsx | 2 +- .../docs/doc-header/components/DocTitle.tsx | 158 +++++++++++++----- .../docs/doc-header/components/DocToolBox.tsx | 19 ++- .../doc-management/assets/simple-document.svg | 2 - .../components/SimpleDocItem.tsx | 2 + .../apps/impress/src/i18n/translations.json | 32 +++- 9 files changed, 205 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23a0631b46..858f4b4018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to ### Added - ✨(frontend) create skeleton component for DocEditor #1491 +- ✨(frontend) add an EmojiPicker in the document tree and title #1381 ### Changed @@ -99,6 +100,9 @@ and this project adheres to ### Added - ✨(api) add API route to fetch document content #1206 +- ✨(frontend) doc emojis improvements #1381 + - add an EmojiPicker in the document tree and document title + - remove emoji buttons in menus ### Changed @@ -112,6 +116,8 @@ and this project adheres to - ✨unify tab focus style for better visual consistency #1341 - ♿hide decorative icons, label menus, avoid accessible name… #1362 - ♻️(tilt) use helm dev-backend chart +- 🩹(frontend) on main pages do not display leading emoji as page icon #1381 +- 🩹(frontend) handle properly emojis in interlinking #1381 ### Removed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts index 2e5a187eb1..89f0528641 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts @@ -65,16 +65,36 @@ test.describe('Doc Header', () => { page, browserName, }) => { - await createDoc(page, 'doc-update', browserName, 1); + await createDoc(page, 'doc-update-emoji', browserName, 1); + + const emojiPicker = page.locator('.--docs--doc-title').getByRole('button'); + + // Top parent should not have emoji picker + await expect(emojiPicker).toBeHidden(); + + const { name: docChild } = await createRootSubPage( + page, + browserName, + 'doc-update-emoji-child', + ); + + await verifyDocName(page, docChild); + + await expect(emojiPicker).toBeVisible(); + await emojiPicker.click({ + delay: 100, + }); + await page.getByRole('button', { name: '😀' }).first().click(); + await expect(emojiPicker).toHaveText('😀'); + const docTitle = page.getByRole('textbox', { name: 'Document title' }); - await expect(docTitle).toBeVisible(); - await docTitle.fill('👍 Hello Emoji World'); + await docTitle.fill('Hello Emoji World'); await docTitle.blur(); - await verifyDocName(page, '👍 Hello Emoji World'); + await verifyDocName(page, 'Hello Emoji World'); // Check the tree const row = await getTreeRow(page, 'Hello Emoji World'); - await expect(row.getByText('👍')).toBeVisible(); + await expect(row.getByText('😀')).toBeVisible(); }); test('it deletes the doc', async ({ page, browserName }) => { 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 8b9ce9319f..422231f714 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 @@ -340,9 +340,11 @@ test.describe('Doc Tree', () => { // 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('😀'); + + const titleEmojiPicker = page + .locator('.--docs--doc-title') + .getByRole('button'); + await expect(titleEmojiPicker).toHaveText('😀'); // Now remove the emoji using the new action await row.hover(); @@ -350,9 +352,7 @@ test.describe('Doc Tree', () => { await page.getByRole('menuitem', { name: 'Remove emoji' }).click(); await expect(row.getByText('😀')).toBeHidden(); - await expect( - page.getByRole('textbox', { name: 'Document title' }), - ).not.toContainText('😀'); + await expect(titleEmojiPicker).not.toHaveText('😀'); }); }); 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 7469b2a093..de4a5c90a2 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 DocTitleEmojiPicker = ({ doc }: DocTitleProps) => { + const { t } = useTranslation(); + const { colorsTokens } = useCunninghamTheme(); + const { emoji } = getEmojiAndTitle(doc.title ?? ''); + + return ( + + + + + ); +}; + const DocTitleInput = ({ doc }: DocTitleProps) => { const { isDesktop } = useResponsiveStore(); const { t } = useTranslation(); const { colorsTokens } = useCunninghamTheme(); - const [titleDisplay, setTitleDisplay] = useState(doc.title); - + const { spacingsTokens } = useCunninghamTheme(); + const { isTopRoot } = useDocUtils(doc); const { untitledDocument } = useTrans(); + const { emoji, titleWithoutEmoji } = getEmojiAndTitle(doc.title ?? ''); + const [titleDisplay, setTitleDisplay] = useState( + isTopRoot ? doc.title : titleWithoutEmoji, + ); const { updateDocTitle } = useDocTitleUpdate(); const handleTitleSubmit = useCallback( (inputText: string) => { - const sanitizedTitle = updateDocTitle(doc, inputText.trim()); - setTitleDisplay(sanitizedTitle); + if (isTopRoot) { + const sanitizedTitle = updateDocTitle(doc, inputText); + setTitleDisplay(sanitizedTitle); + } else { + const sanitizedTitle = updateDocTitle( + doc, + emoji ? `${emoji} ${inputText}` : inputText, + ); + const { titleWithoutEmoji: sanitizedTitleWithoutEmoji } = + getEmojiAndTitle(sanitizedTitle); + + setTitleDisplay(sanitizedTitleWithoutEmoji); + } }, - [doc, updateDocTitle], + [updateDocTitle, doc, emoji, isTopRoot], ); const handleKeyDown = (e: React.KeyboardEvent) => { @@ -72,43 +131,62 @@ const DocTitleInput = ({ doc }: DocTitleProps) => { }; useEffect(() => { - setTitleDisplay(doc.title); - }, [doc]); + setTitleDisplay(isTopRoot ? doc.title : titleWithoutEmoji); + }, [doc.title, isTopRoot, titleWithoutEmoji]); return ( - - - handleTitleSubmit(event.target.textContent || '') - } - $color={colorsTokens['greyscale-1000']} - $minHeight="40px" - $padding={{ right: 'big' }} - $css={css` - &[contenteditable='true']:empty:not(:focus):before { - content: '${untitledDocument}'; - color: grey; - pointer-events: none; - font-style: italic; + + {isTopRoot && ( + + + ); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx index 95a4244169..bc9708d38e 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-header/components/DocToolBox.tsx @@ -20,9 +20,11 @@ import { KEY_DOC, KEY_LIST_DOC, ModalRemoveDoc, + getEmojiAndTitle, useCopyDocLink, useCreateFavoriteDoc, useDeleteFavoriteDoc, + useDocTitleUpdate, useDocUtils, useDuplicateDoc, } from '@/docs/doc-management'; @@ -49,7 +51,7 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { const treeContext = useTreeContext(); const queryClient = useQueryClient(); const router = useRouter(); - const { isChild } = useDocUtils(doc); + const { isChild, isTopRoot } = useDocUtils(doc); const { spacingsTokens, colorsTokens } = useCunninghamTheme(); @@ -83,6 +85,10 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { }); }, [selectHistoryModal.isOpen, queryClient]); + // Emoji Management + const { emoji } = getEmojiAndTitle(doc.title ?? ''); + const { updateDocEmoji } = useDocTitleUpdate(); + const options: DropdownMenuOption[] = [ ...(isSmallMobile ? [ @@ -118,6 +124,17 @@ export const DocToolBox = ({ doc }: DocToolBoxProps) => { }, testId: `docs-actions-${doc.is_favorite ? 'unpin' : 'pin'}-${doc.id}`, }, + ...(emoji && doc.abilities.partial_update && !isTopRoot + ? [ + { + label: t('Remove emoji'), + icon: 'emoji_emotions', + callback: () => { + updateDocEmoji(doc.id, doc.title ?? '', ''); + }, + }, + ] + : []), { label: t('Version history'), icon: 'history', diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/assets/simple-document.svg b/src/frontend/apps/impress/src/features/docs/doc-management/assets/simple-document.svg index ee656f0d47..bddcff8a19 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/assets/simple-document.svg +++ b/src/frontend/apps/impress/src/features/docs/doc-management/assets/simple-document.svg @@ -1,6 +1,4 @@ ) : (