(frontend) use title first emoji as doc icon in tree

Implemented emoji detection system, new DocIcon component.
This commit is contained in:
Olivier Laurendeau
2025-08-29 10:25:40 +02:00
committed by Anthony LC
parent 0b64417058
commit d1cbdfd819
13 changed files with 397 additions and 14 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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);

View File

@@ -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();

View File

@@ -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",

View File

@@ -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');
});
});
});

View File

@@ -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 (
<Text
{...textProps}
$size={$size}
$variation={$variation}
$weight={$weight}
aria-hidden="true"
aria-label={t('Document emoji icon')}
>
{emoji}
</Text>
);
};

View File

@@ -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 (
<Box
$direction="row"
@@ -61,23 +67,29 @@ export const SimpleDocItem = ({
color={colorsTokens['primary-500']}
/>
) : (
<SimpleFileIcon
aria-hidden="true"
aria-label={t('Simple document icon')}
color={colorsTokens['primary-500']}
<DocIcon
emoji={emoji}
defaultIcon={
<SimpleFileIcon
aria-hidden="true"
aria-label={t('Simple document icon')}
color={colorsTokens['primary-500']}
/>
}
$size="25px"
/>
)}
</Box>
<Box $justify="center" $overflow="auto">
<Text
aria-describedby="doc-title"
aria-label={doc.title}
aria-label={displayTitle}
$size="sm"
$variation="1000"
$weight="500"
$css={ItemTextCss}
>
{doc.title || untitledDocument}
{displayTitle}
</Text>
{(!isDesktop || showAccesses) && (
<Box

View File

@@ -1,3 +1,4 @@
import emojiRegex from 'emoji-regex';
import * as Y from 'yjs';
import { Doc, LinkReach, LinkRole, Role } from './types';
@@ -30,3 +31,19 @@ export const getDocLinkReach = (doc: Doc): LinkReach => {
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 };
};

View File

@@ -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<Doc>) => {
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<Doc>) => {
$minHeight="24px"
>
<Box $width="16px" $height="16px">
<SubPageIcon />
<DocIcon emoji={emoji} defaultIcon={<SubPageIcon />} $size="sm" />
</Box>
<Box
@@ -137,7 +145,7 @@ export const DocSubPageItem = (props: TreeViewNodeProps<Doc>) => {
`}
>
<Text $css={ItemTextCss} $size="sm" $variation="1000">
{doc.title || untitledDocument}
{displayTitle}
</Text>
{doc.nb_accesses_direct >= 1 && (
<Icon

View File

@@ -42,6 +42,7 @@
"Docs Logo": "Logo Docs",
"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 icon": "Ikon emojis ar restr",
"Document owner": "Perc'henn ar restr",
"Document sections": "Rannoù an teul",
"Docx": "Docx",
@@ -218,6 +219,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 icon": "Emojisymbol für das Dokument",
"Document owner": "Besitzer des Dokuments",
"Document sections": "Dokumentabschnitte",
"Docx": "Docx",
@@ -443,6 +445,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 icon": "Emoji para el documento",
"Document owner": "Propietario del documento",
"Document sections": "Secciones del documento",
"Docx": "Docx",
@@ -643,6 +646,7 @@
"Docs: Your new companion to collaborate on documents efficiently, intuitively, and securely.": "Docs : Votre nouveau compagnon pour collaborer sur des documents efficacement, intuitivement et en toute sécurité.",
"Document accessible to any connected person": "Document accessible à toute personne connectée",
"Document duplicated successfully!": "Document dupliqué avec succès !",
"Document emoji icon": "Emoji pour le document",
"Document owner": "Propriétaire du document",
"Document sections": "Sections des documents",
"Docx": "Docx",
@@ -860,6 +864,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 icon": "Emoji per il documento",
"Document owner": "Proprietario del documento",
"Docx": "Docx",
"Download": "Scarica",
@@ -1019,6 +1024,7 @@
"Docs transforms your documents into knowledge bases thanks to subpages, powerful search and the ability to pin your important documents.": "Documentatie transformeert uw documenten in een kennisbasis, dankzij subpagina's, krachtig zoeken en de mogelijkheid om uw belangrijke documenten te pinnen.",
"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 accessible to any connected person": "Document is toegankelijk voor ieder verbonden persoon",
"Document emoji icon": "Emoji voor het document",
"Document owner": "Document eigenaar",
"Document sections": "Document secties",
"Docx": "Docx",
@@ -1314,6 +1320,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 icon": "文档表情符号",
"Document owner": "文档所有者",
"Docx": "Doc",
"Download": "下载",

View File

@@ -7624,7 +7624,7 @@ emoji-mart@5.6.0, emoji-mart@^5.6.0:
resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-5.6.0.tgz#71b3ed0091d3e8c68487b240d9d6d9a73c27f023"
integrity sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==
emoji-regex@^10.3.0:
emoji-regex@10.4.0, emoji-regex@^10.3.0:
version "10.4.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.4.0.tgz#03553afea80b3975749cfcb36f776ca268e413d4"
integrity sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==