From 6c2f33448e60d3bf5649c8fb87357b0bff2264b8 Mon Sep 17 00:00:00 2001 From: Anatoliy Sarakhman Date: Mon, 18 Sep 2023 10:37:18 +0300 Subject: [PATCH 1/2] imlemented emoji picker for chat --- package-lock.json | 16 +++ package.json | 2 + .../translations/en/breadcrumbs.json | 1 - .../translations/ua/breadcrumbs.json | 1 - .../chat-text-area/ChatTextArea.styles.ts | 25 +++- .../chat/chat-text-area/ChatTextArea.tsx | 116 ++++++++++++++---- .../chat-dialog-window/ChatDialogWindow.tsx | 16 +-- src/pages/chat/Chat.styles.ts | 2 +- src/pages/chat/Chat.tsx | 17 +-- src/router/constants/crumbs.ts | 5 - src/router/routes/authRouter.tsx | 7 +- 11 files changed, 140 insertions(+), 68 deletions(-) diff --git a/package-lock.json b/package-lock.json index 11ebb8021..034a3126b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "dependencies": { "@date-io/date-fns": "^2.16.0", + "@emoji-mart/data": "^1.1.2", + "@emoji-mart/react": "^1.1.1", "@emotion/react": "^11.7.0", "@emotion/styled": "^11.6.0", "@mui/icons-material": "^5.6.2", @@ -2243,6 +2245,20 @@ "node": ">=10.0.0" } }, + "node_modules/@emoji-mart/data": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.1.2.tgz", + "integrity": "sha512-1HP8BxD2azjqWJvxIaWAMyTySeZY0Osr83ukYjltPVkNXeJvTz7yDrPLBtnrD5uqJ3tg4CcLuuBW09wahqL/fg==" + }, + "node_modules/@emoji-mart/react": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@emoji-mart/react/-/react-1.1.1.tgz", + "integrity": "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==", + "peerDependencies": { + "emoji-mart": "^5.2", + "react": "^16.8 || ^17 || ^18" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.10.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.10.5.tgz", diff --git a/package.json b/package.json index 3274905d2..5495b67e7 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "private": true, "dependencies": { "@date-io/date-fns": "^2.16.0", + "@emoji-mart/data": "^1.1.2", + "@emoji-mart/react": "^1.1.1", "@emotion/react": "^11.7.0", "@emotion/styled": "^11.6.0", "@mui/icons-material": "^5.6.2", diff --git a/src/constants/translations/en/breadcrumbs.json b/src/constants/translations/en/breadcrumbs.json index 87b5cce9d..817547734 100644 --- a/src/constants/translations/en/breadcrumbs.json +++ b/src/constants/translations/en/breadcrumbs.json @@ -11,7 +11,6 @@ "myCooperations": "My cooperations", "myResources": "Мy resources", "newLesson": "New lesson", - "chat": "Chat", "myOffers": "My offers", "editLesson": "Edit lesson", "lessonDetails": "Lesson details", diff --git a/src/constants/translations/ua/breadcrumbs.json b/src/constants/translations/ua/breadcrumbs.json index f56cee423..363835ae0 100644 --- a/src/constants/translations/ua/breadcrumbs.json +++ b/src/constants/translations/ua/breadcrumbs.json @@ -11,7 +11,6 @@ "myCooperations": "Мої співпраці", "myResources": "Мої ресурси", "newLesson": "Новий урок", - "chat": "Чат", "myOffers": "Мої пропозиції", "editLesson": "Редагування уроку", "lessonDetails": "Деталі уроку", diff --git a/src/containers/chat/chat-text-area/ChatTextArea.styles.ts b/src/containers/chat/chat-text-area/ChatTextArea.styles.ts index 38631916e..e6cf241e8 100644 --- a/src/containers/chat/chat-text-area/ChatTextArea.styles.ts +++ b/src/containers/chat/chat-text-area/ChatTextArea.styles.ts @@ -8,15 +8,26 @@ export const styles = { columnGap: '16px', px: '16px' }, + testAreaWithPicker: { flex: 1, position: 'relative' }, + emojiPicker: { + position: 'absolute', + bottom: '90px', + right: 0, + zIndex: 2, + '& > div *': { + maxHeight: '300px', + height: '100%' + } + }, textAreaWrapper: { flex: 1, backgroundColor: 'basic.white', borderRadius: '6px', - p: { xs: '16px 10px', sm: '16px 32px' }, - '& .MuiInputBase-root': { mt: 0 } + p: { xs: '16px 10px', sm: '16px 32px' } }, textArea: { userSelect: 'none', + '& .MuiInputBase-root': { p: '4px 0px 5px', mt: 0 }, '& :hover': { '&::-webkit-scrollbar-track, &::-webkit-scrollbar-thumb': { visibility: 'hidden' @@ -24,10 +35,12 @@ export const styles = { } }, textAreaLabel: (value: string) => ({ - visibility: value ? VisibilityEnum.Hidden : VisibilityEnum.Visible, - color: palette.primary[300], - top: '-6px', - left: '14px' + shrink: false, + style: { + visibility: value ? VisibilityEnum.Hidden : VisibilityEnum.Visible, + color: palette.primary[300], + top: '-6px' + } }), icon: { width: '32px', height: '32px', color: 'primary.800' } } diff --git a/src/containers/chat/chat-text-area/ChatTextArea.tsx b/src/containers/chat/chat-text-area/ChatTextArea.tsx index bb78d918c..ac97b8b68 100644 --- a/src/containers/chat/chat-text-area/ChatTextArea.tsx +++ b/src/containers/chat/chat-text-area/ChatTextArea.tsx @@ -1,10 +1,22 @@ -import { FC, ChangeEvent } from 'react' +import { + FC, + ChangeEvent, + useState, + useRef, + Dispatch, + SetStateAction +} from 'react' +import Picker from '@emoji-mart/react' +import data from '@emoji-mart/data' +import { SxProps } from '@mui/material' import { TextFieldProps } from '@mui/material/TextField' import Box from '@mui/material/Box' import IconButton from '@mui/material/IconButton' import SendIcon from '@mui/icons-material/Send' -import { SxProps } from '@mui/material' +import MoodIcon from '@mui/icons-material/Mood' +import AttachFileIcon from '@mui/icons-material/AttachFile' +import useBreakpoints from '~/hooks/use-breakpoints' import AppTextArea from '~/components/app-text-area/AppTextArea' import { styles } from '~/containers/chat/chat-text-area/ChatTextArea.styles' @@ -13,43 +25,105 @@ import { spliceSx } from '~/utils/helper-functions' interface ChatTextAreaProps extends Omit { value: string - onChange: (e: ChangeEvent) => void + setValue: Dispatch> onClick: () => void sx?: { textAreaWrapper?: SxProps container?: SxProps icon?: SxProps } + emojiPickerProps?: { perLine: number } } const ChatTextArea: FC = ({ value, - onChange, + setValue, onClick, maxRows = 6, minRows = 1, sx = {}, + emojiPickerProps, ...props }) => { + const { isMobile } = useBreakpoints() + const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false) + const inputRef = useRef(null) + + const onChange = (e: ChangeEvent) => + setValue(e.target.value) + + const onEmojiSelect = ({ native: emoji }: { native: string }) => { + if (inputRef.current) { + const input = inputRef.current + const start = input.selectionStart ?? 0 + + setValue((prev) => { + const updatedValue = prev.split('') + updatedValue.splice(start, 0, emoji) + return updatedValue.join('') + }) + + const newCursorPosition = start + emoji.length + + input.setSelectionRange(newCursorPosition, newCursorPosition) + input.focus() + } + } + + const onOpenPicker = () => setIsEmojiPickerOpen(true) + const onClosePicker = () => setIsEmojiPickerOpen(false) + + const endAdornment = ( + <> + + + + + + + + ) + return ( - + + {isEmojiPickerOpen && ( + + + + )} + + + + diff --git a/src/containers/offer-page/chat-dialog-window/ChatDialogWindow.tsx b/src/containers/offer-page/chat-dialog-window/ChatDialogWindow.tsx index fdc521292..dae649dd4 100644 --- a/src/containers/offer-page/chat-dialog-window/ChatDialogWindow.tsx +++ b/src/containers/offer-page/chat-dialog-window/ChatDialogWindow.tsx @@ -1,11 +1,4 @@ -import { - useCallback, - useEffect, - ChangeEvent, - useState, - useRef, - FC -} from 'react' +import { useCallback, useEffect, useState, useRef, FC } from 'react' import { useTranslation } from 'react-i18next' import SimpleBar from 'simplebar-react' import Box from '@mui/material/Box' @@ -39,10 +32,6 @@ const ChatDialogWindow: FC = ({ chatInfo }) => { const scrollRef = useRef(null) const { setChatInfo } = useChatContext() - const onTextAreaChange = (e: ChangeEvent) => { - setTextAreaValue(e.target.value) - } - const getMessages = useCallback( () => messageService.getMessages({ chatId: chatInfo.chatId }), [chatInfo.chatId] @@ -137,10 +126,11 @@ const ChatDialogWindow: FC = ({ chatInfo }) => { )} null} + setValue={setTextAreaValue} sx={styles.textArea} value={textAreaValue} /> diff --git a/src/pages/chat/Chat.styles.ts b/src/pages/chat/Chat.styles.ts index bc0b3caf9..f78b88364 100644 --- a/src/pages/chat/Chat.styles.ts +++ b/src/pages/chat/Chat.styles.ts @@ -6,7 +6,7 @@ import { TypographyVariantEnum } from '~/types' export const styles = { root: { position: 'relative', - mb: '20px', + mb: 0, '& .sash-module_sash__K-9lB': { '&:before': { backgroundColor: 'transparent', diff --git a/src/pages/chat/Chat.tsx b/src/pages/chat/Chat.tsx index dc3bb8e5b..255de65ed 100644 --- a/src/pages/chat/Chat.tsx +++ b/src/pages/chat/Chat.tsx @@ -1,11 +1,4 @@ -import { - useState, - useCallback, - useEffect, - useRef, - ChangeEvent, - MouseEvent -} from 'react' +import { useState, useCallback, useEffect, useRef, MouseEvent } from 'react' import { useTranslation } from 'react-i18next' import { Allotment } from 'allotment' import SimpleBar from 'simplebar-react' @@ -61,10 +54,6 @@ const Chat = () => { setIsSidebarOpen(event) } - const onTextAreaChange = (e: ChangeEvent) => { - setTextAreaValue(e.target.value) - } - const onMessagesResponse = useCallback( (response: MessageInterface[]) => setMessages(response), [setMessages] @@ -165,7 +154,7 @@ const Chat = () => { ) return ( - + {isMobile && ( { void onMessageSend()} + setValue={setTextAreaValue} value={textAreaValue} /> diff --git a/src/router/constants/crumbs.ts b/src/router/constants/crumbs.ts index c47f68734..28c9058c8 100644 --- a/src/router/constants/crumbs.ts +++ b/src/router/constants/crumbs.ts @@ -82,11 +82,6 @@ export const userProfile = ({ data }: { data: UserResponse }) => ({ name: `${data.firstName} ${data.lastName}` }) -export const chat = { - name: t('breadCrumbs.chat'), - path: authRoutes.chat.route -} - export const newQuestion = { name: t('breadCrumbs.newQuestion'), path: authRoutes.myResources.newQuestion.route diff --git a/src/router/routes/authRouter.tsx b/src/router/routes/authRouter.tsx index c76157751..3e81bc49c 100644 --- a/src/router/routes/authRouter.tsx +++ b/src/router/routes/authRouter.tsx @@ -4,7 +4,6 @@ import { Route } from 'react-router-dom' import { authRoutes } from '~/router/constants/authRoutes' import { categories, - chat, editProfile, findOffers, myCooperations, @@ -75,11 +74,7 @@ export const authRouter = ( loader={userProfileLoader} path={authRoutes.userProfile.route} /> - } - handle={{ crumb: chat }} - path={authRoutes.chat.route} - /> + } path={authRoutes.chat.route} /> } handle={{ crumb: myProfile }} From d1c7f3a42beb1bbc95ccf64b4ace25054b728b6d Mon Sep 17 00:00:00 2001 From: Anatoliy Sarakhman Date: Mon, 18 Sep 2023 14:37:15 +0300 Subject: [PATCH 2/2] added coverage --- package-lock.json | 6 ++ package.json | 1 + .../chat/chat-text-area/ChatTextArea.tsx | 2 +- .../chat/chat-text-area/ChatTextArea.spec.jsx | 85 +++++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 tests/unit/pages/chat/chat-text-area/ChatTextArea.spec.jsx diff --git a/package-lock.json b/package-lock.json index 034a3126b..8dbd2849c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "allotment": "^1.19.2", "axios": "^0.24.0", "date-fns": "^2.29.3", + "emoji-mart": "^5.5.2", "i18next": "^21.5.4", "nuka-carousel": "^6.0.3", "react": "^17.0.2", @@ -15651,6 +15652,11 @@ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==", "dev": true }, + "node_modules/emoji-mart": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.5.2.tgz", + "integrity": "sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A==" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", diff --git a/package.json b/package.json index 5495b67e7..38a315797 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "allotment": "^1.19.2", "axios": "^0.24.0", "date-fns": "^2.29.3", + "emoji-mart": "^5.5.2", "i18next": "^21.5.4", "nuka-carousel": "^6.0.3", "react": "^17.0.2", diff --git a/src/containers/chat/chat-text-area/ChatTextArea.tsx b/src/containers/chat/chat-text-area/ChatTextArea.tsx index ac97b8b68..8646e8966 100644 --- a/src/containers/chat/chat-text-area/ChatTextArea.tsx +++ b/src/containers/chat/chat-text-area/ChatTextArea.tsx @@ -88,7 +88,7 @@ const ChatTextArea: FC = ({ {isEmojiPickerOpen && ( - + ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn() +})) + +global.IntersectionObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn() +})) + +const mockRef = { + current: { focus: vi.fn(), setSelectionRange: vi.fn(), selectionStart: 5 } +} +const mockSetValue = vi.fn() +const mockOnClick = vi.fn() + +vi.mock('react', async () => { + const actual = await vi.importActual('react') + return { + ...actual, + useRef: () => vi.fn().mockReturnValue(mockRef) + } +}) + +const props = { + value: 'new message', + label: 'Enter some text', + setValue: mockSetValue, + onClick: mockOnClick +} + +describe('ChatTextArea component test', () => { + const breakpointsData = { isMobile: false } + beforeEach(() => { + useBreakpoints.mockImplementation(() => breakpointsData) + + renderWithProviders() + }) + + it('should render input with emoji icon', async () => { + const input = screen.getByText('Enter some text') + const emojiIcon = screen.getByTestId('MoodIcon') + + expect(input).toBeInTheDocument() + expect(emojiIcon).toBeInTheDocument() + }) + + it('should open emoji picker', async () => { + const emojiIcon = screen.getByTestId('MoodIcon') + + fireEvent.mouseEnter(emojiIcon) + + const picker = screen.getByTestId('emoji-picker') + + expect(picker).toBeInTheDocument() + }) + + it('should send new message and clear input', async () => { + const input = screen.getByLabelText('Enter some text') + + userEvent.type(input, 'new message') + + expect(input.value).toBe('new message') + + const sendBtn = screen.getByTestId('SendIcon') + + fireEvent.click(sendBtn) + + expect(mockOnClick).toHaveBeenCalled() + }) +})