Skip to content

Commit cada6a7

Browse files
committed
fix: prerequisite setting in sidebar and partial updates
1 parent dbb2458 commit cada6a7

9 files changed

Lines changed: 141 additions & 72 deletions

File tree

src/course-outline/data/api.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -239,28 +239,28 @@ export async function configureCourseSection(variables: ConfigureSectionData): P
239239
/**
240240
* Configure course subsection
241241
*/
242-
export async function configureCourseSubsection(variables: ConfigureSubsectionData): Promise<object> {
242+
export async function configureCourseSubsection(variables: Partial<ConfigureSubsectionData> & Pick<ConfigureSubsectionData, 'itemId'>): Promise<object> {
243243
const { data } = await getAuthenticatedHttpClient()
244244
.post(getCourseItemApiUrl(variables.itemId), {
245245
publish: 'republish',
246-
graderType: variables.graderType,
247-
isPrereq: variables.isPrereq,
248-
prereqUsageKey: variables.prereqUsageKey,
249-
prereqMinScore: variables.prereqMinScore,
250-
prereqMinCompletion: variables.prereqMinCompletion,
246+
...(variables.graderType !== undefined && { graderType: variables.graderType }),
247+
...(variables.isPrereq !== undefined && { isPrereq: variables.isPrereq }),
248+
...(variables.prereqUsageKey !== undefined && { prereqUsageKey: variables.prereqUsageKey }),
249+
...(variables.prereqMinScore !== undefined && { prereqMinScore: variables.prereqMinScore }),
250+
...(variables.prereqMinCompletion !== undefined && { prereqMinCompletion: variables.prereqMinCompletion }),
251251
metadata: {
252252
// The backend expects metadata.visible_to_staff_only to either true or null
253-
visible_to_staff_only: variables.isVisibleToStaffOnly ? true : null,
254-
due: variables.dueDate,
255-
hide_after_due: variables.hideAfterDue,
256-
show_correctness: variables.showCorrectness,
257-
is_practice_exam: variables.isPracticeExam,
258-
is_time_limited: variables.isTimeLimited,
259-
is_proctored_enabled: variables.isProctoredExam || variables.isPracticeExam || variables.isOnboardingExam,
260-
exam_review_rules: variables.examReviewRules,
261-
default_time_limit_minutes: variables.defaultTimeLimitMinutes,
262-
is_onboarding_exam: variables.isOnboardingExam,
263-
start: variables.releaseDate,
253+
...(variables.isVisibleToStaffOnly !== undefined && { visible_to_staff_only: variables.isVisibleToStaffOnly ? true : null }),
254+
...(variables.dueDate !== undefined && { due: variables.dueDate }),
255+
...(variables.hideAfterDue !== undefined && { hide_after_due: variables.hideAfterDue }),
256+
...(variables.showCorrectness !== undefined && { show_correctness: variables.showCorrectness }),
257+
...(variables.isPracticeExam !== undefined && { is_practice_exam: variables.isPracticeExam }),
258+
...(variables.isTimeLimited !== undefined && { is_time_limited: variables.isTimeLimited }),
259+
...(variables.isProctoredExam !== undefined || variables.isPracticeExam !== undefined || variables.isOnboardingExam !== undefined ? { is_proctored_enabled: variables.isProctoredExam || variables.isPracticeExam || variables.isOnboardingExam } : {}),
260+
...(variables.examReviewRules !== undefined && { exam_review_rules: variables.examReviewRules }),
261+
...(variables.defaultTimeLimitMinutes !== undefined && { default_time_limit_minutes: variables.defaultTimeLimitMinutes }),
262+
...(variables.isOnboardingExam !== undefined && { is_onboarding_exam: variables.isOnboardingExam }),
263+
...(variables.releaseDate !== undefined && { start: variables.releaseDate }),
264264
},
265265
});
266266
return data;

src/course-outline/data/apiHooks.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
import { getNotificationMessage } from '@src/course-unit/data/utils';
1010
import { createGlobalState } from '@src/data/apiHooks';
1111
import type { XBlockBase, XblockChildInfo } from '@src/data/types';
12-
import { getBlockType, getCourseKey } from '@src/generic/key-utils';
12+
import { ContainerType, getBlockType, getCourseKey, normalizeContainerType } from '@src/generic/key-utils';
1313
import { useMutationWithProcessingNotification } from '@src/generic/processing-notification/data/apiHooks';
1414
import { handleResponseErrors } from '@src/generic/saving-error-alert';
1515
import { useToastContext } from '@src/generic/toast-context';
@@ -243,10 +243,27 @@ export const useConfigureSection = () => {
243243
export const useConfigureSubsection = () => {
244244
const queryClient = useQueryClient();
245245
return useMutationWithProcessingNotification({
246-
mutationFn: (variables: ConfigureSubsectionData & ParentIds) => configureCourseSubsection(variables),
247-
onSettled: (_data, _err, variables) => {
248-
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.itemId)) });
246+
mutationFn: (
247+
variables: Partial<ConfigureSubsectionData> & Pick<ConfigureSubsectionData, 'itemId'> & ParentIds
248+
) => configureCourseSubsection(variables),
249+
onSettled: async (_data, _err, variables) => {
250+
const courseKey = getCourseKey(variables.itemId);
251+
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(courseKey) });
249252
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
253+
if (variables.isPrereq !== undefined) {
254+
const subsectionItemQueries = queryClient.getQueryCache().findAll({
255+
predicate: (query) => {
256+
const queryKey = query.queryKey;
257+
return Array.isArray(queryKey)
258+
&& queryKey.length >= 3
259+
&& queryKey[0] === courseOutlineQueryKeys.all[0]
260+
&& queryKey[1] === courseKey
261+
&& typeof queryKey[2] === 'string'
262+
&& normalizeContainerType(getBlockType(queryKey[2], 'empty')) === ContainerType.Subsection
263+
},
264+
});
265+
await Promise.all(subsectionItemQueries.map((query) => queryClient.invalidateQueries({ queryKey: query.queryKey })));
266+
}
250267
},
251268
});
252269
};

src/course-outline/data/types.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,22 +100,22 @@ export interface ConfigureSectionData {
100100

101101
export interface ConfigureSubsectionData {
102102
itemId: string,
103-
isVisibleToStaffOnly: boolean,
104-
releaseDate: string,
105-
graderType: string,
106-
dueDate: string,
103+
isVisibleToStaffOnly?: boolean,
104+
releaseDate?: string,
105+
graderType?: string,
106+
dueDate?: string,
107107
isTimeLimited?: boolean,
108108
isProctoredExam?: boolean,
109109
isOnboardingExam?: boolean,
110110
isPracticeExam?: boolean,
111111
examReviewRules?: string,
112112
defaultTimeLimitMinutes?: number,
113-
hideAfterDue: boolean,
114-
showCorrectness: 'always' | 'never' | 'past_due' | 'never_but_include_grade',
113+
hideAfterDue?: boolean,
114+
showCorrectness?: 'always' | 'never' | 'past_due' | 'never_but_include_grade',
115115
isPrereq?: boolean,
116116
prereqUsageKey?: string,
117117
prereqMinScore?: number,
118-
prereqMinCompletion: number,
118+
prereqMinCompletion?: number,
119119
}
120120

121121
export interface ConfigureUnitData {

src/course-outline/outline-sidebar/info-sidebar/SubsectionSettings.tsx

Lines changed: 20 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,11 @@ import { getProctoredExamsFlag, getTimedExamsFlag } from '@src/course-outline/da
77
import { ConfigureSubsectionData } from '@src/course-outline/data/types';
88
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
99
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
10-
import { VisibilityTypes } from '@src/data/constants';
1110
import AdvancedTab from '@src/generic/configure-modal/AdvancedTab';
1211
import { DatepickerControl, DATEPICKER_TYPES } from '@src/generic/datepicker-control';
1312
import { SidebarContent, SidebarSection } from '@src/generic/sidebar';
1413
import { useStateWithCallback } from '@src/hooks';
15-
import { useState } from 'react';
14+
import { useCallback, useEffect, useState } from 'react';
1615
import { useSelector } from 'react-redux';
1716
import { ReleaseSection } from './sharedSettings/ReleaseSection';
1817
import messages from './messages';
@@ -166,18 +165,31 @@ const SpecialExamSection = ({ subsectionId, onChange }: SubProps) => {
166165
const { data: itemData } = useCourseItemData(subsectionId);
167166
const enableTimedExams = useSelector(getTimedExamsFlag);
168167
const enableProctoredExams = useSelector(getProctoredExamsFlag);
169-
const [localState, setLocalState] = useStateWithCallback<Partial<ConfigureSubsectionData>>(
170-
{
168+
const getLatestLocalState = useCallback(() => {
169+
return {
171170
isProctoredExam: itemData?.isProctoredExam,
172171
isTimeLimited: itemData?.isTimeLimited,
173172
isOnboardingExam: itemData?.isOnboardingExam,
174173
isPracticeExam: itemData?.isPracticeExam,
175174
defaultTimeLimitMinutes: itemData?.defaultTimeLimitMinutes,
176175
examReviewRules: itemData?.examReviewRules,
177-
},
178-
(val) => onChange(val || {}),
176+
isPrereq: itemData?.isPrereq,
177+
prereqMinScore: defaultPrereqScore(itemData?.prereqMinScore),
178+
prereqMinCompletion: defaultPrereqScore(itemData?.prereqMinCompletion),
179+
prereqUsageKey: itemData?.prereq,
180+
};
181+
}, [itemData]);
182+
183+
const [localState, setLocalState] = useStateWithCallback<Partial<ConfigureSubsectionData>>(
184+
getLatestLocalState(),
185+
(val) => onChange(val || {})
179186
);
180187

188+
useEffect(() => {
189+
if (!itemData) return;
190+
setLocalState({ value: getLatestLocalState(), skipCallback: true });
191+
}, [itemData]);
192+
181193
const setFieldValue = (key: keyof ConfigureSubsectionData, value: any) => {
182194
setLocalState((prev) => ({
183195
...prev,
@@ -190,14 +202,7 @@ const SpecialExamSection = ({ subsectionId, onChange }: SubProps) => {
190202
title={intl.formatMessage(messages.subsectionSpecialExamTitle)}
191203
>
192204
<AdvancedTab
193-
values={{
194-
isProctoredExam: localState?.isProctoredExam,
195-
isTimeLimited: localState?.isTimeLimited,
196-
isOnboardingExam: localState?.isOnboardingExam,
197-
isPracticeExam: localState?.isPracticeExam,
198-
defaultTimeLimitMinutes: localState?.defaultTimeLimitMinutes,
199-
examReviewRules: localState?.examReviewRules,
200-
}}
205+
values={localState || {}}
201206
setFieldValue={setFieldValue}
202207
prereqs={itemData?.prereqs}
203208
releasedToStudents={itemData?.releasedToStudents}
@@ -226,25 +231,10 @@ export const SubsectionSettings = ({ subsectionId }: Props) => {
226231
if (isPending || !itemData) {
227232
return;
228233
}
234+
229235
mutate({
230236
itemId: subsectionId,
231237
sectionId: selectedContainerState?.sectionId,
232-
isVisibleToStaffOnly: itemData.visibilityState === VisibilityTypes.STAFF_ONLY,
233-
releaseDate: itemData.start,
234-
graderType: itemData.format == null ? 'notgraded' : itemData.format,
235-
dueDate: itemData.due == null ? '' : itemData.due,
236-
isTimeLimited: itemData.isTimeLimited,
237-
isProctoredExam: itemData.isProctoredExam,
238-
isOnboardingExam: itemData.isOnboardingExam,
239-
isPracticeExam: itemData.isPracticeExam,
240-
examReviewRules: itemData.examReviewRules,
241-
defaultTimeLimitMinutes: itemData.defaultTimeLimitMinutes,
242-
hideAfterDue: itemData.hideAfterDue === undefined ? false : itemData.hideAfterDue,
243-
showCorrectness: itemData.showCorrectness,
244-
isPrereq: itemData.isPrereq,
245-
prereqUsageKey: itemData.prereq,
246-
prereqMinScore: defaultPrereqScore(itemData.prereqMinScore),
247-
prereqMinCompletion: defaultPrereqScore(itemData.prereqMinCompletion),
248238
...variables,
249239
});
250240
};

src/generic/FormikControl.tsx

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22
import { Form } from '@openedx/paragon';
3-
import { getIn, useFormikContext } from 'formik';
3+
import { FormikContextType, getIn, useFormikContext } from 'formik';
44
import FormikErrorFeedback from './FormikErrorFeedback';
55

66
interface Props {
@@ -10,6 +10,7 @@ interface Props {
1010
className?: string;
1111
controlClasses?: string;
1212
value: string | number;
13+
setFieldValue?: (name: string, value: any) => void;
1314
}
1415

1516
// Because <Form.Control> is only typed as 'any' in Paragon so far, the props of the following become 'any' :/
@@ -22,14 +23,24 @@ const FormikControl: React.FC<Props & React.ComponentProps<typeof Form.Control>>
2223
help = <></>,
2324
className = '',
2425
controlClasses = 'pb-2',
26+
setFieldValue,
2527
...params
2628
}) => {
27-
const {
28-
touched, errors, handleChange, handleBlur, setFieldError,
29-
} = useFormikContext();
30-
const fieldTouched = getIn(touched, name);
31-
const fieldError = getIn(errors, name);
32-
const handleFocus = (e) => setFieldError(e.target.name, undefined);
29+
let formikContext: FormikContextType<unknown> | null;
30+
try {
31+
formikContext = useFormikContext();
32+
} catch (e) {
33+
formikContext = null;
34+
}
35+
36+
const fieldTouched = formikContext ? getIn(formikContext.touched, name) : false;
37+
const fieldError = formikContext ? getIn(formikContext.errors, name) : undefined;
38+
const handleFocus = formikContext ? (
39+
e: { target: { name: any; } }
40+
) => formikContext?.setFieldError(e.target.name, undefined) : undefined;
41+
const handleBlur = formikContext ? formikContext.handleBlur : undefined;
42+
const handleChange = formikContext ? formikContext.handleChange : undefined;
43+
const formikSetFieldValue = formikContext ? formikContext.setFieldValue : undefined;
3344

3445
return (
3546
<Form.Group className={className}>
@@ -38,14 +49,28 @@ const FormikControl: React.FC<Props & React.ComponentProps<typeof Form.Control>>
3849
{...params}
3950
name={name}
4051
className={controlClasses}
41-
onChange={handleChange}
52+
onChange={(e: { target: { value: any; }; }) => {
53+
if (setFieldValue) {
54+
setFieldValue(name, e.target.value);
55+
return;
56+
}
57+
if (handleChange) {
58+
handleChange(e);
59+
return;
60+
}
61+
if (formikSetFieldValue) {
62+
formikSetFieldValue(name, e.target.value);
63+
}
64+
}}
4265
onBlur={handleBlur}
4366
onFocus={handleFocus}
4467
isInvalid={!!fieldTouched && !!fieldError}
4568
/>
46-
<FormikErrorFeedback name={name}>
47-
<Form.Text>{help}</Form.Text>
48-
</FormikErrorFeedback>
69+
{formikContext && (
70+
<FormikErrorFeedback name={name}>
71+
<Form.Text>{help}</Form.Text>
72+
</FormikErrorFeedback>
73+
)}
4974
</Form.Group>
5075
);
5176
};

src/generic/configure-modal/PrereqSettings.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const PrereqSettings = ({
6161
<FormikControl
6262
name="prereqMinScore"
6363
value={prereqMinScore}
64+
setFieldValue={(field, value) => setFieldValue(field, value === '' ? '' : Number(value))}
6465
label={<Form.Label>{intl.formatMessage(messages.minScoreLabel)}</Form.Label>}
6566
controlClassName="text-right"
6667
controlClasses="w-7rem"
@@ -70,6 +71,7 @@ const PrereqSettings = ({
7071
<FormikControl
7172
name="prereqMinCompletion"
7273
value={prereqMinCompletion}
74+
setFieldValue={(field, value) => setFieldValue(field, value === '' ? '' : Number(value))}
7375
label={<Form.Label>{intl.formatMessage(messages.minCompletionLabel)}</Form.Label>}
7476
controlClassName="text-right"
7577
controlClasses="w-7rem"

src/generic/key-utils.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ describe('component utils', () => {
3333
for (const input of ['', undefined, null, 'not a key', 'lb:foo', 'block-v1:foo']) {
3434
it(`throws an exception for usage key '${input}'`, () => {
3535
expect(() => getBlockType(input as any)).toThrow(`Invalid usageKey: ${input}`);
36+
expect(getBlockType(input as any, 'empty')).toBe('');
3637
});
3738
}
3839
});

src/generic/key-utils.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
* @param usageKey e.g. `lb:org:lib:html:id`, `block-v1:org+type@html+block@1`
44
* @returns The block type as a string
55
*/
6-
export function getBlockType(usageKey: string): string {
6+
export function getBlockType(
7+
usageKey: string,
8+
onInvalid: 'empty' | 'error' = 'error',
9+
): string {
710
if (usageKey) {
811
if (usageKey.startsWith('lb:') || usageKey.startsWith('lct:')) {
912
const blockType = usageKey.split(':')[3];
@@ -17,6 +20,11 @@ export function getBlockType(usageKey: string): string {
1720
}
1821
}
1922
}
23+
24+
if (onInvalid === 'empty') {
25+
return '';
26+
}
27+
2028
throw new Error(`Invalid usageKey: ${usageKey}`);
2129
}
2230

src/hooks.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,9 +230,18 @@ export function useToggleWithValue<T>(defaultValue?: T): [
230230
return [isDefined, value, define, undefine];
231231
}
232232

233+
type SetStateWithCallbackAction<T> = React.SetStateAction<T | undefined> | {
234+
value: React.SetStateAction<T | undefined>;
235+
skipCallback?: boolean;
236+
};
237+
233238
/**
234239
* Hook to use `useState` and also trigger a callback when the state updates. This is particularly useful for
235240
* scenarios where you want to update the UI or perform side effects every time the state changes.
241+
*
242+
* The returned setter also accepts `{ value, skipCallback }` to optionally suppress the callback for a
243+
* specific update.
244+
*
236245
* @param defaultValue The default value of the state
237246
* @param callback Receives the latest value as argument
238247
* @param delay Time in milliseconds before the callback is triggered after state update (defaults to 500 ms)
@@ -241,10 +250,11 @@ export function useStateWithCallback<T>(
241250
defaultValue?: T,
242251
callback?: (val: T | undefined) => void,
243252
delay = 500,
244-
): [T | undefined, React.Dispatch<React.SetStateAction<T | undefined>>] {
253+
): [T | undefined, React.Dispatch<SetStateWithCallbackAction<T>>] {
245254
const [data, setData] = useState(defaultValue);
246255
const timeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
247256
const callbackRef = useRef(callback);
257+
const skipCallbackRef = useRef(false);
248258
const prevDataRef = useRef(defaultValue); // Track previous value
249259

250260
useEffect(() => {
@@ -259,6 +269,11 @@ export function useStateWithCallback<T>(
259269

260270
prevDataRef.current = data;
261271

272+
if (skipCallbackRef.current) {
273+
skipCallbackRef.current = false;
274+
return () => {};
275+
}
276+
262277
if (timeoutRef.current) { clearTimeout(timeoutRef.current); }
263278

264279
timeoutRef.current = setTimeout(() => {
@@ -268,5 +283,16 @@ export function useStateWithCallback<T>(
268283
return () => clearTimeout(timeoutRef.current);
269284
}, [data, delay]);
270285

271-
return [data, setData];
286+
const setDataWithCallback = (value: SetStateWithCallbackAction<T>) => {
287+
if (typeof value === 'object' && value !== null && 'value' in value) {
288+
skipCallbackRef.current = !!value.skipCallback;
289+
setData(value.value);
290+
return;
291+
}
292+
293+
skipCallbackRef.current = false;
294+
setData(value as React.SetStateAction<T | undefined>);
295+
};
296+
297+
return [data, setDataWithCallback];
272298
}

0 commit comments

Comments
 (0)