diff --git a/src/certificates/data/thunks.ts b/src/certificates/data/thunks.ts index 213a61e245..a53e490431 100644 --- a/src/certificates/data/thunks.ts +++ b/src/certificates/data/thunks.ts @@ -1,9 +1,6 @@ /* istanbul ignore file */ import { RequestStatus } from '../../data/constants'; -import { - hideProcessingNotification, - showProcessingNotification, -} from '../../generic/processing-notification/data/slice'; +import { showToastOutsideReact, closeToastOutsideReact } from '../../generic/toast-context'; import { handleResponseErrors } from '../../generic/saving-error-alert'; import { NOTIFICATION_MESSAGES } from '../../constants'; import { @@ -45,7 +42,7 @@ export function fetchCertificates(courseId) { export function createCourseCertificate(courseId, certificate) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + showToastOutsideReact(NOTIFICATION_MESSAGES.saving); try { const certificateValues = await createCertificate(courseId, certificate); @@ -55,7 +52,7 @@ export function createCourseCertificate(courseId, certificate) { } catch (error) { return handleResponseErrors(error, dispatch, updateSavingStatus); } finally { - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); } }; } @@ -63,7 +60,7 @@ export function createCourseCertificate(courseId, certificate) { export function updateCourseCertificate(courseId, certificate) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + showToastOutsideReact(NOTIFICATION_MESSAGES.saving); try { const certificatesValues = await updateCertificate(courseId, certificate); @@ -73,7 +70,7 @@ export function updateCourseCertificate(courseId, certificate) { } catch (error) { return handleResponseErrors(error, dispatch, updateSavingStatus); } finally { - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); } }; } @@ -81,7 +78,7 @@ export function updateCourseCertificate(courseId, certificate) { export function deleteCourseCertificate(courseId, certificateId) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting)); + showToastOutsideReact(NOTIFICATION_MESSAGES.deleting); try { await deleteCertificate(courseId, certificateId); @@ -91,7 +88,7 @@ export function deleteCourseCertificate(courseId, certificateId) { } catch (error) { return handleResponseErrors(error, dispatch, updateSavingStatus); } finally { - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); } }; } @@ -99,10 +96,9 @@ export function deleteCourseCertificate(courseId, certificateId) { export function updateCertificateActiveStatus(courseId, path, activationStatus) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - - dispatch(showProcessingNotification( + showToastOutsideReact( activationStatus ? ACTIVATION_MESSAGES.activating : ACTIVATION_MESSAGES.deactivating, - )); + ); try { await updateActiveStatus(path, activationStatus); @@ -112,7 +108,7 @@ export function updateCertificateActiveStatus(courseId, path, activationStatus) } catch (error) { return handleResponseErrors(error, dispatch, updateSavingStatus); } finally { - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); } }; } diff --git a/src/certificates/layout/MainLayout.jsx b/src/certificates/layout/MainLayout.jsx index 8fde8ad017..0c08ee63d3 100644 --- a/src/certificates/layout/MainLayout.jsx +++ b/src/certificates/layout/MainLayout.jsx @@ -3,7 +3,6 @@ import { Container, Layout } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; import { SavingErrorAlert } from '../../generic/saving-error-alert'; -import ProcessingNotification from '../../generic/processing-notification'; import SubHeader from '../../generic/sub-header/SubHeader'; import messages from '../messages'; import CertificatesSidebar from './certificates-sidebar/CertificatesSidebar'; @@ -16,8 +15,6 @@ const MainLayout = ({ courseId, showHeaderButtons, children }) => { const { errorMessage, savingStatus, - isShowProcessingNotification, - processingNotificationTitle, } = useLayout(); return ( @@ -50,10 +47,6 @@ const MainLayout = ({ courseId, showHeaderButtons, children }) => {
- { const savingStatus = useSelector(getSavingStatus); const errorMessage = useSelector(getErrorMessage); - const { - isShow: isShowProcessingNotification, - title: processingNotificationTitle, - } = useSelector(getProcessingNotification); - useEffect(() => { if (savingStatus === RequestStatus.SUCCESSFUL) { window.scrollTo({ top: 0, behavior: 'smooth' }); @@ -23,8 +17,6 @@ const useLayout = () => { return { errorMessage, savingStatus, - isShowProcessingNotification, - processingNotificationTitle, }; }; diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index 95d75627c9..c68eea2c6b 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -20,10 +20,8 @@ import { useLocation } from 'react-router-dom'; import { CourseAuthoringOutlineSidebarSlot } from '@src/plugin-slots/CourseAuthoringOutlineSidebarSlot'; import { LoadingSpinner } from '@src/generic/Loading'; -import { getProcessingNotification } from '@src/generic/processing-notification/data/selectors'; import { RequestStatus } from '@src/data/constants'; import SubHeader from '@src/generic/sub-header/SubHeader'; -import ProcessingNotification from '@src/generic/processing-notification'; import InternetConnectionAlert from '@src/generic/internet-connection-alert'; import DeleteModal from '@src/generic/delete-modal/DeleteModal'; import ConfigureModal from '@src/generic/configure-modal/ConfigureModal'; @@ -31,7 +29,6 @@ import { UnlinkModal } from '@src/generic/unlink-modal'; import AlertMessage from '@src/generic/alert-message'; import getPageHeadTitle from '@src/generic/utils'; import CourseOutlineHeaderActionsSlot from '@src/plugin-slots/CourseOutlineHeaderActionsSlot'; -import { NOTIFICATION_MESSAGES } from '@src/constants'; import { XBlock } from '@src/data/types'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import LegacyLibContentBlockAlert from '@src/course-libraries/LegacyLibContentBlockAlert'; @@ -71,8 +68,6 @@ const CourseOutline = () => { const { courseId, courseUsageKey, - handleAddBlock, - handleAddAndOpenUnit, isUnlinkModalOpen, closeUnlinkModal, currentSelection, @@ -95,7 +90,6 @@ const CourseOutline = () => { isDisabledReindexButton, isHighlightsModalOpen, isConfigureModalOpen, - isConfigureOpPending, isDeleteModalOpen, closeHighlightsModal, handleConfigureModalClose, @@ -108,17 +102,14 @@ const CourseOutline = () => { handleEnableHighlightsSubmit, handleInternetConnectionFailed, handleOpenHighlightsModal, - isSectionHighlightsUpdatePending, handleHighlightsFormSubmit, handleConfigureItemSubmit, handleDeleteItemSubmit, handleDuplicateSectionSubmit, handleDuplicateSubsectionSubmit, handleDuplicateUnitSubmit, - isDuplicatingItem, handleVideoSharingOptionChange, handlePasteClipboardClick, - isPasting, notificationDismissUrl, discussionsSettings, discussionsIncontextLearnmoreUrl, @@ -161,11 +152,6 @@ const CourseOutline = () => { setSections(() => [...sectionsList]); }; - const { - isShow: isShowProcessingNotification, - title: processingNotificationTitle, - } = useSelector(getProcessingNotification); - const { data: currentItemData } = useCourseItemData(currentSelection?.currentId); const itemCategory = currentItemData?.category || ''; @@ -518,20 +504,6 @@ const CourseOutline = () => { />
- Promise), ) => { + const { + showToast, + closeToast, + } = useToastContext(); const queryClient = useQueryClient(); const { setData } = useScrollState(courseKey); const dispatch = useDispatch(); return useMutation({ mutationFn: (variables: CreateCourseXBlockMutationProps) => createCourseXblock(variables), + onMutate: () => { + showToast(NOTIFICATION_MESSAGES.saving, undefined, 15000); + }, onSuccess: async (data: { locator: string; }, variables) => { + closeToast(); await callback?.(data.locator, variables.parentLocator); queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(data.locator)), @@ -126,6 +136,9 @@ export const useCreateCourseBlock = ( dispatch(addSection(newBlock)); } }, + onError: () => { + closeToast(); + }, }); }; @@ -226,9 +239,17 @@ export const useDeleteCourseItem = () => { export const useConfigureSection = () => { const queryClient = useQueryClient(); + const { + showToast, + closeToast, + } = useToastContext(); return useMutation({ mutationFn: (variables: ConfigureSectionData & ParentIds) => configureCourseSection(variables), + onMutate: () => { + showToast(NOTIFICATION_MESSAGES.saving, undefined, 15000); + }, onSettled: (_data, _err, variables) => { + closeToast(); queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.sectionId)), }); @@ -239,9 +260,17 @@ export const useConfigureSection = () => { export const useConfigureSubsection = () => { const queryClient = useQueryClient(); + const { + showToast, + closeToast, + } = useToastContext(); return useMutation({ mutationFn: (variables: ConfigureSubsectionData & ParentIds) => configureCourseSubsection(variables), + onMutate: () => { + showToast(NOTIFICATION_MESSAGES.saving, undefined, 15000); + }, onSettled: (_data, _err, variables) => { + closeToast(); queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.itemId)) }); invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e)); }, @@ -250,9 +279,17 @@ export const useConfigureSubsection = () => { export const useConfigureUnit = () => { const queryClient = useQueryClient(); + const { + showToast, + closeToast, + } = useToastContext(); return useMutation({ mutationFn: (variables: ConfigureUnitData & ParentIds) => configureCourseUnit(variables), + onMutate: () => { + showToast(NOTIFICATION_MESSAGES.saving, undefined, 15000); + }, onSettled: (_data, _err, variables) => { + closeToast(); queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.unitId)) }); invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e)); }, @@ -260,13 +297,21 @@ export const useConfigureUnit = () => { }; export const useUpdateCourseSectionHighlights = () => { + const { + showToast, + closeToast, + } = useToastContext(); const queryClient = useQueryClient(); return useMutation({ mutationFn: (variables: { sectionId: string; highlights: string[]; } & ParentIds) => updateCourseSectionHighlights(variables.sectionId, variables.highlights), + onMutate: () => { + showToast(NOTIFICATION_MESSAGES.saving, undefined, 15000); + }, onSettled: (_data, _err, variables) => { + closeToast(); queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.sectionId)), }); @@ -276,6 +321,10 @@ export const useUpdateCourseSectionHighlights = () => { }; export const useDuplicateItem = (courseKey: string) => { + const { + showToast, + closeToast, + } = useToastContext(); const queryClient = useQueryClient(); const dispatch = useDispatch(); const { setData } = useScrollState(courseKey); @@ -284,6 +333,9 @@ export const useDuplicateItem = (courseKey: string) => { itemId: string; parentId: string; } & ParentIds) => duplicateCourseItem(variables.itemId, variables.parentId), + onMutate: () => { + showToast(NOTIFICATION_MESSAGES.saving, undefined, 15000); + }, onSuccess: async (data, variables) => { await invalidateParentQueries(queryClient, variables); // add duplicated section to store, subsection and unit are handled by invalidateParentQueries @@ -294,6 +346,9 @@ export const useDuplicateItem = (courseKey: string) => { // scroll to newly added block setData({ id: data.locator }); }, + onSettled: () => { + closeToast(); + }, }); }; @@ -307,6 +362,10 @@ export const usePasteFileNotices = createGlobalState( ); export const usePasteItem = (courseId?: string) => { + const { + showToast, + closeToast, + } = useToastContext(); const queryClient = useQueryClient(); const { setData: setScrollState } = useScrollState(courseId); const { setData } = usePasteFileNotices(courseId); @@ -314,6 +373,9 @@ export const usePasteItem = (courseId?: string) => { mutationFn: (variables: { parentLocator: string; } & ParentIds) => pasteBlock(variables.parentLocator), + onMutate: () => { + showToast(NOTIFICATION_MESSAGES.saving, undefined, 15000); + }, onSuccess: async (data, variables) => { await invalidateParentQueries(queryClient, variables); // set pasteFileNotices @@ -321,5 +383,8 @@ export const usePasteItem = (courseId?: string) => { // scroll to pasted block setScrollState({ id: data.locator }); }, + onSettled: () => { + closeToast(); + }, }); }; diff --git a/src/course-outline/data/thunk.ts b/src/course-outline/data/thunk.ts index ae7664c975..187220122b 100644 --- a/src/course-outline/data/thunk.ts +++ b/src/course-outline/data/thunk.ts @@ -1,10 +1,7 @@ import { logError } from '@edx/frontend-platform/logging'; import { RequestStatus } from '@src/data/constants'; import { NOTIFICATION_MESSAGES } from '@src/constants'; -import { - hideProcessingNotification, - showProcessingNotification, -} from '@src/generic/processing-notification/data/slice'; +import { showToastOutsideReact, closeToastOutsideReact } from '@src/generic/toast-context'; import { getCourseBestPracticesChecklist, getCourseLaunchChecklist, @@ -142,16 +139,17 @@ export function fetchCourseBestPracticesQuery({ export function enableCourseHighlightsEmailsQuery(courseId: string) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + showToastOutsideReact(NOTIFICATION_MESSAGES.saving); try { await enableCourseHighlightsEmails(courseId); dispatch(fetchCourseOutlineIndexQuery(courseId)); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - dispatch(hideProcessingNotification()); } catch { dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } finally { + closeToastOutsideReact(); } }; } @@ -159,17 +157,17 @@ export function enableCourseHighlightsEmailsQuery(courseId: string) { export function setVideoSharingOptionQuery(courseId: string, option: string) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + showToastOutsideReact(NOTIFICATION_MESSAGES.saving); try { await setVideoSharingOption(courseId, option); dispatch(updateStatusBar({ videoSharingOptions: option })); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - dispatch(hideProcessingNotification()); } catch { dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); - dispatch(hideProcessingNotification()); + } finally { + closeToastOutsideReact(); } }; } @@ -227,20 +225,20 @@ function setBlockOrderListQuery( ) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + showToastOutsideReact(NOTIFICATION_MESSAGES.saving); try { await apiFn(parentId, blockIds).then(async (result) => { if (result) { successCallback(); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - dispatch(hideProcessingNotification()); } }); } catch { restoreCallback(); - dispatch(hideProcessingNotification()); dispatch(updateSavingStatus({ status: RequestStatus.FAILED })); + } finally { + closeToastOutsideReact(); } }; } diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 6d8197de52..dcf859f061 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -99,7 +99,7 @@ const useCourseOutline = ({ courseId }) => { const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED; - const { mutate: pasteClipboardContent, isPending: isPasting } = usePasteItem(courseId); + const { mutate: pasteClipboardContent } = usePasteItem(courseId); const handlePasteClipboardClick = (parentLocator, subsectionId, sectionId) => { pasteClipboardContent({ parentLocator, @@ -150,7 +150,6 @@ const useCourseOutline = ({ courseId }) => { const { mutate: updateCourseSectionHighlights, - isPending: isSectionHighlightsUpdatePending, } = useUpdateCourseSectionHighlights(); const handleHighlightsFormSubmit = (highlights) => { const dataToSend = Object.values(highlights).filter(Boolean); @@ -190,17 +189,13 @@ const useCourseOutline = ({ courseId }) => { const { mutate: configureCourseSection, - isPending: isSectionConfigurePending, } = useConfigureSection(); const { mutate: configureCourseSubsection, - isPending: isSubsectionConfigurePending, } = useConfigureSubsection(); const { mutate: configureCourseUnit, - isPending: isUnitConfigurePending, } = useConfigureUnit(); - const isConfigureOpPending = isSectionConfigurePending || isSubsectionConfigurePending || isUnitConfigurePending; const handleConfigureItemSubmit = (variables) => { const category = getBlockType(currentSelection.currentId); switch (category) { @@ -298,7 +293,6 @@ const useCourseOutline = ({ courseId }) => { const { mutate: duplicateItem, - isPending: isDuplicatingItem, } = useDuplicateItem(courseId); const handleDuplicateSectionSubmit = () => { duplicateItem({ @@ -407,14 +401,12 @@ const useCourseOutline = ({ courseId }) => { isConfigureModalOpen, openConfigureModal, handleConfigureModalClose, - isConfigureOpPending, headerNavigationsActions, handleEnableHighlightsSubmit, handleHighlightsFormSubmit, handleConfigureItemSubmit, statusBarData, isEnableHighlightsModalOpen, - isSectionHighlightsUpdatePending, openEnableHighlightsModal, closeEnableHighlightsModal, isInternetConnectionAlertFailed: isSavingStatusFailed, @@ -429,11 +421,9 @@ const useCourseOutline = ({ courseId }) => { handleDeleteItemSubmit, handleDuplicateSectionSubmit, handleDuplicateSubsectionSubmit, - isDuplicatingItem, handleDuplicateUnitSubmit, handleVideoSharingOptionChange, handlePasteClipboardClick, - isPasting, notificationDismissUrl, discussionsSettings, discussionsIncontextLearnmoreUrl, diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index f1eea697aa..7620d279d7 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -18,7 +18,7 @@ import { screen, } from '@src/testUtils'; import mockResult from '@src/library-authoring/__mocks__/library-search.json'; -import { IFRAME_FEATURE_POLICY } from '@src/constants'; +import { IFRAME_FEATURE_POLICY, NOTIFICATION_MESSAGES } from '@src/constants'; import { mockWaffleFlags } from '@src/data/apiHooks.mock'; import pasteComponentMessages from '@src/generic/clipboard/paste-component/messages'; import { getClipboardUrl } from '@src/generic/data/api'; @@ -79,6 +79,8 @@ import messages from './messages'; let axiosMock; let store; +let mockShowToast; +let mockCloseToast; const courseId = '123'; const blockId = '567890'; const sequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5'; @@ -147,6 +149,8 @@ describe('', () => { window.scrollTo = jest.fn(); global.localStorage.clear(); store = mocks.reduxStore; + mockShowToast = mocks.mockShowToast; + mockCloseToast = mocks.mockCloseToast; axiosMock = mocks.axiosMock; axiosMock .onGet(getClipboardUrl()) @@ -2162,26 +2166,26 @@ describe('', () => { }); it('displays processing notification on receiving post message', async () => { - const { getByText, queryByText } = render(); + render(); await waitFor(() => { simulatePostMessageEvent(messageTypes.addNewComponent); - expect(getByText(('Adding'))).toBeInTheDocument(); + expect(mockShowToast).toHaveBeenCalledWith(NOTIFICATION_MESSAGES.adding); }); await waitFor(() => { simulatePostMessageEvent(messageTypes.hideProcessingNotification); - expect(queryByText(('Adding'))).not.toBeInTheDocument(); + expect(mockCloseToast).toHaveBeenCalled(); }); await waitFor(() => { simulatePostMessageEvent(messageTypes.pasteNewComponent); - expect(getByText(('Pasting'))).toBeInTheDocument(); + expect(mockShowToast).toHaveBeenCalledWith(NOTIFICATION_MESSAGES.pasting); }); await waitFor(() => { simulatePostMessageEvent(messageTypes.hideProcessingNotification); - expect(queryByText(('Pasting'))).not.toBeInTheDocument(); + expect(mockCloseToast).toHaveBeenCalledTimes(2); }); }); diff --git a/src/course-unit/CourseUnit.tsx b/src/course-unit/CourseUnit.tsx index f60338790c..6b061728cc 100644 --- a/src/course-unit/CourseUnit.tsx +++ b/src/course-unit/CourseUnit.tsx @@ -1,5 +1,4 @@ import { useEffect } from 'react'; -import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; import type { MessageDescriptor } from 'react-intl'; import { @@ -26,13 +25,11 @@ import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import DraftIcon from '@src/generic/DraftIcon'; import { CourseAuthoringUnitSidebarSlot } from '../plugin-slots/CourseAuthoringUnitSidebarSlot'; -import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; import SubHeader from '../generic/sub-header/SubHeader'; import { RequestStatus } from '../data/constants'; import getPageHeadTitle from '../generic/utils'; import AlertMessage from '../generic/alert-message'; import { PasteComponent } from '../generic/clipboard'; -import ProcessingNotification from '../generic/processing-notification'; import { SavingErrorAlert } from '../generic/saving-error-alert'; import ConnectionErrorAlert from '../generic/ConnectionErrorAlert'; import Loading from '../generic/Loading'; @@ -223,11 +220,6 @@ const CourseUnit = () => { useScrollToLastPosition(); - const { - isShow: isShowProcessingNotification, - title: processingNotificationTitle, - } = useSelector(getProcessingNotification); - if (isLoading) { return ; } @@ -405,10 +397,6 @@ const CourseUnit = () => {
- { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + showToastOutsideReact(NOTIFICATION_MESSAGES.saving); try { await editUnitDisplayName(itemId, displayName).then(async (result) => { @@ -86,13 +83,13 @@ export function editCourseItemQuery(itemId, displayName, sequenceId) { models: courseSectionVerticalData.units || [], })); dispatch(fetchSequenceSuccess({ sequenceId })); - dispatch(hideProcessingNotification()); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); } }); } catch (error) { - dispatch(hideProcessingNotification()); handleResponseErrors(error, dispatch, updateSavingStatus); + } finally { + closeToastOutsideReact(); } }; } @@ -110,7 +107,7 @@ export function editCourseUnitVisibilityAndData( dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); dispatch(updateQueryPendingStatus(true)); const notification = getNotificationMessage(type, isVisible, true); - dispatch(showProcessingNotification(notification)); + showToastOutsideReact(notification); try { await handleCourseUnitVisibilityAndData( @@ -128,13 +125,13 @@ export function editCourseUnitVisibilityAndData( dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData)); const courseVerticalChildrenData = await getCourseContainerChildren(blockId); dispatch(updateCourseVerticalChildren(courseVerticalChildrenData)); - dispatch(hideProcessingNotification()); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); } }); } catch (error) { - dispatch(hideProcessingNotification()); handleResponseErrors(error, dispatch, updateSavingStatus); + } finally { + closeToastOutsideReact(); } }; } @@ -142,9 +139,9 @@ export function editCourseUnitVisibilityAndData( export function createNewCourseXBlock(body, callback, blockId, sendMessageToIframe) { return async (dispatch) => { if (body.stagedContent) { - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.pasting)); + showToastOutsideReact(NOTIFICATION_MESSAGES.pasting); } else { - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.adding)); + showToastOutsideReact(NOTIFICATION_MESSAGES.adding); } try { @@ -165,7 +162,7 @@ export function createNewCourseXBlock(body, callback, blockId, sendMessageToIfra } const courseVerticalChildrenData = await getCourseContainerChildren(blockId); dispatch(updateCourseVerticalChildren(courseVerticalChildrenData)); - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); if (callback) { callback(result); } else { @@ -177,7 +174,7 @@ export function createNewCourseXBlock(body, callback, blockId, sendMessageToIfra } }); } catch (error) { - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); handleResponseErrors(error, dispatch, updateSavingStatus); } }; @@ -213,18 +210,18 @@ export function fetchCourseVerticalChildrenData(itemId, isSplitTestType, skipPag export function deleteUnitItemQuery(itemId, xblockId, sendMessageToIframe) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting)); + showToastOutsideReact(NOTIFICATION_MESSAGES.deleting); try { await deleteUnitItem(xblockId); sendMessageToIframe(messageTypes.completeXBlockDeleting, { locator: xblockId }); const courseSectionVerticalData = await getVerticalData(itemId); dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData)); - dispatch(hideProcessingNotification()); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); } catch (error) { - dispatch(hideProcessingNotification()); handleResponseErrors(error, dispatch, updateSavingStatus); + } finally { + closeToastOutsideReact(); } }; } @@ -232,7 +229,7 @@ export function deleteUnitItemQuery(itemId, xblockId, sendMessageToIframe) { export function duplicateUnitItemQuery(itemId, xblockId, callback) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating)); + showToastOutsideReact(NOTIFICATION_MESSAGES.duplicating); try { const { courseKey, locator } = await duplicateUnitItem(itemId, xblockId); @@ -241,11 +238,11 @@ export function duplicateUnitItemQuery(itemId, xblockId, callback) { dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData)); const courseVerticalChildrenData = await getCourseContainerChildren(itemId); dispatch(updateCourseVerticalChildren(courseVerticalChildrenData)); - dispatch(hideProcessingNotification()); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); } catch (error) { - dispatch(hideProcessingNotification()); handleResponseErrors(error, dispatch, updateSavingStatus); + } finally { + closeToastOutsideReact(); } }; } @@ -277,7 +274,7 @@ export function patchUnitItemQuery({ }) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES[isMoving ? 'moving' : 'undoMoving'])); + showToastOutsideReact(NOTIFICATION_MESSAGES[isMoving ? 'moving' : 'undoMoving']); try { await patchUnitItem(sourceLocator, isMoving ? targetParentLocator : currentParentLocator); @@ -302,7 +299,7 @@ export function patchUnitItemQuery({ } catch (error) { handleResponseErrors(error, dispatch, updateSavingStatus); } finally { - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); } }; } @@ -310,16 +307,16 @@ export function patchUnitItemQuery({ export function updateCourseUnitSidebar(itemId) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + showToastOutsideReact(NOTIFICATION_MESSAGES.saving); try { const courseSectionVerticalData = await getVerticalData(itemId); dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData)); - dispatch(hideProcessingNotification()); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); } catch (error) { - dispatch(hideProcessingNotification()); handleResponseErrors(error, dispatch, updateSavingStatus); + } finally { + closeToastOutsideReact(); } }; } diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index c248729693..ee0ee5aed5 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -6,10 +6,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { useToggle, Sheet, StandardModal } from '@openedx/paragon'; import { useDispatch } from 'react-redux'; -import { - hideProcessingNotification, - showProcessingNotification, -} from '@src/generic/processing-notification/data/slice'; +import { useToastContext } from '@src/generic/toast-context'; import DeleteModal from '@src/generic/delete-modal/DeleteModal'; import ConfigureModal from '@src/generic/configure-modal/ConfigureModal'; import ModalIframe from '@src/generic/modal-iframe'; @@ -59,6 +56,10 @@ const XBlockContainerIframe: FC = ({ setCurrentPageKey, setSelectedComponentId, } = useUnitSidebarContext(); + const { + showToast, + closeToast, + } = useToastContext(); // Useful to reload iframe const [iframeKey, setIframeKey] = useState(0); @@ -203,13 +204,13 @@ const XBlockContainerIframe: FC = ({ const handleShowProcessingNotification = (variant: string) => { if (variant) { - dispatch(showProcessingNotification(variant)); + showToast(variant); } }; const handleHideProcessingNotification = () => { dispatch(fetchCourseVerticalChildrenData(blockId, true, true)); - dispatch(hideProcessingNotification()); + closeToast(); }; const handleRefreshIframe = () => { diff --git a/src/course-updates/CourseUpdates.tsx b/src/course-updates/CourseUpdates.tsx index 10745fdd9a..b081186876 100644 --- a/src/course-updates/CourseUpdates.tsx +++ b/src/course-updates/CourseUpdates.tsx @@ -9,8 +9,6 @@ import { import { Add as AddIcon, ErrorOutline as ErrorIcon } from '@openedx/paragon/icons'; import { useSelector } from 'react-redux'; -import { getProcessingNotification } from '@src/generic/processing-notification/data/selectors'; -import ProcessingNotification from '@src/generic/processing-notification'; import SubHeader from '@src/generic/sub-header/SubHeader'; import InternetConnectionAlert from '@src/generic/internet-connection-alert'; import ConnectionErrorAlert from '@src/generic/ConnectionErrorAlert'; @@ -53,11 +51,6 @@ const CourseUpdates = () => { handleDeleteUpdateSubmit, } = useCourseUpdates({ courseId }); - const { - isShow: isShowProcessingNotification, - title: processingNotificationTitle, - } = useSelector(getProcessingNotification); - const loadingStatuses = useSelector(getLoadingStatuses); const savingStatuses = useSelector(getSavingStatuses); const errors = useSelector(getErrors); @@ -222,12 +215,6 @@ const CourseUpdates = () => { close={closeDeleteModal} onDeleteSubmit={handleDeleteUpdateSubmit} /> - {isShowProcessingNotification && ( - - )}
diff --git a/src/course-updates/data/thunk.js b/src/course-updates/data/thunk.js index fff0993c22..56f0364be6 100644 --- a/src/course-updates/data/thunk.js +++ b/src/course-updates/data/thunk.js @@ -1,6 +1,6 @@ import { NOTIFICATION_MESSAGES } from '../../constants'; import { RequestStatus } from '../../data/constants'; -import { hideProcessingNotification, showProcessingNotification } from '../../generic/processing-notification/data/slice'; +import { showToastOutsideReact, closeToastOutsideReact } from '../../generic/toast-context'; import { getCourseUpdates, getCourseHandouts, @@ -50,16 +50,16 @@ export function createCourseUpdateQuery(courseId, data) { return async (dispatch) => { try { dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + showToastOutsideReact(NOTIFICATION_MESSAGES.saving); const courseUpdate = await createUpdate(courseId, data); dispatch(createCourseUpdate(courseUpdate)); - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); dispatch(updateSavingStatuses({ status: { createCourseUpdateQuery: RequestStatus.SUCCESSFUL }, error: { creatingUpdate: false }, })); } catch { - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); dispatch(updateSavingStatuses({ status: { createCourseUpdateQuery: RequestStatus.FAILED }, error: { creatingUpdate: true }, @@ -72,16 +72,16 @@ export function editCourseUpdateQuery(courseId, data) { return async (dispatch) => { try { dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + showToastOutsideReact(NOTIFICATION_MESSAGES.saving); const courseUpdate = await editUpdate(courseId, data); dispatch(editCourseUpdate(courseUpdate)); - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); dispatch(updateSavingStatuses({ status: { createCourseUpdateQuery: RequestStatus.SUCCESSFUL }, error: { savingUpdates: false }, })); } catch { - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); dispatch(updateSavingStatuses({ status: { createCourseUpdateQuery: RequestStatus.FAILED }, error: { savingUpdates: true }, @@ -94,16 +94,16 @@ export function deleteCourseUpdateQuery(courseId, updateId) { return async (dispatch) => { try { dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting)); + showToastOutsideReact(NOTIFICATION_MESSAGES.deleting); const courseUpdates = await deleteUpdate(courseId, updateId); dispatch(deleteCourseUpdate(courseUpdates)); - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); dispatch(updateSavingStatuses({ status: { createCourseUpdateQuery: RequestStatus.SUCCESSFUL }, error: { deletingUpdates: false }, })); } catch { - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); dispatch(updateSavingStatuses({ status: { createCourseUpdateQuery: RequestStatus.FAILED }, error: { deletingUpdates: true }, @@ -142,16 +142,16 @@ export function editCourseHandoutsQuery(courseId, data) { return async (dispatch) => { try { dispatch(updateSavingStatuses({ createCourseUpdateQuery: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + showToastOutsideReact(NOTIFICATION_MESSAGES.saving); const courseHandouts = await editHandouts(courseId, data); dispatch(editCourseHandouts(courseHandouts)); - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); dispatch(updateSavingStatuses({ status: { createCourseUpdateQuery: RequestStatus.SUCCESSFUL }, error: { savingHandouts: false }, })); } catch { - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); dispatch(updateSavingStatuses({ status: { createCourseUpdateQuery: RequestStatus.FAILED }, error: { savingHandouts: true }, diff --git a/src/generic/processing-notification/ProcessingNotification.test.jsx b/src/generic/processing-notification/ProcessingNotification.test.tsx similarity index 94% rename from src/generic/processing-notification/ProcessingNotification.test.jsx rename to src/generic/processing-notification/ProcessingNotification.test.tsx index 4567a0ef87..dc2fc46b5e 100644 --- a/src/generic/processing-notification/ProcessingNotification.test.jsx +++ b/src/generic/processing-notification/ProcessingNotification.test.tsx @@ -1,5 +1,5 @@ import userEvent from '@testing-library/user-event'; -import { initializeMocks, render, screen } from '../../testUtils'; +import { initializeMocks, render, screen } from '@src/testUtils'; import ProcessingNotification from '.'; const mockUndo = jest.fn(); diff --git a/src/generic/processing-notification/data/selectors.js b/src/generic/processing-notification/data/selectors.js deleted file mode 100644 index 7d7ca18963..0000000000 --- a/src/generic/processing-notification/data/selectors.js +++ /dev/null @@ -1,4 +0,0 @@ -export const getProcessingNotification = (state) => ({ - isShow: state.processingNotification.isShow, - title: state.processingNotification.title, -}); diff --git a/src/generic/processing-notification/data/slice.js b/src/generic/processing-notification/data/slice.js deleted file mode 100644 index 03e4e243f8..0000000000 --- a/src/generic/processing-notification/data/slice.js +++ /dev/null @@ -1,30 +0,0 @@ -/* eslint-disable no-param-reassign */ -import { createSlice } from '@reduxjs/toolkit'; - -import { NOTIFICATION_MESSAGES } from '../../../constants'; - -const initialState = { - isShow: false, - title: NOTIFICATION_MESSAGES.empty, -}; - -const slice = createSlice({ - name: 'processingNotification', - initialState, - reducers: { - showProcessingNotification: (state, { payload }) => { - state.isShow = true; - state.title = payload; - }, - hideProcessingNotification: () => initialState, - }, -}); - -export const { - showProcessingNotification, - hideProcessingNotification, -} = slice.actions; - -export const { - reducer, -} = slice; diff --git a/src/generic/processing-notification/index.jsx b/src/generic/processing-notification/index.tsx similarity index 61% rename from src/generic/processing-notification/index.jsx rename to src/generic/processing-notification/index.tsx index 42dc95711f..9fa6aac247 100644 --- a/src/generic/processing-notification/index.jsx +++ b/src/generic/processing-notification/index.tsx @@ -1,20 +1,37 @@ -import PropTypes from 'prop-types'; import { Icon, Toast, } from '@openedx/paragon'; import { Settings as IconSettings } from '@openedx/paragon/icons'; import classNames from 'classnames'; +export interface ProcessingNotificationProps { + isShow: boolean; + title: string; + action?: { + label: string; + onClick?: () => void; + }; + close?: () => void; + delay?: number; +} + const ProcessingNotification = ({ - isShow, title, action, close, -}) => ( + isShow, + title, + action, + close, + delay = 5000, +}: ProcessingNotificationProps) => ( + // @ts-ignore - Toast has a poor definition of children {})} + delay={delay} > + { /* @ts-ignore - Toast has a poor definition of children */ } {title} @@ -22,18 +39,4 @@ const ProcessingNotification = ({ ); -ProcessingNotification.defaultProps = { - close: null, -}; - -ProcessingNotification.propTypes = { - isShow: PropTypes.bool.isRequired, - title: PropTypes.string.isRequired, - action: PropTypes.shape({ - label: PropTypes.string.isRequired, - onClick: PropTypes.func, - }), - close: PropTypes.func, -}; - export default ProcessingNotification; diff --git a/src/generic/toast-context/index.test.tsx b/src/generic/toast-context/index.test.tsx index 11294b0699..58ebbb424c 100644 --- a/src/generic/toast-context/index.test.tsx +++ b/src/generic/toast-context/index.test.tsx @@ -30,6 +30,16 @@ const TestComponentToClose = () => { return
Content
; }; +const TestComponentWithDelay = ({ delay }: { delay: number }) => { + const { showToast } = React.useContext(ToastContext); + + React.useEffect(() => { + showToast('This is the Toast!', undefined, delay); + }, [showToast]); + + return
Content
; +}; + let store; const RootWrapper = ({ children }: WraperProps) => ( @@ -74,4 +84,15 @@ describe('', () => { expect(await screen.findByText('Content')).toBeInTheDocument(); expect(screen.queryByText('This is the Toast!')).not.toBeInTheDocument(); }); + + it('should keep toast visible past default delay when custom delay is provided', async () => { + render(); + expect(await screen.findByText('This is the Toast!')).toBeInTheDocument(); + // Still visible after the default 5000ms delay + jest.advanceTimersByTime(6000); + expect(screen.queryByText('This is the Toast!')).toBeInTheDocument(); + // Gone after the custom 10000ms delay + jest.advanceTimersByTime(5000); + expect(screen.queryByText('This is the Toast!')).not.toBeInTheDocument(); + }); }); diff --git a/src/generic/toast-context/index.tsx b/src/generic/toast-context/index.tsx index ea47dce8d2..b855d2b967 100644 --- a/src/generic/toast-context/index.tsx +++ b/src/generic/toast-context/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useContext } from 'react'; import ProcessingNotification from '../processing-notification'; @@ -10,7 +10,12 @@ export interface ToastActionData { export interface ToastContextData { toastMessage: string | null; toastAction?: ToastActionData; - showToast: (message: string, action?: ToastActionData) => void; + toastDelay?: number; + showToast: ( + message: string, + action?: ToastActionData, + delay?: number, + ) => void; closeToast: () => void; } @@ -29,6 +34,13 @@ export const ToastContext = React.createContext({ closeToast: () => {}, }); +// TODO: Temporary solution. Module-level references to showToast and closeToast, kept in sync by ToastProvider. +// This allows calling them from outside React (e.g. Redux thunks) +// without violating the Rules of Hooks. +// This approach is used in Redux thunks as a workaround and will be migrated to React Query soon. +let internalShowToast: ToastContextData['showToast'] = () => {}; +let internalCloseToast: ToastContextData['closeToast'] = () => {}; + /** * React component to provide `ToastContext` to the app */ @@ -37,11 +49,15 @@ export const ToastProvider = (props: ToastProviderProps) => { // see: https://github.com/open-craft/frontend-app-course-authoring/pull/38#discussion_r1638990647 const [toastMessage, setToastMessage] = React.useState(null); - const [toastAction, setToastAction] = React.useState(undefined); + const [toastAction, setToastAction] = React.useState(); + const [toastDelay, setToastDelay] = React.useState(); const resetState = React.useCallback(() => { setToastMessage(null); setToastAction(undefined); + // Set Toast delay by default, currently, + // it is not possible to disable the timer in Paragon's toast menu. + setToastDelay(undefined); }, []); React.useEffect(() => () => { @@ -49,18 +65,36 @@ export const ToastProvider = (props: ToastProviderProps) => { resetState(); }, []); - const showToast = React.useCallback((message, action?: ToastActionData) => { + const showToast = React.useCallback(( + message, + action?: ToastActionData, + delay?: number, + ) => { setToastMessage(message); setToastAction(action); + setToastDelay(delay); }, [setToastMessage, setToastAction]); const closeToast = React.useCallback(() => resetState(), [setToastMessage, setToastAction]); + // Keep the module-level references up to date whenever the callbacks change. + React.useEffect(() => { + internalShowToast = showToast; + internalCloseToast = closeToast; + }, [showToast, closeToast]); + const context = React.useMemo(() => ({ toastMessage, toastAction, + toastDelay, + showToast, + closeToast, + }), [ + toastMessage, + toastAction, + toastDelay, showToast, closeToast, - }), [toastMessage, toastAction, showToast, closeToast]); + ]); return ( @@ -70,9 +104,37 @@ export const ToastProvider = (props: ToastProviderProps) => { isShow={toastMessage !== null} title={toastMessage} action={toastAction} + delay={toastDelay} close={closeToast} /> )} ); }; + +export function useToastContext(): ToastContextData { + const ctx = useContext(ToastContext); + if (ctx === undefined) { + /* istanbul ignore next */ + throw new Error('useToastContext() was used in a component without a ancestor.'); + } + return ctx; +} + +/** + * Imperative API for triggering a toast notification from outside React + * (e.g. Redux thunks, plain async functions). Requires that a + * is mounted in the tree before this is called. + */ +export function showToastOutsideReact(message: string, action?: ToastActionData) { + internalShowToast(message, action); +} + +/** + * Imperative API for closing the active toast from outside React + * (e.g. Redux thunks, plain async functions). Requires that a + * is mounted in the tree before this is called. + */ +export function closeToastOutsideReact() { + internalCloseToast(); +} diff --git a/src/group-configurations/data/thunk.ts b/src/group-configurations/data/thunk.ts index 16cdef5636..48d83e3944 100644 --- a/src/group-configurations/data/thunk.ts +++ b/src/group-configurations/data/thunk.ts @@ -1,10 +1,7 @@ import { RequestStatus } from '@src/data/constants'; import { NOTIFICATION_MESSAGES } from '@src/constants'; -import { - hideProcessingNotification, - showProcessingNotification, -} from '@src/generic/processing-notification/data/slice'; import { handleResponseErrors } from '@src/generic/saving-error-alert'; +import { showToastOutsideReact, closeToastOutsideReact } from '@src/generic/toast-context'; import { getGroupConfigurations, createContentGroup, @@ -45,7 +42,7 @@ export function fetchGroupConfigurationsQuery(courseId) { export function createContentGroupQuery(courseId, group) { return async (dispatch) => { dispatch(updateSavingStatuses({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + showToastOutsideReact(NOTIFICATION_MESSAGES.saving); try { const data = await createContentGroup(courseId, group); @@ -55,7 +52,7 @@ export function createContentGroupQuery(courseId, group) { } catch (error) { return handleResponseErrors(error, dispatch, updateSavingStatuses); } finally { - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); } }; } @@ -63,7 +60,7 @@ export function createContentGroupQuery(courseId, group) { export function editContentGroupQuery(courseId, group) { return async (dispatch) => { dispatch(updateSavingStatuses({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + showToastOutsideReact(NOTIFICATION_MESSAGES.saving); try { const data = await editContentGroup(courseId, group); @@ -73,7 +70,7 @@ export function editContentGroupQuery(courseId, group) { } catch (error) { return handleResponseErrors(error, dispatch, updateSavingStatuses); } finally { - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); } }; } @@ -81,7 +78,7 @@ export function editContentGroupQuery(courseId, group) { export function deleteContentGroupQuery(courseId, parentGroupId, groupId) { return async (dispatch) => { dispatch(updateSavingStatuses({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting)); + showToastOutsideReact(NOTIFICATION_MESSAGES.deleting); try { await deleteContentGroup(courseId, parentGroupId, groupId); @@ -90,7 +87,7 @@ export function deleteContentGroupQuery(courseId, parentGroupId, groupId) { } catch (error) { handleResponseErrors(error, dispatch, updateSavingStatuses); } finally { - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); } }; } @@ -98,7 +95,7 @@ export function deleteContentGroupQuery(courseId, parentGroupId, groupId) { export function createExperimentConfigurationQuery(courseId, newConfiguration) { return async (dispatch) => { dispatch(updateSavingStatuses({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + showToastOutsideReact(NOTIFICATION_MESSAGES.saving); try { const configuration = await createExperimentConfiguration( @@ -111,7 +108,7 @@ export function createExperimentConfigurationQuery(courseId, newConfiguration) { } catch (error) { return handleResponseErrors(error, dispatch, updateSavingStatuses); } finally { - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); } }; } @@ -122,7 +119,7 @@ export function editExperimentConfigurationQuery( ) { return async (dispatch) => { dispatch(updateSavingStatuses({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + showToastOutsideReact(NOTIFICATION_MESSAGES.saving); try { const configuration = await editExperimentConfiguration( @@ -135,7 +132,7 @@ export function editExperimentConfigurationQuery( } catch (error) { return handleResponseErrors(error, dispatch, updateSavingStatuses); } finally { - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); } }; } @@ -143,7 +140,7 @@ export function editExperimentConfigurationQuery( export function deleteExperimentConfigurationQuery(courseId, configurationId) { return async (dispatch) => { dispatch(updateSavingStatuses({ status: RequestStatus.PENDING })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting)); + showToastOutsideReact(NOTIFICATION_MESSAGES.deleting); try { await deleteExperimentConfiguration(courseId, configurationId); @@ -152,7 +149,7 @@ export function deleteExperimentConfigurationQuery(courseId, configurationId) { } catch (error) { handleResponseErrors(error, dispatch, updateSavingStatuses); } finally { - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); } }; } diff --git a/src/group-configurations/hooks.jsx b/src/group-configurations/hooks.jsx index e3616dc3d8..1cb3f02bd3 100644 --- a/src/group-configurations/hooks.jsx +++ b/src/group-configurations/hooks.jsx @@ -2,7 +2,6 @@ import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { RequestStatus } from '../data/constants'; -import { getProcessingNotification } from '../generic/processing-notification/data/selectors'; import { getGroupConfigurationsData, getLoadingStatus, @@ -26,10 +25,6 @@ const useGroupConfigurations = (courseId) => { const loadingStatus = useSelector(getLoadingStatus); const savingStatus = useSelector(getSavingStatus); const errorMessage = useSelector(getErrorMessage); - const { - isShow: isShowProcessingNotification, - title: processingNotificationTitle, - } = useSelector(getProcessingNotification); const handleInternetConnectionFailed = () => { dispatch(updateSavingStatuses({ status: RequestStatus.FAILED })); @@ -91,8 +86,6 @@ const useGroupConfigurations = (courseId) => { experimentConfigurationActions, errorMessage, groupConfigurations, - isShowProcessingNotification, - processingNotificationTitle, handleInternetConnectionFailed, }; }; diff --git a/src/group-configurations/index.jsx b/src/group-configurations/index.jsx index 9738c53020..88501e16f3 100644 --- a/src/group-configurations/index.jsx +++ b/src/group-configurations/index.jsx @@ -7,7 +7,6 @@ import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { LoadingSpinner } from '../generic/Loading'; import SubHeader from '../generic/sub-header/SubHeader'; import getPageHeadTitle from '../generic/utils'; -import ProcessingNotification from '../generic/processing-notification'; import { SavingErrorAlert } from '../generic/saving-error-alert'; import messages from './messages'; import ContentGroupsSection from './content-groups-section'; @@ -27,8 +26,6 @@ const GroupConfigurations = () => { errorMessage, contentGroupActions, experimentConfigurationActions, - processingNotificationTitle, - isShowProcessingNotification, groupConfigurations: { allGroupConfigurations, shouldShowEnrollmentTrack, @@ -129,10 +126,6 @@ const GroupConfigurations = () => { savingStatus={savingStatus} errorMessage={errorMessage} /> -
); diff --git a/src/store.ts b/src/store.ts index c94e94795e..7ff59a1495 100644 --- a/src/store.ts +++ b/src/store.ts @@ -15,7 +15,6 @@ import { reducer as studioHomeReducer } from './studio-home/data/slice'; import { reducer as scheduleAndDetailsReducer } from './schedule-and-details/data/slice'; import { reducer as filesReducer } from './files-and-videos/files-page/data/slice'; import { reducer as CourseUpdatesReducer } from './course-updates/data/slice'; -import { reducer as processingNotificationReducer } from './generic/processing-notification/data/slice'; import { reducer as courseOptimizerReducer } from './optimizer-page/data/slice'; import { reducer as genericReducer } from './generic/data/slice'; import { reducer as videosReducer } from './files-and-videos/videos-page/data/slice'; @@ -41,7 +40,6 @@ export interface DeprecatedReduxState { models: Record; live: Record; courseUpdates: Record; - processingNotification: Record; courseOptimizer: Record; generic: Record; videos: Record; @@ -71,7 +69,6 @@ export default function initializeStore(preloadedState: Partial { handleTextbookDeleteSubmit, } = useTextbooks(courseId, waffleFlags); - const { - isShow: showProcessingNotification, - title: processingNotificationTitle, - } = useSelector(getProcessingNotification); - if (isLoadingFailed) { return ( @@ -139,10 +131,6 @@ const Textbooks = () => { -
{ dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + showToastOutsideReact(NOTIFICATION_MESSAGES.saving); try { const data = await createTextbook(courseId, textbook); @@ -46,7 +43,7 @@ export function createTextbookQuery(courseId, textbook) { } catch (error) { handleResponseErrors(error, dispatch, updateSavingStatus); } finally { - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); } }; } @@ -54,7 +51,7 @@ export function createTextbookQuery(courseId, textbook) { export function editTextbookQuery(courseId, textbook) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + showToastOutsideReact(NOTIFICATION_MESSAGES.saving); try { const data = await editTextbook(courseId, textbook); @@ -63,7 +60,7 @@ export function editTextbookQuery(courseId, textbook) { } catch (error) { handleResponseErrors(error, dispatch, updateSavingStatus); } finally { - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); } }; } @@ -71,7 +68,7 @@ export function editTextbookQuery(courseId, textbook) { export function deleteTextbookQuery(courseId, textbookId) { return async (dispatch) => { dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); - dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting)); + showToastOutsideReact(NOTIFICATION_MESSAGES.deleting); try { await deleteTextbook(courseId, textbookId); @@ -80,7 +77,7 @@ export function deleteTextbookQuery(courseId, textbookId) { } catch (error) { handleResponseErrors(error, dispatch, updateSavingStatus); } finally { - dispatch(hideProcessingNotification()); + closeToastOutsideReact(); } }; } diff --git a/src/textbooks/data/thunk.test.js b/src/textbooks/data/thunk.test.js index dd3f9ed50c..958981baa5 100644 --- a/src/textbooks/data/thunk.test.js +++ b/src/textbooks/data/thunk.test.js @@ -1,7 +1,4 @@ -import { - hideProcessingNotification, - showProcessingNotification, -} from '../../generic/processing-notification/data/slice'; +import { showToastOutsideReact, closeToastOutsideReact } from '../../generic/toast-context'; import { fetchTextbooksQuery, createTextbookQuery, @@ -22,6 +19,11 @@ import { getTextbooks, createTextbook, editTextbook, deleteTextbook, } from './api'; +jest.mock('../../generic/toast-context', () => ({ + showToastOutsideReact: jest.fn(), + closeToastOutsideReact: jest.fn(), +})); + jest.mock('./api', () => ({ getTextbooks: jest.fn(), createTextbook: jest.fn(), @@ -63,11 +65,11 @@ describe('createTextbookQuery', () => { await createTextbookQuery('courseId', textbook)(dispatch); expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); - expect(dispatch).toHaveBeenCalledWith(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + expect(showToastOutsideReact).toHaveBeenCalledWith(NOTIFICATION_MESSAGES.saving); expect(createTextbook).toHaveBeenCalledWith('courseId', textbook); expect(dispatch).toHaveBeenCalledWith(createTextbookSuccess(textbook)); expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - expect(dispatch).toHaveBeenCalledWith(hideProcessingNotification()); + expect(closeToastOutsideReact).toHaveBeenCalled(); }); it('should dispatch updateSavingStatus with RequestStatus.FAILED on failure', async () => { @@ -76,10 +78,10 @@ describe('createTextbookQuery', () => { await createTextbookQuery('courseId', {})(dispatch); expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); - expect(dispatch).toHaveBeenCalledWith(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + expect(showToastOutsideReact).toHaveBeenCalledWith(NOTIFICATION_MESSAGES.saving); expect(createTextbook).toHaveBeenCalledWith('courseId', {}); expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.FAILED, errorMessage: '' })); - expect(dispatch).toHaveBeenCalledWith(hideProcessingNotification()); + expect(closeToastOutsideReact).toHaveBeenCalled(); }); }); @@ -91,11 +93,11 @@ describe('editTextbookQuery', () => { await editTextbookQuery('courseId', textbook)(dispatch); expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); - expect(dispatch).toHaveBeenCalledWith(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + expect(showToastOutsideReact).toHaveBeenCalledWith(NOTIFICATION_MESSAGES.saving); expect(editTextbook).toHaveBeenCalledWith('courseId', textbook); expect(dispatch).toHaveBeenCalledWith(editTextbookSuccess(textbook)); expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - expect(dispatch).toHaveBeenCalledWith(hideProcessingNotification()); + expect(closeToastOutsideReact).toHaveBeenCalled(); }); it('should dispatch updateSavingStatus with RequestStatus.FAILED on failure', async () => { @@ -104,10 +106,10 @@ describe('editTextbookQuery', () => { await editTextbookQuery('courseId', {})(dispatch); expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); - expect(dispatch).toHaveBeenCalledWith(showProcessingNotification(NOTIFICATION_MESSAGES.saving)); + expect(showToastOutsideReact).toHaveBeenCalledWith(NOTIFICATION_MESSAGES.saving); expect(editTextbook).toHaveBeenCalledWith('courseId', {}); expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.FAILED, errorMessage: '' })); - expect(dispatch).toHaveBeenCalledWith(hideProcessingNotification()); + expect(closeToastOutsideReact).toHaveBeenCalled(); }); }); @@ -118,11 +120,11 @@ describe('deleteTextbookQuery', () => { await deleteTextbookQuery('courseId', 'textbookId')(dispatch); expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); - expect(dispatch).toHaveBeenCalledWith(showProcessingNotification(NOTIFICATION_MESSAGES.deleting)); + expect(showToastOutsideReact).toHaveBeenCalledWith(NOTIFICATION_MESSAGES.deleting); expect(deleteTextbook).toHaveBeenCalledWith('courseId', 'textbookId'); expect(dispatch).toHaveBeenCalledWith(deleteTextbookSuccess('textbookId')); expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - expect(dispatch).toHaveBeenCalledWith(hideProcessingNotification()); + expect(closeToastOutsideReact).toHaveBeenCalled(); }); it('should dispatch updateSavingStatus with RequestStatus.FAILED on failure', async () => { @@ -131,9 +133,9 @@ describe('deleteTextbookQuery', () => { await deleteTextbookQuery('courseId', 'textbookId')(dispatch); expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.IN_PROGRESS })); - expect(dispatch).toHaveBeenCalledWith(showProcessingNotification(NOTIFICATION_MESSAGES.deleting)); + expect(showToastOutsideReact).toHaveBeenCalledWith(NOTIFICATION_MESSAGES.deleting); expect(deleteTextbook).toHaveBeenCalledWith('courseId', 'textbookId'); expect(dispatch).toHaveBeenCalledWith(updateSavingStatus({ status: RequestStatus.FAILED, errorMessage: '' })); - expect(dispatch).toHaveBeenCalledWith(hideProcessingNotification()); + expect(closeToastOutsideReact).toHaveBeenCalled(); }); });