feat: [FC-0070] Remove backend redirects (use SPA functionality) (#1372)

Introduces the ability to utilize SPA functionality when the relevant waffle flags are enabled for current MFE pages. When any new MFE page is loaded, a request is made to retrieve the waffle flags. This includes both global waffle flags related to MFE Authoring pages, as well as waffle flags specific to the current course.
This commit is contained in:
Peter Kulko
2024-11-08 13:19:23 +02:00
committed by GitHub
parent 979c69b48e
commit f9ef00e29f
47 changed files with 987 additions and 360 deletions

11
package-lock.json generated
View File

@@ -21,7 +21,7 @@
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-component-footer": "^14.1.0",
"@edx/frontend-component-header": "^5.6.0",
"@edx/frontend-component-header": "^5.7.0",
"@edx/frontend-enterprise-hotjar": "^2.0.0",
"@edx/frontend-platform": "^8.0.3",
"@edx/openedx-atlas": "^0.6.0",
@@ -2176,9 +2176,9 @@
}
},
"node_modules/@edx/frontend-component-header": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-5.6.0.tgz",
"integrity": "sha512-ITLLrej6BbWVc/0baMkKg/ACTvUGSR188Rn/BC2Y82Tdu8gRsZB6+0GUsDX/6FJjeIazLXdUusKlfwVU90sXLA==",
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-5.7.0.tgz",
"integrity": "sha512-a7ErsU0m6yaU8VGLN4JYC1Y43W5L/zSCZSmpDZw534LiPK43k4eyU8u5Ep1yxp+8sOvB49qED4huIq+1oVDJsA==",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "6.6.0",
"@fortawesome/free-brands-svg-icons": "6.6.0",
@@ -2198,7 +2198,8 @@
"@openedx/paragon": ">= 21.5.7 < 23.0.0",
"prop-types": "^15.5.10",
"react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0"
"react-dom": "^16.9.0 || ^17.0.0",
"react-router-dom": "^6.14.2"
}
},
"node_modules/@edx/frontend-component-header/node_modules/react-responsive": {

View File

@@ -50,7 +50,7 @@
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-component-footer": "^14.1.0",
"@edx/frontend-component-header": "^5.6.0",
"@edx/frontend-component-header": "^5.7.0",
"@edx/frontend-enterprise-hotjar": "^2.0.0",
"@edx/frontend-platform": "^8.0.3",
"@edx/openedx-atlas": "^0.6.0",

View File

@@ -7,7 +7,7 @@ import {
} from 'react-router-dom';
import { StudioFooter } from '@edx/frontend-component-footer';
import Header from './header';
import { fetchCourseDetail } from './data/thunks';
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
import { useModel } from './generic/model-store';
import NotFoundAlert from './generic/NotFoundAlert';
import PermissionDeniedAlert from './generic/PermissionDeniedAlert';
@@ -21,6 +21,7 @@ const CourseAuthoringPage = ({ courseId, children }) => {
useEffect(() => {
dispatch(fetchCourseDetail(courseId));
dispatch(fetchWaffleFlags(courseId));
}, [courseId]);
useEffect(() => {

View File

@@ -12,7 +12,8 @@ import CourseAuthoringPage from './CourseAuthoringPage';
import PagesAndResources from './pages-and-resources/PagesAndResources';
import { executeThunk } from './utils';
import { fetchCourseApps } from './pages-and-resources/data/thunks';
import { fetchCourseDetail } from './data/thunks';
import { fetchCourseDetail, fetchWaffleFlags } from './data/thunks';
import { getApiWaffleFlagsUrl } from './data/api';
const courseId = 'course-v1:edX+TestX+Test_Course';
let mockPathname = '/evilguy/';
@@ -25,7 +26,7 @@ jest.mock('react-router-dom', () => ({
let axiosMock;
let store;
beforeEach(() => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
@@ -36,6 +37,10 @@ beforeEach(() => {
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {});
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
});
describe('Editor Pages Load no header', () => {

View File

@@ -3,8 +3,13 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { initializeMockApp } from '@edx/frontend-platform';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
import initializeStore from './store';
import { executeThunk } from './utils';
import { getApiWaffleFlagsUrl } from './data/api';
import { fetchWaffleFlags } from './data/thunks';
const courseId = 'course-v1:edX+TestX+Test_Course';
const pagesAndResourcesMockText = 'Pages And Resources';
@@ -12,6 +17,7 @@ const editorContainerMockText = 'Editor Container';
const videoSelectorContainerMockText = 'Video Selector Container';
const customPagesMockText = 'Custom Pages';
let store;
let axiosMock;
const mockComponentFn = jest.fn();
jest.mock('react-router-dom', () => ({
@@ -50,7 +56,7 @@ jest.mock('./custom-pages/CustomPages', () => (props) => {
});
describe('<CourseAuthoringRoutes>', () => {
beforeEach(() => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
@@ -60,6 +66,11 @@ describe('<CourseAuthoringRoutes>', () => {
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {});
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
});
fit('renders the PagesAndResources component when the pages and resources route is active', () => {

View File

@@ -1,70 +1,95 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Button,
Hyperlink,
Icon,
} from '@openedx/paragon';
import { Link } from 'react-router-dom';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { ActionRow, Button, Icon } from '@openedx/paragon';
import { useSelector } from 'react-redux';
import { CheckCircle, RadioButtonUnchecked } from '@openedx/paragon/icons';
import { getConfig } from '@edx/frontend-platform';
import { getWaffleFlags } from '../../data/selectors';
import messages from './messages';
const getUpdateLinks = (courseId, waffleFlags) => {
const baseUrl = getConfig().STUDIO_BASE_URL;
const isLegacyGradingUrl = !waffleFlags.useNewGradingPage;
const isLegacyCertificateUrl = !waffleFlags.useNewCertificatesPage;
const isLegacyCourseDatesUrl = !waffleFlags.useNewScheduleDetailsPage;
const isLegacyOutlineUrl = !waffleFlags.useNewCourseOutlinePage;
return {
welcomeMessage: `/course/${courseId}/course_info`,
gradingPolicy: isLegacyGradingUrl
? `${baseUrl}/settings/grading/${courseId}` : `/course/${courseId}/settings/grading`,
certificate: isLegacyCertificateUrl
? `${baseUrl}/certificates/${courseId}` : `/course/${courseId}/certificates`,
courseDates: isLegacyCourseDatesUrl
? `${baseUrl}/settings/details/${courseId}#schedule` : `/course/${courseId}/settings/details/#schedule`,
proctoringEmail: `${baseUrl}/pages-and-resources/proctoring/settings`,
outline: isLegacyOutlineUrl ? `${baseUrl}/course/${courseId}` : `/course/${courseId}`,
};
};
const ChecklistItemBody = ({
courseId,
checkId,
isCompleted,
updateLink,
// injected
intl,
}) => (
<ActionRow>
<div className="mr-3" id={`icon-${checkId}`} data-testid={`icon-${checkId}`}>
{isCompleted ? (
<Icon
data-testid="completed-icon"
src={CheckCircle}
className="text-success"
style={{ height: '32px', width: '32px' }}
screenReaderText={intl.formatMessage(messages.completedItemLabel)}
/>
) : (
<Icon
data-testid="uncompleted-icon"
src={RadioButtonUnchecked}
style={{ height: '32px', width: '32px' }}
screenReaderText={intl.formatMessage(messages.uncompletedItemLabel)}
/>
)}
</div>
<div>
}) => {
const intl = useIntl();
const waffleFlags = useSelector(getWaffleFlags);
const updateLinks = getUpdateLinks(courseId, waffleFlags);
return (
<ActionRow>
<div className="mr-3" id={`icon-${checkId}`} data-testid={`icon-${checkId}`}>
{isCompleted ? (
<Icon
data-testid="completed-icon"
src={CheckCircle}
className="text-success"
style={{ height: '32px', width: '32px' }}
screenReaderText={intl.formatMessage(messages.completedItemLabel)}
/>
) : (
<Icon
data-testid="uncompleted-icon"
src={RadioButtonUnchecked}
style={{ height: '32px', width: '32px' }}
screenReaderText={intl.formatMessage(messages.uncompletedItemLabel)}
/>
)}
</div>
<div>
<FormattedMessage {...messages[`${checkId}ShortDescription`]} />
<div>
<FormattedMessage {...messages[`${checkId}ShortDescription`]} />
</div>
<div className="small">
<FormattedMessage {...messages[`${checkId}LongDescription`]} />
</div>
</div>
<div className="small">
<FormattedMessage {...messages[`${checkId}LongDescription`]} />
</div>
</div>
<ActionRow.Spacer />
{updateLink && (
<Hyperlink destination={updateLink} data-testid="update-hyperlink">
<Button size="sm">
<FormattedMessage {...messages.updateLinkLabel} />
</Button>
</Hyperlink>
)}
</ActionRow>
);
<ActionRow.Spacer />
{updateLinks?.[checkId] && (
<Link
to={updateLinks[checkId]}
data-testid="update-link"
>
<Button size="sm">
<FormattedMessage {...messages.updateLinkLabel} />
</Button>
</Link>
)}
</ActionRow>
);
};
ChecklistItemBody.defaultProps = {
updateLink: null,
};
ChecklistItemBody.propTypes = {
courseId: PropTypes.string.isRequired,
checkId: PropTypes.string.isRequired,
isCompleted: PropTypes.bool.isRequired,
updateLink: PropTypes.string,
// injected
intl: intlShape.isRequired,
};
export default injectIntl(ChecklistItemBody);
export default ChecklistItemBody;

View File

@@ -1,15 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { injectIntl, FormattedMessage, FormattedNumber } from '@edx/frontend-platform/i18n';
import { Hyperlink, Icon } from '@openedx/paragon';
import { Icon } from '@openedx/paragon';
import { Link } from 'react-router-dom';
import { ModeComment } from '@openedx/paragon/icons';
import { getConfig } from '@edx/frontend-platform';
import { getWaffleFlags } from '../../data/selectors';
import messages from './messages';
const ChecklistItemComment = ({
courseId,
checkId,
outlineUrl,
data,
}) => {
const waffleFlags = useSelector(getWaffleFlags);
const getPathToCourseOutlinePage = (assignmentId) => (waffleFlags.useNewCourseOutlinePage
? `/course/${courseId}#${assignmentId}` : `${getConfig().STUDIO_BASE_URL}/course/${courseId}#${assignmentId}`);
const commentWrapper = (comment) => (
<div className="row m-0 mt-3 pt-3 border-top align-items-center" data-identifier="comment">
<div className="mr-4">
@@ -79,9 +87,9 @@ const ChecklistItemComment = ({
<ul className="assignment-list">
{gradedAssignmentsOutsideDateRange.map(assignment => (
<li className="assignment-list-item" key={assignment.id}>
<Hyperlink destination={`${outlineUrl}#${assignment.id}`}>
<Link to={getPathToCourseOutlinePage(assignment.id)}>
{assignment.displayName}
</Hyperlink>
</Link>
</li>
))}
</ul>
@@ -96,6 +104,7 @@ const ChecklistItemComment = ({
};
ChecklistItemComment.propTypes = {
courseId: PropTypes.string.isRequired,
checkId: PropTypes.string.isRequired,
outlineUrl: PropTypes.string.isRequired,
data: PropTypes.oneOfType([

View File

@@ -10,11 +10,11 @@ import ChecklistItemComment from './ChecklistItemComment';
import { checklistItems } from './utils/courseChecklistData';
const ChecklistSection = ({
courseId,
dataHeading,
data,
idPrefix,
isLoading,
updateLinks,
}) => {
const dataList = checklistItems[idPrefix];
const getCompletionCountID = () => (`${idPrefix}-completion-count`);
@@ -37,8 +37,6 @@ const ChecklistSection = ({
{checks.map(check => {
const checkId = check.id;
const isCompleted = values[checkId];
const updateLink = updateLinks?.[checkId];
const outlineUrl = updateLinks.outline;
return (
<div
className={`bg-white border py-3 px-4 ${isCompleted && 'checklist-item-complete'}`}
@@ -46,9 +44,9 @@ const ChecklistSection = ({
data-testid={`checklist-item-${checkId}`}
key={checkId}
>
<ChecklistItemBody {...{ checkId, isCompleted, updateLink }} />
<ChecklistItemBody courseId={courseId} {...{ checkId, isCompleted }} />
<div data-testid={`comment-section-${checkId}`}>
<ChecklistItemComment {...{ checkId, outlineUrl, data }} />
<ChecklistItemComment {...{ courseId, checkId, data }} />
</div>
</div>
);
@@ -61,11 +59,11 @@ const ChecklistSection = ({
};
ChecklistSection.defaultProps = {
updateLinks: {},
data: {},
};
ChecklistSection.propTypes = {
courseId: PropTypes.string.isRequired,
dataHeading: PropTypes.string.isRequired,
data: PropTypes.oneOfType([
PropTypes.shape({
@@ -129,14 +127,6 @@ ChecklistSection.propTypes = {
]),
idPrefix: PropTypes.string.isRequired,
isLoading: PropTypes.bool.isRequired,
updateLinks: PropTypes.shape({
welcomeMessage: PropTypes.string,
gradingPolicy: PropTypes.string,
certificate: PropTypes.string,
courseDates: PropTypes.string,
proctoringEmail: PropTypes.string,
outline: PropTypes.string,
}),
};
export default injectIntl(ChecklistSection);

View File

@@ -1,59 +1,49 @@
/* eslint-disable */
import {
render,
within,
screen,
} from '@testing-library/react';
import { within, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { camelCaseObject } from '@edx/frontend-platform';
import initializeStore from '../../store';
import { initialState,generateCourseLaunchData } from '../factories/mockApiResponses';
import messages from './messages';
import ChecklistSection from './index';
import { initializeMocks, render } from '../../testUtils';
import { getApiWaffleFlagsUrl } from '../../data/api';
import { fetchWaffleFlags } from '../../data/thunks';
import { generateCourseLaunchData } from '../factories/mockApiResponses';
import { executeThunk } from '../../utils';
import { checklistItems } from './utils/courseChecklistData';
import getUpdateLinks from '../utils';
import messages from './messages';
import ChecklistSection from '.';
const testData = camelCaseObject(generateCourseLaunchData());
const courseId = '123';
const defaultProps = {
courseId,
data: testData,
dataHeading: 'Test checklist',
idPrefix: 'launchChecklist',
updateLinks: getUpdateLinks('courseId'),
isLoading: false,
};
const testChecklistData = checklistItems[defaultProps.idPrefix];
const completedItemIds = ['welcomeMessage', 'courseDates']
const completedItemIds = ['welcomeMessage', 'courseDates'];
const renderComponent = (props) => {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<ChecklistSection {...props} />
</AppProvider>
</IntlProvider>,
);
render(<ChecklistSection {...props} />);
};
let store;
describe('ChecklistSection', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
beforeEach(async () => {
const { axiosMock, reduxStore } = initializeMocks();
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {
useNewGradingPage: true,
useNewCertificatesPage: true,
useNewScheduleDetailsPage: true,
useNewCourseOutlinePage: true,
});
await executeThunk(fetchWaffleFlags(courseId), reduxStore.dispatch);
});
it('a heading using the dataHeading prop', () => {
@@ -64,6 +54,7 @@ describe('ChecklistSection', () => {
it('completion count text', () => {
renderComponent(defaultProps);
const completionText = `${completedItemIds.length}/6 completed`;
expect(screen.getByTestId('completion-subheader').textContent).toEqual(completionText);
});
@@ -122,7 +113,7 @@ describe('ChecklistSection', () => {
grades: {
...defaultProps.data.grades,
sumOfWeights: 1,
}
},
},
};
renderComponent(props);
@@ -154,7 +145,7 @@ describe('ChecklistSection', () => {
...defaultProps.data.assignments,
assignmentsWithDatesAfterEnd: [],
assignmentsWithOraDatesBeforeStart: [],
}
},
},
};
renderComponent(props);
@@ -183,73 +174,52 @@ describe('ChecklistSection', () => {
expect(assigmentLinks[1].textContent).toEqual('ORA subsection');
});
});
});
testChecklistData.forEach((check) => {
describe(`check with id '${check.id}'`, () => {
let checkItem;
describe('Checklist Component', () => {
let checklistData;
let updateLinks;
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore(initialState);
renderComponent(defaultProps);
checkItem = screen.getAllByTestId(`checklist-item-${check.id}`);
checklistData = testChecklistData.map((item) => ({
itemId: item.id,
checklistItem: screen.getAllByTestId(`checklist-item-${item.id}`),
icon: screen.getAllByTestId(`icon-${item.id}`),
shortDescription: messages[`${item.id}ShortDescription`].defaultMessage,
longDescription: messages[`${item.id}LongDescription`].defaultMessage,
}));
updateLinks = screen.getAllByTestId('update-link');
});
it('renders', () => {
expect(checkItem).toHaveLength(1);
it('should display the correct icons based on completion status', () => {
checklistData.forEach(({ itemId, icon }) => {
const { queryByTestId } = within(icon[0]);
if (completedItemIds.includes(itemId)) {
expect(queryByTestId('completed-icon')).not.toBeNull();
} else {
expect(queryByTestId('uncompleted-icon')).not.toBeNull();
}
});
});
it('has correct icon', () => {
const icon = screen.getAllByTestId(`icon-${check.id}`)
it('should display short and long descriptions for each checklist item', () => {
checklistData.forEach(({ checklistItem, shortDescription, longDescription }) => {
const { getByText } = within(checklistItem[0]);
expect(icon).toHaveLength(1);
const { queryByTestId } = within(icon[0]);
if (completedItemIds.includes(check.id)) {
expect(queryByTestId('completed-icon')).not.toBeNull();
} else {
expect(queryByTestId('uncompleted-icon')).not.toBeNull();
}
expect(getByText(shortDescription)).toBeVisible();
expect(getByText(longDescription)).toBeVisible();
});
});
it('has correct short description', () => {
const { getByText } = within(checkItem[0]);
const shortDescription = messages[`${check.id}ShortDescription`].defaultMessage;
expect(getByText(shortDescription)).toBeVisible();
});
it('has correct long description', () => {
const { getByText } = within(checkItem[0]);
const longDescription = messages[`${check.id}LongDescription`].defaultMessage;
expect(getByText(longDescription)).toBeVisible();
});
describe('has correct link', () => {
const links = getUpdateLinks('courseId')
const shouldShowLink = Object.keys(links).includes(check.id);
if (shouldShowLink) {
it('with a Hyperlink', () => {
const { getByRole, getByText } = within(checkItem[0]);
expect(getByText('Update')).toBeVisible();
expect(getByRole('link').href).toMatch(links[check.id]);
it('should have valid update links for each checklist item', () => {
checklistData.forEach(({ itemId }) => {
updateLinks.forEach((link) => {
expect(link).toHaveAttribute('href', updateLinks[itemId]);
});
} else {
it('without a Hyperlink', () => {
const { queryByText } = within(checkItem[0]);
expect(queryByText('Update')).toBeNull();
});
}
});
});
});
});

View File

@@ -13,7 +13,6 @@ import AriaLiveRegion from './AriaLiveRegion';
import { RequestStatus } from '../data/constants';
import ChecklistSection from './ChecklistSection';
import { fetchCourseLaunchQuery, fetchCourseBestPracticesQuery } from './data/thunks';
import getUpdateLinks from './utils';
const CourseChecklist = ({
courseId,
@@ -23,7 +22,6 @@ const CourseChecklist = ({
const dispatch = useDispatch();
const courseDetails = useModel('courseDetails', courseId);
const enableQuality = getConfig().ENABLE_CHECKLIST_QUALITY === 'true';
const updateLinks = getUpdateLinks(courseId);
useEffect(() => {
dispatch(fetchCourseLaunchQuery({ courseId }));
@@ -66,19 +64,19 @@ const CourseChecklist = ({
/>
<Stack gap={4}>
<ChecklistSection
courseId={courseId}
dataHeading={intl.formatMessage(messages.launchChecklistLabel)}
data={launchData}
idPrefix="launchChecklist"
isLoading={isCourseLaunchChecklistLoading}
updateLinks={updateLinks}
/>
{enableQuality && (
<ChecklistSection
courseId={courseId}
dataHeading={intl.formatMessage(messages.bestPracticesChecklistLabel)}
data={bestPracticeData}
idPrefix="bestPracticesChecklist"
isLoading={isCourseBestPracticeChecklistLoading}
updateLinks={updateLinks}
/>
)}
</Stack>

View File

@@ -1,12 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
const getUpdateLinks = (courseId) => ({
welcomeMessage: `${getConfig().STUDIO_BASE_URL}/course_info/${courseId}`,
gradingPolicy: `${getConfig().STUDIO_BASE_URL}/settings/grading/${courseId}`,
certificate: `${getConfig().STUDIO_BASE_URL}/certificates/${courseId}`,
courseDates: `${getConfig().STUDIO_BASE_URL}/settings/details/${courseId}#schedule`,
proctoringEmail: 'pages-and-resources/proctoring/settings',
outline: `${getConfig().STUDIO_BASE_URL}/course/${courseId}`,
});
export default getUpdateLinks;

View File

@@ -6,6 +6,7 @@ import { getConfig } from '@edx/frontend-platform';
import { copyToClipboard } from '../generic/data/thunks';
import { getSavingStatus as getGenericSavingStatus } from '../generic/data/selectors';
import { getWaffleFlags } from '../data/selectors';
import { RequestStatus } from '../data/constants';
import { COURSE_BLOCK_NAMES } from './constants';
import {
@@ -58,6 +59,7 @@ import {
const useCourseOutline = ({ courseId }) => {
const dispatch = useDispatch();
const navigate = useNavigate();
const waffleFlags = useSelector(getWaffleFlags);
const {
reindexLink,
@@ -112,7 +114,7 @@ const useCourseOutline = ({ courseId }) => {
};
const getUnitUrl = (locator) => {
if (getConfig().ENABLE_UNIT_PAGE === 'true') {
if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) {
return `/course/${courseId}/container/${locator}`;
}
return `${getConfig().STUDIO_BASE_URL}/container/${locator}`;
@@ -120,7 +122,7 @@ const useCourseOutline = ({ courseId }) => {
const openUnitPage = (locator) => {
const url = getUnitUrl(locator);
if (getConfig().ENABLE_UNIT_PAGE === 'true') {
if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) {
navigate(url);
} else {
window.location.assign(url);

View File

@@ -1,4 +1,4 @@
import React, { useContext } from 'react';
import { useContext } from 'react';
import moment from 'moment/moment';
import PropTypes from 'prop-types';
import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n';
@@ -6,11 +6,14 @@ import { getConfig } from '@edx/frontend-platform/config';
import {
Button, Hyperlink, Form, Stack, useToggle,
} from '@openedx/paragon';
import { Link } from 'react-router-dom';
import { AppContext } from '@edx/frontend-platform/react';
import { useSelector } from 'react-redux';
import { ContentTagsDrawerSheet } from '../../content-tags-drawer';
import TagCount from '../../generic/tag-count';
import { useHelpUrls } from '../../help-urls/hooks';
import { getWaffleFlags } from '../../data/selectors';
import { VIDEO_SHARING_OPTIONS } from '../constants';
import { useContentTagsCount } from '../../generic/data/apiHooks';
import messages from './messages';
@@ -43,6 +46,7 @@ const StatusBar = ({
}) => {
const intl = useIntl();
const { config } = useContext(AppContext);
const waffleFlags = useSelector(getWaffleFlags);
const {
courseReleaseDate,
@@ -62,7 +66,6 @@ const StatusBar = ({
const courseReleaseDateObj = moment.utc(courseReleaseDate, 'MMM DD, YYYY at HH:mm UTC', true);
const checkListTitle = `${completedCourseLaunchChecks + completedCourseBestPracticesChecks}/${totalCourseLaunchChecks + totalCourseBestPracticesChecks}`;
const checklistDestination = () => new URL(`checklists/${courseId}`, config.STUDIO_BASE_URL).href;
const scheduleDestination = () => new URL(`settings/details/${courseId}#schedule`, config.STUDIO_BASE_URL).href;
const {
@@ -82,10 +85,9 @@ const StatusBar = ({
<>
<Stack direction="horizontal" gap={3.5} className="d-flex align-items-stretch outline-status-bar" data-testid="outline-status-bar">
<StatusBarItem title={intl.formatMessage(messages.startDateTitle)}>
<Hyperlink
<Link
className="small"
destination={scheduleDestination()}
showLaunchIcon={false}
to={waffleFlags.useNewScheduleDetailsPage ? `/course/${courseId}/settings/details/#schedule` : scheduleDestination()}
>
{courseReleaseDateObj.isValid() ? (
<FormattedDate
@@ -97,7 +99,7 @@ const StatusBar = ({
minute="numeric"
/>
) : courseReleaseDate}
</Hyperlink>
</Link>
</StatusBarItem>
<StatusBarItem title={intl.formatMessage(messages.pacingTypeTitle)}>
<span className="small">
@@ -107,13 +109,12 @@ const StatusBar = ({
</span>
</StatusBarItem>
<StatusBarItem title={intl.formatMessage(messages.checklistTitle)}>
<Hyperlink
<Link
className="small"
destination={checklistDestination()}
showLaunchIcon={false}
to={`/course/${courseId}/checklists`}
>
{checkListTitle} {intl.formatMessage(messages.checklistCompleted)}
</Hyperlink>
</Link>
</StatusBarItem>
<StatusBarItem title={intl.formatMessage(messages.highlightEmailsTitle)}>
<div className="d-flex align-items-center">

View File

@@ -1,12 +1,14 @@
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Dropdown, Icon } from '@openedx/paragon';
import { Link } from 'react-router-dom';
import {
ArrowDropDown as ArrowDropDownIcon,
ChevronRight as ChevronRightIcon,
} from '@openedx/paragon/icons';
import { getConfig } from '@edx/frontend-platform';
import { createCorrectInternalRoute } from '../../utils';
import { getWaffleFlags } from '../../data/selectors';
import { getCourseSectionVertical } from '../data/selectors';
import messages from './messages';
@@ -14,6 +16,10 @@ const Breadcrumbs = () => {
const intl = useIntl();
const { ancestorXblocks } = useSelector(getCourseSectionVertical);
const [section, subsection] = ancestorXblocks ?? [];
const waffleFlags = useSelector(getWaffleFlags);
const getPathToCourseOutlinePage = (url) => (waffleFlags.useNewCourseOutlinePage
? url : `${getConfig().STUDIO_BASE_URL}${url}`);
return (
<nav className="d-flex align-center mb-2.5">
@@ -30,8 +36,9 @@ const Breadcrumbs = () => {
<Dropdown.Menu>
{section.children.map(({ url, displayName }) => (
<Dropdown.Item
as={Link}
key={url}
href={createCorrectInternalRoute(url)}
to={getPathToCourseOutlinePage(url)}
className="small"
data-testid="breadcrumbs-section-dropdown-item"
>
@@ -59,8 +66,9 @@ const Breadcrumbs = () => {
<Dropdown.Menu>
{subsection.children.map(({ url, displayName }) => (
<Dropdown.Item
as={Link}
key={url}
href={createCorrectInternalRoute(url)}
to={getPathToCourseOutlinePage(url)}
className="small"
data-testid="breadcrumbs-subsection-dropdown-item"
>

View File

@@ -1,21 +1,21 @@
import MockAdapter from 'axios-mock-adapter';
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getConfig } from '@edx/frontend-platform';
import {
initializeMocks, waitFor, act, render,
} from '../../testUtils';
import initializeStore from '../../store';
import { executeThunk } from '../../utils';
import { getCourseSectionVerticalApiUrl, getCourseUnitApiUrl } from '../data/api';
import { getApiWaffleFlagsUrl } from '../../data/api';
import { fetchWaffleFlags } from '../../data/thunks';
import { fetchCourseSectionVerticalData, fetchCourseUnitQuery } from '../data/thunk';
import { courseSectionVerticalMock, courseUnitIndexMock } from '../__mocks__';
import Breadcrumbs from './Breadcrumbs';
let axiosMock;
let store;
let reduxStore;
const courseId = '123';
const mockNavigate = jest.fn();
const breadcrumbsExpected = {
section: {
id: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@interactive_demonstrations',
@@ -26,35 +26,38 @@ const breadcrumbsExpected = {
},
};
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
const renderComponent = () => render(
<AppProvider store={store}>
<IntlProvider locale="en">
<Breadcrumbs courseId={courseId} />
</IntlProvider>
</AppProvider>,
<Breadcrumbs courseId={courseId} />,
);
describe('<Breadcrumbs />', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
reduxStore = mocks.reduxStore;
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getCourseUnitApiUrl(courseId))
.reply(200, courseUnitIndexMock);
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseUnitQuery(courseId), reduxStore.dispatch);
axiosMock
.onGet(getCourseSectionVerticalApiUrl(courseId))
.reply(200, courseSectionVerticalMock);
await executeThunk(fetchCourseSectionVerticalData(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(courseId), reduxStore.dispatch);
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, { useNewCourseOutlinePage: true });
await executeThunk(fetchWaffleFlags(courseId), reduxStore.dispatch);
});
afterEach(() => {
jest.clearAllMocks();
axiosMock.restore();
});
it('render Breadcrumbs component correctly', async () => {
@@ -83,4 +86,38 @@ describe('<Breadcrumbs />', () => {
userEvent.click(getByText(breadcrumbsExpected.subsection.displayName));
expect(queryAllByTestId('breadcrumbs-subsection-dropdown-item')).toHaveLength(2);
});
it('navigates using the new course outline page when the waffle flag is enabled', async () => {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { ancestor_xblocks: [{ children: [{ display_name, url }] }] } = courseSectionVerticalMock;
const { getByText, getByRole } = renderComponent();
await act(async () => {
const dropdownBtn = getByText(breadcrumbsExpected.section.displayName);
userEvent.click(dropdownBtn);
});
await act(async () => {
const dropdownItem = getByRole('link', { name: display_name });
userEvent.click(dropdownItem);
expect(dropdownItem).toHaveAttribute('href', url);
});
});
it('falls back to window.location.href when the waffle flag is disabled', async () => {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { ancestor_xblocks: [{ children: [{ display_name, url }] }] } = courseSectionVerticalMock;
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, { useNewCourseOutlinePage: false });
await executeThunk(fetchWaffleFlags(courseId), reduxStore.dispatch);
const { getByText, getByRole } = renderComponent();
const dropdownBtn = getByText(breadcrumbsExpected.section.displayName);
userEvent.click(dropdownBtn);
const dropdownItem = getByRole('link', { name: display_name });
expect(dropdownItem.href).toBe(`${getConfig().STUDIO_BASE_URL}${url}`);
});
});

View File

@@ -1,6 +1,8 @@
import React, { useEffect, useContext, useState } from 'react';
import { useEffect, useContext, useState } from 'react';
import PropTypes from 'prop-types';
import { Routes, Route, useNavigate } from 'react-router-dom';
import {
Routes, Route, useNavigate, Link,
} from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { AppContext, PageWrap } from '@edx/frontend-platform/react';
import { injectIntl, FormattedMessage, intlShape } from '@edx/frontend-platform/i18n';
@@ -37,6 +39,7 @@ import CustomPageCard from './CustomPageCard';
import messages from './messages';
import CustomPagesProvider from './CustomPagesProvider';
import EditModal from './EditModal';
import { getWaffleFlags } from '../data/selectors';
import getPageHeadTitle from '../generic/utils';
import { getPagePath } from '../utils';
@@ -66,6 +69,7 @@ const CustomPages = ({
const deletePageStatus = useSelector(state => state.customPages.deletingStatus);
const savingStatus = useSelector(getSavingStatus);
const loadingStatus = useSelector(getLoadingStatus);
const waffleFlags = useSelector(getWaffleFlags);
const pages = useModels('customPages', customPagesIds);
@@ -115,9 +119,13 @@ const CustomPages = ({
<div className="small gray-700">
<Breadcrumb
ariaLabel="Custom Page breadcrumbs"
linkAs={Link}
links={[
{ label: 'Content', href: `${config.STUDIO_BASE_URL}/course/${courseId}` },
{ label: 'Pages and Resources', href: getPagePath(courseId, 'true', 'tabs') },
{
label: 'Content',
to: waffleFlags.useNewCourseOutlinePage ? `/course/${courseId}` : `${config.STUDIO_BASE_URL}/course/${courseId}`,
},
{ label: 'Pages and Resources', to: getPagePath(courseId, 'true', 'tabs') },
]}
/>
</div>

View File

@@ -15,6 +15,8 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from '../store';
import { executeThunk } from '../utils';
import { RequestStatus } from '../data/constants';
import { getApiWaffleFlagsUrl } from '../data/api';
import { fetchWaffleFlags } from '../data/thunks';
import CustomPages from './CustomPages';
import {
generateFetchPageApiResponse,
@@ -72,6 +74,15 @@ describe('CustomPages', () => {
});
store = initializeStore(initialState);
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {
useNewGradingPage: true,
useNewCertificatesPage: true,
useNewScheduleDetailsPage: true,
useNewCourseOutlinePage: true,
});
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
});
it('should ', async () => {
renderComponent();

View File

@@ -1,7 +1,15 @@
/* eslint-disable import/prefer-default-export */
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getApiWaffleFlagsUrl = (courseId) => {
const baseUrl = getStudioBaseUrl();
const apiPath = '/api/contentstore/v1/course_waffle_flags';
return courseId ? `${baseUrl}${apiPath}/${courseId}` : `${baseUrl}${apiPath}`;
};
function normalizeCourseDetail(data) {
return {
id: data.course_id,
@@ -15,3 +23,10 @@ export async function getCourseDetail(courseId, username) {
return normalizeCourseDetail(data);
}
export async function getWaffleFlags(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(getApiWaffleFlagsUrl(courseId));
return normalizeCourseDetail(data);
}

2
src/data/selectors.js Normal file
View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export const getWaffleFlags = (state) => state.courseDetail?.waffleFlags;

View File

@@ -9,6 +9,24 @@ const slice = createSlice({
courseId: null,
status: null,
canChangeProvider: null,
waffleFlags: {
useNewHomePage: true,
useNewCustomPages: true,
useNewScheduleDetailsPage: true,
useNewAdvancedSettingsPage: true,
useNewGradingPage: true,
useNewUpdatesPage: true,
useNewImportPage: false,
useNewExportPage: true,
useNewFilesUploadsPage: true,
useNewVideoUploadsPage: true,
useNewCourseOutlinePage: true,
useNewUnitPage: false,
useNewCourseTeamPage: true,
useNewCertificatesPage: true,
useNewTextbooksPage: true,
useNewGroupConfigurationsPage: true,
},
},
reducers: {
updateStatus: (state, { payload }) => {
@@ -18,12 +36,16 @@ const slice = createSlice({
updateCanChangeProviders: (state, { payload }) => {
state.canChangeProviders = payload.canChangeProviders;
},
fetchWaffleFlagsSuccess: (state, { payload }) => {
state.waffleFlags = payload.waffleFlags;
},
},
});
export const {
updateStatus,
updateCanChangeProviders,
fetchWaffleFlagsSuccess,
} = slice.actions;
export const {

View File

@@ -1,13 +1,13 @@
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { addModel } from '../generic/model-store';
import { getCourseDetail } from './api';
import { getCourseDetail, getWaffleFlags } from './api';
import {
updateStatus,
updateCanChangeProviders,
fetchWaffleFlagsSuccess,
} from './slice';
import { RequestStatus } from './constants';
/* eslint-disable import/prefer-default-export */
export function fetchCourseDetail(courseId) {
return async (dispatch) => {
dispatch(updateStatus({ courseId, status: RequestStatus.IN_PROGRESS }));
@@ -29,3 +29,13 @@ export function fetchCourseDetail(courseId) {
}
};
}
export function fetchWaffleFlags(courseId) {
return async (dispatch) => {
dispatch(updateStatus({ courseId, status: RequestStatus.IN_PROGRESS }));
const waffleFlags = await getWaffleFlags(courseId);
dispatch(updateStatus({ courseId, status: RequestStatus.SUCCESSFUL }));
dispatch(fetchWaffleFlagsSuccess({ waffleFlags }));
};
}

View File

@@ -1,10 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import classNames from 'classnames';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { getWaffleFlags } from '../../data/selectors';
import { otherLinkURLParams } from './constants';
import messages from './messages';
import HelpSidebarLink from './HelpSidebarLink';
@@ -25,6 +26,7 @@ const HelpSidebar = ({
scheduleAndDetails,
groupConfigurations,
} = otherLinkURLParams;
const waffleFlags = useSelector(getWaffleFlags);
const showOtherLink = (params) => !pathname.includes(params);
const generateLegacyURL = (urlParameter) => {
@@ -55,36 +57,46 @@ const HelpSidebar = ({
<ul className="p-0 mb-0">
{showOtherLink(scheduleAndDetails) && (
<HelpSidebarLink
pathToPage={scheduleAndDetailsDestination}
pathToPage={waffleFlags.useNewScheduleDetailsPage
? `/course/${courseId}/${scheduleAndDetails}` : scheduleAndDetailsDestination}
title={intl.formatMessage(
messages.sidebarLinkToScheduleAndDetails,
)}
isNewPage={waffleFlags.useNewScheduleDetailsPage}
/>
)}
{showOtherLink(grading) && (
<HelpSidebarLink
pathToPage={gradingDestination}
pathToPage={waffleFlags.useNewGradingPage
? `/course/${courseId}/${grading}` : gradingDestination}
title={intl.formatMessage(messages.sidebarLinkToGrading)}
isNewPage={waffleFlags.useNewGradingPage}
/>
)}
{showOtherLink(courseTeam) && (
<HelpSidebarLink
pathToPage={courseTeamDestination}
pathToPage={waffleFlags.useNewCourseTeamPage
? `/course/${courseId}/${courseTeam}` : courseTeamDestination}
title={intl.formatMessage(messages.sidebarLinkToCourseTeam)}
isNewPage={waffleFlags.useNewCourseTeamPage}
/>
)}
{showOtherLink(groupConfigurations) && (
<HelpSidebarLink
pathToPage={groupConfigurationsDestination}
pathToPage={waffleFlags.useNewGroupConfigurationsPage
? `/course/${courseId}/${groupConfigurations}` : groupConfigurationsDestination}
title={intl.formatMessage(
messages.sidebarLinkToGroupConfigurations,
)}
isNewPage={waffleFlags.useNewGroupConfigurationsPage}
/>
)}
{showOtherLink(advancedSettings) && (
<HelpSidebarLink
pathToPage={advancedSettingsDestination}
pathToPage={waffleFlags.useNewAdvancedSettingsPage
? `/course/${courseId}/${advancedSettings}` : advancedSettingsDestination}
title={intl.formatMessage(messages.sidebarLinkToAdvancedSettings)}
isNewPage={waffleFlags.useNewAdvancedSettingsPage}
/>
)}
{proctoredExamSettingsUrl && (

View File

@@ -1,9 +1,21 @@
import React from 'react';
import { Link } from 'react-router-dom';
import PropTypes from 'prop-types';
import { Hyperlink } from '@openedx/paragon';
const HelpSidebarLink = ({ as, pathToPage, title }) => {
const HelpSidebarLink = ({
as, pathToPage, title, isNewPage,
}) => {
const TagElement = as;
if (isNewPage) {
return (
<TagElement className="sidebar-link">
<Link to={pathToPage}>
{title}
</Link>
</TagElement>
);
}
return (
<TagElement className="sidebar-link">
<Hyperlink
@@ -18,6 +30,7 @@ const HelpSidebarLink = ({ as, pathToPage, title }) => {
};
HelpSidebarLink.propTypes = {
isNewPage: PropTypes.bool,
pathToPage: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
as: PropTypes.string,
@@ -25,6 +38,7 @@ HelpSidebarLink.propTypes = {
HelpSidebarLink.defaultProps = {
as: 'li',
isNewPage: true,
};
export default HelpSidebarLink;

View File

@@ -0,0 +1,46 @@
import { render } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { BrowserRouter as Router } from 'react-router-dom';
import HelpSidebarLink from './HelpSidebarLink';
describe('HelpSidebarLink Component', () => {
const defaultProps = {
isNewPage: true,
pathToPage: '/test-page',
title: 'Test Title',
as: 'li',
};
it('renders a React Router Link when isNewPage is true', () => {
const { getByText } = render(
<Router>
<HelpSidebarLink {...defaultProps} />
</Router>,
);
const linkElement = getByText('Test Title');
expect(linkElement.closest('a')).toHaveAttribute('href', '/test-page');
});
it('renders a Hyperlink when isNewPage is false', () => {
const props = { ...defaultProps, isNewPage: false, pathToPage: 'https://example.com' };
const { getByText } = render(<HelpSidebarLink {...props} />);
const hyperlinkElement = getByText('Test Title');
expect(hyperlinkElement.closest('a')).toHaveAttribute('href', 'https://example.com');
expect(hyperlinkElement.closest('a')).toHaveAttribute('target', '_blank');
});
it('renders the correct tag element specified by "as" prop', () => {
const props = { ...defaultProps, as: 'div' };
const { container } = render(
<Router>
<HelpSidebarLink {...props} />
</Router>,
);
const tagElement = container.querySelector('div.sidebar-link');
expect(tagElement).toBeInTheDocument();
});
});

View File

@@ -1,31 +1,20 @@
import React from 'react';
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
import { render } from '@testing-library/react';
import { injectIntl } from '@edx/frontend-platform/i18n';
import {
initializeMocks, render, screen,
} from '../../testUtils';
import messages from './messages';
import GradingSidebar from '.';
const mockPathname = '/foo-bar';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
const RootWrapper = () => (
<IntlProvider locale="en" messages={{}}>
<GradingSidebar intl={injectIntl} courseId="123" />
</IntlProvider>
);
describe('<GradingSidebar />', () => {
it('renders sidebar text content correctly', () => {
const { getByText } = render(<RootWrapper />);
expect(getByText(messages.gradingSidebarTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.gradingSidebarAbout1.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.gradingSidebarAbout2.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.gradingSidebarAbout3.defaultMessage)).toBeInTheDocument();
beforeEach(async () => {
initializeMocks();
});
it('renders sidebar text content correctly', async () => {
render(<GradingSidebar intl={injectIntl} courseId="123" />);
expect(await screen.findByText(messages.gradingSidebarTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.gradingSidebarAbout1.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.gradingSidebarAbout2.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(messages.gradingSidebarAbout3.defaultMessage)).toBeInTheDocument();
});
});

View File

@@ -8,12 +8,20 @@ import ExperimentConfigurationsSection from '.';
const handleCreateMock = jest.fn();
const handleDeleteMock = jest.fn();
const handleEditMock = jest.fn();
const mockPathname = '/foo-bar';
const experimentConfigurationActions = {
handleCreate: handleCreateMock,
handleDelete: handleDeleteMock,
handleEdit: handleEditMock,
};
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
pathname: mockPathname,
}),
}));
const renderComponent = (props) => render(
<IntlProvider locale="en">
<ExperimentConfigurationsSection

View File

@@ -11,6 +11,7 @@ import { initialExperimentConfiguration } from './constants';
import messages from './messages';
const ExperimentConfigurationsSection = ({
courseId,
availableGroups,
experimentConfigurationActions,
}) => {
@@ -25,7 +26,7 @@ const ExperimentConfigurationsSection = ({
experimentConfigurationActions.handleCreate(configuration, hideNewConfiguration);
};
const { elementWithHash } = useScrollToHashElement({ isLoading: true });
const { elementWithHash } = useScrollToHashElement({ courseId, isLoading: true });
return (
<div className="mt-2.5">
@@ -110,6 +111,7 @@ ExperimentConfigurationsSection.propTypes = {
handleCreate: PropTypes.func,
handleDelete: PropTypes.func,
}).isRequired,
courseId: PropTypes.string.isRequired,
};
export default ExperimentConfigurationsSection;

View File

@@ -87,6 +87,7 @@ const GroupConfigurations = ({ courseId }) => {
)}
{shouldShowExperimentGroups && (
<ExperimentConfigurationsSection
courseId={courseId}
availableGroups={experimentGroupConfigurations}
experimentConfigurationActions={experimentConfigurationActions}
/>

View File

@@ -1,10 +1,11 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { StudioHeader } from '@edx/frontend-component-header';
import { type Container, useToggle } from '@openedx/paragon';
import { generatePath, useHref } from 'react-router-dom';
import { getWaffleFlags } from '../data/selectors';
import { SearchModal } from '../search-modal';
import { useContentMenuItems, useSettingMenuItems, useToolsMenuItems } from './hooks';
import messages from './messages';
@@ -32,6 +33,7 @@ const Header = ({
}: HeaderProps) => {
const intl = useIntl();
const libraryHref = useHref('/library/:libraryId');
const waffleFlags = useSelector(getWaffleFlags);
const [isShowSearchModalOpen, openSearchModal, closeSearchModal] = useToggle(false);
@@ -59,9 +61,12 @@ const Header = ({
},
] : [];
const outlineLink = !isLibrary
? `${studioBaseUrl}/course/${contextId}`
: generatePath(libraryHref, { libraryId: contextId });
const getOutlineLink = () => {
if (isLibrary) {
return generatePath(libraryHref, { libraryId: contextId });
}
return waffleFlags.useNewCourseOutlinePage ? `/course/${contextId}` : `${studioBaseUrl}/course/${contextId}`;
};
return (
<>
@@ -71,9 +76,10 @@ const Header = ({
title={title}
isHiddenMainMenu={isHiddenMainMenu}
mainMenuDropdowns={mainMenuDropdowns}
outlineLink={outlineLink}
outlineLink={getOutlineLink()}
searchButtonAction={meiliSearchEnabled ? openSearchModal : undefined}
containerProps={containerProps}
isNewHomePage={waffleFlags.useNewHomePage}
/>
{meiliSearchEnabled && (
<SearchModal

View File

@@ -1,21 +1,24 @@
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useSelector } from 'react-redux';
import { getPagePath } from '../utils';
import { getWaffleFlags } from '../data/selectors';
import { getStudioHomeData } from '../studio-home/data/selectors';
import messages from './messages';
export const useContentMenuItems = courseId => {
const intl = useIntl();
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
const waffleFlags = useSelector(getWaffleFlags);
const items = [
{
href: `${studioBaseUrl}/course/${courseId}`,
href: waffleFlags.useNewCourseOutlinePage ? `/course/${courseId}` : `${studioBaseUrl}/course/${courseId}`,
title: intl.formatMessage(messages['header.links.outline']),
},
{
href: `${studioBaseUrl}/course_info/${courseId}`,
href: waffleFlags.useNewUpdatesPage ? `/course/${courseId}/course_info` : `${studioBaseUrl}/course_info/${courseId}`,
title: intl.formatMessage(messages['header.links.updates']),
},
{
@@ -23,13 +26,13 @@ export const useContentMenuItems = courseId => {
title: intl.formatMessage(messages['header.links.pages']),
},
{
href: `${studioBaseUrl}/assets/${courseId}`,
href: waffleFlags.useNewFilesUploadsPage ? `/course/${courseId}/assets` : `${studioBaseUrl}/assets/${courseId}`,
title: intl.formatMessage(messages['header.links.filesAndUploads']),
},
];
if (getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true') {
if (getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' || waffleFlags.useNewVideoUploadsPage) {
items.push({
href: `${studioBaseUrl}/videos/${courseId}`,
href: `/course/${courseId}/videos`,
title: intl.formatMessage(messages['header.links.videoUploads']),
});
}
@@ -41,34 +44,35 @@ export const useSettingMenuItems = courseId => {
const intl = useIntl();
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
const { canAccessAdvancedSettings } = useSelector(getStudioHomeData);
const waffleFlags = useSelector(getWaffleFlags);
const items = [
{
href: `${studioBaseUrl}/settings/details/${courseId}`,
href: waffleFlags.useNewScheduleDetailsPage ? `/course/${courseId}/settings/details` : `${studioBaseUrl}/settings/details/${courseId}`,
title: intl.formatMessage(messages['header.links.scheduleAndDetails']),
},
{
href: `${studioBaseUrl}/settings/grading/${courseId}`,
href: waffleFlags.useNewGradingPage ? `/course/${courseId}/settings/grading` : `${studioBaseUrl}/settings/grading/${courseId}`,
title: intl.formatMessage(messages['header.links.grading']),
},
{
href: `${studioBaseUrl}/course_team/${courseId}`,
href: waffleFlags.useNewCourseTeamPage ? `/course/${courseId}/course_team` : `${studioBaseUrl}/course_team/${courseId}`,
title: intl.formatMessage(messages['header.links.courseTeam']),
},
{
href: `${studioBaseUrl}/group_configurations/${courseId}`,
href: waffleFlags.useNewGroupConfigurationsPage ? `/course/${courseId}/group_configurations` : `${studioBaseUrl}/group_configurations/${courseId}`,
title: intl.formatMessage(messages['header.links.groupConfigurations']),
},
...(canAccessAdvancedSettings === true
? [{
href: `${studioBaseUrl}/settings/advanced/${courseId}`,
href: waffleFlags.useNewAdvancedSettingsPage ? `/course/${courseId}/settings/advanced` : `${studioBaseUrl}/settings/advanced/${courseId}`,
title: intl.formatMessage(messages['header.links.advancedSettings']),
}] : []
),
];
if (getConfig().ENABLE_CERTIFICATE_PAGE === 'true') {
if (getConfig().ENABLE_CERTIFICATE_PAGE === 'true' || waffleFlags.useNewCertificatesPage) {
items.push({
href: `${studioBaseUrl}/certificates/${courseId}`,
href: `/course/${courseId}/certificates`,
title: intl.formatMessage(messages['header.links.certificates']),
});
}
@@ -78,14 +82,15 @@ export const useSettingMenuItems = courseId => {
export const useToolsMenuItems = courseId => {
const intl = useIntl();
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
const waffleFlags = useSelector(getWaffleFlags);
const items = [
{
href: `${studioBaseUrl}/import/${courseId}`,
href: waffleFlags.useNewImportPage ? `/course/${courseId}/import` : `${studioBaseUrl}/import/${courseId}`,
title: intl.formatMessage(messages['header.links.import']),
},
{
href: `${studioBaseUrl}/export/${courseId}`,
href: waffleFlags.useNewExportPage ? `/course/${courseId}/export` : `${studioBaseUrl}/export/${courseId}`,
title: intl.formatMessage(messages['header.links.exportCourse']),
},
...(getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true'
@@ -95,7 +100,7 @@ export const useToolsMenuItems = courseId => {
}] : []
),
{
href: `${studioBaseUrl}/checklists/${courseId}`,
href: `/course/${courseId}/checklists`,
title: intl.formatMessage(messages['header.links.checklists']),
},
];

119
src/hooks.test.ts Normal file
View File

@@ -0,0 +1,119 @@
import { renderHook } from '@testing-library/react-hooks';
import { fireEvent } from '@testing-library/react';
import { useLocation } from 'react-router-dom';
import { history } from '@edx/frontend-platform';
import { useScrollToHashElement, useEscapeClick, useLoadOnScroll } from './hooks';
jest.mock('react-router-dom', () => ({
useLocation: jest.fn(),
}));
jest.mock('@edx/frontend-platform', () => ({
history: {
replace: jest.fn(),
},
}));
describe('Custom Hooks', () => {
describe('useScrollToHashElement', () => {
beforeEach(() => {
window.location.hash = '#test';
document.body.innerHTML = '<div id="test">Test Element</div>';
Element.prototype.scrollIntoView = jest.fn();
jest.mocked(useLocation).mockReturnValue({
pathname: '/test',
state: null,
search: '',
hash: '',
key: 'default',
});
});
afterEach(() => {
jest.clearAllMocks();
window.location.hash = '';
});
it('scrolls to element with the hash and replaces the URL hash', () => {
renderHook(() => useScrollToHashElement({ isLoading: false }));
const element = document.getElementById('test');
expect(element).toBeInTheDocument();
expect(element?.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth' });
expect(history.replace).toHaveBeenCalledWith({ pathname: '/test', hash: '' });
});
});
describe('useEscapeClick', () => {
it('calls onEscape when Escape key is pressed', () => {
const onEscape = jest.fn();
renderHook(() => useEscapeClick({ onEscape, dependency: [] }));
fireEvent.keyDown(window, { key: 'Escape' });
expect(onEscape).toHaveBeenCalledTimes(1);
});
it('does not call onEscape for other keys', () => {
const onEscape = jest.fn();
renderHook(() => useEscapeClick({ onEscape, dependency: [] }));
fireEvent.keyDown(window, { key: 'Enter' });
expect(onEscape).not.toHaveBeenCalled();
});
});
describe('useLoadOnScroll', () => {
const fetchNextPage = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
it('calls fetchNextPage when scrolled near the bottom', () => {
renderHook(() => useLoadOnScroll(true, false, fetchNextPage, true));
Object.defineProperty(window, 'innerHeight', { writable: true, configurable: true, value: 1000 });
Object.defineProperty(document.body, 'scrollHeight', { writable: true, configurable: true, value: 1500 });
window.scrollY = 1200;
fireEvent.scroll(window);
expect(fetchNextPage).toHaveBeenCalledTimes(1);
});
it('does not call fetchNextPage if not near the bottom', () => {
renderHook(() => useLoadOnScroll(true, false, fetchNextPage, true));
Object.defineProperty(window, 'innerHeight', { writable: true, configurable: true, value: 1000 });
Object.defineProperty(document.body, 'scrollHeight', { writable: true, configurable: true, value: 2000 });
window.scrollY = 500;
fireEvent.scroll(window);
expect(fetchNextPage).not.toHaveBeenCalled();
});
it('does not call fetchNextPage if fetching is in progress', () => {
renderHook(() => useLoadOnScroll(true, true, fetchNextPage, true));
fireEvent.scroll(window);
expect(fetchNextPage).not.toHaveBeenCalled();
});
it('does not call fetchNextPage if hasNextPage is false', () => {
renderHook(() => useLoadOnScroll(false, false, fetchNextPage, true));
fireEvent.scroll(window);
expect(fetchNextPage).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,8 +1,10 @@
import { useEffect, useState } from 'react';
import { history } from '@edx/frontend-platform';
import { useLocation } from 'react-router-dom';
export const useScrollToHashElement = ({ isLoading }: { isLoading: boolean }) => {
const [elementWithHash, setElementWithHash] = useState<string | null>(null);
const { pathname } = useLocation();
useEffect(() => {
const currentHash = window.location.hash.substring(1);
@@ -10,8 +12,8 @@ export const useScrollToHashElement = ({ isLoading }: { isLoading: boolean }) =>
if (currentHash) {
const element = document.getElementById(currentHash);
if (element) {
element.scrollIntoView();
history.replace({ hash: '' });
element.scrollIntoView({ behavior: 'smooth' });
history.replace({ pathname, hash: '' });
}
setElementWithHash(currentHash);
}

View File

@@ -89,7 +89,7 @@ const PagesAndResources = ({ courseId, intl }) => {
<Route path=":appId/settings" element={<PageWrap><Suspense fallback="..."><SettingsComponent url={redirectUrl} /></Suspense></PageWrap>} />
</Routes>
<PageGrid pages={pages} pluginSlotId="additional_course_plugin" />
<PageGrid pages={pages} pluginSlotId="additional_course_plugin" courseId={courseId} />
{
(contentPermissionsPages.length > 0 || hasAdditionalCoursePlugin)
&& (

View File

@@ -25,11 +25,12 @@ export { CoursePageShape };
const PageCard = ({
page,
settingButton,
courseId,
}) => {
const { formatMessage } = useIntl();
const isDesktop = useIsDesktop();
const SettingButton = settingButton || <PageSettingButton {...page} />;
const SettingButton = settingButton || <PageSettingButton courseId={courseId} {...page} />;
return (
<Card
@@ -59,11 +60,13 @@ const PageCard = ({
PageCard.defaultProps = {
settingButton: null,
courseId: null,
};
PageCard.propTypes = {
page: CoursePageShape.isRequired,
settingButton: PropTypes.node,
courseId: PropTypes.string,
};
export default PageCard;

View File

@@ -2,36 +2,50 @@ import {
render,
queryAllByRole,
} from '@testing-library/react';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { initializeMockApp, getConfig } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import initializeStore from '../../store';
import PageGrid from './PageGrid';
import { executeThunk } from '../../utils';
import { getApiWaffleFlagsUrl } from '../../data/api';
import { fetchWaffleFlags } from '../../data/thunks';
import PagesAndResourcesProvider from '../PagesAndResourcesProvider';
let container;
let store;
let axiosMock;
const courseId = '123';
const mockPageConfig = [
{
id: '1',
legacyLink: `${getConfig().STUDIO_BASE_URL}/tabs/course-v1:OpenedX+DemoX+DemoCourse`,
name: 'Custom pages',
},
{
id: '2',
legacyLink: `${getConfig().STUDIO_BASE_URL}/textbooks/course-v1:OpenedX+DemoX+DemoCourse`,
name: 'Textbook',
enabled: true,
},
{
name: 'Page',
allowedOperations: {
enable: true,
},
id: '3',
},
];
const renderComponent = () => {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<PagesAndResourcesProvider courseId="id">
<PageGrid
pages={[
{ legacyLink: 'SomeUrl', name: 'Custom pages', id: '1' },
{
legacyLink: 'SomeUrl',
name: 'Textbook',
id: '2',
enabled: true,
},
{ name: 'Page', allowedOperations: { enable: true }, id: '3' },
]}
/>
<PagesAndResourcesProvider courseId={courseId}>
<PageGrid pages={mockPageConfig} />
</PagesAndResourcesProvider>
</AppProvider>
</IntlProvider>,
@@ -55,15 +69,27 @@ describe('LiveSettings', () => {
status: 'sucessful',
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
.onGet(getApiWaffleFlagsUrl(courseId))
.reply(200, {
useNewGradingPage: true,
useNewCertificatesPage: true,
useNewScheduleDetailsPage: true,
useNewCourseOutlinePage: true,
});
await executeThunk(fetchWaffleFlags(courseId), store.dispatch);
});
it('should render three cards', async () => {
renderComponent();
expect(queryAllByRole(container, 'button')).toHaveLength(3);
});
it('should navigate to legacyLink', async () => {
renderComponent();
const textbookPagePath = mockPageConfig[0][1];
const textbookSettingsButton = queryAllByRole(container, 'link')[1];
expect(textbookSettingsButton).toHaveAttribute('href', 'SomeUrl');
expect(textbookSettingsButton).toHaveAttribute('href', textbookPagePath);
});
});

View File

@@ -5,7 +5,7 @@ import { CardGrid } from '@openedx/paragon';
import { PluginSlot } from '@openedx/frontend-plugin-framework';
import PageCard, { CoursePageShape } from './PageCard';
const PageGrid = ({ pages, pluginSlotId }) => (
const PageGrid = ({ pages, pluginSlotId, courseId }) => (
<CardGrid columnSizes={{
xs: 12,
sm: 6,
@@ -14,7 +14,7 @@ const PageGrid = ({ pages, pluginSlotId }) => (
}}
>
{pages.map((page) => (
<PageCard page={page} key={page.id} />
<PageCard page={page} key={page.id} courseId={courseId} />
))}
{pluginSlotId && <PluginSlot id={pluginSlotId} />}
</CardGrid>
@@ -22,11 +22,13 @@ const PageGrid = ({ pages, pluginSlotId }) => (
PageGrid.defaultProps = {
pluginSlotId: null,
courseId: null,
};
PageGrid.propTypes = {
pages: PropTypes.arrayOf(CoursePageShape.isRequired).isRequired,
pluginSlotId: PropTypes.string,
courseId: PropTypes.string,
};
export default injectIntl(PageGrid);

View File

@@ -1,36 +1,64 @@
import React, { useContext } from 'react';
import { useContext, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, IconButton, Hyperlink } from '@openedx/paragon';
import { Icon, IconButton } from '@openedx/paragon';
import { ArrowForward, Settings } from '@openedx/paragon/icons';
import { useNavigate } from 'react-router-dom';
import { useNavigate, Link } from 'react-router-dom';
import { getWaffleFlags } from '../../data/selectors';
import messages from '../messages';
import { PagesAndResourcesContext } from '../PagesAndResourcesProvider';
const PageSettingButton = ({
id,
courseId,
legacyLink,
allowedOperations,
}) => {
const { formatMessage } = useIntl();
const { path: pagesAndResourcesPath } = useContext(PagesAndResourcesContext);
const navigate = useNavigate();
const waffleFlags = useSelector(getWaffleFlags);
if (legacyLink) {
const determineLinkDestination = useMemo(() => {
if (!legacyLink) { return null; }
if (legacyLink.includes('textbooks')) {
return waffleFlags.useNewTextbooksPage
? `/course/${courseId}/${id.replace('_', '-')}`
: legacyLink;
}
if (legacyLink.includes('tabs')) {
return waffleFlags.useNewCustomPages
? `/course/${courseId}/${id.replace('_', '-')}`
: legacyLink;
}
return null;
}, [legacyLink, waffleFlags, id]);
const canConfigureOrEnable = allowedOperations?.configure || allowedOperations?.enable;
if (determineLinkDestination) {
return (
<Hyperlink destination={legacyLink}>
<Link to={determineLinkDestination}>
<IconButton
src={ArrowForward}
iconAs={Icon}
size="inline"
alt={formatMessage(messages.settings)}
/>
</Hyperlink>
</Link>
);
} if (!(allowedOperations?.configure || allowedOperations?.enable)) {
}
if (!canConfigureOrEnable) {
return null;
}
return (
<IconButton
src={Settings}
@@ -45,10 +73,12 @@ const PageSettingButton = ({
PageSettingButton.defaultProps = {
legacyLink: null,
allowedOperations: null,
courseId: null,
};
PageSettingButton.propTypes = {
id: PropTypes.string.isRequired,
courseId: PropTypes.string,
legacyLink: PropTypes.string,
allowedOperations: PropTypes.shape({
configure: PropTypes.bool,

View File

@@ -0,0 +1,93 @@
import { screen, render } from '@testing-library/react';
import { useSelector } from 'react-redux';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import PageSettingButton from './PageSettingButton';
jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
jest.mock('react-router-dom', () => {
// eslint-disable-next-line global-require
const PropTypes = require('prop-types');
const Link = ({ children, to }) => <a href={to}>{children}</a>;
Link.propTypes = {
children: PropTypes.node.isRequired,
to: PropTypes.string.isRequired,
};
return {
useNavigate: jest.fn(),
Link,
};
});
const mockWaffleFlags = {
useNewTextbooksPage: true,
useNewCustomPages: true,
};
const defaultProps = {
id: 'page_id',
courseId: 'course-v1:edX+DemoX+Demo_Course',
legacyLink: 'http://legacylink.com/tabs',
allowedOperations: { configure: true, enable: true },
};
const renderComponent = (props = {}) => render(
<IntlProvider locale="en">
<PageSettingButton {...defaultProps} {...props} />
</IntlProvider>,
);
describe('PageSettingButton', () => {
beforeEach(() => {
useSelector.mockClear();
});
it('renders the settings button with the new textbooks page link when useNewTextbooksPage is true', () => {
useSelector.mockReturnValue(mockWaffleFlags);
renderComponent({ legacyLink: 'http://legacylink.com/textbooks' });
const linkElement = screen.getByRole('link');
expect(linkElement).toHaveAttribute('href', `/course/${defaultProps.courseId}/page-id`);
});
it('does not render link when legacyLink prop value incorrect', () => {
useSelector.mockReturnValue(mockWaffleFlags);
renderComponent({ legacyLink: 'http://legacylink.com/some-value' });
expect(screen.queryByRole('link')).toBeNull();
});
it('renders the settings button with the legacy link when useNewTextbooksPage is false', () => {
useSelector.mockReturnValue({ ...mockWaffleFlags, useNewTextbooksPage: false });
renderComponent({ legacyLink: 'http://legacylink.com/textbooks' });
const linkElement = screen.getByRole('link');
expect(linkElement).toHaveAttribute('href', 'http://legacylink.com/textbooks');
});
it('renders the settings button with the new custom pages link when useNewCustomPages is true', () => {
useSelector.mockReturnValue(mockWaffleFlags);
renderComponent();
const linkElement = screen.getByRole('link');
expect(linkElement).toHaveAttribute('href', `/course/${defaultProps.courseId}/page-id`);
});
it('renders the settings button with the legacy link when useNewCustomPages is false', () => {
useSelector.mockReturnValue({ ...mockWaffleFlags, useNewCustomPages: false });
renderComponent();
const linkElement = screen.getByRole('link');
expect(linkElement).toHaveAttribute('href', defaultProps.legacyLink);
});
});

View File

@@ -18,6 +18,8 @@ import { executeThunk } from '../utils';
import { studioHomeMock } from './__mocks__';
import { getStudioHomeApiUrl } from './data/api';
import { fetchStudioHomeData } from './data/thunks';
import { getApiWaffleFlagsUrl } from '../data/api';
import { fetchWaffleFlags } from '../data/thunks';
import messages from './messages';
import createNewCourseMessages from './create-new-course-form/messages';
import createOrRerunCourseMessages from '../generic/create-or-rerun-course/messages';
@@ -84,6 +86,10 @@ describe('<StudioHome />', () => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(getStudioHomeApiUrl()).reply(404);
await executeThunk(fetchStudioHomeData(), store.dispatch);
axiosMock
.onGet(getApiWaffleFlagsUrl())
.reply(200, {});
await executeThunk(fetchWaffleFlags(), store.dispatch);
useSelector.mockReturnValue({ studioHomeLoadingStatus: RequestStatus.FAILED });
});
@@ -113,6 +119,10 @@ describe('<StudioHome />', () => {
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
await executeThunk(fetchStudioHomeData(), store.dispatch);
useSelector.mockReturnValue(studioHomeMock);
axiosMock
.onGet(getApiWaffleFlagsUrl())
.reply(200, {});
await executeThunk(fetchWaffleFlags(), store.dispatch);
});
it('should render page and page title correctly', () => {

View File

@@ -43,7 +43,7 @@ describe('<CardItem />', () => {
const dropDownMenu = screen.getByTestId('toggle-dropdown');
fireEvent.click(dropDownMenu);
const btnReRunCourse = screen.getByText(messages.btnReRunText.defaultMessage);
expect(btnReRunCourse).toHaveAttribute('href', trimSlashes(props.rerunLink));
expect(btnReRunCourse).toHaveAttribute('href', `/${trimSlashes(props.rerunLink)}`);
const viewLiveLink = screen.getByText(messages.viewLiveBtnText.defaultMessage);
expect(viewLiveLink).toHaveAttribute('href', props.lmsLink);
});

View File

@@ -12,6 +12,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { Link } from 'react-router-dom';
import { getWaffleFlags } from '../../data/selectors';
import { COURSE_CREATOR_STATES } from '../../constants';
import { getStudioHomeData } from '../data/selectors';
import messages from '../messages';
@@ -60,7 +61,13 @@ const CardItem: React.FC<Props> = ({
courseCreatorStatus,
rerunCreatorStatus,
} = useSelector(getStudioHomeData);
const destinationUrl: string = path ?? new URL(url, getConfig().STUDIO_BASE_URL).toString();
const waffleFlags = useSelector(getWaffleFlags);
const destinationUrl: string = path ?? (
waffleFlags.useNewCourseOutlinePage
? url
: new URL(url, getConfig().STUDIO_BASE_URL).toString()
);
const subtitle = isLibraries ? `${org} / ${number}` : `${org} / ${number} / ${run}`;
const readOnlyItem = !(lmsLink || rerunLink || url || path);
const showActions = !(readOnlyItem || isLibraries);
@@ -95,7 +102,10 @@ const CardItem: React.FC<Props> = ({
/>
<Dropdown.Menu>
{isShowRerunLink && (
<Dropdown.Item href={trimSlashes(rerunLink ?? '')}>
<Dropdown.Item
as={Link}
to={rerunLink ?? ''}
>
{messages.btnReRunText.defaultMessage}
</Dropdown.Item>
)}

View File

@@ -7,6 +7,7 @@ import { RequestStatus } from '../data/constants';
import { COURSE_CREATOR_STATES } from '../constants';
import { getCourseData, getSavingStatus } from '../generic/data/selectors';
import { fetchStudioHomeData } from './data/thunks';
import { fetchWaffleFlags } from '../data/thunks';
import {
getLoadingStatuses,
getSavingStatuses,
@@ -38,6 +39,7 @@ const useStudioHome = () => {
dispatch(fetchStudioHomeData(location.search ?? ''));
setShowNewCourseContainer(false);
}
dispatch(fetchWaffleFlags());
}, [location.search]);
useEffect(() => {

View File

@@ -1,11 +1,15 @@
import React from 'react';
import { render } from '@testing-library/react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import initializeStore from '../../store';
import messages from './messages';
import VerifyEmailLayout from '.';
let store;
const mockPathname = '/foo-bar';
const fakeAuthenticatedUser = {
email: 'email@fake.com',
@@ -18,28 +22,52 @@ jest.mock('react-router-dom', () => ({
pathname: mockPathname,
}),
}));
jest.mock('@edx/frontend-platform/auth');
getAuthenticatedUser.mockImplementation(() => fakeAuthenticatedUser);
const RootWrapper = () => (
<IntlProvider locale="en" messages={{}}>
<VerifyEmailLayout />
<AppProvider store={store}>
<VerifyEmailLayout />
</AppProvider>
</IntlProvider>
);
describe('<VerifyEmailLayout />', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore({
courseDetail: {
courseId: 'id',
status: 'sucessful',
},
});
});
it('renders successfully', () => {
const { getByText } = render(<RootWrapper />);
expect(
getByText(`Thanks for signing up, ${fakeAuthenticatedUser.username}!`, {
exact: false,
}),
).toBeInTheDocument();
expect(getByText(messages.bannerTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(
`Almost there! In order to complete your sign up we need you to verify your email address (${fakeAuthenticatedUser.email}). An activation message and next steps should be waiting for you there.`,
{ exact: false },
)).toBeInTheDocument();
expect(getByText(messages.sidebarTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.sidebarDescription.defaultMessage)).toBeInTheDocument();
});

View File

@@ -10,6 +10,7 @@ import {
import { Add as AddIcon } from '@openedx/paragon/icons';
import { useSelector } from 'react-redux';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import { SavingErrorAlert } from '../generic/saving-error-alert';
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
@@ -24,9 +25,11 @@ import TextbookForm from './textbook-form/TextbookForm';
import { useTextbooks } from './hooks';
import { getTextbookFormInitialValues } from './utils';
import messages from './messages';
import { getWaffleFlags } from '../data/selectors';
const Textbooks = ({ courseId }) => {
const intl = useIntl();
const waffleFlags = useSelector(getWaffleFlags);
const courseDetails = useModel('courseDetails', courseId);
@@ -43,7 +46,7 @@ const Textbooks = ({ courseId }) => {
handleSavingStatusDispatch,
handleTextbookEditFormSubmit,
handleTextbookDeleteSubmit,
} = useTextbooks(courseId);
} = useTextbooks(courseId, waffleFlags);
const {
isShow: showProcessingNotification,
@@ -70,7 +73,11 @@ const Textbooks = ({ courseId }) => {
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
breadcrumbs={(
<Breadcrumb ariaLabel={intl.formatMessage(messages.breadcrumbAriaLabel)} links={breadcrumbs} />
<Breadcrumb
linkAs={Link}
ariaLabel={intl.formatMessage(messages.breadcrumbAriaLabel)}
links={breadcrumbs}
/>
)}
headerActions={(
<Button

View File

@@ -20,7 +20,7 @@ import {
} from './data/thunk';
import messages from './messages';
const useTextbooks = (courseId) => {
const useTextbooks = (courseId, waffleFlags) => {
const intl = useIntl();
const dispatch = useDispatch();
const { config } = useContext(AppContext);
@@ -35,15 +35,15 @@ const useTextbooks = (courseId) => {
const breadcrumbs = [
{
label: intl.formatMessage(messages.breadcrumbContent),
href: `${config.STUDIO_BASE_URL}/course/${courseId}`,
to: waffleFlags.useNewCourseOutlinePage ? `/course/${courseId}` : `${config.STUDIO_BASE_URL}/course/${courseId}`,
},
{
label: intl.formatMessage(messages.breadcrumbPagesAndResources),
href: `/course/${courseId}/pages-and-resources`,
to: `/course/${courseId}/pages-and-resources`,
},
{
label: '',
href: `/course/${courseId}/textbooks`,
to: `/course/${courseId}/textbooks`,
},
];

View File

@@ -117,9 +117,9 @@ export const createCorrectInternalRoute = (checkPath) => {
export function getPagePath(courseId, isMfePageEnabled, urlParameter) {
if (isMfePageEnabled === 'true') {
if (urlParameter === 'tabs') {
return createCorrectInternalRoute(`/course/${courseId}/pages-and-resources`);
return `/course/${courseId}/pages-and-resources`;
}
return createCorrectInternalRoute(`/course/${courseId}/${urlParameter}`);
return `/course/${courseId}/${urlParameter}`;
}
return `${getConfig().STUDIO_BASE_URL}/${urlParameter}/${courseId}`;
}

View File

@@ -1,6 +1,15 @@
import { getConfig, getPath } from '@edx/frontend-platform';
import { getFileSizeToClosestByte, createCorrectInternalRoute } from './utils';
import {
getFileSizeToClosestByte,
convertObjectToSnakeCase,
deepConvertingKeysToCamelCase,
deepConvertingKeysToSnakeCase,
transformKeysToCamelCase,
parseArrayOrObjectValues,
convertToDateFromString,
convertToStringFromDate,
isValidDate,
getPagePath,
} from './utils';
jest.mock('@edx/frontend-platform', () => ({
getConfig: jest.fn(),
@@ -41,40 +50,129 @@ describe('FilesAndUploads utils', () => {
expect(expectedSize).toEqual(actualSize);
});
});
describe('createCorrectInternalRoute', () => {
beforeEach(() => {
getConfig.mockReset();
getPath.mockReset();
describe('convertObjectToSnakeCase', () => {
it('converts object keys to snake_case', () => {
const input = { firstName: 'John', lastName: 'Doe' };
const expectedOutput = { first_name: { value: 'John' }, last_name: { value: 'Doe' } };
expect(convertObjectToSnakeCase(input)).toEqual(expectedOutput);
});
it('returns the correct internal route when checkPath is not prefixed with basePath', () => {
getConfig.mockReturnValue({ PUBLIC_PATH: 'example.com' });
getPath.mockReturnValue('/');
it('converts object keys to snake_case with unpacked values', () => {
const input = { firstName: 'John', lastName: 'Doe' };
const expectedOutput = { first_name: 'John', last_name: 'Doe' };
expect(convertObjectToSnakeCase(input, true)).toEqual(expectedOutput);
});
});
const checkPath = '/some/path';
const result = createCorrectInternalRoute(checkPath);
expect(result).toBe('/some/path');
describe('deepConvertingKeysToCamelCase', () => {
it('converts object keys to camelCase', () => {
const input = { first_name: 'John', last_name: 'Doe' };
const expectedOutput = { firstName: 'John', lastName: 'Doe' };
expect(deepConvertingKeysToCamelCase(input)).toEqual(expectedOutput);
});
it('returns the input checkPath when it is already prefixed with basePath', () => {
getConfig.mockReturnValue({ PUBLIC_PATH: 'example.com' });
getPath.mockReturnValue('/course-authoring');
it('converts nested object keys to camelCase', () => {
const input = { user_info: { first_name: 'John', last_name: 'Doe' } };
const expectedOutput = { userInfo: { firstName: 'John', lastName: 'Doe' } };
expect(deepConvertingKeysToCamelCase(input)).toEqual(expectedOutput);
});
});
const checkPath = '/course-authoring/some/path';
const result = createCorrectInternalRoute(checkPath);
expect(result).toBe('/course-authoring/some/path');
describe('deepConvertingKeysToSnakeCase', () => {
it('converts object keys to snake_case', () => {
const input = { firstName: 'John', lastName: 'Doe' };
const expectedOutput = { first_name: 'John', last_name: 'Doe' };
expect(deepConvertingKeysToSnakeCase(input)).toEqual(expectedOutput);
});
it('handles basePath ending with a slash correctly', () => {
getConfig.mockReturnValue({ PUBLIC_PATH: 'example.com/' });
getPath.mockReturnValue('/course-authoring/');
it('converts nested object keys to snake_case', () => {
const input = { userInfo: { firstName: 'John', lastName: 'Doe' } };
const expectedOutput = { user_info: { first_name: 'John', last_name: 'Doe' } };
expect(deepConvertingKeysToSnakeCase(input)).toEqual(expectedOutput);
});
});
const checkPath = '/some/path';
const result = createCorrectInternalRoute(checkPath);
describe('transformKeysToCamelCase', () => {
it('transforms a single key to camelCase', () => {
const input = { key: 'first_name' };
const expectedOutput = 'firstName';
expect(transformKeysToCamelCase(input)).toEqual(expectedOutput);
});
});
expect(result).toBe('/course-authoring/some/path');
describe('parseArrayOrObjectValues', () => {
it('parses stringified JSON values', () => {
const input = { key1: '123', key2: '{"name":"John"}' };
const expectedOutput = { key1: '123', key2: { name: 'John' } };
expect(parseArrayOrObjectValues(input)).toEqual(expectedOutput);
});
it('returns non-JSON values as is', () => {
const input = { key1: '123', key2: 'John' };
const expectedOutput = { key1: '123', key2: 'John' };
expect(parseArrayOrObjectValues(input)).toEqual(expectedOutput);
});
});
describe('convertToDateFromString', () => {
it('converts a date string to a Date object', () => {
const dateStr = '2023-10-01T12:00:00Z';
const date = convertToDateFromString(dateStr);
expect(date).toBeInstanceOf(Date);
expect(date.toISOString()).toBe('2023-10-01T12:00:00.000Z');
});
it('returns an empty string for invalid date strings', () => {
const dateStr = '';
const date = convertToDateFromString(dateStr);
expect(date).toBe('');
});
});
describe('convertToStringFromDate', () => {
it('converts a Date object to a date string', () => {
const date = new Date('2023-10-01T12:00:00Z');
const dateStr = convertToStringFromDate(date);
expect(dateStr).toBe('2023-10-01T12:00:00Z');
});
it('returns an empty string for invalid Date objects', () => {
const date = null;
const dateStr = convertToStringFromDate(date);
expect(dateStr).toBe('');
});
});
describe('isValidDate', () => {
it('returns true for valid dates', () => {
const date = new Date('2023-10-01T12:00:00Z');
expect(isValidDate(date)).toBe(true);
});
it('returns false for invalid dates', () => {
const date = new Date('invalid-date');
expect(isValidDate(date)).toBe(false);
});
});
});
describe('getPagePath', () => {
it('returns MFE path when isMfePageEnabled is true and urlParameter is "tabs"', () => {
const courseId = '12345';
const isMfePageEnabled = 'true';
const urlParameter = 'tabs';
const result = getPagePath(courseId, isMfePageEnabled, urlParameter);
expect(result).toBe(`/course/${courseId}/pages-and-resources`);
});
it('returns MFE path when isMfePageEnabled is true and urlParameter is not "tabs"', () => {
const courseId = '12345';
const isMfePageEnabled = 'true';
const urlParameter = 'other-page';
const result = getPagePath(courseId, isMfePageEnabled, urlParameter);
expect(result).toBe(`/course/${courseId}/${urlParameter}`);
});
});