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:
Kshitij Sobti
2024-10-25 15:34:04 +05:30
committed by GitHub
parent 774728a9c0
commit 4d4adce715
12 changed files with 315 additions and 319 deletions

View File

@@ -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;

View File

@@ -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();
});
});

View 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] });
},
});
};

View File

@@ -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,
};

View File

@@ -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;

View File

@@ -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;
}
};
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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');
});
});
});

View File

@@ -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);

View File

@@ -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;

View File

@@ -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,