diff --git a/src/course-home/data/__factories__/block.factory.js b/src/course-home/data/__factories__/block.factory.js new file mode 100644 index 00000000..0bda4738 --- /dev/null +++ b/src/course-home/data/__factories__/block.factory.js @@ -0,0 +1,59 @@ +import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies + +Factory.define('block') + .option('courseId', 'course-v1:edX+DemoX+Demo_Course') + .option('host', 'http://localhost:18000') + // Generating block_id that is similar to md5 hash, but still deterministic + .sequence('block_id', id => ('abcd'.repeat(8) + id).slice(-32)) + .attrs({ + complete: false, + description: null, + due: null, + graded: false, + icon: null, + showLink: true, + type: 'course', + children: [], + }) + .attr('display_name', ['display_name', 'block_id'], (displayName, blockId) => { + if (displayName) { + return displayName; + } + + return blockId; + }) + .attr( + 'id', + ['id', 'block_id', 'type', 'courseId'], + (id, blockId, type, courseId) => { + if (id) { + return id; + } + + const courseInfo = courseId.split(':')[1]; + + return `block-v1:${courseInfo}+type@${type}+block@${blockId}`; + }, + ) + .attr( + 'student_view_url', + ['student_view_url', 'host', 'id'], + (url, host, id) => { + if (url) { + return url; + } + + return `${host}/xblock/${id}`; + }, + ) + .attr( + 'lms_web_url', + ['lms_web_url', 'host', 'courseId', 'id'], + (url, host, courseId, id) => { + if (url) { + return url; + } + + return `${host}/courses/${courseId}/jump_to/${id}`; + }, + ); diff --git a/src/course-home/data/__factories__/courseBlocks.factory.js b/src/course-home/data/__factories__/courseBlocks.factory.js new file mode 100644 index 00000000..026cf8a8 --- /dev/null +++ b/src/course-home/data/__factories__/courseBlocks.factory.js @@ -0,0 +1,86 @@ +import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies +import './block.factory'; + +// Generates an Array of block IDs, either from a single block or an array of blocks. +const getIds = (attr) => { + const blocks = Array.isArray(attr) ? attr : [attr]; + return blocks.map(block => block.id); +}; + +// Generates an Object in { [block.id]: block } format, either from a single block or an array of blocks. +const getBlocks = (attr) => { + const blocks = Array.isArray(attr) ? attr : [attr]; + // eslint-disable-next-line no-return-assign,no-sequences + return blocks.reduce((acc, block) => (acc[block.id] = block, acc), {}); +}; + +Factory.define('courseBlocks') + .option('courseId', 'course-v1:edX+DemoX+Demo_Course') + .option('units', ['courseId'], courseId => ([ + Factory.build( + 'block', + { type: 'vertical' }, + { courseId }, + ), + ])) + .option('sequence', ['courseId', 'units'], (courseId, child) => Factory.build( + 'block', + { type: 'sequential', children: getIds(child) }, + { courseId }, + )) + .option('section', ['courseId', 'sequence'], (courseId, child) => Factory.build( + 'block', + { type: 'chapter', children: getIds(child) }, + { courseId }, + )) + .option('course', ['courseId', 'section'], (courseId, child) => Factory.build( + 'block', + { type: 'course', children: getIds(child) }, + { courseId }, + )) + .attr( + 'blocks', + ['course', 'section', 'sequence', 'units'], + (course, section, sequence, units) => ({ + [course.id]: course, + ...getBlocks(section), + ...getBlocks(sequence), + ...getBlocks(units), + }), + ) + .attr('root', ['course'], course => course.id); + +/** + * Builds a course with a single chapter, sequence, and unit. + */ +export default function buildSimpleCourseBlocks(courseId, title, options = {}) { + const sequenceBlock = options.sequenceBlock || [Factory.build( + 'block', + { type: 'sequential' }, + { courseId }, + )]; + const sectionBlock = options.sectionBlock || Factory.build( + 'block', + { type: 'chapter', children: sequenceBlock.map(block => block.id) }, + { courseId }, + ); + const courseBlock = options.courseBlocks || Factory.build( + 'block', + { type: 'course', display_name: title, children: [sectionBlock.id] }, + { courseId }, + ); + return { + courseBlocks: options.courseBlocks || Factory.build( + 'courseBlocks', + { courseId }, + { + sequence: sequenceBlock, + section: sectionBlock, + course: courseBlock, + }, + ), + sequenceBlock, + sectionBlock, + courseBlock, + }; +} diff --git a/src/course-home/data/__factories__/outlineTabData.factory.js b/src/course-home/data/__factories__/outlineTabData.factory.js index 381a7c7b..771a62f2 100644 --- a/src/course-home/data/__factories__/outlineTabData.factory.js +++ b/src/course-home/data/__factories__/outlineTabData.factory.js @@ -1,6 +1,6 @@ import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies -import buildSimpleCourseBlocks from '../../../courseware/data/__factories__/courseBlocks.factory'; +import buildSimpleCourseBlocks from './courseBlocks.factory'; Factory.define('outlineTabData') .option('courseId', 'course-v1:edX+DemoX+Demo_Course') diff --git a/src/course-home/data/__snapshots__/redux.test.js.snap b/src/course-home/data/__snapshots__/redux.test.js.snap index 8a5cd8db..fac3b8b6 100644 --- a/src/course-home/data/__snapshots__/redux.test.js.snap +++ b/src/course-home/data/__snapshots__/redux.test.js.snap @@ -164,41 +164,34 @@ Object { "course-v1:edX+DemoX+Demo_Course_1": Object { "courseBlocks": Object { "courses": Object { - "block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd4": Object { + "block-v1:edX+DemoX+Demo_Course+type@course+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object { "id": "course-v1:edX+DemoX+Demo_Course_1", "sectionIds": Array [ - "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3", - ], - "title": "bcdabcdabcdabcdabcdabcdabcdabcd4", - }, - }, - "sections": Object { - "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3": Object { - "courseId": "course-v1:edX+DemoX+Demo_Course_1", - "id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3", - "sequenceIds": Array [ - "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2", + "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2", ], "title": "bcdabcdabcdabcdabcdabcdabcdabcd3", }, }, - "sequences": Object { - "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object { - "id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2", - "lmsWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2", - "sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd3", - "title": "bcdabcdabcdabcdabcdabcdabcdabcd2", - "unitIds": Array [ - "block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1", + "sections": Object { + "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2": Object { + "complete": false, + "courseId": "course-v1:edX+DemoX+Demo_Course_1", + "id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2", + "sequenceIds": Array [ + "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1", ], + "title": "bcdabcdabcdabcdabcdabcdabcdabcd2", }, }, - "units": Object { - "block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object { - "graded": false, - "id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1", - "lmsWebUrl": "http://localhost:18000/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@bcdabcdabcdabcdabcdabcdabcdabcd1", - "sequenceId": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd2", + "sequences": Object { + "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1": Object { + "complete": false, + "description": null, + "due": null, + "icon": null, + "id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@bcdabcdabcdabcdabcdabcdabcdabcd1", + "sectionId": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@bcdabcdabcdabcdabcdabcdabcdabcd2", + "showLink": true, "title": "bcdabcdabcdabcdabcdabcdabcdabcd1", }, }, diff --git a/src/course-home/data/api.js b/src/course-home/data/api.js index 5dca115b..686ad62c 100644 --- a/src/course-home/data/api.js +++ b/src/course-home/data/api.js @@ -1,7 +1,6 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -// TODO: Pull this normalization function up so we're not reaching into courseware -import { normalizeBlocks } from '../../courseware/data/api'; +import { logError } from '@edx/frontend-platform/logging'; function normalizeCourseHomeCourseMetadata(metadata) { const data = camelCaseObject(metadata); @@ -15,6 +14,74 @@ function normalizeCourseHomeCourseMetadata(metadata) { }; } +export function normalizeOutlineBlocks(courseId, blocks) { + const models = { + courses: {}, + sections: {}, + sequences: {}, + }; + Object.values(blocks).forEach(block => { + switch (block.type) { + case 'course': + models.courses[block.id] = { + id: courseId, + title: block.display_name, + sectionIds: block.children || [], + }; + break; + + case 'chapter': + models.sections[block.id] = { + complete: block.complete, + id: block.id, + title: block.display_name, + sequenceIds: block.children || [], + }; + break; + + case 'sequential': + models.sequences[block.id] = { + complete: block.complete, + description: block.description, + due: block.due, + icon: block.icon, + id: block.id, + showLink: !!block.lms_web_url, // we reconstruct the url ourselves as an MFE-internal + title: block.display_name, + }; + break; + + default: + logError(`Unexpected course block type: ${block.type} with ID ${block.id}. Expected block types are course, chapter, and sequential.`); + } + }); + + // Next go through each list and use their child lists to decorate those children with a + // reference back to their parent. + Object.values(models.courses).forEach(course => { + if (Array.isArray(course.sectionIds)) { + course.sectionIds.forEach(sectionId => { + const section = models.sections[sectionId]; + section.courseId = course.id; + }); + } + }); + + Object.values(models.sections).forEach(section => { + if (Array.isArray(section.sequenceIds)) { + section.sequenceIds.forEach(sequenceId => { + if (sequenceId in models.sequences) { + models.sequences[sequenceId].sectionId = section.id; + } else { + logError(`Section ${section.id} has child block ${sequenceId}, but that block is not in the list of sequences.`); + } + }); + } + }); + + return models; +} + export async function getCourseHomeCourseMetadata(courseId) { const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`; const { data } = await getAuthenticatedHttpClient().get(url); @@ -68,7 +135,7 @@ export async function getOutlineTabData(courseId) { const { data, } = tabData; - const courseBlocks = normalizeBlocks(courseId, data.course_blocks.blocks); + const courseBlocks = normalizeOutlineBlocks(courseId, data.course_blocks.blocks); const courseGoals = camelCaseObject(data.course_goals); const courseExpiredHtml = data.course_expired_html; const courseTools = camelCaseObject(data.course_tools); diff --git a/src/course-home/outline-tab/OutlineTab.jsx b/src/course-home/outline-tab/OutlineTab.jsx index 92387509..fee37212 100644 --- a/src/course-home/outline-tab/OutlineTab.jsx +++ b/src/course-home/outline-tab/OutlineTab.jsx @@ -121,8 +121,7 @@ function OutlineTab({ intl }) {
))} diff --git a/src/course-home/outline-tab/Section.jsx b/src/course-home/outline-tab/Section.jsx index 21318357..62de2ab3 100644 --- a/src/course-home/outline-tab/Section.jsx +++ b/src/course-home/outline-tab/Section.jsx @@ -1,50 +1,46 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Collapsible } from '@edx/paragon'; -import { faChevronRight, faChevronDown } from '@fortawesome/free-solid-svg-icons'; +import { faCheckCircle } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import SequenceLink from './SequenceLink'; import { useModel } from '../../generic/model-store'; -export default function Section({ courseId, title, sequenceIds }) { +export default function Section({ courseId, section }) { + const { + complete, + sequenceIds, + title, + } = section; const { courseBlocks: { sequences, }, } = useModel('outline', courseId); - return ( - - - -
- -
-
- -
- -
-
-
{title}
-
+ const sectionTitle = ( +
+ {complete && } +
{title}
+
+ ); - - {sequenceIds.map((sequenceId) => ( - - ))} - -
+ return ( + + {sequenceIds.map((sequenceId, index) => ( + + ))} + ); } Section.propTypes = { courseId: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - sequenceIds: PropTypes.arrayOf(PropTypes.string).isRequired, + section: PropTypes.shape().isRequired, }; diff --git a/src/course-home/outline-tab/SequenceLink.jsx b/src/course-home/outline-tab/SequenceLink.jsx index 73e72da6..e17bdb49 100644 --- a/src/course-home/outline-tab/SequenceLink.jsx +++ b/src/course-home/outline-tab/SequenceLink.jsx @@ -1,11 +1,103 @@ import React from 'react'; import PropTypes from 'prop-types'; +import classNames from 'classnames'; import { Link } from 'react-router-dom'; +import { FormattedMessage, FormattedTime } from '@edx/frontend-platform/i18n'; +import { faClock, faEdit } from '@fortawesome/free-regular-svg-icons'; +import { + faCheck, + faCheckCircle, + faExclamationTriangle, + faSpinner, + faTimesCircle, +} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +import { useModel } from '../../generic/model-store'; + +export default function SequenceLink({ + id, + courseId, + first, + sequence, +}) { + const { + complete, + description, + due, + icon, + showLink, + title, + } = sequence; + const { + datesWidget: { + userTimezone, + }, + } = useModel('outline', courseId); + + const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {}; + + let text = title; + + let faIcon; + switch (icon) { + // list of possible ones here: https://github.com/edx/edx-proctoring/blob/master/edx_proctoring/api.py + case 'fa-check': faIcon = faCheck; break; + case 'fa-clock-o': faIcon = faClock; break; + case 'fa-exclamation-triangle': faIcon = faExclamationTriangle; break; + case 'fa-pencil-square-o': faIcon = faEdit; break; + case 'fa-spinner fa-spin': faIcon = faSpinner; break; + case 'fa-times-circle': faIcon = faTimesCircle; break; + default: faIcon = null; break; + } + if (faIcon) { + text = <> {text}; + } + + if (due) { + text = ( + <> + {text}
+ + + ), + description: description || '', + }} + /> + + + ); + } + + text =
{text}
; + + if (complete) { + text = <>{text}; + } + + // Do link last so we include everything above in the link + if (showLink) { + text =
{text}
; + } -export default function SequenceLink({ id, courseId, title }) { return ( -
- {title} +
+ {text}
); } @@ -13,5 +105,6 @@ export default function SequenceLink({ id, courseId, title }) { SequenceLink.propTypes = { id: PropTypes.string.isRequired, courseId: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, + first: PropTypes.bool.isRequired, + sequence: PropTypes.shape().isRequired, };