Skip to content

Commit

Permalink
Merge pull request #62 from open-craft/kshitij/use-custom-grade-range…
Browse files Browse the repository at this point in the history
…-redwood

feat: Use configured DEFAULT_GRADE_DESIGNATIONS
  • Loading branch information
xitij2000 authored Sep 2, 2024
2 parents d4f6606 + acbd7fa commit 7324297
Show file tree
Hide file tree
Showing 10 changed files with 232 additions and 268 deletions.
101 changes: 53 additions & 48 deletions src/grading-settings/GradingSettings.jsx
Original file line number Diff line number Diff line change
@@ -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 { useModel } from '../generic/model-store';
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 { RequestStatus } from '../data/constants';
import InternetConnectionAlert from '../generic/internet-connection-alert';
import SubHeader from '../generic/sub-header/SubHeader';

import { useModel } from '../generic/model-store';
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,
Expand All @@ -60,7 +68,7 @@ const GradingSettings = ({ intl, courseId }) => {
handleResetPageData,
handleAddAssignment,
handleRemoveAssignment,
} = useUpdateGradingData(gradingSettingsData, setOverrideInternetConnectionAlert, setShowSuccessAlert);
} = useUpdateGradingData(courseGradingDetails, setOverrideInternetConnectionAlert, setShowSuccessAlert);

const {
gradeLetters,
Expand All @@ -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 = () => {
Expand All @@ -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
Expand Down Expand Up @@ -156,6 +161,7 @@ const GradingSettings = ({ intl, courseId }) => {
resetDataRef={resetDataRef}
setOverrideInternetConnectionAlert={setOverrideInternetConnectionAlert}
setEligibleGrade={setEligibleGrade}
defaultGradeDesignations={gradingSettings?.defaultGradeDesignations}
/>
</section>
{courseSettingsData.creditEligibilityEnabled && courseSettingsData.isCreditCourse && (
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -263,8 +269,7 @@ const GradingSettings = ({ intl, courseId }) => {
};

GradingSettings.propTypes = {
intl: intlShape.isRequired,
courseId: PropTypes.string.isRequired,
};

export default injectIntl(GradingSettings);
export default GradingSettings;
116 changes: 70 additions & 46 deletions src/grading-settings/GradingSettings.test.jsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
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';

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>
);
Expand All @@ -28,10 +35,7 @@ describe('<GradingSettings />', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
userId: 3, username: 'abc123', administrator: true, roles: [],
},
});

Expand All @@ -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 changes: 26 additions & 0 deletions src/grading-settings/data/apiHooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getCourseSettings, getGradingSettings, sendGradingSettings } from './api';

export const useGradingSettings = (courseId) => (
useQuery({
queryKey: ['gradingSettings', courseId],
queryFn: () => getGradingSettings(courseId),
})
);

export const useCourseSettings = (courseId) => (
useQuery({
queryKey: ['courseSettings', courseId],
queryFn: () => getCourseSettings(courseId),
})
);

export const useGradingSettingUpdater = (courseId) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (settings) => sendGradingSettings(courseId, settings),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['gradingSettings', courseId] });
},
});
};
13 changes: 0 additions & 13 deletions src/grading-settings/data/selectors.js

This file was deleted.

Loading

0 comments on commit 7324297

Please sign in to comment.