AA-186: Refactoring to separate Course Home logic from Courseware (#93)

- Pulled Course Home specific components into `course-home`
- Created a courseHome reducer (and all necessary data files - api, thunks, slice)
- Removed Course Home logic from Courseware's data files (api, thunks, slice, etc.)
- Renamed Outline Tab URL to end in `/home` rather than `/outline` again (per Product)

Co-authored-by: Carla Duarte <cduarte@edx.org>
This commit is contained in:
Carla Duarte
2020-06-25 10:26:47 -04:00
committed by GitHub
parent 8b34f8c792
commit a6edc9132f
37 changed files with 227 additions and 194 deletions

View File

@@ -36,7 +36,6 @@ CourseTabsNavigation.propTypes = {
className: PropTypes.string,
tabs: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string.isRequired,
priority: PropTypes.number.isRequired,
slug: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
})).isRequired,

View File

@@ -0,0 +1,65 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { normalizeBlocks } from '../../data';
function normalizeCourseHomeCourseMetadata(metadata) {
const data = camelCaseObject(metadata);
return {
...data,
tabs: data.tabs.map(tab => ({
slug: tab.tabId,
title: tab.title,
url: tab.url,
})),
};
}
export async function getCourseHomeCourseMetadata(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
const { data } = await getAuthenticatedHttpClient().get(url);
return normalizeCourseHomeCourseMetadata(data);
}
export async function getDatesTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/dates/${courseId}`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
return {};
}
throw error;
}
}
export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/outline/${courseId}`;
let { tabData } = {};
try {
tabData = await getAuthenticatedHttpClient().get(url);
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
global.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/home`);
return {};
}
throw error;
}
const {
data,
} = tabData;
const courseBlocks = normalizeBlocks(courseId, data.course_blocks.blocks);
const courseTools = camelCaseObject(data.course_tools);
const datesWidget = camelCaseObject(data.dates_widget);
return { courseTools, courseBlocks, datesWidget };
}
export async function updateCourseDeadlines(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`);
await getAuthenticatedHttpClient().post(url.href, { course_key: courseId });
}

View File

@@ -0,0 +1,7 @@
export {
fetchDatesTab,
fetchOutlineTab,
resetDeadlines,
} from './thunks';
export { reducer } from './slice';

View File

@@ -0,0 +1,38 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
export const LOADING = 'loading';
export const LOADED = 'loaded';
export const FAILED = 'failed';
const slice = createSlice({
name: 'course-home',
initialState: {
courseStatus: 'loading',
courseId: null,
},
reducers: {
fetchTabRequest: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = LOADING;
},
fetchTabSuccess: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = LOADED;
},
fetchTabFailure: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = FAILED;
},
},
});
export const {
fetchTabRequest,
fetchTabSuccess,
fetchTabFailure,
} = slice.actions;
export const {
reducer,
} = slice;

View File

@@ -0,0 +1,76 @@
import { logError } from '@edx/frontend-platform/logging';
import {
getCourseHomeCourseMetadata,
getDatesTabData,
getOutlineTabData,
updateCourseDeadlines,
} from './api';
import {
addModel,
} from '../../model-store';
import {
fetchTabFailure,
fetchTabRequest,
fetchTabSuccess,
} from './slice';
export function fetchTab(courseId, tab, getTabData) {
return async (dispatch) => {
dispatch(fetchTabRequest({ courseId }));
Promise.allSettled([
getCourseHomeCourseMetadata(courseId),
getTabData(courseId),
]).then(([courseHomeCourseMetadataResult, tabDataResult]) => {
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
const fetchedTabData = tabDataResult.status === 'fulfilled';
if (fetchedCourseHomeCourseMetadata) {
dispatch(addModel({
modelType: 'courses',
model: {
id: courseId,
...courseHomeCourseMetadataResult.value,
},
}));
} else {
logError(courseHomeCourseMetadataResult.reason);
}
if (fetchedTabData) {
dispatch(addModel({
modelType: tab,
model: {
id: courseId,
...tabDataResult.value,
},
}));
} else {
logError(tabDataResult.reason);
}
if (fetchedCourseHomeCourseMetadata && fetchedTabData) {
dispatch(fetchTabSuccess({ courseId }));
} else {
dispatch(fetchTabFailure({ courseId }));
}
});
};
}
export function fetchDatesTab(courseId) {
return fetchTab(courseId, 'dates', getDatesTabData);
}
export function fetchOutlineTab(courseId) {
return fetchTab(courseId, 'outline', getOutlineTabData);
}
export function resetDeadlines(courseId, getTabData) {
return async (dispatch) => {
updateCourseDeadlines(courseId).then(() => {
dispatch(getTabData(courseId));
});
};
}

View File

@@ -21,7 +21,7 @@ function DatesBanner(props) {
{intl.formatMessage(messages[`datesBanner.${name}.body`])}
</div>
{bannerClickHandler && (
<button type="button" className="btn rounded align-self-center border border-primary bg-white mr-3" onClick={bannerClickHandler}>
<button type="button" className="btn rounded align-self-center border border-primary bg-white mr-3 font-weight-bold" onClick={bannerClickHandler}>
{intl.formatMessage(messages[`datesBanner.${name}.button`])}
</button>
)}

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useModel } from '../model-store';
import { useModel } from '../../model-store';
import DatesBanner from './DatesBanner';
import { fetchDatesTab, resetDeadlines } from '../data/thunks';
@@ -14,7 +14,7 @@ function DatesBannerContainer(props) {
const {
courseId,
} = useSelector(state => state.courseware);
} = useSelector(state => state.courseHome);
const {
datesBannerInfo,
@@ -29,7 +29,7 @@ function DatesBannerContainer(props) {
const {
isSelfPaced,
} = useModel('courseHomeMetadata', courseId);
} = useModel('courses', courseId);
const dispatch = useDispatch();
const upgradeToCompleteGraded = model === 'dates' && contentTypeGatingEnabled && !missedDeadlines;

View File

@@ -0,0 +1,3 @@
import DatesBannerContainer from './DatesBannerContainer';
export default DatesBannerContainer;

View File

@@ -58,7 +58,7 @@ const messages = defineMessages({
},
'datesBanner.resetDatesBanner.button': {
id: 'datesBanner.resetDatesBanner.button',
defaultMessage: 'Reset my deadlines',
defaultMessage: 'Shift due dates',
description: 'Button in Reset Dates Banner',
},
});

View File

@@ -8,10 +8,10 @@ import DatesBannerContainer from '../dates-banner/DatesBannerContainer';
function DatesTab({ intl }) {
return (
<>
<DatesBannerContainer model="dates" />
<h2 className="mb-4">
{intl.formatMessage(messages.title)}
</h2>
<DatesBannerContainer model="dates" />
<Timeline />
</>
);

View File

@@ -4,7 +4,7 @@ import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { FormattedDate, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useModel } from '../model-store';
import { useModel } from '../../model-store';
import { getBadgeListAndColor } from './badgelist';
import { isLearnerAssignment } from './utils';
@@ -14,7 +14,7 @@ function Day({
}) {
const {
courseId,
} = useSelector(state => state.courseware);
} = useSelector(state => state.courseHome);
const {
userTimezone,

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { useModel } from '../model-store';
import { useModel } from '../../model-store';
import Day from './Day';
import { daycmp, isLearnerAssignment } from './utils';
@@ -9,7 +9,7 @@ import { daycmp, isLearnerAssignment } from './utils';
export default function Timeline() {
const {
courseId,
} = useSelector(state => state.courseware);
} = useSelector(state => state.courseHome);
const {
courseDateBlocks,

View File

@@ -0,0 +1,3 @@
import DatesTab from './DatesTab';
export default DatesTab;

View File

@@ -1 +0,0 @@
export { default } from './outline-tab/OutlineTab';

View File

@@ -3,7 +3,7 @@ import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons';
import { FormattedDate } from '@edx/frontend-platform/i18n';
import React from 'react';
import PropTypes from 'prop-types';
import { isLearnerAssignment } from '../../dates-tab/utils';
import { isLearnerAssignment } from '../dates-tab/utils';
import './DateSummary.scss';
export default function DateSummary({

View File

@@ -4,8 +4,8 @@ import { Button } from '@edx/paragon';
import { AlertList } from '../../user-messages';
import CourseDates from './CourseDates';
import CourseTools from './CourseTools';
import CourseDates from './widgets/CourseDates';
import CourseTools from './widgets/CourseTools';
import Section from './Section';
import { useModel } from '../../model-store';
@@ -19,7 +19,7 @@ const LogistrationAlert = React.lazy(() => import('../../alerts/logistration-ale
export default function OutlineTab() {
const {
courseId,
} = useSelector(state => state.courseware);
} = useSelector(state => state.courseHome);
const {
title,

View File

@@ -0,0 +1 @@
export { default } from './OutlineTab';

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useModel } from '../../model-store';
import DateSummary from './DateSummary';
import { useModel } from '../../../model-store';
import DateSummary from '../DateSummary';
export default function CourseDates({ courseId }) {
const {

View File

@@ -5,7 +5,7 @@ import {
faBookmark, faCertificate, faInfo, faCalendar, faStar,
} from '@fortawesome/free-solid-svg-icons';
import { faNewspaper } from '@fortawesome/free-regular-svg-icons';
import { useModel } from '../../model-store';
import { useModel } from '../../../model-store';
export default function CourseTools(

View File

@@ -214,6 +214,7 @@ export default function CoursewareContainer() {
activeTabSlug="courseware"
courseId={courseId}
unitId={routeUnitId}
courseStatus={courseStatus}
>
<Course
courseId={courseId}

View File

@@ -42,48 +42,13 @@ function normalizeMetadata(metadata) {
};
}
function normalizeCourseHomeCourseMetadata(metadata) {
const data = camelCaseObject(metadata);
return {
...data,
tabs: data.tabs.map(tab => ({
slug: tab.tabId,
title: tab.title,
url: tab.url,
})),
};
}
export async function getCourseMetadata(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`;
const { data } = await getAuthenticatedHttpClient().get(url);
return normalizeMetadata(data);
}
export async function getCourseHomeCourseMetadata(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/v1/course_metadata/${courseId}`;
const { data } = await getAuthenticatedHttpClient().get(url);
return normalizeCourseHomeCourseMetadata(data);
}
export async function getDatesTabData(courseId, version) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/${version}/dates/${courseId}`;
try {
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
return window.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/dates`);
}
// async functions expect return values. to satisfy that requirement
// we return true here which in turn continues with the normal flow of displaying
// the "unexpected error try again" screen to the user.
return true;
}
}
function normalizeBlocks(courseId, blocks) {
export function normalizeBlocks(courseId, blocks) {
const models = {
courses: {},
sections: {},
@@ -170,29 +135,6 @@ export async function getCourseBlocks(courseId) {
return normalizeBlocks(courseId, data.blocks);
}
export async function getOutlineTabData(courseId, version) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/${version}/outline/${courseId}`;
let { tabData } = {};
try {
tabData = await getAuthenticatedHttpClient().get(url);
} catch (error) {
const { httpErrorStatus } = error && error.customAttributes;
if (httpErrorStatus === 404) {
return window.location.replace(`${getConfig().LMS_BASE_URL}/courses/${courseId}/course`);
}
}
const {
data,
} = tabData;
const courseBlocks = normalizeBlocks(courseId, data.course_blocks.blocks);
const courseTools = camelCaseObject(data.course_tools);
const datesWidget = camelCaseObject(data.dates_widget);
return {
courseTools, courseBlocks, datesWidget,
};
}
function normalizeSequenceMetadata(sequence) {
return {
sequence: {
@@ -250,8 +192,3 @@ export function setFirstSectionCelebrationComplete(courseId) {
first_section: false,
});
}
export async function updateCourseDeadlines(courseId) {
const url = new URL(`${getConfig().LMS_BASE_URL}/api/course_experience/v1/reset_course_deadlines`);
await getAuthenticatedHttpClient().post(url.href, { course_key: courseId });
}

View File

@@ -1,9 +1,7 @@
export {
fetchCourse,
fetchDatesTab,
fetchOutlineTab,
fetchSequence,
} from './thunks';
export { getResumeBlock } from './api';
export { getResumeBlock, normalizeBlocks } from './api';
export { reducer } from './slice';

View File

@@ -43,18 +43,6 @@ const slice = createSlice({
state.sequenceId = payload.sequenceId;
state.sequenceStatus = FAILED;
},
fetchTabRequest: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = LOADING;
},
fetchTabSuccess: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = LOADED;
},
fetchTabFailure: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = FAILED;
},
},
});
@@ -66,9 +54,6 @@ export const {
fetchSequenceRequest,
fetchSequenceSuccess,
fetchSequenceFailure,
fetchTabRequest,
fetchTabSuccess,
fetchTabFailure,
} = slice.actions;
export const {

View File

@@ -3,10 +3,6 @@ import {
getCourseMetadata,
getCourseBlocks,
getSequenceMetadata,
getDatesTabData,
getOutlineTabData,
getCourseHomeCourseMetadata,
updateCourseDeadlines,
} from './api';
import {
addModelsMap, updateModel, updateModels, updateModelsMap, addModel,
@@ -19,9 +15,6 @@ import {
fetchSequenceRequest,
fetchSequenceSuccess,
fetchSequenceFailure,
fetchTabRequest,
fetchTabSuccess,
fetchTabFailure,
} from './slice';
export function fetchCourse(courseId) {
@@ -93,73 +86,6 @@ export function fetchCourse(courseId) {
};
}
export function fetchTab(courseId, tab, version, getTabData) {
return async (dispatch) => {
dispatch(fetchTabRequest({ courseId }));
Promise.allSettled([
getCourseMetadata(courseId),
getCourseHomeCourseMetadata(courseId),
getTabData(courseId, version),
]).then(([courseMetadataResult, courseHomeCourseMetadataResult, tabDataResult]) => {
const fetchedMetadata = courseMetadataResult.status === 'fulfilled';
const fetchedCourseHomeCourseMetadata = courseHomeCourseMetadataResult.status === 'fulfilled';
const fetchedTabData = tabDataResult.status === 'fulfilled';
if (fetchedMetadata) {
/*
* NOTE: The "courses" models created by this thunk do not include an array of sectionIds.
* If that data is required for some use case, then fetchTab will need to call
* getCourseBlocks as well. See fetchCourse above.
*/
dispatch(addModel({
modelType: 'courses',
model: courseMetadataResult.value,
}));
} else {
logError(courseMetadataResult.reason);
}
if (fetchedCourseHomeCourseMetadata) {
dispatch(addModel({
modelType: 'courseHomeMetadata',
model: {
id: courseId,
...courseHomeCourseMetadataResult.value,
},
}));
} else {
logError(courseHomeCourseMetadataResult.reason);
}
if (fetchedTabData) {
dispatch(addModel({
modelType: tab,
model: {
id: courseId,
...tabDataResult.value,
},
}));
} else {
logError(tabDataResult.reason);
}
if (fetchedMetadata && fetchedCourseHomeCourseMetadata && fetchedTabData) {
dispatch(fetchTabSuccess({ courseId }));
} else {
dispatch(fetchTabFailure({ courseId }));
}
});
};
}
export function fetchDatesTab(courseId) {
return fetchTab(courseId, 'dates', 'v1', getDatesTabData);
}
export function fetchOutlineTab(courseId) {
return fetchTab(courseId, 'outline', 'v1', getOutlineTabData);
}
export function fetchSequence(sequenceId) {
return async (dispatch) => {
dispatch(fetchSequenceRequest({ sequenceId }));
@@ -180,11 +106,3 @@ export function fetchSequence(sequenceId) {
}
};
}
export function resetDeadlines(courseId, getTabData) {
return async (dispatch) => {
updateCourseDeadlines(courseId).then(() => {
dispatch(getTabData(courseId));
});
};
}

View File

@@ -1 +0,0 @@
export { default } from './DatesTab';

View File

@@ -18,14 +18,14 @@ import { UserMessagesProvider } from './user-messages';
import './index.scss';
import './assets/favicon.ico';
import OutlineTab from './course-home';
import OutlineTab from './course-home/outline-tab';
import CoursewareContainer from './courseware';
import CoursewareRedirect from './CoursewareRedirect';
import DatesTab from './dates-tab';
import DatesTab from './course-home/dates-tab';
import { TabContainer } from './tab-page';
import store from './store';
import { fetchDatesTab, fetchOutlineTab } from './data';
import { fetchDatesTab, fetchOutlineTab } from './course-home/data';
subscribe(APP_READY, () => {
ReactDOM.render(
@@ -33,7 +33,7 @@ subscribe(APP_READY, () => {
<UserMessagesProvider>
<Switch>
<Route path="/redirect" component={CoursewareRedirect} />
<Route path="/course/:courseId/outline">
<Route path="/course/:courseId/home">
<TabContainer tab="outline" fetch={fetchOutlineTab}>
<OutlineTab />
</TabContainer>

View File

@@ -311,5 +311,5 @@ $primary: #1176B2;
@import 'courseware/course/celebration/CelebrationModal.scss';
@import 'courseware/course/content-tools/calculator/calculator.scss';
@import 'courseware/course/content-tools/contentTools.scss';
@import 'dates-tab/Badge.scss';
@import 'dates-tab/Day.scss';
@import 'course-home/dates-tab/Badge.scss';
@import 'course-home/dates-tab/Day.scss';

View File

@@ -1,11 +1,13 @@
import { configureStore } from '@reduxjs/toolkit';
import { reducer as coursewareReducer } from './data';
import { reducer as modelsReducer } from './model-store';
import { reducer as courseHomeReducer } from './course-home/data';
const store = configureStore({
reducer: {
models: modelsReducer,
courseware: coursewareReducer,
courseHome: courseHomeReducer,
},
});

View File

@@ -23,12 +23,14 @@ export default function TabContainer(props) {
// we don't want the application to adjust to it until it has actually loaded the new data.
const {
courseId,
} = useSelector(state => state.courseware);
courseStatus,
} = useSelector(state => state.courseHome);
return (
<TabPage
activeTabSlug={tab}
courseId={courseId}
courseStatus={courseStatus}
>
{children}
</TabPage>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { Header } from '../course-header';
import { useLogistrationAlert } from '../alerts/logistration-alert';
@@ -11,12 +11,11 @@ import LoadedTabPage from './LoadedTabPage';
function TabPage({
intl,
courseStatus,
...passthroughProps
}) {
useLogistrationAlert();
const courseStatus = useSelector(state => state.courseware.courseStatus);
if (courseStatus === 'loading') {
return (
<>
@@ -47,6 +46,7 @@ function TabPage({
TabPage.propTypes = {
intl: intlShape.isRequired,
courseStatus: PropTypes.string.isRequired,
};
export default injectIntl(TabPage);