Repository navigation
refactor!: adopt frontend-lib-special-exams v5 and drop the redux store - #2168
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2168 +/- ##
==========================================
+ Coverage 95.85% 96.16% +0.31%
==========================================
Files 373 373
Lines 6146 6025 -121
Branches 1516 1484 -32
==========================================
- Hits 5891 5794 -97
+ Misses 246 222 -24
Partials 9 9 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
69c92b9 to
54b8f3a
Compare
54b8f3a to
d1d3fc4
Compare
The last redux store went in #2168, so @reduxjs/toolkit, react-redux and redux leave package.json, along with reselect, the selector library used with them, whose last import went in #2146. The lock is regenerated from scratch again (install, then `npm dedupe`). @reduxjs/toolkit, redux-thunk, @types/react-redux and reselect leave the tree. react-redux and redux stay as frontend-platform's peers, with one react-redux 8.1.1 shared with frontend-plugin-framework. @types/react and @types/react-dom ^18 join devDependencies, as in frontend-template-application and frontend-app-authoring. Without learning's own react-redux 7, npm places the react-redux 8 peer at the root, whose optional @types/react peer allows only 16–18, while @testing-library/react's optional @types/react-dom peer resolved to 19, which needs @types/react 19; npm stopped with ERESOLVE. Declaring the React 18 types resolves both to 18. Closes #2018. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d1d3fc4 to
6b776a2
Compare
The last redux store went in #2168, so @reduxjs/toolkit, react-redux and redux leave package.json, along with reselect, the selector library used with them, whose last import went in #2146. The lock is regenerated from scratch again (install, then `npm dedupe`). @reduxjs/toolkit, redux-thunk, @types/react-redux and reselect leave the tree. react-redux and redux stay as frontend-platform's peers, with one react-redux 8.1.1 shared with frontend-plugin-framework. @types/react and @types/react-dom ^18 join devDependencies, as in frontend-template-application and frontend-app-authoring. Without learning's own react-redux 7, npm places the react-redux 8 peer at the root, whose optional @types/react peer allows only 16–18, while @testing-library/react's optional @types/react-dom peer resolved to 19, which needs @types/react 19; npm stopped with ERESOLVE. Declaring the React 18 types resolves both to 18. Closes #2018. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6b776a2 to
ff87a2a
Compare
arbrandes
left a comment
There was a problem hiding this comment.
Pre-approved with two nits inline, plus:
src/plugin-slots/LearnerToolsSlot/README.md:23 (and the comment at index.jsx:31) still tells plugins to query the Redux store, which this PR removes. Worth pointing at the query hooks or backend APIs instead.
| renderTimedExam({ status: 'started', remainingSeconds: 2 }); | ||
|
|
||
| // The library holds the countdown at 00:00 for a 5 s grace period, then expires the attempt. | ||
| expect(await screen.findByText('Plugin heading, timeIsOver: true', {}, { timeout: 10_000 })).toBeInTheDocument(); | ||
|
|
||
| await act(async () => { await jest.runOnlyPendingTimersAsync(); }); | ||
| jest.useRealTimers(); | ||
| }); |
There was a problem hiding this comment.
Nit: move the drain and jest.useRealTimers() into an afterEach, as Sequence.specialExams.test.tsx does. If the findByText times out, line 120 never runs and the next case runs under fake timers.
| </Routes> | ||
| </MemoryRouter>, | ||
| { store: initializeStore() }, | ||
| {}, |
There was a problem hiding this comment.
Nit: the {} can go; the other call sites just omit the options.
v5 of `@edx/frontend-lib-special-exams` reads the exam through React Query and takes its host services from `SpecialExamsProvider` instead of frontend-platform and a redux slice. Learning's `SpecialExamsProvider` fills the library's provider from its config, authenticated HTTP client, logger and signed-in user. It is mounted where the library is used: around `SequenceExamWrapper` in `Sequence` and around `OuterExamTimer` in `TabWithTimer`. `src/store.ts`, the `store` prop on `AppProvider` and the tests' `initializeStore()` go: the library's slice was the last thing in it. `useExamAccess` reduces to the two remaining library hooks, and the backend mock the tests use comes from the library's `./testing` subpath, replacing learning's copy. The submitted-timed-exam plugin slot moves here from the library, same slot id, rendered through `SequenceExamWrapper`'s `submittedTimedExamInstructions` prop; a new test drives a real plugin through it. The exam tests take the adaptations v5's timing needs and assert the single submit v5's listener fix makes possible. Part of #1946. BREAKING CHANGE: `AppProvider` is no longer given a redux store, so plugins can no longer read or dispatch to one with `useSelector` / `useDispatch`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ff87a2a to
a644272
Compare
|
Addressed all comments in https://github.com/openedx/frontend-app-learning/compare/ff87a2a68d00858d422b578c10e7eb83af65ade7..a64427223b1bec45bf693a652e06e00c2d0a3a4e
Good catch, addressed by moving the comment change from the layer above this down to this layer. |
* chore: regenerate package-lock.json so the redux removal shows only removals Wiped package-lock.json and node_modules, ran `npm install`, then `npm dedupe` (as #2042 did). package.json is unchanged, so every line here is the current ranges resolving to newer versions: 132 version changes, one major (ajv-formats 2 to 3, under copy-webpack-plugin's schema-utils). Dedupe keeps a single react-intl 6.8.9 and @types/node 22, so no pins are needed. Part of #2018. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: remove redux packages that no learning code imports anymore The last redux store went in #2168, so @reduxjs/toolkit, react-redux and redux leave package.json, along with reselect, the selector library used with them, whose last import went in #2146. The lock is regenerated from scratch again (install, then `npm dedupe`). @reduxjs/toolkit, redux-thunk, @types/react-redux and reselect leave the tree. react-redux and redux stay as frontend-platform's peers, with one react-redux 8.1.1 shared with frontend-plugin-framework. @types/react and @types/react-dom ^18 join devDependencies, as in frontend-template-application and frontend-app-authoring. Without learning's own react-redux 7, npm places the react-redux 8 peer at the root, whose optional @types/react peer allows only 16–18, while @testing-library/react's optional @types/react-dom peer resolved to 19, which needs @types/react 19; npm stopped with ERESOLVE. Declaring the React 18 types resolves both to 18. Closes #2018. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: remove comments that point readers at Redux Learning no longer has a Redux store, so comments that offer it as an option, name it as a data source, or compare code with the slices and thunks it replaced now mislead. The LearnerToolsSlot README and comment point plugins at config and backend APIs instead, and the SequenceNavigation TODO names `useUnit` as the value that overrides the lock icon. The plugin-overrides README keeps its migration guide for plugins that still dispatch to the old store. Part of #2018. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Swaps
@edx/frontend-lib-special-examsto v5 (openedx/frontend-lib-special-exams#176, published as 5.0.0), which reads the exam through React Query and takes its host services from aSpecialExamsProviderinstead of frontend-platform and a redux slice. With the library's slice gone, learning's redux store goes too. Breaking for plugins: see "Plugins — breaking" below.Part of #1946 and #2018; stacked above #2164 (the HTTP-boundary exam tests this swap is checked against). The redux packages leave in the next layer.
What changed
package.jsonrequires^5.0.0; the lock change is that entry plus the v4 dependencies it no longer pulls in.src/generic/special-exams/SpecialExamsProvider.tsxfills the library's provider from learning'sgetConfig()values, authenticated HTTP client,logErrorand signed-in user. It is mounted where the library is used: aroundSequenceExamWrapperinSequenceand aroundOuterExamTimerinTabWithTimer.src/store.ts, thestoreprop onAppProviderand the tests'initializeStore()(15 test files) are removed.useExamAccessis three lines of TypeScript over the library'suseIsExamanduseExamAccessToken; its test is rewritten.src/plugin-slots/SubmittedTimedExamInstructionsSlot/, same slot id, passed toSequenceExamWrapperassubmittedTimedExamInstructions; README and screenshots come with it.@edx/frontend-lib-special-exams/testing; learning's copy (src/tests/mockSpecialExams.ts, its test,specialExamsFactories.ts) is deleted, and the Jest mapper for the package is anchored so the subpath resolves.Sequence.specialExams.test.tsxtakes the adaptations v5's timing needs (listed in the decision log) and asserts exactly one submit on expiry, which v5's listener fix makes possible.LearnerToolsSlot's README and comment no longer point plugins at the Redux store, which this layer makes unreachable.Plugins — breaking
What stops working.
AppProvideris no longer given a redux store, so it renders no react-reduxProvider. A plugin that callsuseSelectororuseDispatchnow throws (react-redux cannot find its context). The only state left in that store was the library'sspecialExamsslice; course data has been read through React Query hooks for several layers, and plugin overrides moved to@src/generic/plugin-overridesalready.Who is affected. Plugins that read
state.specialExamsor dispatch to the store. Plugins that use the hooks and context the earlier layers introduced are unaffected.What to use instead. Course data comes from the query hooks; overrides from
usePluginOverrides()(seesrc/generic/plugin-overrides/README.md). For the one exam value a plugin was given, nothing changes in its configuration: the submitted-timed-exam slot keeps its id,org.openedx.frontend.special_exams.submitted_timed_exam_instructions.v1, and still passestimeIsOverto the plugin; it is now rendered by learning instead of the library. Its README moved tosrc/plugin-slots/SubmittedTimedExamInstructionsSlot/README.md, and the example that read the course id from the store now usesuseParams().Testing
npm run lintandnpm run typesclean;npm test: 124 suites, 1,279 tests;npm run buildcompiles with webpack's two usual asset-size warnings.Sequence.specialExams.test.tsx(79 cases at the HTTP boundary, from test: cover the special-exams learner flows at the HTTP boundary #2164) passes against v5.DIRECT_PLUGINthroughSequence→SequenceExamWrapper→ the library → learning's slot: submitted on request, submitted by the timer, and with the default kept.CourseLicense.test.tsx(new) covers the license strings Studio produces; it was the app's largest untested file, rendered besideSequenceExamWrapper, and brings project coverage back above the base after this layer's deletions (decision 10).Manual testing
Manual testing — smoke test of v5 in learning (#2168)
A short in-browser pass over #2168 (learning on
@edx/frontend-lib-special-exams5.0.0) against tutor local. TheHTTP-boundary tests already cover the exam states; this only checks what they
can't: the real app wiring (learning's
SpecialExamsProviderinSequenceand
TabWithTimer, no redux store, the slot rendered by learning) againstthe real LMS, with the dev server resolving the installed package.
Setup
bsmith/special-exams-v5,npm ci, thennpm run dev.Move
module.config.jsaside first: with it present the dev serveraliases the library to the sibling checkout's source instead of the
installed package.
(
manual-testing-2018.md, Setup steps 1–4); give it a fresh short timelimit or a new learner if the old attempt is already submitted.
proctoring|attempt.Checks
it renders; no console errors (in particular nothing from react-redux or
"SpecialExamsProvider is required").
renders with no exam timer and no console errors.
start it → the unit renders with the timer above it.
timer shows there too.
exam." → "You have submitted your timed exam." (the default heading, now
rendered through learning's slot).
with no exam instructions or timer.
Results
2026-10-14: all six checks passed, run against tutor local with the dev server
resolving the installed 5.0.0 package.
Decisions
Every choice in this layer, as built, with the reasons:
Full decision log
Decisions — adopt
@edx/frontend-lib-special-examsv5 (#2018)Layer 2 of the #2018 stack on
master, above the HTTP-boundary tests(#2163). Replaces the library with v5 (openedx/frontend-lib-special-exams#176,
published as 5.0.0); the redux packages leave in the layer above. The
shape was settled in
plan-2018.md("Learning side", "Sequencing"); theentries below record it as built and what building it
decided (2026-10-09).
@edx/frontend-lib-special-exams5.0.0(
^5.0.0). The lock change is that entry plus the v4 dependencies it nolonger pulls in (frontend-plugin-framework's tree among them): a targeted
update from layer 1's lock with
node_modulesdeleted first, not aregeneration, which the teardown layer does.
SpecialExamsProviderfills the library's provider(
src/generic/special-exams/SpecialExamsProvider.tsx; it imports thelibrary's as
LibrarySpecialExamsProvider):getConfig()mapped to thecamelCase
config(getSpecialExamsConfig, also used by the testsetup's backend mock),
getAuthenticatedHttpClient(), frontend-platform'slogError, andisAuthenticatedfromAppContext'sauthenticatedUser. It shares the library's name because it sits in thetree like learning's other
XProviders and, inside learning, is thespecial exams provider. No
useMemoonconfig(review 2026-10-09): itre-renders only when
AppContextchanges (sign-in, config, locale, theParagon theme loading at startup), where a fresh
configcosts are-render of the library's components and no refetch or effect, and
reading
getConfig()on each render follows a runtimeCONFIG_CHANGED,which a
[]memo would have missed.Mounted where the library is used, not at the app root (review
2026-10-09): around
SequenceExamWrapperinSequence(whose childrenhold the unit, so
useExamAccessis covered; the unit renders nowhereelse in
Sequence) and aroundOuterExamTimerinTabWithTimer. The twoare on disjoint routes, so no page mounts it twice; the query cache
belongs to the root
QueryClienteither way. This follows the Convert Learning to frontend-base #1905tabs-as-apps shape, where each app mounts what it needs, and keeps
index.jsxand the seven tests that build their ownAppProvidertreeto the store removal alone.
SequenceContent.testandUnit/index.testrender a piece of
Sequence's tree withoutSequence, so they mountthe provider in their render helpers.
src/store.ts,AppProvider'sstoreprop and thetest
render'sstoreoption go; 15 test files drop theirinitializeStore()(andSequence.specialExams.test.tsxthe{}leftwhere
{ store: initializeStore() }was; review 2026-10-15). With nostore
AppProviderrenders no react-reduxProvider, so a plugin callinguseSelectororuseDispatchnow throws: the commit carries the!. Theonly in-repo state left in redux was the library's
specialExamsslice;the plugin store had already moved to
@src/generic/plugin-overrides.LearnerToolsSlot's README and comment told plugins to query the Reduxstore; they say backend APIs and config instead (review 2026-10-15: this
is the layer where the store stops being reachable, so the rewording moves
down from chore: remove redux from
package.json, update comments #2180, which only removes the packages; naming the query hooksin that README was considered and decided against while doing chore: remove redux from
package.json, update comments #2180).src/tests/mockSpecialExams.ts,its test and
src/tests/specialExamsFactories.tsare deleted;mockCourseRequestscallsmockSpecialExamsfrom@edx/frontend-lib-special-exams/testingwithgetSpecialExamsConfig(),and the exam test imports
ExamScenariofrom there (lib decision 0b.5). Thelibrary's mock is learning's moved, so the scenarios and recorded requests
are unchanged. Jest's mapper for the package is anchored
(
'^@edx/frontend-lib-special-exams$'): unanchored it also matched the/testingsubpath and resolved it to the package root (lib decision 0b.7).useExamAccesscollapses to the two remaining hooks and is TypeScript,having been rewritten:
{ blockAccess: isExam && isPending, accessToken }.It no longer takes the unit id:
Unitis keyed by unit id, so each unitmounts its own token query and refetches as
staleTime: 0(D3), which istoday's fetch on every unit change, and the library's
isPendingcoversthat refetch, so each unit waits for its own token as in v4 (settled in
review, lib decision 3.7). The test is rewritten around the two mocked
hooks: not an exam, pending, token arrived, exam with no token
needed. Errors are logged by the library now, not here.
src/plugin-slots/SubmittedTimedExamInstructionsSlot/is the library's oldslot component without
useSelector:timeIsOverarrives as the propSequenceExamWrapperpasses itssubmittedTimedExamInstructionscomponent, the slot id is unchanged
(
org.openedx.frontend.special_exams.submitted_timed_exam_instructions.v1),and the default heading keeps the library's message ids so the existing
translations apply. The README and its three screenshots come from the
library's history; the "Course Outline button" example read the course id
from the redux store, which no longer exists, and now uses
useParams(). The slot is listed insrc/plugin-slots/README.md.index.test.tsx, plan-2018): FPF unmocked, aDIRECT_PLUGINconfigured for the slot id that prints thetimeIsOveritreceived, and learning's
Sequencerendered with the library's backendmock, so the path is
Sequence→SequenceExamWrapper→SubmittedInstructions→ this slot → FPF → plugin. Cases: submitted onrequest (plugin shown, default absent,
timeIsOver: false), submitted bythe timer (
timeIsOver: true), andkeepDefault: true(plugin beside thedefault heading). The fake timers are drained and the clock restored in
afterEach, asSequence.specialExams.test.tsxdoes, so a timed-outfindByTextcannot leave the next case on fake timers (review 2026-10-15;the first cut did both at the end of the timer case).
("Exam test adaptations found by running
Sequence.specialExams.test.tsxagainst the v5 queries"): a non-exam sequence asks for the latest attempt
and never the exam; the different-sequence timer is awaited; the
masquerade unit is awaited behind its token; the probe reply is awaited
before the fake timeout; and fake timers are drained before the clock swap.
One assertion tightens: "when time runs out › submits the exam itself"
expected one submit per timer mounted earlier in the file because v4 never
removed its
TIMER_LIMIT_REACHEDlistener; v5 does (lib decision 2.5), soit now expects exactly
['submit'].clean; 124 suites, 1,279 tests pass;
npm run buildcompiles withwebpack's two usual asset-size warnings.
CourseLicensegains a test (CourseLicense.test.tsx; review2026-10-09). The layer deletes 121 fully covered lines (learning's copy of
the backend mock and its factories, the old
useExamAccess,store.ts)and adds none uncovered, so the same 246 misses became a larger share and
Codecov's project check, at a 0% threshold, failed (95.85% → 95.76%).
CourseLicense, whichSequencerenders besideSequenceExamWrapperinthe block this layer edits, had no test and the most uncovered lines in
the app (23). Five cases cover the license strings Studio produces: none
(the default prop), all rights reserved, Creative Commons with terms and a
version, terms without a version (4.0 by default), and no terms (CC0 1.0).
One line stays uncovered,
parseLicense's!licensefallback:Sequence, the only caller, passeslicense || undefined, so a missinglicense always arrives through the default prop.
🤖 Generated with Claude Code