Skip to content

refactor: convert course-home CTA toast from Redux to a React ToastProvider - #1982

Open
brian-smith-tcril wants to merge 1 commit into
masterfrom
bsmith/react-query-course-home-toast
Open

refactor: convert course-home CTA toast from Redux to a React ToastProvider#1982
brian-smith-tcril wants to merge 1 commit into
masterfrom
bsmith/react-query-course-home-toast

Conversation

@brian-smith-tcril

@brian-smith-tcril brian-smith-tcril commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Extracts the course-home call-to-action toast from Redux to a React context, and converts the two POSTs that feed it to React Query mutations. Part of the Redux → React Query migration (#1946), Phase 3 (course-home), stacked on the courseware-search conversion (#1970, the branch below). This is the prerequisite that lets the course-home tabs convert without a Redux toast bridge.

Behavior is preserved — same toast content, action link, auto-hide, and manual close, plus the same post-action data refresh — verified with the full test suite and a live manual smoke pass on both master and this branch (with a small fade-out improvement noted below).

What changed

  • Toast state → React context: new ToastProvider/useToast (src/generic/ToastContext.tsx), mounted once at the route root; TabPage renders the single Paragon <Toast> from it. Message and visibility are decoupled (setToastContent vs openToast/closeToast), removing the slice's show={!!toastHeader} content-vs-visibility fusion.
  • Writers → React Query mutations: useResetDeadlines / usePostEvent in src/course-home/data/apiHooks.ts, following the product-tours mutation recipe. The hooks own only the POST + toast and are Redux-free; the transitional model-store refresh (dispatch(getTabData)) stays in each caller's mutate onSuccess until that data becomes an RQ query.
  • Redux removed: the resetDeadlines + processEvent thunks (and the resetDeadlines re-export), and the setCallToActionToast reducer + toastHeader/toastBodyText/toastBodyLink fields from the course-home slice.
  • Courseware touch: the processEvent postMessage parse/guard moves into useIFrameBehavior (handlePostMessageEvent), so window.onmessage is a bare reference; eventTypes is exported from thunks.js.
  • Bonus: decoupling content from visibility removes a small glitch where the toast text blanked for a frame on auto-hide before fading.

Testing

Automated: npm run types, npm run lint, npm run build, and the full npm test suite (106 suites, 896 passing) pass. New unit tests: ToastContext.test.tsx (incl. the decoupling) and course-home/data/apiHooks.test.tsx (axios-level POST → toast mapping + both onError paths). Own-wrapper suites that render TabPage (DatesTab, DiscussionTab, CoursewareContainer) get the provider.

Manual smoke (dev, self-paced course with a missed deadline): exercised both writers — resetDeadlines via the dates-tab "Shift due dates" banner (master + this branch) and usePostEvent via the LMS-rendered in-unit post_event banner (this branch). Toast content, auto-hide, and the post-action refresh match master; the fade-out is cleaner here (content persists through the animation instead of blanking).

Decisions

Full decision log

Decisions — Redux → React Query: course-home CTA toast

Working notes for this PR (part of the wider Redux → React Query migration,
#1946). Not checked in — referenced when opening the PR. First PR of Phase 3
(course-home), stacked on the courseware-search conversion (#1970).

Scope & why the toast goes first

The course-home "call-to-action" toast is shared client state. It lives in
the courseHome slice (toastHeader/toastBodyText/toastBodyLink +
setCallToActionToast), is read/rendered by the shared TabPage, and is
written by two POSTs:

  • resetDeadlines thunk → from ShiftDatesAlert (dates + outline tabs)
  • processEvent thunk → from useIFrameBehavior (the courseware in-unit iframe)

Because TabPage is shared by every tab (and courseware), no course-home tab can
be considered de-Redux'd while the toast still reaches Redux. So the toast is
extracted first; the tabs then convert with no toast bridge.

Two moves in this PR: (1) the toast client state → a React ToastProvider
context; (2) the two POST writers that feed it → React Query mutations whose
onSuccess sets the toast.

Verified: exactly one toast in the whole app — the single Paragon <Toast> in
TabPage.jsx, backed only by those slice members. So this is a scoped extraction,
not a general toast framework.

ToastProvider: state only, TabPage renders

New src/generic/ToastContext.tsx, mirroring CoursewareSearchContext (useState

  • useMemo, hook throws outside its provider). The provider owns only state;
    TabPage keeps rendering the single <Toast>, now reading from useToast()
    instead of useSelector. State in the provider, render in the consumer — the same
    split as the courseware-search context. (We explicitly did not move the
    <Toast> component into the provider.)

Named ToastProvider/useToast, not CTAToast…: nothing in the mechanism is
CTA-specific — it holds content and renders whatever it's handed. This is not
pre-building for hypothetical future toasts (there's only one today); it's just
naming the thing for what it is rather than for its single current caller.

Consumers call useToast() directly and the client state lives in the context —
no model-store hydration and no prop-drilling-with-fallback bridges. That's the
same shape as the courseware-search and product-tours conversions; the earlier
"hydrate the model store" / "lift to props" options were discarded as not matching
the established pattern.

Placement: mounted once at the route root in src/index.jsx, wrapping
<Routes> — the only common ancestor of both TabPage parents (TabContainer
and CoursewareContainer, which renders TabPage directly).

Message and visibility are fully decoupled (the core design call)

The Redux slice fused content and visibility: show={!!toastHeader} derived
visibility from content presence, and closing meant clearing all three content
fields. That fusion is the wart we're removing — not relocating into the
provider.
The context exposes two independent pieces of state:

  • toastContent: ToastContent | null — the current notification content
  • isToastOpen: boolean — whether the <Toast> is showing

Nothing couples them. Setting the content does not open the toast, and closing
does not clear the content. A writer that wants to surface a notification does
two explicit things: setToastContent(...) then openToast(). This was a
deliberate rejection of a showToast(content) helper that sets both — the "set
also opens" magic is exactly the coupling we're trying to leave behind. If that
means a mutation's onSuccess calls two functions, that's fine.

Closing only flips isToastOpen; the content stays in place, so Paragon's
auto-hide / close-button onClose (both wired to closeToast) fades the toast out
with its content rather than blanking it mid-animation.

Context value: { toastContent, setToastContent, isToastOpen, openToast, closeToast }.

Why the context carries content, not just a boolean

An early question was whether the context could hold only "should the toast show?"
and let TabPage supply the text. It can't: the toast text is server-sourced
the POST response's { header, link, link_text } is what's displayed, and there
is no client i18n copy for it to hardcode (unlike the dates banner, which does
have its own messages). So whatever emits the toast has to hand the content along;
the provider stores a ToastContent, not a bare flag, and the writer POSTs feed
setToastContent with the response.

Naming

  • Type ToastContent — not ToastMessage (it carries a message and an
    action, so "Message" undersells it), not ToastData ("Data" says nothing).
    Paragon's <Toast> takes children + action; together those are the toast's
    content.
  • Field/setter toastContent/setToastContent — kept aligned with the type
    name.
  • isToastOpen, openToast, closeToasttoast-prefixed because they're
    destructured from a general hook; a bare isOpen/show reads ambiguously at the
    call site. Mirrors CoursewareSearchContext's show/open/close, extended
    with the content payload.

ToastContent shape (derived from the real APIs, not invented)

Paragon <Toast> takes children, action ({ label, href?, onClick? }),
show, onClose, closeLabel, delay. TabPage today passes
children=toastHeader, action={ label: toastBodyText, href: toastBodyLink },
show=!!toastHeader. Only children + action carry per-toast content; show
is now isToastOpen, and closeLabel/delay are constant. So:

export interface ToastContent {
  message: React.ReactNode;                                         // → Toast children
  action?: { label: string; href?: string; onClick?: () => void };  // → Toast action (Paragon's own shape)
}

Writers → React Query mutations

New src/course-home/data/apiHooks.ts (first RQ hook in that folder), following
the repo's one established mutation recipe (product-tours/data/apiHooks.ts):
useMutationmutationFn calls the existing api.js fn → onError: logError.
Reuses postCourseDeadlines / executePostFromPostEvent unchanged.

The two fire-and-forget thunks become useResetDeadlines and usePostEvent. Each
hook owns only the permanent behavior — the POST plus the two toast calls on
success — and a shared toastFrom(response.data) maps the server
{ header, link, link_text } to ToastContent. The hook has zero Redux in it.

Mutation variables are typed inline ({ courseId, model }, { postData, researchEventData }); only the nested post-event postData shape is pulled out as
a small PostData type, purely to keep that mutationFn signature under the line
limit — not a per-mutation …Variables interface.

Deletes both thunks (and the resetDeadlines re-export from data/index.js),
plus setCallToActionToast and the three toast fields from slice.js.

The one transitional Redux seam lives in the caller, not the hook

Each writer also refreshed model-store data on success —
dispatch(getTabData(courseId)), i.e. fetchDatesTab / fetchOutlineTab /
fetchCourse. That data is still Redux (the model store) and doesn't convert
until the later Phase 3 tab PRs / Phase 4 (courseware). So the refresh has to stay
a dispatch(...) — an RQ→Redux call — for now.

We keep that call out of the mutation hook and fire it from the caller's
mutate(vars, { onSuccess }). Both onSuccesses run (the hook's toast first, then
the caller's refresh). This keeps apiHooks.ts Redux-free from day one: when that
data becomes an RQ query, delete the single caller line (or swap it for
invalidateQueries) and never touch the hook.

  • ShiftDatesAlert.jsx: resetDeadlines.mutate({ courseId, model }, { onSuccess: () => dispatch(fetch(courseId)) }) (model/fetch props unchanged; dispatch from the existing useDispatch).
  • useIFrameBehavior.ts: postEvent.mutate({ postData, researchEventData }, { onSuccess: () => dispatch(fetchCourse(...)) }).

processEvent's refresh (fetchCourse, courseware) is the longest-lived seam —
courseware data converts much later than the course-home tabs.

Why not defer the toast until that data is RQ (toast-last)? Extracting the
toast only once fetchDatesTab/fetchOutlineTab/fetchCourse are RQ queries
would avoid the seam entirely (the refresh would be invalidateQueries from day
one). We chose toast-first anyway: TabPage is shared, so leaving the toast in
Redux keeps every course-home tab Redux-coupled until courseware converts
(Phase 4, far off). Toast-first unblocks the tab conversions now; the price is one
clearly-temporary RQ→Redux line per caller, deleted as each data source converts.

processEvent parse/guard moves into useIFrameBehavior

The postMessage parse/guard that lived in the processEvent thunk (pull
research_event_data out before camelCasing so it stays in the shape the backend
expects; camelCaseObject; check eventName === POST_EVENT) moves into a named
handlePostMessageEvent function in the hook, so window.onmessage = handlePostMessageEvent is a bare reference rather than an inline block. eventTypes
is exported from course-home/data/thunks.js for the guard. This PR therefore
touches the courseware iframe hook, not only course-home.

Tests

  • ToastContext.test.tsx (new) — provider/hook unit tests (initial state,
    setToastContent, openToast/closeToast, throw-outside-provider); mirrors
    CoursewareSearchContext.test.tsx.
  • course-home/data/apiHooks.test.tsx (new) — the low-level coverage:
    renderHook hosts useResetDeadlines/usePostEvent with an axios-level
    MockAdapter
    on the POST URL (same style as the old TabPage toast test), and
    asserts the server response is mapped into toastContent + isToastOpen, plus the
    POST body and the onError → logError path (both mutations). This is where the old
    TabPage "displays Learning Toast" data-path coverage relocated to, because the
    response→content mapping now lives in the hook — keeping the mock at the network
    level for parity rather than stubbing the api module. Mutations are driven with the
    await act(async () => { await …mutateAsync(); }) form from
    product-tours/data/apiHooks.test.tsx. apiHooks.ts lands at 100%.
  • TabPage.test.jsx — the toast test is kept but narrowed to TabPage's
    actual remaining job: "given a toast in the context, render it." It mocks
    useToast
    (via a one-line mockUseToast factory so the return shape is written
    once and each call passes only overrides) rather than driving a POST through the
    whole chain. Rationale: TabPage is now a pass-through renderer; testing the render
    belongs here, testing the server-data→content mapping belongs in the hook test.
    We rejected reconstructing the data path here with a contrived trigger component.
  • setupTest.js — the shared render() wrapper gains <ToastProvider> (TabPage
    now calls useToast).
  • Any suite with its own provider wrapper (not setupTest's render()) that
    renders TabPage
    must add <ToastProvider> to that wrapper, since TabPage
    now calls useToast() and would otherwise throw. Found via the full run:
    • dates-tab/DatesTab.test.jsx — wrapper gains <ToastProvider>; the "shift
      due dates" test now exercises the full path end-to-end (click →
      useResetDeadlines → context → TabPage renders the toast), the real coverage of
      TabPage's render wiring.
    • discussion-tab/DiscussionTab.test.jsx — wrapper gains <ToastProvider>
      (renders TabPage via TabContainer).
    • courseware/CoursewareContainer.test.jsx — wrapper gains <ToastProvider>
      (CoursewareContainer renders TabPage directly).
  • course-home/data/slice.test.js — drop the toast fields from the local
    fixtures.
  • course-home/data/redux.test.js — remove the resetDeadlines thunk test
    (thunk deleted; coverage → apiHooks.test.tsx).
  • useIFrameBehavior.test.js — the old dispatch(processEvent(...)) assertion
    is invalid; mock usePostEvent and assert the parse/guard → mutate(...) (and its
    onSuccess refresh) instead. Test name kept ("registers an event handler to
    process fetchCourse events") — the handler still ends in a fetchCourse refresh,
    so only the body changed; the name was never inaccurate.

Verification

nvm use && npm run types && npm run lint && npm test && npm run build — all green:
types clean, lint clean, full suite 106 suites / 896 passing (3 pre-existing skips),
build succeeds. git grep for setCallToActionToast / toastHeader /
toastBodyText / toastBodyLink and for a surviving resetDeadlines/processEvent
thunk (or index.js re-export) both come back clean.

Manual testing (seeding the toast triggers via edx-when)

Both toast triggers need a self-paced course with a missed suggested-schedule
deadline
:

  • Dates/Outline tab "Shift due dates" → the resetDeadlines path.
  • In-unit courseware CTA → the processEvent / post_event path.

That state requires a graded subsection with a relative due date, which can't
be set through the course-authoring MFE
: frontend-app-authoring PR #976
deliberately hid the release/due-date fields for self-paced courses and never added
a relative-date field (legacy Studio's custom_relative_dates UI wasn't ported).
So the data is seeded directly via edx-when in the CMS shell rather than
authored in Studio:

  1. Enable course_experience.relative_dates (LMS waffle) for the course; confirm with RELATIVE_DATES_FLAG.is_enabled(course_key).
  2. Find the graded subsection key (modulestore().get_course(ck) → chapters → sequentials, pick the one with graded == True).
  3. edx_when.api.set_dates_for_course(ck, [(subsection, {'due': timedelta(weeks=1)})]).
  4. Backdate that enrollment's Schedule.start_date (~30 days) so the due date is in the past → dates_banner_info.missed_deadlines: true → the button appears.

Do not re-publish the course afterward — a publish re-syncs edx-when from the
blocks' own fields (which have no due date) and wipes the manually-seeded date.

Then compare master vs. this branch: identical toast header/action, identical
auto-hide + manual close, and the post-action data refresh (banner clears / dates
shift) still fires. The only intended differences are the two noted under the
decoupling section (content persists through the fade-out; failed POSTs now log).

Verified on both master and this branch (self-paced course, seeded as above).
Behavior matches, with one confirmed improvement: on master the toast text blanks
for a frame on auto-hide before fading (closing nulls the content while
show={!!toastHeader} is still animating out); on this branch the content persists
and it fades cleanly — a direct payoff of decoupling content from visibility.

Both writers were exercised end-to-end: the resetDeadlines path via the
dates-tab "Shift due dates" banner (master + branch), and the usePostEvent
post_event path via the LMS-rendered in-unit missed-deadlines banner (branch).

Coverage caveat: every manual run rendered a reset-deadlines payload —
a header with no action link (the local env has no endpoint that returns a
different CTA, and post_event in this setup also points at reset-deadlines). So
the message-with-action variant (toastContent.action → the Paragon action
button) was not observed by eye; it rests on the unit tests
(apiHooks.test maps { header, link, link_text }action, and its
"omits the toast action when the response has no link text" case; TabPage.test
renders the action label) plus Paragon owning the present/absent-action render.
Given TabPage maps it in one line (action={toastContent?.action ?? null}), the
risk is low, but note it wasn't manually confirmed.

Plan

Implementation plan (as approved — the type was later renamed `ToastMessage` → `ToastContent` during implementation; see the decision log)

Plan: Convert the CTA toast from Redux to a React ToastProvider

Context

Part of the Redux → React Query migration (Stage 1, issue #1946), Phase 3
(course-home)
, stacked on #1970. Standalone prerequisite PR before converting
any course-home tab.

Why this first: the course-home "call-to-action" toast is shared client
state. It lives in the courseHome Redux slice (toastHeader / toastBodyText
/ toastBodyLink + the setCallToActionToast reducer), is read/rendered by
the shared TabPage
, and is written by two places:

  • resetDeadlines thunk → dispatched from ShiftDatesAlert (course-home: dates + outline)
  • processEvent thunk → dispatched from useIFrameBehavior.ts (courseware in-unit iframe)

Because TabPage is shared across every tab (and courseware), a tab can't be
"converted" while it still reaches Redux for the toast. So we extract the toast
first, then the tabs convert with no toast bridge.

This PR de-Redux-es the toast path end to end — two moves: (1) the toast
client state → a React ToastProvider; (2) the two POST writers that feed it
(resetDeadlines, processEvent) → React Query mutations whose onSuccess sets
the toast. The only Redux left in the path afterward is the transitional
dispatch(getTabData) refresh, which the later tab PRs turn into
invalidateQueries.

Toast landscape (verified): exactly one toast in the whole app — the
single Paragon <Toast> in TabPage.jsx, backed only by those slice members.
No other toast/notification state exists.

Design: a general ToastProvider (state only)

There's exactly one toast in the app today (the landscape note above), so
this isn't about future reuse — it's extracting the one toast out of Redux. The provider
owns only the toast state; TabPage renders the single <Toast> reading
from it. (State in the provider, render in the consumer — same split as
CoursewareSearchContext.) It's named ToastProvider/useToast rather than
CTAToast… simply because nothing in the mechanism is CTA-specific — it holds a
ToastMessage and renders whatever it's handed — not because we're pre-building
for hypothetical future toasts.

Framing: the Redux slice today fuses content and visibility — one
show={!!toastHeader} derivation, cleared together to close. That fusion is the
wart we're removing, not relocating into the provider. In the new design the
message and the open/closed state are fully independent:

  • toastMessage: ToastMessage | null — the current notification content, written/cleared on its own.
  • isToastOpen: boolean — whether the <Toast> is showing, opened/closed on its own.

Nothing couples them: setting the message does not open the toast, and
closing does not clear the message. A writer that wants to surface a
notification does two explicit things — setToastMessage(...) then openToast()
(e.g. an RQ mutation's onSuccess calls both). Paragon's auto-hide and the close
button both fire onClose, wired to closeToast, which only flips isToastOpen
— the content stays in place so the toast fades out with content, not blank.

Shape — derived from the real APIs, not invented:

  • Paragon <Toast> (node_modules/@openedx/paragon/dist/Toast/index.js) takes children, action ({ label, href?, onClick? }), show, onClose, closeLabel (defaults to intl "Close"), delay.
  • TabPage currently passes children=toastHeader, action={ label: toastBodyText, href: toastBodyLink }, show=!!toastHeader.
  • Only children + action carry per-toast content; show is derived and the rest are constant. So the stored payload is exactly:
interface ToastMessage {
  message: React.ReactNode;                                        // → Toast children
  action?: { label: string; href?: string; onClick?: () => void }; // → Toast action (Paragon's own shape)
}

Approach

New src/generic/ToastContext.tsx (TypeScript; mirrors CoursewareSearchContext
useState + useMemo, hook throws outside provider):

  • ToastProvider holds two independent state pieces and renders only {children} (no <Toast>):
    const [toastMessage, setToastMessage] = useState<ToastMessage | null>(null);
    const [isToastOpen, setIsToastOpen] = useState(false);
    const value = useMemo(() => ({
      toastMessage, setToastMessage, isToastOpen,
      openToast:  () => setIsToastOpen(true),
      closeToast: () => setIsToastOpen(false),
    }), [toastMessage, isToastOpen]);
  • useToast(){ toastMessage, setToastMessage, isToastOpen, openToast, closeToast }. toast-prefixed names so they read correctly destructured from a general hook (isOpen/show would be ambiguous at the call site).

Provider placement — mounted once at the route root in src/index.jsx,
wrapping <Routes> (the only common ancestor of both TabPage parents:
TabContainer and CoursewareContainer, which renders TabPage directly).

TabPage.jsx renders the <Toast> from context — keeps the render and its
Toast/genericMessages imports; only the source swaps from Redux to useToast():

const { toastMessage, isToastOpen, closeToast } = useToast();
<Toast show={isToastOpen} onClose={closeToast} action={toastMessage?.action}
  closeLabel={intl.formatMessage(genericMessages.close)}>
  {toastMessage?.message}
</Toast>

Drop the useSelector(state.courseHome) toast fields and the setCallToActionToast
dispatch. (Leave its errorMessage/courseHomeMeta reads — out of scope.)

Writers → React Query mutations — the two thunks are fire-and-forget POSTs
(.then not awaited, no .catch) that on success dispatch(getTabData(courseId))
then dispatch(setCallToActionToast(...)). These are server-state calls, so they
become mutations in a new src/course-home/data/apiHooks.ts (first RQ hook in
that folder), following the only established mutation recipe in the repo
(src/product-tours/data/apiHooks.ts): useMutationmutationFn calls the
existing api.js fn → onSuccess(_data, vars)onError: logError. Reuse the
existing postCourseDeadlines / executePostFromPostEvent in api.js unchanged.

The hook owns only the permanent behavior — POST + toast — and has zero Redux
in it
:

const toastFrom = ({ header, link, link_text: linkText }) => ({
  message: header,
  action: linkText ? { label: linkText, href: link } : undefined,
});

export const useResetDeadlines = () => {
  const { setToastMessage, openToast } = useToast();
  return useMutation({
    mutationFn: ({ courseId, model }) => postCourseDeadlines(courseId, model),
    onSuccess: ({ data }) => { setToastMessage(toastFrom(data)); openToast(); },
    onError: logError,
  });
};
// usePostEvent: same shape; mutationFn: ({ postData, researchEventData }) => executePostFromPostEvent(postData, researchEventData)

onSuccess is exactly the decoupled "two things" (setToastMessage then openToast).
No queryKeys.ts/useQueryClient yet — nothing RQ to invalidate until the data converts.

Transitional refresh lives in the caller, not the hook — the success refresh
(fetchDatesTab/fetchOutlineTab/fetchCourse) writes into the Redux model
store
, which isn't converted until the later tab/courseware PRs. So the caller
fires it as a per-call mutate(vars, { onSuccess }); both onSuccesses run (the
hook's toast first, then the caller's refresh). This keeps the new apiHooks.ts
Redux-free from day one: when that data becomes an RQ query, you delete the one
caller line (or swap it for invalidateQueries) and never touch the hook.

Call sites (each already uses useDispatch today):

  • src/course-home/suggested-schedule-messaging/ShiftDatesAlert.jsx: const resetDeadlines = useResetDeadlines();onClick={() => resetDeadlines.mutate({ courseId, model }, { onSuccess: () => dispatch(fetch(courseId)) })} (model/fetch props unchanged; dispatch from the existing useDispatch).
  • src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.ts: const postEvent = usePostEvent();. The postMessage parse/guard that lived in processEvent (raw research_event_data, camelCaseObject, eventName === POST_EVENT) moves into the window.onmessage handler, which then calls postEvent.mutate({ postData, researchEventData }, { onSuccess: () => dispatch(fetchCourse(postData.bodyParams.courseId)) }). eventTypes is exported from thunks.js (or relocated) for the guard. This PR therefore touches the courseware iframe hook, not only course-home.

Delete resetDeadlines + processEvent from thunks.js and their index.js
re-exports; delete setCallToActionToast + the three toast fields from slice.js.

Slice cleanupsrc/course-home/data/slice.js: remove toastHeader /
toastBodyText / toastBodyLink from initial state and the setCallToActionToast
reducer + export.

Files

  • new src/generic/ToastContext.tsx (+ ToastContext.test.tsx)
  • new src/course-home/data/apiHooks.ts (+ apiHooks.test.tsx) — useResetDeadlines, usePostEvent mutations
  • src/index.jsx — wrap <Routes> in <ToastProvider>
  • src/tab-page/TabPage.jsx — render <Toast> from useToast() instead of the state.courseHome reads
  • src/course-home/data/thunks.js — delete resetDeadlines + processEvent; export eventTypes for the iframe guard
  • src/course-home/data/index.js — drop the resetDeadlines re-export
  • src/course-home/suggested-schedule-messaging/ShiftDatesAlert.jsxuseResetDeadlines().mutate(...)
  • src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.ts — parse/guard + usePostEvent().mutate(...)
  • src/course-home/data/slice.js — remove setCallToActionToast + the three toast fields
  • Tests:
    • new src/course-home/data/apiHooks.test.tsxrenderHook + mutate(...) for useResetDeadlines/usePostEvent: assert the POST args and the toast calls (setToastMessage/openToast). The getTabData refresh is now the caller's concern, so it's asserted in the call-site tests below, not here. Absorbs the old redux.test.js resetDeadlines POST coverage.
    • src/setupTest.js — add <ToastProvider> to the shared render() wrapper (TabPage now calls useToast).
    • src/course-home/dates-tab/DatesTab.test.jsx — add <ToastProvider> to its own component wrapper; its "Shift due dates" → Toast assertion now runs through useResetDeadlines + the provider render.
    • src/tab-page/TabPage.test.jsx — wrap in <ToastProvider>; TabPage still renders the Toast (now fed from context), so its toast assertions stay.
    • src/course-home/data/slice.test.js — drop the toast initial-state fields + the setCallToActionToast reducer test.
    • src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.test.js — the dispatch(processEvent(...)) assertion (~line 357) is now invalid; mock usePostEvent and assert the parse/guard → mutate(...) instead.
    • src/course-home/data/redux.test.js — remove the resetDeadlines thunk test (thunk deleted; coverage moves to apiHooks.test.tsx).
  • decisions.md (repo root, untracked) — decision log for the PR body: single-toast finding; general state-only ToastProvider, TabPage renders; message and open/close state decoupled (no auto-open on set); shape derived from Paragon's Toast API; writers → RQ mutations owning only POST + toast (Redux-free), with the transitional model-store refresh kept in the caller's mutate onSuccess so the hook never carries Redux; and why this touches the courseware iframe hook.

Verification

  • nvm use && npm run types && npm run lint && npm test (targeted first: TabPage, DatesTab, slice, redux, useIFrameBehavior, new ToastContext and apiHooks tests), then npm run build.
  • git grep -n "setCallToActionToast\|toastHeader\|toastBodyText\|toastBodyLink" src → none in source; slice carries no toast state.
  • git grep -n "resetDeadlines\|processEvent" src → only the new apiHooks.ts (useResetDeadlines/usePostEvent) and its callers/tests; no surviving thunk or index.js re-export.
  • Manual smoke (dev, dates or outline tab with a missed deadline): click Shift due dates → Toast appears with the success header + action link and closes; button disappears after refetch. Confirm the in-unit courseware POST_EVENT path still toasts.
  • Then gh stack add onto refactor: convert courseware search from Redux to React Query #1970, open the stacked PR as a draft, improve coverage, mark ready on your go.

Closes #1980

🤖 Generated with Claude Code

@brian-smith-tcril
brian-smith-tcril marked this pull request as ready for review August 7, 2026 06:15
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-course-home-toast branch from 2a92c61 to 76d2c03 Compare August 7, 2026 06:16
@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.55172% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.84%. Comparing base (9055943) to head (e71919b).

Files with missing lines Patch % Lines
...re/course/sequence/Unit/hooks/useIFrameBehavior.ts 85.71% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1982      +/-   ##
==========================================
+ Coverage   92.61%   92.84%   +0.23%     
==========================================
  Files         358      360       +2     
  Lines        5851     5874      +23     
  Branches     1368     1406      +38     
==========================================
+ Hits         5419     5454      +35     
+ Misses        413      402      -11     
+ Partials       19       18       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-course-home-toast branch from 76d2c03 to bc03f2a Compare August 7, 2026 06:29
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-course-home-toast branch 2 times, most recently from 499f731 to 051cf57 Compare August 7, 2026 07:18
Base automatically changed from bsmith/react-query-courseware-search to master August 7, 2026 15:27
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-course-home-toast branch from 051cf57 to 3817200 Compare August 7, 2026 15:27
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-course-home-toast branch from 3817200 to 7cb895a Compare August 7, 2026 21:26
…ovider

The shared TabPage read the call-to-action toast from the courseHome Redux
slice, so no course-home tab could be de-Redux'd while the toast stayed in
Redux. This extracts it end to end (part of #1946, Phase 3):

- Toast client state -> a new React ToastProvider/useToast context
  (src/generic/ToastContext.tsx), mounted at the route root. Message and
  visibility are decoupled (setToastContent vs openToast/closeToast), removing
  the slice's show={!!toastHeader} content/visibility fusion. TabPage renders
  the single <Toast> from the context.
- The two POST writers that feed it -> React Query mutations in
  src/course-home/data/apiHooks.ts (useResetDeadlines, usePostEvent), following
  the product-tours mutation pattern. The hooks own only the POST + toast and
  carry no Redux; the transitional model-store refresh stays in each caller's
  mutate onSuccess (dispatch(getTabData)) until that data is RQ, keeping
  apiHooks.ts Redux-free.
- Deletes the resetDeadlines/processEvent thunks (and the index.js re-export)
  and the setCallToActionToast reducer + toast fields from the slice. The
  processEvent postMessage parse/guard moves into useIFrameBehavior.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Extract the course-home CTA toast to a React ToastProvider

1 participant