feat: add bookmarking for units (#11)

* feat: add bookmarking for units

* refactor: add redux for state management
This commit is contained in:
Adam Butterworth
2020-02-14 09:10:43 -07:00
committed by GitHub
parent ab3d3e8834
commit 46cd511e15
23 changed files with 916 additions and 325 deletions

View File

@@ -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);

View File

@@ -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 = {

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}

View 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,
};

View 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} />;
}

View 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} />;
}