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:
11
package-lock.json
generated
11
package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
2
src/data/selectors.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const getWaffleFlags = (state) => state.courseDetail?.waffleFlags;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
46
src/generic/help-sidebar/HelpSidebarLink.test.jsx
Normal file
46
src/generic/help-sidebar/HelpSidebarLink.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -87,6 +87,7 @@ const GroupConfigurations = ({ courseId }) => {
|
||||
)}
|
||||
{shouldShowExperimentGroups && (
|
||||
<ExperimentConfigurationsSection
|
||||
courseId={courseId}
|
||||
availableGroups={experimentGroupConfigurations}
|
||||
experimentConfigurationActions={experimentConfigurationActions}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
119
src/hooks.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
&& (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
93
src/pages-and-resources/pages/PageSettingButton.test.jsx
Normal file
93
src/pages-and-resources/pages/PageSettingButton.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user