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,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 && (
<Course
courseOrg={metadata.org}
courseNumber={metadata.number}
courseName={metadata.name}
courseOrg={props.metadata.org}
courseNumber={props.metadata.number}
courseName={props.metadata.name}
courseUsageKey={courseUsageKey}
courseId={courseId}
sequenceId={sequenceId}
unitId={unitId}
models={models}
tabs={metadata.tabs}
tabs={props.metadata.tabs}
/>
);
}
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));

View File

@@ -92,5 +92,5 @@ Course.propTypes = {
};
Course.defaultProps = {
unitId: null,
unitId: undefined,
};

View File

@@ -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 (
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
/>
);
}
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 (
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
/>
);
}
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));

View File

@@ -1,4 +0,0 @@
iframe {
border: 0;
width: 100%;
}

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