Skip to content

refactor: convert TabPage to TypeScript - #1986

Open
brian-smith-tcril wants to merge 1 commit into
bsmith/react-query-course-home-toastfrom
bsmith/react-query-tabpage-typescript
Open

refactor: convert TabPage to TypeScript#1986
brian-smith-tcril wants to merge 1 commit into
bsmith/react-query-course-home-toastfrom
bsmith/react-query-tabpage-typescript

Conversation

@brian-smith-tcril

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

Copy link
Copy Markdown
Contributor

Summary

Converts the shared src/tab-page/TabPage.jsxTabPage.tsx as 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 → .tsx conversion in its own layer keeps it a clean, reviewable diff — so later course-home changes to TabPage build 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.jsxTabPage.tsx: typed TabPageProps (courseId?/unitId? optional, courseStatus: StatusValue, children?: ReactNode); structure and control flow unchanged.
  • Typed selectors: the two useSelector reads use the store's exported RootState (ReturnType<typeof store.getState>), imported type-only — not a bespoke interface or a Partial.
  • The && courseId guard (the one behavior-adjacent change): LoadedTabPage requires courseId: string, but TabPage's courseId is optional (null until the course loads). Rendering it under && courseId narrows the type to string without a cast and without loosening LoadedTabPage's contract. Behavior-identical in the app — loaded/denied always imply a courseId.
  • Status checks: kept the LOADING/LOADED/DENIED constants, compared with === — not [].includes, which won't type-check against literal-typed constants without an as StatusValue[] cast.
  • Dependency in refactor: convert course-home CTA toast from Redux to a React ToastProvider #1982 (below): ToastContent.message is narrowed ReactNode → string there so Paragon's string-only <Toast> slot type-checks once TabPage is TypeScript. It lives in the toast PR because that's where ToastContext is introduced, and the mismatch only surfaces once TabPage is TS.

Testing

nvm use && npm run types && npm run lint && npm test — all green. TabPage.test.jsx passes 7/7; its one unrealistic { courseStatus: 'loaded' } fixture (no courseId) gains a realistic courseId: 'test-course' so the "displays Loaded Tab Page" test still asserts LoadedTabPage renders under the && courseId guard. Git records the file as a rename (TabPage.jsxTabPage.tsx, ~65% similarity). No behavior change.

Decisions

Full decision log

Decisions — convert TabPage to TypeScript

Working 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.jsxTabPage.tsx with no behavior change, isolated in its own
layer so later course-home changes to TabPage build on an already-TypeScript
component (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 && courseId guard (the one behavior-adjacent change — needs justifying)

LoadedTabPage requires courseId: string, but TabPage's courseId is
optional (string | undefined): its callers (TabContainer,
CoursewareContainer) pass it from Redux, where it's null until the course
loads, and the original propTypes declared 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 LoadedTabPage only when courseId is present:

{(courseStatus === LOADED || courseStatus === DENIED) && courseId && (
  <LoadedTabPage activeTabSlug={activeTabSlug} courseId={courseId}  />
)}

The && courseId narrows courseId to string, satisfying LoadedTabPage's
required prop without a cast and without loosening its contract.

Behavior. In the app, loaded/denied always imply a courseId, so
LoadedTabPage renders exactly as before. The only case that differs is a
loaded-with-no-courseId state, which doesn't occur in production.
TabPage.test.jsx had one such unrealistic fixture ({ courseStatus: 'loaded' }
with no courseId); it now includes a realistic courseId: 'test-course' (a
loaded tab always has a course), so the "displays Loaded Tab Page" test still
asserts LoadedTabPage renders.

Rejected:

  • courseId={courseId as string} / {...(props as LoadedTabPageProps)} — a cast
    that asserts a non-null the type doesn't guarantee.
  • Making LoadedTabPage.courseId optional — loosens a component that genuinely
    needs a courseId (it fetches course metadata by it).

Toast typing (two Paragon <Toast> mismatches the .jsx masked)

  • children: string vs ToastContent.message: ReactNode. Fixed at the
    source, not with a cast: narrowed ToastContent.message to string in
    ToastContext.tsx. It's only ever set to the API header (a string) and it
    feeds a string-only Paragon slot, so ReactNode was an over-broad type. Render
    is {toastContent?.message ?? ''} — the ?? '' supplies the required string
    when 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 ToastContext is introduced; the mismatch only surfaces once
    TabPage is TS, so it's carried in that layer.)
  • action?: ToastAction (no null) vs the original action={… ?? null}.
    Dropped the ?? nullaction={toastContent?.action}. toastContent?.action
    is ToastAction | undefined (optional chaining never yields null, and
    action?: is … | undefined), so it's equivalent to the old value and matches
    Paragon's optional prop.

useSelector typing → the store's real RootState

Typed 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: we
want those slices present).

Status checks: constants + === (not [].includes)

Kept the existing LOADING/LOADED/DENIED constants 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't
type-check (it'd need an as StatusValue[] cast). The original only worked because
bare 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 it
appears.

Type-only imports

import type { RootState } (whole import is a type); inline type for
StatusValue (shares the constants value import) and ReactNode (shares the
react default value import).

Verification

npm run types / lint / test all green; TabPage.test.jsx 7/7. TabPage.jsx
is removed (git records a rename to TabPage.tsx, ~65% similarity). No behavior
change.

Closes #1985

🤖 Generated with Claude Code

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

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.86%. Comparing base (e71919b) to head (b194e65).

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.
📢 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 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>
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.

Convert TabPage to TypeScript

1 participant