From 46cd511e15315b5723d63545c1cf6dc2345dc82f Mon Sep 17 00:00:00 2001 From: Adam Butterworth Date: Fri, 14 Feb 2020 09:10:43 -0700 Subject: [PATCH] feat: add bookmarking for units (#11) * feat: add bookmarking for units * refactor: add redux for state management --- package-lock.json | 53 ++++- package.json | 3 +- src/courseware/CourseContainer.jsx | 113 +++++----- src/courseware/course/Course.jsx | 2 +- src/courseware/course/SequenceContainer.jsx | 206 +++++++++--------- src/courseware/index.scss | 4 - src/courseware/sequence/Sequence.jsx | 101 ++++----- .../sequence/SequenceNavigation.jsx | 21 +- src/courseware/sequence/Unit.jsx | 70 ++++-- src/courseware/sequence/UnitButton.jsx | 56 +++-- src/courseware/sequence/api.js | 49 ----- .../sequence/bookmark/BookmarkButton.jsx | 54 +++++ .../sequence/bookmark/BookmarkFilledIcon.jsx | 7 + .../sequence/bookmark/BookmarkOutlineIcon.jsx | 7 + src/data/course-blocks/api.js | 109 +++++++++ src/data/course-blocks/slice.js | 134 ++++++++++++ src/data/course-blocks/thunks.js | 124 +++++++++++ src/data/course-meta/api.js | 57 +++++ src/data/course-meta/slice.js | 32 +++ src/data/course-meta/thunks.js | 21 ++ src/index.jsx | 4 +- src/index.scss | 2 - src/store.js | 12 + 23 files changed, 916 insertions(+), 325 deletions(-) delete mode 100644 src/courseware/index.scss delete mode 100644 src/courseware/sequence/api.js create mode 100644 src/courseware/sequence/bookmark/BookmarkButton.jsx create mode 100644 src/courseware/sequence/bookmark/BookmarkFilledIcon.jsx create mode 100644 src/courseware/sequence/bookmark/BookmarkOutlineIcon.jsx create mode 100644 src/data/course-blocks/api.js create mode 100644 src/data/course-blocks/slice.js create mode 100644 src/data/course-blocks/thunks.js create mode 100644 src/data/course-meta/api.js create mode 100644 src/data/course-meta/slice.js create mode 100644 src/data/course-meta/thunks.js create mode 100644 src/store.js diff --git a/package-lock.json b/package-lock.json index 82c7981a..9eccf0dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3186,6 +3186,26 @@ "integrity": "sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==", "dev": true }, + "@reduxjs/toolkit": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.2.3.tgz", + "integrity": "sha512-CgeZl41Bmz1rFkASt5gA9egCy9YWXzy485EsEXoGd2Xm1o63UQCxfuCLTH+XlTs25WqtGjSmn5H4xu7n86ytYw==", + "requires": { + "immer": "^4.0.1", + "redux": "^4.0.0", + "redux-devtools-extension": "^2.13.8", + "redux-immutable-state-invariant": "^2.1.0", + "redux-thunk": "^2.3.0", + "reselect": "^4.0.0" + }, + "dependencies": { + "immer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/immer/-/immer-4.0.2.tgz", + "integrity": "sha512-Q/tm+yKqnKy4RIBmmtISBlhXuSDrB69e9EKTYiIenIKQkXBQir43w+kN/eGiax3wt1J0O1b2fYcNqLSbEcXA7w==" + } + } + }, "@sindresorhus/is": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.7.0.tgz", @@ -12159,8 +12179,7 @@ "json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", - "dev": true + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" }, "json3": { "version": "3.3.3", @@ -15799,14 +15818,33 @@ } }, "redux": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.4.tgz", - "integrity": "sha512-vKv4WdiJxOWKxK0yRoaK3Y4pxxB0ilzVx6dszU2W8wLxlb2yikRph4iV/ymtdJ6ZxpBLFbyrxklnT5yBbQSl3Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz", + "integrity": "sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==", "requires": { "loose-envify": "^1.4.0", "symbol-observable": "^1.2.0" } }, + "redux-devtools-extension": { + "version": "2.13.8", + "resolved": "https://registry.npmjs.org/redux-devtools-extension/-/redux-devtools-extension-2.13.8.tgz", + "integrity": "sha512-8qlpooP2QqPtZHQZRhx3x3OP5skEV1py/zUdMY28WNAocbafxdG2tRD1MWE7sp8obGMNYuLWanhhQ7EQvT1FBg==" + }, + "redux-immutable-state-invariant": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/redux-immutable-state-invariant/-/redux-immutable-state-invariant-2.1.0.tgz", + "integrity": "sha512-3czbDKs35FwiBRsx/3KabUk5zSOoTXC+cgVofGkpBNv3jQcqIe5JrHcF5AmVt7B/4hyJ8MijBIpCJ8cife6yJg==", + "requires": { + "invariant": "^2.1.0", + "json-stringify-safe": "^5.0.1" + } + }, + "redux-thunk": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz", + "integrity": "sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw==" + }, "reflect.ownkeys": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz", @@ -16147,6 +16185,11 @@ "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=", "dev": true }, + "reselect": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-4.0.0.tgz", + "integrity": "sha512-qUgANli03jjAyGlnbYVAV5vvnOmJnODyABz51RdBN7M4WaVu8mecZWgyQNkG8Yqe3KRGRt0l4K4B3XVEULC4CA==" + }, "resolve": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.13.1.tgz", diff --git a/package.json b/package.json index 59383777..0f7afd45 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@fortawesome/free-regular-svg-icons": "^5.12.0", "@fortawesome/free-solid-svg-icons": "^5.12.0", "@fortawesome/react-fontawesome": "^0.1.8", + "@reduxjs/toolkit": "^1.2.3", "classnames": "^2.2.6", "core-js": "^3.6.2", "prop-types": "^15.7.2", @@ -51,7 +52,7 @@ "react-redux": "^7.1.3", "react-router": "^5.1.2", "react-router-dom": "^5.1.2", - "redux": "^4.0.4", + "redux": "^4.0.5", "regenerator-runtime": "^0.13.3" }, "devDependencies": { diff --git a/src/courseware/CourseContainer.jsx b/src/courseware/CourseContainer.jsx index 7bcde8db..0fd33486 100644 --- a/src/courseware/CourseContainer.jsx +++ b/src/courseware/CourseContainer.jsx @@ -1,64 +1,35 @@ -import React, { useEffect, useContext, useState } from 'react'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { history, getConfig, camelCaseObject } from '@edx/frontend-platform'; -import { AppContext } from '@edx/frontend-platform/react'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { history } from '@edx/frontend-platform'; +import { fetchCourseMetadata } from '../data/course-meta/thunks'; +import { fetchCourseBlocks } from '../data/course-blocks/thunks'; import messages from './messages'; import PageLoading from './PageLoading'; import Course from './course/Course'; -import { createBlocksMap } from './utils'; - -export async function getCourseBlocks(courseUsageKey, username) { - const url = new URL(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/`); - url.searchParams.append('course_id', courseUsageKey); - url.searchParams.append('username', username); - url.searchParams.append('depth', 3); - url.searchParams.append('requested_fields', 'children,show_gated_sections'); - - const { data } = await getAuthenticatedHttpClient().get(url.href, {}); - - return data; -} - -export async function getCourse(courseUsageKey) { - const url = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseUsageKey}`; - const { data } = await getAuthenticatedHttpClient().get(url); - - return data; -} - -function useLoadCourse(courseUsageKey) { - const { authenticatedUser } = useContext(AppContext); - - const [models, setModels] = useState(null); - const [courseId, setCourseId] = useState(); - const [metadata, setMetadata] = useState(null); - - useEffect(() => { - getCourseBlocks(courseUsageKey, authenticatedUser.username).then((blocksData) => { - setModels(createBlocksMap(blocksData.blocks)); - setCourseId(blocksData.root); - }); - getCourse(courseUsageKey).then((data) => { - setMetadata(camelCaseObject(data)); - }); - }, [courseUsageKey]); - - return { - models, courseId, metadata, - }; -} function CourseContainer(props) { - const { intl, match } = props; + const { + intl, + match, + courseId, + blocks: models, + metadata, + } = props; const { courseUsageKey, sequenceId, unitId, } = match.params; - const { models, courseId, metadata } = useLoadCourse(courseUsageKey); + + const metadataLoaded = metadata.fetchState === 'loaded'; + + useEffect(() => { + props.fetchCourseMetadata(courseUsageKey); + props.fetchCourseBlocks(courseUsageKey); + }, [courseUsageKey]); useEffect(() => { if (courseId && !sequenceId) { @@ -78,23 +49,42 @@ function CourseContainer(props) { ); } - return metadata && ( + return metadataLoaded && ( ); } CourseContainer.propTypes = { intl: intlShape.isRequired, + courseId: PropTypes.string, + blocks: PropTypes.objectOf(PropTypes.shape({ + id: PropTypes.string, + })), + metadata: PropTypes.shape({ + fetchState: PropTypes.string, + org: PropTypes.string, + number: PropTypes.string, + name: PropTypes.string, + tabs: PropTypes.arrayOf(PropTypes.shape({ + priority: PropTypes.number, + slug: PropTypes.string, + title: PropTypes.string, + type: PropTypes.string, + url: PropTypes.string, + })), + }), + fetchCourseMetadata: PropTypes.func.isRequired, + fetchCourseBlocks: PropTypes.func.isRequired, match: PropTypes.shape({ params: PropTypes.shape({ courseUsageKey: PropTypes.string.isRequired, @@ -104,4 +94,19 @@ CourseContainer.propTypes = { }).isRequired, }; -export default injectIntl(CourseContainer); +CourseContainer.defaultProps = { + blocks: {}, + metadata: undefined, + courseId: undefined, +}; + +const mapStateToProps = state => ({ + courseId: state.courseBlocks.root, + metadata: state.courseMeta, + blocks: state.courseBlocks.blocks, +}); + +export default connect(mapStateToProps, { + fetchCourseMetadata, + fetchCourseBlocks, +})(injectIntl(CourseContainer)); diff --git a/src/courseware/course/Course.jsx b/src/courseware/course/Course.jsx index 37b4d22f..57fc0dbf 100644 --- a/src/courseware/course/Course.jsx +++ b/src/courseware/course/Course.jsx @@ -92,5 +92,5 @@ Course.propTypes = { }; Course.defaultProps = { - unitId: null, + unitId: undefined, }; diff --git a/src/courseware/course/SequenceContainer.jsx b/src/courseware/course/SequenceContainer.jsx index 614beb66..a639e00a 100644 --- a/src/courseware/course/SequenceContainer.jsx +++ b/src/courseware/course/SequenceContainer.jsx @@ -1,114 +1,78 @@ /* eslint-disable no-plusplus */ -import React, { - useEffect, useState, useContext, useCallback, -} from 'react'; +import React, { useEffect, useCallback, useMemo } from 'react'; import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { history, camelCaseObject, getConfig } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { history } from '@edx/frontend-platform'; import messages from '../messages'; import PageLoading from '../PageLoading'; import Sequence from '../sequence/Sequence'; -import UserMessagesContext from '../../user-messages/UserMessagesContext'; - -export async function getSequenceMetadata(courseUsageKey, sequenceId) { - const { data } = await getAuthenticatedHttpClient() - .get(`${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceId}`, {}); - - return data; -} - -function useLoadSequence(courseUsageKey, sequenceId) { - const [metadata, setMetadata] = useState(null); - const [units, setUnits] = useState(null); - const [loaded, setLoaded] = useState(false); - - useEffect(() => { - setLoaded(false); - setMetadata(null); - getSequenceMetadata(courseUsageKey, sequenceId).then((data) => { - const unitsMap = {}; - for (let i = 0; i < data.items.length; i++) { - const item = data.items[i]; - unitsMap[item.id] = camelCaseObject(item); - } - - setMetadata(camelCaseObject(data)); - setUnits(unitsMap); - setLoaded(true); - }); - }, [courseUsageKey, sequenceId]); - - return { - metadata, - units, - loaded, - }; -} - -function SequenceContainer({ - courseUsageKey, courseId, sequenceId, unitId, models, intl, onNext, onPrevious, -}) { - const { metadata, loaded, units } = useLoadSequence(courseUsageKey, sequenceId); - - useEffect(() => { - if (loaded && !unitId) { - // The position may be null, in which case we'll just assume 0. - const position = metadata.position !== null ? metadata.position - 1 : 0; - const nextUnitId = metadata.items[position].id; - history.push(`/course/${courseUsageKey}/${sequenceId}/${nextUnitId}`); - } - }, [loaded, metadata, unitId]); - - const handleUnitNavigation = useCallback((nextUnitId) => { - history.push(`/course/${courseUsageKey}/${sequenceId}/${nextUnitId}`); - }, [courseUsageKey, sequenceId]); - - const { add, remove } = useContext(UserMessagesContext); - useEffect(() => { - let id = null; - if (metadata && metadata.bannerText) { - id = add({ - code: null, - dismissible: false, - text: metadata.bannerText, - type: 'info', - topic: 'sequence', - }); - } - return () => { - if (id) { - remove(id); - } - }; - }, [metadata]); - - // Exam redirect - useEffect(() => { - if (metadata && models) { - if (metadata.isTimeLimited) { - global.location.href = models[sequenceId].lmsWebUrl; - } - } - }, [metadata, models]); - - if (!loaded || !unitId || (metadata && metadata.isTimeLimited)) { - return ( - - ); - } +import { fetchSequenceMetadata, checkBlockCompletion, saveSequencePosition } from '../../data/course-blocks/thunks'; +function SequenceContainer(props) { const { + courseUsageKey, + courseId, + sequenceId, + unitId, + intl, + onNext, + onPrevious, + fetchState, displayName, showCompletion, isTimeLimited, savePosition, bannerText, gatedContent, - } = metadata; + position, + items, + lmsWebUrl, + } = props; + const loaded = fetchState === 'loaded'; + + const unitIds = useMemo(() => items.map(({ id }) => id), [items]); + + useEffect(() => { + props.fetchSequenceMetadata(sequenceId); + }, [sequenceId]); + + useEffect(() => { + if (savePosition) { + const activeUnitIndex = unitIds.indexOf(unitId); + props.saveSequencePosition(courseUsageKey, sequenceId, activeUnitIndex); + } + }, [unitId]); + + useEffect(() => { + if (loaded && !unitId) { + // The position may be null, in which case we'll just assume 0. + const unitIndex = position || 0; + const nextUnitId = unitIds[unitIndex]; + history.push(`/course/${courseUsageKey}/${sequenceId}/${nextUnitId}`); + } + }, [loaded, unitId]); + + const handleUnitNavigation = useCallback((nextUnitId) => { + props.checkBlockCompletion(courseUsageKey, sequenceId, unitId); + history.push(`/course/${courseUsageKey}/${sequenceId}/${nextUnitId}`); + }, [courseUsageKey, sequenceId]); + + // Exam redirect + useEffect(() => { + if (isTimeLimited) { + global.location.href = lmsWebUrl; + } + }, [isTimeLimited]); + + if (!loaded || !unitId || isTimeLimited) { + return ( + + ); + } const prerequisite = { id: gatedContent.prereqId, @@ -120,8 +84,7 @@ function SequenceContainer({ id={sequenceId} courseUsageKey={courseUsageKey} courseId={courseId} - unitIds={metadata.items.map((item) => item.id)} - units={units} + unitIds={unitIds} displayName={displayName} activeUnitId={unitId} showCompletion={showCompletion} @@ -141,17 +104,52 @@ SequenceContainer.propTypes = { onNext: PropTypes.func.isRequired, onPrevious: PropTypes.func.isRequired, courseUsageKey: PropTypes.string.isRequired, - models: PropTypes.objectOf(PropTypes.shape({ - id: PropTypes.string.isRequired, - lmsWebUrl: PropTypes.string.isRequired, - })).isRequired, courseId: PropTypes.string.isRequired, sequenceId: PropTypes.string.isRequired, unitId: PropTypes.string, intl: intlShape.isRequired, + items: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string, + })), + gatedContent: PropTypes.shape({ + gated: PropTypes.bool, + gatedSectionName: PropTypes.string, + prereqId: PropTypes.string, + }), + checkBlockCompletion: PropTypes.func.isRequired, + fetchSequenceMetadata: PropTypes.func.isRequired, + saveSequencePosition: PropTypes.func.isRequired, + savePosition: PropTypes.bool, + lmsWebUrl: PropTypes.string, + position: PropTypes.number, + fetchState: PropTypes.string, + displayName: PropTypes.string, + showCompletion: PropTypes.bool, + isTimeLimited: PropTypes.bool, + bannerText: PropTypes.string, }; SequenceContainer.defaultProps = { - unitId: null, + unitId: undefined, + gatedContent: undefined, + showCompletion: false, + lmsWebUrl: undefined, + position: undefined, + fetchState: undefined, + displayName: undefined, + isTimeLimited: undefined, + bannerText: undefined, + savePosition: undefined, + items: [], }; -export default injectIntl(SequenceContainer); + +export default connect( + (state, props) => ({ + ...state.courseBlocks.blocks[props.sequenceId], + }), + { + fetchSequenceMetadata, + checkBlockCompletion, + saveSequencePosition, + }, +)(injectIntl(SequenceContainer)); diff --git a/src/courseware/index.scss b/src/courseware/index.scss deleted file mode 100644 index 97c9813e..00000000 --- a/src/courseware/index.scss +++ /dev/null @@ -1,4 +0,0 @@ -iframe { - border: 0; - width: 100%; -} diff --git a/src/courseware/sequence/Sequence.jsx b/src/courseware/sequence/Sequence.jsx index 70007093..e9e657c5 100644 --- a/src/courseware/sequence/Sequence.jsx +++ b/src/courseware/sequence/Sequence.jsx @@ -1,22 +1,20 @@ /* eslint-disable no-use-before-define */ -import React, { useState, useEffect, Suspense } from 'react'; +import React, { useEffect, useContext, Suspense } from 'react'; import PropTypes from 'prop-types'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; import Unit from './Unit'; import SequenceNavigation from './SequenceNavigation'; import PageLoading from '../PageLoading'; -import { getBlockCompletion, saveSequencePosition } from './api'; import messages from './messages'; import AlertList from '../../user-messages/AlertList'; +import UserMessagesContext from '../../user-messages/UserMessagesContext'; const ContentLock = React.lazy(() => import('./content-lock')); function Sequence({ courseUsageKey, - id, unitIds, - units: initialUnits, displayName, showCompletion, onNext, @@ -24,70 +22,53 @@ function Sequence({ onNavigateUnit, isGated, prerequisite, - savePosition, - activeUnitId: initialActiveUnitId, + activeUnitId, + bannerText, intl, }) { - const [units, setUnits] = useState(initialUnits); - const [activeUnitId, setActiveUnitId] = useState(initialActiveUnitId); - - const activeUnitIndex = unitIds.indexOf(activeUnitId); - const activeUnit = units[activeUnitId]; - const unitsArr = unitIds.map(unitId => ({ - ...units[unitId], - id: unitId, - isActive: unitId === activeUnitId, - })); - - // TODO: Use callback - const updateUnitCompletion = (unitId) => { - // If the unit is already complete, don't check. - if (units[unitId].complete) { - return; - } - - getBlockCompletion(courseUsageKey, id, unitId).then((isComplete) => { - if (isComplete) { - setUnits({ - ...units, - [unitId]: { ...units[unitId], complete: isComplete }, - }); - } - }); - }; - const handleNext = () => { - if (activeUnitIndex < unitIds.length - 1) { - handleNavigate(activeUnitIndex + 1); + const nextIndex = unitIds.indexOf(activeUnitId) + 1; + if (nextIndex < unitIds.length) { + const newUnitId = unitIds[nextIndex]; + handleNavigate(newUnitId); } else { onNext(); } }; const handlePrevious = () => { - if (activeUnitIndex > 0) { - handleNavigate(activeUnitIndex - 1); + const previousIndex = unitIds.indexOf(activeUnitId) - 1; + if (previousIndex >= 0) { + const newUnitId = unitIds[previousIndex]; + handleNavigate(newUnitId); } else { onPrevious(); } }; - const handleNavigate = (unitIndex) => { - const newUnitId = unitIds[unitIndex]; - if (showCompletion) { - updateUnitCompletion(activeUnitId); - } - setActiveUnitId(newUnitId); - if (onNavigateUnit !== null) { - onNavigateUnit(newUnitId, units[newUnitId]); - } + const handleNavigate = (unitId) => { + onNavigateUnit(unitId); }; + const { add, remove } = useContext(UserMessagesContext); useEffect(() => { - if (savePosition) { - saveSequencePosition(courseUsageKey, id, activeUnitIndex); + let id = null; + if (bannerText) { + id = add({ + code: null, + dismissible: false, + text: bannerText, + type: 'info', + topic: 'sequence', + }); } - }, [activeUnitId]); + return () => { + if (id) { + remove(id); + } + }; + }, [bannerText]); + return (
@@ -98,7 +79,8 @@ function Sequence({ onNext={handleNext} onNavigate={handleNavigate} onPrevious={handlePrevious} - units={unitsArr} + unitIds={unitIds} + activeUnitId={activeUnitId} isLocked={isGated} showCompletion={showCompletion} /> @@ -120,7 +102,10 @@ function Sequence({ )}
{!isGated && ( - + )} ); @@ -128,33 +113,25 @@ function Sequence({ Sequence.propTypes = { activeUnitId: PropTypes.string.isRequired, - bannerText: PropTypes.string, courseUsageKey: PropTypes.string.isRequired, displayName: PropTypes.string.isRequired, - id: PropTypes.string.isRequired, intl: intlShape.isRequired, isGated: PropTypes.bool.isRequired, - isTimeLimited: PropTypes.bool.isRequired, onNavigateUnit: PropTypes.func, onNext: PropTypes.func.isRequired, onPrevious: PropTypes.func.isRequired, - savePosition: PropTypes.bool.isRequired, showCompletion: PropTypes.bool.isRequired, prerequisite: PropTypes.shape({ name: PropTypes.string, id: PropTypes.string, }).isRequired, unitIds: PropTypes.arrayOf(PropTypes.string).isRequired, - units: PropTypes.objectOf(PropTypes.shape({ - id: PropTypes.string.isRequired, - complete: PropTypes.bool, - pageTitle: PropTypes.string.isRequired, - })).isRequired, + bannerText: PropTypes.string, }; Sequence.defaultProps = { - bannerText: null, onNavigateUnit: null, + bannerText: undefined, }; export default injectIntl(Sequence); diff --git a/src/courseware/sequence/SequenceNavigation.jsx b/src/courseware/sequence/SequenceNavigation.jsx index f20f8e29..044dd807 100644 --- a/src/courseware/sequence/SequenceNavigation.jsx +++ b/src/courseware/sequence/SequenceNavigation.jsx @@ -9,18 +9,19 @@ export default function SequenceNavigation({ onNext, onPrevious, onNavigate, - units, + unitIds, isLocked, showCompletion, + activeUnitId, className, }) { - const unitButtons = units.map((unit, index) => ( + const unitButtons = unitIds.map(unitId => ( )); @@ -44,12 +45,10 @@ SequenceNavigation.propTypes = { onNext: PropTypes.func.isRequired, onPrevious: PropTypes.func.isRequired, onNavigate: PropTypes.func.isRequired, - units: PropTypes.arrayOf(PropTypes.shape({ - id: PropTypes.string.isRequired, - complete: PropTypes.bool, - })).isRequired, isLocked: PropTypes.bool.isRequired, showCompletion: PropTypes.bool.isRequired, + unitIds: PropTypes.arrayOf(PropTypes.string).isRequired, + activeUnitId: PropTypes.string.isRequired, }; SequenceNavigation.defaultProps = { diff --git a/src/courseware/sequence/Unit.jsx b/src/courseware/sequence/Unit.jsx index 95c415ea..9fae3836 100644 --- a/src/courseware/sequence/Unit.jsx +++ b/src/courseware/sequence/Unit.jsx @@ -1,10 +1,19 @@ import React, { useRef, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { getConfig } from '@edx/frontend-platform'; +import { connect } from 'react-redux'; +import BookmarkButton from './bookmark/BookmarkButton'; +import { addBookmark, removeBookmark } from '../../data/course-blocks/thunks'; -export default function Unit({ id, pageTitle }) { +function Unit({ + bookmarked, + bookmarkedUpdateState, + displayName, + id, + ...props +}) { const iframeRef = useRef(null); - const iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}`; + const iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}?show_title=0&show_bookmark_button=0`; const [iframeHeight, setIframeHeight] = useState(0); useEffect(() => { @@ -17,21 +26,56 @@ export default function Unit({ id, pageTitle }) { }; }, []); + const toggleBookmark = () => { + if (bookmarked) { + props.removeBookmark(id); + } else { + props.addBookmark(id); + } + }; + return ( -