Finish up basic dates tab support (#70)

- Drop mock data, call real API instead
- Call course metadata API for general info, not the dates API
- Mark text as translatable
- Add badges and timeline dots, group same-day items

AA-116
This commit is contained in:
Michael Terry
2020-05-26 13:15:36 -04:00
committed by GitHub
parent 7487d8d32f
commit bdb1afe990
14 changed files with 385 additions and 167 deletions

View File

@@ -3,9 +3,6 @@ import { getConfig, camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
// FIXME: remove this WIP hack once we're done developing the tab
import datesTabData from './dates';
function overrideTabUrls(id, tabs) {
// "LMS tab slug" to "MFE URL slug" for overridden tabs
const tabOverrides = {};
@@ -53,11 +50,7 @@ export async function getCourseMetadata(courseId) {
}
export async function getTabData(courseId, tab, version) {
if (tab === 'dates') {
return camelCaseObject(datesTabData());
}
const url = `${getConfig().LMS_BASE_URL}/course/${courseId}/api/course_home/${version}/${tab}`;
const url = `${getConfig().LMS_BASE_URL}/api/course_home/${version}/${tab}/${courseId}`;
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
}

View File

@@ -1,101 +0,0 @@
// Sample WIP data while we develop the dates tab
export default function datesData() {
return JSON.parse(`
{
"id": "course-v1:edX+DemoX+Demo_Course",
"isStaff": true,
"number": "DemoX",
"org": "edX",
"title": "Demonstration Course",
"tabs": [
{
"url": "/courses/course-v1:edX+DemoX+Demo_Course/course/",
"title": "Course",
"slug": "courseware",
"type": "courseware",
"priority": 0
},
{
"url": "/courses/course-v1:edX+DemoX+Demo_Course/discussion/forum/",
"title": "Discussion",
"slug": "discussion",
"type": "discussion",
"priority": 1
},
{
"url": "/courses/course-v1:edX+DemoX+Demo_Course/course_wiki",
"title": "Wiki",
"slug": "wiki",
"type": "wiki",
"priority": 2
},
{
"url": "/courses/course-v1:edX+DemoX+Demo_Course/progress",
"title": "Progress",
"slug": "progress",
"type": "progress",
"priority": 3
},
{
"url": "/courses/course-v1:edX+DemoX+Demo_Course/dates",
"title": "Dates",
"slug": "dates",
"type": "dates",
"priority": 4
},
{
"url": "/courses/course-v1:edX+DemoX+Demo_Course/instructor",
"title": "Instructor",
"slug": "instructor",
"type": "instructor",
"priority": 5
}
],
"dates": [
{
"title": "Course Starts",
"link": "/testing",
"date": "2015-02-05T05:00:00Z"
},
{
"contains_gated_content": true,
"link": "/testing",
"title": "Homework - Question Styles force publish",
"date": "2020-01-14T13:00:00Z"
},
{
"title": "New Subsection",
"date": "2020-04-20T08:30:00Z"
},
{
"title": "current_datetime",
"date": "2020-05-05T17:47:55.957725Z"
},
{
"title": "Verification Upgrade Deadline",
"date": "2020-09-16T19:05:00Z"
},
{
"title": "edX Exams",
"date": "2022-01-02T00:00:00Z"
},
{
"title": "Homework - Labs and Demos",
"date": "2022-01-14T13:00:00Z"
},
{
"title": "Homework - Essays",
"date": "2022-04-30T12:00:00Z"
},
{
"title": "Course End",
"date": "2025-02-09T00:30:00Z"
},
{
"title": "Verification Deadline",
"date": "2025-02-09T00:30:00Z"
}
]
}
`);
}

View File

@@ -34,7 +34,7 @@ export function fetchCourse(courseId) {
model: courseMetadataResult.value,
}));
dispatch(addModel({
modelType: 'pageInfo',
modelType: 'courseInfo',
model: {
id: courseMetadataResult.value.id,
isStaff: courseMetadataResult.value.isStaff,
@@ -103,29 +103,46 @@ export function fetchCourse(courseId) {
export function fetchTab(courseId, tab, version) {
return async (dispatch) => {
dispatch(fetchTabRequest({ courseId }));
getTabData(courseId, tab, version).then((result) => {
dispatch(addModel({
modelType: 'pageInfo',
model: {
id: result.id,
isStaff: result.isStaff,
number: result.number,
org: result.org,
tabs: result.tabs,
title: result.title,
},
}));
Promise.allSettled([
getCourseMetadata(courseId),
getTabData(courseId, tab, version),
]).then(([courseMetadataResult, tabDataResult]) => {
const fetchedMetadata = courseMetadataResult.status === 'fulfilled';
const fetchedTabData = tabDataResult.status === 'fulfilled';
dispatch(addModel({
modelType: tab,
model: result,
}));
if (fetchedMetadata) {
dispatch(addModel({
modelType: 'courseInfo',
model: {
id: courseMetadataResult.value.id,
isStaff: courseMetadataResult.value.isStaff,
number: courseMetadataResult.value.number,
org: courseMetadataResult.value.org,
tabs: courseMetadataResult.value.tabs,
title: courseMetadataResult.value.title,
},
}));
} else {
logError(courseMetadataResult.reason);
}
// TODO: do we need access restrictions for tabs, like we have for courseware?
dispatch(fetchTabSuccess({ courseId }));
}, (reason) => {
logError(reason);
dispatch(fetchTabFailure({ courseId }));
if (fetchedTabData) {
dispatch(addModel({
modelType: tab,
model: {
id: courseId,
...tabDataResult.value,
},
}));
} else {
logError(tabDataResult.reason);
}
if (fetchedMetadata && fetchedTabData) {
dispatch(fetchTabSuccess({ courseId }));
} else {
dispatch(fetchTabFailure({ courseId }));
}
});
};
}

23
src/dates-tab/Badge.jsx Normal file
View File

@@ -0,0 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import './Badge.scss';
export default function Badge({ children, className }) {
return (
<span className={classNames('dates-badge badge align-bottom font-italic ml-2 px-2', className)}>
{children}
</span>
);
}
Badge.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
};
Badge.defaultProps = {
children: null,
className: null,
};

4
src/dates-tab/Badge.scss Normal file
View File

@@ -0,0 +1,4 @@
.dates-badge {
font-size: 0.9rem;
line-height: 1.25;
}

View File

@@ -1,12 +1,22 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './messages';
import Timeline from './Timeline';
export default function DatesTab() {
function DatesTab({ intl }) {
return (
<>
<h2 className="mb-4">Important Dates</h2>
<h2 className="mb-4">
{intl.formatMessage(messages.title)}
</h2>
<Timeline />
</>
);
}
DatesTab.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(DatesTab);

View File

@@ -1,34 +1,162 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { FormattedDate, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faLock } from '@fortawesome/free-solid-svg-icons';
export default function Day({ dateInfo }) {
const parsedDate = new Date(dateInfo.date);
const formattedDate = parsedDate.toLocaleString('default', {
day: 'numeric',
month: 'short',
weekday: 'short',
year: 'numeric',
});
import { useModel } from '../model-store';
const hasLink = dateInfo.link && !dateInfo.containsGatedContent;
const title = hasLink ? (<u><a href={dateInfo.link} className="text-reset">{dateInfo.title}</a></u>) : dateInfo.title;
const textColor = dateInfo.containsGatedContent ? 'text-dark-200' : 'text-dark-500';
import Badge from './Badge';
import messages from './messages';
import { daycmp, isLearnerAssignment } from './utils';
import './Day.scss';
function Day({
date, first, hasDueNextAssignment, intl, items, last,
}) {
const {
courseId,
} = useSelector(state => state.courseware);
const {
userTimezone,
} = useModel('dates', courseId);
const now = new Date();
const learnerHasAccess = items.every(x => x.learnerHasAccess);
const assignments = items.filter(isLearnerAssignment);
const isComplete = assignments.length && assignments.every(x => x.complete);
const isPastDue = assignments.some(x => !x.complete && new Date(x.date) < now);
const unreleased = assignments.some(x => !x.link);
const isInFuture = daycmp(date, now) > 0;
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
// This badge info list is in order of priority (they will appear left to right in this order and the first badge
// sets the color of the dot in the timeline).
const badgesInfo = [
{
message: messages.today,
shown: daycmp(date, now) === 0,
bg: 'dates-bg-today',
},
{
message: messages.completed,
shown: isComplete,
bg: 'bg-dark-100',
},
{
message: messages.pastDue,
shown: isPastDue,
bg: 'bg-dark-200',
},
{
message: messages.dueNext,
shown: hasDueNextAssignment,
bg: 'bg-gray-500',
className: 'text-white',
},
{
message: messages.unreleased,
shown: unreleased,
className: 'border border-dark-200 text-gray-500',
},
{
message: messages.verifiedOnly,
shown: !learnerHasAccess,
icon: faLock,
bg: 'bg-dark-500',
className: 'text-white',
},
];
let dotColor = null;
const badges = (
<>
{badgesInfo.map(b => {
if (b.shown) {
if (!dotColor && !isInFuture) {
dotColor = b.bg;
}
return (
<Badge key={b.message.id} className={classNames(b.bg, b.className)}>
{b.icon && <FontAwesomeIcon icon={b.icon} className="mr-1" />}
{intl.formatMessage(b.message)}
</Badge>
);
}
return null;
})}
</>
);
if (!dotColor && isInFuture) {
dotColor = 'bg-gray-900';
}
return (
<div className="mb-4">
<h5>{formattedDate}</h5>
<div className={textColor}>
<div className="font-weight-bold">{title}</div>
<div className="dates-day pb-4">
{/* Top Line */}
{!first && <div className="dates-line-top border border-left border-gray-900" />}
{/* Dot */}
<div className={classNames(dotColor, 'dates-dot border border-gray-900')} />
{/* Bottom Line */}
{!last && <div className="dates-line-bottom border border-left border-gray-900" />}
{/* Content */}
<div className="d-inline-block ml-3 pl-3">
<div>
<h5 className="d-inline text-dark-500">
<FormattedDate
value={date}
day="numeric"
month="short"
weekday="short"
year="numeric"
{...timezoneFormatArgs}
/>
</h5>
{badges}
</div>
{items.map((item) => {
const showLink = item.link && isLearnerAssignment(item);
const title = showLink ? (<u><a href={item.link} className="text-reset">{item.title}</a></u>) : item.title;
const available = item.learnerHasAccess && (item.link || !isLearnerAssignment(item));
const textColor = available ? 'text-dark-500' : 'text-dark-200';
return (
<div key={item.title + item.date} className={textColor}>
<div className="font-weight-bold">{title}</div>
<div>{item.description}</div>
</div>
);
})}
</div>
</div>
);
}
Day.propTypes = {
dateInfo: PropTypes.shape({
containsGatedContent: PropTypes.bool,
date: PropTypes.objectOf(Date).isRequired,
first: PropTypes.bool,
hasDueNextAssignment: PropTypes.bool,
intl: intlShape.isRequired,
items: PropTypes.arrayOf(PropTypes.shape({
date: PropTypes.string,
dateType: PropTypes.string,
description: PropTypes.string,
learnerHasAccess: PropTypes.bool,
link: PropTypes.string,
title: PropTypes.string,
}).isRequired,
})).isRequired,
last: PropTypes.bool,
};
Day.defaultProps = {
first: false,
hasDueNextAssignment: false,
last: false,
};
export default injectIntl(Day);

48
src/dates-tab/Day.scss Normal file
View File

@@ -0,0 +1,48 @@
$dot-radius: 0.3rem;
$dot-size: $dot-radius * 2;
$top-offset: 0.45rem;
$left-offset: 0.2rem + $dot-radius;
.dates-day {
position: relative;
}
.dates-line-top {
display: inline-block;
position: absolute;
left: $left-offset;
top: 0;
height: $top-offset;
z-index: 0;
}
.dates-dot {
display: inline-block;
position: absolute;
border-radius: 50%;
left: $dot-radius; // save room for today's larger size
top: $top-offset;
height: $dot-size;
width: $dot-size;
z-index: 1;
&.dates-bg-today {
left: 0;
top: $top-offset - $dot-radius;
height: $dot-size * 2;
width: $dot-size * 2;
}
}
.dates-line-bottom {
display: inline-block;
position: absolute;
top: $top-offset + $dot-size;
bottom: 0;
left: $left-offset;
z-index: 0;
}
.dates-bg-today {
background: #ffdb87;
}

View File

@@ -4,6 +4,7 @@ import { useSelector } from 'react-redux';
import { useModel } from '../model-store';
import Day from './Day';
import { daycmp, isLearnerAssignment } from './utils';
export default function Timeline() {
const {
@@ -11,14 +12,61 @@ export default function Timeline() {
} = useSelector(state => state.courseware);
const {
dates,
courseDateBlocks,
} = useModel('dates', courseId);
// Group date items by day (assuming they are sorted in first place) and add some metadata
const groupedDates = [];
const now = new Date();
let foundNextDue = false;
let foundToday = false;
courseDateBlocks.forEach(dateInfo => {
const parsedDate = new Date(dateInfo.date);
let hasDueNextAssignment = false;
if (!foundNextDue && parsedDate >= now && isLearnerAssignment(dateInfo) && !dateInfo.complete) {
foundNextDue = true;
hasDueNextAssignment = true;
}
if (!foundToday) {
const compared = daycmp(parsedDate, now);
if (compared === 0) {
foundToday = true;
} else if (compared > 0) {
foundToday = true;
groupedDates.push({
date: now,
hasDueNextAssignment: false,
items: [],
});
}
}
if (groupedDates.length === 0 || daycmp(groupedDates[groupedDates.length - 1].date, parsedDate) !== 0) {
// Add new grouped date
groupedDates.push({
date: parsedDate,
hasDueNextAssignment,
items: [dateInfo],
first: groupedDates.length === 0,
});
} else {
if (hasDueNextAssignment) {
groupedDates[groupedDates.length - 1].hasNextAssigment = true;
}
groupedDates[groupedDates.length - 1].items.push(dateInfo);
}
});
if (groupedDates.length) {
groupedDates[groupedDates.length - 1].last = true;
}
return (
<div className="border-left border-gray-900 ml-2 pl-4">
{dates.map((date) => (
<Day dateInfo={date} />
<>
{groupedDates.map((groupedDate) => (
<Day key={groupedDate.date} {...groupedDate} />
))}
</div>
</>
);
}

34
src/dates-tab/messages.js Normal file
View File

@@ -0,0 +1,34 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
completed: {
id: 'learning.dates.badge.completed',
defaultMessage: 'Completed',
},
dueNext: {
id: 'learning.dates.badge.dueNext',
defaultMessage: 'Due Next',
},
pastDue: {
id: 'learning.dates.badge.pastDue',
defaultMessage: 'Past Due',
},
title: {
id: 'learning.dates.title',
defaultMessage: 'Important Dates',
},
today: {
id: 'learning.dates.badge.today',
defaultMessage: 'Today',
},
unreleased: {
id: 'learning.dates.badge.unreleased',
defaultMessage: 'Not Yet Released',
},
verifiedOnly: {
id: 'learning.dates.badge.verifiedOnly',
defaultMessage: 'Verified Only',
},
});
export default messages;

16
src/dates-tab/utils.jsx Normal file
View File

@@ -0,0 +1,16 @@
function daycmp(a, b) {
if (a.getFullYear() < b.getFullYear()) { return -1; }
if (a.getFullYear() > b.getFullYear()) { return 1; }
if (a.getMonth() < b.getMonth()) { return -1; }
if (a.getMonth() > b.getMonth()) { return 1; }
if (a.getDate() < b.getDate()) { return -1; }
if (a.getDate() > b.getDate()) { return 1; }
return 0;
}
// item is a date block returned from the API
function isLearnerAssignment(item) {
return item.learnerHasAccess && item.dateType === 'assignment-due-date';
}
export { daycmp, isLearnerAssignment };

View File

@@ -20,7 +20,7 @@ function LoadedTabPage({
org,
tabs,
title,
} = useModel('pageInfo', courseId);
} = useModel('courseInfo', courseId);
return (
<>

View File

@@ -22,7 +22,7 @@ function TabPage({
<>
<Header />
<PageLoading
srMessage={intl.formatMessage(messages['learn.loading'])}
srMessage={intl.formatMessage(messages.loading)}
/>
</>
);
@@ -39,7 +39,7 @@ function TabPage({
<>
<Header />
<p className="text-center py-5 mx-auto" style={{ maxWidth: '30em' }}>
{intl.formatMessage(messages['learn.loading.failure'])}
{intl.formatMessage(messages.failure)}
</p>
</>
);

View File

@@ -1,15 +1,13 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'learn.loading': {
id: 'learn.loading',
defaultMessage: 'Loading course page...',
description: 'Message when course page is being loaded',
},
'learn.loading.failure': {
id: 'learn.loading.failure',
failure: {
id: 'learning.loading.failure',
defaultMessage: 'There was an error loading this course.',
description: 'Message when a course page fails to load',
},
loading: {
id: 'learning.loading',
defaultMessage: 'Loading course page…',
},
});