feat: add checklist page (#870)

* feat: add checklist page

* fix: failing tests

* fix: styling bugs

* fix: lint errors

* feat: add test fro CourseChecklist

* fix: lint errors

* feat: add ChecklistSection tests

* fix: lint error

* fix: missing api reply status
This commit is contained in:
Kristin Aoki
2024-03-07 15:20:33 -05:00
committed by GitHub
parent f035391c2f
commit 8100281fb4
35 changed files with 2260 additions and 0 deletions

1
.env
View File

@@ -40,3 +40,4 @@ HOTJAR_VERSION=6
HOTJAR_DEBUG=false
INVITE_STUDENTS_EMAIL_TO=''
AI_TRANSLATIONS_BASE_URL=''
ENABLE_CHECKLIST_QUALITY=''

View File

@@ -42,3 +42,4 @@ HOTJAR_VERSION=6
HOTJAR_DEBUG=true
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
AI_TRANSLATIONS_BASE_URL='http://localhost:18760'
ENABLE_CHECKLIST_QUALITY=true

View File

@@ -34,3 +34,4 @@ ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN=true
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_CHECKLIST_QUALITY=true

1
course-ingestion-tool Submodule

Submodule course-ingestion-tool added at f8ec84aa4a

View File

@@ -20,6 +20,7 @@ import { CourseUnit } from './course-unit';
import CourseExportPage from './export-page/CourseExportPage';
import CourseImportPage from './import-page/CourseImportPage';
import { DECODED_ROUTES } from './constants';
import CourseChecklist from './course-checklist';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
@@ -110,6 +111,10 @@ const CourseAuthoringRoutes = () => {
path="export"
element={<PageWrap><CourseExportPage courseId={courseId} /></PageWrap>}
/>
<Route
path="checklists"
element={<PageWrap><CourseChecklist courseId={courseId} /></PageWrap>}
/>
</Routes>
</CourseAuthoringPage>
);

View File

@@ -0,0 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import messages from './messages';
const AriaLiveRegion = ({
isCourseLaunchChecklistLoading,
isCourseBestPracticeChecklistLoading,
enableQuality,
}) => {
const courseLaunchLoadingMessage = isCourseLaunchChecklistLoading
? <FormattedMessage {...messages.launchChecklistLoadingLabel} />
: <FormattedMessage {...messages.launchChecklistDoneLoadingLabel} />;
const courseBestPracticesLoadingMessage = isCourseBestPracticeChecklistLoading
? <FormattedMessage {...messages.bestPracticesChecklistLoadingLabel} />
: <FormattedMessage {...messages.bestPracticesChecklistDoneLoadingLabel} />;
return (
<div className="sr-only" aria-live="polite" role="status">
<div>
{courseLaunchLoadingMessage}
</div>
{enableQuality ? <div>{courseBestPracticesLoadingMessage}</div> : null}
</div>
);
};
AriaLiveRegion.propTypes = {
isCourseLaunchChecklistLoading: PropTypes.bool.isRequired,
isCourseBestPracticeChecklistLoading: PropTypes.bool.isRequired,
enableQuality: PropTypes.bool.isRequired,
};
export default injectIntl(AriaLiveRegion);

View File

@@ -0,0 +1,70 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Button,
Hyperlink,
Icon,
} from '@openedx/paragon';
import { CheckCircle, RadioButtonUnchecked } from '@openedx/paragon/icons';
import messages from './messages';
const ChecklistItemBody = ({
checkId,
isCompleted,
updateLink,
// injected
intl,
}) => (
<ActionRow>
<div className="mr-3" id={`icon-${checkId}`} data-testid={`icon-${checkId}`}>
{isCompleted ? (
<Icon
data-testid="completed-icon"
src={CheckCircle}
className="text-success"
style={{ height: '32px', width: '32px' }}
screenReaderText={intl.formatMessage(messages.completedItemLabel)}
/>
) : (
<Icon
data-testid="uncompleted-icon"
src={RadioButtonUnchecked}
style={{ height: '32px', width: '32px' }}
screenReaderText={intl.formatMessage(messages.uncompletedItemLabel)}
/>
)}
</div>
<div>
<div>
<FormattedMessage {...messages[`${checkId}ShortDescription`]} />
</div>
<div className="small">
<FormattedMessage {...messages[`${checkId}LongDescription`]} />
</div>
</div>
<ActionRow.Spacer />
{updateLink && (
<Hyperlink destination={updateLink} data-testid="update-hyperlink">
<Button size="sm">
<FormattedMessage {...messages.updateLinkLabel} />
</Button>
</Hyperlink>
)}
</ActionRow>
);
ChecklistItemBody.defaultProps = {
updateLink: null,
};
ChecklistItemBody.propTypes = {
checkId: PropTypes.string.isRequired,
isCompleted: PropTypes.bool.isRequired,
updateLink: PropTypes.string,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(ChecklistItemBody);

View File

@@ -0,0 +1,123 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, FormattedMessage, FormattedNumber } from '@edx/frontend-platform/i18n';
import { Hyperlink, Icon } from '@openedx/paragon';
import { ModeComment } from '@openedx/paragon/icons';
import messages from './messages';
const ChecklistItemComment = ({
checkId,
outlineUrl,
data,
}) => {
const commentWrapper = (comment) => (
<div className="row m-0 mt-3 pt-3 border-top align-items-center" data-identifier="comment">
<div className="mr-4">
<Icon src={ModeComment} size="lg" style={{ height: '32px', width: '32px' }} />
</div>
<div className="small">
{comment}
</div>
</div>
);
if (checkId === 'gradingPolicy') {
const sumOfWeights = data?.grades.sumOfWeights || 0;
const showGradingCommentSection = Object.keys(data).length > 0 && sumOfWeights !== 1;
const weightSumPercentage = (sumOfWeights * 100).toFixed(2);
const comment = (
<FormattedMessage
{...messages.gradingPolicyComment}
values={{
percent: (
<FormattedNumber
maximumFractionDigits={2}
minimumFractionDigits={2}
value={weightSumPercentage}
/>
),
}}
/>
);
return (showGradingCommentSection ? (
commentWrapper(comment)
) : null);
}
if (checkId === 'assignmentDeadlines') {
const showDeadlinesCommentSection = Object.keys(data).length > 0
&& (
data.assignments.assignmentsWithDatesBeforeStart.length > 0
|| data?.assignments.assignmentsWithDatesAfterEnd.length > 0
|| data?.assignments.assignmentsWithOraDatesBeforeStart.length > 0
|| data?.assignments.assignmentsWithOraDatesAfterEnd.length > 0
);
const allGradedAssignmentsOutsideDateRange = [].concat(
data?.assignments.assignmentsWithDatesBeforeStart,
data?.assignments.assignmentsWithDatesAfterEnd,
data?.assignments.assignmentsWithOraDatesBeforeStart,
data?.assignments.assignmentsWithOraDatesAfterEnd,
);
// de-dupe in case one assignment has multiple violations
const assignmentsMap = new Map();
allGradedAssignmentsOutsideDateRange.forEach(
(assignment) => { assignmentsMap.set(assignment.id, assignment); },
);
const gradedAssignmentsOutsideDateRange = [];
assignmentsMap.forEach(
(value) => {
gradedAssignmentsOutsideDateRange.push(value);
},
);
const comment = (
<>
<FormattedMessage {...messages.assignmentDeadlinesComment} />
<ul className="assignment-list">
{gradedAssignmentsOutsideDateRange.map(assignment => (
<li className="assignment-list-item" key={assignment.id}>
<Hyperlink
content={assignment.displayName}
destination={`${outlineUrl}#${assignment.id}`}
/>
</li>
))}
</ul>
</>
);
return (showDeadlinesCommentSection ? (
commentWrapper(comment)
) : null);
}
return null;
};
ChecklistItemComment.propTypes = {
checkId: PropTypes.string.isRequired,
outlineUrl: PropTypes.string.isRequired,
data: PropTypes.oneOfType([
PropTypes.shape({
grades: PropTypes.shape({
sumOfWeights: PropTypes.number,
}),
}).isRequired,
PropTypes.shape({
assignments: PropTypes.shape({
totalNumber: PropTypes.number,
totalVisible: PropTypes.number,
/* eslint-disable react/forbid-prop-types */
assignmentsWithDatesBeforeStart: PropTypes.array,
assignmentsWithDatesAfterEnd: PropTypes.array,
assignmentsWithOraDatesBeforeStart: PropTypes.array,
assignmentsWithOraDatesAfterEnd: PropTypes.array,
/* eslint-enable react/forbid-prop-types */
}),
}).isRequired,
]).isRequired,
};
export default injectIntl(ChecklistItemComment);

View File

@@ -0,0 +1,142 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { Container, Stack } from '@openedx/paragon';
import { LoadingSpinner } from '../../generic/Loading';
import { getCompletionCount, useChecklistState } from './hooks';
import ChecklistItemBody from './ChecklistItemBody';
import ChecklistItemComment from './ChecklistItemComment';
import { checklistItems } from './utils/courseChecklistData';
const ChecklistSection = ({
dataHeading,
data,
idPrefix,
isLoading,
updateLinks,
}) => {
const dataList = checklistItems[idPrefix];
const getCompletionCountID = () => (`${idPrefix}-completion-count`);
const { checklistState } = useChecklistState({ data, dataList });
const { checks, totalCompletedChecks, values } = checklistState;
return (
<Container>
<h3 aria-describedby={getCompletionCountID()} className="lead">{dataHeading}</h3>
{isLoading ? (
<div className="row justify-content-center" data-testid="loading-spinner">
<LoadingSpinner />
</div>
) : (
<>
<div data-testid="completion-subheader">
{getCompletionCount(checks, totalCompletedChecks)}
</div>
<Stack gap={3} className="mt-3">
{checks.map(check => {
const checkId = check.id;
const isCompleted = values[checkId];
const updateLink = updateLinks?.[checkId];
const outlineUrl = updateLinks.outline;
return (
<div
className={`bg-white border py-3 px-4 ${isCompleted && 'checklist-item-complete'}`}
id={`checklist-item-${checkId}`}
data-testid={`checklist-item-${checkId}`}
key={checkId}
>
<ChecklistItemBody {...{ checkId, isCompleted, updateLink }} />
<div data-testid={`comment-section-${checkId}`}>
<ChecklistItemComment {...{ checkId, outlineUrl, data }} />
</div>
</div>
);
})}
</Stack>
</>
)}
</Container>
);
};
ChecklistSection.defaultProps = {
updateLinks: {},
data: {},
};
ChecklistSection.propTypes = {
dataHeading: PropTypes.string.isRequired,
data: PropTypes.oneOfType([
PropTypes.shape({
assignments: PropTypes.shape({
totalNumber: PropTypes.number,
totalVisible: PropTypes.number,
numWithDatesBeforeEnd: PropTypes.number,
numWithDates: PropTypes.number,
numWithDatesAfterStart: PropTypes.number,
}),
dates: PropTypes.shape({
hasStartDate: PropTypes.bool,
hasEndDate: PropTypes.bool,
}),
updates: PropTypes.shape({
hasUpdate: PropTypes.bool,
}),
certificates: PropTypes.shape({
isEnabled: PropTypes.bool,
isActivated: PropTypes.bool,
hasCertificate: PropTypes.bool,
}),
grades: PropTypes.shape({
sumOfWeights: PropTypes.number,
}),
is_self_paced: PropTypes.bool,
}).isRequired,
PropTypes.shape({
assignments: PropTypes.shape({
totalNumber: PropTypes.number,
totalVisible: PropTypes.number,
/* eslint-disable react/forbid-prop-types */
assignmentsWithDatesBeforeStart: PropTypes.array,
assignmentsWithDatesAfterEnd: PropTypes.array,
assignmentsWithOraDatesBeforeStart: PropTypes.array,
assignmentsWithOraDatesAfterEnd: PropTypes.array,
/* eslint-enable react/forbid-prop-types */
}),
dates: PropTypes.shape({
hasStartDate: PropTypes.bool,
hasEndDate: PropTypes.bool,
}),
updates: PropTypes.shape({
hasUpdate: PropTypes.bool,
}),
certificates: PropTypes.shape({
isEnabled: PropTypes.bool,
isActivated: PropTypes.bool,
hasCertificate: PropTypes.bool,
}),
grades: PropTypes.shape({
hasGradingPolicy: PropTypes.bool,
sumOfWeights: PropTypes.number,
}),
proctoring: PropTypes.shape({
needsProctoringEscalationEmail: PropTypes.bool,
hasProctoringEscalation_email: PropTypes.bool,
}),
isSelfPaced: PropTypes.bool,
}).isRequired,
]),
idPrefix: PropTypes.string.isRequired,
isLoading: PropTypes.bool.isRequired,
updateLinks: PropTypes.shape({
welcomeMessage: PropTypes.string,
gradingPolicy: PropTypes.string,
certificate: PropTypes.string,
courseDates: PropTypes.string,
proctoringEmail: PropTypes.string,
outline: PropTypes.string,
}),
};
export default injectIntl(ChecklistSection);

View File

@@ -0,0 +1,22 @@
.assignment-list-item {
list-style: none;
display: inline-block;
&::after {
content: ",";
}
&:last-child {
&::after { content: ""; }
}
}
.assignment-list {
display: inline;
padding-inline-start: map-get($spacers, 1);
}
//complete checklist item style
.checklist-item-complete {
box-shadow: -5px 0 0 0 $success-500;
}

View File

@@ -0,0 +1,255 @@
/* eslint-disable */
import {
render,
within,
screen,
} from '@testing-library/react';
import '@testing-library/jest-dom';
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from '../../store';
import { initialState,generateCourseLaunchData } from '../factories/mockApiResponses';
import messages from './messages';
import ChecklistSection from './index';
import { checklistItems } from './utils/courseChecklistData';
import getUpdateLinks from '../utils';
const testData = camelCaseObject(generateCourseLaunchData());
const defaultProps = {
data: testData,
dataHeading: 'Test checklist',
idPrefix: 'launchChecklist',
updateLinks: getUpdateLinks('courseId'),
isLoading: false,
};
const testChecklistData = checklistItems[defaultProps.idPrefix];
const completedItemIds = ['welcomeMessage', 'courseDates']
const renderComponent = (props) => {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<ChecklistSection {...props} />
</AppProvider>
</IntlProvider>,
);
};
let store;
describe('ChecklistSection', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
});
it('a heading using the dataHeading prop', () => {
renderComponent(defaultProps);
expect(screen.getByText(defaultProps.dataHeading)).toBeVisible();
});
it('completion count text', () => {
renderComponent(defaultProps);
const completionText = `${completedItemIds.length}/6 completed`;
expect(screen.getByTestId('completion-subheader').textContent).toEqual(completionText);
});
it('a loading spinner when isLoading prop is true', () => {
renderComponent({ ...defaultProps, isLoading: true });
const completionSubheader = screen.queryByTestId('completion-subheader');
expect(completionSubheader).toBeNull();
const loadingSpinner = screen.getByTestId('loading-spinner');
expect(loadingSpinner).toBeVisible();
});
it('the correct number of checks', () => {
renderComponent(defaultProps);
const listItems = screen.getAllByTestId('checklist-item', { exact: false });
expect(listItems).toHaveLength(6);
});
it('welcomeMessage comment section should be null', () => {
renderComponent(defaultProps);
const comment = screen.getByTestId('comment-section-welcomeMessage');
expect(comment.children).toHaveLength(0);
});
it('certificate comment section should be null', () => {
renderComponent(defaultProps);
const comment = screen.getByTestId('comment-section-certificate');
expect(comment.children).toHaveLength(0);
});
it('courseDates comment section should be null', () => {
renderComponent(defaultProps);
const comment = screen.getByTestId('comment-section-courseDates');
expect(comment.children).toHaveLength(0);
});
it('proctoringEmail comment section should be null', () => {
renderComponent(defaultProps);
const comment = screen.getByTestId('comment-section-proctoringEmail');
expect(comment.children).toHaveLength(0);
});
describe('gradingPolicy comment section', () => {
it('should be null if sum of weights is equal to 1', () => {
const props = {
...defaultProps,
data: {
...defaultProps.data,
grades: {
...defaultProps.data.grades,
sumOfWeights: 1,
}
},
};
renderComponent(props);
const comment = screen.getByTestId('comment-section-gradingPolicy');
expect(comment.children).toHaveLength(0);
});
it('should have comment section', () => {
renderComponent(defaultProps);
const comment = screen.getByTestId('comment-section-gradingPolicy');
expect(comment.children).toHaveLength(1);
expect(screen.getByText(
'Your current grading policy adds up to',
{ exact: false },
)).toBeVisible();
});
});
describe('assignmentDeadlines comment section', () => {
it('should be null if assignments with dates before start and after end are empty', () => {
const props = {
...defaultProps,
data: {
...defaultProps.data,
assignments: {
...defaultProps.data.assignments,
assignmentsWithDatesAfterEnd: [],
assignmentsWithOraDatesBeforeStart: [],
}
},
};
renderComponent(props);
const comment = screen.getByTestId('comment-section-assignmentDeadlines');
expect(comment.children).toHaveLength(0);
});
it('should have comment section', () => {
renderComponent(defaultProps);
const comment = screen.getByTestId('comment-section-assignmentDeadlines');
const assigmentLinks = within(comment).getAllByRole('link');
expect(comment.children).toHaveLength(1);
expect(screen.getByText(
messages.assignmentDeadlinesComment.defaultMessage,
{ exact: false },
)).toBeVisible();
expect(assigmentLinks).toHaveLength(2);
expect(assigmentLinks[0].textContent).toEqual('Subsection');
expect(assigmentLinks[1].textContent).toEqual('ORA subsection');
});
});
});
testChecklistData.forEach((check) => {
describe(`check with id '${check.id}'`, () => {
let checkItem;
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
renderComponent(defaultProps);
checkItem = screen.getAllByTestId(`checklist-item-${check.id}`);
});
it('renders', () => {
expect(checkItem).toHaveLength(1);
});
it('has correct icon', () => {
const icon = screen.getAllByTestId(`icon-${check.id}`)
expect(icon).toHaveLength(1);
const { queryByTestId } = within(icon[0]);
if (completedItemIds.includes(check.id)) {
expect(queryByTestId('completed-icon')).not.toBeNull();
} else {
expect(queryByTestId('uncompleted-icon')).not.toBeNull();
}
});
it('has correct short description', () => {
const { getByText } = within(checkItem[0]);
const shortDescription = messages[`${check.id}ShortDescription`].defaultMessage;
expect(getByText(shortDescription)).toBeVisible();
});
it('has correct long description', () => {
const { getByText } = within(checkItem[0]);
const longDescription = messages[`${check.id}LongDescription`].defaultMessage;
expect(getByText(longDescription)).toBeVisible();
});
describe('has correct link', () => {
const links = getUpdateLinks('courseId')
const shouldShowLink = Object.keys(links).includes(check.id);
if (shouldShowLink) {
it('with a Hyperlink', () => {
const { getByRole, getByText } = within(checkItem[0]);
expect(getByText('Update')).toBeVisible();
expect(getByRole('link').href).toMatch(links[check.id]);
});
} else {
it('without a Hyperlink', () => {
const { queryByText } = within(checkItem[0]);
expect(queryByText('Update')).toBeNull();
});
}
});
});
});

View File

@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import messages from './messages';
import getFilteredChecklist from './utils/getFilteredChecklist';
import getValidatedValue from './utils/getValidatedValue';
export const useChecklistState = ({ data, dataList }) => {
const [checklistState, setChecklistState] = useState({
checks: [],
totalCompletedChecks: 0,
values: {},
});
const updateChecklistState = () => {
if (Object.keys(data).length > 0) {
const { isSelfPaced } = data;
const hasCertificatesEnabled = data.certificates && data.certificates.isEnabled;
const hasHighlightsEnabled = data.sections && data.sections.highlightsEnabled;
const needsProctoringEscalationEmail = (
data.proctoring && data.proctoring.needsProctoringEscalationEmail
);
const checks = getFilteredChecklist(
dataList,
isSelfPaced,
hasCertificatesEnabled,
hasHighlightsEnabled,
needsProctoringEscalationEmail,
);
const values = {};
let totalCompletedChecks = 0;
checks.forEach((check) => {
const value = getValidatedValue(data, check.id);
if (value) {
totalCompletedChecks += 1;
}
values[check.id] = value;
});
setChecklistState({
checks,
totalCompletedChecks,
values,
});
}
};
useEffect(() => {
updateChecklistState();
}, [data]);
return {
checklistState,
setChecklistState,
};
};
export const getCompletionCount = (checks, totalCompletedChecks) => {
const totalChecks = Object.values(checks).length;
return (
<FormattedMessage
{...messages.completionCountLabel}
values={{ completed: totalCompletedChecks, total: totalChecks }}
/>
);
};

View File

@@ -0,0 +1,3 @@
import ChecklistSection from './ChecklistSection';
export default ChecklistSection;

View File

@@ -0,0 +1,146 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
welcomeMessageShortDescription: {
id: 'welcomeMessageShortDescription',
defaultMessage: 'Add a welcome message',
description: 'Label for a section that describes a welcome message for a course',
},
welcomeMessageLongDescription: {
id: 'welcomeMessageLongDescription',
defaultMessage: 'Personally welcome learners into your course and prepare learners for a positive course experience.',
description: 'Description for a section that prompts a user to enter a welcome message for a course',
},
gradingPolicyShortDescription: {
id: 'gradingPolicyShortDescription',
defaultMessage: 'Create your course grading policy',
description: 'Label for a section that describes a grading policy for a course',
},
gradingPolicyLongDescription: {
id: 'gradingPolicyLongDescription',
defaultMessage: 'Establish your grading policy, including assignment types and passing score. All assignments add up to 100%.',
description: 'Description for a section that prompts a user to enter a grading policy for a course',
},
gradingPolicyComment: {
id: 'gradingPolicyComment',
defaultMessage: 'Your current grading policy adds up to {percent}%.',
description: 'Description for a section that displays a course\'s grading policy total',
},
certificateShortDescription: {
id: 'certificateShortDescription',
defaultMessage: 'Enable your certificate',
description: 'Label for a section that describes a certificate for completing a course',
},
certificateLongDescription: {
id: 'certificateLongDescription',
defaultMessage: 'Make sure that all text is correct, signatures have been uploaded, and the certificate has been activated.',
description: 'Description for a section that prompts a user to create a course completion certificate',
},
courseDatesShortDescription: {
id: 'courseDatesShortDescription',
defaultMessage: 'Set important course dates',
description: 'Label for a section that describes a certificate for completing a course',
},
courseDatesLongDescription: {
id: 'courseDatesLongDescription',
defaultMessage: 'Establish your course schedule, including when the course starts and ends.',
description: 'Description for a section that prompts a user to set up a course schedule',
},
assignmentDeadlinesShortDescription: {
id: 'assignmentDeadlinesShortDescription',
defaultMessage: 'Validate assignment deadlines',
description: 'Label for a section that describes course assignment deadlines',
},
assignmentDeadlinesLongDescription: {
id: 'assignmentDeadlinesLongDescription',
defaultMessage: 'Ensure all assignment deadlines are between course start and end dates.',
description: 'Description for a section that prompts a user to enter course assignment deadlines',
},
assignmentDeadlinesComment: {
id: 'assignmentDeadlinesComment',
defaultMessage: 'The following assignments have deadlines that do not fall between course start and end date:',
description: 'Description for a section that displays which assignments are outside of a course\'s start and end date',
},
videoDurationShortDescription: {
id: 'videoDurationShortDescription',
defaultMessage: 'Check video duration',
description: 'Label for a section that describes video durations',
},
videoDurationLongDescription: {
id: 'videoDurationLongDescription',
defaultMessage: 'Learners engage best with short videos followed by opportunities to practice. Ensure that 80% or more of course videos are less than 10 minutes long.',
description: 'Description for a section that prompts a user to follow best practices for video length',
},
mobileFriendlyVideoShortDescription: {
id: 'mobileFriendlyVideoShortDescription',
defaultMessage: 'Create mobile-friendly video',
description: 'Label for a section that describes mobile friendly videos',
},
mobileFriendlyVideoLongDescription: {
id: 'mobileFriendlyVideoLongDescription',
defaultMessage: 'Mobile-friendly videos can be viewed across all supported devices. Ensure that at least 90% of course videos are mobile friendly by uploading course videos to the edX video pipeline.',
description: 'Description for a section that prompts a user to follow best practices for mobile friendly videos',
},
diverseSequencesShortDescription: {
id: 'diverseSequencesShortDescription',
defaultMessage: 'Build diverse learning sequences',
description: 'Label for a section that describes diverse sequences of educational content',
},
diverseSequencesLongDescription: {
id: 'diverseSequencesLongDescription',
defaultMessage: 'Research shows that a diverse content experience drives learner engagement. We recommend that 80% or more of your learning sequences or subsections include multiple content types (such as video, discussion, or problem).',
description: 'Description for a section that prompts a user to follow best practices diverse sequences of educational content',
},
weeklyHighlightsShortDescription: {
id: 'weeklyHighlightsShortDescription',
defaultMessage: 'Set weekly highlights',
description: 'Label for a section that describes weekly highlights',
},
weeklyHighlightsLongDescription: {
id: 'weeklyHighlightsLongDescription',
defaultMessage: 'Enable and specify weekly highlights to keep learners engaged and on track in your course.',
description: 'Description for a section that prompts a user to follow best practices for course weekly highlights',
},
unitDepthShortDescription: {
id: 'unitDepthShortDescription',
defaultMessage: 'Manage unit depth',
description: 'Label for a section that describes course unit depth',
},
unitDepthLongDescription: {
id: 'unitDepthLongDescription',
defaultMessage: 'Breaking up course content into manageable pieces promotes learner engagement. We recommend units contain no more than three components.',
description: 'Description for a section that prompts a user to follow best practices for course unit depth',
},
proctoringEmailShortDescription: {
id: 'proctoringEmailShortDescription',
defaultMessage: 'Add a proctortrack escalation email',
description: 'Label for a section that describes proctoring escalation email',
},
proctoringEmailLongDescription: {
id: 'proctoringEmailLongDescription',
defaultMessage: 'Courses using Proctortrack require an escalation email. Ensure learners and Support can contact your course team regarding proctoring issues (e.g. appeals, exam resets, etc).',
description: 'Description for a section that prompts the user to add a Proctortrack escalation email for the course',
},
updateLinkLabel: {
id: 'updateLinkLabel',
defaultMessage: 'Update',
description: 'Label for a link that takes the user to a page where they can update settings',
},
completionCountLabel: {
id: 'completionCountLabel',
defaultMessage: '{completed}/{total} completed',
description: 'Label that describes how many tasks have been completed out of a total number of tasks',
},
completedItemLabel: {
id: 'completedItemLabel',
defaultMessage: 'completed',
description: 'Label that describes a completed task',
},
uncompletedItemLabel: {
id: 'uncompletedItemLabel',
defaultMessage: 'uncompleted',
description: 'Label that describes an uncompleted task',
},
});
export default messages;

View File

@@ -0,0 +1,56 @@
export const filters = {
ALL: 'ALL',
SELF_PACED: 'SELF_PACED',
INSTRUCTOR_PACED: 'INSTRUCTOR_PACED',
};
export const checklistItems = {
launchChecklist: [
{
id: 'welcomeMessage',
pacingTypeFilter: filters.ALL,
},
{
id: 'gradingPolicy',
pacingTypeFilter: filters.ALL,
},
{
id: 'certificate',
pacingTypeFilter: filters.ALL,
},
{
id: 'courseDates',
pacingTypeFilter: filters.ALL,
},
{
id: 'assignmentDeadlines',
pacingTypeFilter: filters.INSTRUCTOR_PACED,
},
{
id: 'proctoringEmail',
pacingTypeFilter: filters.ALL,
},
],
bestPracticesChecklist: [
{
id: 'videoDuration',
pacingTypeFilter: filters.ALL,
},
{
id: 'mobileFriendlyVideo',
pacingTypeFilter: filters.ALL,
},
{
id: 'diverseSequences',
pacingTypeFilter: filters.ALL,
},
{
id: 'weeklyHighlights',
pacingTypeFilter: filters.SELF_PACED,
},
{
id: 'unitDepth',
pacingTypeFilter: filters.ALL,
},
],
};

View File

@@ -0,0 +1,76 @@
export const hasWelcomeMessage = updates => (
updates.hasUpdate
);
export const hasGradingPolicy = grades => (
grades.hasGradingPolicy
&& parseFloat(grades.sumOfWeights.toPrecision(2), 10) === 1.0
);
export const hasCertificate = certificates => (
certificates.isActivated && certificates.hasCertificate
);
export const hasDates = dates => (
dates.hasStartDate && dates.hasEndDate
);
export const hasAssignmentDeadlines = (assignments, dates) => {
if (!hasDates(dates)) {
return false;
} if (assignments.totalNumber === 0) {
return false;
} if (assignments.assignmentsWithDatesBeforeStart.length > 0) {
return false;
} if (assignments.assignmentsWithDatesAfterEnd.length > 0) {
return false;
} if (assignments.assignmentsWithOraDatesBeforeStart.length > 0) {
return false;
} if (assignments.assignmentsWithOraDatesAfterEnd.length > 0) {
return false;
}
return true;
};
export const hasShortVideoDuration = (videos) => {
if (videos.totalNumber === 0) {
return true;
} if (videos.totalNumber > 0 && videos.durations.median <= 600) {
return true;
}
return false;
};
export const hasMobileFriendlyVideos = (videos) => {
if (videos.totalNumber === 0) {
return true;
} if (videos.totalNumber > 0 && (videos.numMobileEncoded / videos.totalNumber) >= 0.9) {
return true;
}
return false;
};
export const hasDiverseSequences = (subsections) => {
if (subsections.totalVisible === 0) {
return false;
} if (subsections.totalVisible > 0) {
return ((subsections.numWithOneBlockType / subsections.totalVisible) < 0.2);
}
return false;
};
export const hasWeeklyHighlights = sections => (
sections.highlightsActiveForCourse && sections.highlightsEnabled
);
export const hasShortUnitDepth = units => (
units.numBlocks.median <= 3
);
export const hasProctoringEscalationEmail = proctoring => (
proctoring.hasProctoringEscalationEmail
);

View File

@@ -0,0 +1,297 @@
import * as validators from './courseChecklistValidators';
describe('courseCheckValidators utility functions', () => {
describe('hasWelcomeMessage', () => {
it('returns true when course run has an update', () => {
expect(validators.hasWelcomeMessage({ hasUpdate: true })).toEqual(true);
});
it('returns false when course run does not have an update', () => {
expect(validators.hasWelcomeMessage({ hasUpdate: false })).toEqual(false);
});
});
describe('hasGradingPolicy', () => {
it('returns true when sum of weights is 1', () => {
expect(validators.hasGradingPolicy(
{ hasGradingPolicy: true, sumOfWeights: 1 },
)).toEqual(true);
});
it('returns true when sum of weights is not 1 due to floating point approximation (1.00004)', () => {
expect(validators.hasGradingPolicy(
{ hasGradingPolicy: true, sumOfWeights: 1.00004 },
)).toEqual(true);
});
it('returns false when sum of weights is not 1', () => {
expect(validators.hasGradingPolicy(
{ hasGradingPolicy: true, sumOfWeights: 2 },
)).toEqual(false);
});
it('returns true when hasGradingPolicy is true', () => {
expect(validators.hasGradingPolicy(
{ hasGradingPolicy: true, sumOfWeights: 1 },
)).toEqual(true);
});
it('returns false when hasGradingPolicy is false', () => {
expect(validators.hasGradingPolicy(
{ hasGradingPolicy: false, sumOfWeights: 1 },
)).toEqual(false);
});
});
describe('hasCertificate', () => {
it('returns true when certificates are activated and course run has a certificate', () => {
expect(validators.hasCertificate({ isActivated: true, hasCertificate: true }))
.toEqual(true);
});
it('returns false when certificates are not activated and course run has a certificate', () => {
expect(validators.hasCertificate({ isActivated: false, hasCertificate: true }))
.toEqual(false);
});
it('returns false when certificates are activated and course run does not have a certificate', () => {
expect(validators.hasCertificate({ isActivated: true, hasCertificate: false }))
.toEqual(false);
});
it('returns false when certificates are not activated and course run does not have a certificate', () => {
expect(validators.hasCertificate({ isActivated: false, hasCertificate: false }))
.toEqual(false);
});
});
describe('hasDates', () => {
it('returns true when course run has start date and end date', () => {
expect(validators.hasDates({ hasStartDate: true, hasEndDate: true })).toEqual(true);
});
it('returns false when course run has no start date and end date', () => {
expect(validators.hasDates({ hasStartDate: false, hasEndDate: true })).toEqual(false);
});
it('returns true when course run has start date and no end date', () => {
expect(validators.hasDates({ hasStartDate: true, hasEndDate: false })).toEqual(false);
});
it('returns true when course run has no start date and no end date', () => {
expect(validators.hasDates({ hasStartDate: false, hasEndDate: false })).toEqual(false);
});
});
describe('hasAssignmentDeadlines', () => {
it('returns true when a course run has start and end date and all assignments are within range', () => {
expect(validators.hasAssignmentDeadlines(
{
assignmentsWithDatesBeforeStart: 0,
assignmentsWithDatesAfterEnd: 0,
assignmentsWithOraDatesAfterEnd: 0,
assignmentsWithOraDatesBeforeStart: 0,
},
{
hasStartDate: true,
hasEndDate: true,
},
)).toEqual(true);
});
it('returns false when a course run has no start and no end date', () => {
expect(validators.hasAssignmentDeadlines(
{},
{
hasStartDate: false,
hasEndDate: false,
},
)).toEqual(false);
});
it('returns false when a course has start and end date and no assignments', () => {
expect(validators.hasAssignmentDeadlines(
{
totalNumber: 0,
},
{
hasStartDate: true,
hasEndDate: true,
},
)).toEqual(false);
});
it('returns false when a course run has start and end date and assignments before start', () => {
expect(validators.hasAssignmentDeadlines(
{
assignmentsWithDatesBeforeStart: ['test'],
assignmentsWithDatesAfterEnd: 0,
assignmentsWithOraDatesAfterEnd: 0,
assignmentsWithOraDatesBeforeStart: 0,
},
{
hasStartDate: true,
hasEndDate: true,
},
)).toEqual(false);
});
it('returns false when a course run has start and end date and assignments after end', () => {
expect(validators.hasAssignmentDeadlines(
{
assignmentsWithDatesBeforeStart: 0,
assignmentsWithDatesAfterEnd: ['test'],
assignmentsWithOraDatesAfterEnd: 0,
assignmentsWithOraDatesBeforeStart: 0,
},
{
hasStartDate: true,
hasEndDate: true,
},
)).toEqual(false);
});
});
it(
'returns false when a course run has start and end date and an ora with a date before start',
() => {
expect(validators.hasAssignmentDeadlines(
{
assignmentsWithDatesBeforeStart: 0,
assignmentsWithDatesAfterEnd: 0,
assignmentsWithOraDatesAfterEnd: 0,
assignmentsWithOraDatesBeforeStart: ['test'],
},
{
hasStartDate: true,
hasEndDate: true,
},
)).toEqual(false);
},
);
it(
'returns false when a course run has start and end date and an ora with a date after end',
() => {
expect(validators.hasAssignmentDeadlines(
{
assignmentsWithDatesBeforeStart: 0,
assignmentsWithDatesAfterEnd: 0,
assignmentsWithOraDatesAfterEnd: ['test'],
assignmentsWithOraDatesBeforeStart: 0,
},
{
hasStartDate: true,
hasEndDate: true,
},
)).toEqual(false);
},
);
describe('hasShortVideoDuration', () => {
it('returns true if course run has no videos', () => {
expect(validators.hasShortVideoDuration({ totalNumber: 0 })).toEqual(true);
});
it('returns true if course run videos have a median duration <= to 600', () => {
expect(validators.hasShortVideoDuration({ totalNumber: 1, durations: { median: 100 } }))
.toEqual(true);
});
it('returns true if course run videos have a median duration > to 600', () => {
expect(validators.hasShortVideoDuration({ totalNumber: 10, durations: { median: 700 } }))
.toEqual(false);
});
});
describe('hasMobileFriendlyVideos', () => {
it('returns true if course run has no videos', () => {
expect(validators.hasMobileFriendlyVideos({ totalNumber: 0 })).toEqual(true);
});
it('returns true if course run videos are >= 90% mobile friendly', () => {
expect(validators.hasMobileFriendlyVideos({ totalNumber: 10, numMobileEncoded: 9 }))
.toEqual(true);
});
it('returns true if course run videos are < 90% mobile friendly', () => {
expect(validators.hasMobileFriendlyVideos({ totalNumber: 10, numMobileEncoded: 8 }))
.toEqual(false);
});
});
describe('hasDiverseSequences', () => {
it('returns true if < 20% of visible subsections have more than one block type', () => {
expect(validators.hasDiverseSequences({ totalVisible: 10, numWithOneBlockType: 1 }))
.toEqual(true);
});
it('returns false if no visible subsections', () => {
expect(validators.hasDiverseSequences({ totalVisible: 0 })).toEqual(false);
});
it('returns false if >= 20% of visible subsections have more than one block type', () => {
expect(validators.hasDiverseSequences({ totalVisible: 10, numWithOneBlockType: 3 }))
.toEqual(false);
});
it('return false if < 0 visible subsections', () => {
expect(validators.hasDiverseSequences({ totalVisible: -1, numWithOneBlockType: 1 }))
.toEqual(false);
});
});
describe('hasWeeklyHighlights', () => {
it('returns true when course run has highlights enabled', () => {
const data = { highlightsActiveForCourse: true, highlightsEnabled: true };
expect(validators.hasWeeklyHighlights(data)).toEqual(true);
});
it('returns false when course run has highlights enabled', () => {
const data = { highlightsActiveForCourse: false, highlightsEnabled: false };
expect(validators.hasWeeklyHighlights(data)).toEqual(false);
data.highlightsEnabled = true;
data.highlightsActiveForCourse = false;
expect(validators.hasWeeklyHighlights(data)).toEqual(false);
data.highlightsEnabled = false;
data.highlightsActiveForCourse = true;
expect(validators.hasWeeklyHighlights(data)).toEqual(false);
});
});
describe('hasShortUnitDepth', () => {
it('returns true when course run has median number of blocks <= 3', () => {
const units = {
numBlocks: {
median: 3,
},
};
expect(validators.hasShortUnitDepth(units)).toEqual(true);
});
it('returns false when course run has median number of blocks > 3', () => {
const units = {
numBlocks: {
median: 4,
},
};
expect(validators.hasShortUnitDepth(units)).toEqual(false);
});
});
describe('hasProctoringEscalationEmail', () => {
it('returns true when the course has a proctoring escalation email', () => {
const proctoring = { hasProctoringEscalationEmail: true };
expect(validators.hasProctoringEscalationEmail(proctoring)).toEqual(true);
});
it('returns false when the course does not have a proctoring escalation email', () => {
const proctoring = { hasProctoringEscalationEmail: false };
expect(validators.hasProctoringEscalationEmail(proctoring)).toEqual(false);
});
});
});

View File

@@ -0,0 +1,32 @@
import { filters } from './courseChecklistData';
const getFilteredChecklist = (
checklist,
isSelfPaced,
hasCertificatesEnabled,
hasHighlightsEnabled,
needsProctoringEscalationEmail,
) => {
let filteredCheckList;
if (isSelfPaced) {
filteredCheckList = checklist.filter(data => data.pacingTypeFilter === filters.ALL
|| data.pacingTypeFilter === filters.SELF_PACED);
} else {
filteredCheckList = checklist.filter(data => data.pacingTypeFilter === filters.ALL
|| data.pacingTypeFilter === filters.INSTRUCTOR_PACED);
}
filteredCheckList = filteredCheckList.filter(data => data.id !== 'certificate'
|| hasCertificatesEnabled);
filteredCheckList = filteredCheckList.filter(data => data.id !== 'weeklyHighlights'
|| hasHighlightsEnabled);
filteredCheckList = filteredCheckList.filter(data => data.id !== 'proctoringEmail'
|| needsProctoringEscalationEmail);
return filteredCheckList;
};
export default getFilteredChecklist;

View File

@@ -0,0 +1,149 @@
import { filters } from './courseChecklistData';
import getFilteredChecklist from './getFilteredChecklist';
const checklist = [
{
id: 'welcomeMessage',
pacingTypeFilter: filters.ALL,
},
{
id: 'gradingPolicy',
pacingTypeFilter: filters.ALL,
},
{
id: 'certificate',
pacingTypeFilter: filters.ALL,
},
{
id: 'courseDates',
pacingTypeFilter: filters.ALL,
},
{
id: 'assignmentDeadlines',
pacingTypeFilter: filters.INSTRUCTOR_PACED,
},
{
id: 'weeklyHighlights',
pacingTypeFilter: filters.SELF_PACED,
},
{
id: 'proctoringEmail',
pacingTypeFilter: filters.ALL,
},
];
let courseData;
describe('getFilteredChecklist utility function', () => {
beforeEach(() => {
courseData = {
isSelfPaced: true,
hasCertificatesEnabled: true,
hasHighlightsEnabled: true,
needsProctoringEscalationEmail: true,
};
});
it('returns only checklist items with filters ALL and SELF_PACED when isSelfPaced is true', () => {
const filteredChecklist = getFilteredChecklist(
checklist,
courseData.isSelfPaced,
courseData.hasCertificatesEnabled,
courseData.hasHighlightsEnabled,
courseData.needsProctoringEscalationEmail,
);
filteredChecklist.forEach(((
item => expect(item.pacingTypeFilter === filters.ALL
|| item.pacingTypeFilter === filters.SELF_PACED)
)));
expect(filteredChecklist.filter(item => item.pacingTypeFilter === filters.ALL).length)
.toEqual(checklist.filter(item => item.pacingTypeFilter === filters.ALL).length);
expect(filteredChecklist.filter(item => item.pacingTypeFilter === filters.SELF_PACED).length)
.toEqual(checklist.filter(item => item.pacingTypeFilter === filters.SELF_PACED).length);
});
it('returns only checklist items with filters ALL and INSTRUCTOR_PACED when isSelfPaced is false', () => {
courseData.isSelfPaced = false;
const filteredChecklist = getFilteredChecklist(
checklist,
courseData.isSelfPaced,
courseData.hasCertificatesEnabled,
courseData.hasHighlightsEnabled,
courseData.needsProctoringEscalationEmail,
);
filteredChecklist.forEach(((
item => expect(item.pacingTypeFilter === filters.ALL
|| item.pacingTypeFilter === filters.INSTRUCTOR_PACED)
)));
expect(filteredChecklist.filter(item => item.pacingTypeFilter === filters.ALL).length)
.toEqual(checklist.filter(item => item.pacingTypeFilter === filters.ALL).length);
expect(filteredChecklist
.filter(item => item.pacingTypeFilter === filters.INSTRUCTOR_PACED).length)
.toEqual(checklist.filter(item => item.pacingTypeFilter === filters.INSTRUCTOR_PACED).length);
});
it('excludes certificates when they are disabled', () => {
let filteredChecklist = getFilteredChecklist(
checklist,
courseData.isSelfPaced,
courseData.hasCertificatesEnabled,
courseData.hasHighlightsEnabled,
courseData.needsProctoringEscalationEmail,
);
expect(checklist.filter(item => item.id === 'certificate').length).toEqual(1);
courseData.hasCertificatesEnabled = false;
filteredChecklist = getFilteredChecklist(
checklist,
courseData.isSelfPaced,
courseData.hasCertificatesEnabled,
courseData.hasHighlightsEnabled,
courseData.needsProctoringEscalationEmail,
);
expect(filteredChecklist.filter(item => item.id === 'certificate').length).toEqual(0);
});
it('excludes weekly highlights when they are disabled', () => {
let filteredChecklist = getFilteredChecklist(
checklist,
courseData.isSelfPaced,
courseData.hasCertificatesEnabled,
courseData.hasHighlightsEnabled,
courseData.needsProctoringEscalationEmail,
);
expect(filteredChecklist.filter(item => item.id === 'weeklyHighlights').length).toEqual(1);
courseData.hasHighlightsEnabled = false;
filteredChecklist = getFilteredChecklist(
checklist,
courseData.isSelfPaced,
courseData.hasCertificatesEnabled,
courseData.hasHighlightsEnabled,
courseData.needsProctoringEscalationEmail,
);
expect(filteredChecklist.filter(item => item.id === 'weeklyHighlights').length).toEqual(0);
});
it('excludes proctoring escalation email when not needed', () => {
let filteredChecklist = getFilteredChecklist(
checklist,
courseData.isSelfPaced,
courseData.hasCertificatesEnabled,
courseData.hasHighlightsEnabled,
courseData.needsProctoringEscalationEmail,
);
expect(filteredChecklist.filter(item => item.id === 'proctoringEmail').length).toEqual(1);
courseData.needsProctoringEscalationEmail = false;
filteredChecklist = getFilteredChecklist(
checklist,
courseData.isSelfPaced,
courseData.hasCertificatesEnabled,
courseData.hasHighlightsEnabled,
courseData.needsProctoringEscalationEmail,
);
expect(filteredChecklist.filter(item => item.id === 'proctoringEmail').length).toEqual(0);
});
});

View File

@@ -0,0 +1,32 @@
import * as healthValidators from './courseChecklistValidators';
const getValidatedValue = (data, id) => {
switch (id) {
case 'welcomeMessage':
return healthValidators.hasWelcomeMessage(data.updates);
case 'gradingPolicy':
return healthValidators.hasGradingPolicy(data.grades);
case 'certificate':
return healthValidators.hasCertificate(data.certificates);
case 'courseDates':
return healthValidators.hasDates(data.dates);
case 'assignmentDeadlines':
return healthValidators.hasAssignmentDeadlines(data.assignments, data.dates);
case 'videoDuration':
return healthValidators.hasShortVideoDuration(data.videos);
case 'mobileFriendlyVideo':
return healthValidators.hasMobileFriendlyVideos(data.videos);
case 'diverseSequences':
return healthValidators.hasDiverseSequences(data.subsections);
case 'weeklyHighlights':
return healthValidators.hasWeeklyHighlights(data.sections);
case 'unitDepth':
return healthValidators.hasShortUnitDepth(data.units);
case 'proctoringEmail':
return healthValidators.hasProctoringEscalationEmail(data.proctoring);
default:
throw new Error(`Unknown validator ${id}.`);
}
};
export default getValidatedValue;

View File

@@ -0,0 +1,166 @@
import * as validators from './courseChecklistValidators';
import getValidatedValue from './getValidatedValue';
describe('getValidatedValue utility function', () => {
const localValidators = validators;
it('welcome message', () => {
const spy = jest.fn();
localValidators.hasWelcomeMessage = spy;
const props = {
data: {
updates: {},
},
};
getValidatedValue(props, 'welcomeMessage');
expect(spy).toHaveBeenCalledTimes(1);
});
it('grading policy', () => {
const spy = jest.fn();
localValidators.hasGradingPolicy = spy;
const props = {
data: {
grades: {},
},
};
getValidatedValue(props, 'gradingPolicy');
expect(spy).toHaveBeenCalledTimes(1);
});
it('certificate', () => {
const spy = jest.fn();
localValidators.hasCertificate = spy;
const props = {
data: {
certificates: {},
},
};
getValidatedValue(props, 'certificate');
expect(spy).toHaveBeenCalledTimes(1);
});
it('course dates', () => {
const spy = jest.fn();
localValidators.hasDates = spy;
const props = {
data: {
dates: {},
},
};
getValidatedValue(props, 'courseDates');
expect(spy).toHaveBeenCalledTimes(1);
});
it('assignment deadlines', () => {
const spy = jest.fn();
localValidators.hasAssignmentDeadlines = spy;
const props = {
data: {
assignments: {},
dates: {},
},
};
getValidatedValue(props, 'assignmentDeadlines');
expect(spy).toHaveBeenCalledTimes(1);
});
it('video duration', () => {
const spy = jest.fn();
localValidators.hasShortVideoDuration = spy;
const props = {
data: {
videos: {},
},
};
getValidatedValue(props, 'videoDuration');
expect(spy).toHaveBeenCalledTimes(1);
});
it('mobile friendly video', () => {
const spy = jest.fn();
localValidators.hasMobileFriendlyVideos = spy;
const props = {
data: {
videos: {},
},
};
getValidatedValue(props, 'mobileFriendlyVideo');
expect(spy).toHaveBeenCalledTimes(1);
});
it('diverse sequences', () => {
const spy = jest.fn();
localValidators.hasDiverseSequences = spy;
const props = {
data: {
subsections: {},
},
};
getValidatedValue(props, 'diverseSequences');
expect(spy).toHaveBeenCalledTimes(1);
});
it('weekly highlights', () => {
const spy = jest.fn();
localValidators.hasWeeklyHighlights = spy;
const props = {
data: {
sections: {},
},
};
getValidatedValue(props, 'weeklyHighlights');
expect(spy).toHaveBeenCalledTimes(1);
});
it('unit depth', () => {
const spy = jest.fn();
localValidators.hasShortUnitDepth = spy;
const props = {
data: {
units: {},
},
};
getValidatedValue(props, 'unitDepth');
expect(spy).toHaveBeenCalledTimes(1);
});
it('proctoring email', () => {
const spy = jest.fn();
localValidators.hasProctoringEscalationEmail = spy;
const props = {
data: {
proctoring: {},
},
};
getValidatedValue(props, 'proctoringEmail');
expect(spy).toHaveBeenCalledTimes(1);
});
it('other', () => {
const sampleID = 'edX';
expect(() => getValidatedValue({}, sampleID)).toThrow(Error);
expect(() => getValidatedValue({}, sampleID)).toThrow(`Unknown validator ${sampleID}`);
});
});

View File

@@ -0,0 +1,96 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { useDispatch, useSelector } from 'react-redux';
import { Container, Stack } from '@openedx/paragon';
import { useModel } from '../generic/model-store';
import SubHeader from '../generic/sub-header/SubHeader';
import messages from './messages';
import AriaLiveRegion from './AriaLiveRegion';
import { RequestStatus } from '../data/constants';
import ChecklistSection from './ChecklistSection';
import { fetchCourseLaunchQuery, fetchCourseBestPracticesQuery } from './data/thunks';
import getUpdateLinks from './utils';
const CourseChecklist = ({
courseId,
// injected,
intl,
}) => {
const dispatch = useDispatch();
const courseDetails = useModel('courseDetails', courseId);
const enableQuality = getConfig().ENABLE_CHECKLIST_QUALITY === 'true';
const updateLinks = getUpdateLinks(courseId);
useEffect(() => {
dispatch(fetchCourseLaunchQuery({ courseId }));
dispatch(fetchCourseBestPracticesQuery({ courseId }));
}, [courseId]);
const {
loadingStatus,
launchData,
bestPracticeData,
} = useSelector(state => state.courseChecklist);
const { bestPracticeChecklistLoadingStatus, launchChecklistLoadingStatus } = loadingStatus;
const isCourseLaunchChecklistLoading = bestPracticeChecklistLoadingStatus === RequestStatus.IN_PROGRESS;
const isCourseBestPracticeChecklistLoading = launchChecklistLoadingStatus === RequestStatus.IN_PROGRESS;
return (
<>
<Helmet>
<title>
{intl.formatMessage(messages.pageTitle, {
headingTitle: intl.formatMessage(messages.headingTitle),
courseName: courseDetails?.name,
siteName: process.env.SITE_NAME,
})}
</title>
</Helmet>
<Container size="xl" className="p-4 pt-4.5">
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
/>
<AriaLiveRegion
{...{
isCourseLaunchChecklistLoading,
isCourseBestPracticeChecklistLoading,
enableQuality,
}}
/>
<Stack gap={4}>
<ChecklistSection
dataHeading={intl.formatMessage(messages.launchChecklistLabel)}
data={launchData}
idPrefix="launchChecklist"
isLoading={isCourseLaunchChecklistLoading}
updateLinks={updateLinks}
/>
{enableQuality && (
<ChecklistSection
dataHeading={intl.formatMessage(messages.bestPracticesChecklistLabel)}
data={bestPracticeData}
idPrefix="bestPracticesChecklist"
isLoading={isCourseBestPracticeChecklistLoading}
updateLinks={updateLinks}
/>
)}
</Stack>
</Container>
</>
);
};
CourseChecklist.propTypes = {
courseId: PropTypes.string.isRequired,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(CourseChecklist);

View File

@@ -0,0 +1 @@
@import "./ChecklistSection/ChecklistSection";

View File

@@ -0,0 +1,153 @@
import {
render,
waitFor,
screen,
} from '@testing-library/react';
import '@testing-library/jest-dom';
import { getConfig, setConfig, initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from '../store';
import { RequestStatus } from '../data/constants';
import { executeThunk } from '../utils';
import { getCourseLaunchApiUrl, getCourseBestPracticesApiUrl } from './data/api';
import { fetchCourseLaunchQuery, fetchCourseBestPracticesQuery } from './data/thunks';
import {
courseId,
initialState,
generateCourseLaunchData,
generateCourseBestPracticesData,
} from './factories/mockApiResponses';
import messages from './messages';
import CourseChecklist from './index';
let axiosMock;
let store;
const renderComponent = () => {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<CourseChecklist {...{ courseId }} />
</AppProvider>
</IntlProvider>,
);
};
const mockStore = async (status) => {
axiosMock.onGet(getCourseLaunchApiUrl(courseId)).reply(status, generateCourseLaunchData());
axiosMock.onGet(getCourseBestPracticesApiUrl(courseId)).reply(status, generateCourseBestPracticesData());
await executeThunk(fetchCourseLaunchQuery(courseId), store.dispatch);
await executeThunk(fetchCourseBestPracticesQuery(courseId), store.dispatch);
};
describe('CourseChecklistPage', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
describe('renders', () => {
describe('if enable_quality prop is true', () => {
it('two checklist components ', () => {
renderComponent();
mockStore(200);
expect(screen.getByText(messages.launchChecklistLabel.defaultMessage)).toBeVisible();
expect(screen.getByText(messages.bestPracticesChecklistLabel.defaultMessage)).toBeVisible();
});
describe('an aria-live region with', () => {
it('an aria-live region', () => {
renderComponent();
const ariaLiveRegion = screen.getByRole('status');
expect(ariaLiveRegion).toBeDefined();
expect(ariaLiveRegion.classList.contains('sr-only')).toBe(true);
});
it('correct content when the launch checklist has loaded', async () => {
renderComponent();
mockStore(404);
await waitFor(() => {
const { launchChecklistStatus } = store.getState().courseChecklist.loadingStatus;
expect(launchChecklistStatus).not.toEqual(RequestStatus.SUCCESSFUL);
expect(screen.getByText(messages.launchChecklistDoneLoadingLabel.defaultMessage)).toBeInTheDocument();
});
});
it('correct content when the best practices checklist is loading', async () => {
renderComponent();
mockStore(404);
await waitFor(() => {
const { bestPracticeChecklistStatus } = store.getState().courseChecklist.loadingStatus;
expect(bestPracticeChecklistStatus).not.toEqual(RequestStatus.IN_PROGRESS);
expect(
screen.getByText(messages.bestPracticesChecklistDoneLoadingLabel.defaultMessage),
).toBeInTheDocument();
});
});
});
});
describe('if enable_quality prop is false', () => {
beforeEach(() => {
setConfig({
...getConfig(),
ENABLE_CHECKLIST_QUALITY: 'false',
});
});
it('one checklist components ', () => {
renderComponent();
mockStore(200);
expect(screen.getByText(messages.launchChecklistLabel.defaultMessage)).toBeVisible();
expect(screen.queryByText(messages.bestPracticesChecklistLabel.defaultMessage)).toBeNull();
});
describe('an aria-live region with', () => {
it('correct content when the launch checklist has loaded', async () => {
renderComponent();
mockStore(404);
await waitFor(() => {
const { launchChecklistStatus } = store.getState().courseChecklist.loadingStatus;
expect(launchChecklistStatus).not.toEqual(RequestStatus.SUCCESSFUL);
expect(screen.getByText(messages.launchChecklistDoneLoadingLabel.defaultMessage)).toBeInTheDocument();
});
});
it('correct content when the best practices checklist is loading', async () => {
renderComponent();
mockStore(404);
await waitFor(() => {
const { bestPracticeChecklistStatus } = store.getState().courseChecklist.loadingStatus;
expect(bestPracticeChecklistStatus).not.toEqual(RequestStatus.IN_PROGRESS);
expect(screen.queryByText(messages.bestPracticesChecklistDoneLoadingLabel.defaultMessage)).toBeNull();
});
});
});
});
});
});

View File

@@ -0,0 +1,64 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCourseBestPracticesApiUrl = ({
courseId,
excludeGraded,
all,
}) => `${getApiBaseUrl()}/api/courses/v1/quality/${courseId}/?exclude_graded=${excludeGraded}&all=${all}`;
export const getCourseLaunchApiUrl = ({
courseId,
gradedOnly,
validateOras,
all,
}) => `${getApiBaseUrl()}/api/courses/v1/validation/${courseId}/?graded_only=${gradedOnly}&validate_oras=${validateOras}&all=${all}`;
/**
* Get course best practices.
* @param {{courseId: string, excludeGraded: boolean, all: boolean}} options
* @returns {Promise<{isSelfPaced: boolean, sections: any, subsection: any, units: any, videos: any }>}
*/
export async function getCourseBestPractices({
courseId,
excludeGraded,
all,
}) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseBestPracticesApiUrl({ courseId, excludeGraded, all }));
return camelCaseObject(data);
}
/** @typedef {object} courseLaunchData
* @property {boolean} isSelfPaced
* @property {object} dates
* @property {object} assignments
* @property {object} grades
* @property {number} grades.sum_of_weights
* @property {object} certificates
* @property {object} updates
* @property {object} proctoring
*/
/**
* Get course launch.
* @param {{courseId: string, gradedOnly: boolean, validateOras: boolean, all: boolean}} options
* @returns {Promise<courseLaunchData>}
*/
export async function getCourseLaunch({
courseId,
gradedOnly,
validateOras,
all,
}) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseLaunchApiUrl({
courseId, gradedOnly, validateOras, all,
}));
return camelCaseObject(data);
}

View File

@@ -0,0 +1,41 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { RequestStatus } from '../../data/constants';
const slice = createSlice({
name: 'courseChecklist',
initialState: {
loadingStatus: {
launchChecklistStatus: RequestStatus.IN_PROGRESS,
bestPracticeChecklistStatus: RequestStatus.IN_PROGRESS,
},
launchData: {},
bestPracticeData: {},
},
reducers: {
fetchLaunchChecklistSuccess: (state, { payload }) => {
state.launchData = payload.data;
},
updateLaunchChecklistStatus: (state, { payload }) => {
state.loadingStatus.launchChecklistStatus = payload.status;
},
fetchBestPracticeChecklistSuccess: (state, { payload }) => {
state.bestPracticeData = payload.data;
},
updateBestPracticeChecklisttStatus: (state, { payload }) => {
state.loadingStatus.bestPracticeChecklistStatus = payload.status;
},
},
});
export const {
fetchLaunchChecklistSuccess,
updateLaunchChecklistStatus,
fetchBestPracticeChecklistSuccess,
updateBestPracticeChecklisttStatus,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -0,0 +1,46 @@
import { RequestStatus } from '../../data/constants';
import {
getCourseBestPractices,
getCourseLaunch,
} from './api';
import {
fetchLaunchChecklistSuccess,
updateLaunchChecklistStatus,
fetchBestPracticeChecklistSuccess,
updateBestPracticeChecklisttStatus,
} from './slice';
export function fetchCourseLaunchQuery({
courseId,
gradedOnly = true,
validateOras = true,
all = true,
}) {
return async (dispatch) => {
try {
const data = await getCourseLaunch({
courseId, gradedOnly, validateOras, all,
});
dispatch(fetchLaunchChecklistSuccess({ data }));
dispatch(updateLaunchChecklistStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateLaunchChecklistStatus({ status: RequestStatus.FAILED }));
}
};
}
export function fetchCourseBestPracticesQuery({
courseId,
excludeGraded = true,
all = true,
}) {
return async (dispatch) => {
try {
const data = await getCourseBestPractices({ courseId, excludeGraded, all });
dispatch(fetchBestPracticeChecklistSuccess({ data }));
dispatch(updateBestPracticeChecklisttStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
dispatch(updateBestPracticeChecklisttStatus({ status: RequestStatus.FAILED }));
}
};
}

View File

@@ -0,0 +1,104 @@
import { RequestStatus } from '../../data/constants';
export const courseId = 'course-v1:edX+DemoX+Demo_Course';
export const initialState = {
courseDetail: {
courseId,
status: 'sucessful',
},
courseChecklist: {
loadingStatus: {
launchChecklistStatus: RequestStatus.IN_PROGRESS,
bestPracticeChecklistStatus: RequestStatus.IN_PROGRESS,
},
launchData: {},
bestPracticesData: {},
},
};
export const generateCourseBestPracticesData = () => ({
is_self_paced: false,
sections: {
total_number: 2,
total_visible: 2,
number_with_highlights: 0,
highlights_active_for_course: false,
highlights_enabled: true,
},
subsections: {
total_visible: 1,
num_with_one_block_type: 1,
num_block_types: {
min: 1,
max: 1,
mean: 1.0,
median: 1.0,
mode: 1,
},
},
units: {
total_visible: 1,
num_blocks: {
min: 1,
max: 1,
mean: 1.0,
median: 1.0,
mode: 1,
},
},
videos: {
total_number: 10,
num_mobile_encoded: 5,
num_with_val_id: 10,
durations: {
min: 9.409,
max: 168.001,
mean: 41.0,
median: 9.0,
mode: 9.409,
},
},
});
export const generateCourseLaunchData = () => ({
is_self_paced: false,
dates: {
has_start_date: true,
has_end_date: true,
},
assignments: {
total_number: 2,
total_visible: 2,
assignments_with_dates_before_start: [],
assignments_with_dates_after_end: [
{
id: 'block-v1',
display_name: 'Subsection',
},
],
assignments_with_ora_dates_before_start: [
{
id: 'block-v2',
display_name: 'ORA subsection',
},
],
assignments_with_ora_dates_after_end: [],
},
grades: {
has_grading_policy: true,
sum_of_weights: 0.9500000000000001,
},
certificates: {
is_activated: false,
has_certificate: false,
is_enabled: true,
},
updates: {
has_update: true,
},
proctoring: {
needs_proctoring_escalation_email: true,
has_proctoring_escalation_email: false,
},
});

View File

@@ -0,0 +1,3 @@
import CourseChecklist from './CourseChecklist';
export default CourseChecklist;

View File

@@ -0,0 +1,49 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
pageTitle: {
id: 'course-authoring.export.page.title',
defaultMessage: '{headingTitle} | {courseName} | {siteName}',
},
headingTitle: {
id: 'course-authoring.course-checklist.heading.title',
defaultMessage: 'Checklists',
description: 'Header text for the Checklist page',
},
headingSubtitle: {
id: 'course-authoring.course-checklist.heading.subtitle',
defaultMessage: 'Tools',
},
launchChecklistLabel: {
id: 'launchChecklistLabel',
defaultMessage: 'Launch checklist',
description: 'Header text for a checklist that describes actions to have completed before a course should launch',
},
bestPracticesChecklistLabel: {
id: 'bestPracticesChecklistLabel',
defaultMessage: 'Best practices checklist',
description: 'Header text for a checklist that describes best practices for a course',
},
launchChecklistLoadingLabel: {
id: 'doneLoadingChecklistStatusLabel',
defaultMessage: 'Launch Checklist data is loading',
description: 'Label telling the user that the Launch Checklist is loading',
},
launchChecklistDoneLoadingLabel: {
id: 'launchChecklistDoneLoadingLabel',
defaultMessage: 'Launch Checklist data is done loading',
description: 'Label telling the user that the Launch Checklist is done loading',
},
bestPracticesChecklistLoadingLabel: {
id: 'bestPracticesChecklistLoadingLabel',
defaultMessage: 'Best Practices Checklist data is loading',
description: 'Label telling the user that the Best Practices Checklist is loading',
},
bestPracticesChecklistDoneLoadingLabel: {
id: 'bestPracticesChecklistDoneLoadingLabel',
defaultMessage: 'Best Practices Checklist data is done loading',
description: 'Label telling the user that the Best Practices Checklist is done loading',
},
});
export default messages;

View File

@@ -0,0 +1,12 @@
import { getConfig } from '@edx/frontend-platform';
const getUpdateLinks = (courseId) => ({
welcomeMessage: `${getConfig().STUDIO_BASE_URL}/course_info/${courseId}`,
gradingPolicy: `${getConfig().STUDIO_BASE_URL}/settings/grading/${courseId}`,
certificate: `${getConfig().STUDIO_BASE_URL}/certificates/${courseId}`,
courseDates: `${getConfig().STUDIO_BASE_URL}/settings/details/${courseId}#schedule`,
proctoringEmail: 'pages-and-resources/proctoring/settings',
outline: `${getConfig().STUDIO_BASE_URL}/course/${courseId}`,
});
export default getUpdateLinks;

View File

@@ -123,6 +123,7 @@ initialize({
ENABLE_UNIT_PAGE: process.env.ENABLE_UNIT_PAGE || 'false',
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: process.env.ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN || 'false',
ENABLE_TAGGING_TAXONOMY_PAGES: process.env.ENABLE_TAGGING_TAXONOMY_PAGES || 'false',
ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true',
}, 'CourseAuthoringConfig');
},
},

View File

@@ -22,3 +22,4 @@
@import "content-tags-drawer/TagBubble";
@import "course-outline/CourseOutline";
@import "course-unit/CourseUnit";
@import "course-checklist/CourseChecklist";

View File

@@ -46,6 +46,8 @@ mergeConfig({
CALCULATOR_HELP_URL: process.env.CALCULATOR_HELP_URL || null,
ENABLE_PROGRESS_GRAPH_SETTINGS: process.env.ENABLE_PROGRESS_GRAPH_SETTINGS || 'false',
ENABLE_TEAM_TYPE_SETTING: process.env.ENABLE_TEAM_TYPE_SETTING === 'true',
ENABLE_CHECKLIST_QUALITY: process.env.ENABLE_CHECKLIST_QUALITY || 'true',
STUDIO_BASE_URL: process.env.STUDIO_BASE_URL || null,
}, 'CourseAuthoringConfig');
class ResizeObserver {

View File

@@ -24,6 +24,7 @@ import { reducer as courseImportReducer } from './import-page/data/slice';
import { reducer as videosReducer } from './files-and-videos/videos-page/data/slice';
import { reducer as courseOutlineReducer } from './course-outline/data/slice';
import { reducer as courseUnitReducer } from './course-unit/data/slice';
import { reducer as courseChecklistReducer } from './course-checklist/data/slice';
import { reducer as accessibilityPageReducer } from './accessibility-page/data/slice';
export default function initializeStore(preloadedState = undefined) {
@@ -50,6 +51,7 @@ export default function initializeStore(preloadedState = undefined) {
videos: videosReducer,
courseOutline: courseOutlineReducer,
courseUnit: courseUnitReducer,
courseChecklist: courseChecklistReducer,
accessibilityPage: accessibilityPageReducer,
},
preloadedState,