diff --git a/src/constants/translations/en/offer-page.json b/src/constants/translations/en/offer-page.json index 023cac3c0..499fe6e4d 100644 --- a/src/constants/translations/en/offer-page.json +++ b/src/constants/translations/en/offer-page.json @@ -12,7 +12,12 @@ "buttonTitles": { "tutor": "Create offer", "student": "Create request" - } + }, + "extendedSuccessMessage": { + "tutor": "You successfuly created an offer!;You can review all your offers on My Offers page.", + "student": "You successfuly created a request!;You can review all your requests on My Requests page." + }, + "seeAll": "See all" }, "editOffer": { "title": { diff --git a/src/constants/translations/uk/offer-page.json b/src/constants/translations/uk/offer-page.json index 401dd5805..51b31f16b 100644 --- a/src/constants/translations/uk/offer-page.json +++ b/src/constants/translations/uk/offer-page.json @@ -12,7 +12,12 @@ "buttonTitles": { "tutor": "Створити пропозицію", "student": "Створити запит" - } + }, + "extendedSuccessMessage": { + "tutor": "Ви успішно створили пропозицію!;Ви можете переглянути всі свої пропозиції на сторінці \"Мої пропозиції\".", + "student": "Ви успішно створили запит!;Ви можете переглянути всі свої запити на сторінці \"Мої запити\"." + }, + "seeAll": "Переглянути всі" }, "editOffer": { "title": { diff --git a/src/containers/layout/app-snackbar/AppSnackbar.styles.ts b/src/containers/layout/app-snackbar/AppSnackbar.styles.ts new file mode 100644 index 000000000..db1911ea2 --- /dev/null +++ b/src/containers/layout/app-snackbar/AppSnackbar.styles.ts @@ -0,0 +1,13 @@ +export const styles = { + alert: { + color: 'basic.white' + }, + action: { + p: '4px 8px 0 30px', + cursor: 'pointer' + }, + secondMessage: { + fontSize: '12px', + fontWeight: '300' + } +} diff --git a/src/containers/layout/app-snackbar/AppSnackbar.tsx b/src/containers/layout/app-snackbar/AppSnackbar.tsx index f8d17a690..d72bf172e 100644 --- a/src/containers/layout/app-snackbar/AppSnackbar.tsx +++ b/src/containers/layout/app-snackbar/AppSnackbar.tsx @@ -2,22 +2,49 @@ import { useAppDispatch, useAppSelector } from '~/hooks/use-redux' import Snackbar from '@mui/material/Snackbar' import Alert from '@mui/material/Alert' import Box from '@mui/material/Box' + import { closeAlert, snackbarSelector } from '~/redux/features/snackbarSlice' import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' +import { styles } from '~/containers/layout/app-snackbar/AppSnackbar.styles' const AppSnackbar = () => { - const { isOpened, message, duration, severity } = + const { isOpened, message, duration, severity, isExtended, route } = useAppSelector(snackbarSelector) const { t } = useTranslation() - const dispatch = useAppDispatch() + const navigate = useNavigate() const handleClose = () => dispatch(closeAlert()) const translatedMessage = typeof message === 'string' ? t(message) : t(message.text, message.options) + const actionBody = translatedMessage + .split(', ') + .map((line) => {line}) + + const handleButtonClick = () => { + navigate(route!) + handleClose() + } + + const actionButton = ( + + {t('offerPage.createOffer.seeAll')} + + ) + + const [firstMessage, secondMessage] = translatedMessage.split(';') + + const extendedBody = ( + <> + {firstMessage} + {secondMessage} + + ) + return ( { onClose={handleClose} open={isOpened} > - - {translatedMessage.split(', ').map((line) => ( - {line} - ))} + + {isExtended ? extendedBody : actionBody} ) diff --git a/src/containers/offer-page/create-or-edit-offer/CreateOrEditOffer.constants.ts b/src/containers/offer-page/create-or-edit-offer/CreateOrEditOffer.constants.ts index 220827126..31f8b22f9 100644 --- a/src/containers/offer-page/create-or-edit-offer/CreateOrEditOffer.constants.ts +++ b/src/containers/offer-page/create-or-edit-offer/CreateOrEditOffer.constants.ts @@ -6,6 +6,7 @@ export const getInitialValues = (offer: Offer | null) => ({ subject: offer?.subject._id ?? '', proficiencyLevel: offer?.proficiencyLevel ?? [], languages: offer?.languages ?? [], + enrolledUsers: [], title: offer?.title ?? '', description: offer?.description ?? '', price: offer?.price.toString() ?? '', diff --git a/src/containers/offer-page/create-or-edit-offer/CreateOrEditOffer.tsx b/src/containers/offer-page/create-or-edit-offer/CreateOrEditOffer.tsx index 4d09093f9..551028bb2 100644 --- a/src/containers/offer-page/create-or-edit-offer/CreateOrEditOffer.tsx +++ b/src/containers/offer-page/create-or-edit-offer/CreateOrEditOffer.tsx @@ -26,7 +26,6 @@ import { ButtonVariantEnum, ComponentEnum, CreateOrUpdateOfferData, - ErrorResponse, Offer, OfferActionsEnum, ServiceFunction, @@ -35,7 +34,6 @@ import { } from '~/types' import { styles } from '~/containers/offer-page/OfferPage.styles' import { openAlert } from '~/redux/features/snackbarSlice' -import { getErrorKey } from '~/utils/get-error-key' interface CreateOrUpdateOfferProps { existingOffer?: Offer | null @@ -61,22 +59,28 @@ const CreateOrEditOffer: FC = ({ ? OfferActionsEnum.Edit : OfferActionsEnum.Create - const onResponseError = (error?: ErrorResponse) => { - dispatch( - openAlert({ - severity: snackbarVariants.error, - message: getErrorKey(error) - }) - ) - } const onResponse = (response: Offer | null) => { + const isHash = hash === '#offer' + dispatch( - openAlert({ - severity: snackbarVariants.success, - message: `offerPage.${offerAction}.successMessage` - }) + openAlert( + isHash + ? { + severity: snackbarVariants.success, + message: `offerPage.createOffer.extendedSuccessMessage.${userRole}`, + duration: 10000, + isExtended: true, + route: authRoutes.myOffers.path + } + : { + severity: snackbarVariants.success, + message: `offerPage.${offerAction}.successMessage` + } + ) ) + closeDrawer() + if (hash == '#offer') { navigate(`${authRoutes.myProfile.path}#complete`) updateOffer!(true) @@ -97,8 +101,7 @@ const CreateOrEditOffer: FC = ({ service, fetchOnMount: false, defaultResponse: null, - onResponse, - onResponseError + onResponse }) const { diff --git a/src/redux/features/snackbarSlice.ts b/src/redux/features/snackbarSlice.ts index cd02ddd62..b83988a79 100644 --- a/src/redux/features/snackbarSlice.ts +++ b/src/redux/features/snackbarSlice.ts @@ -16,12 +16,16 @@ interface SnackbarState { severity: AlertColor message: SnackbarMessage duration: number + isExtended?: boolean + route?: string } interface SnackbarOpenParams { severity: AlertColor message: SnackbarMessage duration?: number + isExtended?: boolean + route?: string } type OpenSnackbarAction = PayloadAction @@ -30,7 +34,9 @@ const initialState: SnackbarState = { isOpened: false, severity: 'info', message: '', - duration: 0 + duration: 0, + isExtended: false, + route: '' } const snackbarSlice = createSlice({ @@ -42,6 +48,8 @@ const snackbarSlice = createSlice({ state.severity = action.payload.severity state.message = action.payload.message state.duration = action.payload.duration || 4000 + state.isExtended = action.payload.isExtended ?? false + state.route = action.payload.route ?? '' }, closeAlert: (state) => { state.isOpened = false diff --git a/tests/unit/containers/layout/app-snackbar/AppSnackbarExtended.spec.jsx b/tests/unit/containers/layout/app-snackbar/AppSnackbarExtended.spec.jsx new file mode 100644 index 000000000..7d855003e --- /dev/null +++ b/tests/unit/containers/layout/app-snackbar/AppSnackbarExtended.spec.jsx @@ -0,0 +1,67 @@ +import { screen, fireEvent, waitFor } from '@testing-library/react' +import { renderWithProviders } from '~tests/test-utils' +import LoginDialog from '~/containers/guest-home-page/login-dialog/LoginDialog' +import { TestSnackbar } from '~tests/test-utils' +import { vi } from 'vitest' +import { useAppSelector } from '~/hooks/use-redux' + +const preloadedState = { + appMain: { loading: false, authLoading: false, userRole: '', error: '' } +} +const unwrap = vi.fn().mockRejectedValue({ data: { code: 'error' } }) +const loginUser = vi.fn().mockReturnValue({ unwrap }) + +vi.mock('~/containers/guest-home-page/google-button/GoogleButton', () => ({ + __esModule: true, + default: function () { + return + } +})) + +vi.mock('~/services/auth-service', async () => { + return { + __esModule: true, + authService: { + endpoint: { matchFulfilled: vi.fn(), matchPending: vi.fn() } + }, + useLoginMutation: () => [loginUser] + } +}) + +vi.mock('~/hooks/use-redux', async () => { + const actual = await vi.importActual('~/hooks/use-redux') + return { + ...actual, + useAppSelector: vi.fn() + } +}) + +describe('snackbar with extended', () => { + beforeEach(async () => { + vi.mocked(useAppSelector).mockReturnValue({ + isOpened: true, + message: 'Test message; Additional info', + duration: 3000, + severity: 'info', + isExtended: true, + route: '/test-route' + }) + + renderWithProviders( + + + , + preloadedState + ) + }) + + it('should render extended content when isExtended is true', async () => { + const mainMessage = await screen.findByText('Test message') + const additionalInfo = await screen.findByText('Additional info') + const actionButton = await screen.findByText('offerPage.createOffer.seeAll') + + expect(mainMessage).toBeInTheDocument() + expect(additionalInfo).toBeInTheDocument() + expect(actionButton).toBeInTheDocument() + }) +}) diff --git a/tests/unit/containers/offer-page/create-or-edit-offer/CreateOrEditOffer.spec.jsx b/tests/unit/containers/offer-page/create-or-edit-offer/CreateOrEditOffer.spec.jsx new file mode 100644 index 000000000..858f3e885 --- /dev/null +++ b/tests/unit/containers/offer-page/create-or-edit-offer/CreateOrEditOffer.spec.jsx @@ -0,0 +1,123 @@ +import { screen, fireEvent, waitFor } from '@testing-library/react' +import { configureStore } from '@reduxjs/toolkit' + +import { renderWithProviders } from '~tests/test-utils' +import CreateOffer from '~/containers/offer-page/create-offer/CreateOffer' +import snackbarReducer, { openAlert } from '~/redux/features/snackbarSlice' +import { snackbarVariants } from '~/constants' +import { expect } from 'vitest' +import reducer from '~/redux/reducer' + +const mockDispatch = vi.fn() +const mockCloseDrawer = vi.fn() +const mockNavigate = vi.fn() + +vi.mock('~/hooks/use-axios', async () => { + const actual = await vi.importActual('~/hooks/use-axios') + return { + ...actual, + useAxios: vi.fn() + } +}) + +vi.mock('~/redux/features/snackbarSlice', async () => { + const actual = await vi.importActual('~/redux/features/snackbarSlice') + return { + ...actual, + openAlert: vi.fn() + } +}) + +vi.mock('~/hooks/use-redux', async () => { + const actual = await vi.importActual('~/hooks/use-redux') + return { + ...actual, + useAppDispatch: () => mockDispatch, + useAppSelector: vi.fn(() => ({ userRole: 'tutor' })) + } +}) + +vi.mock('~/services/offer-service', async () => { + const actual = await vi.importActual('~/services/offer-service') + const mockCreateOffer = vi.fn() + return { + ...actual, + OfferService: { + createOffer: mockCreateOffer + } + } +}) + +vi.mock('react-router-dom', async () => ({ + ...(await vi.importActual('react-router-dom')), + useNavigate: () => mockNavigate +})) + +const store = configureStore({ + reducer: { + appMain: reducer, + snackbar: snackbarReducer + } +}) + +describe('CreateOrEditOffer', () => { + beforeEach(() => { + renderWithProviders(, { + store + }) + }) + + afterEach(() => { + vi.clearAllMocks() + mockDispatch.mockReset() + }) + + it('should call a dispatch and navigate on successful response', async () => { + const { OfferService } = await import('~/services/offer-service') + OfferService.createOffer.mockResolvedValue({}) + + const saveButton = screen.getByRole('button', { + name: /offerPage.createOffer.buttonTitles.tutor/i + }) + fireEvent.click(saveButton) + + waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith( + openAlert({ + severity: snackbarVariants.success, + message: 'offerPage.createOffer.successMessage' + }) + ) + expect(mockCloseDrawer).toHaveBeenCalled() + expect(mockNavigate).toHaveBeenCalledWith( + expect.stringMatching(/^\/offer-details/) + ) + }) + }) + + it('should call different dispatch and navigate with #offer on successful response', () => { + vi.mock('react-router-dom', async () => ({ + ...(await vi.importActual('react-router-dom')), + useLocation: () => ({ hash: '#offer' }) + })) + + const saveButton = screen.getByRole('button', { + name: /offerPage.createOffer.buttonTitles.tutor/i + }) + fireEvent.click(saveButton) + + waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith( + openAlert({ + severity: snackbarVariants.success, + message: 'offerPage.createOffer.extendedSuccessMessage.tutor', + duration: 10000, + isExtended: true, + route: '/my-offers' + }) + ) + expect(mockCloseDrawer).toHaveBeenCalled() + expect(mockNavigate).toHaveBeenCalledWith('/my-profile#complete') + }) + }) +})