From d1cbdfd819aa247423b319d88d95aaeebb799768 Mon Sep 17 00:00:00 2001 From: Olivier Laurendeau Date: Fri, 29 Aug 2025 10:25:40 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8(frontend)=20use=20title=20first?= =?UTF-8?q?=20emoji=20as=20doc=20icon=20in=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented emoji detection system, new DocIcon component. --- CHANGELOG.md | 1 + Makefile | 4 + README.md | 6 + .../__tests__/app-impress/doc-header.spec.ts | 25 ++ .../e2e/__tests__/app-impress/utils-common.ts | 8 +- src/frontend/apps/impress/package.json | 1 + .../doc-management/__tests__/utils.test.tsx | 264 ++++++++++++++++++ .../doc-management/components/DocIcon.tsx | 36 +++ .../components/SimpleDocItem.tsx | 26 +- .../src/features/docs/doc-management/utils.ts | 17 ++ .../doc-tree/components/DocSubPageItem.tsx | 14 +- .../apps/impress/src/i18n/translations.json | 7 + src/frontend/yarn.lock | 2 +- 13 files changed, 397 insertions(+), 14 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/__tests__/utils.test.tsx create mode 100644 src/frontend/apps/impress/src/features/docs/doc-management/components/DocIcon.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 87d362ae0a..f6c5d55c0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to ### Added - 👷(CI) add bundle size check job #1268 +- ✨(frontend) use title first emoji as doc icon in tree ### Changed diff --git a/Makefile b/Makefile index 376a7f65e9..5f91be4cd6 100644 --- a/Makefile +++ b/Makefile @@ -406,6 +406,10 @@ run-frontend-development: ## Run the frontend in development mode cd $(PATH_FRONT_IMPRESS) && yarn dev .PHONY: run-frontend-development +frontend-test: ## Run the frontend tests + cd $(PATH_FRONT_IMPRESS) && yarn test +.PHONY: frontend-test + frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin cd $(PATH_FRONT) && yarn i18n:extract .PHONY: frontend-i18n-extract diff --git a/README.md b/README.md index c772d37442..ee3b60034b 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,12 @@ To start all the services, except the frontend container, you can use the follow $ make run-backend ``` +To execute frontend tests & linting only +```shellscript +$ make frontend-test +$ make frontend-lint +``` + **Adding content** You can create a basic demo site by running this command: 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 8547e1133e..bf65953fcd 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 @@ -61,6 +61,31 @@ test.describe('Doc Header', () => { await verifyDocName(page, 'Hello World'); }); + test('it updates the title doc adding a leading emoji', async ({ + page, + browserName, + }) => { + await createDoc(page, 'doc-update', browserName, 1); + const docTitle = page.getByRole('textbox', { name: 'doc title input' }); + await expect(docTitle).toBeVisible(); + await docTitle.fill('👍 Hello Emoji World'); + await docTitle.blur(); + 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.getByLabel('Document emoji icon')).toBeVisible(); + await expect(docTree.getByLabel('Simple document icon')).toBeHidden(); + + await page.getByTestId('home-button').click(); + + // Check the documents grid + const gridRow = await getGridRow(page, 'Hello Emoji World'); + await expect(gridRow.getByLabel('Document emoji icon')).toBeVisible(); + await expect(gridRow.getByLabel('Simple document icon')).toBeHidden(); + }); + test('it deletes the doc', async ({ page, browserName }) => { const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts index 4cbf886bb8..6b340b087c 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -136,9 +136,11 @@ export const getGridRow = async (page: Page, title: string) => { const rows = docsGrid.getByRole('row'); - const row = rows.filter({ - hasText: title, - }); + const row = rows + .filter({ + hasText: title, + }) + .first(); await expect(row).toBeVisible(); diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index a37c3491ea..72ee58e72d 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -41,6 +41,7 @@ "crisp-sdk-web": "1.0.25", "docx": "9.5.0", "emoji-mart": "5.6.0", + "emoji-regex": "10.4.0", "i18next": "25.3.2", "i18next-browser-languagedetector": "8.2.0", "idb": "8.0.3", diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/__tests__/utils.test.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/__tests__/utils.test.tsx new file mode 100644 index 0000000000..350673fb6c --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/__tests__/utils.test.tsx @@ -0,0 +1,264 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as Y from 'yjs'; + +import { LinkReach, LinkRole, Role } from '../types'; +import { + base64ToBlocknoteXmlFragment, + base64ToYDoc, + currentDocRole, + getDocLinkReach, + getDocLinkRole, + getEmojiAndTitle, +} from '../utils'; + +// Mock Y.js +vi.mock('yjs', () => ({ + Doc: vi.fn().mockImplementation(() => ({ + getXmlFragment: vi.fn().mockReturnValue('mocked-xml-fragment'), + })), + applyUpdate: vi.fn(), +})); + +describe('doc-management utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('currentDocRole', () => { + it('should return OWNER when destroy ability is true', () => { + const abilities = { + destroy: true, + accesses_manage: false, + partial_update: false, + } as any; + + const result = currentDocRole(abilities); + + expect(result).toBe(Role.OWNER); + }); + + it('should return ADMIN when accesses_manage ability is true and destroy is false', () => { + const abilities = { + destroy: false, + accesses_manage: true, + partial_update: false, + } as any; + + const result = currentDocRole(abilities); + + expect(result).toBe(Role.ADMIN); + }); + + it('should return EDITOR when partial_update ability is true and higher abilities are false', () => { + const abilities = { + destroy: false, + accesses_manage: false, + partial_update: true, + } as any; + + const result = currentDocRole(abilities); + + expect(result).toBe(Role.EDITOR); + }); + + it('should return READER when no higher abilities are true', () => { + const abilities = { + destroy: false, + accesses_manage: false, + partial_update: false, + } as any; + + const result = currentDocRole(abilities); + + expect(result).toBe(Role.READER); + }); + }); + + describe('base64ToYDoc', () => { + it('should convert base64 string to Y.Doc', () => { + const base64String = 'dGVzdA=='; // "test" in base64 + const mockYDoc = { getXmlFragment: vi.fn() }; + + (Y.Doc as any).mockReturnValue(mockYDoc); + + const result = base64ToYDoc(base64String); + + expect(Y.Doc).toHaveBeenCalled(); + expect(Y.applyUpdate).toHaveBeenCalledWith(mockYDoc, expect.any(Buffer)); + expect(result).toBe(mockYDoc); + }); + + it('should handle empty base64 string', () => { + const base64String = ''; + const mockYDoc = { getXmlFragment: vi.fn() }; + + (Y.Doc as any).mockReturnValue(mockYDoc); + + const result = base64ToYDoc(base64String); + + expect(Y.Doc).toHaveBeenCalled(); + expect(Y.applyUpdate).toHaveBeenCalledWith(mockYDoc, expect.any(Buffer)); + expect(result).toBe(mockYDoc); + }); + }); + + describe('base64ToBlocknoteXmlFragment', () => { + it('should convert base64 to Blocknote XML fragment', () => { + const base64String = 'dGVzdA=='; + const mockYDoc = { + getXmlFragment: vi.fn().mockReturnValue('mocked-xml-fragment'), + }; + + (Y.Doc as any).mockReturnValue(mockYDoc); + + const result = base64ToBlocknoteXmlFragment(base64String); + + expect(Y.Doc).toHaveBeenCalled(); + expect(Y.applyUpdate).toHaveBeenCalledWith(mockYDoc, expect.any(Buffer)); + expect(mockYDoc.getXmlFragment).toHaveBeenCalledWith('document-store'); + expect(result).toBe('mocked-xml-fragment'); + }); + }); + + describe('getDocLinkReach', () => { + it('should return computed_link_reach when available', () => { + const doc = { + computed_link_reach: LinkReach.PUBLIC, + link_reach: LinkReach.RESTRICTED, + } as any; + + const result = getDocLinkReach(doc); + + expect(result).toBe(LinkReach.PUBLIC); + }); + + it('should fallback to link_reach when computed_link_reach is not available', () => { + const doc = { + link_reach: LinkReach.AUTHENTICATED, + } as any; + + const result = getDocLinkReach(doc); + + expect(result).toBe(LinkReach.AUTHENTICATED); + }); + + it('should handle undefined computed_link_reach', () => { + const doc = { + computed_link_reach: undefined, + link_reach: LinkReach.RESTRICTED, + } as any; + + const result = getDocLinkReach(doc); + + expect(result).toBe(LinkReach.RESTRICTED); + }); + }); + + describe('getDocLinkRole', () => { + it('should return computed_link_role when available', () => { + const doc = { + computed_link_role: LinkRole.EDITOR, + link_role: LinkRole.READER, + } as any; + + const result = getDocLinkRole(doc); + + expect(result).toBe(LinkRole.EDITOR); + }); + + it('should fallback to link_role when computed_link_role is not available', () => { + const doc = { + link_role: LinkRole.READER, + } as any; + + const result = getDocLinkRole(doc); + + expect(result).toBe(LinkRole.READER); + }); + + it('should handle undefined computed_link_role', () => { + const doc = { + computed_link_role: undefined, + link_role: LinkRole.EDITOR, + } as any; + + const result = getDocLinkRole(doc); + + expect(result).toBe(LinkRole.EDITOR); + }); + }); + + describe('getEmojiAndTitle', () => { + it('should extract emoji and title when emoji is present at the beginning', () => { + const title = '🚀 My Awesome Document'; + + const result = getEmojiAndTitle(title); + + expect(result.emoji).toBe('🚀'); + expect(result.titleWithoutEmoji).toBe('My Awesome Document'); + }); + + it('should handle complex emojis with modifiers', () => { + const title = '👨‍💻 Developer Notes'; + + const result = getEmojiAndTitle(title); + + expect(result.emoji).toBe('👨‍💻'); + expect(result.titleWithoutEmoji).toBe('Developer Notes'); + }); + + it('should handle emojis with skin tone modifiers', () => { + const title = '👍 Great Work!'; + + const result = getEmojiAndTitle(title); + + expect(result.emoji).toBe('👍'); + expect(result.titleWithoutEmoji).toBe('Great Work!'); + }); + + it('should return null emoji and full title when no emoji is present', () => { + const title = 'Document Without Emoji'; + + const result = getEmojiAndTitle(title); + + expect(result.emoji).toBeNull(); + expect(result.titleWithoutEmoji).toBe('Document Without Emoji'); + }); + + it('should handle empty title', () => { + const title = ''; + + const result = getEmojiAndTitle(title); + + expect(result.emoji).toBeNull(); + expect(result.titleWithoutEmoji).toBe(''); + }); + + it('should handle title with only emoji', () => { + const title = '📝'; + + const result = getEmojiAndTitle(title); + + expect(result.emoji).toBe('📝'); + expect(result.titleWithoutEmoji).toBe(''); + }); + + it('should handle title with emoji in the middle (should not extract)', () => { + const title = 'My 📝 Document'; + + const result = getEmojiAndTitle(title); + + expect(result.emoji).toBeNull(); + expect(result.titleWithoutEmoji).toBe('My 📝 Document'); + }); + + it('should handle title with multiple emojis at the beginning', () => { + const title = '🚀📚 Project Documentation'; + + const result = getEmojiAndTitle(title); + + expect(result.emoji).toBe('🚀'); + expect(result.titleWithoutEmoji).toBe('📚 Project Documentation'); + }); + }); +}); 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 new file mode 100644 index 0000000000..0bcc3aa5a4 --- /dev/null +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/DocIcon.tsx @@ -0,0 +1,36 @@ +import { useTranslation } from 'react-i18next'; + +import { Text, TextType } from '@/components'; + +type DocIconProps = TextType & { + emoji?: string | null; + defaultIcon: React.ReactNode; +}; + +export const DocIcon = ({ + emoji, + defaultIcon, + $size = 'sm', + $variation = '1000', + $weight = '400', + ...textProps +}: DocIconProps) => { + const { t } = useTranslation(); + + if (!emoji) { + return <>{defaultIcon}; + } + + return ( + + ); +}; 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 ff1524b651..672947acd9 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,12 +4,14 @@ import { css } from 'styled-components'; import { Box, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { Doc, useTrans } from '@/docs/doc-management'; +import { Doc, getEmojiAndTitle, useTrans } from '@/docs/doc-management'; import { useResponsiveStore } from '@/stores'; 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; @@ -36,6 +38,10 @@ export const SimpleDocItem = ({ const { isDesktop } = useResponsiveStore(); const { untitledDocument } = useTrans(); + const { emoji, titleWithoutEmoji: displayTitle } = getEmojiAndTitle( + doc.title || untitledDocument, + ); + return ( ) : ( - - {doc.title || untitledDocument} + {displayTitle} {(!isDesktop || showAccesses) && ( { export const getDocLinkRole = (doc: Doc): LinkRole => { return doc.computed_link_role ?? doc.link_role; }; + +export const getEmojiAndTitle = (title: string) => { + // Use emoji-regex library for comprehensive emoji detection compatible with ES5 + const regex = emojiRegex(); + + // Check if the title starts with an emoji + const match = title.match(regex); + + if (match && title.startsWith(match[0])) { + const emoji = match[0]; + const titleWithoutEmoji = title.substring(emoji.length).trim(); + return { emoji, titleWithoutEmoji }; + } + + return { emoji: null, titleWithoutEmoji: title }; +}; 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 073824cf58..408e110654 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 @@ -9,7 +9,12 @@ import { css } from 'styled-components'; import { Box, Icon, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { Doc, useTrans } from '@/features/docs/doc-management'; +import { + Doc, + 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'; @@ -38,6 +43,9 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { const router = useRouter(); const { togglePanel } = useLeftPanelStore(); + const { emoji, titleWithoutEmoji } = getEmojiAndTitle(doc.title || ''); + const displayTitle = titleWithoutEmoji || untitledDocument; + const afterCreate = (createdDoc: Doc) => { const actualChildren = node.data.children ?? []; @@ -122,7 +130,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps) => { $minHeight="24px" > - + } $size="sm" /> ) => { `} > - {doc.title || untitledDocument} + {displayTitle} {doc.nb_accesses_direct >= 1 && ( Date: Fri, 15 Aug 2025 18:19:44 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=A7(frontend)=20increase=20test-e2?= =?UTF-8?q?e-other-browser=20action=20timeout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was failing at 20min, increase the timeout to 30 min --- .github/workflows/impress-frontend.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/impress-frontend.yml b/.github/workflows/impress-frontend.yml index 70b611a41e..7c8a3caad1 100644 --- a/.github/workflows/impress-frontend.yml +++ b/.github/workflows/impress-frontend.yml @@ -101,7 +101,7 @@ jobs: test-e2e-other-browser: runs-on: ubuntu-latest needs: test-e2e-chromium - timeout-minutes: 20 + timeout-minutes: 30 steps: - name: Checkout repository uses: actions/checkout@v4