feat: Use configured DEFAULT_GRADE_DESIGNATIONS (#1227)
Support was recently added to edx-platform to add customisised default grade designations, this change adds support for them to this MFE as well to bring it to partiy with the edx-platform UI It also refactors the grading-settings page to use React Query and updates the logic used when partitioning grades by default to make it work better when there are more than 5 partitions. Co-authored-by: Farhaan Bukhsh <farhaan@opencraft.com>
This commit is contained in:
@@ -1,51 +1,59 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Container, Layout, Button, StatefulButton,
|
||||
Button, Container, Layout, StatefulButton,
|
||||
} from '@openedx/paragon';
|
||||
import { CheckCircle, Warning, Add as IconAdd } from '@openedx/paragon/icons';
|
||||
import { Add as IconAdd, CheckCircle, Warning } from '@openedx/paragon/icons';
|
||||
import {
|
||||
useCourseSettings,
|
||||
useGradingSettings,
|
||||
useGradingSettingUpdater,
|
||||
} from 'CourseAuthoring/grading-settings/data/apiHooks';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { STATEFUL_BUTTON_STATES } from '../constants';
|
||||
import AlertMessage from '../generic/alert-message';
|
||||
import InternetConnectionAlert from '../generic/internet-connection-alert';
|
||||
|
||||
import { useModel } from '../generic/model-store';
|
||||
import AlertMessage from '../generic/alert-message';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import InternetConnectionAlert from '../generic/internet-connection-alert';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import SectionSubHeader from '../generic/section-sub-header';
|
||||
import { STATEFUL_BUTTON_STATES } from '../constants';
|
||||
import {
|
||||
getGradingSettings,
|
||||
getCourseAssignmentLists,
|
||||
getSavingStatus,
|
||||
getLoadingStatus,
|
||||
getCourseSettings,
|
||||
} from './data/selectors';
|
||||
import { fetchGradingSettings, sendGradingSetting, fetchCourseSettingsQuery } from './data/thunks';
|
||||
import GradingScale from './grading-scale/GradingScale';
|
||||
import GradingSidebar from './grading-sidebar';
|
||||
import messages from './messages';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import AssignmentSection from './assignment-section';
|
||||
import CreditSection from './credit-section';
|
||||
import DeadlineSection from './deadline-section';
|
||||
import GradingScale from './grading-scale/GradingScale';
|
||||
import GradingSidebar from './grading-sidebar';
|
||||
import { useConvertGradeCutoffs, useUpdateGradingData } from './hooks';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import messages from './messages';
|
||||
|
||||
const GradingSettings = ({ courseId }) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
data: gradingSettings,
|
||||
isLoading: isGradingSettingsLoading,
|
||||
} = useGradingSettings(courseId);
|
||||
const {
|
||||
data: courseSettingsData,
|
||||
isLoading: isCourseSettingsLoading,
|
||||
} = useCourseSettings(courseId);
|
||||
const {
|
||||
mutate: updateGradingSettings,
|
||||
isLoading: savePending,
|
||||
isSuccess: savingStatus,
|
||||
isError: savingFailed,
|
||||
} = useGradingSettingUpdater(courseId);
|
||||
|
||||
const courseAssignmentLists = gradingSettings?.courseAssignmentLists;
|
||||
const courseGradingDetails = gradingSettings?.courseDetails;
|
||||
|
||||
const GradingSettings = ({ intl, courseId }) => {
|
||||
const gradingSettingsData = useSelector(getGradingSettings);
|
||||
const courseSettingsData = useSelector(getCourseSettings);
|
||||
const courseAssignmentLists = useSelector(getCourseAssignmentLists);
|
||||
const savingStatus = useSelector(getSavingStatus);
|
||||
const loadingStatus = useSelector(getLoadingStatus);
|
||||
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
const isLoading = loadingStatus === RequestStatus.IN_PROGRESS;
|
||||
const isLoading = isCourseSettingsLoading || isGradingSettingsLoading;
|
||||
const [isQueryPending, setIsQueryPending] = useState(false);
|
||||
const [showOverrideInternetConnectionAlert, setOverrideInternetConnectionAlert] = useState(false);
|
||||
const [eligibleGrade, setEligibleGrade] = useState(null);
|
||||
|
||||
const courseDetails = useModel('courseDetails', courseId);
|
||||
document.title = getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle));
|
||||
const courseName = useModel('courseDetails', courseId)?.name;
|
||||
|
||||
const {
|
||||
graders,
|
||||
@@ -60,7 +68,7 @@ const GradingSettings = ({ intl, courseId }) => {
|
||||
handleResetPageData,
|
||||
handleAddAssignment,
|
||||
handleRemoveAssignment,
|
||||
} = useUpdateGradingData(gradingSettingsData, setOverrideInternetConnectionAlert, setShowSuccessAlert);
|
||||
} = useUpdateGradingData(courseGradingDetails, setOverrideInternetConnectionAlert, setShowSuccessAlert);
|
||||
|
||||
const {
|
||||
gradeLetters,
|
||||
@@ -69,28 +77,22 @@ const GradingSettings = ({ intl, courseId }) => {
|
||||
} = useConvertGradeCutoffs(gradeCutoffs);
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
if (savingStatus) {
|
||||
setShowSuccessAlert(!showSuccessAlert);
|
||||
setShowSavePrompt(!showSavePrompt);
|
||||
setTimeout(() => setShowSuccessAlert(false), 15000);
|
||||
setIsQueryPending(!isQueryPending);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGradingSettings(courseId));
|
||||
dispatch(fetchCourseSettingsQuery(courseId));
|
||||
}, [courseId]);
|
||||
}, [savePending]);
|
||||
|
||||
if (isLoading) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <></>;
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleQueryProcessing = () => {
|
||||
setShowSuccessAlert(false);
|
||||
dispatch(sendGradingSetting(courseId, gradingData));
|
||||
updateGradingSettings(gradingData);
|
||||
};
|
||||
|
||||
const handleSendGradingSettingsData = () => {
|
||||
@@ -110,11 +112,14 @@ const GradingSettings = ({ intl, courseId }) => {
|
||||
default: intl.formatMessage(messages.buttonSaveText),
|
||||
pending: intl.formatMessage(messages.buttonSavingText),
|
||||
},
|
||||
disabledStates: [RequestStatus.PENDING],
|
||||
disabledStates: [STATEFUL_BUTTON_STATES.pending],
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle))}</title>
|
||||
</Helmet>
|
||||
<Container size="xl" className="grading px-4">
|
||||
<div className="mt-5">
|
||||
<AlertMessage
|
||||
@@ -156,6 +161,7 @@ const GradingSettings = ({ intl, courseId }) => {
|
||||
resetDataRef={resetDataRef}
|
||||
setOverrideInternetConnectionAlert={setOverrideInternetConnectionAlert}
|
||||
setEligibleGrade={setEligibleGrade}
|
||||
defaultGradeDesignations={gradingSettings?.defaultGradeDesignations}
|
||||
/>
|
||||
</section>
|
||||
{courseSettingsData.creditEligibilityEnabled && courseSettingsData.isCreditCourse && (
|
||||
@@ -226,7 +232,7 @@ const GradingSettings = ({ intl, courseId }) => {
|
||||
<div className="alert-toast">
|
||||
{showOverrideInternetConnectionAlert && (
|
||||
<InternetConnectionAlert
|
||||
isFailed={savingStatus === RequestStatus.FAILED}
|
||||
isFailed={savingFailed}
|
||||
isQueryPending={isQueryPending}
|
||||
onQueryProcessing={handleQueryProcessing}
|
||||
onInternetConnectionFailed={handleInternetConnectionFailed}
|
||||
@@ -263,8 +269,7 @@ const GradingSettings = ({ intl, courseId }) => {
|
||||
};
|
||||
|
||||
GradingSettings.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(GradingSettings);
|
||||
export default GradingSettings;
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import React from 'react';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { render, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
act, fireEvent, render, screen,
|
||||
} from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import React from 'react';
|
||||
|
||||
import initializeStore from '../store';
|
||||
import { getGradingSettingsApiUrl } from './data/api';
|
||||
import gradingSettings from './__mocks__/gradingSettings';
|
||||
import { getCourseSettingsApiUrl, getGradingSettingsApiUrl } from './data/api';
|
||||
import GradingSettings from './GradingSettings';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -16,10 +19,14 @@ const courseId = '123';
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<GradingSettings intl={injectIntl} courseId={courseId} />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GradingSettings intl={injectIntl} courseId={courseId} />
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
@@ -28,10 +35,7 @@ describe('<GradingSettings />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
userId: 3, username: 'abc123', administrator: true, roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -40,52 +44,72 @@ describe('<GradingSettings />', () => {
|
||||
axiosMock
|
||||
.onGet(getGradingSettingsApiUrl(courseId))
|
||||
.reply(200, gradingSettings);
|
||||
axiosMock
|
||||
.onPost(getGradingSettingsApiUrl(courseId))
|
||||
.reply(200, {});
|
||||
axiosMock.onGet(getCourseSettingsApiUrl(courseId))
|
||||
.reply(200, {});
|
||||
render(<RootWrapper />);
|
||||
});
|
||||
|
||||
it('should render without errors', async () => {
|
||||
const { getByText, getAllByText } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const gradingElements = getAllByText(messages.headingTitle.defaultMessage);
|
||||
const gradingTitle = gradingElements[0];
|
||||
expect(getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(gradingTitle).toBeInTheDocument();
|
||||
expect(getByText(messages.policy.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.policiesDescription.defaultMessage)).toBeInTheDocument();
|
||||
function testSaving() {
|
||||
const saveBtn = screen.getByText(messages.buttonSaveText.defaultMessage);
|
||||
expect(saveBtn).toBeInTheDocument();
|
||||
fireEvent.click(saveBtn);
|
||||
expect(screen.getByText(messages.buttonSavingText.defaultMessage)).toBeInTheDocument();
|
||||
}
|
||||
|
||||
function setOnlineStatus(isOnline) {
|
||||
jest.spyOn(navigator, 'onLine', 'get').mockReturnValue(isOnline);
|
||||
act(() => {
|
||||
window.dispatchEvent(new window.Event(isOnline ? 'online' : 'offline'));
|
||||
});
|
||||
}
|
||||
|
||||
it('should render without errors', async () => {
|
||||
const gradingElements = await screen.findAllByText(messages.headingTitle.defaultMessage);
|
||||
const gradingTitle = gradingElements[0];
|
||||
expect(screen.getByText(messages.headingSubtitle.defaultMessage)).toBeInTheDocument();
|
||||
expect(gradingTitle).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.policy.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.getByText(messages.policiesDescription.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should update segment input value and show save alert', async () => {
|
||||
const { getByTestId, getAllByTestId } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const segmentInputs = getAllByTestId('grading-scale-segment-input');
|
||||
expect(segmentInputs).toHaveLength(5);
|
||||
const segmentInput = segmentInputs[1];
|
||||
fireEvent.change(segmentInput, { target: { value: 'Test' } });
|
||||
expect(segmentInput).toHaveValue('Test');
|
||||
expect(getByTestId('grading-settings-save-alert')).toBeVisible();
|
||||
});
|
||||
const segmentInputs = await screen.findAllByTestId('grading-scale-segment-input');
|
||||
expect(segmentInputs).toHaveLength(5);
|
||||
const segmentInput = segmentInputs[1];
|
||||
fireEvent.change(segmentInput, { target: { value: 'Test' } });
|
||||
expect(segmentInput).toHaveValue('Test');
|
||||
expect(screen.getByTestId('grading-settings-save-alert')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should update grading scale segment input value on change and cancel the action', async () => {
|
||||
const { getByText, getAllByTestId } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const segmentInputs = getAllByTestId('grading-scale-segment-input');
|
||||
const segmentInput = segmentInputs[1];
|
||||
fireEvent.change(segmentInput, { target: { value: 'Test' } });
|
||||
fireEvent.click(getByText(messages.buttonCancelText.defaultMessage));
|
||||
expect(segmentInput).toHaveValue('a');
|
||||
});
|
||||
const segmentInputs = await screen.findAllByTestId('grading-scale-segment-input');
|
||||
const segmentInput = segmentInputs[1];
|
||||
fireEvent.change(segmentInput, { target: { value: 'Test' } });
|
||||
fireEvent.click(screen.getByText(messages.buttonCancelText.defaultMessage));
|
||||
expect(segmentInput).toHaveValue('a');
|
||||
});
|
||||
|
||||
it('should save segment input changes and display saving message', async () => {
|
||||
const { getByText, getAllByTestId } = render(<RootWrapper />);
|
||||
await waitFor(() => {
|
||||
const segmentInputs = getAllByTestId('grading-scale-segment-input');
|
||||
const segmentInput = segmentInputs[1];
|
||||
fireEvent.change(segmentInput, { target: { value: 'Test' } });
|
||||
const saveBtn = getByText(messages.buttonSaveText.defaultMessage);
|
||||
expect(saveBtn).toBeInTheDocument();
|
||||
fireEvent.click(saveBtn);
|
||||
expect(getByText(messages.buttonSavingText.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
const segmentInputs = await screen.findAllByTestId('grading-scale-segment-input');
|
||||
const segmentInput = segmentInputs[1];
|
||||
fireEvent.change(segmentInput, { target: { value: 'Test' } });
|
||||
testSaving();
|
||||
});
|
||||
|
||||
it('should handle being offline gracefully', async () => {
|
||||
setOnlineStatus(false);
|
||||
const segmentInputs = await screen.findAllByTestId('grading-scale-segment-input');
|
||||
const segmentInput = segmentInputs[1];
|
||||
fireEvent.change(segmentInput, { target: { value: 'Test' } });
|
||||
const saveBtn = screen.getByText(messages.buttonSaveText.defaultMessage);
|
||||
expect(saveBtn).toBeInTheDocument();
|
||||
fireEvent.click(saveBtn);
|
||||
expect(screen.getByText(/studio's having trouble saving your work/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(messages.buttonSavingText.defaultMessage)).not.toBeInTheDocument();
|
||||
setOnlineStatus(true);
|
||||
testSaving();
|
||||
});
|
||||
});
|
||||
|
||||
26
src/grading-settings/data/apiHooks.ts
Normal file
26
src/grading-settings/data/apiHooks.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { getCourseSettings, getGradingSettings, sendGradingSettings } from './api';
|
||||
|
||||
export const useGradingSettings = (courseId: string) => (
|
||||
useQuery({
|
||||
queryKey: ['gradingSettings', courseId],
|
||||
queryFn: () => getGradingSettings(courseId),
|
||||
})
|
||||
);
|
||||
|
||||
export const useCourseSettings = (courseId: string) => (
|
||||
useQuery({
|
||||
queryKey: ['courseSettings', courseId],
|
||||
queryFn: () => getCourseSettings(courseId),
|
||||
})
|
||||
);
|
||||
|
||||
export const useGradingSettingUpdater = (courseId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (settings) => sendGradingSettings(courseId, settings),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['gradingSettings', courseId] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
const getLoadingStatus = (state) => state.gradingSettings.loadingStatus;
|
||||
const getGradingSettings = (state) => state.gradingSettings.gradingSettings.courseDetails;
|
||||
const getCourseAssignmentLists = (state) => state.gradingSettings.gradingSettings.courseAssignmentLists;
|
||||
const getSavingStatus = (state) => state.gradingSettings.savingStatus;
|
||||
const getCourseSettings = (state) => state.gradingSettings.courseSettings;
|
||||
|
||||
export {
|
||||
getLoadingStatus,
|
||||
getGradingSettings,
|
||||
getCourseAssignmentLists,
|
||||
getSavingStatus,
|
||||
getCourseSettings,
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'gradingSettings',
|
||||
initialState: {
|
||||
loadingStatus: RequestStatus.IN_PROGRESS,
|
||||
savingStatus: '',
|
||||
gradingSettings: {},
|
||||
courseSettings: {},
|
||||
},
|
||||
reducers: {
|
||||
updateLoadingStatus: (state, { payload }) => {
|
||||
state.loadingStatus = payload.status;
|
||||
},
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
fetchGradingSettingsSuccess: (state, { payload }) => {
|
||||
Object.assign(state.gradingSettings, payload);
|
||||
},
|
||||
sendGradingSettingsSuccess: (state, { payload }) => {
|
||||
Object.assign(state.gradingSettings, payload);
|
||||
},
|
||||
fetchCourseSettingsSuccess: (state, { payload }) => {
|
||||
Object.assign(state.courseSettings, payload);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
fetchGradingSettingsSuccess,
|
||||
sendGradingSettingsSuccess,
|
||||
fetchCourseSettingsSuccess,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
reducer,
|
||||
} = slice;
|
||||
@@ -1,55 +0,0 @@
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import {
|
||||
getGradingSettings,
|
||||
sendGradingSettings,
|
||||
getCourseSettings,
|
||||
} from './api';
|
||||
import {
|
||||
sendGradingSettingsSuccess,
|
||||
updateLoadingStatus,
|
||||
updateSavingStatus,
|
||||
fetchGradingSettingsSuccess,
|
||||
fetchCourseSettingsSuccess,
|
||||
} from './slice';
|
||||
|
||||
export function fetchGradingSettings(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
try {
|
||||
const settingValues = await getGradingSettings(courseId);
|
||||
dispatch(fetchGradingSettingsSuccess(settingValues));
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function sendGradingSetting(courseId, settings) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
try {
|
||||
const settingValues = await sendGradingSettings(courseId, settings);
|
||||
dispatch(sendGradingSettingsSuccess(settingValues));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourseSettingsQuery(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
try {
|
||||
const settingsValues = await getCourseSettings(courseId);
|
||||
dispatch(fetchCourseSettingsSuccess(settingsValues));
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
dispatch(updateLoadingStatus({ status: RequestStatus.FAILED }));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,19 +1,18 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, IconButtonWithTooltip } from '@openedx/paragon';
|
||||
import { Add as IconAdd } from '@openedx/paragon/icons';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { GradingScaleHandle, GradingScaleSegment, GradingScaleTicks } from './components';
|
||||
import messages from './messages';
|
||||
|
||||
import { useRanger } from './react-ranger';
|
||||
import messages from './messages';
|
||||
import { convertGradeData, MAXIMUM_SCALE_LENGTH } from './utils';
|
||||
import { GradingScaleTicks, GradingScaleHandle, GradingScaleSegment } from './components';
|
||||
|
||||
const DEFAULT_LETTERS = ['A', 'B', 'C', 'D'];
|
||||
const DEFAULT_GRADE_LETTERS = ['A', 'B', 'C', 'D'];
|
||||
const getDefaultPassText = intl => intl.formatMessage(messages.defaultPassText);
|
||||
|
||||
const GradingScale = ({
|
||||
intl,
|
||||
showSavePrompt,
|
||||
gradeCutoffs,
|
||||
setShowSuccessAlert,
|
||||
@@ -23,7 +22,9 @@ const GradingScale = ({
|
||||
sortedGrades,
|
||||
setOverrideInternetConnectionAlert,
|
||||
setEligibleGrade,
|
||||
defaultGradeDesignations,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [gradingSegments, setGradingSegments] = useState(sortedGrades);
|
||||
const [letters, setLetters] = useState(gradeLetters);
|
||||
const [convertedResult, setConvertedResult] = useState({});
|
||||
@@ -54,39 +55,52 @@ const GradingScale = ({
|
||||
}, [gradingSegments, letters]);
|
||||
|
||||
const addNewGradingSegment = () => {
|
||||
setGradingSegments(prevSegments => {
|
||||
const firstSegment = prevSegments[prevSegments.length - 1];
|
||||
const secondSegment = prevSegments[prevSegments.length - 2];
|
||||
const newCurrentValue = Math.ceil((secondSegment.current - secondSegment.previous) / 2);
|
||||
setGradingSegments((prevSegments) => {
|
||||
let updatedGradingSegment = [];
|
||||
if (prevSegments.length >= 5) {
|
||||
const segSize = MAXIMUM_SCALE_LENGTH / (prevSegments.length + 1);
|
||||
updatedGradingSegment = Array.from({
|
||||
length: prevSegments.length + 1,
|
||||
}).map((_, i) => ({
|
||||
current: 100 - i * segSize,
|
||||
previous: 100 - (i + 1) * segSize,
|
||||
}));
|
||||
} else {
|
||||
const firstSegment = prevSegments[prevSegments.length - 1];
|
||||
const secondSegment = prevSegments[prevSegments.length - 2];
|
||||
const newCurrentValue = Math.ceil(
|
||||
(secondSegment.current - secondSegment.previous) / 2,
|
||||
);
|
||||
|
||||
const newSegment = {
|
||||
current: (firstSegment.current + newCurrentValue),
|
||||
previous: firstSegment.current,
|
||||
};
|
||||
const newSegment = {
|
||||
current: firstSegment.current + newCurrentValue,
|
||||
previous: firstSegment.current,
|
||||
};
|
||||
|
||||
const updatedSecondSegment = {
|
||||
...secondSegment,
|
||||
previous: (firstSegment.current + newCurrentValue),
|
||||
};
|
||||
const updatedSecondSegment = {
|
||||
...secondSegment,
|
||||
previous: firstSegment.current + newCurrentValue,
|
||||
};
|
||||
updatedGradingSegment = [
|
||||
...prevSegments.slice(0, prevSegments.length - 2),
|
||||
updatedSecondSegment,
|
||||
newSegment,
|
||||
firstSegment,
|
||||
];
|
||||
}
|
||||
|
||||
showSavePrompt(true);
|
||||
setShowSuccessAlert(false);
|
||||
setOverrideInternetConnectionAlert(false);
|
||||
|
||||
return [
|
||||
...prevSegments.slice(0, prevSegments.length - 2),
|
||||
updatedSecondSegment,
|
||||
newSegment,
|
||||
firstSegment,
|
||||
];
|
||||
return updatedGradingSegment;
|
||||
});
|
||||
|
||||
const nextIndex = (letters.length % DEFAULT_LETTERS.length);
|
||||
const nextIndex = (letters.length % defaultGradeDesignations.length);
|
||||
|
||||
if (gradingSegments.length === 2) {
|
||||
setLetters([DEFAULT_LETTERS[0], DEFAULT_LETTERS[nextIndex]]);
|
||||
setLetters([defaultGradeDesignations[0], defaultGradeDesignations[nextIndex]]);
|
||||
} else {
|
||||
setLetters(prevLetters => [...prevLetters, DEFAULT_LETTERS[nextIndex]]);
|
||||
setLetters(prevLetters => [...prevLetters, defaultGradeDesignations[nextIndex]]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -191,7 +205,7 @@ const GradingScale = ({
|
||||
<IconButtonWithTooltip
|
||||
tooltipPlacement="top"
|
||||
tooltipContent={intl.formatMessage(messages.addNewSegmentButtonAltText)}
|
||||
disabled={gradingSegments.length >= 5}
|
||||
disabled={gradingSegments.length >= (defaultGradeDesignations.length + 1)}
|
||||
data-testid="grading-scale-btn-add-segment"
|
||||
className="mr-3"
|
||||
src={IconAdd}
|
||||
@@ -230,7 +244,6 @@ const GradingScale = ({
|
||||
};
|
||||
|
||||
GradingScale.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
showSavePrompt: PropTypes.func.isRequired,
|
||||
gradeCutoffs: PropTypes.objectOf(PropTypes.number).isRequired,
|
||||
gradeLetters: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
@@ -245,6 +258,11 @@ GradingScale.propTypes = {
|
||||
}),
|
||||
).isRequired,
|
||||
setEligibleGrade: PropTypes.func.isRequired,
|
||||
defaultGradeDesignations: PropTypes.arrayOf(PropTypes.string),
|
||||
};
|
||||
|
||||
export default injectIntl(GradingScale);
|
||||
GradingScale.defaultProps = {
|
||||
defaultGradeDesignations: DEFAULT_GRADE_LETTERS,
|
||||
};
|
||||
|
||||
export default GradingScale;
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
height: 3.125rem;
|
||||
width: 100%;
|
||||
border: 1px solid $black;
|
||||
overflow: hidden;
|
||||
|
||||
.grading-scale-tick {
|
||||
.grading-scale-tick-number {
|
||||
@@ -81,7 +82,6 @@
|
||||
.grading-scale-segment-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-right: 1.25rem;
|
||||
margin-top: .375rem;
|
||||
font-size: .7rem;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -123,4 +123,33 @@ describe('<GradingScale />', () => {
|
||||
expect(segmentInputs[1]).toHaveValue('Test');
|
||||
});
|
||||
});
|
||||
|
||||
it('should render GradingScale component with more than 5 grades', async () => {
|
||||
const { getAllByTestId } = render(
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<GradingScale
|
||||
intl={injectIntl}
|
||||
gradeCutoffs={gradeCutoffs}
|
||||
gradeLetters={gradeLetters}
|
||||
sortedGrades={sortedGrades}
|
||||
resetDataRef={{ current: false }}
|
||||
showSavePrompt={jest.fn()}
|
||||
setShowSuccessAlert={jest.fn()}
|
||||
setGradingData={jest.fn()}
|
||||
setOverrideInternetConnectionAlert={jest.fn()}
|
||||
setEligibleGrade={jest.fn()}
|
||||
defaultGradeDesignations={['A', 'B', 'C', 'D', 'E']}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
const addNewSegmentBtn = getAllByTestId('grading-scale-btn-add-segment');
|
||||
expect(addNewSegmentBtn[0]).toBeInTheDocument();
|
||||
fireEvent.click(addNewSegmentBtn[0]);
|
||||
const segments = getAllByTestId('grading-scale-segment-number');
|
||||
// Calculation is based on 100/6 i.e A, B, C, D, E, F which comes to 16.666666666666657
|
||||
expect(segments[0].textContent).toEqual('83.33333333333333 - 100');
|
||||
expect(segments[6].textContent).toEqual('0 - 15.666666666666657');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { Button } from '@openedx/paragon';
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getLettersOnLongScale, getLettersOnShortScale } from '../utils';
|
||||
import messages from '../messages';
|
||||
|
||||
const GradingScaleSegment = ({
|
||||
intl,
|
||||
idx,
|
||||
value,
|
||||
getSegmentProps,
|
||||
handleLetterChange,
|
||||
letters,
|
||||
gradingSegments,
|
||||
removeGradingSegment,
|
||||
}) => (
|
||||
<div
|
||||
key={value}
|
||||
className={`grading-scale-segment segment-${idx - 1}`}
|
||||
data-testid="grading-scale-segment"
|
||||
{...getSegmentProps()}
|
||||
>
|
||||
<div className="grading-scale-segment-content">
|
||||
{gradingSegments.length === 2 && (
|
||||
<input
|
||||
className="grading-scale-segment-content-title m-0"
|
||||
data-testid="grading-scale-segment-input"
|
||||
value={getLettersOnShortScale(idx, letters, intl)}
|
||||
onChange={e => handleLetterChange(e, idx)}
|
||||
disabled={idx === gradingSegments.length}
|
||||
/>
|
||||
)}
|
||||
{gradingSegments.length > 2 && (
|
||||
<input
|
||||
className="grading-scale-segment-content-title m-0"
|
||||
data-testid="grading-scale-segment-input"
|
||||
value={getLettersOnLongScale(idx, letters, gradingSegments)}
|
||||
onChange={e => handleLetterChange(e, idx)}
|
||||
disabled={idx === gradingSegments.length}
|
||||
/>
|
||||
)}
|
||||
<span className="grading-scale-segment-content-number m-0">
|
||||
{gradingSegments[idx === 0 ? 0 : idx - 1]?.previous} - {value === 100 ? value : value - 1}
|
||||
</span>
|
||||
</div>
|
||||
{idx !== gradingSegments.length && idx - 1 !== 0 && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="inline"
|
||||
className="grading-scale-segment-btn-remove"
|
||||
data-testid="grading-scale-btn-remove"
|
||||
type="button"
|
||||
onClick={() => removeGradingSegment(idx)}
|
||||
>
|
||||
{intl.formatMessage(messages.removeSegmentButtonText)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
GradingScaleSegment.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
idx: PropTypes.number.isRequired,
|
||||
value: PropTypes.number.isRequired,
|
||||
getSegmentProps: PropTypes.func.isRequired,
|
||||
handleLetterChange: PropTypes.func.isRequired,
|
||||
removeGradingSegment: PropTypes.func.isRequired,
|
||||
gradingSegments: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
current: PropTypes.number.isRequired,
|
||||
previous: PropTypes.number.isRequired,
|
||||
}),
|
||||
).isRequired,
|
||||
letters: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(GradingScaleSegment);
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import React, { ChangeEvent } from 'react';
|
||||
import messages from '../messages';
|
||||
|
||||
import { getLettersOnLongScale, getLettersOnShortScale } from '../utils';
|
||||
|
||||
interface RangeSegment {
|
||||
previous: number,
|
||||
current: number,
|
||||
}
|
||||
|
||||
interface GradingScaleSegmentProps {
|
||||
idx: number,
|
||||
value: number,
|
||||
getSegmentProps: () => { [key: string]: string },
|
||||
handleLetterChange: (event: ChangeEvent, idx: number) => void,
|
||||
letters: [string],
|
||||
gradingSegments: RangeSegment[],
|
||||
removeGradingSegment: (idx: number) => void,
|
||||
}
|
||||
|
||||
const GradingScaleSegment = ({
|
||||
idx,
|
||||
value,
|
||||
getSegmentProps,
|
||||
handleLetterChange,
|
||||
letters,
|
||||
gradingSegments,
|
||||
removeGradingSegment,
|
||||
}: GradingScaleSegmentProps) => {
|
||||
const intl = useIntl();
|
||||
const prevValue = gradingSegments[idx === 0 ? 0 : idx - 1]?.previous ?? 0;
|
||||
const segmentRightMargin = (value - prevValue) < 6 ? '0.125rem' : '1.25rem';
|
||||
return (
|
||||
<div
|
||||
key={value}
|
||||
className={`grading-scale-segment segment-${idx - 1}`}
|
||||
data-testid="grading-scale-segment"
|
||||
{...getSegmentProps()}
|
||||
>
|
||||
<div
|
||||
className="grading-scale-segment-content"
|
||||
style={{
|
||||
marginRight: segmentRightMargin,
|
||||
}}
|
||||
>
|
||||
{gradingSegments.length === 2 && (
|
||||
<input
|
||||
className="grading-scale-segment-content-title m-0"
|
||||
data-testid="grading-scale-segment-input"
|
||||
value={getLettersOnShortScale(idx, letters, intl)}
|
||||
onChange={e => handleLetterChange(e, idx)}
|
||||
disabled={idx === gradingSegments.length}
|
||||
/>
|
||||
)}
|
||||
{gradingSegments.length > 2 && (
|
||||
<input
|
||||
className="grading-scale-segment-content-title m-0"
|
||||
data-testid="grading-scale-segment-input"
|
||||
value={getLettersOnLongScale(idx, letters, gradingSegments)}
|
||||
onChange={e => handleLetterChange(e, idx)}
|
||||
disabled={idx === gradingSegments.length}
|
||||
/>
|
||||
)}
|
||||
<span data-testid="grading-scale-segment-number" className="grading-scale-segment-content-number m-0">
|
||||
{gradingSegments[idx === 0 ? 0 : idx - 1]?.previous} - {value === 100 ? value : value - 1}
|
||||
</span>
|
||||
</div>
|
||||
{idx !== gradingSegments.length && idx - 1 !== 0 && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="inline"
|
||||
className="grading-scale-segment-btn-remove"
|
||||
data-testid="grading-scale-btn-remove"
|
||||
type="button"
|
||||
onClick={() => removeGradingSegment(idx)}
|
||||
>
|
||||
{intl.formatMessage(messages.removeSegmentButtonText)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GradingScaleSegment;
|
||||
@@ -10,7 +10,6 @@ import { reducer as discussionsReducer } from './pages-and-resources/discussions
|
||||
import { reducer as pagesAndResourcesReducer } from './pages-and-resources/data/slice';
|
||||
import { reducer as customPagesReducer } from './custom-pages/data/slice';
|
||||
import { reducer as advancedSettingsReducer } from './advanced-settings/data/slice';
|
||||
import { reducer as gradingSettingsReducer } from './grading-settings/data/slice';
|
||||
import { reducer as studioHomeReducer } from './studio-home/data/slice';
|
||||
import { reducer as scheduleAndDetailsReducer } from './schedule-and-details/data/slice';
|
||||
import { reducer as filesReducer } from './files-and-videos/files-page/data/slice';
|
||||
@@ -40,7 +39,6 @@ export default function initializeStore(preloadedState = undefined) {
|
||||
pagesAndResources: pagesAndResourcesReducer,
|
||||
scheduleAndDetails: scheduleAndDetailsReducer,
|
||||
advancedSettings: advancedSettingsReducer,
|
||||
gradingSettings: gradingSettingsReducer,
|
||||
studioHome: studioHomeReducer,
|
||||
models: modelsReducer,
|
||||
live: liveReducer,
|
||||
|
||||
Reference in New Issue
Block a user