Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/impress-frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ and this project adheres to

## [Unreleased]

### Added

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

### Changed

- ⚡️(frontend) improve accessibility:
Expand Down
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,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
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,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:
Expand Down
1 change: 1 addition & 0 deletions src/frontend/apps/impress/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import * as Y from 'yjs';

import { LinkReach, LinkRole, Role } from '../types';
import {
base64ToBlocknoteXmlFragment,
base64ToYDoc,
currentDocRole,
getDocLinkReach,
getDocLinkRole,
getEmojiAndTitle,
} from '../utils';

// Mock Y.js
jest.mock('yjs', () => ({
Doc: jest.fn().mockImplementation(() => ({
getXmlFragment: jest.fn().mockReturnValue('mocked-xml-fragment'),
})),
applyUpdate: jest.fn(),
}));

describe('doc-management utils', () => {
beforeEach(() => {
jest.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: jest.fn() };

(Y.Doc as jest.Mock).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: jest.fn() };

(Y.Doc as jest.Mock).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: jest.fn().mockReturnValue('mocked-xml-fragment'),
};

(Y.Doc as jest.Mock).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');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Text } from '@/components';

type DocIconProps = {
emoji?: string | null;
defaultIcon: React.ReactNode;
iconSize?: 'sm' | 'lg';
iconVariation?:
| '500'
| '400'
| 'text'
| '1000'
| '000'
| '100'
| '200'
| '300'
| '600'
| '700'
| '800'
| '900';
iconWeight?: '400' | '500' | '600' | '700' | '800' | '900';
};

export const DocIcon = ({
emoji,
defaultIcon,
iconSize = 'sm',
iconVariation = '1000',
iconWeight = '400',
}: DocIconProps) => {
if (emoji) {
return (
<Text
$size={iconSize}
$variation={iconVariation}
$weight={iconWeight}
aria-hidden="true"
aria-label="Document emoji icon"
>
{emoji}
</Text>
);
}

return <>{defaultIcon}</>;
};
Loading
Loading