Skip to content

Commit f437f27

Browse files
committed
✨(frontend) use title first emoji as doc icon in tree
Implemented emoji detection system, new DocIcon component.
1 parent d0c9de9 commit f437f27

File tree

9 files changed

+390
-21
lines changed

9 files changed

+390
-21
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ and this project adheres to
88

99
## [Unreleased]
1010

11+
### Added
12+
13+
- ✨(frontend) use title first emoji as doc icon in tree
14+
1115
### Changed
1216

1317
- ⚡️(frontend) improve accessibility:

Makefile

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

345+
frontend-test: ## Run the frontend tests
346+
cd $(PATH_FRONT_IMPRESS) && yarn test
347+
.PHONY: frontend-test
348+
345349
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
346350
cd $(PATH_FRONT) && yarn i18n:extract
347351
.PHONY: frontend-i18n-extract

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

0 commit comments

Comments
 (0)