Skip to content

Commit 38c28d5

Browse files
committed
feat: subsection settings
1 parent 8b3ac9f commit 38c28d5

5 files changed

Lines changed: 321 additions & 11 deletions

File tree

src/course-outline/data/types.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export interface CourseDetails {
3535
org: string;
3636
description?: string;
3737
hasChanges: boolean;
38+
selfPaced: boolean;
3839
}
3940

4041
export interface ChecklistType {
@@ -103,17 +104,17 @@ export interface ConfigureSubsectionData {
103104
releaseDate: string,
104105
graderType: string,
105106
dueDate: string,
106-
isTimeLimited: boolean,
107-
isProctoredExam: boolean,
108-
isOnboardingExam: boolean,
109-
isPracticeExam: boolean,
110-
examReviewRules: string,
111-
defaultTimeLimitMin: number,
112-
hideAfterDue: string,
107+
isTimeLimited?: boolean,
108+
isProctoredExam?: boolean,
109+
isOnboardingExam?: boolean,
110+
isPracticeExam?: boolean,
111+
examReviewRules?: string,
112+
defaultTimeLimitMin?: number,
113+
hideAfterDue: boolean,
113114
showCorrectness: string,
114-
isPrereq: boolean,
115-
prereqUsageKey: string,
116-
prereqMinScore: number,
115+
isPrereq?: boolean,
116+
prereqUsageKey?: string,
117+
prereqMinScore?: number,
117118
prereqMinCompletion: number,
118119
}
119120

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/Ou
1313
import { InfoSection } from './InfoSection';
1414
import { PublishButon } from './PublishButon';
1515
import messages from '../messages';
16+
import { SubsectionSettings } from './SubsectionSettings';
1617

1718
interface Props {
1819
subsectionId: string;
@@ -59,7 +60,8 @@ export const SubsectionSidebar = ({ subsectionId }: Props) => {
5960
<InfoSection itemId={subsectionId} />
6061
</Tab>
6162
<Tab eventKey="settings" title={intl.formatMessage(messages.settingsTabText)}>
62-
<div>Settings</div>
63+
{/* key is required to reset local state of tab */}
64+
<SubsectionSettings key={subsectionId} subsectionId={subsectionId}/>
6365
</Tab>
6466
</Tabs>
6567
</>
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { FormattedMessage, useIntl } from "@edx/frontend-platform/i18n";
2+
import { Button, ButtonGroup, Form, Stack } from "@openedx/paragon";
3+
import { useConfigureSubsection, useCourseDetails, useCourseItemData } from "@src/course-outline/data/apiHooks";
4+
import { ConfigureSubsectionData } from "@src/course-outline/data/types";
5+
import { useOutlineSidebarContext } from "@src/course-outline/outline-sidebar/OutlineSidebarContext";
6+
import { useCourseAuthoringContext } from "@src/CourseAuthoringContext";
7+
import { VisibilityTypes } from "@src/data/constants";
8+
import { DatepickerControl, DATEPICKER_TYPES } from "@src/generic/datepicker-control";
9+
import { SidebarContent, SidebarSection } from "@src/generic/sidebar"
10+
import { useStateWithCallback } from "@src/hooks";
11+
import { FormEvent, useState } from "react";
12+
import messages from './messages';
13+
14+
interface Props {
15+
subsectionId: string;
16+
}
17+
18+
const defaultPrereqScore = (val: string | number | null | undefined) => {
19+
if (val === null || val === undefined) {
20+
return 100;
21+
}
22+
const parsed = parseFloat(val.toString());
23+
return isNaN(parsed) ? 100 : parsed;
24+
};
25+
26+
interface SubProps extends Props {
27+
onChange: (variables: Partial<ConfigureSubsectionData>) => void;
28+
}
29+
30+
const ReleaseSection = ({ subsectionId, onChange }: SubProps) => {
31+
const intl = useIntl();
32+
const { data: itemData } = useCourseItemData(subsectionId);
33+
const [localState, setLocalState] = useStateWithCallback(
34+
itemData?.start,
35+
(val) => onChange({ releaseDate: val }),
36+
);
37+
38+
return (
39+
<SidebarSection
40+
title={intl.formatMessage(messages.subsectionReleaseTitle)}
41+
>
42+
<Stack className="mt-3" direction="horizontal" gap={3}>
43+
<DatepickerControl
44+
type={DATEPICKER_TYPES.date}
45+
value={localState}
46+
label={intl.formatMessage(messages.releaseDateLabel)}
47+
controlName="state-date"
48+
onChange={setLocalState}
49+
/>
50+
<DatepickerControl
51+
type={DATEPICKER_TYPES.time}
52+
value={localState}
53+
label={intl.formatMessage(messages.releaseTimeLabel)}
54+
controlName="start-time"
55+
onChange={setLocalState}
56+
/>
57+
</Stack>
58+
</SidebarSection>
59+
60+
)
61+
}
62+
63+
const GradingSection = ({ subsectionId, onChange }: SubProps) => {
64+
const intl = useIntl();
65+
const { data: itemData } = useCourseItemData(subsectionId);
66+
const [graded, setGraded] = useState(itemData?.graded);
67+
const { courseId } = useCourseAuthoringContext();
68+
const { data: courseDetails } = useCourseDetails(courseId);
69+
const [localState, setLocalState] = useStateWithCallback<Partial<ConfigureSubsectionData>>(
70+
{
71+
graderType: itemData?.format,
72+
dueDate: itemData?.due || '',
73+
},
74+
(val) => onChange(val || {})
75+
);
76+
77+
const setUngraded = () => {
78+
setGraded(false);
79+
onChange({ graderType: "notgraded" });
80+
}
81+
82+
const createOptions = () => itemData?.courseGraders?.map((option) => (
83+
<option key={option} value={option}> {option} </option>
84+
));
85+
86+
return (
87+
<SidebarSection
88+
title={intl.formatMessage(messages.subsectionGradingTitle)}
89+
>
90+
<ButtonGroup toggle>
91+
<Button
92+
variant={graded ? 'outline-primary' : 'primary'}
93+
onClick={setUngraded}
94+
>
95+
<FormattedMessage {...messages.subsectionGradingUngradedBtn} />
96+
</Button>
97+
<Button
98+
variant={graded ? 'primary' : 'outline-primary'}
99+
onClick={() => setGraded(true)}
100+
>
101+
<FormattedMessage {...messages.subsectionGradingGradedBtn} />
102+
</Button>
103+
</ButtonGroup>
104+
{graded &&
105+
<Form.Group>
106+
<Form.Label className="x-small">
107+
<FormattedMessage {...messages.subsectionGradingDropdownLabel} />
108+
</Form.Label>
109+
<Form.Control
110+
as="select"
111+
defaultValue={itemData?.format}
112+
onChange={(e) => setLocalState({ ...localState, graderType: e.target.value })}
113+
data-testid="grader-type-select"
114+
>
115+
<option key="notgraded" value="notgraded">
116+
{intl.formatMessage(messages.subsectionGradingDropdownPlaceholder)}
117+
</option>
118+
{createOptions()}
119+
</Form.Control>
120+
</Form.Group>
121+
}
122+
{!courseDetails?.selfPaced && graded &&
123+
<Stack className="mt-3" direction="horizontal" gap={3}>
124+
<DatepickerControl
125+
type={DATEPICKER_TYPES.date}
126+
value={localState?.dueDate}
127+
label={intl.formatMessage(messages.subsectionGradingDueDateLabel)}
128+
controlName="state-date"
129+
onChange={(val) => setLocalState({ ...localState, dueDate: val })}
130+
data-testid="due-date-picker"
131+
/>
132+
<DatepickerControl
133+
type={DATEPICKER_TYPES.time}
134+
value={localState?.dueDate}
135+
label={intl.formatMessage(messages.subsectionGradingDueTimeLabel)}
136+
controlName="start-time"
137+
onChange={(val) => setLocalState({ ...localState, dueDate: val })}
138+
/>
139+
</Stack>
140+
}
141+
</SidebarSection>
142+
);
143+
}
144+
145+
export const SubsectionSettings = ({ subsectionId }: Props) => {
146+
const { courseId } = useCourseAuthoringContext();
147+
const { data: courseDetails } = useCourseDetails(courseId);
148+
const { data: itemData, isPending } = useCourseItemData(subsectionId);
149+
const { mutate } = useConfigureSubsection();
150+
const { selectedContainerState } = useOutlineSidebarContext();
151+
152+
const onChange = (variables: Partial<ConfigureSubsectionData>) => {
153+
if (isPending || !itemData) {
154+
return;
155+
}
156+
return mutate({
157+
itemId: subsectionId,
158+
sectionId: selectedContainerState?.sectionId,
159+
isVisibleToStaffOnly: itemData.visibilityState === VisibilityTypes.STAFF_ONLY,
160+
releaseDate: itemData.start,
161+
graderType: itemData.format == null ? 'notgraded' : itemData.format,
162+
dueDate: itemData.due == null ? '' : itemData.due,
163+
isTimeLimited: itemData.isTimeLimited,
164+
isProctoredExam: itemData.isProctoredExam,
165+
isOnboardingExam: itemData.isOnboardingExam,
166+
isPracticeExam: itemData.isPracticeExam,
167+
examReviewRules: itemData.examReviewRules,
168+
defaultTimeLimitMin: itemData.defaultTimeLimitMinutes,
169+
hideAfterDue: itemData.hideAfterDue === undefined ? false : itemData.hideAfterDue,
170+
showCorrectness: itemData.showCorrectness,
171+
isPrereq: itemData.isPrereq,
172+
prereqUsageKey: itemData.prereq,
173+
prereqMinScore: defaultPrereqScore(itemData.prereqMinScore),
174+
prereqMinCompletion: defaultPrereqScore(itemData.prereqMinCompletion),
175+
...variables,
176+
})
177+
}
178+
179+
return (
180+
<SidebarContent>
181+
{ !courseDetails?.selfPaced && <ReleaseSection
182+
subsectionId={subsectionId}
183+
onChange={onChange}
184+
/> }
185+
<GradingSection
186+
subsectionId={subsectionId}
187+
onChange={onChange}
188+
/>
189+
</SidebarContent>
190+
)
191+
}
192+
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { defineMessages } from '@edx/frontend-platform/i18n';
2+
3+
const messages = defineMessages({
4+
subsectionReleaseTitle: {
5+
id: 'course-authoring.course-outline.sidebar.library.subsection-settings.release.title',
6+
defaultMessage: 'Release Date and Time',
7+
description: 'Release data time section title in subsection settings sidebar.',
8+
},
9+
releaseDateLabel: {
10+
id: 'course-authoring.course-outline.sidebar.library.subsection-settings.release.date-label',
11+
defaultMessage: 'Release Date',
12+
description: 'Release data time section label in subsection settings sidebar.',
13+
},
14+
releaseTimeLabel: {
15+
id: 'course-authoring.course-outline.sidebar.library.subsection-settings.release.time-label',
16+
defaultMessage: 'Release Time (UTC)',
17+
description: 'Release data time section label in subsection settings sidebar.',
18+
},
19+
subsectionGradingTitle: {
20+
id: 'course-authoring.course-outline.sidebar.library.subsection-settings.grading.title',
21+
defaultMessage: 'Subsection Grading',
22+
description: 'Subsection Grading section title in subsection settings sidebar',
23+
},
24+
subsectionGradingUngradedBtn: {
25+
id: 'course-authoring.course-outline.sidebar.library.subsection-settings.grading.ungraded-btn',
26+
defaultMessage: 'Ungraded',
27+
description: 'Subsection Grading section Ungraded button text in subsection settings sidebar',
28+
},
29+
subsectionGradingGradedBtn: {
30+
id: 'course-authoring.course-outline.sidebar.library.subsection-settings.grading.graded-btn',
31+
defaultMessage: 'Graded',
32+
description: 'Subsection Grading section Graded button text in subsection settings sidebar',
33+
},
34+
subsectionGradingDropdownLabel: {
35+
id: 'course-authoring.course-outline.sidebar.library.subsection-settings.grading.dropdown-label',
36+
defaultMessage: 'Grade as:',
37+
description: 'Dropdown label for selecting assignment type in subsection settings sidebar',
38+
},
39+
subsectionGradingDropdownPlaceholder: {
40+
id: 'course-authoring.course-outline.sidebar.library.subsection-settings.grading.dropdown-placeholder',
41+
defaultMessage: 'Select Assignment Type',
42+
description: 'Dropdown placeholder for selecting assignment type in subsection settings sidebar',
43+
},
44+
subsectionGradingDueDateLabel: {
45+
id: 'course-authoring.course-outline.sidebar.library.subsection-settings.grading.due-date-label',
46+
defaultMessage: 'Due Date',
47+
description: 'Label for Due Date field in subsection settings sidebar',
48+
},
49+
subsectionGradingDueTimeLabel: {
50+
id: 'course-authoring.course-outline.sidebar.library.subsection-settings.grading.due-time-label',
51+
defaultMessage: 'Due Time (UTC)',
52+
description: 'Label for Due Time field in subsection settings sidebar',
53+
},
54+
subsectionVisibilityTitle: {
55+
id: 'course-authoring.course-outline.sidebar.library.subsection-settings.visibility.title',
56+
defaultMessage: 'Visibility',
57+
description: 'Subsection visibility section title in subsection settings sidebar',
58+
},
59+
subsectionVisibilityStudentVisible: {
60+
id: 'course-authoring.course-outline.sidebar.library.subsection-settings.visibility.student-visible',
61+
defaultMessage: 'Student Visible',
62+
description: 'Visibility option for student visibility in subsection settings sidebar',
63+
},
64+
subsectionVisibilityStaffOnly: {
65+
id: 'course-authoring.course-outline.sidebar.library.subsection-settings.visibility.staff-only',
66+
defaultMessage: 'Staff Only',
67+
description: 'Visibility option for staff only in subsection settings sidebar',
68+
},
69+
subsectionAssessmentResultsTitle: {
70+
id: 'course-authoring.course-outline.sidebar.library.subsection-settings.assessment-results.title',
71+
defaultMessage: 'Assessment Results Visibility',
72+
description: 'Subsection Assessment Results Visibility section title in subsection settings sidebar',
73+
},
74+
subsectionSpecialExamTitle: {
75+
id: 'course-authoring.course-outline.sidebar.library.subsection-settings.special-exam.title',
76+
defaultMessage: 'Set as Special Exam',
77+
description: 'Subsection Set as Special Exam section title in subsection settings sidebar',
78+
},
79+
accessRestrictionsTitle: {
80+
id: 'course-authoring.course-outline.sidebar.library.subsection-settings.access-restrictions.title',
81+
defaultMessage: 'Access Restrictions',
82+
description: 'Title for the Access Restrictions section in subsection settings sidebar',
83+
},
84+
accessRestrictionsContentGroups: {
85+
id: 'course-authoring.course-outline.sidebar.library.subsection-settings.access-restrictions.content-groups',
86+
defaultMessage: 'Content Groups',
87+
description: 'Label for the Content Groups dropdown in access restrictions section',
88+
},
89+
accessRestrictionsSelectGroupsLabel: {
90+
id: 'course-authoring.course-outline.sidebar.library.subsection-settings.access-restrictions.select-groups-label',
91+
defaultMessage: 'Select one or more groups:',
92+
description: 'Label for selecting groups in the access restrictions section',
93+
},
94+
});
95+
96+
export default messages;
97+

src/hooks.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,21 @@ export function useToggleWithValue<T>(defaultValue?: T): [
228228
const isDefined = useMemo(() => value !== undefined, [value]);
229229
return [isDefined, value, define, undefine];
230230
}
231+
232+
/**
233+
* Hook to use `useState` and also trigger a callback when the state updates. This is particularly useful for
234+
* scenarios where you want to update the UI or perform side effects every time the state changes.
235+
* @param defaultValue The default value of the state
236+
* @param callback Receives the latest value as argument
237+
*/
238+
export function useStateWithCallback<T>(
239+
defaultValue?: T,
240+
callback?: (val: T | undefined) => void
241+
): [T | undefined, (val?: T) => void] {
242+
const [value, setValue] = useState<T | undefined>(defaultValue);
243+
const setValueWithCallback = useCallback((val?: T) => {
244+
setValue(val);
245+
callback?.(val)
246+
}, [callback]);
247+
return [value, setValueWithCallback];
248+
}

0 commit comments

Comments
 (0)