refactor: convert courseware search from Redux to React Query - #1970
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #1970 +/- ##
==========================================
+ Coverage 92.34% 92.61% +0.27%
==========================================
Files 355 358 +3
Lines 5852 5851 -1
Branches 1404 1368 -36
==========================================
+ Hits 5404 5419 +15
+ Misses 429 413 -16
Partials 19 19 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
bc7e0fb to
73560b3
Compare
arbrandes
left a comment
There was a problem hiding this comment.
Looks good, but have two inline questions.
| }:CourseTabsNavigationProps) => { | ||
| const intl = useIntl(); | ||
| const { show } = useCoursewareSearchState(); | ||
| const { show } = useCoursewareSearch(); |
There was a problem hiding this comment.
Was dropping the feature-flag check here intentional?
useCoursewareSearchState() returned { show: enabled && show }, so the panel below ({show && <CoursewareSearch />}) was gated on the waffle flag too. useCoursewareSearch() returns the raw context flag, and enabled isn't reinstated anywhere. Only CoursewareSearchToggle still checks it, and only to decide whether to render its own button.
There was a problem hiding this comment.
Good catch!
My manual testing didn't include a "try to load a deep link to a search when the waffle flag is off" case, so I missed this there, and the change to the test file slipped past me.
I restructured to have the checks live in CoursewareSearch.jsx, making the CoursewareSearch component a thin wrapper around what is now CoursewareSearchModal gating on show and enabled. The wrapper component ensures we're following rules of hooks (instead of throwing an early return before the useEffect, useCoursewareSearchParams, useLockScroll etc.)
| toastBodyText: null, | ||
| toastBodyLink: null, | ||
| toastHeader: '', | ||
| showSearch: false, |
There was a problem hiding this comment.
slice.test.js still references this field — four hand-built initialState literals include showSearch, and line 141 asserts expect(newState.showSearch).toBe(initialState.showSearch) on something the slice no longer defines.
It passes rather than fails: those tests feed their own state object to the reducer, so the extra key rides through Immer untouched and the assertion is now vacuous. git grep showSearch is otherwise clean.
There was a problem hiding this comment.
73560b3 to
7070b70
Compare
ab03391 to
2ba67fb
Compare
Convert the courseware-search feature off Redux: - `data/apiHooks.ts`: `useCoursewareSearchResults` (a `useQuery` keyed by the URL search keyword) + `useCoursewareSearchEnabled` (feature-flag query, `retry: false` to match the original one-shot fail-to-false); `data/queryKeys.ts` rooted at appId. - `CoursewareSearchContext.tsx`: the `showSearch` client flag becomes a React context (`show`/`open`/`close`), provided by `CourseTabsNavigationSlot` (above the PluginSlot) so plugins in that slot keep the ability to hook into search; the toggle/panel/nav read it via `useCoursewareSearch`. - `CoursewareSearch.jsx` / `CoursewareResultsFilter.jsx` read results from the query (`isLoading`/`isError`/`data`) instead of the `contentSearchResults` model; the type filter stays client-side; fixes a rules-of-hooks ordering in the filter. - `hooks.js`: `useCoursewareSearchFeatureFlag` is now backed by the query. - remove the `searchCourseContent` + `fetchCoursewareSearchSettings` thunks and the `setShowSearch`/`showSearch` members from the course-home slice; the `contentSearchResults` model is no longer written. Behavior is preserved (spinner on each search via `isLoading`, URL-keyword results label, client-side type filter, deep-link auto-open, close/backspace clear). Architected so adopting search-on-type later is an input-handler-only change (no query/context rework), following frontend-app-catalog's pattern. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2ba67fb to
75718d7
Compare
arbrandes
left a comment
There was a problem hiding this comment.
Thanks for addressing my comments! 👍🏼
Summary
Converts the courseware search feature from Redux to React Query, per OEP-0067 ADR-0010. Part of the Redux → React Query migration (#1946), stacked on the product-tours conversion (the branch below).
Behavior is preserved — same open/close, spinner-per-search, results and "Results for …" summary, client-side type-filter tabs, deep-link auto-open, and close/backspace clear. Verified with the full test suite and a live manual smoke pass.
What changed
courseware-search/data/queryKeys.ts(rooted at appId, keyed by the URL keyword) +data/apiHooks.ts—useCoursewareSearchResults(auseQuerykeyed off?q=,enabledwhen there's a keyword) anduseCoursewareSearchEnabled(the feature-flag check,retry: falseto match the original one-shot fail-to-false).CoursewareSearchContext(show/open/close) replaces theshowSearchslice member. The provider wraps thePluginSlotinCourseTabsNavigationSlot, so a plugin in that slot can still hook into search via the exporteduseCoursewareSearch()— Redux madeshowSearchglobally reachable, and this preserves that capability rather than burying it in local state.CoursewareSearch(panel) andCoursewareResultsFilterread results from the query (isLoading/isError/data) instead of thecontentSearchResultsmodel; the type-filter tabs stay client-side (the API returns a flat list, so switching tabs doesn't refetch);CoursewareSearchToggle/CourseTabsNavigationuse the context.searchCourseContent+fetchCoursewareSearchSettingsthunks, thesetShowSearch/showSearchmembers of the course-home slice, and thecontentSearchResultsmodel write.CoursewareResultsFilter(twouseMemos were running after early returns).frontend-app-catalog's search — so switching to search-on-type later is an input-handler-only change (debounce +keepPreviousData), with no rework of the query, query key, or client state. See the decision log.Testing
Automated:
npm run types,npm run lint,npm run build, and the fullnpm testsuite pass.Manual smoke (dev, with the
courseware.mfe_courseware_searchflag on): toggle open (scroll-lock, no URL change) → submit → spinner → results + "Results for …" summary → filter tabs drive?f=→?q=set on submit → close/backspace clear → deep-link auto-opens and runs the search on the right tab → a second search blanks + re-spins (matches the on-submit baseline). No search-related console errors.Decisions
Full decision log
Decisions — Redux → React Query: courseware search
Working notes for this PR (part of the wider Redux → React Query migration,
#1946). Not checked in — referenced when opening the PR. Stacked on the
product-tours conversion (#1968).
Scope
Convert the
courseware-searchfeature off Redux:searchCourseContentthunk (writes thecontentSearchResultsmodel) → aReact Query query keyed by the URL search keyword.
setShowSearchaction +showSearchstate (client UI flag) → aCoursewareSearchProviderReact context (see Client state below).fetchCoursewareSearchSettings(already a plain async fn, not Redux) → auseQueryfor the feature flag (dedupe/caching; improvement, not Redux removal).Removes the thunk, the
setShowSearch/showSearchslice members, and onemodelswrite (contentSearchResults). The courseHome slice stays (tabmachinery).
Architecture: search-on-type readiness (from frontend-app-catalog)
We convert on-submit now (faithful to current behavior), but architect so
adopting search-on-type later is an input-handler-only change — no rework of
the React Query hook, query key, or client state. Basis:
frontend-app-catalog'ssearch, which landed search-on-type starting from an on-submit design without
touching its query layer (catalog PR #42, tracker #48, polish #76). The
load-bearing lesson from that thread: "only searching using
onSubmitis hidingissues" — on-submit masks the state bugs (cyclic re-renders, flicker, stale
results) that on-type exposes, so build the query/state layer right up front.
Query hook —
useCoursewareSearchResults(courseId, keyword):queryKey: [appId, courseId, 'search', keyword]— the term is in the key.The
?f=type filter stays client-side on the results (as today,CoursewareResultsFilter), so it is not in the key (unlike catalog, whichkeyed filters into the query).
placeholderData: keepPreviousData— deliberately NOT added now (deferred tothe search-on-type work). Tested the current behavior: a second search (with the
panel open) blanks the results and shows the spinner each time. That's the
correct feedback for search-on-click (a click should visibly start a fresh
search), and only becomes wrong for search-on-type (where you want the
previous results to stay put between keystrokes). Since this PR stays on-click, we
keep the faithful blank+spinner behavior — spinner driven off
isPending(default), no
placeholderData.placeholderData: keepPreviousDataand drive thespinner off
isFetching(notisPending) so previous results persist while thenext keystroke's fetch runs (also gives out-of-order-response protection without
an
AbortSignal). This is a small, localized query-hook + spinner change — itdoes not touch the query key, the term/URL flow, or the client state, so the
on-type-readiness goal holds. (Note: the v5 form is the imported helper
placeholderData: keepPreviousData; thekeepPreviousData: truebooleanoption was removed in v5, but the helper import remains — catalog's
(prev) => previs the exact inline equivalent.)enabled: !!keyword— courseware search is idle-when-empty (showsnothing until you search), unlike catalog's browse-all-when-empty (no
enabled).Orthogonal to on-type. No min-length gating (catalog deliberately didn't).
Loading status uses
isLoading(=isPending && isFetching), notisFetchingor
isPendingalone. In v5 the disabled (no-keyword) query isisPending: truewith
fetchStatus: 'idle', soisPendingalone would show a spinner at the idlestate;
isFetchingalone would flash the spinner on a background focus-refetch(data present).
isLoadingis true only while fetching with no data to show —which reproduces the baseline exactly (spinner on each fresh/new-keyword search,
nothing at idle) and degrades gracefully if a background refetch ever runs. It also
means we don't need
refetchOnWindowFocus: falsehere (unlike the tours query): afocus refetch is invisible because
isLoadingstays false while data is present.Feature-flag query —
useCoursewareSearchEnabled(courseId)usesretry: false(consumer reads
data?.enabled ?? false). This matches the originalfetchCoursewareSearchSettings, which wrappedgetCoursewareSearchEnabledin atry/catchand returned{ enabled: false }one-shot: fail fast to "not enabled"rather than retrying a "should I show this button" probe. The results query keeps
the bare-client default retry.
Term flow / choke point — the keyword stays in the URL (
?q=, viauseCoursewareSearchParams);handleSubmit → setQueryis the single choke point.Adopting on-type later = debounce the input's
onChange → setQueryin a separateinput hook, with
setSearchParams(..., { replace: true })(avoid history spam)and a
lastQueryRefguard on the debounced dispatch — not the live term — toavoid the cyclic re-fire catalog hit, and don't fully control the input
valuewhile debouncing (their "repeating" bug). None of that touches the query hook or
the
showSearchstate.Client state (
showSearch) → context, not local stateshowSearchbecomes aCoursewareSearchProvidercontext (show+open()+close()), exported from the feature alongside auseCoursewareSearch()hook.Why context and not lifted local state (the first instinct —
CourseTabsNavigationis the single common parent of the toggle + panel). Redux's
showSearchwasglobally readable, so a plugin dropped into
CourseTabsNavigationSlotcould hookinto search today via
useSelector/dispatch. That's not an official API (nopluginProp), so breaking a plugin's old Redux code is acceptable — but making itimpossible to interact with search would be a needless capability regression.
Local state (or a context buried inside the default
CourseTabsNavigation) woulddo exactly that: a plugin that wraps or replaces the nav via the slot renders
outside it and couldn't reach the state. Context also keeps the state with the
search feature (not the nav component) and drops prop-drilling — consistent with
the tours conversion and OEP-0067.
Placement: the provider wraps the
PluginSlotinsideCourseTabsNavigationSlot,so everything the slot renders — the default nav, the toggle, the panel, and any
plugin — is inside it and can call the exported
useCoursewareSearch(). It has tosit above the
PluginSlot(not insideCourseTabsNavigation) precisely soslot plugins keep access. The panel stays conditionally mounted
(
{show && <CoursewareSearch />}) so itsshowModal()/scroll-lock mount effectsstill fire on open;
CourseTabsNavigationreadsshowfor that gate, the togglereads
open, the panel readsclose.Lifecycle: the provider is per-tab, so
showresets on tab navigation — butthis is unobservable, because search can't be open across a tab change: the
panel is a modal (
dialog.showModal()) that inerts the background (the tab barisn't clickable while it's open), closing via the X clears the query string, and
popstateis wired to close it. So per-tab scope is fine; Redux's globalpersistence of
showSearchhad no reachable effect here.Follow-up option: if we later want a first-class API rather than "import our
hook," expose search controls as a
pluginPropon the slot. Not needed now.Results filtering stays client-side
The
/search/{courseId}endpoint returns a flat array of all results — it takesno filter param.
CoursewareResultsFilterpartitions that array client-side (auseMemoreducer) by each result'stypeinto buckets (all+text/video/sequence/other), renders one Paragon<Tab>per non-empty bucket, and the?f=param just selects which already-computed bucket to show. Switching filtersdoes not refetch. (It also skips the tabs when there'd be < 3, i.e. "all" plus
a single identical bucket.)
CoursewareResultsFilter; only thesource of the flat array changes:
useModel('contentSearchResults', courseId)→
data.resultsfromuseCoursewareSearchResults(courseId, keyword). The?f=tab selection is untouched.
?f=filter is deliberately NOT in the query key — switching tabs mustnot refetch (matches today). This is the key contrast with catalog, which put
filters in the query because its API filtered server-side.
CoursewareResultsFiltercalls itsuseMemos after two early returns (if (!lastSearch) return null,if (!data.length) return …) — a conditional-hooks violation that happens towork. While rewiring this file, hoist the early returns below the hooks so
the hooks always run.
Results-summary label keyword: use the URL term, drop
lastSearchKeywordThe results summary reads
Results for "<keyword>":. The original sourced thatkeyword from the model (
contentSearchResults.searchKeyword, aliasedlastSearchKeyword) — the keyword the displayed results are for — separate fromsearchKeyword(the current URL/?q=term). The RQ port usessearchKeyword(the URL term) and drops
lastSearchKeyword.Why that's correct, not a regression:
The URL only changes on submit (
setQuery, same handler that starts the fetch);while fetching,
status === 'loading'hides the summary; when results arrive themodel's keyword equals the submitted value equals the URL. So
lastSearchKeywordwas redundant here — the original read from the model only because results +
keyword were stored together, not to guard a differ-case. (The existing test's
model='fubar'/ URL=''split is an artificial mock, not a real state.)heading uses the live input/URL
searchString, not the term the data is for,and it does not guard the
keepPreviousDatamismatch window (heading showsthe new term while the list still shows the previous term mid-fetch). Catalog
PR fix: “current.” was left over from when the implementation used refs #42 even removed a dedicated "last search query" heading state in favor of
the live term. So using the URL term matches the reference even under
search-on-type.
Consequences: no need to echo the keyword back in the query data (an earlier
idea, dropped); and no on-type follow-up for the label — the URL term is the
intended behavior for on-type too. If we ever wanted the label to always match the
on-screen rows under
keepPreviousData(stricter than catalog), we'd derive itfrom the query data instead — but that's a deliberate deviation we're not making.
Tests & coverage
CoursewareSearchContext.test.tsx— provider/hook unit tests (initialshow,open/close, the throw-outside-provider). These were the biggest patch gapsince every consumer test mocks the context.
data/apiHooks.test.tsx—useCoursewareSearchEnabledanduseCoursewareSearchResults(fetch +mapSearchResponse, and theenabledgatethat skips fetching with no keyword); also exercises
queryKeys.CoursewareSearch,CoursewareResultsFilter,CoursewareSearchToggle,CourseTabsNavigation,hooks) were updated to mock the query + context insteadof the model/thunk/slice.
CourseTabsNavigationSlottest. The only way tocover the slot's provider wrapper is to assert its default content is
CourseTabsNavigation— which over-commits a test to a default the Slot APIintentionally leaves replaceable. So the few provider-wrapper lines there stay
uncovered on purpose rather than baking in that assumption.
Current behavior (baseline — from live manual testing on master)
Feature gated by the
courseware.mfe_courseware_searchCourseWaffleFlag(
COURSEWARE_MICROFRONTEND_SEARCH_ENABLED); the/courseware-search/enabled/endpoint just checks that flag, which is what makes the "Content search" toggle
appear in the course tab bar.
Open: the dialog covers everything below the header/masquerade bar (anchored
under
courseTabsNavigation), showing the "Search this course" idle state (titlelocked while open (
useLockScroll).Typing vs submitting: typing in the box does not change the URL; only
submitting (the search button) sets
?q=<term>. (onChangeonly clears onempty;
handleSubmitcallssetQuery.) → this is why keying the query off theURL keyword is faithful: it re-runs on submit, not per keystroke.
Submitting a search: shows a spinner while loading, then results. Above the
results, a summary line reads
Results for "<keyword>":— there is nonumeric count (the
searchResultsLabelmessage only uses{keyword};totalis passed but unused, and only gates whether the summary renders, via
total > 0). Results have filter tabs — all content / text / other — backedby the
?f=URL param (CoursewareResultsFilter): text →f=text,other →
f=other. Each result row is a content-type icon (a "T" for text vsa document icon for other) + the unit title with a per-result match-count
badge (e.g. "Code Grading 3") + a breadcrumb path to the unit. (Confirmed
against a live screenshot.)
Closing / clearing: the X clears
?q=/?f=and the results; reopeningstarts fresh. Backspacing the box to empty also clears the query param and
results immediately — no submit needed (
handleOnChange:if (!value) clearSearch()). Feels odd but preserved as-is.Deep link: loading a course URL that already has
?q=<term>(and optional?f=) auto-opens the search dialog and runs the search, landing on the?f=results tab. Two effects:CoursewareSearchToggleopens whenenabled && query;CoursewareSearchrunshandleSubmiton mount when there'sa keyword. The URL-keyed query preserves this (it auto-runs off the URL keyword
on load); the auto-open becomes: initialize the lifted
showSearchfromenabled && query.Known quirks (preserve-vs-fix TBD during conversion)
?q=&f=onto the URL, even when no searchwas performed. Cause:
close()→clearSearch()→clearSearchParams()doessetSearchParams({ q: '', f: '' })(theinitSearchParams), and React Routerwrites empty-valued params. Lives in
useCoursewareSearchParams(a URL hook,orthogonal to the Redux work). Almost certainly unintended — candidate for an
opportunistic fix (remove the params, or don't touch the URL when nothing to
clear) when we rework
close().all-content tab is active with
f=(blank), but explicitly clicking theall-content tab sets
f=all. So the same filter is both""and"all".Cosmetic URL inconsistency in
CoursewareResultsFilter; candidate to normalize.Closes #1979
🤖 Generated with Claude Code