From 8100281fb435e1941622822b764fd4aaa0f6ba6a Mon Sep 17 00:00:00 2001 From: Kristin Aoki <42981026+KristinAoki@users.noreply.github.com> Date: Thu, 7 Mar 2024 15:20:33 -0500 Subject: [PATCH] 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 --- .env | 1 + .env.development | 1 + .env.test | 1 + course-ingestion-tool | 1 + src/CourseAuthoringRoutes.jsx | 5 + src/course-checklist/AriaLiveRegion.jsx | 36 +++ .../ChecklistSection/ChecklistItemBody.jsx | 70 +++++ .../ChecklistSection/ChecklistItemComment.jsx | 123 ++++++++ .../ChecklistSection/ChecklistSection.jsx | 142 +++++++++ .../ChecklistSection/ChecklistSection.scss | 22 ++ .../ChecklistSection.test.jsx | 255 +++++++++++++++ .../ChecklistSection/hooks.jsx | 71 +++++ .../ChecklistSection/index.js | 3 + .../ChecklistSection/messages.js | 146 +++++++++ .../utils/courseChecklistData.jsx | 56 ++++ .../utils/courseChecklistValidators.js | 76 +++++ .../utils/courseChecklistValidators.test.js | 297 ++++++++++++++++++ .../utils/getFilteredChecklist.js | 32 ++ .../utils/getFilteredChecklist.test.js | 149 +++++++++ .../utils/getValidatedValue.js | 32 ++ .../utils/getValidatedValue.test.js | 166 ++++++++++ src/course-checklist/CourseChecklist.jsx | 96 ++++++ src/course-checklist/CourseChecklist.scss | 1 + src/course-checklist/CourseChecklist.test.jsx | 153 +++++++++ src/course-checklist/data/api.js | 64 ++++ src/course-checklist/data/slice.js | 41 +++ src/course-checklist/data/thunks.js | 46 +++ .../factories/mockApiResponses.jsx | 104 ++++++ src/course-checklist/index.js | 3 + src/course-checklist/messages.js | 49 +++ src/course-checklist/utils.js | 12 + src/index.jsx | 1 + src/index.scss | 1 + src/setupTest.js | 2 + src/store.js | 2 + 35 files changed, 2260 insertions(+) create mode 160000 course-ingestion-tool create mode 100644 src/course-checklist/AriaLiveRegion.jsx create mode 100644 src/course-checklist/ChecklistSection/ChecklistItemBody.jsx create mode 100644 src/course-checklist/ChecklistSection/ChecklistItemComment.jsx create mode 100644 src/course-checklist/ChecklistSection/ChecklistSection.jsx create mode 100644 src/course-checklist/ChecklistSection/ChecklistSection.scss create mode 100644 src/course-checklist/ChecklistSection/ChecklistSection.test.jsx create mode 100644 src/course-checklist/ChecklistSection/hooks.jsx create mode 100644 src/course-checklist/ChecklistSection/index.js create mode 100644 src/course-checklist/ChecklistSection/messages.js create mode 100644 src/course-checklist/ChecklistSection/utils/courseChecklistData.jsx create mode 100644 src/course-checklist/ChecklistSection/utils/courseChecklistValidators.js create mode 100644 src/course-checklist/ChecklistSection/utils/courseChecklistValidators.test.js create mode 100644 src/course-checklist/ChecklistSection/utils/getFilteredChecklist.js create mode 100644 src/course-checklist/ChecklistSection/utils/getFilteredChecklist.test.js create mode 100644 src/course-checklist/ChecklistSection/utils/getValidatedValue.js create mode 100644 src/course-checklist/ChecklistSection/utils/getValidatedValue.test.js create mode 100644 src/course-checklist/CourseChecklist.jsx create mode 100644 src/course-checklist/CourseChecklist.scss create mode 100644 src/course-checklist/CourseChecklist.test.jsx create mode 100644 src/course-checklist/data/api.js create mode 100644 src/course-checklist/data/slice.js create mode 100644 src/course-checklist/data/thunks.js create mode 100644 src/course-checklist/factories/mockApiResponses.jsx create mode 100644 src/course-checklist/index.js create mode 100644 src/course-checklist/messages.js create mode 100644 src/course-checklist/utils.js diff --git a/.env b/.env index d0bb7ec0d..84914b08f 100644 --- a/.env +++ b/.env @@ -40,3 +40,4 @@ HOTJAR_VERSION=6 HOTJAR_DEBUG=false INVITE_STUDENTS_EMAIL_TO='' AI_TRANSLATIONS_BASE_URL='' +ENABLE_CHECKLIST_QUALITY='' diff --git a/.env.development b/.env.development index 045c52f2d..ed64eb4c6 100644 --- a/.env.development +++ b/.env.development @@ -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 diff --git a/.env.test b/.env.test index 67ad2994b..c7ebc1440 100644 --- a/.env.test +++ b/.env.test @@ -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 diff --git a/course-ingestion-tool b/course-ingestion-tool new file mode 160000 index 000000000..f8ec84aa4 --- /dev/null +++ b/course-ingestion-tool @@ -0,0 +1 @@ +Subproject commit f8ec84aa4aa45f5193140fe8bbbccded96586858 diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 0f1a470eb..910269974 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -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={} /> + } + /> ); diff --git a/src/course-checklist/AriaLiveRegion.jsx b/src/course-checklist/AriaLiveRegion.jsx new file mode 100644 index 000000000..06685be3d --- /dev/null +++ b/src/course-checklist/AriaLiveRegion.jsx @@ -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 + ? + : ; + + const courseBestPracticesLoadingMessage = isCourseBestPracticeChecklistLoading + ? + : ; + + return ( +
+
+ {courseLaunchLoadingMessage} +
+ {enableQuality ?
{courseBestPracticesLoadingMessage}
: null} +
+ ); +}; + +AriaLiveRegion.propTypes = { + isCourseLaunchChecklistLoading: PropTypes.bool.isRequired, + isCourseBestPracticeChecklistLoading: PropTypes.bool.isRequired, + enableQuality: PropTypes.bool.isRequired, +}; + +export default injectIntl(AriaLiveRegion); diff --git a/src/course-checklist/ChecklistSection/ChecklistItemBody.jsx b/src/course-checklist/ChecklistSection/ChecklistItemBody.jsx new file mode 100644 index 000000000..f48bdc69b --- /dev/null +++ b/src/course-checklist/ChecklistSection/ChecklistItemBody.jsx @@ -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, +}) => ( + +
+ {isCompleted ? ( + + ) : ( + + )} +
+
+
+ +
+
+ +
+
+ + {updateLink && ( + + + + )} +
+); + +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); diff --git a/src/course-checklist/ChecklistSection/ChecklistItemComment.jsx b/src/course-checklist/ChecklistSection/ChecklistItemComment.jsx new file mode 100644 index 000000000..b254a79c1 --- /dev/null +++ b/src/course-checklist/ChecklistSection/ChecklistItemComment.jsx @@ -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) => ( +
+
+ +
+
+ {comment} +
+
+ ); + + 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 = ( + + ), + }} + /> + ); + 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 = ( + <> + +
    + {gradedAssignmentsOutsideDateRange.map(assignment => ( +
  • + +
  • + ))} +
+ + ); + 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); diff --git a/src/course-checklist/ChecklistSection/ChecklistSection.jsx b/src/course-checklist/ChecklistSection/ChecklistSection.jsx new file mode 100644 index 000000000..46fe71889 --- /dev/null +++ b/src/course-checklist/ChecklistSection/ChecklistSection.jsx @@ -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 ( + +

{dataHeading}

+ {isLoading ? ( +
+ +
+ ) : ( + <> +
+ {getCompletionCount(checks, totalCompletedChecks)} +
+ + {checks.map(check => { + const checkId = check.id; + const isCompleted = values[checkId]; + const updateLink = updateLinks?.[checkId]; + const outlineUrl = updateLinks.outline; + return ( +
+ +
+ +
+
+ ); + })} +
+ + )} +
+ ); +}; + +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); diff --git a/src/course-checklist/ChecklistSection/ChecklistSection.scss b/src/course-checklist/ChecklistSection/ChecklistSection.scss new file mode 100644 index 000000000..f06797013 --- /dev/null +++ b/src/course-checklist/ChecklistSection/ChecklistSection.scss @@ -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; +} diff --git a/src/course-checklist/ChecklistSection/ChecklistSection.test.jsx b/src/course-checklist/ChecklistSection/ChecklistSection.test.jsx new file mode 100644 index 000000000..1c8317c90 --- /dev/null +++ b/src/course-checklist/ChecklistSection/ChecklistSection.test.jsx @@ -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( + + + + + , + ); +}; + +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(); + }); + } + }); + }); +}); diff --git a/src/course-checklist/ChecklistSection/hooks.jsx b/src/course-checklist/ChecklistSection/hooks.jsx new file mode 100644 index 000000000..8032e8bb9 --- /dev/null +++ b/src/course-checklist/ChecklistSection/hooks.jsx @@ -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 ( + + ); +}; diff --git a/src/course-checklist/ChecklistSection/index.js b/src/course-checklist/ChecklistSection/index.js new file mode 100644 index 000000000..fa1df413b --- /dev/null +++ b/src/course-checklist/ChecklistSection/index.js @@ -0,0 +1,3 @@ +import ChecklistSection from './ChecklistSection'; + +export default ChecklistSection; diff --git a/src/course-checklist/ChecklistSection/messages.js b/src/course-checklist/ChecklistSection/messages.js new file mode 100644 index 000000000..362f568d1 --- /dev/null +++ b/src/course-checklist/ChecklistSection/messages.js @@ -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; diff --git a/src/course-checklist/ChecklistSection/utils/courseChecklistData.jsx b/src/course-checklist/ChecklistSection/utils/courseChecklistData.jsx new file mode 100644 index 000000000..c59d1f83b --- /dev/null +++ b/src/course-checklist/ChecklistSection/utils/courseChecklistData.jsx @@ -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, + }, + ], +}; diff --git a/src/course-checklist/ChecklistSection/utils/courseChecklistValidators.js b/src/course-checklist/ChecklistSection/utils/courseChecklistValidators.js new file mode 100644 index 000000000..583ca4bac --- /dev/null +++ b/src/course-checklist/ChecklistSection/utils/courseChecklistValidators.js @@ -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 +); diff --git a/src/course-checklist/ChecklistSection/utils/courseChecklistValidators.test.js b/src/course-checklist/ChecklistSection/utils/courseChecklistValidators.test.js new file mode 100644 index 000000000..401475bc2 --- /dev/null +++ b/src/course-checklist/ChecklistSection/utils/courseChecklistValidators.test.js @@ -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); + }); + }); +}); diff --git a/src/course-checklist/ChecklistSection/utils/getFilteredChecklist.js b/src/course-checklist/ChecklistSection/utils/getFilteredChecklist.js new file mode 100644 index 000000000..e85846240 --- /dev/null +++ b/src/course-checklist/ChecklistSection/utils/getFilteredChecklist.js @@ -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; diff --git a/src/course-checklist/ChecklistSection/utils/getFilteredChecklist.test.js b/src/course-checklist/ChecklistSection/utils/getFilteredChecklist.test.js new file mode 100644 index 000000000..bffcf157f --- /dev/null +++ b/src/course-checklist/ChecklistSection/utils/getFilteredChecklist.test.js @@ -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); + }); +}); diff --git a/src/course-checklist/ChecklistSection/utils/getValidatedValue.js b/src/course-checklist/ChecklistSection/utils/getValidatedValue.js new file mode 100644 index 000000000..695ac602e --- /dev/null +++ b/src/course-checklist/ChecklistSection/utils/getValidatedValue.js @@ -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; diff --git a/src/course-checklist/ChecklistSection/utils/getValidatedValue.test.js b/src/course-checklist/ChecklistSection/utils/getValidatedValue.test.js new file mode 100644 index 000000000..bed8f76ef --- /dev/null +++ b/src/course-checklist/ChecklistSection/utils/getValidatedValue.test.js @@ -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}`); + }); +}); diff --git a/src/course-checklist/CourseChecklist.jsx b/src/course-checklist/CourseChecklist.jsx new file mode 100644 index 000000000..5766bfe45 --- /dev/null +++ b/src/course-checklist/CourseChecklist.jsx @@ -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 ( + <> + + + {intl.formatMessage(messages.pageTitle, { + headingTitle: intl.formatMessage(messages.headingTitle), + courseName: courseDetails?.name, + siteName: process.env.SITE_NAME, + })} + + + + + + + + {enableQuality && ( + + )} + + + + ); +}; + +CourseChecklist.propTypes = { + courseId: PropTypes.string.isRequired, + // injected + intl: intlShape.isRequired, +}; + +export default injectIntl(CourseChecklist); diff --git a/src/course-checklist/CourseChecklist.scss b/src/course-checklist/CourseChecklist.scss new file mode 100644 index 000000000..afcbff6dc --- /dev/null +++ b/src/course-checklist/CourseChecklist.scss @@ -0,0 +1 @@ +@import "./ChecklistSection/ChecklistSection"; diff --git a/src/course-checklist/CourseChecklist.test.jsx b/src/course-checklist/CourseChecklist.test.jsx new file mode 100644 index 000000000..53d52af77 --- /dev/null +++ b/src/course-checklist/CourseChecklist.test.jsx @@ -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( + + + + + , + ); +}; + +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(); + }); + }); + }); + }); + }); +}); diff --git a/src/course-checklist/data/api.js b/src/course-checklist/data/api.js new file mode 100644 index 000000000..c9524005c --- /dev/null +++ b/src/course-checklist/data/api.js @@ -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} + */ +export async function getCourseLaunch({ + courseId, + gradedOnly, + validateOras, + all, +}) { + const { data } = await getAuthenticatedHttpClient() + .get(getCourseLaunchApiUrl({ + courseId, gradedOnly, validateOras, all, + })); + + return camelCaseObject(data); +} diff --git a/src/course-checklist/data/slice.js b/src/course-checklist/data/slice.js new file mode 100644 index 000000000..d50c7ecb2 --- /dev/null +++ b/src/course-checklist/data/slice.js @@ -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; diff --git a/src/course-checklist/data/thunks.js b/src/course-checklist/data/thunks.js new file mode 100644 index 000000000..20be7648a --- /dev/null +++ b/src/course-checklist/data/thunks.js @@ -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 })); + } + }; +} diff --git a/src/course-checklist/factories/mockApiResponses.jsx b/src/course-checklist/factories/mockApiResponses.jsx new file mode 100644 index 000000000..eec4f3439 --- /dev/null +++ b/src/course-checklist/factories/mockApiResponses.jsx @@ -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, + }, +}); diff --git a/src/course-checklist/index.js b/src/course-checklist/index.js new file mode 100644 index 000000000..a9a60ae4e --- /dev/null +++ b/src/course-checklist/index.js @@ -0,0 +1,3 @@ +import CourseChecklist from './CourseChecklist'; + +export default CourseChecklist; diff --git a/src/course-checklist/messages.js b/src/course-checklist/messages.js new file mode 100644 index 000000000..7176459c8 --- /dev/null +++ b/src/course-checklist/messages.js @@ -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; diff --git a/src/course-checklist/utils.js b/src/course-checklist/utils.js new file mode 100644 index 000000000..3e6b549ff --- /dev/null +++ b/src/course-checklist/utils.js @@ -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; diff --git a/src/index.jsx b/src/index.jsx index a68c1f1f4..b6e9ff6b5 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -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'); }, }, diff --git a/src/index.scss b/src/index.scss index c8f328f33..feedc9424 100755 --- a/src/index.scss +++ b/src/index.scss @@ -22,3 +22,4 @@ @import "content-tags-drawer/TagBubble"; @import "course-outline/CourseOutline"; @import "course-unit/CourseUnit"; +@import "course-checklist/CourseChecklist"; diff --git a/src/setupTest.js b/src/setupTest.js index 15a33c8bf..35b1c9ebe 100755 --- a/src/setupTest.js +++ b/src/setupTest.js @@ -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 { diff --git a/src/store.js b/src/store.js index 76864e93f..d0730c685 100644 --- a/src/store.js +++ b/src/store.js @@ -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,