Skip to content

Commit d1cbdfd

Browse files
olaurendeauAntoLC
authored andcommitted
✨(frontend) use title first emoji as doc icon in tree
Implemented emoji detection system, new DocIcon component.
1 parent 0b64417 commit d1cbdfd

File tree

13 files changed

+397
-14
lines changed

13 files changed

+397
-14
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to
1111
### Added
1212

1313
- 👷(CI) add bundle size check job #1268
14+
- ✨(frontend) use title first emoji as doc icon in tree
1415

1516
### Changed
1617

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,10 @@ run-frontend-development: ## Run the frontend in development mode
406406
cd $(PATH_FRONT_IMPRESS) && yarn dev
407407
.PHONY: run-frontend-development
408408

409+
frontend-test: ## Run the frontend tests
410+
cd $(PATH_FRONT_IMPRESS) && yarn test
411+
.PHONY: frontend-test
412+
409413
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
410414
cd $(PATH_FRONT) && yarn i18n:extract
411415
.PHONY: frontend-i18n-extract

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ To start all the services, except the frontend container, you can use the follow
140140
$ make run-backend
141141
```
142142

143+
To execute frontend tests & linting only
144+
```shellscript
145+
$ make frontend-test
146+
$ make frontend-lint
147+
```
148+
143149
**Adding content**
144150

145151
You can create a basic demo site by running this command:

src/frontend/apps/e2e/__tests__/app-impress/doc-header.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,31 @@ test.describe('Doc Header', () => {
6161
await verifyDocName(page, 'Hello World');
6262
});
6363

64+
test('it updates the title doc adding a leading emoji', async ({
65+
page,
66+
browserName,
67+
}) => {
68+
await createDoc(page, 'doc-update', browserName, 1);
69+
const docTitle = page.getByRole('textbox', { name: 'doc title input' });
70+
await expect(docTitle).toBeVisible();
71+
await docTitle.fill('👍 Hello Emoji World');
72+
await docTitle.blur();
73+
await verifyDocName(page, '👍 Hello Emoji World');
74+
75+
// Check the tree
76+
const docTree = page.getByTestId('doc-tree');
77+
await expect(docTree.getByText('Hello Emoji World')).toBeVisible();
78+
await expect(docTree.getByLabel('Document emoji icon')).toBeVisible();
79+
await expect(docTree.getByLabel('Simple document icon')).toBeHidden();
80+
81+
await page.getByTestId('home-button').click();
82+
83+
// Check the documents grid
84+
const gridRow = await getGridRow(page, 'Hello Emoji World');
85+
await expect(gridRow.getByLabel('Document emoji icon')).toBeVisible();
86+
await expect(gridRow.getByLabel('Simple document icon')).toBeHidden();
87+
});
88+
6489
test('it deletes the doc', async ({ page, browserName }) => {
6590
const [randomDoc] = await createDoc(page, 'doc-delete', browserName, 1);
6691

src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,11 @@ export const getGridRow = async (page: Page, title: string) => {
136136

137137
const rows = docsGrid.getByRole('row');
138138

139-
const row = rows.filter({
140-
hasText: title,
141-
});
139+
const row = rows
140+
.filter({
141+
hasText: title,
142+
})
143+
.first();
142144

143145
await expect(row).toBeVisible();
144146

src/frontend/apps/impress/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"crisp-sdk-web": "1.0.25",
4242
"docx": "9.5.0",
4343
"emoji-mart": "5.6.0",
44+
"emoji-regex": "10.4.0",
4445
"i18next": "25.3.2",
4546
"i18next-browser-languagedetector": "8.2.0",
4647
"idb": "8.0.3",
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import * as Y from 'yjs';
3+
4+
import { LinkReach, LinkRole, Role } from '../types';
5+
import {
6+
base64ToBlocknoteXmlFragment,
7+
base64ToYDoc,
8+
currentDocRole,
9+
getDocLinkReach,
10+
getDocLinkRole,
11+
getEmojiAndTitle,
12+
} from '../utils';
13+
14+
// Mock Y.js
15+
vi.mock('yjs', () => ({
16+
Doc: vi.fn().mockImplementation(() => ({
17+
getXmlFragment: vi.fn().mockReturnValue('mocked-xml-fragment'),
18+
})),
19+
applyUpdate: vi.fn(),
20+
}));
21+
22+
describe('doc-management utils', () => {
23+
beforeEach(() => {
24+
vi.clearAllMocks();
25+
});
26+
27+
describe('currentDocRole', () => {
28+
it('should return OWNER when destroy ability is true', () => {
29+
const abilities = {
30+
destroy: true,
31+
accesses_manage: false,
32+
partial_update: false,
33+
} as any;
34+
35+
const result = currentDocRole(abilities);
36+
37+
expect(result).toBe(Role.OWNER);
38+
});
39+
40+
it('should return ADMIN when accesses_manage ability is true and destroy is false', () => {
41+
const abilities = {
42+
destroy: false,
43+
accesses_manage: true,
44+
partial_update: false,
45+
} as any;
46+
47+
const result = currentDocRole(abilities);
48+
49+
expect(result).toBe(Role.ADMIN);
50+
});
51+
52+
it('should return EDITOR when partial_update ability is true and higher abilities are false', () => {
53+
const abilities = {
54+
destroy: false,
55+
accesses_manage: false,
56+
partial_update: true,
57+
} as any;
58+
59+
const result = currentDocRole(abilities);
60+
61+
expect(result).toBe(Role.EDITOR);
62+
});
63+
64+
it('should return READER when no higher abilities are true', () => {
65+
const abilities = {
66+
destroy: false,
67+
accesses_manage: false,
68+
partial_update: false,
69+
} as any;
70+
71+
const result = currentDocRole(abilities);
72+
73+
expect(result).toBe(Role.READER);
74+
});
75+
});
76+
77+
describe('base64ToYDoc', () => {
78+
it('should convert base64 string to Y.Doc', () => {
79+
const base64String = 'dGVzdA=='; // "test" in base64
80+
const mockYDoc = { getXmlFragment: vi.fn() };
81+
82+
(Y.Doc as any).mockReturnValue(mockYDoc);
83+
84+
const result = base64ToYDoc(base64String);
85+
86+
expect(Y.Doc).toHaveBeenCalled();
87+
expect(Y.applyUpdate).toHaveBeenCalledWith(mockYDoc, expect.any(Buffer));
88+
expect(result).toBe(mockYDoc);
89+
});
90+
91+
it('should handle empty base64 string', () => {
92+
const base64String = '';
93+
const mockYDoc = { getXmlFragment: vi.fn() };
94+
95+
(Y.Doc as any).mockReturnValue(mockYDoc);
96+
97+
const result = base64ToYDoc(base64String);
98+
99+
expect(Y.Doc).toHaveBeenCalled();
100+
expect(Y.applyUpdate).toHaveBeenCalledWith(mockYDoc, expect.any(Buffer));
101+
expect(result).toBe(mockYDoc);
102+
});
103+
});
104+
105+
describe('base64ToBlocknoteXmlFragment', () => {
106+
it('should convert base64 to Blocknote XML fragment', () => {
107+
const base64String = 'dGVzdA==';
108+
const mockYDoc = {
109+
getXmlFragment: vi.fn().mockReturnValue('mocked-xml-fragment'),
110+
};
111+
112+
(Y.Doc as any).mockReturnValue(mockYDoc);
113+
114+
const result = base64ToBlocknoteXmlFragment(base64String);
115+
116+
expect(Y.Doc).toHaveBeenCalled();
117+
expect(Y.applyUpdate).toHaveBeenCalledWith(mockYDoc, expect.any(Buffer));
118+
expect(mockYDoc.getXmlFragment).toHaveBeenCalledWith('document-store');
119+
expect(result).toBe('mocked-xml-fragment');
120+
});
121+
});
122+
123+
describe('getDocLinkReach', () => {
124+
it('should return computed_link_reach when available', () => {
125+
const doc = {
126+
computed_link_reach: LinkReach.PUBLIC,
127+
link_reach: LinkReach.RESTRICTED,
128+
} as any;
129+
130+
const result = getDocLinkReach(doc);
131+
132+
expect(result).toBe(LinkReach.PUBLIC);
133+
});
134+
135+
it('should fallback to link_reach when computed_link_reach is not available', () => {
136+
const doc = {
137+
link_reach: LinkReach.AUTHENTICATED,
138+
} as any;
139+
140+
const result = getDocLinkReach(doc);
141+
142+
expect(result).toBe(LinkReach.AUTHENTICATED);
143+
});
144+
145+
it('should handle undefined computed_link_reach', () => {
146+
const doc = {
147+
computed_link_reach: undefined,
148+
link_reach: LinkReach.RESTRICTED,
149+
} as any;
150+
151+
const result = getDocLinkReach(doc);
152+
153+
expect(result).toBe(LinkReach.RESTRICTED);
154+
});
155+
});
156+
157+
describe('getDocLinkRole', () => {
158+
it('should return computed_link_role when available', () => {
159+
const doc = {
160+
computed_link_role: LinkRole.EDITOR,
161+
link_role: LinkRole.READER,
162+
} as any;
163+
164+
const result = getDocLinkRole(doc);
165+
166+
expect(result).toBe(LinkRole.EDITOR);
167+
});
168+
169+
it('should fallback to link_role when computed_link_role is not available', () => {
170+
const doc = {
171+
link_role: LinkRole.READER,
172+
} as any;
173+
174+
const result = getDocLinkRole(doc);
175+
176+
expect(result).toBe(LinkRole.READER);
177+
});
178+
179+
it('should handle undefined computed_link_role', () => {
180+
const doc = {
181+
computed_link_role: undefined,
182+
link_role: LinkRole.EDITOR,
183+
} as any;
184+
185+
const result = getDocLinkRole(doc);
186+
187+
expect(result).toBe(LinkRole.EDITOR);
188+
});
189+
});
190+
191+
describe('getEmojiAndTitle', () => {
192+
it('should extract emoji and title when emoji is present at the beginning', () => {
193+
const title = '🚀 My Awesome Document';
194+
195+
const result = getEmojiAndTitle(title);
196+
197+
expect(result.emoji).toBe('🚀');
198+
expect(result.titleWithoutEmoji).toBe('My Awesome Document');
199+
});
200+
201+
it('should handle complex emojis with modifiers', () => {
202+
const title = '👨‍💻 Developer Notes';
203+
204+
const result = getEmojiAndTitle(title);
205+
206+
expect(result.emoji).toBe('👨‍💻');
207+
expect(result.titleWithoutEmoji).toBe('Developer Notes');
208+
});
209+
210+
it('should handle emojis with skin tone modifiers', () => {
211+
const title = '👍 Great Work!';
212+
213+
const result = getEmojiAndTitle(title);
214+
215+
expect(result.emoji).toBe('👍');
216+
expect(result.titleWithoutEmoji).toBe('Great Work!');
217+
});
218+
219+
it('should return null emoji and full title when no emoji is present', () => {
220+
const title = 'Document Without Emoji';
221+
222+
const result = getEmojiAndTitle(title);
223+
224+
expect(result.emoji).toBeNull();
225+
expect(result.titleWithoutEmoji).toBe('Document Without Emoji');
226+
});
227+
228+
it('should handle empty title', () => {
229+
const title = '';
230+
231+
const result = getEmojiAndTitle(title);
232+
233+
expect(result.emoji).toBeNull();
234+
expect(result.titleWithoutEmoji).toBe('');
235+
});
236+
237+
it('should handle title with only emoji', () => {
238+
const title = '📝';
239+
240+
const result = getEmojiAndTitle(title);
241+
242+
expect(result.emoji).toBe('📝');
243+
expect(result.titleWithoutEmoji).toBe('');
244+
});
245+
246+
it('should handle title with emoji in the middle (should not extract)', () => {
247+
const title = 'My 📝 Document';
248+
249+
const result = getEmojiAndTitle(title);
250+
251+
expect(result.emoji).toBeNull();
252+
expect(result.titleWithoutEmoji).toBe('My 📝 Document');
253+
});
254+
255+
it('should handle title with multiple emojis at the beginning', () => {
256+
const title = '🚀📚 Project Documentation';
257+
258+
const result = getEmojiAndTitle(title);
259+
260+
expect(result.emoji).toBe('🚀');
261+
expect(result.titleWithoutEmoji).toBe('📚 Project Documentation');
262+
});
263+
});
264+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { useTranslation } from 'react-i18next';
2+
3+
import { Text, TextType } from '@/components';
4+
5+
type DocIconProps = TextType & {
6+
emoji?: string | null;
7+
defaultIcon: React.ReactNode;
8+
};
9+
10+
export const DocIcon = ({
11+
emoji,
12+
defaultIcon,
13+
$size = 'sm',
14+
$variation = '1000',
15+
$weight = '400',
16+
...textProps
17+
}: DocIconProps) => {
18+
const { t } = useTranslation();
19+
20+
if (!emoji) {
21+
return <>{defaultIcon}</>;
22+
}
23+
24+
return (
25+
<Text
26+
{...textProps}
27+
$size={$size}
28+
$variation={$variation}
29+
$weight={$weight}
30+
aria-hidden="true"
31+
aria-label={t('Document emoji icon')}
32+
>
33+
{emoji}
34+
</Text>
35+
);
36+
};

0 commit comments

Comments
 (0)