Skip to content

Commit 061a137

Browse files
authored
Refactor admin panel (#2983)
* Refactor AdminPanel code structure * Refactor data flow logic in assessment config panel * Create WithImperativeApi utility type * Refactor components * Fix state bug
1 parent 6ceba50 commit 061a137

File tree

3 files changed

+404
-384
lines changed

3 files changed

+404
-384
lines changed

src/commons/utils/TypeHelper.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,22 @@ export type RemoveIndex<T> = {
5252
: K]: T[K];
5353
};
5454

55-
/** A true intersection of the properties of types `A` and `B`, unlike the confusingly named
55+
/**
56+
* A true intersection of the properties of types `A` and `B`, unlike the confusingly named
5657
* "Intersection Types" in TypeScript which uses the `&` operator and are actually unions.
57-
* This also excludes the index signature from both `A` and `B` automatically. */
58+
* This also excludes the index signature from both `A` and `B` automatically.
59+
*/
5860
export type SharedProperties<A, B> = Pick<A, Extract<keyof RemoveIndex<A>, keyof RemoveIndex<B>>>;
5961

62+
/**
63+
* A type that represents a React component that has an imperative API
64+
* (i.e. a ref that can be used to call methods on the component). Such
65+
* components are typically wrapped in `forwardRef` and `useImperativeHandle`.
66+
*/
67+
export type WithImperativeApi<T, C extends React.ComponentType<any> = React.FC> = React.FC<
68+
React.ComponentProps<C> & React.RefAttributes<T>
69+
>;
70+
6071
/* =========================================
6172
* Utility types for tuple type manipulation
6273
* ========================================= */

src/pages/academy/adminPanel/AdminPanel.tsx

Lines changed: 96 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,9 @@ import 'ag-grid-community/styles/ag-grid.css';
22
import 'ag-grid-community/styles/ag-theme-balham.css';
33

44
import { Button, Divider, H1, Intent, Tab, Tabs } from '@blueprintjs/core';
5-
import { cloneDeep } from 'lodash';
6-
import React from 'react';
5+
import React, { useCallback, useEffect, useRef, useState } from 'react';
76
import { useDispatch } from 'react-redux';
8-
import { Role } from 'src/commons/application/ApplicationTypes';
9-
import { useTypedSelector } from 'src/commons/utils/Hooks';
7+
import { useSession } from 'src/commons/utils/Hooks';
108
import {
119
addNewStoriesUsersToCourse,
1210
addNewUsersToCourse
@@ -19,65 +17,48 @@ import {
1917
fetchAssessmentConfigs,
2018
fetchCourseConfig,
2119
fetchNotificationConfigs,
22-
setAssessmentConfigurations,
2320
updateAssessmentConfigs,
2421
updateCourseConfig,
2522
updateUserRole
2623
} from '../../../commons/application/actions/SessionActions';
2724
import { UpdateCourseConfiguration } from '../../../commons/application/types/SessionTypes';
28-
import { AssessmentConfiguration } from '../../../commons/assessment/AssessmentTypes';
2925
import ContentDisplay from '../../../commons/ContentDisplay';
30-
import AddStoriesUserPanel, { NameUsernameRole } from './subcomponents/AddStoriesUserPanel';
31-
import AddUserPanel, { UsernameRoleGroup } from './subcomponents/AddUserPanel';
32-
import AssessmentConfigPanel from './subcomponents/assessmentConfigPanel/AssessmentConfigPanel';
26+
import AddStoriesUserPanel from './subcomponents/AddStoriesUserPanel';
27+
import AddUserPanel from './subcomponents/AddUserPanel';
28+
import AssessmentConfigPanel, {
29+
ImperativeAssessmentConfigPanel
30+
} from './subcomponents/assessmentConfigPanel/AssessmentConfigPanel';
3331
import CourseConfigPanel from './subcomponents/CourseConfigPanel';
3432
import NotificationConfigPanel from './subcomponents/NotificationConfigPanel';
3533
import UserConfigPanel from './subcomponents/userConfigPanel/UserConfigPanel';
3634

37-
const AdminPanel: React.FC = () => {
38-
const [hasChangesCourseConfig, setHasChangesCourseConfig] = React.useState(false);
39-
const [hasChangesAssessmentConfig, setHasChangesAssessmentConfig] = React.useState(false);
35+
const defaultCourseConfig: UpdateCourseConfiguration = {
36+
courseName: '',
37+
courseShortName: '',
38+
viewable: true,
39+
enableGame: true,
40+
enableAchievements: true,
41+
enableSourcecast: true,
42+
enableStories: false,
43+
moduleHelpText: ''
44+
};
4045

41-
const [courseConfiguration, setCourseConfiguration] = React.useState<UpdateCourseConfiguration>({
42-
courseName: '',
43-
courseShortName: '',
44-
viewable: true,
45-
enableGame: true,
46-
enableAchievements: true,
47-
enableSourcecast: true,
48-
enableStories: false,
49-
moduleHelpText: ''
50-
});
46+
const AdminPanel: React.FC = () => {
47+
const [hasChangesCourseConfig, setHasChangesCourseConfig] = useState(false);
48+
const [hasChangesAssessmentConfig, setHasChangesAssessmentConfig] = useState(false);
49+
const [courseConfiguration, setCourseConfiguration] = useState(defaultCourseConfig);
5150

5251
const dispatch = useDispatch();
52+
const session = useSession();
5353

54-
const session = useTypedSelector(state => state.session);
55-
56-
/**
57-
* Mutable ref to track the assessment configuration form state instead of useState. This is
58-
* because ag-grid does not update the cellRendererParams whenever there is an update in rowData,
59-
* leading to a stale closure problem where the handlers in AssessmentConfigPanel capture the old
60-
* value of assessmentConfig.
61-
*
62-
* Also, useState causes a flicker in ag-grid during rerenders. Thus we use this mutable ref and
63-
* ag-grid's API to update cell values instead.
64-
*/
65-
const assessmentConfig = React.useRef(session.assessmentConfigurations);
66-
67-
// Tracks the assessment configurations to be deleted in the backend when the save button is clicked
68-
const [assessmentConfigsToDelete, setAssessmentConfigsToDelete] = React.useState<
69-
AssessmentConfiguration[]
70-
>([]);
71-
72-
React.useEffect(() => {
54+
useEffect(() => {
7355
dispatch(fetchCourseConfig());
7456
dispatch(fetchAssessmentConfigs());
7557
dispatch(fetchAdminPanelCourseRegistrations());
7658
dispatch(fetchNotificationConfigs());
7759
}, [dispatch]);
7860

79-
// After updated configs have been loaded from the backend, put them into local React state
80-
React.useEffect(() => {
61+
useEffect(() => {
8162
setCourseConfiguration({
8263
courseName: session.courseName,
8364
courseShortName: session.courseShortName,
@@ -88,10 +69,22 @@ const AdminPanel: React.FC = () => {
8869
enableStories: session.enableStories,
8970
moduleHelpText: session.moduleHelpText
9071
});
91-
92-
// IMPT: To prevent mutation of props
93-
assessmentConfig.current = cloneDeep(session.assessmentConfigurations);
94-
}, [session]);
72+
}, [
73+
session.assessmentConfigurations,
74+
session.courseName,
75+
session.courseShortName,
76+
session.enableAchievements,
77+
session.enableGame,
78+
session.enableSourcecast,
79+
session.enableStories,
80+
session.moduleHelpText,
81+
session.viewable
82+
]);
83+
84+
const tableRef = useRef<ImperativeAssessmentConfigPanel>(null);
85+
useEffect(() => {
86+
tableRef.current?.resetData();
87+
}, [session.assessmentConfigurations]);
9588

9689
const courseConfigPanelProps = {
9790
courseConfiguration: courseConfiguration,
@@ -101,67 +94,31 @@ const AdminPanel: React.FC = () => {
10194
}
10295
};
10396

104-
const assessmentConfigPanelProps = React.useMemo(() => {
105-
return {
106-
// Would have been loaded by the useEffect above
107-
assessmentConfig: assessmentConfig as React.MutableRefObject<AssessmentConfiguration[]>,
108-
setAssessmentConfig: (val: AssessmentConfiguration[]) => {
109-
assessmentConfig.current = val;
110-
setHasChangesAssessmentConfig(true);
111-
},
112-
setAssessmentConfigsToDelete: (deletedElement: AssessmentConfiguration) => {
113-
// If it is not a newly created row that is yet to be persisted in the backend
114-
if (deletedElement.assessmentConfigId !== -1) {
115-
const temp = [...assessmentConfigsToDelete];
116-
temp.push(deletedElement);
117-
setAssessmentConfigsToDelete(temp);
118-
}
119-
},
120-
setHasChangesAssessmentConfig: setHasChangesAssessmentConfig
121-
};
122-
}, [assessmentConfigsToDelete]);
123-
124-
const userConfigPanelProps = {
125-
courseRegId: session.courseRegId,
126-
userCourseRegistrations: session.userCourseRegistrations,
127-
handleUpdateUserRole: (courseRegId: number, role: Role) =>
128-
dispatch(updateUserRole(courseRegId, role)),
129-
handleDeleteUserFromCourse: (courseRegId: number) =>
130-
dispatch(deleteUserCourseRegistration(courseRegId))
131-
};
132-
133-
const addUserPanelProps = {
134-
handleAddNewUsersToCourse: (users: UsernameRoleGroup[], provider: string) =>
135-
dispatch(addNewUsersToCourse(users, provider))
136-
};
137-
138-
const addStoriesUserPanelProps = {
139-
handleAddNewUsersToCourse: (users: NameUsernameRole[], provider: string) =>
140-
dispatch(addNewStoriesUsersToCourse(users, provider))
141-
};
142-
14397
// Handler to submit changes to Course Configration and Assessment Configuration to the backend.
14498
// Changes made to users are handled separately.
145-
const submitHandler = () => {
99+
const submitHandler = useCallback(() => {
146100
if (hasChangesCourseConfig) {
147101
dispatch(updateCourseConfig(courseConfiguration));
148102
setHasChangesCourseConfig(false);
149103
}
150-
if (assessmentConfigsToDelete.length > 0) {
151-
assessmentConfigsToDelete.forEach(assessmentConfig => {
152-
dispatch(deleteAssessmentConfig(assessmentConfig));
153-
});
154-
setAssessmentConfigsToDelete([]);
155-
}
104+
const tableState = tableRef.current?.getData() ?? [];
105+
const currentConfigs = session.assessmentConfigurations ?? [];
106+
const currentIds = new Set(tableState.map(config => config.assessmentConfigId));
107+
const configsToDelete = currentConfigs.filter(
108+
config => !currentIds.has(config.assessmentConfigId)
109+
);
110+
configsToDelete.forEach(config => dispatch(deleteAssessmentConfig(config)));
156111
if (hasChangesAssessmentConfig) {
157-
// Reset the store first so that old props do not propagate down and cause a flicker
158-
dispatch(setAssessmentConfigurations([]));
159-
160-
// assessmentConfig.current will exist after the first load
161-
dispatch(updateAssessmentConfigs(assessmentConfig.current!));
112+
dispatch(updateAssessmentConfigs(tableState));
162113
setHasChangesAssessmentConfig(false);
163114
}
164-
};
115+
}, [
116+
courseConfiguration,
117+
dispatch,
118+
hasChangesAssessmentConfig,
119+
hasChangesCourseConfig,
120+
session.assessmentConfigurations
121+
]);
165122

166123
const data = (
167124
<div className="admin-panel">
@@ -175,9 +132,14 @@ const AdminPanel: React.FC = () => {
175132
<>
176133
<CourseConfigPanel {...courseConfigPanelProps} />
177134
<Divider />
178-
<AssessmentConfigPanel {...assessmentConfigPanelProps} />
135+
<AssessmentConfigPanel
136+
ref={tableRef}
137+
initialConfigs={session.assessmentConfigurations ?? []}
138+
setHasChangesAssessmentConfig={setHasChangesAssessmentConfig}
139+
/>
179140
<Button
180141
text="Save"
142+
disabled={!hasChangesCourseConfig && !hasChangesAssessmentConfig}
181143
style={{ marginTop: '15px' }}
182144
intent={
183145
hasChangesCourseConfig || hasChangesAssessmentConfig
@@ -189,12 +151,43 @@ const AdminPanel: React.FC = () => {
189151
</>
190152
}
191153
/>
192-
<Tab id="users" title="Users" panel={<UserConfigPanel {...userConfigPanelProps} />} />
193-
<Tab id="add-users" title="Add Users" panel={<AddUserPanel {...addUserPanelProps} />} />
154+
<Tab
155+
id="users"
156+
title="Users"
157+
panel={
158+
<UserConfigPanel
159+
courseRegId={session.courseRegId}
160+
userCourseRegistrations={session.userCourseRegistrations}
161+
handleUpdateUserRole={(courseRegId, role) =>
162+
dispatch(updateUserRole(courseRegId, role))
163+
}
164+
handleDeleteUserFromCourse={(courseRegId: number) =>
165+
dispatch(deleteUserCourseRegistration(courseRegId))
166+
}
167+
/>
168+
}
169+
/>
170+
<Tab
171+
id="add-users"
172+
title="Add Users"
173+
panel={
174+
<AddUserPanel
175+
handleAddNewUsersToCourse={(users, provider) =>
176+
dispatch(addNewUsersToCourse(users, provider))
177+
}
178+
/>
179+
}
180+
/>
194181
<Tab
195182
id="add-stories-users"
196183
title="Add Stories Users"
197-
panel={<AddStoriesUserPanel {...addStoriesUserPanelProps} />}
184+
panel={
185+
<AddStoriesUserPanel
186+
handleAddNewUsersToCourse={(users, provider) =>
187+
dispatch(addNewStoriesUsersToCourse(users, provider))
188+
}
189+
/>
190+
}
198191
/>
199192
<Tab id="notification-config" title="Notifications" panel={<NotificationConfigPanel />} />
200193
</Tabs>

0 commit comments

Comments
 (0)