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
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 (
+
+ {emoji}
+
+ );
+};
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 (
) : (
-
+ }
+ $size="25px"
/>
)}
- {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 && (