Skip to content

Commit 27be8b3

Browse files
authored
refactor(course-outline): migrate to React Query and consolidate outline architecture (#3073)
Large architectural refactor of the course outline module. Migrated from Redux to React Query for outline data and mutation flows, consolidated context management, and unified component rendering. **1. Redux to React Query Migration** - Removed Redux outline slice, thunks, selectors, and all Redux-based state management - All outline data fetching now uses React Query hooks - Mutations (reorder, delete, configure, publish, duplicate) centralized in `apiHooks.ts` - Cache invalidation handled via dedicated utilities in `cacheInvalidation.ts` **2. Context Consolidation** - Outline state consolidated into `CourseOutlineContext`; sidebar/drag contexts remain separate - Extracted state management into focused hooks: `useOutlineReorderState`, `useOutlineStatusState`, `useConfigureModal`, `useDeleteModal`, `useHighlightsModal`, `useUnlinkModal`, `useCreateBlockSidebar`, `useModalState` - Sidebar modal state simplified with `OutlineModals` component **3. Component Unification** - Card components (SectionCard, SubsectionCard, UnitCard) replaced by single recursive `OutlineNode` component; legacy style/message modules remain where still imported - Introduced `OutlineTree` for recursive outline rendering with depth-based logic **4. Test Infrastructure Modernization** - Old fixture usage replaced by shared builders/utilities in `__mocks__/testSetup.tsx` and `__mocks__/helpers.ts` - Added comprehensive hook tests (apiHooks, useOutlineReorderState, etc.) - Migrated all outline tests to use new fixture builders and shared setup **5. Bug fixes** - **Title Edit Escape Fix**: Pressing Escape while editing a title now correctly cancels the edit instead of saving. Previously the blur handler fired on Escape, persisting unintended changes. - **?show Scroll Fix**: Repeated scroll to item based on `?show=` is prevented. It will only scroll to the item once.
1 parent de79c0c commit 27be8b3

116 files changed

Lines changed: 10844 additions & 9133 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/CourseAuthoringContext.tsx

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,12 @@ import {
55
useMemo,
66
} from 'react';
77
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
8-
import { useSelector } from 'react-redux';
98
import { useNavigate } from 'react-router';
109
import { useToggleWithValue } from '@src/hooks';
1110
import { type UnitXBlock, type XBlock } from '@src/data/types';
1211
import { CourseDetailsData } from './data/api';
1312
import { useCourseDetails, useWaffleFlags } from './data/apiHooks';
1413
import { RequestStatusType } from './data/constants';
15-
import { getOutlineIndexData } from './course-outline/data/selectors';
1614

1715
export type ModalState = {
1816
value?: XBlock | UnitXBlock;
@@ -23,7 +21,6 @@ export type ModalState = {
2321
export type CourseAuthoringContextData = {
2422
/** The ID of the current course */
2523
courseId: string;
26-
courseUsageKey: string;
2724
courseDetails?: CourseDetailsData;
2825
courseDetailStatus: RequestStatusType;
2926
canChangeProviders: boolean;
@@ -56,8 +53,6 @@ export const CourseAuthoringProvider = ({
5653
const waffleFlags = useWaffleFlags();
5754
const { data: courseDetails, status: courseDetailStatus } = useCourseDetails(courseId);
5855
const canChangeProviders = getAuthenticatedUser().administrator || new Date(courseDetails?.start ?? 0) > new Date();
59-
const { courseStructure } = useSelector(getOutlineIndexData);
60-
const { id: courseUsageKey } = courseStructure || {};
6156
const [
6257
isUnlinkModalOpen,
6358
currentUnlinkModalData,
@@ -88,7 +83,6 @@ export const CourseAuthoringProvider = ({
8883

8984
const context = useMemo<CourseAuthoringContextData>(() => ({
9085
courseId,
91-
courseUsageKey,
9286
courseDetails,
9387
courseDetailStatus,
9488
canChangeProviders,
@@ -100,7 +94,6 @@ export const CourseAuthoringProvider = ({
10094
currentUnlinkModalData,
10195
}), [
10296
courseId,
103-
courseUsageKey,
10497
courseDetails,
10598
courseDetailStatus,
10699
canChangeProviders,

src/CourseAuthoringRoutes.tsx

Lines changed: 115 additions & 181 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
Routes,
44
Route,
55
useParams,
6+
Outlet,
67
} from 'react-router-dom';
78
import { getConfig } from '@edx/frontend-platform';
89
import { PageWrap } from '@edx/frontend-platform/react';
@@ -16,10 +17,10 @@ import { FilesPage, VideosPage } from './files-and-videos';
1617
import { AdvancedSettings } from './advanced-settings';
1718
import {
1819
CourseOutline,
20+
CourseOutlineProvider,
1921
OutlineSidebarProvider,
2022
OutlineSidebarPagesProvider,
2123
} from './course-outline';
22-
import { CourseOutlineProvider } from './course-outline/CourseOutlineContext';
2324
import ScheduleAndDetails from './schedule-and-details';
2425
import { GradingSettings } from './grading-settings';
2526
import CourseTeam from './course-team/CourseTeam';
@@ -38,6 +39,13 @@ import { CourseAuthoringProvider } from './CourseAuthoringContext';
3839
import { CourseImportProvider } from './import-page/CourseImportContext';
3940
import { CourseExportProvider } from './export-page/CourseExportContext';
4041

42+
/** Layout route: renders its child routes inside PageWrap. */
43+
const PageWrapLayout = () => (
44+
<PageWrap>
45+
<Outlet />
46+
</PageWrap>
47+
);
48+
4149
/**
4250
* As of this writing, these routes are mounted at a path prefixed with the following:
4351
*
@@ -62,208 +70,134 @@ const CourseAuthoringRoutes = () => {
6270
throw new Error('Error: route is missing courseId.');
6371
}
6472

73+
const enableVideos = getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true';
74+
const enableCertificates = getConfig().ENABLE_CERTIFICATE_PAGE === 'true';
75+
6576
return (
6677
<CourseAuthoringProvider courseId={courseId}>
6778
<CourseAuthoringPage>
6879
<Routes>
69-
<Route
70-
path="/"
71-
element={
72-
<PageWrap>
73-
<CourseOutlineProvider>
80+
<Route element={<PageWrapLayout />}>
81+
<Route
82+
path="/"
83+
element={
84+
<CourseOutlineProvider key={courseId}>
7485
<OutlineSidebarPagesProvider>
7586
<OutlineSidebarProvider>
7687
<CourseOutline />
7788
</OutlineSidebarProvider>
7889
</OutlineSidebarPagesProvider>
7990
</CourseOutlineProvider>
80-
</PageWrap>
81-
}
82-
/>
83-
<Route
84-
path="course_info"
85-
element={
86-
<PageWrap>
87-
<CourseUpdates />
88-
</PageWrap>
89-
}
90-
/>
91-
<Route
92-
path="libraries"
93-
element={
94-
<PageWrap>
95-
<CourseLibraries />
96-
</PageWrap>
97-
}
98-
/>
99-
<Route
100-
path="assets"
101-
element={
102-
<PageWrap>
103-
<FilesPage />
104-
</PageWrap>
105-
}
106-
/>
107-
<Route
108-
path="videos"
109-
element={getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true'
110-
? (
111-
<PageWrap>
112-
<VideosPage />
113-
</PageWrap>
114-
)
115-
: null}
116-
/>
117-
<Route
118-
path="pages-and-resources/*"
119-
element={
120-
<PageWrap>
121-
<PagesAndResources />
122-
</PageWrap>
123-
}
124-
/>
125-
<Route
126-
path="proctored-exam-settings"
127-
element={<Navigate replace to={`/course/${courseId}/pages-and-resources`} />}
128-
/>
129-
<Route
130-
path="custom-pages/*"
131-
element={
132-
<PageWrap>
133-
<CustomPages />
134-
</PageWrap>
135-
}
136-
/>
137-
<Route
138-
path="/subsection/:subsectionId"
139-
element={
140-
<PageWrap>
141-
<SubsectionUnitRedirect />
142-
</PageWrap>
143-
}
144-
/>
145-
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
91+
}
92+
/>
14693
<Route
147-
key={path}
148-
path={path}
149-
element={
150-
<PageWrap>
94+
path="course_info"
95+
element={<CourseUpdates />}
96+
/>
97+
<Route
98+
path="libraries"
99+
element={<CourseLibraries />}
100+
/>
101+
<Route
102+
path="assets"
103+
element={<FilesPage />}
104+
/>
105+
{enableVideos && (
106+
<Route
107+
path="videos"
108+
element={<VideosPage />}
109+
/>
110+
)}
111+
<Route
112+
path="pages-and-resources/*"
113+
element={<PagesAndResources />}
114+
/>
115+
<Route
116+
path="custom-pages/*"
117+
element={<CustomPages />}
118+
/>
119+
<Route
120+
path="/subsection/:subsectionId"
121+
element={<SubsectionUnitRedirect />}
122+
/>
123+
{DECODED_ROUTES.COURSE_UNIT.map((path) => (
124+
<Route
125+
key={path}
126+
path={path}
127+
element={
151128
<IframeProvider>
152129
<CourseUnit />
153130
</IframeProvider>
154-
</PageWrap>
155-
}
131+
}
132+
/>
133+
))}
134+
<Route
135+
path="editor/course-videos/:blockId"
136+
element={<VideoSelectorContainer />}
156137
/>
157-
))}
158-
<Route
159-
path="editor/course-videos/:blockId"
160-
element={
161-
<PageWrap>
162-
<VideoSelectorContainer />
163-
</PageWrap>
164-
}
165-
/>
166-
<Route
167-
path="editor/:blockType/:blockId?"
168-
element={
169-
<PageWrap>
170-
<EditorContainer learningContextId={courseId} />
171-
</PageWrap>
172-
}
173-
/>
174-
<Route
175-
path="settings/details"
176-
element={
177-
<PageWrap>
178-
<ScheduleAndDetails />
179-
</PageWrap>
180-
}
181-
/>
182-
<Route
183-
path="settings/grading"
184-
element={
185-
<PageWrap>
186-
<GradingSettings />
187-
</PageWrap>
188-
}
189-
/>
190-
<Route
191-
path="course_team"
192-
element={
193-
<PageWrap>
194-
<CourseTeam />
195-
</PageWrap>
196-
}
197-
/>
198-
<Route
199-
path="group_configurations"
200-
element={
201-
<PageWrap>
202-
<GroupConfigurations />
203-
</PageWrap>
204-
}
205-
/>
206-
<Route
207-
path="settings/advanced"
208-
element={
209-
<PageWrap>
210-
<AdvancedSettings />
211-
</PageWrap>
212-
}
213-
/>
214-
<Route
215-
path="import"
216-
element={
217-
<PageWrap>
138+
<Route
139+
path="editor/:blockType/:blockId?"
140+
element={<EditorContainer learningContextId={courseId} />}
141+
/>
142+
<Route
143+
path="settings/details"
144+
element={<ScheduleAndDetails />}
145+
/>
146+
<Route
147+
path="settings/grading"
148+
element={<GradingSettings />}
149+
/>
150+
<Route
151+
path="course_team"
152+
element={<CourseTeam />}
153+
/>
154+
<Route
155+
path="group_configurations"
156+
element={<GroupConfigurations />}
157+
/>
158+
<Route
159+
path="settings/advanced"
160+
element={<AdvancedSettings />}
161+
/>
162+
<Route
163+
path="import"
164+
element={
218165
<CourseImportProvider>
219166
<CourseImportPage />
220167
</CourseImportProvider>
221-
</PageWrap>
222-
}
223-
/>
224-
<Route
225-
path="export"
226-
element={
227-
<PageWrap>
168+
}
169+
/>
170+
<Route
171+
path="export"
172+
element={
228173
<CourseExportProvider>
229174
<CourseExportPage />
230175
</CourseExportProvider>
231-
</PageWrap>
232-
}
233-
/>
234-
<Route
235-
path="optimizer"
236-
element={
237-
<PageWrap>
238-
<CourseOptimizerPage />
239-
</PageWrap>
240-
}
241-
/>
242-
<Route
243-
path="checklists"
244-
element={
245-
<PageWrap>
246-
<CourseChecklist />
247-
</PageWrap>
248-
}
249-
/>
250-
<Route
251-
path="certificates"
252-
element={getConfig().ENABLE_CERTIFICATE_PAGE === 'true'
253-
? (
254-
<PageWrap>
255-
<Certificates />
256-
</PageWrap>
257-
)
258-
: null}
259-
/>
176+
}
177+
/>
178+
<Route
179+
path="optimizer"
180+
element={<CourseOptimizerPage />}
181+
/>
182+
<Route
183+
path="checklists"
184+
element={<CourseChecklist />}
185+
/>
186+
{enableCertificates && (
187+
<Route
188+
path="certificates"
189+
element={<Certificates />}
190+
/>
191+
)}
192+
<Route
193+
path="textbooks"
194+
element={<Textbooks />}
195+
/>
196+
</Route>
197+
{/* Routes without PageWrap */}
260198
<Route
261-
path="textbooks"
262-
element={
263-
<PageWrap>
264-
<Textbooks />
265-
</PageWrap>
266-
}
199+
path="proctored-exam-settings"
200+
element={<Navigate replace to={`/course/${courseId}/pages-and-resources`} />}
267201
/>
268202
</Routes>
269203
</CourseAuthoringPage>

0 commit comments

Comments
 (0)