refactor: convert TabPage to TypeScript - #1986
Open
brian-smith-tcril wants to merge 1 commit into
Open
Conversation
Behavior-preserving conversion of TabPage.jsx -> TabPage.tsx. - Typed props (courseId/unitId optional, courseStatus: StatusValue) and useSelector state via the store's RootState. - Render LoadedTabPage only when courseId is present, narrowing its required courseId prop without a cast. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## bsmith/react-query-course-home-toast #1986 +/- ##
========================================================================
+ Coverage 92.84% 92.86% +0.01%
========================================================================
Files 360 360
Lines 5874 5890 +16
Branches 1406 1404 -2
========================================================================
+ Hits 5454 5470 +16
Misses 402 402
Partials 18 18 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
brian-smith-tcril
marked this pull request as ready for review
August 7, 2026 23:29
brian-smith-tcril
added a commit
that referenced
this pull request
Aug 8, 2026
Convert the dates tab off Redux thunks to React Query. The tab becomes
self-wrapping: it renders TabPage itself and owns its data loading via query
hooks.
- DatesTab renders TabWithTimer and owns its data via useCourseHomeMeta +
useDatesTabData; courseId comes from useParams.
- TabPage: courseStatus becomes a union (StatusValue | { metadataQuery,
tabDataQuery }); a converted tab passes its queries and TabPage derives the
view (loading/error/denied/loaded), reading access from the metadata query.
Builds on the TabPage TypeScript conversion in the layer below (#1986).
- TabWithTimer wraps TabPage with OuterExamTimer, keeping
@edx/frontend-lib-special-exams out of the shared TabPage; TabContainer uses
it, CoursewareContainer keeps rendering plain TabPage.
- ShiftDatesAlert invalidates the dates query on reset; its fetch prop is now
optional (the still-Redux outline tab's transitional refresh). The dates
subtree reads courseId from useParams.
- Transitional model-store bridge (data/queryKeys, data/modelStoreBridge)
mirrors query results into the existing useModel readers until model-store is
removed; createTestQueryClient wires it when given a store.
- Drop the now-unused fetchDatesTab thunk and its re-export.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
brian-smith-tcril
added a commit
that referenced
this pull request
Aug 9, 2026
Convert the dates tab off Redux thunks to React Query. The tab becomes
self-wrapping: it renders TabPage itself and owns its data loading via query
hooks.
- DatesTab renders TabWithTimer and owns its data via useCourseHomeMeta +
useDatesTabData; courseId comes from useParams.
- TabPage: courseStatus becomes a union (StatusValue | { metadataQuery,
tabDataQuery }); a converted tab passes its queries and TabPage derives the
view (loading/error/denied/loaded), reading access from the metadata query.
Builds on the TabPage TypeScript conversion in the layer below (#1986).
- TabWithTimer wraps TabPage with OuterExamTimer, keeping
@edx/frontend-lib-special-exams out of the shared TabPage; TabContainer uses
it, CoursewareContainer keeps rendering plain TabPage.
- ShiftDatesAlert invalidates the dates query on reset; its fetch prop is now
optional (the still-Redux outline tab's transitional refresh). The dates
subtree reads courseId from useParams.
- Transitional model-store bridge (data/queryKeys, data/modelStoreBridge)
mirrors query results into the existing useModel readers until model-store is
removed; createTestQueryClient wires it when given a store.
- Drop the now-unused fetchDatesTab thunk and its re-export.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
brian-smith-tcril
added a commit
that referenced
this pull request
Aug 9, 2026
Convert the dates tab off Redux thunks to React Query. The tab becomes
self-wrapping: it renders TabPage itself and owns its data loading via query
hooks.
- DatesTab renders TabWithTimer and owns its data via useCourseHomeMeta +
useDatesTabData; courseId comes from useParams.
- TabPage: courseStatus becomes a union (StatusValue | { metadataQuery,
tabDataQuery }); a converted tab passes its queries and TabPage derives the
view (loading/error/denied/loaded), reading access from the metadata query.
Builds on the TabPage TypeScript conversion in the layer below (#1986).
- TabWithTimer wraps TabPage with OuterExamTimer, keeping
@edx/frontend-lib-special-exams out of the shared TabPage; TabContainer uses
it, CoursewareContainer keeps rendering plain TabPage.
- ShiftDatesAlert invalidates the dates query on reset; its fetch prop is now
optional (the still-Redux outline tab's transitional refresh). The dates
subtree reads courseId from useParams.
- Transitional model-store bridge (data/queryKeys, data/modelStoreBridge)
mirrors query results into the existing useModel readers until model-store is
removed; createTestQueryClient wires it when given a store.
- Drop the now-unused fetchDatesTab thunk and its re-export.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Converts the shared
src/tab-page/TabPage.jsx→TabPage.tsxas a standalone, behavior-preserving refactor. Part of the Redux → React Query migration (#1946), Phase 3 (course-home), stacked on the CTA-toast conversion (#1982, the branch below). Tracked by #1985.Isolating the mechanical
.jsx → .tsxconversion in its own layer keeps it a clean, reviewable diff — so later course-home changes toTabPagebuild on an already-TypeScript component instead of mixing a conversion together with feature changes.Principle: a pure conversion — only the changes TypeScript actually forces. Structure, control flow, the string-status constants, and the destructured props signature are kept as-is, so the diff reads ~1:1.
What changed
TabPage.jsx→TabPage.tsx: typedTabPageProps(courseId?/unitId?optional,courseStatus: StatusValue,children?: ReactNode); structure and control flow unchanged.useSelectorreads use the store's exportedRootState(ReturnType<typeof store.getState>), imported type-only — not a bespoke interface or aPartial.&& courseIdguard (the one behavior-adjacent change):LoadedTabPagerequirescourseId: string, butTabPage'scourseIdis optional (nulluntil the course loads). Rendering it under&& courseIdnarrows the type tostringwithout a cast and without looseningLoadedTabPage's contract. Behavior-identical in the app —loaded/deniedalways imply acourseId.LOADING/LOADED/DENIEDconstants, compared with===— not[].includes, which won't type-check against literal-typed constants without anas StatusValue[]cast.ToastContent.messageis narrowedReactNode → stringthere so Paragon's string-only<Toast>slot type-checks onceTabPageis TypeScript. It lives in the toast PR because that's whereToastContextis introduced, and the mismatch only surfaces onceTabPageis TS.Testing
nvm use && npm run types && npm run lint && npm test— all green.TabPage.test.jsxpasses 7/7; its one unrealistic{ courseStatus: 'loaded' }fixture (nocourseId) gains a realisticcourseId: 'test-course'so the "displays Loaded Tab Page" test still assertsLoadedTabPagerenders under the&& courseIdguard. Git records the file as a rename (TabPage.jsx→TabPage.tsx, ~65% similarity). No behavior change.Decisions
Full decision log
Decisions — convert
TabPageto TypeScriptWorking notes for this PR (untracked; referenced when opening the PR). Part of the
Redux → React Query migration (#1946). This is a standalone conversion PR: it
converts
TabPage.jsx→TabPage.tsxwith no behavior change, isolated in its ownlayer so later course-home changes to
TabPagebuild on an already-TypeScriptcomponent (a small, reviewable diff instead of a conversion + feature change tangled
together).
Principle: a pure conversion — only the changes TypeScript actually forces.
Structure, control flow, string-status constants, and the destructured props
signature are kept as-is so the diff reads 1:1.
The
&& courseIdguard (the one behavior-adjacent change — needs justifying)LoadedTabPagerequirescourseId: string, butTabPage'scourseIdisoptional (
string | undefined): its callers (TabContainer,CoursewareContainer) pass it from Redux, where it'snulluntil the courseloads, and the original
propTypesdeclared it optional. The original rendered<LoadedTabPage {...props} />on loaded/denied unconditionally — fine in JS(untyped spread), a type error in TS (optional → required).
Decision. Render
LoadedTabPageonly whencourseIdis present:The
&& courseIdnarrowscourseIdtostring, satisfyingLoadedTabPage'srequired prop without a cast and without loosening its contract.
Behavior. In the app,
loaded/deniedalways imply acourseId, soLoadedTabPagerenders exactly as before. The only case that differs is aloaded-with-no-courseIdstate, which doesn't occur in production.TabPage.test.jsxhad one such unrealistic fixture ({ courseStatus: 'loaded' }with no
courseId); it now includes a realisticcourseId: 'test-course'(aloaded tab always has a course), so the "displays Loaded Tab Page" test still
asserts
LoadedTabPagerenders.Rejected:
courseId={courseId as string}/{...(props as LoadedTabPageProps)}— a castthat asserts a non-null the type doesn't guarantee.
LoadedTabPage.courseIdoptional — loosens a component that genuinelyneeds a
courseId(it fetches course metadata by it).Toast typing (two Paragon
<Toast>mismatches the.jsxmasked)children: stringvsToastContent.message: ReactNode. Fixed at thesource, not with a cast: narrowed
ToastContent.messagetostringinToastContext.tsx. It's only ever set to the APIheader(a string) and itfeeds a
string-only Paragon slot, soReactNodewas an over-broad type. Renderis
{toastContent?.message ?? ''}— the?? ''supplies the requiredstringwhen there's no toast. (This edit lives in the toast PR refactor: convert course-home CTA toast from Redux to a React ToastProvider #1982 directly below,
since that's where
ToastContextis introduced; the mismatch only surfaces onceTabPageis TS, so it's carried in that layer.)action?: ToastAction(nonull) vs the originalaction={… ?? null}.Dropped the
?? null→action={toastContent?.action}.toastContent?.actionis
ToastAction | undefined(optional chaining never yieldsnull, andaction?:is… | undefined), so it's equivalent to the old value and matchesParagon's optional prop.
useSelectortyping → the store's realRootStateTyped the selector state with
RootState(ReturnType<typeof store.getState>,already exported from
src/store.ts), imported type-only(
import type { RootState } from '../store'). Kept the original destructure form—
const { errorMessage: courseHomeErrorMessage } = useSelector((state: RootState) => state.courseHome)— rather than a bespoke interface or a
Partial<RootState>(not a partial: wewant those slices present).
Status checks: constants +
===(not[].includes)Kept the existing
LOADING/LOADED/DENIEDconstants and compared with===.Did not keep the original
['loaded','denied'].includes(courseStatus)idiom:the constants are literal-typed, so
[LOADED, DENIED]infers as('loaded' | 'denied')[]and.includes(courseStatus: StatusValue)won'ttype-check (it'd need an
as StatusValue[]cast). The original only worked becausebare string literals widen to
string[].===keeps the constants with no cast.No
showContent-style helper — the two-value check is inlined in the two places itappears.
Type-only imports
import type { RootState }(whole import is a type); inlinetypeforStatusValue(shares theconstantsvalue import) andReactNode(shares thereactdefault value import).Verification
npm run types/lint/testall green;TabPage.test.jsx7/7.TabPage.jsxis removed (git records a rename to
TabPage.tsx, ~65% similarity). No behaviorchange.
Closes #1985
🤖 Generated with Claude Code