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-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/e2e/__tests__/app-impress/doc-header.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts index ff2754434e..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 @@ -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('/'); @@ -65,25 +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 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/doc-tree.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-tree.spec.ts index 6247cbc64e..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 @@ -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,58 @@ 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', + ); + + 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(); + + // Verify the emoji is updated in the tree and in the document title + await expect(row.getByText('😀')).toBeVisible(); + + const titleEmojiPicker = page + .locator('.--docs--doc-title') + .getByRole('button'); + await expect(titleEmojiPicker).toHaveText('😀'); + + // 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(titleEmojiPicker).not.toHaveText('😀'); + }); }); test.describe('Doc Tree: Inheritance', () => { 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-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 @@ - + void; onEmojiSelect: ({ native }: { native: string }) => void; + withOverlay?: boolean; } export const EmojiPicker = ({ emojiData, onClickOutside, onEmojiSelect, + withOverlay = false, }: EmojiPickerProps) => { const { i18n } = useTranslation(); - return ( + const pickerContent = ( ); + + if (withOverlay) { + return ( + <> + {/* Overlay transparent pour fermer en cliquant à l'extérieur */} +
+ {pickerContent} + + ); + } + + return pickerContent; }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx index 1bed38055e..9027ec69dc 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/CalloutBlock.tsx @@ -122,6 +122,7 @@ const CalloutComponent = ({ emojiData={emojidata} onClickOutside={onClickOutside} onEmojiSelect={onEmojiSelect} + withOverlay={true} /> )} diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts index 9614838837..65814e96df 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-blocks/index.ts @@ -1,4 +1,5 @@ export * from './AccessibleImageBlock'; export * from './CalloutBlock'; +export { default as emojidata } from './initEmojiCallout'; export * from './PdfBlock'; export * from './UploadLoaderBlock'; diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/InterlinkingLinkInlineContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/InterlinkingLinkInlineContent.tsx index 4bc0747269..8f5d30a337 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/InterlinkingLinkInlineContent.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/InterlinkingLinkInlineContent.tsx @@ -7,7 +7,7 @@ import { css } from 'styled-components'; import { BoxButton, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; import SelectedPageIcon from '@/docs/doc-editor/assets/doc-selected.svg'; -import { useDoc } from '@/docs/doc-management'; +import { getEmojiAndTitle, useDoc } from '@/docs/doc-management'; export const InterlinkingLinkInlineContent = createReactInlineContentSpec( { @@ -52,6 +52,8 @@ interface LinkSelectedProps { } const LinkSelected = ({ url, title }: LinkSelectedProps) => { 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={ + + } + /> + ); + }} /> { ); }; +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 treeContext = useTreeContext(); - + 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 { 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) { + if (isTopRoot) { + const sanitizedTitle = updateDocTitle(doc, inputText); setTitleDisplay(sanitizedTitle); - updateDoc({ id: doc.id, title: sanitizedTitle }); + } else { + const sanitizedTitle = updateDocTitle( + doc, + emoji ? `${emoji} ${inputText}` : inputText, + ); + const { titleWithoutEmoji: sanitizedTitleWithoutEmoji } = + getEmojiAndTitle(sanitizedTitle); + + setTitleDisplay(sanitizedTitleWithoutEmoji); } }, - [doc.id, doc.title, updateDoc], + [updateDocTitle, doc, emoji, isTopRoot], ); const handleKeyDown = (e: React.KeyboardEvent) => { @@ -105,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 @@ void; + withEmojiPicker?: boolean; }; export const DocIcon = ({ @@ -11,22 +21,102 @@ 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/SimpleDocItem.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/SimpleDocItem.tsx index 27b31cd1ab..45e412561d 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 +89,7 @@ export const SimpleDocItem = ({ $css={ItemTextCss} data-testid="doc-title" > - {displayTitle} + {doc.title || untitledDocument} {(!isDesktop || showAccesses) && ( ({ + 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/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, diff --git a/src/frontend/apps/impress/src/i18n/translations.json b/src/frontend/apps/impress/src/i18n/translations.json index 2347f5925a..e05bf74450 100644 --- a/src/frontend/apps/impress/src/i18n/translations.json +++ b/src/frontend/apps/impress/src/i18n/translations.json @@ -70,6 +70,7 @@ "Document access mode": "Doare moned ar restr", "Document accessible to any connected person": "Restr a c'hall bezañ tizhet gant ne vern piv a vefe kevreet", "Document duplicated successfully!": "Restr eilet gant berzh!", + "Document emoji": "Emoju ar restr", "Document owner": "Perc'henn ar restr", "Document role text": "Testenn rol ar restr", "Document sections": "Kevrennoù ar restr", @@ -175,6 +176,7 @@ "Reader": "Lenner", "Reading": "Lenn hepken", "Remove access": "Dilemel ar moned", + "Remove emoji": "Dilemel ar emoju", "Rename": "Adenvel", "Rephrase": "Adformulenniñ", "Request access": "Goulenn mont e-barzh", @@ -296,6 +298,7 @@ "Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Pages: Ihr neuer Begleiter für eine effiziente, intuitive und sichere Zusammenarbeit bei Dokumenten.", "Document accessible to any connected person": "Dokument für jeden angemeldeten Benutzer zugänglich", "Document duplicated successfully!": "Dokument erfolgreich dupliziert!", + "Document emoji": "Dokument-Emoji", "Document owner": "Besitzer des Dokuments", "Docx": "Docx", "Download": "Herunterladen", @@ -378,6 +381,7 @@ "Reader": "Leser", "Reading": "Lesen", "Remove access": "Zugriff entziehen", + "Remove emoji": "Emoji entfernen", "Rename": "Umbenennen", "Rephrase": "Umformulieren", "Request access": "Zugriff anfragen", @@ -496,6 +500,7 @@ "Docs transforms your documents into knowledge bases thanks to subpages, powerful search and the ability to pin your important documents.": "Docs transforma sus documentos en bases de conocimiento gracias a las subpáginas, una potente herramienta de búsqueda y la capacidad de marcar como favorito sus documentos más importantes.", "Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Docs: su nuevo compañero para colaborar en documentos de forma eficiente, intuitiva y segura.", "Document accessible to any connected person": "Documento accesible a cualquier persona conectada", + "Document emoji": "Emoji del documento", "Document owner": "Propietario del documento", "Docx": "Docx", "Download": "Descargar", @@ -565,6 +570,7 @@ "Quick search input": "Entrada de búsqueda rápida", "Reader": "Lector", "Reading": "Lectura", + "Remove emoji": "Eliminar emoji", "Rename": "Cambiar el nombre", "Rephrase": "Reformular", "Request access": "Solicitar acceso", @@ -694,6 +700,7 @@ "Document accessible to any connected person": "Document accessible à toute personne connectée", "Document deleted": "Document supprimé", "Document duplicated successfully!": "Document dupliqué avec succès !", + "Document emoji": "Emoji du document", "Document owner": "Propriétaire du document", "Document role text": "Texte du rôle du document", "Document sections": "Sections du document", @@ -806,6 +813,7 @@ "Reader": "Lecteur", "Reading": "Lecture seule", "Remove access": "Supprimer l'accès", + "Remove emoji": "Supprimer l'emoji", "Rename": "Renommer", "Rephrase": "Reformuler", "Request access": "Demander l'accès", @@ -930,6 +938,7 @@ "Docs transforms your documents into knowledge bases thanks to subpages, powerful search and the ability to pin your important documents.": "Docs trasforma i tuoi documenti in piattaforme di conoscenza grazie alle sotto-pagine, alla ricerca potente e alla capacità di fissare i tuoi documenti importanti.", "Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Docs: Il tuo nuovo compagno di collaborare sui documenti in modo efficiente, intuitivo e sicuro.", "Document accessible to any connected person": "Documento accessibile a qualsiasi persona collegata", + "Document emoji": "Emoji del documento", "Document owner": "Proprietario del documento", "Docx": "Docx", "Download": "Scarica", @@ -990,6 +999,7 @@ "Public document": "Documento pubblico", "Reader": "Lettore", "Reading": "Leggendo", + "Remove emoji": "Rimuovi emoji", "Rename": "Rinomina", "Rephrase": "Riformula", "Restore": "Ripristina", @@ -1103,6 +1113,7 @@ "Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Docs: Je nieuwe metgezel om efficiënt, intuïtief en veilig samen te werken aan documenten.", "Document access mode": "Document toegangsmodus", "Document accessible to any connected person": "Document is toegankelijk voor ieder verbonden persoon", + "Document emoji": "Document emoji", "Document duplicated successfully!": "Document met succes gedupliceerd!", "Document owner": "Document eigenaar", "Document role text": "Document roltekst", @@ -1214,6 +1225,7 @@ "Reader": "Lezer", "Reading": "Lezen", "Remove access": "Toegang verwijderen", + "Remove emoji": "Emoji verwijderen", "Rename": "Hernoemen", "Rephrase": "Herschrijf", "Request access": "Toegang aanvragen", @@ -1287,7 +1299,11 @@ "pdf": "PDF" } }, - "pt": { "translation": {} }, + "pt": { + "translation": { + "Remove emoji": "Remover emoji" + } + }, "ru": { "translation": { "\"{{email}}\" is already invited to the document.": "\"{{email}}\" уже имеет приглашение для этого документа.", @@ -1368,6 +1384,7 @@ "Document accessible to any connected person": "Документ доступен всем, кто присоединится", "Document deleted": "Документ удалён", "Document duplicated successfully!": "Документ успешно дублирован!", + "Document emoji": "Эмодзи документа", "Document owner": "Владелец документа", "Document role text": "Текст роли документа", "Document sections": "Разделы документа", @@ -1480,6 +1497,7 @@ "Reader": "Читатель", "Reading": "Чтение", "Remove access": "Отменить доступ", + "Remove emoji": "Убрать эмодзи", "Rename": "Переименовать", "Rephrase": "Переформулировать", "Request access": "Запрос доступа", @@ -1565,6 +1583,7 @@ "sl": { "translation": { "Load more": "Naloži več", + "Remove emoji": "Odstrani emoji", "Untitled document": "Dokument brez naslova" } }, @@ -1598,7 +1617,8 @@ "This file is flagged as unsafe.": "Denna fil är flaggad som osäker.", "Too many requests. Please wait 60 seconds.": "För många förfrågningar. Vänligen vänta 60 sekunder.", "Use as prompt": "Använd som prompt", - "Warning": "Varning" + "Warning": "Varning", + "Remove emoji": "Ta bort emoji" } }, "tr": { @@ -1625,6 +1645,7 @@ "Docs": "Docs", "Docs Logo": "Docs logosu", "Document accessible to any connected person": "Bağlanan herhangi bir kişi tarafından erişilebilen belge", + "Document emoji": "Belge emojisi", "Docx": "Docx", "Download": "İndir", "Download anyway": "Yine de indir", @@ -1668,7 +1689,8 @@ "Version history": "Sürüm geçmişi", "Warning": "Uyarı", "Write": "Yaz", - "Your {{format}} was downloaded succesfully": "{{format}} indirildi" + "Your {{format}} was downloaded succesfully": "{{format}} indirildi", + "Remove emoji": "Emoji kaldır" } }, "uk": { @@ -1751,6 +1773,7 @@ "Document accessible to any connected person": "Документ, доступний для будь-якої особи, що приєдналася", "Document deleted": "Документ видалено", "Document duplicated successfully!": "Документ успішно продубльовано!", + "Document emoji": "Емодзі документа", "Document owner": "Власник документа", "Document role text": "Текст ролі документа", "Document sections": "Розділи документу", @@ -1863,6 +1886,7 @@ "Reader": "Читач", "Reading": "Читання", "Remove access": "Вилучити доступ", + "Remove emoji": "Видалити емодзі", "Rename": "Перейменувати", "Rephrase": "Перефразувати", "Request access": "Запит доступу", @@ -2000,6 +2024,7 @@ "Docs transforms your documents into knowledge bases thanks to subpages, powerful search and the ability to pin your important documents.": "Docs 通过子页面、强大的搜索功能以及固定重要文档的能力,将您的文档转化为知识库。", "Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Docs 为您提供高效、直观且安全的文档协作解决方案。", "Document accessible to any connected person": "任何来访的人都可以访问文档", + "Document emoji": "文档表情符号", "Document owner": "文档所有者", "Document title": "文档标题", "Docx": "Doc", @@ -2076,6 +2101,7 @@ "Quick search input": "快速搜索", "Reader": "阅读者", "Reading": "阅读中", + "Remove emoji": "移除表情符号", "Rename": "重命名", "Rephrase": "改写", "Reset": "重置", 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}`]); }, }));