Skip to content

Commit 604c11e

Browse files
Muhammad Faraz  MaqsoodMuhammad Faraz  Maqsood
authored andcommitted
test: commit
1 parent d1b21a1 commit 604c11e

File tree

11 files changed

+658
-38
lines changed

11 files changed

+658
-38
lines changed

src/CourseTeamManagement/CoursesTable.jsx

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,50 @@ import PropTypes from 'prop-types';
1212
import messages from './messages';
1313
import SortableHeader from './customSortableHeader';
1414
import TableActions from './customTableActions';
15+
import { getChangedRows } from './utils';
16+
import ChangeConfirmationModal from './changeConfirmationModal';
17+
import { updateUserRolesInCourses } from './data/api';
1518

16-
export default function CoursesTable({ username, userCourses }) {
19+
export default function CoursesTable({
20+
username, email, userCourses, setCourseUpdateErrors,
21+
}) {
1722
const intl = useIntl();
23+
const saveButtonRef = useRef();
24+
const [showModal, setShowModal] = useState(false);
25+
const [submitButtonState, setSubmitButtonState] = useState('default');
26+
const handleCancel = () => {
27+
setShowModal(false);
28+
};
29+
1830
let userCoursesData = userCourses;
31+
const [originalRowRoles] = useState(() => userCoursesData.reduce((acc, row) => {
32+
acc[row.run] = row.role == null ? 'null' : row.role;
33+
return acc;
34+
}, {}));
35+
36+
const [originalCheckedRows] = useState(() => {
37+
const initial = {};
38+
userCoursesData.forEach((row) => {
39+
if (row.role === 'staff' || row.role === 'instructor') {
40+
initial[row.run] = true;
41+
}
42+
});
43+
return initial;
44+
});
45+
46+
const unsavedChangesRef = useRef({ newlyCheckedWithRole: [], uncheckedWithRole: [], roleChangedRows: [] });
47+
const hasUnsavedChangesRef = useRef(false);
48+
useEffect(() => {
49+
if (submitButtonState === 'pending') {
50+
updateUserRolesInCourses({ userEmail: email, changedCourses: unsavedChangesRef.current }).then((data) => {
51+
setSubmitButtonState('complete');
52+
setTimeout(() => {
53+
setCourseUpdateErrors({ success: true, errors: data });
54+
setShowModal(false);
55+
}, 2000);
56+
});
57+
}
58+
}, [submitButtonState]);
1959

2060
// Change role null to 'null' for sorting to working correctly
2161
// As we are considering 'null' to appear as staff with disabled dropdown
@@ -93,11 +133,20 @@ export default function CoursesTable({ username, userCourses }) {
93133
const numChecked = allRowIds.filter((id) => checkedRows[id]).length;
94134
const allChecked = numChecked === allRowIds.length && allRowIds.length > 0;
95135
const someChecked = numChecked > 0 && numChecked < allRowIds.length;
136+
const [isSaveBtnEnabled, setIsSaveBtnEnabled] = useState(false);
96137
useEffect(() => {
97138
if (headerCheckboxRef.current) {
98139
headerCheckboxRef.current.indeterminate = someChecked && !allChecked;
99140
}
100-
}, [someChecked, allChecked, numChecked, sortBy]);
141+
}, [someChecked, allChecked, numChecked, sortBy, rowRoles, isSaveBtnEnabled, showModal, submitButtonState]);
142+
143+
useEffect(() => {
144+
const changes = getChangedRows(checkedRows, originalCheckedRows, rowRoles, originalRowRoles, userCoursesData);
145+
hasUnsavedChangesRef.current = Object.values(changes).some(arr => arr.length > 0);
146+
setIsSaveBtnEnabled(hasUnsavedChangesRef.current);
147+
sessionStorage.setItem(`${username}hasUnsavedChanges`, hasUnsavedChangesRef.current);
148+
unsavedChangesRef.current = changes;
149+
}, [checkedRows, rowRoles]);
101150

102151
const handleHeaderCheckboxChange = () => {
103152
if (allChecked) {
@@ -246,6 +295,17 @@ export default function CoursesTable({ username, userCourses }) {
246295
setSortBy={setSortBy}
247296
/>
248297
);
298+
useEffect(() => {
299+
const handleBeforeUnload = (e) => {
300+
if (!hasUnsavedChangesRef.current) { return; }
301+
302+
e.preventDefault();
303+
e.returnValue = '';
304+
};
305+
306+
window.addEventListener('beforeunload', handleBeforeUnload);
307+
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
308+
}, []);
249309

250310
return (
251311
<div className="course-team-management-courses-table">
@@ -322,17 +382,38 @@ export default function CoursesTable({ username, userCourses }) {
322382
{sortedAndFilteredData.length > 0 && <DataTable.TableFooter />}
323383
</DataTable>
324384
<div className="py-4 my-2 d-flex justify-content-end align-items-center">
325-
<Button variant="brand" style={{ width: '8%' }}>
385+
<Button onClick={() => setShowModal(true)} disabled={!isSaveBtnEnabled} variant="brand" style={{ width: '8%' }}>
326386
{intl.formatMessage(messages.saveButtonLabel)}
327387
</Button>
328388
</div>
389+
<ChangeConfirmationModal
390+
changedData={unsavedChangesRef.current}
391+
isOpen={showModal}
392+
onConfirm={setSubmitButtonState}
393+
submitButtonState={submitButtonState}
394+
onCancel={handleCancel}
395+
username={username}
396+
email={email}
397+
positionRef={saveButtonRef}
398+
/>
329399
</div>
330400
);
331401
}
332402

333403
CoursesTable.propTypes = {
334-
username: PropTypes.string.isRequired,
335-
userCourses: PropTypes.string.isRequired,
404+
username: PropTypes.string,
405+
email: PropTypes.string,
406+
userCourses: PropTypes.shape({
407+
course_id: PropTypes.string,
408+
course_name: PropTypes.string,
409+
course_url: PropTypes.string,
410+
role: PropTypes.string,
411+
status: PropTypes.string,
412+
org: PropTypes.string,
413+
number: PropTypes.string,
414+
run: PropTypes.string,
415+
}).isRequired,
416+
setCourseUpdateErrors: PropTypes.func.isRequired,
336417
row: PropTypes.shape({
337418
run: PropTypes.string.isRequired,
338419
original: PropTypes.shape({
@@ -341,3 +422,8 @@ CoursesTable.propTypes = {
341422
}).isRequired,
342423
}).isRequired,
343424
};
425+
426+
CoursesTable.defaultProps = {
427+
username: '',
428+
email: '',
429+
};

src/CourseTeamManagement/CoursesTable.test.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { mount } from 'enzyme';
22
import { IntlProvider } from '@edx/frontend-platform/i18n';
33
import CoursesTable from './CoursesTable';
44

5-
const intlProviderWrapper = (component) => (
5+
export const intlProviderWrapper = (component) => (
66
<IntlProvider locale="en" messages={{}}>
77
{component}
88
</IntlProvider>
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import PropTypes from 'prop-types';
2+
import { useState } from 'react';
3+
import {
4+
ModalLayer, ModalCloseButton, Button, StatefulButton, Icon,
5+
} from '@openedx/paragon';
6+
import { CheckCircleOutline, SpinnerSimple } from '@openedx/paragon/icons';
7+
import { useIntl } from '@edx/frontend-platform/i18n';
8+
import messages from './messages';
9+
10+
export default function ChangeConfirmationModal({
11+
changedData,
12+
isOpen,
13+
onConfirm,
14+
submitButtonState,
15+
onCancel,
16+
username,
17+
email,
18+
positionRef,
19+
}) {
20+
const intl = useIntl();
21+
22+
// Track expanded states for each section
23+
const [expanded, setExpanded] = useState({
24+
added: false,
25+
removed: false,
26+
updated: false,
27+
});
28+
29+
const renderSection = (title, items, type, formatter) => {
30+
if (!items?.length) { return null; }
31+
32+
const isExpanded = expanded[type];
33+
const displayItems = isExpanded ? items : items.slice(0, 4);
34+
const hiddenCount = items.length - 4;
35+
36+
return (
37+
<div className="mb-4">
38+
<p className="mb-2"><strong>{intl.formatMessage(title, { count: items.length })}</strong></p>
39+
{displayItems.map(formatter)}
40+
{hiddenCount > 0 && !isExpanded && (
41+
<Button
42+
variant="link"
43+
onClick={() => setExpanded(prev => ({ ...prev, [type]: true }))}
44+
>
45+
{
46+
intl.formatMessage(
47+
messages.showMoreChangesInConfirmChangesModal,
48+
{
49+
hiddenCount,
50+
},
51+
)
52+
}
53+
</Button>
54+
)}
55+
</div>
56+
);
57+
};
58+
59+
return (
60+
<ModalLayer isOpen={isOpen} onClose={onCancel} positionRef={positionRef}>
61+
<div
62+
role="dialog"
63+
aria-label={intl.formatMessage(messages.confirmChangesModalHeader)}
64+
className="p-4 bg-white mx-auto my-5 border rounded-sm change-confirm-modal"
65+
>
66+
<div className="d-flex justify-content-start align-items-center mb-3">
67+
<h2 className="text-lg font-semibold">
68+
{intl.formatMessage(messages.confirmChangesModalHeader)}
69+
</h2>
70+
</div>
71+
<div className="mb-3 section-divider-1" />
72+
73+
<p className="mb-2 text-sm text-gray-700">
74+
{intl.formatMessage(messages.confirmChangesModalDescription)}
75+
</p>
76+
<span className="d-flex justify-content-start align-items-center">
77+
<p className="mr-2">
78+
<strong>
79+
{`${username.charAt(0).toUpperCase()}${username.slice(1).toLowerCase()} `}
80+
</strong>
81+
</p>
82+
<p>{email}</p>
83+
</span>
84+
<div className="mb-3 section-divider-2" />
85+
86+
{changedData && (
87+
<div className="ctm-courses-changes mb-4">
88+
{renderSection(
89+
messages.addedToCourseCountChangesInConfirmChangesModal,
90+
changedData.newlyCheckedWithRole,
91+
'added',
92+
({
93+
courseName, number, runId, role,
94+
}) => (
95+
<p key={`added-${runId}`}>
96+
<span>{`${courseName} (${number} - ${runId})`}</span> {role === 'instructor' ? 'Instructor' : 'Staff'}
97+
</p>
98+
),
99+
)}
100+
101+
{renderSection(
102+
messages.removedFromCourseCountChangesInConfirmChangesModal,
103+
changedData.uncheckedWithRole,
104+
'removed',
105+
({
106+
courseName, number, runId, role,
107+
}) => (
108+
<p key={`removed-${runId}`}>
109+
<span>{`${courseName} (${number} - ${runId})`}</span> {role === 'instructor' ? 'Instructor' : 'Staff'}
110+
</p>
111+
),
112+
)}
113+
114+
{renderSection(
115+
messages.roleUpdatedInCourseCountChangesInConfirmChangesModal,
116+
changedData.roleChangedRows,
117+
'updated',
118+
({
119+
courseName, number, runId, from, to,
120+
}) => (
121+
<p key={`role-${runId}`}>
122+
<span className="font-medium">{`${courseName} (${number} - ${runId}) ${from === 'instructor' ? 'Instructor' : 'Staff'}${to === 'instructor' ? 'Instructor' : 'Staff'}`}</span>
123+
</p>
124+
),
125+
)}
126+
</div>
127+
)}
128+
129+
<div className="d-flex justify-content-end align-items-center">
130+
<ModalCloseButton
131+
className="mr-3"
132+
variant="outline-primary"
133+
disabled={submitButtonState === 'saving'}
134+
onClick={() => {
135+
setExpanded({ added: false, removed: false, updated: false });
136+
onCancel();
137+
}}
138+
>
139+
{intl.formatMessage(messages.confirmChangesModalCancelButton)}
140+
</ModalCloseButton>
141+
<StatefulButton
142+
labels={{
143+
default: 'Save',
144+
pending: 'Saving',
145+
complete: 'Saved',
146+
}}
147+
variant={submitButtonState === 'Error' ? 'danger' : 'brand'}
148+
icons={{
149+
pending: <Icon src={SpinnerSimple} className="icon-spin" />,
150+
complete: <Icon src={CheckCircleOutline} />,
151+
}}
152+
onClick={() => {
153+
setExpanded({ added: false, removed: false, updated: false });
154+
onConfirm('pending');
155+
}}
156+
state={submitButtonState}
157+
/>
158+
</div>
159+
</div>
160+
</ModalLayer>
161+
);
162+
}
163+
164+
ChangeConfirmationModal.propTypes = {
165+
changedData: PropTypes.shape({
166+
newlyCheckedWithRole: PropTypes.shape({
167+
courseName: PropTypes.string,
168+
number: PropTypes.string,
169+
runId: PropTypes.string,
170+
role: PropTypes.string,
171+
}).isRequired,
172+
uncheckedWithRole: PropTypes.shape({
173+
courseName: PropTypes.string,
174+
number: PropTypes.string,
175+
runId: PropTypes.string,
176+
role: PropTypes.string,
177+
}).isRequired,
178+
roleChangedRows: PropTypes.shape({
179+
courseName: PropTypes.string,
180+
number: PropTypes.string,
181+
runId: PropTypes.string,
182+
role: PropTypes.string,
183+
}).isRequired,
184+
}).isRequired,
185+
isOpen: PropTypes.bool.isRequired,
186+
onConfirm: PropTypes.func.isRequired,
187+
submitButtonState: PropTypes.string.isRequired,
188+
onCancel: PropTypes.func.isRequired,
189+
username: PropTypes.string.isRequired,
190+
email: PropTypes.string.isRequired,
191+
positionRef: PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
192+
};

src/CourseTeamManagement/data/api.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
22
import { getConfig } from '@edx/frontend-platform';
3+
import { getDataToUpdateForPutRequest, extractErrorsFromUpdateResponse } from '../utils';
34

45
const { LMS_BASE_URL } = getConfig();
56

6-
export default async function fetchUserRoleBasedCourses(userEmail) {
7+
export async function fetchUserRoleBasedCourses(userEmail) {
78
const queryParams = new URLSearchParams({
89
email: userEmail,
910
});
@@ -16,3 +17,21 @@ export default async function fetchUserRoleBasedCourses(userEmail) {
1617
}
1718
return [];
1819
}
20+
21+
export async function updateUserRolesInCourses({
22+
userEmail,
23+
changedCourses,
24+
}) {
25+
const coursesToUpdate = getDataToUpdateForPutRequest(changedCourses);
26+
const apiUrl = `${LMS_BASE_URL}/api/support/v1/manage_course_team/`;
27+
try {
28+
const { data } = await getAuthenticatedHttpClient().put(apiUrl, {
29+
email: userEmail,
30+
bulk_role_operations: coursesToUpdate,
31+
});
32+
return extractErrorsFromUpdateResponse(data);
33+
} catch (error) {
34+
// TODO
35+
}
36+
return [];
37+
}

0 commit comments

Comments
 (0)