feat: add bookmarking for units (#11)
* feat: add bookmarking for units * refactor: add redux for state management
This commit is contained in:
@@ -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 (
|
||||
<div className="flex-grow-1">
|
||||
@@ -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({
|
||||
)}
|
||||
</div>
|
||||
{!isGated && (
|
||||
<Unit key={activeUnitId} {...activeUnit} />
|
||||
<Unit
|
||||
key={activeUnitId}
|
||||
id={activeUnitId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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 => (
|
||||
<UnitButton
|
||||
key={unit.id}
|
||||
{...unit}
|
||||
isComplete={showCompletion && unit.complete}
|
||||
index={index}
|
||||
clickHandler={onNavigate}
|
||||
key={unitId}
|
||||
unitId={unitId}
|
||||
isActive={activeUnitId === unitId}
|
||||
showCompletion={showCompletion}
|
||||
onClick={onNavigate}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 (
|
||||
<iframe
|
||||
title={pageTitle}
|
||||
ref={iframeRef}
|
||||
src={iframeUrl}
|
||||
allowFullScreen
|
||||
className="d-block container-fluid px-0"
|
||||
height={iframeHeight}
|
||||
scrolling="no"
|
||||
referrerPolicy="origin"
|
||||
/>
|
||||
<div>
|
||||
<div className="container-fluid mb-2">
|
||||
<h2 className="mb-0">{displayName}</h2>
|
||||
<BookmarkButton
|
||||
onClick={toggleBookmark}
|
||||
isBookmarked={bookmarked}
|
||||
isProcessing={bookmarkedUpdateState === 'loading'}
|
||||
/>
|
||||
</div>
|
||||
<iframe
|
||||
title={displayName}
|
||||
ref={iframeRef}
|
||||
src={iframeUrl}
|
||||
allowFullScreen
|
||||
className="d-block container-fluid px-0"
|
||||
height={iframeHeight}
|
||||
scrolling="no"
|
||||
referrerPolicy="origin"
|
||||
style={{ border: 0, width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Unit.propTypes = {
|
||||
addBookmark: PropTypes.func.isRequired,
|
||||
bookmarked: PropTypes.bool,
|
||||
bookmarkedUpdateState: PropTypes.string,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
pageTitle: PropTypes.string.isRequired,
|
||||
removeBookmark: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
Unit.defaultProps = {
|
||||
bookmarked: false,
|
||||
bookmarkedUpdateState: undefined,
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, props) => state.courseBlocks.blocks[props.id] || {};
|
||||
|
||||
export default connect(mapStateToProps, {
|
||||
addBookmark,
|
||||
removeBookmark,
|
||||
})(Unit);
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import classNames from 'classnames';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import UnitIcon from './UnitIcon';
|
||||
import CompleteIcon from './CompleteIcon';
|
||||
import BookmarkFilledIcon from './bookmark/BookmarkFilledIcon';
|
||||
|
||||
export default function UnitButton({
|
||||
clickHandler,
|
||||
pageTitle,
|
||||
type,
|
||||
function UnitButton({
|
||||
onClick,
|
||||
displayName,
|
||||
contentType,
|
||||
isActive,
|
||||
isComplete,
|
||||
index,
|
||||
bookmarked,
|
||||
complete,
|
||||
showCompletion,
|
||||
unitId,
|
||||
}) {
|
||||
const onClick = useCallback(() => {
|
||||
clickHandler(index);
|
||||
const handleClick = useCallback(() => {
|
||||
onClick(unitId);
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -26,25 +30,41 @@ export default function UnitButton({
|
||||
'btn-outline-secondary': isActive,
|
||||
})}
|
||||
|
||||
onClick={onClick}
|
||||
title={pageTitle}
|
||||
onClick={handleClick}
|
||||
title={displayName}
|
||||
>
|
||||
<UnitIcon type={type} />
|
||||
{isComplete ? <CompleteIcon className="text-success ml-2" /> : null}
|
||||
<UnitIcon type={contentType} />
|
||||
{showCompletion && complete ? <CompleteIcon className="text-success ml-2" /> : null}
|
||||
{bookmarked ? (
|
||||
<BookmarkFilledIcon
|
||||
className="text-primary small position-absolute"
|
||||
style={{ top: '-3px', right: '5px' }}
|
||||
/>
|
||||
) : null}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
UnitButton.propTypes = {
|
||||
index: PropTypes.number.isRequired,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
isActive: PropTypes.bool,
|
||||
isComplete: PropTypes.bool,
|
||||
clickHandler: PropTypes.func.isRequired,
|
||||
pageTitle: PropTypes.string.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
bookmarked: PropTypes.bool,
|
||||
complete: PropTypes.bool,
|
||||
showCompletion: PropTypes.bool,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
contentType: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
UnitButton.defaultProps = {
|
||||
isActive: false,
|
||||
isComplete: false,
|
||||
bookmarked: false,
|
||||
complete: false,
|
||||
showCompletion: true,
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, props) => ({
|
||||
...state.courseBlocks.blocks[props.unitId],
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(UnitButton);
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
const getSequenceXModuleHandlerUrl = (courseUsageKey, sequenceId) => `${getConfig().LMS_BASE_URL}/courses/${courseUsageKey}/xblock/${sequenceId}/handler/xmodule_handler`;
|
||||
|
||||
|
||||
export async function saveSequencePosition(courseUsageKey, sequenceId, position) {
|
||||
// Post data sent to this endpoint must be url encoded
|
||||
// TODO: Remove the need for this to be the case.
|
||||
// TODO: Ensure this usage of URLSearchParams is working in Internet Explorer
|
||||
const urlEncoded = new URLSearchParams();
|
||||
urlEncoded.append('position', position + 1);
|
||||
const requestConfig = {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient().post(
|
||||
`${getSequenceXModuleHandlerUrl(courseUsageKey, sequenceId)}/goto_position`,
|
||||
urlEncoded.toString(),
|
||||
requestConfig,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getBlockCompletion(courseUsageKey, sequenceId, usageKey) {
|
||||
// Post data sent to this endpoint must be url encoded
|
||||
// TODO: Remove the need for this to be the case.
|
||||
// TODO: Ensure this usage of URLSearchParams is working in Internet Explorer
|
||||
const urlEncoded = new URLSearchParams();
|
||||
urlEncoded.append('usage_key', usageKey);
|
||||
const requestConfig = {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient().post(
|
||||
`${getSequenceXModuleHandlerUrl(courseUsageKey, sequenceId)}/get_completion`,
|
||||
urlEncoded.toString(),
|
||||
requestConfig,
|
||||
);
|
||||
|
||||
if (data.complete) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
54
src/courseware/sequence/bookmark/BookmarkButton.jsx
Normal file
54
src/courseware/sequence/bookmark/BookmarkButton.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { StatefulButton } from '@edx/paragon';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import BookmarkOutlineIcon from './BookmarkOutlineIcon';
|
||||
import BookmarkFilledIcon from './BookmarkFilledIcon';
|
||||
|
||||
const addBookmarkLabel = (
|
||||
<FormattedMessage
|
||||
id="unit.bookmark.button.add.bookmark"
|
||||
defaultMessage="Bookmark this page"
|
||||
description="The button to bookmark a page"
|
||||
/>
|
||||
);
|
||||
|
||||
const hasBookmarkLabel = (
|
||||
<FormattedMessage
|
||||
id="unit.bookmark.button.remove.bookmark"
|
||||
defaultMessage="Bookmarked"
|
||||
description="The button to show a page is bookmarked and the button to remove that bookmark"
|
||||
/>
|
||||
);
|
||||
|
||||
export default function BookmarkButton({ onClick, isBookmarked, isProcessing }) {
|
||||
const bookmarkState = isBookmarked ? 'bookmarked' : 'default';
|
||||
const state = isProcessing ? `${bookmarkState}Processing` : bookmarkState;
|
||||
|
||||
return (
|
||||
<StatefulButton
|
||||
className="btn-link px-1 ml-n1"
|
||||
onClick={onClick}
|
||||
state={state}
|
||||
disabledStates={['defaultProcessing', 'bookmarkedProcessing']}
|
||||
labels={{
|
||||
default: addBookmarkLabel,
|
||||
defaultProcessing: addBookmarkLabel,
|
||||
bookmarked: hasBookmarkLabel,
|
||||
bookmarkedProcessing: hasBookmarkLabel,
|
||||
}}
|
||||
icons={{
|
||||
default: <BookmarkOutlineIcon className="text-primary" />,
|
||||
defaultProcessing: <BookmarkOutlineIcon className="text-primary" />,
|
||||
bookmarked: <BookmarkFilledIcon className="text-primary" />,
|
||||
bookmarkedProcessing: <BookmarkFilledIcon className="text-primary" />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
BookmarkButton.propTypes = {
|
||||
onClick: PropTypes.func.isRequired,
|
||||
isBookmarked: PropTypes.bool.isRequired,
|
||||
isProcessing: PropTypes.bool.isRequired,
|
||||
};
|
||||
7
src/courseware/sequence/bookmark/BookmarkFilledIcon.jsx
Normal file
7
src/courseware/sequence/bookmark/BookmarkFilledIcon.jsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faBookmark } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
export default function BookmarkFilledIcon(props) {
|
||||
return <FontAwesomeIcon icon={faBookmark} {...props} />;
|
||||
}
|
||||
7
src/courseware/sequence/bookmark/BookmarkOutlineIcon.jsx
Normal file
7
src/courseware/sequence/bookmark/BookmarkOutlineIcon.jsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faBookmark } from '@fortawesome/free-regular-svg-icons';
|
||||
|
||||
export default function BookmarkOutlineIcon(props) {
|
||||
return <FontAwesomeIcon icon={faBookmark} {...props} />;
|
||||
}
|
||||
Reference in New Issue
Block a user