refactor: de-class UnitButton and convert it to TypeScript - #2065
Merged
Merged
Conversation
brian-smith-tcril
added this pull request to stack #2062
September 15, 2026 19:10
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2065 +/- ##
==========================================
+ Coverage 93.68% 93.71% +0.02%
==========================================
Files 369 369
Lines 6005 6017 +12
Branches 1423 1429 +6
==========================================
+ Hits 5626 5639 +13
+ Misses 363 362 -1
Partials 16 16 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
6 tasks
brian-smith-tcril
force-pushed
the
bsmith/de-class-unit-button
branch
from
September 15, 2026 19:25
1d801b2 to
4e372d9
Compare
brian-smith-tcril
marked this pull request as ready for review
September 15, 2026 19:30
arbrandes
force-pushed
the
bsmith/de-class-unit-button
branch
from
September 18, 2026 14:58
4e372d9 to
c201560
Compare
arbrandes
force-pushed
the
bsmith/de-class-unit-button
branch
from
September 18, 2026 16:13
c201560 to
0a0df12
Compare
brian-smith-tcril
force-pushed
the
bsmith/de-class-unit-button
branch
from
September 18, 2026 18:05
0a0df12 to
c72c7e3
Compare
brian-smith-tcril
force-pushed
the
bsmith/de-class-unit-button
branch
from
September 18, 2026 18:18
c72c7e3 to
06e1d07
Compare
brian-smith-tcril
force-pushed
the
bsmith/de-class-unit-button
branch
from
September 18, 2026 18:35
06e1d07 to
886794b
Compare
Base automatically changed from
bsmith/react-query-outline-sidebar
to
master
September 18, 2026 18:40
brian-smith-tcril
force-pushed
the
bsmith/de-class-unit-button
branch
from
September 18, 2026 18:40
886794b to
a9c2168
Compare
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
De-class
UnitButton— the app's lastconnect()— and convert the file to TypeScript. This is a structural peel with no data-layer change (the #2008/#2012 convention): the component reads the sameunitsmodel entry throughuseModelthatmapStateToPropsspread before, and renders identically for every caller. It's the first of two layers for Target 3 (bookmarking) of the courseware decomposition (plan) in the Redux → React Query migration (#1946, Stage 1), stacked on #2064; the bookmark-conversion layer above closes the issue. Part of #2014.What changed
connect→useModel('units', unitId). Connect's merge direction is preserved: model values win when the entry exists (useModelreturns{}for a missing one, so the destructure defaults engage exactly where the spread of a missing entry did). The one caller relying on the prop fallback is the gated branch (SequenceNavigation:title=""/contentType="lock"for a unit with no model entry).UnitButton.jsx→.tsx):propTypes/defaultPropsare replaced by aPropsinterface with destructure defaults; the connect-injectedbookmarked/completeleave the prop contract (no caller passes them — they're model-sourced);title/contentTypebecome optional props for the gated fallback. TheuseSelectorgets the Peel: convert CoursewareContainer to TypeScript (fast-follow to de-class #2008) #2019RootStatetyping pattern.state.courseware.{courseId,sequenceId}useSelector— it was never part of theconnect(the issue body mis-described it) and is a bridge-written courseware-slice read owned by the Tear down the courseware Redux slice + replace useContextId #1976 teardown, same as the identical read inSequenceNavigation.jsx.Testing
npm run types(0 errors),npm run lint(clean); all six sequence-navigation suites pass with no edits to any existing assertion (48 tests — two coverage cases added after codecov's patch check, see decision log §7),UnitButton.tsxat 100% statement/branch/function/line coverage, and the full jest suite green at the stack head (112 suites, 1113 passed / 3 pre-existing skips). Manual pass on tutor local (DemoX) in the details block below; the gated-sequence lock button is now also pinned directly by the new no-model-entry unit test.Decisions
Full decision log
Decisions — Layer A: de-class UnitButton (#2014)
This issue ships as two stack layers; this is the structural peel. The two
jobs in Convert bookmarking to React Query + de-class UnitButton #2014 are orthogonal — the bookmark mutation writes the units model,
which
UnitButtonreads identically throughconnectoruseModel— so thede-class goes first as its own PR (the Peel: de-class CoursewareContainer (structural, no data-layer change) #2008/Peel: convert checkBlockCompletion to a React Query mutation #2012 "peel: structural, no
data-layer change" convention) and the bookmark conversion follows as a pure
data-layer layer that closes the issue. The riskiest part of the issue
(preserving connect's merge semantics) gets its own small, focused review.
useModel, notuseParams, and theuseSelectorline stays. The issuebody described the connect as covering
state.models.units[unitId]+state.courseware.{courseId,sequenceId}and suggesteduseModel/useParamsreads. Thestate.coursewareread is not part of the connect —it was already a plain
useSelectorin the function body, identical to theone in
SequenceNavigation.jsxthat this issue doesn't touch. Both arebridge-written courseware-slice fields owned by the Tear down the courseware Redux slice + replace useContextId #1976 teardown, so this
layer replaces only the actual
connect(the units-model spread) and leavesthe
useSelectorline untouched.Merge direction preserved: model values over passed props. Connect's
default merge is
{ ...ownProps, ...stateProps }, so the model entry winswhenever it exists. The rewrite destructures the model entry with prop
fallbacks (
title = fallbackTitle,contentType = fallbackContentType).useModelreturns{}for a missing entry, so the fallbacks engage exactlywhere the connect spread of a missing entry did — the one caller relying on
that is the gated branch (
SequenceNavigation.jsx:title="",contentType="lock"for a unit with no model entry). Model entries alwaysdefine
title/contentType(page_title/typeinnormalizeSequenceMetadata), so default-on-undefined is equivalent to thespread for every real payload.
The file converts to TypeScript, and propTypes go entirely. With the
connectwrapper gone the component's contract is worth stating precisely,and TS does that better than propTypes: a
Propsinterface (onClick/unitIdrequired;
title/contentType/isActive/showCompletion/showTitle/classNameoptional) replacespropTypes/defaultProps, with the olddefaults becoming destructure defaults (
isActive = false,showTitle = false,showCompletion = true). New/reworked files are TS perthe project direction; the
useSelectorgets the Peel: convert CoursewareContainer to TypeScript (fast-follow to de-class #2008) #2019 typing pattern(
(state: RootState) => state.courseware).bookmarked/completeleave the prop contract. They were connect-injectedonly — no caller passes them — so they exist solely as destructure defaults
off the model entry (
bookmarked = false,complete = false, matching theold
defaultProps).title/contentTypestay as optional props for thegated-branch fallback (before, connect injected them ahead of the propTypes
check, so their
isRequirednever actually fired for the callers that passneither).
No behavior changes. Renders byte-identically for every caller, gated
branch included; all six sequence-navigation suites pass with no edits to any
existing assertion.
Two coverage cases added after codecov's patch check (the rename makes
the whole file "new patch", and the repo's codecov has zero patch tolerance —
its 4 "missing lines" were the destructure-default branches, which had zero
hits suite-wide). Both pin previously-untested pre-existing behavior:
title="",contentType="lock", absentunitsentry → lock icon, nothingmodel-sourced rendered). This was genuinely uncovered everywhere: the
existing
SequenceNavigation"renders locked button for gated content"test seeds the units into the store, so the model entry overrides the lock
props — its own TODO comment admits it asserts
fa-tasksinstead of alock. The new case is the first to exercise the fallback direction of the
connect-merge semantics this peel preserves.
pathname.startsWith('/preview')ternary'strue branch, previously never taken): rendered inside a
MemoryRouterat a/preview/course/…entry, asserting the link href keeps the prefix.With these,
UnitButton.tsxis at 100% statement/branch/function/linecoverage.
Manual testing
Manual testing — Layer A: de-class UnitButton (#2014)
In-browser verification for the peel layer, run against a live backend (tutor
local). This layer claims zero user-facing change:
UnitButtondrops itsconnectwrapper and reads the sameunitsmodel entry throughuseModel('units', unitId)instead — same state, same merge direction (modelvalues over passed props), same rendering for every caller. No network behavior
changes at all (the component fetches nothing).
The one code path with any subtlety is the gated-sequence fallback: the lock
button (
SequenceNavigation'sisLockedbranch) passestitle=""/contentType="lock"for a unit with no model entry, relying on the prop-fallbackdirection the de-class must preserve.
Getting real IDs (DemoX on tutor local)
Course id:
course-v1:OpenedX+DemoX+DemoCourse; basehttp://apps.local.openedx.io:2000/learning. Any unit page shows the sequencenav. For the completion check and bookmark dot, use a sequence containing at
least one completed unit and one bookmarked unit (bookmark one via the
"Bookmark this page" button first if needed). The dropdown variant needs a
narrow viewport (below the
smallbreakpoint) or a sequence with enough unitsto overflow.
Verify by hand
per unit with the right content-type icon, active unit highlighted,
completed units show the check, the bookmarked unit shows the bookmark dot
(top-right of its button).
/course/{courseId}/{sequenceId}/{unitId}, unit content loads.buttons overflow their container (
useIndexOfLastVisibleChild), not at aviewport breakpoint: use a sequence with enough units and a narrow window
(~375px device toolbar; an open outline sidebar narrows the container
too). The "{n} of {m}" dropdown lists unit titles (the
showTitlepath,titles come from the model), completion/bookmark markers intact,
selection navigates.
(not DemoX; rely on the existing
SequenceNavigationsuite if none ishandy): the locked sequence shows the single lock-icon button (the
prop-fallback path — no model entry for the unit).
/preview— in a unit page under/preview/course/…, unit-button links keep the/previewprefix.Left to the automated suite (not re-done by hand)
UnitButton.test.jsxcases (title hidden/shown, completionshown/hidden, bookmark dot, click handler) — unchanged assertions, now
exercising the
useModelread.SequenceNavigation*suites (including the locked-sequence rendering)render
UnitButtonthrough the seeded store — unchanged.Results
Env: tutor local,
course-v1:OpenedX+DemoX+DemoCourse, run against the localbranch @
1d801b20(before any PR).The four checked items passed as described; nothing surprising observed. The
overflow dropdown needed the real trigger — button overflow of the tabs
container, not a viewport breakpoint — so the checklist item was corrected
mid-pass. The gated-sequence item was not run by hand (no prereq-gated course
locally); it rests on the
SequenceNavigationsuite's locked-sequencerendering case, which passes. Setup note: a local
env.config.jsxhad to beadded for the app to render at all — the file is untracked in this repo (known
local-dev quirk, unrelated to this branch's change).
🤖 Generated with Claude Code