feat: library section subsection reuse in course (#2279)
Adds option for course author to import and use sections and subsections from library v2.
This commit is contained in:
@@ -1,16 +1,20 @@
|
||||
import {
|
||||
act, render, waitFor, fireEvent, within, screen,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { getConfig, initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { closestCorners } from '@dnd-kit/core';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import { clipboardUnit } from '@src/__mocks__';
|
||||
import { executeThunk } from '@src/utils';
|
||||
import configureModalMessages from '@src/generic/configure-modal/messages';
|
||||
import pasteButtonMessages from '@src/generic/clipboard/paste-component/messages';
|
||||
import { getApiBaseUrl, getClipboardUrl } from '@src/generic/data/api';
|
||||
import { postXBlockBaseApiUrl } from '@src/course-unit/data/api';
|
||||
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
|
||||
import {
|
||||
act, fireEvent, initializeMocks, render, screen, waitFor, within,
|
||||
} from '@src/testUtils';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import {
|
||||
getCourseBestPracticesApiUrl,
|
||||
getCourseLaunchApiUrl,
|
||||
@@ -21,16 +25,14 @@ import {
|
||||
getCourseItemApiUrl,
|
||||
getXBlockBaseApiUrl,
|
||||
exportTags,
|
||||
createDiscussionsTopics,
|
||||
createDiscussionsTopicsUrl,
|
||||
} from './data/api';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import {
|
||||
fetchCourseBestPracticesQuery,
|
||||
fetchCourseLaunchQuery,
|
||||
fetchCourseOutlineIndexQuery, syncDiscussionsTopics,
|
||||
updateCourseSectionHighlightsQuery,
|
||||
} from './data/thunk';
|
||||
import initializeStore from '../store';
|
||||
import {
|
||||
courseOutlineIndexMock,
|
||||
courseOutlineIndexWithoutSections,
|
||||
@@ -39,15 +41,10 @@ import {
|
||||
courseSectionMock,
|
||||
courseSubsectionMock,
|
||||
} from './__mocks__';
|
||||
import { clipboardUnit } from '../__mocks__';
|
||||
import { executeThunk } from '../utils';
|
||||
import { COURSE_BLOCK_NAMES, VIDEO_SHARING_OPTIONS } from './constants';
|
||||
import CourseOutline from './CourseOutline';
|
||||
|
||||
import configureModalMessages from '../generic/configure-modal/messages';
|
||||
import pasteButtonMessages from '../generic/clipboard/paste-component/messages';
|
||||
import messages from './messages';
|
||||
import { getApiBaseUrl, getClipboardUrl } from '../generic/data/api';
|
||||
import headerMessages from './header-navigations/messages';
|
||||
import cardHeaderMessages from './card-header/messages';
|
||||
import enableHighlightsModalMessages from './enable-highlights-modal/messages';
|
||||
@@ -60,14 +57,13 @@ import {
|
||||
moveSubsection,
|
||||
moveUnit,
|
||||
} from './drag-helper/utils';
|
||||
import { postXBlockBaseApiUrl } from '../course-unit/data/api';
|
||||
import { COMPONENT_TYPES } from '../generic/block-type-utils/constants';
|
||||
|
||||
let axiosMock;
|
||||
let axiosMock: import('axios-mock-adapter/types');
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
const containerKey = 'lct:org:lib:unit:1';
|
||||
const getContainerKey = jest.fn().mockReturnValue('lct:org:lib:unit:1');
|
||||
const getContainerType = jest.fn().mockReturnValue('unit');
|
||||
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
@@ -76,7 +72,7 @@ jest.mock('react-router-dom', () => ({
|
||||
useLocation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../help-urls/hooks', () => ({
|
||||
jest.mock('@src/help-urls/hooks', () => ({
|
||||
useHelpUrls: () => ({
|
||||
contentHighlights: 'some',
|
||||
visibility: 'some',
|
||||
@@ -98,13 +94,13 @@ jest.mock('./data/api', () => ({
|
||||
}));
|
||||
|
||||
// Mock ComponentPicker to call onComponentSelected on click
|
||||
jest.mock('../library-authoring/component-picker', () => ({
|
||||
jest.mock('@src/library-authoring/component-picker', () => ({
|
||||
ComponentPicker: (props) => {
|
||||
const onClick = () => {
|
||||
// eslint-disable-next-line react/prop-types
|
||||
props.onComponentSelected({
|
||||
usageKey: containerKey,
|
||||
blockType: 'unti',
|
||||
usageKey: getContainerKey(),
|
||||
blockType: getContainerType(),
|
||||
});
|
||||
};
|
||||
return (
|
||||
@@ -119,8 +115,6 @@ jest.mock('@edx/frontend-platform/logging', () => ({
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
jest.mock('@dnd-kit/core', () => ({
|
||||
...jest.requireActual('@dnd-kit/core'),
|
||||
// Since jsdom (used by jest) does not support getBoundingClientRect function
|
||||
@@ -130,38 +124,34 @@ jest.mock('@dnd-kit/core', () => ({
|
||||
closestCorners: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@src/studio-home/data/selectors', () => ({
|
||||
...jest.requireActual('@src/studio-home/data/selectors'),
|
||||
getStudioHomeData: jest.fn().mockReturnValue({
|
||||
librariesV2Enabled: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line no-promise-executor-return
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IntlProvider locale="en">
|
||||
<CourseOutline courseId={courseId} />
|
||||
</IntlProvider>
|
||||
</QueryClientProvider>
|
||||
</AppProvider>
|
||||
const renderComponent = () => render(
|
||||
<CourseOutline courseId={courseId} />,
|
||||
);
|
||||
|
||||
describe('<CourseOutline />', () => {
|
||||
beforeEach(async () => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
const mocks = initializeMocks();
|
||||
|
||||
useLocation.mockReturnValue({
|
||||
jest.mocked(useLocation).mockReturnValue({
|
||||
pathname: mockPathname,
|
||||
state: undefined,
|
||||
key: '',
|
||||
search: '',
|
||||
hash: '',
|
||||
});
|
||||
|
||||
store = initializeStore({
|
||||
studioHome: { studioHomeData: { librariesV2Enabled: true } },
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
store = mocks.reduxStore;
|
||||
axiosMock = mocks.axiosMock;
|
||||
axiosMock
|
||||
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||
.reply(200, courseOutlineIndexMock);
|
||||
@@ -188,7 +178,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('render CourseOutline component correctly', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
const { getByText } = renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText(messages.headingTitle.defaultMessage)).toBeInTheDocument();
|
||||
@@ -198,10 +188,10 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
it('logs an error when syncDiscussionsTopics encounters an API failure', async () => {
|
||||
axiosMock
|
||||
.onGet(createDiscussionsTopics(courseId))
|
||||
.onPost(createDiscussionsTopicsUrl(courseId))
|
||||
.reply(500, 'some internal error');
|
||||
|
||||
await executeThunk(syncDiscussionsTopics(), store.dispatch);
|
||||
await executeThunk(syncDiscussionsTopics(courseId), store.dispatch);
|
||||
|
||||
expect(logError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -211,7 +201,7 @@ describe('<CourseOutline />', () => {
|
||||
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||
.reply(500, 'some internal error');
|
||||
|
||||
const { findByText, queryByRole } = render(<RootWrapper />);
|
||||
const { findByText, queryByRole } = renderComponent();
|
||||
expect(await findByText('"some internal error"')).toBeInTheDocument();
|
||||
// check errors in store
|
||||
expect(store.getState().courseOutline.errors).toEqual({
|
||||
@@ -230,7 +220,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check reindex and render success alert is correctly', async () => {
|
||||
const { findByText, findByTestId } = render(<RootWrapper />);
|
||||
const { findByText, findByTestId } = renderComponent();
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseReindexApiUrl(courseOutlineIndexMock.reindexLink))
|
||||
@@ -242,7 +232,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check video sharing option udpates correctly', async () => {
|
||||
const { findByLabelText } = render(<RootWrapper />);
|
||||
const { findByLabelText } = renderComponent();
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseBlockApiUrl(courseId), {
|
||||
@@ -265,7 +255,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check video sharing option shows error on failure', async () => {
|
||||
render(<RootWrapper />);
|
||||
renderComponent();
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseBlockApiUrl(courseId), {
|
||||
@@ -295,7 +285,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('render error alert after failed reindex correctly', async () => {
|
||||
const { findByText, findByTestId } = render(<RootWrapper />);
|
||||
const { findByText, findByTestId } = renderComponent();
|
||||
|
||||
axiosMock
|
||||
.onGet(getCourseReindexApiUrl(courseOutlineIndexMock.reindexLink))
|
||||
@@ -307,7 +297,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check that new section list is saved when dragged', async () => {
|
||||
const { findAllByRole, findByTestId } = render(<RootWrapper />);
|
||||
const { findAllByRole, findByTestId } = renderComponent();
|
||||
const expandAllButton = await findByTestId('expand-collapse-all-button');
|
||||
fireEvent.click(expandAllButton);
|
||||
const [section] = store.getState().courseOutline.sectionsList;
|
||||
@@ -319,7 +309,7 @@ describe('<CourseOutline />', () => {
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
const section1 = store.getState().courseOutline.sectionsList[0].id;
|
||||
closestCorners.mockReturnValue([{ id: section1 }]);
|
||||
jest.mocked(closestCorners).mockReturnValue([{ id: section1 }]);
|
||||
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
await sleep(1);
|
||||
@@ -334,7 +324,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check section list is restored to original order when API call fails', async () => {
|
||||
const { findAllByRole, findByTestId } = render(<RootWrapper />);
|
||||
const { findAllByRole, findByTestId } = renderComponent();
|
||||
const expandAllButton = await findByTestId('expand-collapse-all-button');
|
||||
fireEvent.click(expandAllButton);
|
||||
const [section] = store.getState().courseOutline.sectionsList;
|
||||
@@ -346,7 +336,7 @@ describe('<CourseOutline />', () => {
|
||||
.reply(500);
|
||||
|
||||
const section1 = store.getState().courseOutline.sectionsList[0].id;
|
||||
closestCorners.mockReturnValue([{ id: section1 }]);
|
||||
jest.mocked(closestCorners).mockReturnValue([{ id: section1 }]);
|
||||
|
||||
fireEvent.keyDown(draggableButton, { code: 'Space' });
|
||||
await sleep(1);
|
||||
@@ -361,11 +351,18 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('adds new section correctly', async () => {
|
||||
const { findAllByTestId, findByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
let elements = await findAllByTestId('section-card');
|
||||
window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({
|
||||
top: 0,
|
||||
bottom: 4000,
|
||||
height: 0,
|
||||
width: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
toJSON: () => {},
|
||||
}));
|
||||
expect(elements.length).toBe(4);
|
||||
|
||||
@@ -377,22 +374,29 @@ describe('<CourseOutline />', () => {
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(courseSectionMock.id))
|
||||
.reply(200, courseSectionMock);
|
||||
const newSectionButton = await findByTestId('new-section-button');
|
||||
const newSectionButton = (await screen.findAllByRole('button', { name: 'New section' }))[0];
|
||||
await act(async () => fireEvent.click(newSectionButton));
|
||||
|
||||
elements = await findAllByTestId('section-card');
|
||||
expect(elements.length).toBe(5);
|
||||
expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled();
|
||||
expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds new subsection correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
const [section] = await findAllByTestId('section-card');
|
||||
let subsections = await within(section).findAllByTestId('subsection-card');
|
||||
expect(subsections.length).toBe(2);
|
||||
window.HTMLElement.prototype.getBoundingClientRect = jest.fn(() => ({
|
||||
top: 0,
|
||||
bottom: 4000,
|
||||
height: 0,
|
||||
width: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
toJSON: () => {},
|
||||
}));
|
||||
|
||||
axiosMock
|
||||
@@ -403,18 +407,18 @@ describe('<CourseOutline />', () => {
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(courseSubsectionMock.id))
|
||||
.reply(200, courseSubsectionMock);
|
||||
const newSubsectionButton = await within(section).findByTestId('new-subsection-button');
|
||||
const newSubsectionButton = await within(section).findByRole('button', { name: 'New subsection' });
|
||||
await act(async () => {
|
||||
fireEvent.click(newSubsectionButton);
|
||||
});
|
||||
|
||||
subsections = await within(section).findAllByTestId('subsection-card');
|
||||
expect(subsections.length).toBe(3);
|
||||
expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled();
|
||||
expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds new unit correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const units = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
@@ -425,7 +429,7 @@ describe('<CourseOutline />', () => {
|
||||
.reply(200, {
|
||||
locator: 'some',
|
||||
});
|
||||
const newUnitButton = await within(subsectionElement).findByTestId('new-unit-button');
|
||||
const newUnitButton = await within(subsectionElement).findByRole('button', { name: 'New unit' });
|
||||
await act(async () => fireEvent.click(newUnitButton));
|
||||
expect(axiosMock.history.post.length).toBe(3);
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
@@ -438,7 +442,9 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('adds a unit from library correctly', async () => {
|
||||
render(<RootWrapper />);
|
||||
getContainerKey.mockReturnValue('lct:org:lib:unit:1');
|
||||
getContainerKey.mockReturnValue('unit');
|
||||
renderComponent();
|
||||
const [sectionElement] = await screen.findAllByTestId('section-card');
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const units = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
@@ -448,6 +454,7 @@ describe('<CourseOutline />', () => {
|
||||
.onPost(postXBlockBaseApiUrl())
|
||||
.reply(200, {
|
||||
locator: 'some',
|
||||
parent_locator: 'parent',
|
||||
});
|
||||
|
||||
const addUnitFromLibraryButton = within(subsectionElement).getByRole('button', {
|
||||
@@ -459,20 +466,95 @@ describe('<CourseOutline />', () => {
|
||||
const dummyBtn = await screen.findByRole('button', { name: 'Dummy button' });
|
||||
fireEvent.click(dummyBtn);
|
||||
|
||||
waitFor(() => expect(axiosMock.history.post.length).toBe(2));
|
||||
waitFor(() => expect(axiosMock.history.post.length).toBe(3));
|
||||
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [subsection] = section.childInfo.children;
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: 'vertical',
|
||||
parent_locator: subsection.id,
|
||||
library_content_key: containerKey,
|
||||
}));
|
||||
waitFor(() => {
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: 'vertical',
|
||||
parent_locator: subsection.id,
|
||||
library_content_key: getContainerKey(),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a subsection from library correctly', async () => {
|
||||
getContainerKey.mockReturnValue('lct:org:lib:subsection:1');
|
||||
getContainerKey.mockReturnValue('subsection');
|
||||
renderComponent();
|
||||
const [sectionElement] = await screen.findAllByTestId('section-card');
|
||||
const subsections = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
expect(subsections.length).toBe(2);
|
||||
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl())
|
||||
.reply(200, {
|
||||
locator: 'some',
|
||||
parent_locator: 'parent',
|
||||
});
|
||||
|
||||
const addSubsectionFromLibraryButton = within(sectionElement).getByRole('button', {
|
||||
name: /use subsection from library/i,
|
||||
});
|
||||
fireEvent.click(addSubsectionFromLibraryButton);
|
||||
|
||||
// click dummy button to execute onComponentSelected prop.
|
||||
const dummyBtn = await screen.findByRole('button', { name: 'Dummy button' });
|
||||
fireEvent.click(dummyBtn);
|
||||
|
||||
waitFor(() => expect(axiosMock.history.post.length).toBe(3));
|
||||
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
waitFor(() => {
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: 'sequential',
|
||||
parent_locator: section.id,
|
||||
library_content_key: getContainerKey(),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a section from library correctly', async () => {
|
||||
getContainerKey.mockReturnValue('lct:org:lib:section:1');
|
||||
getContainerKey.mockReturnValue('section');
|
||||
renderComponent();
|
||||
const sections = await screen.findAllByTestId('section-card');
|
||||
expect(sections.length).toBe(4);
|
||||
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl())
|
||||
.reply(200, {
|
||||
locator: 'some',
|
||||
parent_locator: 'parent',
|
||||
});
|
||||
|
||||
const addSectionFromLibraryButton = await screen.findByRole('button', {
|
||||
name: /use section from library/i,
|
||||
});
|
||||
fireEvent.click(addSectionFromLibraryButton);
|
||||
|
||||
// click dummy button to execute onComponentSelected prop.
|
||||
const dummyBtn = await screen.findByRole('button', { name: 'Dummy button' });
|
||||
fireEvent.click(dummyBtn);
|
||||
|
||||
waitFor(() => expect(axiosMock.history.post.length).toBe(3));
|
||||
|
||||
const courseUsageKey = courseOutlineIndexMock.courseStructure.id;
|
||||
waitFor(() => {
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: 'chapter',
|
||||
parent_locator: courseUsageKey,
|
||||
library_content_key: getContainerKey(),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it('render checklist value correctly', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
const { getByText } = renderComponent();
|
||||
|
||||
await executeThunk(fetchCourseLaunchQuery({
|
||||
courseId, gradedOnly: true, validateOras: true, all: true,
|
||||
@@ -490,7 +572,7 @@ describe('<CourseOutline />', () => {
|
||||
courseId, gradedOnly: true, validateOras: true, all: true,
|
||||
}))
|
||||
.reply(500);
|
||||
const { findByText, findByRole } = render(<RootWrapper />);
|
||||
const { findByText, findByRole } = renderComponent();
|
||||
|
||||
await executeThunk(fetchCourseLaunchQuery({
|
||||
courseId, gradedOnly: true, validateOras: true, all: true,
|
||||
@@ -520,7 +602,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check highlights are enabled after enable highlights query is successful', async () => {
|
||||
const { findByTestId, findByText } = render(<RootWrapper />);
|
||||
const { findByTestId, findByText } = renderComponent();
|
||||
|
||||
axiosMock.reset();
|
||||
axiosMock
|
||||
@@ -549,7 +631,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('should expand and collapse subsections, after click on subheader buttons', async () => {
|
||||
const { queryAllByTestId, findByText } = render(<RootWrapper />);
|
||||
const { queryAllByTestId, findByText } = renderComponent();
|
||||
|
||||
const collapseBtn = await findByText(headerMessages.collapseAllButton.defaultMessage);
|
||||
expect(collapseBtn).toBeInTheDocument();
|
||||
@@ -573,7 +655,7 @@ describe('<CourseOutline />', () => {
|
||||
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||
.reply(200, courseOutlineIndexWithoutSections);
|
||||
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('empty-placeholder')).toBeInTheDocument();
|
||||
@@ -588,7 +670,7 @@ describe('<CourseOutline />', () => {
|
||||
notificationDismissUrl: '/some/url',
|
||||
});
|
||||
|
||||
render(<RootWrapper />);
|
||||
renderComponent();
|
||||
const alert = await screen.findByText(pageAlertMessages.configurationErrorTitle.defaultMessage);
|
||||
expect(alert).toBeInTheDocument();
|
||||
const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' });
|
||||
@@ -601,15 +683,11 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check edit title works for section, subsection and unit', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
const checkEditTitle = async (section, element, item, newName, elementName) => {
|
||||
axiosMock.reset();
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(item.id, {
|
||||
metadata: {
|
||||
display_name: newName,
|
||||
},
|
||||
}))
|
||||
.onPost(getCourseItemApiUrl(item.id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
// mock section, subsection and unit name and check within the elements.
|
||||
// this is done to avoid adding conditions to this mock.
|
||||
@@ -643,14 +721,13 @@ describe('<CourseOutline />', () => {
|
||||
await act(async () => fireEvent.blur(editField));
|
||||
expect(
|
||||
axiosMock.history.post[axiosMock.history.post.length - 1].data,
|
||||
`Failed for ${elementName}!`,
|
||||
).toBe(JSON.stringify({
|
||||
metadata: {
|
||||
display_name: newName,
|
||||
},
|
||||
}));
|
||||
const results = await within(element).findAllByText(newName);
|
||||
expect(results.length, `Failed for ${elementName}!`).toBeGreaterThan(0);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
};
|
||||
|
||||
// check section
|
||||
@@ -670,7 +747,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether section, subsection and unit is deleted when corresponding delete button is clicked', async () => {
|
||||
render(<RootWrapper />);
|
||||
renderComponent();
|
||||
// get section, subsection and unit
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await screen.findAllByTestId('section-card');
|
||||
@@ -681,7 +758,7 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
const checkDeleteBtn = async (item, element, elementName) => {
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(item.displayName), `Failed for ${elementName}!`).toBeInTheDocument();
|
||||
expect(screen.queryByText(item.displayName)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
axiosMock.onDelete(getCourseItemApiUrl(item.id)).reply(200);
|
||||
@@ -694,7 +771,7 @@ describe('<CourseOutline />', () => {
|
||||
fireEvent.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(item.displayName), `Failed for ${elementName}!`).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(item.displayName)).not.toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -708,9 +785,9 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether section, subsection and unit is duplicated successfully', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get section, subsection and unit
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children as unknown as XBlock[];
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsection] = section.childInfo.children;
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
@@ -722,12 +799,10 @@ describe('<CourseOutline />', () => {
|
||||
if (parentElement) {
|
||||
expect(
|
||||
await within(parentElement).findAllByTestId(`${elementName}-card`),
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveLength(expectedLength - 1);
|
||||
} else {
|
||||
expect(
|
||||
await findAllByTestId(`${elementName}-card`),
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveLength(expectedLength - 1);
|
||||
}
|
||||
|
||||
@@ -758,12 +833,10 @@ describe('<CourseOutline />', () => {
|
||||
if (parentElement) {
|
||||
expect(
|
||||
await within(parentElement).findAllByTestId(`${elementName}-card`),
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveLength(expectedLength);
|
||||
} else {
|
||||
expect(
|
||||
await findAllByTestId(`${elementName}-card`),
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveLength(expectedLength);
|
||||
}
|
||||
};
|
||||
@@ -778,8 +851,8 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check section, subsection & unit is published when publish button is clicked', async () => {
|
||||
const { findAllByTestId, findByTestId } = render(<RootWrapper />);
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const { findAllByTestId, findByTestId } = renderComponent();
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children as unknown as XBlock[];
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsection] = section.childInfo.children;
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
@@ -788,8 +861,7 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
const checkPublishBtn = async (item, element, elementName) => {
|
||||
expect(
|
||||
(await within(element).getAllByRole('status'))[0],
|
||||
`Failed for ${elementName}!`,
|
||||
(await within(element).findAllByRole('status'))[0],
|
||||
).toHaveTextContent(cardHeaderMessages.statusBadgeDraft.defaultMessage);
|
||||
|
||||
axiosMock
|
||||
@@ -819,6 +891,7 @@ describe('<CourseOutline />', () => {
|
||||
{
|
||||
...section.childInfo.children[0],
|
||||
childInfo: {
|
||||
displayName: 'Unit Tests',
|
||||
children: [
|
||||
{
|
||||
...section.childInfo.children[0].childInfo.children[0],
|
||||
@@ -846,8 +919,7 @@ describe('<CourseOutline />', () => {
|
||||
await act(async () => fireEvent.click(confirmButton));
|
||||
|
||||
expect(
|
||||
(await within(element).getAllByRole('status'))[0],
|
||||
`Failed for ${elementName}!`,
|
||||
(await within(element).findAllByRole('status'))[0],
|
||||
).toHaveTextContent(cardHeaderMessages.statusBadgeLive.defaultMessage);
|
||||
};
|
||||
|
||||
@@ -860,7 +932,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check configure modal for section', async () => {
|
||||
const { findByTestId, findAllByTestId } = render(<RootWrapper />);
|
||||
const { findByTestId, findAllByTestId } = renderComponent();
|
||||
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||
const newReleaseDateIso = '2025-09-10T22:00:00Z';
|
||||
const newReleaseDate = '09/10/2025';
|
||||
@@ -916,8 +988,8 @@ describe('<CourseOutline />', () => {
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]);
|
||||
} = renderComponent();
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]) as unknown as XBlock;
|
||||
const [subsection] = section.childInfo.children;
|
||||
const expectedRequestData = {
|
||||
publish: 'republish',
|
||||
@@ -953,7 +1025,7 @@ describe('<CourseOutline />', () => {
|
||||
subsection.format = expectedRequestData.graderType;
|
||||
subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited;
|
||||
subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes;
|
||||
subsection.hideAfterDue = expectedRequestData.metadata.hideAfterDue;
|
||||
subsection.hideAfterDue = expectedRequestData.metadata.hide_after_due;
|
||||
section.childInfo.children[0] = subsection;
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
@@ -1009,7 +1081,7 @@ describe('<CourseOutline />', () => {
|
||||
expect(releaseDatePicker).toHaveValue('08/10/2025');
|
||||
releaseDateTimePicker = await within(releaseDateStack).findByPlaceholderText('HH:MM');
|
||||
expect(releaseDateTimePicker).toHaveValue('00:00');
|
||||
dueDateStack = await await within(configureModal).findByTestId('due-date-stack');
|
||||
dueDateStack = await within(configureModal).findByTestId('due-date-stack');
|
||||
dueDatePicker = await within(dueDateStack).findByPlaceholderText('MM/DD/YYYY');
|
||||
expect(dueDatePicker).toHaveValue('09/10/2025');
|
||||
dueDateTimePicker = await within(dueDateStack).findByPlaceholderText('HH:MM');
|
||||
@@ -1031,8 +1103,8 @@ describe('<CourseOutline />', () => {
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]);
|
||||
} = renderComponent();
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]) as unknown as XBlock;
|
||||
const [subsection, secondSubsection] = section.childInfo.children;
|
||||
const expectedRequestData = {
|
||||
publish: 'republish',
|
||||
@@ -1176,8 +1248,8 @@ describe('<CourseOutline />', () => {
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]);
|
||||
} = renderComponent();
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]) as unknown as XBlock;
|
||||
const [subsection] = section.childInfo.children;
|
||||
const expectedRequestData = {
|
||||
publish: 'republish',
|
||||
@@ -1276,8 +1348,8 @@ describe('<CourseOutline />', () => {
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]);
|
||||
} = renderComponent();
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[0]) as unknown as XBlock;
|
||||
const [, subsection] = section.childInfo.children;
|
||||
const expectedRequestData = {
|
||||
publish: 'republish',
|
||||
@@ -1376,8 +1448,8 @@ describe('<CourseOutline />', () => {
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[1]);
|
||||
} = renderComponent();
|
||||
const section = cloneDeep(courseOutlineIndexMock.courseStructure.childInfo.children[1]) as unknown as XBlock;
|
||||
const [subsection] = section.childInfo.children;
|
||||
const expectedRequestData = {
|
||||
publish: 'republish',
|
||||
@@ -1466,7 +1538,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check configure modal for unit', async () => {
|
||||
const { findAllByTestId, findByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId, findByTestId } = renderComponent();
|
||||
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||
const [subsection] = section.childInfo.children;
|
||||
const [unit] = subsection.childInfo.children;
|
||||
@@ -1530,7 +1602,7 @@ describe('<CourseOutline />', () => {
|
||||
.reply(200, section);
|
||||
|
||||
fireEvent.click(unitDropdownButton);
|
||||
const configureBtn = await within(firstUnit).getByTestId('unit-card-header__menu-configure-button');
|
||||
const configureBtn = await within(firstUnit).findByTestId('unit-card-header__menu-configure-button');
|
||||
fireEvent.click(configureBtn);
|
||||
|
||||
let configureModal = await findByTestId('configure-modal');
|
||||
@@ -1575,7 +1647,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check update highlights when update highlights query is successfully', async () => {
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||
const highlights = [
|
||||
@@ -1610,7 +1682,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether section move up and down options work correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get second section element
|
||||
const courseBlockId = courseOutlineIndexMock.courseStructure.id;
|
||||
const [, secondSection] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
@@ -1639,7 +1711,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether section move up & down option is rendered correctly based on index', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get first, second and last section element
|
||||
const {
|
||||
0: firstSection, 1: secondSection, length, [length - 1]: lastSection,
|
||||
@@ -1682,7 +1754,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether subsection move up and down options work correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get second section element
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
@@ -1721,7 +1793,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether subsection move up to prev section if it is on top of its parent section', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
const [firstSection, section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsection] = section.childInfo.children;
|
||||
@@ -1757,7 +1829,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether subsection move down to next section if it is in bottom position of its parent section', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
const [section, secondSection] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const lastSubsectionIdx = section.childInfo.children.length - 1;
|
||||
@@ -1794,7 +1866,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether subsection move up & down option is rendered correctly based on index', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// using first section
|
||||
const sectionElements = await findAllByTestId('section-card');
|
||||
const firstSectionElement = sectionElements[0];
|
||||
@@ -1844,7 +1916,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether unit move up and down options work correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get second section -> second subsection -> second unit element
|
||||
const [, section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
@@ -1883,7 +1955,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether unit moves up to previous subsection if it is in top position in parent subsection', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get second section -> second subsection -> first unit element
|
||||
const [, section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
@@ -1917,7 +1989,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether unit moves up to previous subsection of prev section if it is in top position in parent subsection & section', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get second section -> second subsection -> first unit element
|
||||
const [firstSection, secondSection] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
@@ -1962,7 +2034,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether unit moves down to next subsection if it is in last position in parent subsection', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get second section -> second subsection -> first unit element
|
||||
const [, section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
@@ -1997,7 +2069,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether unit moves down to next subsection of next section if it is in last position in parent subsection & section', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get second section -> second subsection -> first unit element
|
||||
const [, secondSection, thirdSection] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [, sectionElement] = await findAllByTestId('section-card');
|
||||
@@ -2044,7 +2116,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether unit move up & down option is rendered correctly based on index', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// using first section -> first subsection -> first unit
|
||||
const sections = await findAllByTestId('section-card');
|
||||
const [sectionElement] = sections;
|
||||
@@ -2085,7 +2157,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check that new subsection list is saved when dragged', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
@@ -2095,7 +2167,7 @@ describe('<CourseOutline />', () => {
|
||||
const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' });
|
||||
const draggableButton = subsectionsDraggers[1];
|
||||
const subsection1 = section.childInfo.children[0].id;
|
||||
closestCorners.mockReturnValue([{ id: subsection1 }]);
|
||||
jest.mocked(closestCorners).mockReturnValue([{ id: subsection1 }]);
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(section.id))
|
||||
.reply(200, { dummy: 'value' });
|
||||
@@ -2119,7 +2191,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check that new subsection list is restored to original order when API call fails', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
@@ -2129,7 +2201,7 @@ describe('<CourseOutline />', () => {
|
||||
const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' });
|
||||
const draggableButton = subsectionsDraggers[1];
|
||||
const subsection1 = section.childInfo.children[0].id;
|
||||
closestCorners.mockReturnValue([{ id: subsection1 }]);
|
||||
jest.mocked(closestCorners).mockReturnValue([{ id: subsection1 }]);
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(section.id))
|
||||
@@ -2148,7 +2220,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check that new unit list is saved when dragged', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get third section
|
||||
const [, , sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
@@ -2159,7 +2231,7 @@ describe('<CourseOutline />', () => {
|
||||
const sections = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
|
||||
const unit1 = subsection.childInfo.children[0].id;
|
||||
closestCorners.mockReturnValue([{ id: unit1 }]);
|
||||
jest.mocked(closestCorners).mockReturnValue([{ id: unit1 }]);
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(subsection.id))
|
||||
@@ -2182,7 +2254,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check that new unit list is restored to original order when API call fails', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const { findAllByTestId } = renderComponent();
|
||||
// get third section
|
||||
const [, , sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
@@ -2193,7 +2265,7 @@ describe('<CourseOutline />', () => {
|
||||
const sections = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
|
||||
const unit1 = subsection.childInfo.children[0].id;
|
||||
closestCorners.mockReturnValue([{ id: unit1 }]);
|
||||
jest.mocked(closestCorners).mockReturnValue([{ id: unit1 }]);
|
||||
|
||||
axiosMock
|
||||
.onPut(getCourseItemApiUrl(subsection.id))
|
||||
@@ -2216,7 +2288,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check whether unit copy & paste option works correctly', async () => {
|
||||
render(<RootWrapper />);
|
||||
renderComponent();
|
||||
// get first section -> first subsection -> first unit element
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await screen.findAllByTestId('section-card');
|
||||
@@ -2251,7 +2323,7 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
// find clipboard content popover link
|
||||
const popoverContent = screen.queryByTestId('popover-content');
|
||||
expect(popoverContent.tagName).toBe('A');
|
||||
expect(popoverContent?.tagName).toBe('A');
|
||||
expect(popoverContent).toHaveAttribute('href', `${getConfig().STUDIO_BASE_URL}${unit.studioUrl}`);
|
||||
|
||||
// check paste button functionality
|
||||
@@ -2310,16 +2382,23 @@ describe('<CourseOutline />', () => {
|
||||
// Without the delay the success message renders too quickly
|
||||
const delayedResponse = axiosMock
|
||||
.onGet(exportTags(courseId))
|
||||
// Issue with types in upstream lib, should be fixed by this PR
|
||||
// https://github.com/ctimmerm/axios-mock-adapter/pull/391/files
|
||||
// @ts-ignore-next-line
|
||||
.withDelayInMs(500);
|
||||
delayedResponse(200, expectedResponse);
|
||||
|
||||
useLocation.mockReturnValue({
|
||||
jest.mocked(useLocation).mockReturnValue({
|
||||
pathname: '/foo-bar',
|
||||
hash: '#export-tags',
|
||||
state: undefined,
|
||||
key: '',
|
||||
search: '',
|
||||
});
|
||||
|
||||
window.URL.createObjectURL = jest.fn().mockReturnValue('http://example.com/archivo');
|
||||
window.URL.revokeObjectURL = jest.fn();
|
||||
render(<RootWrapper />);
|
||||
renderComponent();
|
||||
await screen.findByText('Please wait. Creating export file for course tags...');
|
||||
|
||||
const expectedRequest = axiosMock.history.get.filter(request => request.url === exportTags(courseId));
|
||||
@@ -2333,15 +2412,21 @@ describe('<CourseOutline />', () => {
|
||||
// Without the delay the error renders too quickly
|
||||
const delayedResponse = axiosMock
|
||||
.onGet(exportTags(courseId))
|
||||
// Issue with types in upstream lib, should be fixed by this PR
|
||||
// https://github.com/ctimmerm/axios-mock-adapter/pull/391/files
|
||||
// @ts-ignore-next-line
|
||||
.withDelayInMs(500);
|
||||
delayedResponse(404);
|
||||
|
||||
useLocation.mockReturnValue({
|
||||
jest.mocked(useLocation).mockReturnValue({
|
||||
pathname: '/foo-bar',
|
||||
hash: '#export-tags',
|
||||
state: undefined,
|
||||
key: '',
|
||||
search: '',
|
||||
});
|
||||
|
||||
render(<RootWrapper />);
|
||||
renderComponent();
|
||||
await screen.findByText('Please wait. Creating export file for course tags...');
|
||||
await screen.findByText('An error has occurred creating the file');
|
||||
});
|
||||
@@ -2351,7 +2436,7 @@ describe('<CourseOutline />', () => {
|
||||
.onGet(getCourseOutlineIndexApiUrl(courseId))
|
||||
.reply(403);
|
||||
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
const { getByTestId } = renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('redux-provider')).toBeInTheDocument();
|
||||
@@ -1,20 +1,15 @@
|
||||
// @ts-check
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Layout,
|
||||
Row,
|
||||
TransitionReplace,
|
||||
Toast,
|
||||
StandardModal,
|
||||
} from '@openedx/paragon';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import {
|
||||
Add as IconAdd,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { CheckCircle as CheckCircleIcon } from '@openedx/paragon/icons';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
arrayMove,
|
||||
@@ -22,18 +17,25 @@ import {
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { CourseAuthoringOutlineSidebarSlot } from '../plugin-slots/CourseAuthoringOutlineSidebarSlot';
|
||||
import { CourseAuthoringOutlineSidebarSlot } from '@src/plugin-slots/CourseAuthoringOutlineSidebarSlot';
|
||||
|
||||
import { LoadingSpinner } from '../generic/Loading';
|
||||
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import ProcessingNotification from '../generic/processing-notification';
|
||||
import InternetConnectionAlert from '../generic/internet-connection-alert';
|
||||
import DeleteModal from '../generic/delete-modal/DeleteModal';
|
||||
import ConfigureModal from '../generic/configure-modal/ConfigureModal';
|
||||
import AlertMessage from '../generic/alert-message';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import { LoadingSpinner } from '@src/generic/Loading';
|
||||
import { getProcessingNotification } from '@src/generic/processing-notification/data/selectors';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import SubHeader from '@src/generic/sub-header/SubHeader';
|
||||
import ProcessingNotification from '@src/generic/processing-notification';
|
||||
import InternetConnectionAlert from '@src/generic/internet-connection-alert';
|
||||
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
|
||||
import ConfigureModal from '@src/generic/configure-modal/ConfigureModal';
|
||||
import AlertMessage from '@src/generic/alert-message';
|
||||
import getPageHeadTitle from '@src/generic/utils';
|
||||
import CourseOutlineHeaderActionsSlot from '@src/plugin-slots/CourseOutlineHeaderActionsSlot';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
import { ComponentPicker, SelectedComponent } from '@src/library-authoring';
|
||||
import { ContentType } from '@src/library-authoring/routes';
|
||||
import { NOTIFICATION_MESSAGES } from '@src/constants';
|
||||
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import { getCurrentItem, getProctoredExamsFlag } from './data/selectors';
|
||||
import { COURSE_BLOCK_NAMES } from './constants';
|
||||
import StatusBar from './status-bar/StatusBar';
|
||||
@@ -54,13 +56,18 @@ import {
|
||||
import { useCourseOutline } from './hooks';
|
||||
import messages from './messages';
|
||||
import { getTagsExportFile } from './data/api';
|
||||
import CourseOutlineHeaderActionsSlot from '../plugin-slots/CourseOutlineHeaderActionsSlot';
|
||||
import OutlineAddChildButtons from './OutlineAddChildButtons';
|
||||
|
||||
const CourseOutline = ({ courseId }) => {
|
||||
interface CourseOutlineProps {
|
||||
courseId: string,
|
||||
}
|
||||
|
||||
const CourseOutline = ({ courseId }: CourseOutlineProps) => {
|
||||
const intl = useIntl();
|
||||
const location = useLocation();
|
||||
|
||||
const {
|
||||
courseUsageKey,
|
||||
courseName,
|
||||
savingStatus,
|
||||
statusBarData,
|
||||
@@ -89,6 +96,9 @@ const CourseOutline = ({ courseId }) => {
|
||||
headerNavigationsActions,
|
||||
openEnableHighlightsModal,
|
||||
closeEnableHighlightsModal,
|
||||
isAddLibrarySectionModalOpen,
|
||||
openAddLibrarySectionModal,
|
||||
closeAddLibrarySectionModal,
|
||||
handleEnableHighlightsSubmit,
|
||||
handleInternetConnectionFailed,
|
||||
handleOpenHighlightsModal,
|
||||
@@ -104,6 +114,8 @@ const CourseOutline = ({ courseId }) => {
|
||||
handleNewSubsectionSubmit,
|
||||
handleNewUnitSubmit,
|
||||
handleAddUnitFromLibrary,
|
||||
handleAddSubsectionFromLibrary,
|
||||
handleAddSectionFromLibrary,
|
||||
getUnitUrl,
|
||||
handleVideoSharingOptionChange,
|
||||
handlePasteClipboardClick,
|
||||
@@ -119,10 +131,11 @@ const CourseOutline = ({ courseId }) => {
|
||||
handleSubsectionDragAndDrop,
|
||||
handleUnitDragAndDrop,
|
||||
errors,
|
||||
resetScrollState,
|
||||
} = useCourseOutline({ courseId });
|
||||
|
||||
// Use `setToastMessage` to show the toast.
|
||||
const [toastMessage, setToastMessage] = useState(/** @type{null|string} */ (null));
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for the course data to load before exporting tags.
|
||||
@@ -139,7 +152,7 @@ const CourseOutline = ({ courseId }) => {
|
||||
}
|
||||
}, [location, courseId, courseName]);
|
||||
|
||||
const [sections, setSections] = useState(sectionsList);
|
||||
const [sections, setSections] = useState<XBlock[]>(sectionsList);
|
||||
|
||||
const restoreSectionList = () => {
|
||||
setSections(() => [...sectionsList]);
|
||||
@@ -157,10 +170,8 @@ const CourseOutline = ({ courseId }) => {
|
||||
|
||||
/**
|
||||
* Move section to new index
|
||||
* @param {any} currentIndex
|
||||
* @param {any} newIndex
|
||||
*/
|
||||
const updateSectionOrderByIndex = (currentIndex, newIndex) => {
|
||||
const updateSectionOrderByIndex = (currentIndex: number, newIndex: number) => {
|
||||
if (currentIndex === newIndex) {
|
||||
return;
|
||||
}
|
||||
@@ -173,11 +184,8 @@ const CourseOutline = ({ courseId }) => {
|
||||
|
||||
/**
|
||||
* Uses details from move information and moves subsection
|
||||
* @param {any} section
|
||||
* @param {any} moveDetails
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateSubsectionOrderByIndex = (section, moveDetails) => {
|
||||
const updateSubsectionOrderByIndex = (section: XBlock, moveDetails) => {
|
||||
const { fn, args, sectionId } = moveDetails;
|
||||
if (!args) {
|
||||
return;
|
||||
@@ -196,11 +204,8 @@ const CourseOutline = ({ courseId }) => {
|
||||
|
||||
/**
|
||||
* Uses details from move information and moves unit
|
||||
* @param {any} section
|
||||
* @param {any} moveDetails
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateUnitOrderByIndex = (section, moveDetails) => {
|
||||
const updateUnitOrderByIndex = (section: XBlock, moveDetails) => {
|
||||
const {
|
||||
fn, args, sectionId, subsectionId,
|
||||
} = moveDetails;
|
||||
@@ -220,6 +225,16 @@ const CourseOutline = ({ courseId }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectLibrarySection = useCallback((selectedSection: SelectedComponent) => {
|
||||
handleAddSectionFromLibrary.mutateAsync({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: ContainerType.Chapter,
|
||||
parentLocator: courseUsageKey,
|
||||
libraryContentKey: selectedSection.usageKey,
|
||||
});
|
||||
closeAddLibrarySectionModal();
|
||||
}, [closeAddLibrarySectionModal, handleAddSectionFromLibrary.mutateAsync, courseId, courseUsageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setSections(sectionsList);
|
||||
}, [sectionsList]);
|
||||
@@ -357,6 +372,8 @@ const CourseOutline = ({ courseId }) => {
|
||||
isSectionsExpanded={isSectionsExpanded}
|
||||
onNewSubsectionSubmit={handleNewSubsectionSubmit}
|
||||
onOrderChange={updateSectionOrderByIndex}
|
||||
onAddSubsectionFromLibrary={handleAddSubsectionFromLibrary.mutateAsync}
|
||||
resetScrollState={resetScrollState}
|
||||
>
|
||||
<SortableContext
|
||||
id={section.id}
|
||||
@@ -385,9 +402,10 @@ const CourseOutline = ({ courseId }) => {
|
||||
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onNewUnitSubmit={handleNewUnitSubmit}
|
||||
onAddUnitFromLibrary={handleAddUnitFromLibrary}
|
||||
onAddUnitFromLibrary={handleAddUnitFromLibrary.mutateAsync}
|
||||
onOrderChange={updateSubsectionOrderByIndex}
|
||||
onPasteClick={handlePasteClipboardClick}
|
||||
resetScrollState={resetScrollState}
|
||||
>
|
||||
<SortableContext
|
||||
id={subsection.id}
|
||||
@@ -431,23 +449,25 @@ const CourseOutline = ({ courseId }) => {
|
||||
</SortableContext>
|
||||
</DraggableList>
|
||||
{courseActions.childAddable && (
|
||||
<Button
|
||||
data-testid="new-section-button"
|
||||
className="mt-4"
|
||||
variant="outline-primary"
|
||||
onClick={handleNewSectionSubmit}
|
||||
iconBefore={IconAdd}
|
||||
block
|
||||
>
|
||||
{intl.formatMessage(messages.newSectionButton)}
|
||||
</Button>
|
||||
<OutlineAddChildButtons
|
||||
handleNewButtonClick={handleNewSectionSubmit}
|
||||
handleUseFromLibraryClick={openAddLibrarySectionModal}
|
||||
childType={ContainerType.Section}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyPlaceholder
|
||||
onCreateNewSection={handleNewSectionSubmit}
|
||||
childAddable={courseActions.childAddable}
|
||||
/>
|
||||
<EmptyPlaceholder>
|
||||
{courseActions.childAddable && (
|
||||
<OutlineAddChildButtons
|
||||
handleNewButtonClick={handleNewSectionSubmit}
|
||||
handleUseFromLibraryClick={openAddLibrarySectionModal}
|
||||
childType={ContainerType.Section}
|
||||
btnVariant="primary"
|
||||
btnClasses="mt-1"
|
||||
/>
|
||||
)}
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -493,11 +513,33 @@ const CourseOutline = ({ courseId }) => {
|
||||
close={closeDeleteModal}
|
||||
onDeleteSubmit={handleDeleteItemSubmit}
|
||||
/>
|
||||
<StandardModal
|
||||
title={intl.formatMessage(messages.sectionPickerModalTitle)}
|
||||
isOpen={isAddLibrarySectionModalOpen}
|
||||
onClose={closeAddLibrarySectionModal}
|
||||
isOverflowVisible={false}
|
||||
size="xl"
|
||||
>
|
||||
<ComponentPicker
|
||||
showOnlyPublished
|
||||
extraFilter={['block_type = "section"']}
|
||||
componentPickerMode="single"
|
||||
onComponentSelected={handleSelectLibrarySection}
|
||||
visibleTabs={[ContentType.sections]}
|
||||
/>
|
||||
</StandardModal>
|
||||
</Container>
|
||||
<div className="alert-toast">
|
||||
<ProcessingNotification
|
||||
isShow={isShowProcessingNotification}
|
||||
title={processingNotificationTitle}
|
||||
// Show processing toast if any mutation is running
|
||||
isShow={
|
||||
isShowProcessingNotification
|
||||
|| handleAddUnitFromLibrary.isPending
|
||||
|| handleAddSubsectionFromLibrary.isPending
|
||||
|| handleAddSectionFromLibrary.isPending
|
||||
}
|
||||
// HACK: Use saving as default title till we have a need for better messages
|
||||
title={processingNotificationTitle || NOTIFICATION_MESSAGES.saving}
|
||||
/>
|
||||
<InternetConnectionAlert
|
||||
isFailed={isInternetConnectionAlertFailed}
|
||||
@@ -518,8 +560,4 @@ const CourseOutline = ({ courseId }) => {
|
||||
);
|
||||
};
|
||||
|
||||
CourseOutline.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default CourseOutline;
|
||||
42
src/course-outline/OutlineAddChildButtons.test.tsx
Normal file
42
src/course-outline/OutlineAddChildButtons.test.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
import {
|
||||
initializeMocks, render, screen, waitFor,
|
||||
} from '@src/testUtils';
|
||||
import OutlineAddChildButtons from './OutlineAddChildButtons';
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn().mockReturnValue({ librariesV2Enabled: true }),
|
||||
}));
|
||||
|
||||
[
|
||||
{ containerType: ContainerType.Section },
|
||||
{ containerType: ContainerType.Subsection },
|
||||
{ containerType: ContainerType.Unit },
|
||||
].forEach(({ containerType }) => {
|
||||
describe(`<OutlineAddChildButtons> for ${containerType}`, () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
it('renders and behaves correctly', async () => {
|
||||
const newClickHandler = jest.fn();
|
||||
const useFromLibClickHandler = jest.fn();
|
||||
render(<OutlineAddChildButtons
|
||||
handleNewButtonClick={newClickHandler}
|
||||
handleUseFromLibraryClick={useFromLibClickHandler}
|
||||
childType={containerType}
|
||||
/>);
|
||||
|
||||
const newBtn = await screen.findByRole('button', { name: `New ${containerType}` });
|
||||
expect(newBtn).toBeInTheDocument();
|
||||
const useBtn = await screen.findByRole('button', { name: `Use ${containerType} from library` });
|
||||
expect(useBtn).toBeInTheDocument();
|
||||
userEvent.click(newBtn);
|
||||
waitFor(() => expect(newClickHandler).toHaveBeenCalled());
|
||||
userEvent.click(useBtn);
|
||||
waitFor(() => expect(useFromLibClickHandler).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
});
|
||||
89
src/course-outline/OutlineAddChildButtons.tsx
Normal file
89
src/course-outline/OutlineAddChildButtons.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Button, Stack } from '@openedx/paragon';
|
||||
import { Add as IconAdd, Newsstand } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getStudioHomeData } from '@src/studio-home/data/selectors';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
import messages from './messages';
|
||||
|
||||
interface NewChildButtonsProps {
|
||||
handleNewButtonClick: () => void;
|
||||
handleUseFromLibraryClick: () => void;
|
||||
childType: ContainerType;
|
||||
btnVariant?: string;
|
||||
btnClasses?: string;
|
||||
btnSize?: 'sm' | 'md' | 'lg' | 'inline';
|
||||
}
|
||||
|
||||
const OutlineAddChildButtons = ({
|
||||
handleNewButtonClick,
|
||||
handleUseFromLibraryClick,
|
||||
childType,
|
||||
btnVariant = 'outline-primary',
|
||||
btnClasses = 'mt-4 border-gray-500 rounded-0',
|
||||
btnSize,
|
||||
}: NewChildButtonsProps) => {
|
||||
// WARNING: Do not use "useStudioHome" to get "librariesV2Enabled" flag below,
|
||||
// as it has a useEffect that fetches course waffle flags whenever
|
||||
// location.search is updated. Course search updates location.search when
|
||||
// user types, which will then trigger the useEffect and reload the page.
|
||||
// See https://github.com/openedx/frontend-app-authoring/pull/1938.
|
||||
const { librariesV2Enabled } = useSelector(getStudioHomeData);
|
||||
const intl = useIntl();
|
||||
let messageMap = {
|
||||
newButton: messages.newUnitButton,
|
||||
importButton: messages.useUnitFromLibraryButton,
|
||||
};
|
||||
|
||||
switch (childType) {
|
||||
case ContainerType.Section:
|
||||
messageMap = {
|
||||
newButton: messages.newSectionButton,
|
||||
importButton: messages.useSectionFromLibraryButton,
|
||||
};
|
||||
break;
|
||||
case ContainerType.Subsection:
|
||||
messageMap = {
|
||||
newButton: messages.newSubsectionButton,
|
||||
importButton: messages.useSubsectionFromLibraryButton,
|
||||
};
|
||||
break;
|
||||
case ContainerType.Unit:
|
||||
messageMap = {
|
||||
newButton: messages.newUnitButton,
|
||||
importButton: messages.useUnitFromLibraryButton,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="horizontal" gap={3}>
|
||||
<Button
|
||||
className={btnClasses}
|
||||
variant={btnVariant}
|
||||
iconBefore={IconAdd}
|
||||
size={btnSize}
|
||||
block
|
||||
onClick={handleNewButtonClick}
|
||||
>
|
||||
{intl.formatMessage(messageMap.newButton)}
|
||||
</Button>
|
||||
{librariesV2Enabled && (
|
||||
<Button
|
||||
className={btnClasses}
|
||||
variant={btnVariant}
|
||||
iconBefore={Newsstand}
|
||||
block
|
||||
size={btnSize}
|
||||
onClick={handleUseFromLibraryClick}
|
||||
>
|
||||
{intl.formatMessage(messageMap.importButton)}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default OutlineAddChildButtons;
|
||||
@@ -1,12 +1,9 @@
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import {
|
||||
act, render, fireEvent, waitFor, screen,
|
||||
} from '@testing-library/react';
|
||||
import { setConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { ITEM_BADGE_STATUS } from '../constants';
|
||||
import { ITEM_BADGE_STATUS } from '@src/course-outline/constants';
|
||||
import {
|
||||
act, fireEvent, initializeMocks, render, screen, waitFor,
|
||||
} from '@src/testUtils';
|
||||
import CardHeader from './CardHeader';
|
||||
import TitleButton from './TitleButton';
|
||||
import messages from './messages';
|
||||
@@ -56,9 +53,7 @@ const cardHeaderProps = {
|
||||
},
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const renderComponent = (props, entry = '/') => {
|
||||
const renderComponent = (props?: object, entry = '/') => {
|
||||
const titleComponent = (
|
||||
<TitleButton
|
||||
isExpanded
|
||||
@@ -70,113 +65,117 @@ const renderComponent = (props, entry = '/') => {
|
||||
);
|
||||
|
||||
return render(
|
||||
<IntlProvider locale="en">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<CardHeader
|
||||
{...cardHeaderProps}
|
||||
titleComponent={titleComponent}
|
||||
{...props}
|
||||
/>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>,
|
||||
<CardHeader
|
||||
{...cardHeaderProps}
|
||||
titleComponent={titleComponent}
|
||||
{...props}
|
||||
/>,
|
||||
{
|
||||
path: '/',
|
||||
routerProps: {
|
||||
initialEntries: [entry],
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
describe('<CardHeader />', () => {
|
||||
it('render CardHeader component correctly', async () => {
|
||||
const { findByText, findByTestId, queryByTestId } = renderComponent();
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
expect(await findByText(cardHeaderProps.title)).toBeInTheDocument();
|
||||
expect(await findByTestId('subsection-card-header__expanded-btn')).toBeInTheDocument();
|
||||
expect(await findByTestId('subsection-card-header__menu')).toBeInTheDocument();
|
||||
it('render CardHeader component correctly', async () => {
|
||||
renderComponent();
|
||||
|
||||
expect(await screen.findByText(cardHeaderProps.title)).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('subsection-card-header__expanded-btn')).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('subsection-card-header__menu')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId('edit field')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('edit field')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('render status badge as live', async () => {
|
||||
const { findByText } = renderComponent();
|
||||
expect(await findByText(messages.statusBadgeLive.defaultMessage)).toBeInTheDocument();
|
||||
renderComponent();
|
||||
expect(await screen.findByText(messages.statusBadgeLive.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render status badge as published_not_live', async () => {
|
||||
const { findByText } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
status: ITEM_BADGE_STATUS.publishedNotLive,
|
||||
});
|
||||
|
||||
expect(await findByText(messages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument();
|
||||
expect(await screen.findByText(messages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render status badge as staff_only', async () => {
|
||||
const { findByText } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
status: ITEM_BADGE_STATUS.staffOnly,
|
||||
});
|
||||
|
||||
expect(await findByText(messages.statusBadgeStaffOnly.defaultMessage)).toBeInTheDocument();
|
||||
expect(await screen.findByText(messages.statusBadgeStaffOnly.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render status badge as draft', async () => {
|
||||
const { findByText } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
status: ITEM_BADGE_STATUS.draft,
|
||||
});
|
||||
|
||||
expect(await findByText(messages.statusBadgeDraft.defaultMessage)).toBeInTheDocument();
|
||||
expect(await screen.findByText(messages.statusBadgeDraft.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('check publish menu item is disabled when subsection status is live or published not live and it has no changes', async () => {
|
||||
const { findByText, findByTestId } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
status: ITEM_BADGE_STATUS.publishedNotLive,
|
||||
});
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menuButton);
|
||||
expect(await findByText(messages.menuPublish.defaultMessage)).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(await screen.findByText(messages.menuPublish.defaultMessage)).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('check publish menu item is enabled when subsection status is live or published not live and it has changes', async () => {
|
||||
const { findByText, findByTestId } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
status: ITEM_BADGE_STATUS.publishedNotLive,
|
||||
hasChanges: true,
|
||||
});
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menuButton);
|
||||
expect(await findByText(messages.menuPublish.defaultMessage)).not.toHaveAttribute('aria-disabled');
|
||||
expect(await screen.findByText(messages.menuPublish.defaultMessage)).not.toHaveAttribute('aria-disabled');
|
||||
});
|
||||
|
||||
it('calls handleExpanded when button is clicked', async () => {
|
||||
const { findByTestId } = renderComponent();
|
||||
renderComponent();
|
||||
|
||||
const expandButton = await findByTestId('subsection-card-header__expanded-btn');
|
||||
const expandButton = await screen.findByTestId('subsection-card-header__expanded-btn');
|
||||
fireEvent.click(expandButton);
|
||||
expect(onExpandMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClickMenuButton when menu is clicked', async () => {
|
||||
const { findByTestId } = renderComponent();
|
||||
renderComponent();
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menuButton));
|
||||
expect(onClickMenuButtonMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClickPublish when item is clicked', async () => {
|
||||
const { findByText, findByTestId } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
status: ITEM_BADGE_STATUS.draft,
|
||||
});
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const publishMenuItem = await findByText(messages.menuPublish.defaultMessage);
|
||||
const publishMenuItem = await screen.findByText(messages.menuPublish.defaultMessage);
|
||||
await act(async () => fireEvent.click(publishMenuItem));
|
||||
expect(onClickPublishMock).toHaveBeenCalled();
|
||||
});
|
||||
@@ -210,119 +209,114 @@ describe('<CardHeader />', () => {
|
||||
});
|
||||
|
||||
it('calls onClickEdit when the button is clicked', async () => {
|
||||
const { findByTestId } = renderComponent();
|
||||
renderComponent();
|
||||
|
||||
const editButton = await findByTestId('subsection-edit-button');
|
||||
const editButton = await screen.findByTestId('subsection-edit-button');
|
||||
await act(async () => fireEvent.click(editButton));
|
||||
expect(onClickEditMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('check is field visible when isFormOpen is true', async () => {
|
||||
const { findByTestId, queryByTestId } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
isFormOpen: true,
|
||||
});
|
||||
|
||||
expect(await findByTestId('subsection-edit-field')).toBeInTheDocument();
|
||||
expect(await screen.findByTestId('subsection-edit-field')).toBeInTheDocument();
|
||||
waitFor(() => {
|
||||
expect(queryByTestId('subsection-card-header__expanded-btn')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('edit-button')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('subsection-card-header__expanded-btn')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('edit-button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('check is field disabled when isDisabledEditField is true', async () => {
|
||||
const { findByTestId } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
isFormOpen: true,
|
||||
isDisabledEditField: true,
|
||||
});
|
||||
|
||||
expect(await findByTestId('subsection-edit-field')).toBeDisabled();
|
||||
expect(await screen.findByTestId('subsection-edit-field')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('check editing is enabled when isDisabledEditField is false', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
...cardHeaderProps,
|
||||
});
|
||||
renderComponent({ ...cardHeaderProps });
|
||||
|
||||
expect(getByTestId('subsection-edit-button')).toBeEnabled();
|
||||
expect(screen.getByTestId('subsection-edit-button')).toBeEnabled();
|
||||
|
||||
// Ensure menu items related to editing are enabled
|
||||
const menuButton = getByTestId('subsection-card-header__menu-button');
|
||||
const menuButton = screen.getByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menuButton));
|
||||
expect(await getByTestId('subsection-card-header__menu-configure-button')).not.toHaveAttribute('aria-disabled');
|
||||
expect(await getByTestId('subsection-card-header__menu-manage-tags-button')).not.toHaveAttribute('aria-disabled');
|
||||
expect(await screen.findByTestId('subsection-card-header__menu-configure-button')).not.toHaveAttribute('aria-disabled');
|
||||
expect(await screen.findByTestId('subsection-card-header__menu-manage-tags-button')).not.toHaveAttribute('aria-disabled');
|
||||
});
|
||||
|
||||
it('check editing is disabled when isDisabledEditField is true', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
...cardHeaderProps,
|
||||
isDisabledEditField: true,
|
||||
});
|
||||
renderComponent({ ...cardHeaderProps, isDisabledEditField: true });
|
||||
|
||||
expect(await getByTestId('subsection-edit-button')).toBeDisabled();
|
||||
expect(await screen.findByTestId('subsection-edit-button')).toBeDisabled();
|
||||
|
||||
// Ensure menu items related to editing are disabled
|
||||
const menuButton = getByTestId('subsection-card-header__menu-button');
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menuButton));
|
||||
expect(await getByTestId('subsection-card-header__menu-configure-button')).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(await getByTestId('subsection-card-header__menu-manage-tags-button')).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(await screen.findByTestId('subsection-card-header__menu-configure-button')).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(await screen.findByTestId('subsection-card-header__menu-manage-tags-button')).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('calls onClickDelete when item is clicked', async () => {
|
||||
const { findByText, findByTestId } = renderComponent();
|
||||
renderComponent();
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menuButton));
|
||||
const deleteMenuItem = await findByText(messages.menuDelete.defaultMessage);
|
||||
const deleteMenuItem = await screen.findByText(messages.menuDelete.defaultMessage);
|
||||
await act(async () => fireEvent.click(deleteMenuItem));
|
||||
expect(onClickDeleteMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClickDuplicate when item is clicked', async () => {
|
||||
const { findByText, findByTestId } = renderComponent();
|
||||
renderComponent();
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const duplicateMenuItem = await findByText(messages.menuDuplicate.defaultMessage);
|
||||
const duplicateMenuItem = await screen.findByText(messages.menuDuplicate.defaultMessage);
|
||||
fireEvent.click(duplicateMenuItem);
|
||||
await act(async () => fireEvent.click(duplicateMenuItem));
|
||||
expect(onClickDuplicateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('check if proctoringExamConfigurationLink is visible', async () => {
|
||||
const { findByText, findByTestId } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
proctoringExamConfigurationLink: 'proctoringlink',
|
||||
isSequential: true,
|
||||
});
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menuButton));
|
||||
|
||||
const element = await findByText(messages.menuProctoringLinkText.defaultMessage);
|
||||
const element = await screen.findByText(messages.menuProctoringLinkText.defaultMessage);
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element.getAttribute('href')).toBe(`${getConfig().STUDIO_BASE_URL}/proctoringlink`);
|
||||
});
|
||||
|
||||
it('check if proctoringExamConfigurationLink is absolute', async () => {
|
||||
const { findByText, findByTestId } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
proctoringExamConfigurationLink: 'http://localhost:9000/proctoringlink',
|
||||
isSequential: true,
|
||||
});
|
||||
|
||||
const menuButton = await findByTestId('subsection-card-header__menu-button');
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menuButton));
|
||||
|
||||
const element = await findByText(messages.menuProctoringLinkText.defaultMessage);
|
||||
const element = await screen.findByText(messages.menuProctoringLinkText.defaultMessage);
|
||||
expect(element).toBeInTheDocument();
|
||||
expect(element.getAttribute('href')).toBe('http://localhost:9000/proctoringlink');
|
||||
});
|
||||
|
||||
it('check if discussion enabled badge is visible', async () => {
|
||||
const { queryByText } = renderComponent({
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
isVertical: true,
|
||||
discussionEnabled: true,
|
||||
@@ -336,7 +330,7 @@ describe('<CardHeader />', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByText(messages.discussionEnabledBadgeText.defaultMessage)).toBeInTheDocument();
|
||||
expect(screen.queryByText(messages.discussionEnabledBadgeText.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render tag count if is not zero and the waffle flag is enabled', async () => {
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-check
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
ReactNode, useEffect, useRef, useState,
|
||||
} from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
@@ -18,15 +18,57 @@ import {
|
||||
Sync as SyncIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import { useContentTagsCount } from '../../generic/data/apiHooks';
|
||||
import { ContentTagsDrawerSheet } from '../../content-tags-drawer';
|
||||
import TagCount from '../../generic/tag-count';
|
||||
import { useEscapeClick } from '../../hooks';
|
||||
import { useContentTagsCount } from '@src/generic/data/apiHooks';
|
||||
import { ContentTagsDrawerSheet } from '@src/content-tags-drawer';
|
||||
import TagCount from '@src/generic/tag-count';
|
||||
import { useEscapeClick } from '@src/hooks';
|
||||
import { XBlockActions } from '@src/data/types';
|
||||
import { ITEM_BADGE_STATUS } from '../constants';
|
||||
import { scrollToElement } from '../utils';
|
||||
import CardStatus from './CardStatus';
|
||||
import messages from './messages';
|
||||
|
||||
interface CardHeaderProps {
|
||||
title: string;
|
||||
status: string;
|
||||
cardId?: string,
|
||||
hasChanges: boolean;
|
||||
onClickPublish: () => void;
|
||||
onClickConfigure: () => void;
|
||||
onClickMenuButton: () => void;
|
||||
onClickEdit: () => void;
|
||||
isFormOpen: boolean;
|
||||
onEditSubmit: (titleValue: string) => void;
|
||||
closeForm: () => void;
|
||||
isDisabledEditField: boolean;
|
||||
onClickDelete: () => void;
|
||||
onClickDuplicate: () => void;
|
||||
onClickMoveUp: () => void;
|
||||
onClickMoveDown: () => void;
|
||||
onClickCopy?: () => void;
|
||||
titleComponent: ReactNode;
|
||||
namePrefix: string;
|
||||
proctoringExamConfigurationLink?: string,
|
||||
actions: XBlockActions,
|
||||
enableCopyPasteUnits?: boolean;
|
||||
isVertical?: boolean;
|
||||
isSequential?: boolean;
|
||||
discussionEnabled?: boolean;
|
||||
discussionsSettings?: {
|
||||
providerType: string;
|
||||
enableGradedUnits: boolean;
|
||||
};
|
||||
parentInfo?: {
|
||||
graded: boolean;
|
||||
isTimeLimited?: boolean;
|
||||
},
|
||||
// An optional component that is rendered before the dropdown. This is used by the Subsection
|
||||
// and Unit card components to render their plugin slots.
|
||||
extraActionsComponent?: ReactNode,
|
||||
onClickSync?: () => void;
|
||||
readyToSync?: boolean;
|
||||
}
|
||||
|
||||
const CardHeader = ({
|
||||
title,
|
||||
status,
|
||||
@@ -58,7 +100,7 @@ const CardHeader = ({
|
||||
extraActionsComponent,
|
||||
onClickSync,
|
||||
readyToSync,
|
||||
}) => {
|
||||
}: CardHeaderProps) => {
|
||||
const intl = useIntl();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [titleValue, setTitleValue] = useState(title);
|
||||
@@ -93,7 +135,7 @@ const CardHeader = ({
|
||||
&& discussionsSettings?.providerType === 'openedx'
|
||||
&& (
|
||||
discussionsSettings?.enableGradedUnits
|
||||
|| (!discussionsSettings?.enableGradedUnits && !parentInfo.graded)
|
||||
|| (!discussionsSettings?.enableGradedUnits && !parentInfo?.graded)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -155,7 +197,7 @@ const CardHeader = ({
|
||||
)}
|
||||
<div className="ml-auto d-flex">
|
||||
{(isVertical || isSequential) && (
|
||||
<CardStatus status={status} showDiscussionsEnabledBadge={showDiscussionsEnabledBadge} />
|
||||
<CardStatus status={status} showDiscussionsEnabledBadge={showDiscussionsEnabledBadge || false} />
|
||||
)}
|
||||
{ getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && !!contentTagCount && (
|
||||
<TagCount count={contentTagCount} onClick={openManageTagsDrawer} />
|
||||
@@ -260,67 +302,4 @@ const CardHeader = ({
|
||||
);
|
||||
};
|
||||
|
||||
CardHeader.defaultProps = {
|
||||
enableCopyPasteUnits: false,
|
||||
isVertical: false,
|
||||
isSequential: false,
|
||||
onClickCopy: null,
|
||||
proctoringExamConfigurationLink: null,
|
||||
discussionEnabled: false,
|
||||
discussionsSettings: {},
|
||||
parentInfo: {},
|
||||
cardId: '',
|
||||
extraActionsComponent: null,
|
||||
readyToSync: false,
|
||||
onClickSync: null,
|
||||
};
|
||||
|
||||
CardHeader.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
status: PropTypes.string.isRequired,
|
||||
cardId: PropTypes.string,
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
onClickPublish: PropTypes.func.isRequired,
|
||||
onClickConfigure: PropTypes.func.isRequired,
|
||||
onClickMenuButton: PropTypes.func.isRequired,
|
||||
onClickEdit: PropTypes.func.isRequired,
|
||||
isFormOpen: PropTypes.bool.isRequired,
|
||||
onEditSubmit: PropTypes.func.isRequired,
|
||||
closeForm: PropTypes.func.isRequired,
|
||||
isDisabledEditField: PropTypes.bool.isRequired,
|
||||
onClickDelete: PropTypes.func.isRequired,
|
||||
onClickDuplicate: PropTypes.func.isRequired,
|
||||
onClickMoveUp: PropTypes.func.isRequired,
|
||||
onClickMoveDown: PropTypes.func.isRequired,
|
||||
onClickCopy: PropTypes.func,
|
||||
titleComponent: PropTypes.node.isRequired,
|
||||
namePrefix: PropTypes.string.isRequired,
|
||||
proctoringExamConfigurationLink: PropTypes.string,
|
||||
actions: PropTypes.shape({
|
||||
deletable: PropTypes.bool.isRequired,
|
||||
draggable: PropTypes.bool.isRequired,
|
||||
childAddable: PropTypes.bool.isRequired,
|
||||
duplicable: PropTypes.bool.isRequired,
|
||||
allowMoveUp: PropTypes.bool,
|
||||
allowMoveDown: PropTypes.bool,
|
||||
}).isRequired,
|
||||
enableCopyPasteUnits: PropTypes.bool,
|
||||
isVertical: PropTypes.bool,
|
||||
isSequential: PropTypes.bool,
|
||||
discussionEnabled: PropTypes.bool,
|
||||
discussionsSettings: PropTypes.shape({
|
||||
providerType: PropTypes.string,
|
||||
enableGradedUnits: PropTypes.bool,
|
||||
}),
|
||||
parentInfo: PropTypes.shape({
|
||||
isTimeLimited: PropTypes.bool,
|
||||
graded: PropTypes.bool,
|
||||
}),
|
||||
// An optional component that is rendered before the dropdown. This is used by the Subsection
|
||||
// and Unit card components to render their plugin slots.
|
||||
extraActionsComponent: PropTypes.node,
|
||||
onClickSync: PropTypes.func,
|
||||
readyToSync: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default CardHeader;
|
||||
@@ -1,15 +1,22 @@
|
||||
// @ts-check
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import { CourseOutline } from './types';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
|
||||
export const getCourseOutlineIndexApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_index/${courseId}`;
|
||||
export const getCourseOutlineIndexApiUrl = (
|
||||
courseId: string,
|
||||
) => `${getApiBaseUrl()}/api/contentstore/v1/course_index/${courseId}`;
|
||||
|
||||
export const getCourseBestPracticesApiUrl = ({
|
||||
courseId,
|
||||
excludeGraded,
|
||||
all,
|
||||
}: {
|
||||
courseId: string,
|
||||
excludeGraded: boolean,
|
||||
all: boolean,
|
||||
}) => `${getApiBaseUrl()}/api/courses/v1/quality/${courseId}/?exclude_graded=${excludeGraded}&all=${all}`;
|
||||
|
||||
export const getCourseLaunchApiUrl = ({
|
||||
@@ -17,42 +24,31 @@ export const getCourseLaunchApiUrl = ({
|
||||
gradedOnly,
|
||||
validateOras,
|
||||
all,
|
||||
}:{
|
||||
courseId: string,
|
||||
gradedOnly: boolean,
|
||||
validateOras: boolean,
|
||||
all: boolean,
|
||||
}) => `${getApiBaseUrl()}/api/courses/v1/validation/${courseId}/?graded_only=${gradedOnly}&validate_oras=${validateOras}&all=${all}`;
|
||||
|
||||
export const getCourseBlockApiUrl = (courseId) => {
|
||||
export const getCourseBlockApiUrl = (courseId: string) => {
|
||||
const formattedCourseId = courseId.split('course-v1:')[1];
|
||||
return `${getApiBaseUrl()}/xblock/block-v1:${formattedCourseId}+type@course+block@course`;
|
||||
};
|
||||
|
||||
export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${reindexLink}`;
|
||||
export const getCourseReindexApiUrl = (reindexLink: string) => `${getApiBaseUrl()}${reindexLink}`;
|
||||
export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
|
||||
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
|
||||
export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
|
||||
export const exportTags = (courseId) => `${getApiBaseUrl()}/api/content_tagging/v1/object_tags/${courseId}/export/`;
|
||||
|
||||
/**
|
||||
* @typedef {Object} courseOutline
|
||||
* @property {string} courseReleaseDate
|
||||
* @property {Object} courseStructure
|
||||
* @property {Object} deprecatedBlocksInfo
|
||||
* @property {string} discussionsIncontextLearnmoreUrl
|
||||
* @property {Object} initialState
|
||||
* @property {Object} initialUserClipboard
|
||||
* @property {string} languageCode
|
||||
* @property {string} lmsLink
|
||||
* @property {string} mfeProctoredExamSettingsUrl
|
||||
* @property {string} notificationDismissUrl
|
||||
* @property {string[]} proctoringErrors
|
||||
* @property {string} reindexLink
|
||||
* @property {null} rerunNotificationId
|
||||
*/
|
||||
export const getCourseItemApiUrl = (itemId: string) => `${getXBlockBaseApiUrl()}${itemId}`;
|
||||
export const getXBlockApiUrl = (blockId: string) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
|
||||
export const exportTags = (courseId: string) => `${getApiBaseUrl()}/api/content_tagging/v1/object_tags/${courseId}/export/`;
|
||||
export const createDiscussionsTopicsUrl = (courseId: string) => `${getApiBaseUrl()}/api/discussions/v0/course/${courseId}/sync_discussion_topics`;
|
||||
|
||||
/**
|
||||
* Get course outline index.
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<courseOutline>}
|
||||
*/
|
||||
export async function getCourseOutlineIndex(courseId) {
|
||||
export async function getCourseOutlineIndex(courseId: string): Promise<CourseOutline> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseOutlineIndexApiUrl(courseId));
|
||||
|
||||
@@ -64,9 +60,9 @@ export async function getCourseOutlineIndex(courseId) {
|
||||
* @param courseId
|
||||
* @returns {Promise<Array|Object>}
|
||||
*/
|
||||
export async function createDiscussionsTopics(courseId) {
|
||||
export async function createDiscussionsTopics(courseId: string): Promise<Array<any> | object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(`${getApiBaseUrl()}/api/discussions/v0/course/${courseId}/sync_discussion_topics`);
|
||||
.post(createDiscussionsTopicsUrl(courseId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
@@ -79,35 +75,46 @@ export async function getCourseBestPractices({
|
||||
courseId,
|
||||
excludeGraded,
|
||||
all,
|
||||
}) {
|
||||
}: {
|
||||
courseId: string;
|
||||
excludeGraded: boolean;
|
||||
all: boolean;
|
||||
}): Promise<{
|
||||
isSelfPaced: boolean;
|
||||
sections: any;
|
||||
subsection: any;
|
||||
units: any;
|
||||
videos: any;
|
||||
}> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseBestPracticesApiUrl({ courseId, excludeGraded, all }));
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/** @typedef {object} courseLaunchData
|
||||
* @property {boolean} isSelfPaced
|
||||
* @property {object} dates
|
||||
* @property {object} assignments
|
||||
* @property {object} grades
|
||||
* @property {number} grades.sum_of_weights
|
||||
* @property {object} certificates
|
||||
* @property {object} updates
|
||||
* @property {object} proctoring
|
||||
*/
|
||||
interface CourseLaunchData {
|
||||
isSelfPaced: boolean;
|
||||
dates: object;
|
||||
assignments: object;
|
||||
grades: {
|
||||
sum_of_weights: number;
|
||||
};
|
||||
certificates: object;
|
||||
updates: object;
|
||||
proctoring: object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get course launch.
|
||||
* @param {{courseId: string, gradedOnly: boolean, validateOras: boolean, all: boolean}} options
|
||||
* @returns {Promise<courseLaunchData>}
|
||||
* @returns {Promise<CourseLaunchData>}
|
||||
*/
|
||||
export async function getCourseLaunch({
|
||||
courseId,
|
||||
gradedOnly,
|
||||
validateOras,
|
||||
all,
|
||||
}) {
|
||||
}: { courseId: string; gradedOnly: boolean; validateOras: boolean; all: boolean; }): Promise<CourseLaunchData> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseLaunchApiUrl({
|
||||
courseId, gradedOnly, validateOras, all,
|
||||
@@ -121,7 +128,7 @@ export async function getCourseLaunch({
|
||||
* @param {string} courseId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function enableCourseHighlightsEmails(courseId) {
|
||||
export async function enableCourseHighlightsEmails(courseId: string): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseBlockApiUrl(courseId), {
|
||||
publish: 'republish',
|
||||
@@ -138,7 +145,7 @@ export async function enableCourseHighlightsEmails(courseId) {
|
||||
* @param {string} reindexLink
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function restartIndexingOnCourse(reindexLink) {
|
||||
export async function restartIndexingOnCourse(reindexLink: string): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCourseReindexApiUrl(reindexLink));
|
||||
|
||||
@@ -146,49 +153,11 @@ export async function restartIndexingOnCourse(reindexLink) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} section
|
||||
* @property {string} id
|
||||
* @property {string} displayName
|
||||
* @property {string} category
|
||||
* @property {boolean} hasChildren
|
||||
* @property {string} editedOn
|
||||
* @property {boolean} published
|
||||
* @property {string} publishedOn
|
||||
* @property {string} studioUrl
|
||||
* @property {boolean} releasedToStudents
|
||||
* @property {string} releaseDate
|
||||
* @property {string} visibilityState
|
||||
* @property {boolean} hasExplicitStaffLock
|
||||
* @property {string} start
|
||||
* @property {boolean} graded
|
||||
* @property {string} dueDate
|
||||
* @property {null} due
|
||||
* @property {null} relativeWeeksDue
|
||||
* @property {null} format
|
||||
* @property {string[]} courseGraders
|
||||
* @property {boolean} hasChanges
|
||||
* @property {object} actions
|
||||
* @property {null} explanatoryMessage
|
||||
* @property {object[]} userPartitions
|
||||
* @property {string} showCorrectness
|
||||
* @property {string[]} highlights
|
||||
* @property {boolean} highlightsEnabled
|
||||
* @property {boolean} highlightsPreviewOnly
|
||||
* @property {string} highlightsDocUrl
|
||||
* @property {object} childInfo
|
||||
* @property {boolean} ancestorHasStaffLock
|
||||
* @property {boolean} staffOnlyMessage
|
||||
* @property {boolean} hasPartitionGroupComponents
|
||||
* @property {object} userPartitionInfo
|
||||
* @property {boolean} enableCopyPasteUnits
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get course section
|
||||
* Get course Xblock
|
||||
* @param {string} itemId
|
||||
* @returns {Promise<section>}
|
||||
* @returns {Promise<XBlock>}
|
||||
*/
|
||||
export async function getCourseItem(itemId) {
|
||||
export async function getCourseItem(itemId: string): Promise<XBlock> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getXBlockApiUrl(itemId));
|
||||
return camelCaseObject(data);
|
||||
@@ -200,7 +169,10 @@ export async function getCourseItem(itemId) {
|
||||
* @param {Array<string>} highlights
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function updateCourseSectionHighlights(sectionId, highlights) {
|
||||
export async function updateCourseSectionHighlights(
|
||||
sectionId: string,
|
||||
highlights: Array<string>,
|
||||
): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(sectionId), {
|
||||
publish: 'republish',
|
||||
@@ -217,7 +189,7 @@ export async function updateCourseSectionHighlights(sectionId, highlights) {
|
||||
* @param {string} sectionId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function publishCourseSection(sectionId) {
|
||||
export async function publishCourseSection(sectionId: string): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(sectionId), {
|
||||
publish: 'make_public',
|
||||
@@ -233,7 +205,11 @@ export async function publishCourseSection(sectionId) {
|
||||
* @param {string} startDatetime
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function configureCourseSection(sectionId, isVisibleToStaffOnly, startDatetime) {
|
||||
export async function configureCourseSection(
|
||||
sectionId: string,
|
||||
isVisibleToStaffOnly: boolean,
|
||||
startDatetime: string,
|
||||
): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(sectionId), {
|
||||
publish: 'republish',
|
||||
@@ -269,24 +245,24 @@ export async function configureCourseSection(sectionId, isVisibleToStaffOnly, st
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function configureCourseSubsection(
|
||||
itemId,
|
||||
isVisibleToStaffOnly,
|
||||
releaseDate,
|
||||
graderType,
|
||||
dueDate,
|
||||
isTimeLimited,
|
||||
isProctoredExam,
|
||||
isOnboardingExam,
|
||||
isPracticeExam,
|
||||
examReviewRules,
|
||||
defaultTimeLimitMin,
|
||||
hideAfterDue,
|
||||
showCorrectness,
|
||||
isPrereq,
|
||||
prereqUsageKey,
|
||||
prereqMinScore,
|
||||
prereqMinCompletion,
|
||||
) {
|
||||
itemId: string,
|
||||
isVisibleToStaffOnly: string,
|
||||
releaseDate: string,
|
||||
graderType: string,
|
||||
dueDate: string,
|
||||
isTimeLimited: boolean,
|
||||
isProctoredExam: boolean,
|
||||
isOnboardingExam: boolean,
|
||||
isPracticeExam: boolean,
|
||||
examReviewRules: string,
|
||||
defaultTimeLimitMin: number,
|
||||
hideAfterDue: string,
|
||||
showCorrectness: string,
|
||||
isPrereq: boolean,
|
||||
prereqUsageKey: string,
|
||||
prereqMinScore: number,
|
||||
prereqMinCompletion: number,
|
||||
): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(itemId), {
|
||||
publish: 'republish',
|
||||
@@ -318,9 +294,15 @@ export async function configureCourseSubsection(
|
||||
* @param {string} unitId
|
||||
* @param {boolean} isVisibleToStaffOnly
|
||||
* @param {object} groupAccess
|
||||
* @param {boolean} discussionEnabled
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function configureCourseUnit(unitId, isVisibleToStaffOnly, groupAccess, discussionEnabled) {
|
||||
export async function configureCourseUnit(
|
||||
unitId: string,
|
||||
isVisibleToStaffOnly: boolean,
|
||||
groupAccess: object,
|
||||
discussionEnabled: boolean,
|
||||
): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(unitId), {
|
||||
publish: 'republish',
|
||||
@@ -341,7 +323,10 @@ export async function configureCourseUnit(unitId, isVisibleToStaffOnly, groupAcc
|
||||
* @param {string} displayName
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function editItemDisplayName(itemId, displayName) {
|
||||
export async function editItemDisplayName(
|
||||
itemId: string,
|
||||
displayName: string,
|
||||
): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(itemId), {
|
||||
metadata: {
|
||||
@@ -357,7 +342,7 @@ export async function editItemDisplayName(itemId, displayName) {
|
||||
* @param {string} itemId
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function deleteCourseItem(itemId) {
|
||||
export async function deleteCourseItem(itemId: string): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.delete(getCourseItemApiUrl(itemId));
|
||||
|
||||
@@ -368,9 +353,9 @@ export async function deleteCourseItem(itemId) {
|
||||
* Duplicate course section
|
||||
* @param {string} itemId
|
||||
* @param {string} parentId
|
||||
* @returns {Promise<Object>}
|
||||
* @returns {Promise<XBlock>}
|
||||
*/
|
||||
export async function duplicateCourseItem(itemId, parentId) {
|
||||
export async function duplicateCourseItem(itemId: string, parentId: string): Promise<XBlock> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXBlockBaseApiUrl(), {
|
||||
duplicate_source_locator: itemId,
|
||||
@@ -387,7 +372,7 @@ export async function duplicateCourseItem(itemId, parentId) {
|
||||
* @param {string} displayName
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function addNewCourseItem(parentLocator, category, displayName) {
|
||||
export async function addNewCourseItem(parentLocator: string, category: string, displayName: string): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXBlockBaseApiUrl(), {
|
||||
parent_locator: parentLocator,
|
||||
@@ -404,7 +389,7 @@ export async function addNewCourseItem(parentLocator, category, displayName) {
|
||||
* @param {Array<string>} children list of sections id's
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function setSectionOrderList(courseId, children) {
|
||||
export async function setSectionOrderList(courseId: string, children: Array<string>): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.put(getCourseBlockApiUrl(courseId), {
|
||||
children,
|
||||
@@ -419,7 +404,7 @@ export async function setSectionOrderList(courseId, children) {
|
||||
* @param {Array<string>} children list of sections id's
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function setCourseItemOrderList(itemId, children) {
|
||||
export async function setCourseItemOrderList(itemId: string, children: Array<string>): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.put(getCourseItemApiUrl(itemId), {
|
||||
children,
|
||||
@@ -434,7 +419,10 @@ export async function setCourseItemOrderList(itemId, children) {
|
||||
* @param {string} videoSharingOption
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function setVideoSharingOption(courseId, videoSharingOption) {
|
||||
export async function setVideoSharingOption(
|
||||
courseId: string,
|
||||
videoSharingOption: string,
|
||||
): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseBlockApiUrl(courseId), {
|
||||
metadata: {
|
||||
@@ -450,7 +438,7 @@ export async function setVideoSharingOption(courseId, videoSharingOption) {
|
||||
* @param {string} parentLocator
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function pasteBlock(parentLocator) {
|
||||
export async function pasteBlock(parentLocator: string): Promise<object> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getXBlockBaseApiUrl(), {
|
||||
parent_locator: parentLocator,
|
||||
@@ -465,7 +453,7 @@ export async function pasteBlock(parentLocator) {
|
||||
* @param {string} url
|
||||
* @returns void
|
||||
*/
|
||||
export async function dismissNotification(url) {
|
||||
export async function dismissNotification(url: string) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.delete(url);
|
||||
}
|
||||
@@ -473,9 +461,10 @@ export async function dismissNotification(url) {
|
||||
/**
|
||||
* Downloads the file of the exported tags
|
||||
* @param {string} courseId The ID of the content
|
||||
* @param {string} courseName
|
||||
* @returns void
|
||||
*/
|
||||
export async function getTagsExportFile(courseId, courseName) {
|
||||
export async function getTagsExportFile(courseId: string, courseName: string) {
|
||||
// Gets exported tags and builds the blob to download CSV file.
|
||||
// This can be done with this code:
|
||||
// `window.location.href = exportTags(contentId);`
|
||||
24
src/course-outline/data/apiHooks.ts
Normal file
24
src/course-outline/data/apiHooks.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { createCourseXblock } from '@src/course-unit/data/api';
|
||||
|
||||
export const courseOutlineQueryKeys = {
|
||||
all: ['courseOutline'],
|
||||
/**
|
||||
* Base key for data specific to a course in outline
|
||||
*/
|
||||
contentLibrary: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId],
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to create an XBLOCK in a course .
|
||||
* The `locator` is the ID of the parent block where this new XBLOCK should be created.
|
||||
* Can also be used to import block from library by passing `libraryContentKey` in request body
|
||||
*/
|
||||
export const useCreateCourseBlock = (
|
||||
callback?: ((locator?: string, parentLocator?: string) => void),
|
||||
) => useMutation({
|
||||
mutationFn: createCourseXblock,
|
||||
onSettled: async (data) => {
|
||||
callback?.(data.locator, data.parent_locator);
|
||||
},
|
||||
});
|
||||
@@ -1,142 +1,157 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import { VIDEO_SHARING_OPTIONS } from '../constants';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { CourseOutlineState } from './types';
|
||||
|
||||
const initialState = {
|
||||
loadingStatus: {
|
||||
outlineIndexLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
reIndexLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
fetchSectionLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
courseLaunchQueryStatus: RequestStatus.IN_PROGRESS,
|
||||
},
|
||||
errors: {
|
||||
outlineIndexApi: null,
|
||||
reindexApi: null,
|
||||
sectionLoadingApi: null,
|
||||
courseLaunchApi: null,
|
||||
},
|
||||
outlineIndexData: {},
|
||||
savingStatus: '',
|
||||
statusBarData: {
|
||||
courseReleaseDate: '',
|
||||
highlightsEnabledForMessaging: false,
|
||||
isSelfPaced: false,
|
||||
checklist: {
|
||||
totalCourseLaunchChecks: 0,
|
||||
completedCourseLaunchChecks: 0,
|
||||
totalCourseBestPracticesChecks: 0,
|
||||
completedCourseBestPracticesChecks: 0,
|
||||
},
|
||||
videoSharingEnabled: false,
|
||||
videoSharingOptions: VIDEO_SHARING_OPTIONS.perVideo,
|
||||
},
|
||||
sectionsList: [],
|
||||
isCustomRelativeDatesActive: false,
|
||||
currentSection: {},
|
||||
currentSubsection: {},
|
||||
currentItem: {},
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
allowMoveUp: false,
|
||||
allowMoveDown: false,
|
||||
},
|
||||
enableProctoredExams: false,
|
||||
pasteFileNotices: {},
|
||||
createdOn: null,
|
||||
} satisfies CourseOutlineState as unknown as CourseOutlineState;
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'courseOutline',
|
||||
initialState: {
|
||||
loadingStatus: {
|
||||
outlineIndexLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
reIndexLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
fetchSectionLoadingStatus: RequestStatus.IN_PROGRESS,
|
||||
courseLaunchQueryStatus: RequestStatus.IN_PROGRESS,
|
||||
},
|
||||
errors: {
|
||||
outlineIndexApi: null,
|
||||
reindexApi: null,
|
||||
sectionLoadingApi: null,
|
||||
courseLaunchApi: null,
|
||||
},
|
||||
outlineIndexData: {},
|
||||
savingStatus: '',
|
||||
statusBarData: {
|
||||
courseReleaseDate: '',
|
||||
highlightsEnabledForMessaging: false,
|
||||
isSelfPaced: false,
|
||||
checklist: {
|
||||
totalCourseLaunchChecks: 0,
|
||||
completedCourseLaunchChecks: 0,
|
||||
totalCourseBestPracticesChecks: 0,
|
||||
completedCourseBestPracticesChecks: 0,
|
||||
},
|
||||
videoSharingEnabled: false,
|
||||
videoSharingOptions: VIDEO_SHARING_OPTIONS.perVideo,
|
||||
},
|
||||
sectionsList: [],
|
||||
isCustomRelativeDatesActive: false,
|
||||
currentSection: {},
|
||||
currentSubsection: {},
|
||||
currentItem: {},
|
||||
actions: {
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
enableProctoredExams: false,
|
||||
pasteFileNotices: {},
|
||||
createdOn: null,
|
||||
},
|
||||
initialState,
|
||||
reducers: {
|
||||
fetchOutlineIndexSuccess: (state, { payload }) => {
|
||||
fetchOutlineIndexSuccess: (state: CourseOutlineState, { payload }) => {
|
||||
state.outlineIndexData = payload;
|
||||
state.sectionsList = payload.courseStructure?.childInfo?.children || [];
|
||||
state.isCustomRelativeDatesActive = payload.isCustomRelativeDatesActive;
|
||||
state.enableProctoredExams = payload.courseStructure?.enableProctoredExams;
|
||||
state.createdOn = payload.createdOn;
|
||||
},
|
||||
updateOutlineIndexLoadingStatus: (state, { payload }) => {
|
||||
updateOutlineIndexLoadingStatus: (state: CourseOutlineState, { payload }) => {
|
||||
state.loadingStatus = {
|
||||
...state.loadingStatus,
|
||||
outlineIndexLoadingStatus: payload.status,
|
||||
};
|
||||
state.errors.outlineIndexApi = payload.errors || null;
|
||||
},
|
||||
updateReindexLoadingStatus: (state, { payload }) => {
|
||||
updateReindexLoadingStatus: (state: CourseOutlineState, { payload }) => {
|
||||
state.loadingStatus = {
|
||||
...state.loadingStatus,
|
||||
reIndexLoadingStatus: payload.status,
|
||||
};
|
||||
state.errors.reindexApi = payload.errors || null;
|
||||
},
|
||||
updateFetchSectionLoadingStatus: (state, { payload }) => {
|
||||
updateFetchSectionLoadingStatus: (state: CourseOutlineState, { payload }) => {
|
||||
state.loadingStatus = {
|
||||
...state.loadingStatus,
|
||||
fetchSectionLoadingStatus: payload.status,
|
||||
};
|
||||
state.errors.sectionLoadingApi = payload.errors || null;
|
||||
},
|
||||
updateCourseLaunchQueryStatus: (state, { payload }) => {
|
||||
updateCourseLaunchQueryStatus: (state: CourseOutlineState, { payload }) => {
|
||||
state.loadingStatus = {
|
||||
...state.loadingStatus,
|
||||
courseLaunchQueryStatus: payload.status,
|
||||
};
|
||||
state.errors.courseLaunchApi = payload.errors || null;
|
||||
},
|
||||
dismissError: (state, { payload }) => {
|
||||
dismissError: (state: CourseOutlineState, { payload }) => {
|
||||
state.errors[payload] = null;
|
||||
},
|
||||
updateStatusBar: (state, { payload }) => {
|
||||
updateStatusBar: (state: CourseOutlineState, { payload }) => {
|
||||
state.statusBarData = {
|
||||
...state.statusBarData,
|
||||
...payload,
|
||||
};
|
||||
},
|
||||
updateCourseActions: (state, { payload }) => {
|
||||
updateCourseActions: (state: CourseOutlineState, { payload }) => {
|
||||
state.actions = {
|
||||
...state.actions,
|
||||
...payload,
|
||||
};
|
||||
},
|
||||
fetchStatusBarChecklistSuccess: (state, { payload }) => {
|
||||
fetchStatusBarChecklistSuccess: (state: CourseOutlineState, { payload }) => {
|
||||
state.statusBarData.checklist = {
|
||||
...state.statusBarData.checklist,
|
||||
...payload,
|
||||
};
|
||||
},
|
||||
fetchStatusBarSelfPacedSuccess: (state, { payload }) => {
|
||||
fetchStatusBarSelfPacedSuccess: (state: CourseOutlineState, { payload }) => {
|
||||
state.statusBarData.isSelfPaced = payload.isSelfPaced;
|
||||
},
|
||||
updateSavingStatus: (state, { payload }) => {
|
||||
updateSavingStatus: (state: CourseOutlineState, { payload }) => {
|
||||
state.savingStatus = payload.status;
|
||||
},
|
||||
updateSectionList: (state, { payload }) => {
|
||||
updateSectionList: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => (section.id in payload ? payload[section.id] : section));
|
||||
},
|
||||
setCurrentItem: (state, { payload }) => {
|
||||
setCurrentItem: (state: CourseOutlineState, { payload }) => {
|
||||
state.currentItem = payload;
|
||||
},
|
||||
reorderSectionList: (state, { payload }) => {
|
||||
reorderSectionList: (state: CourseOutlineState, { payload }) => {
|
||||
const sectionsList = [...state.sectionsList];
|
||||
sectionsList.sort((a, b) => payload.indexOf(a.id) - payload.indexOf(b.id));
|
||||
|
||||
state.sectionsList = [...sectionsList];
|
||||
},
|
||||
setCurrentSection: (state, { payload }) => {
|
||||
setCurrentSection: (state: CourseOutlineState, { payload }) => {
|
||||
state.currentSection = payload;
|
||||
},
|
||||
setCurrentSubsection: (state, { payload }) => {
|
||||
setCurrentSubsection: (state: CourseOutlineState, { payload }) => {
|
||||
state.currentSubsection = payload;
|
||||
},
|
||||
addSection: (state, { payload }) => {
|
||||
addSection: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = [
|
||||
...state.sectionsList,
|
||||
payload,
|
||||
];
|
||||
},
|
||||
addSubsection: (state, { payload }) => {
|
||||
resetScrollField: (state) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
section.shouldScroll = false;
|
||||
section.childInfo.children.map((subsection) => {
|
||||
subsection.shouldScroll = false;
|
||||
return subsection;
|
||||
});
|
||||
return section;
|
||||
});
|
||||
},
|
||||
addSubsection: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
if (section.id === payload.parentLocator) {
|
||||
section.childInfo.children = [
|
||||
@@ -147,12 +162,12 @@ const slice = createSlice({
|
||||
return section;
|
||||
});
|
||||
},
|
||||
deleteSection: (state, { payload }) => {
|
||||
deleteSection: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.filter(
|
||||
({ id }) => id !== payload.itemId,
|
||||
);
|
||||
},
|
||||
deleteSubsection: (state, { payload }) => {
|
||||
deleteSubsection: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
if (section.id !== payload.sectionId) {
|
||||
return section;
|
||||
@@ -163,7 +178,7 @@ const slice = createSlice({
|
||||
return section;
|
||||
});
|
||||
},
|
||||
deleteUnit: (state, { payload }) => {
|
||||
deleteUnit: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
if (section.id !== payload.sectionId) {
|
||||
return section;
|
||||
@@ -180,7 +195,7 @@ const slice = createSlice({
|
||||
return section;
|
||||
});
|
||||
},
|
||||
duplicateSection: (state, { payload }) => {
|
||||
duplicateSection: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.reduce((result, currentValue) => {
|
||||
if (currentValue.id === payload.id) {
|
||||
return [...result, currentValue, payload.duplicatedItem];
|
||||
@@ -188,12 +203,12 @@ const slice = createSlice({
|
||||
return [...result, currentValue];
|
||||
}, []);
|
||||
},
|
||||
setPasteFileNotices: (state, { payload }) => {
|
||||
setPasteFileNotices: (state: CourseOutlineState, { payload }) => {
|
||||
state.pasteFileNotices = payload;
|
||||
},
|
||||
removePasteFileNotices: (state, { payload }) => {
|
||||
removePasteFileNotices: (state: CourseOutlineState, { payload }) => {
|
||||
const pasteFileNotices = { ...state.pasteFileNotices };
|
||||
payload.forEach((key) => delete pasteFileNotices[key]);
|
||||
payload.forEach((key: string | number) => delete pasteFileNotices[key]);
|
||||
state.pasteFileNotices = pasteFileNotices;
|
||||
},
|
||||
},
|
||||
@@ -221,11 +236,10 @@ export const {
|
||||
deleteUnit,
|
||||
duplicateSection,
|
||||
reorderSectionList,
|
||||
reorderSubsectionList,
|
||||
reorderUnitList,
|
||||
setPasteFileNotices,
|
||||
removePasteFileNotices,
|
||||
dismissError,
|
||||
resetScrollField,
|
||||
} = slice.actions;
|
||||
|
||||
export const {
|
||||
@@ -1,11 +1,12 @@
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import { NOTIFICATION_MESSAGES } from '@src/constants';
|
||||
import {
|
||||
hideProcessingNotification,
|
||||
showProcessingNotification,
|
||||
} from '../../generic/processing-notification/data/slice';
|
||||
} from '@src/generic/processing-notification/data/slice';
|
||||
import { createCourseXblock } from '@src/course-unit/data/api';
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
import {
|
||||
getCourseBestPracticesChecklist,
|
||||
getCourseLaunchChecklist,
|
||||
@@ -54,9 +55,14 @@ import {
|
||||
setPasteFileNotices,
|
||||
updateCourseLaunchQueryStatus,
|
||||
} from './slice';
|
||||
import { createCourseXblock } from '../../course-unit/data/api';
|
||||
|
||||
export function fetchCourseOutlineIndexQuery(courseId) {
|
||||
/**
|
||||
* Action to fetch course outline.
|
||||
*
|
||||
* @param {string} courseId - ID of the course
|
||||
* @returns {Object} - Object containing fetch course outline index query success or failure status
|
||||
*/
|
||||
export function fetchCourseOutlineIndexQuery(courseId: string): object {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
@@ -81,7 +87,7 @@ export function fetchCourseOutlineIndexQuery(courseId) {
|
||||
dispatch(updateCourseActions(actions));
|
||||
|
||||
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.response && error.response.status === 403) {
|
||||
dispatch(updateOutlineIndexLoadingStatus({
|
||||
status: RequestStatus.DENIED,
|
||||
@@ -96,7 +102,7 @@ export function fetchCourseOutlineIndexQuery(courseId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function syncDiscussionsTopics(courseId) {
|
||||
export function syncDiscussionsTopics(courseId: string) {
|
||||
return async () => {
|
||||
try {
|
||||
await createDiscussionsTopics(courseId);
|
||||
@@ -148,7 +154,7 @@ export function fetchCourseBestPracticesQuery({
|
||||
};
|
||||
}
|
||||
|
||||
export function enableCourseHighlightsEmailsQuery(courseId) {
|
||||
export function enableCourseHighlightsEmailsQuery(courseId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
@@ -165,7 +171,7 @@ export function enableCourseHighlightsEmailsQuery(courseId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function setVideoSharingOptionQuery(courseId, option) {
|
||||
export function setVideoSharingOptionQuery(courseId: string, option: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
@@ -183,7 +189,7 @@ export function setVideoSharingOptionQuery(courseId, option) {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourseReindexQuery(courseId, reindexLink) {
|
||||
export function fetchCourseReindexQuery(reindexLink: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateReindexLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
@@ -199,16 +205,36 @@ export function fetchCourseReindexQuery(courseId, reindexLink) {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchCourseSectionQuery(sectionIds, shouldScroll = false) {
|
||||
/**
|
||||
* Fetches course sections and optionally scrolls to a specific subsection/unit.
|
||||
*/
|
||||
export function fetchCourseSectionQuery(sectionIds: string[], scrollToId?: {
|
||||
subsectionId: string,
|
||||
unitId?: string,
|
||||
}) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
try {
|
||||
const sections = {};
|
||||
const results = await Promise.all(sectionIds.map((sectionId) => getCourseItem(sectionId)));
|
||||
results.forEach((data) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
data.shouldScroll = shouldScroll;
|
||||
sections[data.id] = data;
|
||||
results.forEach(section => {
|
||||
if (scrollToId) {
|
||||
const targetSubsection = section?.childInfo?.children?.find(
|
||||
subsection => subsection.id === scrollToId.subsectionId,
|
||||
);
|
||||
|
||||
if (targetSubsection) {
|
||||
if (scrollToId.unitId) {
|
||||
const targetUnit = targetSubsection?.childInfo?.children?.find(unit => unit.id === scrollToId.unitId);
|
||||
if (targetUnit) {
|
||||
targetUnit.shouldScroll = true;
|
||||
}
|
||||
} else {
|
||||
targetSubsection.shouldScroll = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
sections[section.id] = section;
|
||||
});
|
||||
dispatch(updateSectionList(sections));
|
||||
dispatch(updateFetchSectionLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
@@ -221,7 +247,7 @@ export function fetchCourseSectionQuery(sectionIds, shouldScroll = false) {
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCourseSectionHighlightsQuery(sectionId, highlights) {
|
||||
export function updateCourseSectionHighlightsQuery(sectionId: string, highlights: string[]) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
@@ -241,7 +267,7 @@ export function updateCourseSectionHighlightsQuery(sectionId, highlights) {
|
||||
};
|
||||
}
|
||||
|
||||
export function publishCourseItemQuery(itemId, sectionId) {
|
||||
export function publishCourseItemQuery(itemId: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
@@ -261,7 +287,7 @@ export function publishCourseItemQuery(itemId, sectionId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseItemQuery(sectionId, configureFn) {
|
||||
export function configureCourseItemQuery(sectionId: string, configureFn: () => Promise<any>) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
@@ -281,7 +307,7 @@ export function configureCourseItemQuery(sectionId, configureFn) {
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseSectionQuery(sectionId, isVisibleToStaffOnly, startDatetime) {
|
||||
export function configureCourseSectionQuery(sectionId: string, isVisibleToStaffOnly: boolean, startDatetime: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(configureCourseItemQuery(
|
||||
sectionId,
|
||||
@@ -291,24 +317,24 @@ export function configureCourseSectionQuery(sectionId, isVisibleToStaffOnly, sta
|
||||
}
|
||||
|
||||
export function configureCourseSubsectionQuery(
|
||||
itemId,
|
||||
sectionId,
|
||||
isVisibleToStaffOnly,
|
||||
releaseDate,
|
||||
graderType,
|
||||
dueDate,
|
||||
isTimeLimited,
|
||||
isProctoredExam,
|
||||
isOnboardingExam,
|
||||
isPracticeExam,
|
||||
examReviewRules,
|
||||
defaultTimeLimitMin,
|
||||
hideAfterDue,
|
||||
showCorrectness,
|
||||
isPrereq,
|
||||
prereqUsageKey,
|
||||
prereqMinScore,
|
||||
prereqMinCompletion,
|
||||
itemId: string,
|
||||
sectionId: string,
|
||||
isVisibleToStaffOnly: string,
|
||||
releaseDate: string,
|
||||
graderType: string,
|
||||
dueDate: string,
|
||||
isTimeLimited: boolean,
|
||||
isProctoredExam: boolean,
|
||||
isOnboardingExam: boolean,
|
||||
isPracticeExam: boolean,
|
||||
examReviewRules: string,
|
||||
defaultTimeLimitMin: number,
|
||||
hideAfterDue: string,
|
||||
showCorrectness: string,
|
||||
isPrereq: boolean,
|
||||
prereqUsageKey: string,
|
||||
prereqMinScore: number,
|
||||
prereqMinCompletion: number,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(configureCourseItemQuery(
|
||||
@@ -336,7 +362,13 @@ export function configureCourseSubsectionQuery(
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseUnitQuery(itemId, sectionId, isVisibleToStaffOnly, groupAccess, discussionEnabled) {
|
||||
export function configureCourseUnitQuery(
|
||||
itemId: string,
|
||||
sectionId: string,
|
||||
isVisibleToStaffOnly: boolean,
|
||||
groupAccess: object,
|
||||
discussionEnabled: boolean,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(configureCourseItemQuery(
|
||||
sectionId,
|
||||
@@ -345,7 +377,7 @@ export function configureCourseUnitQuery(itemId, sectionId, isVisibleToStaffOnly
|
||||
};
|
||||
}
|
||||
|
||||
export function editCourseItemQuery(itemId, sectionId, displayName) {
|
||||
export function editCourseItemQuery(itemId: string, sectionId: string, displayName: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
@@ -369,9 +401,8 @@ export function editCourseItemQuery(itemId, sectionId, displayName) {
|
||||
* Generic function to delete course item, see below wrapper funcs for specific implementations.
|
||||
* @param {string} itemId
|
||||
* @param {() => {}} deleteItemFn
|
||||
* @returns {}
|
||||
*/
|
||||
function deleteCourseItemQuery(itemId, deleteItemFn) {
|
||||
function deleteCourseItemQuery(itemId: string, deleteItemFn: () => {}) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting));
|
||||
@@ -388,7 +419,7 @@ function deleteCourseItemQuery(itemId, deleteItemFn) {
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCourseSectionQuery(sectionId) {
|
||||
export function deleteCourseSectionQuery(sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(deleteCourseItemQuery(
|
||||
sectionId,
|
||||
@@ -397,7 +428,7 @@ export function deleteCourseSectionQuery(sectionId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCourseSubsectionQuery(subsectionId, sectionId) {
|
||||
export function deleteCourseSubsectionQuery(subsectionId: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(deleteCourseItemQuery(
|
||||
subsectionId,
|
||||
@@ -406,7 +437,7 @@ export function deleteCourseSubsectionQuery(subsectionId, sectionId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCourseUnitQuery(unitId, subsectionId, sectionId) {
|
||||
export function deleteCourseUnitQuery(unitId: string, subsectionId: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(deleteCourseItemQuery(
|
||||
unitId,
|
||||
@@ -420,9 +451,12 @@ export function deleteCourseUnitQuery(unitId, subsectionId, sectionId) {
|
||||
* @param {string} itemId
|
||||
* @param {string} parentLocator
|
||||
* @param {(locator) => Promise<any>} duplicateFn
|
||||
* @returns {}
|
||||
*/
|
||||
function duplicateCourseItemQuery(itemId, parentLocator, duplicateFn) {
|
||||
function duplicateCourseItemQuery(
|
||||
itemId: string,
|
||||
parentLocator: string,
|
||||
duplicateFn: (locator: string) => Promise<any>,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.duplicating));
|
||||
@@ -442,7 +476,7 @@ function duplicateCourseItemQuery(itemId, parentLocator, duplicateFn) {
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateSectionQuery(sectionId, courseBlockId) {
|
||||
export function duplicateSectionQuery(sectionId: string, courseBlockId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
sectionId,
|
||||
@@ -457,35 +491,40 @@ export function duplicateSectionQuery(sectionId, courseBlockId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateSubsectionQuery(subsectionId, sectionId) {
|
||||
export function duplicateSubsectionQuery(subsectionId: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
subsectionId,
|
||||
sectionId,
|
||||
async () => dispatch(fetchCourseSectionQuery([sectionId], true)),
|
||||
async (itemId: string) => dispatch(fetchCourseSectionQuery([sectionId], {
|
||||
subsectionId: itemId, // To scroll to the newly duplicated subsection
|
||||
})),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateUnitQuery(unitId, subsectionId, sectionId) {
|
||||
export function duplicateUnitQuery(unitId: string, subsectionId: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
unitId,
|
||||
subsectionId,
|
||||
async () => dispatch(fetchCourseSectionQuery([sectionId], true)),
|
||||
async (itemId: string) => dispatch(fetchCourseSectionQuery([sectionId], {
|
||||
subsectionId,
|
||||
unitId: itemId, // To scroll to the newly duplicated unit
|
||||
})),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic function to add any course item. See wrapper functions below for specific implementations.
|
||||
* @param {string} parentLocator
|
||||
* @param {string} category
|
||||
* @param {string} displayName
|
||||
* @param {(data) => {}} addItemFn
|
||||
* @returns {}
|
||||
*/
|
||||
function addNewCourseItemQuery(parentLocator, category, displayName, addItemFn) {
|
||||
function addNewCourseItemQuery(
|
||||
parentLocator: string,
|
||||
category: string,
|
||||
displayName: string,
|
||||
addItemFn: (data: any) => Promise<any>,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
@@ -509,7 +548,7 @@ function addNewCourseItemQuery(parentLocator, category, displayName, addItemFn)
|
||||
};
|
||||
}
|
||||
|
||||
export function addNewSectionQuery(parentLocator) {
|
||||
export function addNewSectionQuery(parentLocator: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(addNewCourseItemQuery(
|
||||
parentLocator,
|
||||
@@ -525,7 +564,7 @@ export function addNewSectionQuery(parentLocator) {
|
||||
};
|
||||
}
|
||||
|
||||
export function addNewSubsectionQuery(parentLocator) {
|
||||
export function addNewSubsectionQuery(parentLocator: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(addNewCourseItemQuery(
|
||||
parentLocator,
|
||||
@@ -541,7 +580,7 @@ export function addNewSubsectionQuery(parentLocator) {
|
||||
};
|
||||
}
|
||||
|
||||
export function addNewUnitQuery(parentLocator, callback) {
|
||||
export function addNewUnitQuery(parentLocator: string, callback: { (locator: any): void }) {
|
||||
return async (dispatch) => {
|
||||
dispatch(addNewCourseItemQuery(
|
||||
parentLocator,
|
||||
@@ -552,7 +591,15 @@ export function addNewUnitQuery(parentLocator, callback) {
|
||||
};
|
||||
}
|
||||
|
||||
export function addUnitFromLibrary(body, callback) {
|
||||
export function addUnitFromLibrary(body: {
|
||||
type: string;
|
||||
category?: string;
|
||||
parentLocator: string;
|
||||
displayName?: string;
|
||||
boilerplate?: string;
|
||||
stagedContent?: string;
|
||||
libraryContentKey?: string;
|
||||
}, callback: (arg0: any) => void) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
@@ -573,11 +620,16 @@ export function addUnitFromLibrary(body, callback) {
|
||||
}
|
||||
|
||||
function setBlockOrderListQuery(
|
||||
parentId,
|
||||
blockIds,
|
||||
apiFn,
|
||||
restoreCallback,
|
||||
successCallback,
|
||||
parentId: string,
|
||||
blockIds: string[],
|
||||
apiFn: {
|
||||
(courseId: string, children: string[]): Promise<object>;
|
||||
(itemId: string, children: string[]): Promise<object>;
|
||||
(itemId: string, children: string[]): Promise<object>;
|
||||
(arg0: any, arg1: any): Promise<any>;
|
||||
},
|
||||
restoreCallback: () => void,
|
||||
successCallback: { (): any; (): void; (): void; (): void; },
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
@@ -599,7 +651,11 @@ function setBlockOrderListQuery(
|
||||
};
|
||||
}
|
||||
|
||||
export function setSectionOrderListQuery(courseId, sectionListIds, restoreCallback) {
|
||||
export function setSectionOrderListQuery(
|
||||
courseId: string,
|
||||
sectionListIds: string[],
|
||||
restoreCallback: () => void,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(setBlockOrderListQuery(
|
||||
courseId,
|
||||
@@ -612,10 +668,10 @@ export function setSectionOrderListQuery(courseId, sectionListIds, restoreCallba
|
||||
}
|
||||
|
||||
export function setSubsectionOrderListQuery(
|
||||
sectionId,
|
||||
prevSectionId,
|
||||
subsectionListIds,
|
||||
restoreCallback,
|
||||
sectionId: string,
|
||||
prevSectionId: string,
|
||||
subsectionListIds: string[],
|
||||
restoreCallback: () => void,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(setBlockOrderListQuery(
|
||||
@@ -635,11 +691,11 @@ export function setSubsectionOrderListQuery(
|
||||
}
|
||||
|
||||
export function setUnitOrderListQuery(
|
||||
sectionId,
|
||||
subsectionId,
|
||||
prevSectionId,
|
||||
unitListIds,
|
||||
restoreCallback,
|
||||
sectionId: string,
|
||||
subsectionId: string,
|
||||
prevSectionId: string,
|
||||
unitListIds: string[],
|
||||
restoreCallback: () => void,
|
||||
) {
|
||||
return async (dispatch) => {
|
||||
dispatch(setBlockOrderListQuery(
|
||||
@@ -658,15 +714,15 @@ export function setUnitOrderListQuery(
|
||||
};
|
||||
}
|
||||
|
||||
export function pasteClipboardContent(parentLocator, sectionId) {
|
||||
export function pasteClipboardContent(parentLocator: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.pasting));
|
||||
|
||||
try {
|
||||
await pasteBlock(parentLocator).then(async (result) => {
|
||||
await pasteBlock(parentLocator).then(async (result: any) => {
|
||||
if (result) {
|
||||
dispatch(fetchCourseSectionQuery([sectionId], true));
|
||||
dispatch(fetchCourseSectionQuery([sectionId], { subsectionId: parentLocator, unitId: result.locator }));
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(setPasteFileNotices(result?.staticFileNotices));
|
||||
@@ -679,7 +735,7 @@ export function pasteClipboardContent(parentLocator, sectionId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function dismissNotificationQuery(url) {
|
||||
export function dismissNotificationQuery(url: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
|
||||
64
src/course-outline/data/types.ts
Normal file
64
src/course-outline/data/types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { XBlock, XBlockActions } from '@src/data/types';
|
||||
|
||||
export interface CourseStructure {
|
||||
highlightsEnabledForMessaging: boolean,
|
||||
videoSharingEnabled: boolean,
|
||||
videoSharingOptions: string,
|
||||
actions: XBlockActions,
|
||||
}
|
||||
|
||||
// TODO: Create interface for all `Object` fields in courseOutline
|
||||
export interface CourseOutline {
|
||||
courseReleaseDate: string;
|
||||
courseStructure: CourseStructure;
|
||||
deprecatedBlocksInfo: Object;
|
||||
discussionsIncontextLearnmoreUrl: string;
|
||||
initialState: Object;
|
||||
initialUserClipboard: Object;
|
||||
languageCode: string;
|
||||
lmsLink: string;
|
||||
mfeProctoredExamSettingsUrl: string;
|
||||
notificationDismissUrl: string;
|
||||
proctoringErrors: string[];
|
||||
reindexLink: string;
|
||||
rerunNotificationId: null;
|
||||
}
|
||||
|
||||
export interface CourseOutlineState {
|
||||
loadingStatus: {
|
||||
outlineIndexLoadingStatus: string;
|
||||
reIndexLoadingStatus: string;
|
||||
fetchSectionLoadingStatus: string;
|
||||
courseLaunchQueryStatus: string;
|
||||
};
|
||||
errors: {
|
||||
outlineIndexApi: null | object;
|
||||
reindexApi: null | object;
|
||||
sectionLoadingApi: null | object;
|
||||
courseLaunchApi: null | object;
|
||||
};
|
||||
outlineIndexData: object;
|
||||
savingStatus: string;
|
||||
statusBarData: {
|
||||
courseReleaseDate: string;
|
||||
highlightsEnabledForMessaging: boolean;
|
||||
isSelfPaced: boolean;
|
||||
checklist: {
|
||||
totalCourseLaunchChecks: number;
|
||||
completedCourseLaunchChecks: number;
|
||||
totalCourseBestPracticesChecks: number;
|
||||
completedCourseBestPracticesChecks: number;
|
||||
};
|
||||
videoSharingEnabled: boolean;
|
||||
videoSharingOptions: string;
|
||||
};
|
||||
sectionsList: Array<XBlock>;
|
||||
isCustomRelativeDatesActive: boolean;
|
||||
currentSection: XBlock | {};
|
||||
currentSubsection: XBlock | {};
|
||||
currentItem: XBlock | {};
|
||||
actions: XBlockActions;
|
||||
enableProctoredExams: boolean;
|
||||
pasteFileNotices: object;
|
||||
createdOn: null | Date;
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Add as IconAdd } from '@openedx/paragon/icons/es5';
|
||||
import { Button, OverlayTrigger, Tooltip } from '@openedx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const EmptyPlaceholder = ({
|
||||
onCreateNewSection,
|
||||
childAddable,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<div className="outline-empty-placeholder bg-gray-100" data-testid="empty-placeholder">
|
||||
<p className="mb-0 text-gray-500">{intl.formatMessage(messages.title)}</p>
|
||||
{childAddable && (
|
||||
<OverlayTrigger
|
||||
placement="bottom"
|
||||
overlay={(
|
||||
<Tooltip id={intl.formatMessage(messages.tooltip)}>
|
||||
{intl.formatMessage(messages.tooltip)}
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
iconBefore={IconAdd}
|
||||
onClick={onCreateNewSection}
|
||||
>
|
||||
{intl.formatMessage(messages.button)}
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
EmptyPlaceholder.propTypes = {
|
||||
onCreateNewSection: PropTypes.func.isRequired,
|
||||
childAddable: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
export default EmptyPlaceholder;
|
||||
@@ -1,34 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import EmptyPlaceholder from './EmptyPlaceholder';
|
||||
import messages from './messages';
|
||||
|
||||
const onCreateNewSectionMock = jest.fn();
|
||||
|
||||
const renderComponent = () => render(
|
||||
<IntlProvider locale="en">
|
||||
<EmptyPlaceholder
|
||||
onCreateNewSection={onCreateNewSectionMock}
|
||||
childAddable
|
||||
/>
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
describe('<EmptyPlaceholder />', () => {
|
||||
it('renders EmptyPlaceholder component correctly', () => {
|
||||
const { getByText, getByRole } = renderComponent();
|
||||
|
||||
expect(getByText(messages.title.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.button.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls the onCreateNewSection function when the button is clicked', () => {
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
const addButton = getByRole('button', { name: messages.button.defaultMessage });
|
||||
fireEvent.click(addButton);
|
||||
expect(onCreateNewSectionMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Button } from '@openedx/paragon';
|
||||
import {
|
||||
fireEvent, initializeMocks, render, screen,
|
||||
} from '@src/testUtils';
|
||||
|
||||
import EmptyPlaceholder from './EmptyPlaceholder';
|
||||
import messages from './messages';
|
||||
|
||||
const onCreateNewSectionMock = jest.fn();
|
||||
|
||||
const renderComponent = () => render(
|
||||
<EmptyPlaceholder>
|
||||
<Button onClick={onCreateNewSectionMock}>Create New Section</Button>
|
||||
</EmptyPlaceholder>,
|
||||
);
|
||||
|
||||
describe('<EmptyPlaceholder />', () => {
|
||||
it('renders EmptyPlaceholder component correctly', async () => {
|
||||
initializeMocks();
|
||||
renderComponent();
|
||||
|
||||
expect(await screen.findByText(messages.title.defaultMessage)).toBeInTheDocument();
|
||||
const addButton = await screen.findByRole('button', { name: 'Create New Section' });
|
||||
expect(addButton).toBeInTheDocument();
|
||||
fireEvent.click(addButton);
|
||||
expect(onCreateNewSectionMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
21
src/course-outline/empty-placeholder/EmptyPlaceholder.tsx
Normal file
21
src/course-outline/empty-placeholder/EmptyPlaceholder.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Stack } from '@openedx/paragon';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactElement;
|
||||
}
|
||||
|
||||
const EmptyPlaceholder = ({ children }: Props) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<Stack direction="vertical" className="outline-empty-placeholder bg-gray-100" data-testid="empty-placeholder">
|
||||
<p className="mb-0 text-gray-500">{intl.formatMessage(messages.title)}</p>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyPlaceholder;
|
||||
@@ -5,14 +5,6 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.course-outline.empty-placeholder.title',
|
||||
defaultMessage: 'You haven\'t added any content to this course yet.',
|
||||
},
|
||||
button: {
|
||||
id: 'course-authoring.course-outline.empty-placeholder.button.new-section',
|
||||
defaultMessage: 'New section',
|
||||
},
|
||||
tooltip: {
|
||||
id: 'course-authoring.course-outline.empty-placeholder.button.tooltip',
|
||||
defaultMessage: 'Click to add a new section',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -5,13 +5,16 @@ import { useToggle } from '@openedx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import moment from 'moment';
|
||||
import { getSavingStatus as getGenericSavingStatus } from '../generic/data/selectors';
|
||||
import { useWaffleFlags } from '../data/apiHooks';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { getSavingStatus as getGenericSavingStatus } from '@src/generic/data/selectors';
|
||||
import { useWaffleFlags } from '@src/data/apiHooks';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import { COURSE_BLOCK_NAMES } from './constants';
|
||||
import {
|
||||
addSection,
|
||||
addSubsection,
|
||||
setCurrentItem,
|
||||
setCurrentSection,
|
||||
resetScrollField,
|
||||
updateSavingStatus,
|
||||
} from './data/slice';
|
||||
import {
|
||||
@@ -55,8 +58,10 @@ import {
|
||||
setUnitOrderListQuery,
|
||||
pasteClipboardContent,
|
||||
dismissNotificationQuery,
|
||||
addUnitFromLibrary, syncDiscussionsTopics,
|
||||
syncDiscussionsTopics,
|
||||
} from './data/thunk';
|
||||
import { useCreateCourseBlock } from './data/apiHooks';
|
||||
import { getCourseItem } from './data/api';
|
||||
|
||||
const useCourseOutline = ({ courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -75,6 +80,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
mfeProctoredExamSettingsUrl,
|
||||
advanceSettingsUrl,
|
||||
} = useSelector(getOutlineIndexData);
|
||||
/** Course usage key is different than courseKey and useful in using as parentLocator for imported sections */
|
||||
const createdOn = useSelector(getCreatedOn);
|
||||
const { outlineIndexLoadingStatus, reIndexLoadingStatus } = useSelector(getLoadingStatus);
|
||||
const statusBarData = useSelector(getStatusBarData);
|
||||
@@ -96,6 +102,11 @@ const useCourseOutline = ({ courseId }) => {
|
||||
const [isPublishModalOpen, openPublishModal, closePublishModal] = useToggle(false);
|
||||
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
|
||||
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
|
||||
const [
|
||||
isAddLibrarySectionModalOpen,
|
||||
openAddLibrarySectionModal,
|
||||
closeAddLibrarySectionModal,
|
||||
] = useToggle(false);
|
||||
|
||||
const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED;
|
||||
|
||||
@@ -131,17 +142,44 @@ const useCourseOutline = ({ courseId }) => {
|
||||
dispatch(addNewUnitQuery(subsectionId, openUnitPage));
|
||||
};
|
||||
|
||||
const handleAddUnitFromLibrary = (body) => {
|
||||
dispatch(addUnitFromLibrary(body, openUnitPage));
|
||||
/**
|
||||
* import a unit block from library and redirect user to this unit page.
|
||||
*/
|
||||
const handleAddUnitFromLibrary = useCreateCourseBlock(openUnitPage);
|
||||
|
||||
const handleAddSubsectionFromLibrary = useCreateCourseBlock(async (locator, parentLocator) => {
|
||||
try {
|
||||
const data = await getCourseItem(locator);
|
||||
data.shouldScroll = true;
|
||||
// Page should scroll to newly added subsection.
|
||||
dispatch(addSubsection({ parentLocator, data }));
|
||||
} catch (error) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
});
|
||||
|
||||
const resetScrollState = () => {
|
||||
dispatch(resetScrollField());
|
||||
};
|
||||
|
||||
const handleAddSectionFromLibrary = useCreateCourseBlock(async (locator) => {
|
||||
try {
|
||||
const data = await getCourseItem(locator);
|
||||
// Page should scroll to newly added section.
|
||||
data.shouldScroll = true;
|
||||
dispatch(addSection(data));
|
||||
} catch (error) {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
});
|
||||
|
||||
const headerNavigationsActions = {
|
||||
handleNewSection: handleNewSectionSubmit,
|
||||
handleReIndex: () => {
|
||||
setDisableReindexButton(true);
|
||||
setShowSuccessAlert(false);
|
||||
|
||||
dispatch(fetchCourseReindexQuery(courseId, reindexLink)).then(() => {
|
||||
dispatch(fetchCourseReindexQuery(reindexLink)).then(() => {
|
||||
setDisableReindexButton(false);
|
||||
});
|
||||
},
|
||||
@@ -305,6 +343,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
}, [reIndexLoadingStatus]);
|
||||
|
||||
return {
|
||||
courseUsageKey: courseStructure?.id,
|
||||
courseActions,
|
||||
savingStatus,
|
||||
sectionsList,
|
||||
@@ -320,6 +359,9 @@ const useCourseOutline = ({ courseId }) => {
|
||||
closePublishModal,
|
||||
isConfigureModalOpen,
|
||||
openConfigureModal,
|
||||
isAddLibrarySectionModalOpen,
|
||||
openAddLibrarySectionModal,
|
||||
closeAddLibrarySectionModal,
|
||||
handleConfigureModalClose,
|
||||
headerNavigationsActions,
|
||||
handleEnableHighlightsSubmit,
|
||||
@@ -350,6 +392,8 @@ const useCourseOutline = ({ courseId }) => {
|
||||
openUnitPage,
|
||||
handleNewUnitSubmit,
|
||||
handleAddUnitFromLibrary,
|
||||
handleAddSubsectionFromLibrary,
|
||||
handleAddSectionFromLibrary,
|
||||
handleVideoSharingOptionChange,
|
||||
handlePasteClipboardClick,
|
||||
notificationDismissUrl,
|
||||
@@ -365,6 +409,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
handleSubsectionDragAndDrop,
|
||||
handleUnitDragAndDrop,
|
||||
errors,
|
||||
resetScrollState,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ const messages = defineMessages({
|
||||
newSectionButton: {
|
||||
id: 'course-authoring.course-outline.section-list.button.new-section',
|
||||
defaultMessage: 'New section',
|
||||
description: 'Text of button to create new section in course outline',
|
||||
},
|
||||
useSectionFromLibraryButton: {
|
||||
id: 'course-authoring.course-outline.button.use-section-from-library',
|
||||
defaultMessage: 'Use section from library',
|
||||
description: 'Text of the button to add a section from a library in a course.',
|
||||
},
|
||||
exportTagsCreatingToastMessage: {
|
||||
id: 'course-authoring.course-outline.export-tags.toast.creating.message',
|
||||
@@ -44,6 +50,31 @@ const messages = defineMessages({
|
||||
defaultMessage: 'An error has occurred creating the file',
|
||||
description: 'Error message in toast when exporting tags of a course',
|
||||
},
|
||||
newUnitButton: {
|
||||
id: 'course-authoring.course-outline.button.new-unit',
|
||||
defaultMessage: 'New unit',
|
||||
description: 'Message of the button to create a new unit in a subsection.',
|
||||
},
|
||||
useUnitFromLibraryButton: {
|
||||
id: 'course-authoring.course-outline.button.use-unit-from-library',
|
||||
defaultMessage: 'Use unit from library',
|
||||
description: 'Message of the button to add a new unit from a library in a subsection.',
|
||||
},
|
||||
newSubsectionButton: {
|
||||
id: 'course-authoring.course-outline.button.new-subsection',
|
||||
defaultMessage: 'New subsection',
|
||||
description: 'Text of button to create new subsection in a section',
|
||||
},
|
||||
useSubsectionFromLibraryButton: {
|
||||
id: 'course-authoring.course-outline.button.use-subsection-from-library',
|
||||
defaultMessage: 'Use subsection from library',
|
||||
description: 'Message of the button to add a new subsection from a library in a subsection.',
|
||||
},
|
||||
sectionPickerModalTitle: {
|
||||
id: 'course-authoring.course-outline.button.section-modal.title',
|
||||
defaultMessage: 'Select section',
|
||||
description: 'Section modal picker title text in outline',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
// @ts-check
|
||||
import React, {
|
||||
useContext, useEffect, useState, useRef,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Bubble, Button, useToggle } from '@openedx/paragon';
|
||||
import { Add as IconAdd } from '@openedx/paragon/icons';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { setCurrentItem, setCurrentSection } from '../data/slice';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import CardHeader from '../card-header/CardHeader';
|
||||
import SortableItem from '../drag-helper/SortableItem';
|
||||
import { DragContext } from '../drag-helper/DragContextProvider';
|
||||
import TitleButton from '../card-header/TitleButton';
|
||||
import XBlockStatus from '../xblock-status/XBlockStatus';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
|
||||
import messages from './messages';
|
||||
|
||||
const SectionCard = ({
|
||||
section,
|
||||
isSelfPaced,
|
||||
isCustomRelativeDatesActive,
|
||||
children,
|
||||
index,
|
||||
canMoveItem,
|
||||
onOpenHighlightsModal,
|
||||
onOpenPublishModal,
|
||||
onOpenConfigureModal,
|
||||
onEditSectionSubmit,
|
||||
savingStatus,
|
||||
onOpenDeleteModal,
|
||||
onDuplicateSubmit,
|
||||
isSectionsExpanded,
|
||||
onNewSubsectionSubmit,
|
||||
onOrderChange,
|
||||
}) => {
|
||||
const currentRef = useRef(null);
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const { activeId, overId } = useContext(DragContext);
|
||||
const [searchParams] = useSearchParams();
|
||||
const locatorId = searchParams.get('show');
|
||||
const isScrolledToElement = locatorId === section.id;
|
||||
|
||||
// Expand the section if a search result should be shown/scrolled to
|
||||
const containsSearchResult = () => {
|
||||
if (locatorId) {
|
||||
const subsections = section.childInfo?.children;
|
||||
if (subsections) {
|
||||
for (let i = 0; i < subsections.length; i++) {
|
||||
const subsection = subsections[i];
|
||||
|
||||
// Check if the search result is one of the subsections
|
||||
const matchedSubsection = subsection.id === locatorId;
|
||||
if (matchedSubsection) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the search result is one of the units
|
||||
const matchedUnit = !!subsection.childInfo?.children?.filter((child) => child.id === locatorId).length;
|
||||
if (matchedUnit) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
const [isExpanded, setIsExpanded] = useState(containsSearchResult() || isSectionsExpanded);
|
||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||
const namePrefix = 'section';
|
||||
|
||||
useEffect(() => {
|
||||
setIsExpanded(isSectionsExpanded);
|
||||
}, [isSectionsExpanded]);
|
||||
|
||||
const {
|
||||
id,
|
||||
category,
|
||||
displayName,
|
||||
hasChanges,
|
||||
published,
|
||||
visibilityState,
|
||||
highlights,
|
||||
actions: sectionActions,
|
||||
isHeaderVisible = true,
|
||||
} = section;
|
||||
|
||||
useEffect(() => {
|
||||
if (activeId === id && isExpanded) {
|
||||
setIsExpanded(false);
|
||||
} else if (overId === id && !isExpanded) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [activeId, overId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentRef.current && (section.shouldScroll || isScrolledToElement)) {
|
||||
// Align element closer to the top of the screen if scrolling for search result
|
||||
const alignWithTop = !!isScrolledToElement;
|
||||
scrollToElement(currentRef.current, alignWithTop);
|
||||
}
|
||||
}, [isScrolledToElement]);
|
||||
|
||||
useEffect(() => {
|
||||
// If the locatorId is set/changed, we need to make sure that the section is expanded
|
||||
// if it contains the result, in order to scroll to it
|
||||
setIsExpanded((prevState) => containsSearchResult() || prevState);
|
||||
}, [locatorId, setIsExpanded]);
|
||||
|
||||
// re-create actions object for customizations
|
||||
const actions = { ...sectionActions };
|
||||
// add actions to control display of move up & down menu buton.
|
||||
actions.allowMoveUp = canMoveItem(index, -1);
|
||||
actions.allowMoveDown = canMoveItem(index, 1);
|
||||
|
||||
const sectionStatus = getItemStatus({
|
||||
published,
|
||||
visibilityState,
|
||||
hasChanges,
|
||||
});
|
||||
|
||||
// remove border when section is expanded
|
||||
const borderStyle = getItemStatusBorder(!isExpanded ? sectionStatus : '');
|
||||
|
||||
const handleExpandContent = () => {
|
||||
setIsExpanded((prevState) => !prevState);
|
||||
};
|
||||
|
||||
const handleClickMenuButton = () => {
|
||||
dispatch(setCurrentItem(section));
|
||||
dispatch(setCurrentSection(section));
|
||||
};
|
||||
|
||||
const handleEditSubmit = (titleValue) => {
|
||||
if (displayName !== titleValue) {
|
||||
// both itemId and sectionId are same
|
||||
onEditSectionSubmit(id, id, titleValue);
|
||||
return;
|
||||
}
|
||||
|
||||
closeForm();
|
||||
};
|
||||
|
||||
const handleOpenHighlightsModal = () => {
|
||||
onOpenHighlightsModal(section);
|
||||
};
|
||||
|
||||
const handleNewSubsectionSubmit = () => {
|
||||
onNewSubsectionSubmit(id);
|
||||
};
|
||||
|
||||
const handleSectionMoveUp = () => {
|
||||
onOrderChange(index, index - 1);
|
||||
};
|
||||
|
||||
const handleSectionMoveDown = () => {
|
||||
onOrderChange(index, index + 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
closeForm();
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
const titleComponent = (
|
||||
<TitleButton
|
||||
title={displayName}
|
||||
isExpanded={isExpanded}
|
||||
onTitleClick={handleExpandContent}
|
||||
namePrefix={namePrefix}
|
||||
/>
|
||||
);
|
||||
|
||||
const isDraggable = actions.draggable && (actions.allowMoveUp || actions.allowMoveDown);
|
||||
|
||||
return (
|
||||
<SortableItem
|
||||
id={id}
|
||||
category={category}
|
||||
isDraggable={isDraggable}
|
||||
isDroppable={actions.childAddable}
|
||||
componentStyle={{
|
||||
padding: '1.75rem',
|
||||
...borderStyle,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`section-card ${isScrolledToElement ? 'highlight' : ''}`}
|
||||
data-testid="section-card"
|
||||
ref={currentRef}
|
||||
>
|
||||
<div>
|
||||
{isHeaderVisible && (
|
||||
<CardHeader
|
||||
cardId={id}
|
||||
title={displayName}
|
||||
status={sectionStatus}
|
||||
hasChanges={hasChanges}
|
||||
onClickMenuButton={handleClickMenuButton}
|
||||
onClickPublish={onOpenPublishModal}
|
||||
onClickConfigure={onOpenConfigureModal}
|
||||
onClickEdit={openForm}
|
||||
onClickDelete={onOpenDeleteModal}
|
||||
onClickMoveUp={handleSectionMoveUp}
|
||||
onClickMoveDown={handleSectionMoveDown}
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
actions={actions}
|
||||
/>
|
||||
)}
|
||||
<div className="section-card__content" data-testid="section-card__content">
|
||||
<div className="outline-section__status mb-1">
|
||||
<Button
|
||||
className="p-0 bg-transparent"
|
||||
data-destid="section-card-highlights-button"
|
||||
variant="tertiary"
|
||||
onClick={handleOpenHighlightsModal}
|
||||
>
|
||||
<Bubble className="mr-1">
|
||||
{highlights.length}
|
||||
</Bubble>
|
||||
<p className="m-0 text-black">{messages.sectionHighlightsBadge.defaultMessage}</p>
|
||||
</Button>
|
||||
</div>
|
||||
<XBlockStatus
|
||||
isSelfPaced={isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
blockData={section}
|
||||
/>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div
|
||||
data-testid="section-card__subsections"
|
||||
className={classNames('section-card__subsections', { 'item-children': isDraggable })}
|
||||
>
|
||||
{children}
|
||||
{actions.childAddable && (
|
||||
<Button
|
||||
data-testid="new-subsection-button"
|
||||
className="mt-4"
|
||||
variant="outline-primary"
|
||||
iconBefore={IconAdd}
|
||||
block
|
||||
onClick={handleNewSubsectionSubmit}
|
||||
>
|
||||
{intl.formatMessage(messages.newSubsectionButton)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SortableItem>
|
||||
);
|
||||
};
|
||||
|
||||
SectionCard.defaultProps = {
|
||||
children: null,
|
||||
};
|
||||
|
||||
SectionCard.propTypes = {
|
||||
section: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
category: PropTypes.string.isRequired,
|
||||
published: PropTypes.bool.isRequired,
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
visibilityState: PropTypes.string.isRequired,
|
||||
highlights: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
shouldScroll: PropTypes.bool,
|
||||
actions: PropTypes.shape({
|
||||
deletable: PropTypes.bool.isRequired,
|
||||
draggable: PropTypes.bool.isRequired,
|
||||
childAddable: PropTypes.bool.isRequired,
|
||||
duplicable: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
isHeaderVisible: PropTypes.bool,
|
||||
childInfo: PropTypes.shape({
|
||||
children: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
childInfo: PropTypes.shape({
|
||||
children: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
}),
|
||||
).isRequired,
|
||||
}).isRequired,
|
||||
}),
|
||||
).isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
isSelfPaced: PropTypes.bool.isRequired,
|
||||
isCustomRelativeDatesActive: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node,
|
||||
onOpenHighlightsModal: PropTypes.func.isRequired,
|
||||
onOpenPublishModal: PropTypes.func.isRequired,
|
||||
onOpenConfigureModal: PropTypes.func.isRequired,
|
||||
onEditSectionSubmit: PropTypes.func.isRequired,
|
||||
savingStatus: PropTypes.string.isRequired,
|
||||
onOpenDeleteModal: PropTypes.func.isRequired,
|
||||
onDuplicateSubmit: PropTypes.func.isRequired,
|
||||
isSectionsExpanded: PropTypes.bool.isRequired,
|
||||
onNewSubsectionSubmit: PropTypes.func.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
canMoveItem: PropTypes.func.isRequired,
|
||||
onOrderChange: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default SectionCard;
|
||||
@@ -1,17 +1,9 @@
|
||||
import React from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import {
|
||||
act, render, fireEvent, within,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
act, fireEvent, initializeMocks, render, screen, within,
|
||||
} from '@src/testUtils';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import SectionCard from './SectionCard';
|
||||
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
@@ -45,7 +37,7 @@ const subsection = {
|
||||
id: unit.id,
|
||||
}],
|
||||
},
|
||||
};
|
||||
} as XBlock;
|
||||
|
||||
const section = {
|
||||
id: '123',
|
||||
@@ -72,90 +64,79 @@ const section = {
|
||||
},
|
||||
}],
|
||||
},
|
||||
};
|
||||
} as XBlock;
|
||||
|
||||
const onEditSectionSubmit = jest.fn();
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const renderComponent = (props, entry = '/') => render(
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<IntlProvider locale="en">
|
||||
<SectionCard
|
||||
section={section}
|
||||
index={1}
|
||||
canMoveItem={jest.fn()}
|
||||
onOrderChange={jest.fn()}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenHighlightsModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSectionSubmit={onEditSectionSubmit}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
isSectionsExpanded
|
||||
onNewSubsectionSubmit={jest.fn()}
|
||||
isSelfPaced={false}
|
||||
isCustomRelativeDatesActive={false}
|
||||
{...props}
|
||||
>
|
||||
<span>children</span>
|
||||
</SectionCard>
|
||||
</IntlProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</AppProvider>,
|
||||
const renderComponent = (props?: object, entry = '/') => render(
|
||||
<SectionCard
|
||||
section={section}
|
||||
index={1}
|
||||
canMoveItem={jest.fn()}
|
||||
onOrderChange={jest.fn()}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenHighlightsModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSectionSubmit={onEditSectionSubmit}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
isSectionsExpanded
|
||||
onNewSubsectionSubmit={jest.fn()}
|
||||
isSelfPaced={false}
|
||||
isCustomRelativeDatesActive={false}
|
||||
onAddSubsectionFromLibrary={jest.fn()}
|
||||
resetScrollState={jest.fn()}
|
||||
{...props}
|
||||
>
|
||||
<span>children</span>
|
||||
</SectionCard>,
|
||||
{
|
||||
path: '/',
|
||||
routerProps: {
|
||||
initialEntries: [entry],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
describe('<SectionCard />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
it('render SectionCard component correctly', () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
renderComponent();
|
||||
|
||||
expect(getByTestId('section-card-header')).toBeInTheDocument();
|
||||
expect(getByTestId('section-card__content')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('section-card-header')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('section-card__content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands/collapses the card when the expand button is clicked', () => {
|
||||
const { queryByTestId, getByTestId } = renderComponent();
|
||||
renderComponent();
|
||||
|
||||
const expandButton = getByTestId('section-card-header__expanded-btn');
|
||||
const expandButton = screen.getByTestId('section-card-header__expanded-btn');
|
||||
fireEvent.click(expandButton);
|
||||
expect(queryByTestId('section-card__subsections')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('new-subsection-button')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('section-card__subsections')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'New subsection' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(expandButton);
|
||||
expect(queryByTestId('section-card__subsections')).toBeInTheDocument();
|
||||
expect(queryByTestId('new-subsection-button')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('section-card__subsections')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'New subsection' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('title only updates if changed', async () => {
|
||||
const { findByTestId } = renderComponent();
|
||||
renderComponent();
|
||||
|
||||
let editButton = await findByTestId('section-edit-button');
|
||||
let editButton = await screen.findByTestId('section-edit-button');
|
||||
fireEvent.click(editButton);
|
||||
let editField = await findByTestId('section-edit-field');
|
||||
let editField = await screen.findByTestId('section-edit-field');
|
||||
fireEvent.blur(editField);
|
||||
|
||||
expect(onEditSectionSubmit).not.toHaveBeenCalled();
|
||||
|
||||
editButton = await findByTestId('section-edit-button');
|
||||
editButton = await screen.findByTestId('section-edit-button');
|
||||
fireEvent.click(editButton);
|
||||
editField = await findByTestId('section-edit-field');
|
||||
editField = await screen.findByTestId('section-edit-field');
|
||||
fireEvent.change(editField, { target: { value: 'some random value' } });
|
||||
fireEvent.blur(editField);
|
||||
expect(onEditSectionSubmit).toHaveBeenCalled();
|
||||
@@ -172,7 +153,7 @@ describe('<SectionCard />', () => {
|
||||
});
|
||||
|
||||
it('hides add new, duplicate & delete option based on childAddable, duplicable & deletable action flag', async () => {
|
||||
const { findByTestId, queryByTestId } = renderComponent({
|
||||
renderComponent({
|
||||
section: {
|
||||
...section,
|
||||
actions: {
|
||||
@@ -183,32 +164,34 @@ describe('<SectionCard />', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const element = await findByTestId('section-card');
|
||||
const element = await screen.findByTestId('section-card');
|
||||
const menu = await within(element).findByTestId('section-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menu));
|
||||
expect(within(element).queryByTestId('section-card-header__menu-duplicate-button')).not.toBeInTheDocument();
|
||||
expect(within(element).queryByTestId('section-card-header__menu-delete-button')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('new-subsection-button')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'New subsection' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('check extended section when URL "show" param in subsection under section', async () => {
|
||||
const collapsedSections = { ...section };
|
||||
// @ts-ignore-next-line
|
||||
collapsedSections.isSectionsExpanded = false;
|
||||
const { findByTestId } = renderComponent(collapsedSections, `?show=${subsection.id}`);
|
||||
renderComponent(collapsedSections, `?show=${subsection.id}`);
|
||||
|
||||
const cardSubsections = await findByTestId('section-card__subsections');
|
||||
const newSubsectionButton = await findByTestId('new-subsection-button');
|
||||
const cardSubsections = await screen.findByTestId('section-card__subsections');
|
||||
const newSubsectionButton = await screen.findByRole('button', { name: 'New subsection' });
|
||||
expect(cardSubsections).toBeInTheDocument();
|
||||
expect(newSubsectionButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('check extended section when URL "show" param in unit under section', async () => {
|
||||
const collapsedSections = { ...section };
|
||||
// @ts-ignore-next-line
|
||||
collapsedSections.isSectionsExpanded = false;
|
||||
const { findByTestId } = renderComponent(collapsedSections, `?show=${unit.id}`);
|
||||
renderComponent(collapsedSections, `?show=${unit.id}`);
|
||||
|
||||
const cardSubsections = await findByTestId('section-card__subsections');
|
||||
const newSubsectionButton = await findByTestId('new-subsection-button');
|
||||
const cardSubsections = await screen.findByTestId('section-card__subsections');
|
||||
const newSubsectionButton = await screen.findByRole('button', { name: 'New subsection' });
|
||||
expect(cardSubsections).toBeInTheDocument();
|
||||
expect(newSubsectionButton).toBeInTheDocument();
|
||||
});
|
||||
@@ -216,11 +199,12 @@ describe('<SectionCard />', () => {
|
||||
it('check not extended section when URL "show" param not in section', async () => {
|
||||
const randomId = 'random-id';
|
||||
const collapsedSections = { ...section };
|
||||
// @ts-ignore-next-line
|
||||
collapsedSections.isSectionsExpanded = false;
|
||||
const { queryByTestId } = renderComponent(collapsedSections, `?show=${randomId}`);
|
||||
renderComponent(collapsedSections, `?show=${randomId}`);
|
||||
|
||||
const cardSubsections = await queryByTestId('section-card__subsections');
|
||||
const newSubsectionButton = await queryByTestId('new-subsection-button');
|
||||
const cardSubsections = screen.queryByTestId('section-card__subsections');
|
||||
const newSubsectionButton = screen.queryByRole('button', { name: 'New subsection' });
|
||||
expect(cardSubsections).toBeNull();
|
||||
expect(newSubsectionButton).toBeNull();
|
||||
});
|
||||
330
src/course-outline/section-card/SectionCard.tsx
Normal file
330
src/course-outline/section-card/SectionCard.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import {
|
||||
useContext, useEffect, useState, useRef, useCallback, ReactNode,
|
||||
} from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Bubble, Button, StandardModal, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { setCurrentItem, setCurrentSection } from '@src/course-outline/data/slice';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import CardHeader from '@src/course-outline/card-header/CardHeader';
|
||||
import SortableItem from '@src/course-outline/drag-helper/SortableItem';
|
||||
import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider';
|
||||
import TitleButton from '@src/course-outline/card-header/TitleButton';
|
||||
import XBlockStatus from '@src/course-outline/xblock-status/XBlockStatus';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course-outline/utils';
|
||||
import OutlineAddChildButtons from '@src/course-outline/OutlineAddChildButtons';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
import { ComponentPicker, SelectedComponent } from '@src/library-authoring';
|
||||
import { ContentType } from '@src/library-authoring/routes';
|
||||
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import messages from './messages';
|
||||
|
||||
interface SectionCardProps {
|
||||
section: XBlock,
|
||||
isSelfPaced: boolean,
|
||||
isCustomRelativeDatesActive: boolean,
|
||||
children: ReactNode,
|
||||
onOpenHighlightsModal: (section: XBlock) => void,
|
||||
onOpenPublishModal: () => void,
|
||||
onOpenConfigureModal: () => void,
|
||||
onEditSectionSubmit: (itemId: string, sectionId: string, displayName: string) => void,
|
||||
savingStatus: string,
|
||||
onOpenDeleteModal: () => void,
|
||||
onDuplicateSubmit: () => void,
|
||||
isSectionsExpanded: boolean,
|
||||
onNewSubsectionSubmit: (id: string) => void,
|
||||
onAddSubsectionFromLibrary: (props: object) => {},
|
||||
index: number,
|
||||
canMoveItem: (oldIndex: number, newIndex: number) => boolean,
|
||||
onOrderChange: (oldIndex: number, newIndex: number) => void,
|
||||
resetScrollState: () => void,
|
||||
}
|
||||
|
||||
const SectionCard = ({
|
||||
section,
|
||||
isSelfPaced,
|
||||
isCustomRelativeDatesActive,
|
||||
children,
|
||||
index,
|
||||
canMoveItem,
|
||||
onOpenHighlightsModal,
|
||||
onOpenPublishModal,
|
||||
onOpenConfigureModal,
|
||||
onEditSectionSubmit,
|
||||
savingStatus,
|
||||
onOpenDeleteModal,
|
||||
onDuplicateSubmit,
|
||||
isSectionsExpanded,
|
||||
onNewSubsectionSubmit,
|
||||
onAddSubsectionFromLibrary,
|
||||
onOrderChange,
|
||||
resetScrollState,
|
||||
}: SectionCardProps) => {
|
||||
const currentRef = useRef(null);
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const { activeId, overId } = useContext(DragContext);
|
||||
const [searchParams] = useSearchParams();
|
||||
const locatorId = searchParams.get('show');
|
||||
const isScrolledToElement = locatorId === section.id;
|
||||
const [
|
||||
isAddLibrarySubsectionModalOpen,
|
||||
openAddLibrarySubsectionModal,
|
||||
closeAddLibrarySubsectionModal,
|
||||
] = useToggle(false);
|
||||
|
||||
// Expand the section if a search result should be shown/scrolled to
|
||||
const containsSearchResult = () => {
|
||||
if (locatorId) {
|
||||
const subsections = section.childInfo?.children;
|
||||
if (subsections) {
|
||||
for (let i = 0; i < subsections.length; i++) {
|
||||
const subsection = subsections[i];
|
||||
|
||||
// Check if the search result is one of the subsections
|
||||
const matchedSubsection = subsection.id === locatorId;
|
||||
if (matchedSubsection) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the search result is one of the units
|
||||
const matchedUnit = !!subsection.childInfo?.children?.filter((child) => child.id === locatorId).length;
|
||||
if (matchedUnit) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
const [isExpanded, setIsExpanded] = useState(containsSearchResult() || isSectionsExpanded);
|
||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||
const namePrefix = 'section';
|
||||
|
||||
useEffect(() => {
|
||||
setIsExpanded(isSectionsExpanded);
|
||||
}, [isSectionsExpanded]);
|
||||
|
||||
const {
|
||||
id,
|
||||
category,
|
||||
displayName,
|
||||
hasChanges,
|
||||
published,
|
||||
visibilityState,
|
||||
highlights,
|
||||
actions: sectionActions,
|
||||
isHeaderVisible = true,
|
||||
} = section;
|
||||
|
||||
useEffect(() => {
|
||||
if (activeId === id && isExpanded) {
|
||||
setIsExpanded(false);
|
||||
} else if (overId === id && !isExpanded) {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [activeId, overId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentRef.current && (section.shouldScroll || isScrolledToElement)) {
|
||||
// Align element closer to the top of the screen if scrolling for search result
|
||||
const alignWithTop = !!isScrolledToElement;
|
||||
scrollToElement(currentRef.current, alignWithTop, true);
|
||||
resetScrollState();
|
||||
}
|
||||
}, [isScrolledToElement]);
|
||||
|
||||
useEffect(() => {
|
||||
// If the locatorId is set/changed, we need to make sure that the section is expanded
|
||||
// if it contains the result, in order to scroll to it
|
||||
setIsExpanded((prevState) => containsSearchResult() || prevState);
|
||||
}, [locatorId, setIsExpanded]);
|
||||
|
||||
// re-create actions object for customizations
|
||||
const actions = { ...sectionActions };
|
||||
// add actions to control display of move up & down menu buton.
|
||||
actions.allowMoveUp = canMoveItem(index, -1);
|
||||
actions.allowMoveDown = canMoveItem(index, 1);
|
||||
|
||||
const sectionStatus = getItemStatus({
|
||||
published,
|
||||
visibilityState,
|
||||
hasChanges,
|
||||
});
|
||||
|
||||
// remove border when section is expanded
|
||||
const borderStyle = getItemStatusBorder(!isExpanded ? sectionStatus : '');
|
||||
|
||||
const handleExpandContent = () => {
|
||||
setIsExpanded((prevState) => !prevState);
|
||||
};
|
||||
|
||||
const handleClickMenuButton = () => {
|
||||
dispatch(setCurrentItem(section));
|
||||
dispatch(setCurrentSection(section));
|
||||
};
|
||||
|
||||
const handleEditSubmit = (titleValue: string) => {
|
||||
if (displayName !== titleValue) {
|
||||
// both itemId and sectionId are same
|
||||
onEditSectionSubmit(id, id, titleValue);
|
||||
return;
|
||||
}
|
||||
|
||||
closeForm();
|
||||
};
|
||||
|
||||
const handleOpenHighlightsModal = () => {
|
||||
onOpenHighlightsModal(section);
|
||||
};
|
||||
|
||||
const handleNewSubsectionSubmit = () => {
|
||||
onNewSubsectionSubmit(id);
|
||||
};
|
||||
|
||||
const handleSectionMoveUp = () => {
|
||||
onOrderChange(index, index - 1);
|
||||
};
|
||||
|
||||
const handleSectionMoveDown = () => {
|
||||
onOrderChange(index, index + 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback to handle the selection of a library subsection to be imported to course.
|
||||
* @param {Object} selectedSubection - The selected subsection details.
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleSelectLibrarySubsection = useCallback((selectedSubection: SelectedComponent) => {
|
||||
onAddSubsectionFromLibrary({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: ContainerType.Sequential,
|
||||
parentLocator: id,
|
||||
libraryContentKey: selectedSubection.usageKey,
|
||||
});
|
||||
closeAddLibrarySubsectionModal();
|
||||
}, [id, onAddSubsectionFromLibrary, closeAddLibrarySubsectionModal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
closeForm();
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
const titleComponent = (
|
||||
<TitleButton
|
||||
title={displayName}
|
||||
isExpanded={isExpanded}
|
||||
onTitleClick={handleExpandContent}
|
||||
namePrefix={namePrefix}
|
||||
/>
|
||||
);
|
||||
|
||||
const isDraggable = actions.draggable && (actions.allowMoveUp || actions.allowMoveDown);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SortableItem
|
||||
id={id}
|
||||
category={category}
|
||||
isDraggable={isDraggable}
|
||||
isDroppable={actions.childAddable}
|
||||
componentStyle={{
|
||||
padding: '1.75rem',
|
||||
...borderStyle,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`section-card ${isScrolledToElement ? 'highlight' : ''}`}
|
||||
data-testid="section-card"
|
||||
ref={currentRef}
|
||||
>
|
||||
<div>
|
||||
{isHeaderVisible && (
|
||||
<CardHeader
|
||||
cardId={id}
|
||||
title={displayName}
|
||||
status={sectionStatus}
|
||||
hasChanges={hasChanges}
|
||||
onClickMenuButton={handleClickMenuButton}
|
||||
onClickPublish={onOpenPublishModal}
|
||||
onClickConfigure={onOpenConfigureModal}
|
||||
onClickEdit={openForm}
|
||||
onClickDelete={onOpenDeleteModal}
|
||||
onClickMoveUp={handleSectionMoveUp}
|
||||
onClickMoveDown={handleSectionMoveDown}
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
actions={actions}
|
||||
/>
|
||||
)}
|
||||
<div className="section-card__content" data-testid="section-card__content">
|
||||
<div className="outline-section__status mb-1">
|
||||
<Button
|
||||
className="p-0 bg-transparent"
|
||||
data-destid="section-card-highlights-button"
|
||||
variant="tertiary"
|
||||
onClick={handleOpenHighlightsModal}
|
||||
>
|
||||
<Bubble className="mr-1">
|
||||
{highlights.length}
|
||||
</Bubble>
|
||||
<p className="m-0 text-black">{messages.sectionHighlightsBadge.defaultMessage}</p>
|
||||
</Button>
|
||||
</div>
|
||||
<XBlockStatus
|
||||
isSelfPaced={isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
blockData={section}
|
||||
/>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div
|
||||
data-testid="section-card__subsections"
|
||||
className={classNames('section-card__subsections', { 'item-children': isDraggable })}
|
||||
>
|
||||
{children}
|
||||
{actions.childAddable && (
|
||||
<OutlineAddChildButtons
|
||||
handleNewButtonClick={handleNewSubsectionSubmit}
|
||||
handleUseFromLibraryClick={openAddLibrarySubsectionModal}
|
||||
childType={ContainerType.Subsection}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SortableItem>
|
||||
<StandardModal
|
||||
title={intl.formatMessage(messages.subsectionPickerModalTitle)}
|
||||
isOpen={isAddLibrarySubsectionModalOpen}
|
||||
onClose={closeAddLibrarySubsectionModal}
|
||||
isOverflowVisible={false}
|
||||
size="xl"
|
||||
>
|
||||
<ComponentPicker
|
||||
showOnlyPublished
|
||||
extraFilter={['block_type = "subsection"']}
|
||||
componentPickerMode="single"
|
||||
onComponentSelected={handleSelectLibrarySubsection}
|
||||
visibleTabs={[ContentType.subsections]}
|
||||
/>
|
||||
</StandardModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionCard;
|
||||
@@ -1,14 +1,15 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
newSubsectionButton: {
|
||||
id: 'course-authoring.course-outline.section.button.new-subsection',
|
||||
defaultMessage: 'New subsection',
|
||||
},
|
||||
sectionHighlightsBadge: {
|
||||
id: 'course-authoring.course-outline.section.badge.section-highlights',
|
||||
defaultMessage: 'Section highlights',
|
||||
},
|
||||
subsectionPickerModalTitle: {
|
||||
id: 'course-authoring.course-outline.section.subsection-modal.title',
|
||||
defaultMessage: 'Select subsection',
|
||||
description: 'Subsection modal picker title text in outline',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
|
||||
import {
|
||||
act, render, fireEvent, within, screen,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import SubsectionCard from './SubsectionCard';
|
||||
act, fireEvent, initializeMocks, render, screen, within,
|
||||
} from '@src/testUtils';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import cardHeaderMessages from '../card-header/messages';
|
||||
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
|
||||
import SubsectionCard from './SubsectionCard';
|
||||
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
@@ -32,7 +26,7 @@ jest.mock('react-redux', () => ({
|
||||
}));
|
||||
|
||||
// Mock ComponentPicker to call onComponentSelected on click
|
||||
jest.mock('../../library-authoring/component-picker', () => ({
|
||||
jest.mock('@src/library-authoring/component-picker', () => ({
|
||||
ComponentPicker: (props) => {
|
||||
const onClick = () => {
|
||||
// eslint-disable-next-line react/prop-types
|
||||
@@ -53,7 +47,7 @@ const unit = {
|
||||
id: 'unit-1',
|
||||
};
|
||||
|
||||
const subsection = {
|
||||
const subsection: XBlock = {
|
||||
id: '123',
|
||||
displayName: 'Subsection Name',
|
||||
category: 'sequential',
|
||||
@@ -73,9 +67,9 @@ const subsection = {
|
||||
id: unit.id,
|
||||
}],
|
||||
},
|
||||
};
|
||||
} as XBlock;
|
||||
|
||||
const section = {
|
||||
const section: XBlock = {
|
||||
id: '123',
|
||||
displayName: 'Section Name',
|
||||
published: true,
|
||||
@@ -87,83 +81,71 @@ const section = {
|
||||
id: subsection.id,
|
||||
}],
|
||||
},
|
||||
};
|
||||
} as XBlock;
|
||||
|
||||
const onEditSubectionSubmit = jest.fn();
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const renderComponent = (props, entry = '/') => render(
|
||||
<AppProvider store={store} wrapWithRouter={false}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<IntlProvider locale="en">
|
||||
<SubsectionCard
|
||||
section={section}
|
||||
subsection={subsection}
|
||||
index={1}
|
||||
isSelfPaced={false}
|
||||
getPossibleMoves={jest.fn()}
|
||||
onOrderChange={jest.fn()}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenHighlightsModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onNewUnitSubmit={jest.fn()}
|
||||
onAddUnitFromLibrary={handleOnAddUnitFromLibrary}
|
||||
isCustomRelativeDatesActive={false}
|
||||
onEditClick={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSubmit={onEditSubectionSubmit}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
namePrefix="subsection"
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
onPasteClick={jest.fn()}
|
||||
{...props}
|
||||
>
|
||||
<span>children</span>
|
||||
</SubsectionCard>
|
||||
</IntlProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</AppProvider>,
|
||||
const renderComponent = (props?: object, entry = '/') => render(
|
||||
<SubsectionCard
|
||||
section={section}
|
||||
subsection={subsection}
|
||||
index={1}
|
||||
isSelfPaced={false}
|
||||
getPossibleMoves={jest.fn()}
|
||||
onOrderChange={jest.fn()}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onNewUnitSubmit={jest.fn()}
|
||||
onAddUnitFromLibrary={handleOnAddUnitFromLibrary}
|
||||
isCustomRelativeDatesActive={false}
|
||||
savingStatus=""
|
||||
onEditSubmit={onEditSubectionSubmit}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
onPasteClick={jest.fn()}
|
||||
resetScrollState={jest.fn()}
|
||||
isSectionsExpanded={false}
|
||||
{...props}
|
||||
>
|
||||
<span>children</span>
|
||||
</SubsectionCard>,
|
||||
{
|
||||
path: '/',
|
||||
routerProps: {
|
||||
initialEntries: [entry],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
describe('<SubsectionCard />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
const mocks = initializeMocks();
|
||||
store = mocks.reduxStore;
|
||||
});
|
||||
|
||||
it('render SubsectionCard component correctly', () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
renderComponent();
|
||||
|
||||
expect(getByTestId('subsection-card-header')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('subsection-card-header')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands/collapses the card when the subsection button is clicked', async () => {
|
||||
const { queryByTestId, findByTestId } = renderComponent();
|
||||
renderComponent();
|
||||
|
||||
const expandButton = await findByTestId('subsection-card-header__expanded-btn');
|
||||
const expandButton = await screen.findByTestId('subsection-card-header__expanded-btn');
|
||||
fireEvent.click(expandButton);
|
||||
expect(queryByTestId('subsection-card__units')).toBeInTheDocument();
|
||||
expect(queryByTestId('new-unit-button')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('subsection-card__units')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'New unit' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(expandButton);
|
||||
expect(queryByTestId('subsection-card__units')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('new-unit-button')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('subsection-card__units')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'New unit' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates current section, subsection and item', async () => {
|
||||
const { findByTestId } = renderComponent();
|
||||
renderComponent();
|
||||
|
||||
const menu = await findByTestId('subsection-card-header__menu');
|
||||
const menu = await screen.findByTestId('subsection-card-header__menu');
|
||||
fireEvent.click(menu);
|
||||
const { currentSection, currentSubsection, currentItem } = store.getState().courseOutline;
|
||||
expect(currentSection).toEqual(section);
|
||||
@@ -172,35 +154,35 @@ describe('<SubsectionCard />', () => {
|
||||
});
|
||||
|
||||
it('title only updates if changed', async () => {
|
||||
const { findByTestId } = renderComponent();
|
||||
renderComponent();
|
||||
|
||||
let editButton = await findByTestId('subsection-edit-button');
|
||||
let editButton = await screen.findByTestId('subsection-edit-button');
|
||||
fireEvent.click(editButton);
|
||||
let editField = await findByTestId('subsection-edit-field');
|
||||
let editField = await screen.findByTestId('subsection-edit-field');
|
||||
fireEvent.blur(editField);
|
||||
|
||||
expect(onEditSubectionSubmit).not.toHaveBeenCalled();
|
||||
|
||||
editButton = await findByTestId('subsection-edit-button');
|
||||
editButton = await screen.findByTestId('subsection-edit-button');
|
||||
fireEvent.click(editButton);
|
||||
editField = await findByTestId('subsection-edit-field');
|
||||
editField = await screen.findByTestId('subsection-edit-field');
|
||||
fireEvent.change(editField, { target: { value: 'some random value' } });
|
||||
fireEvent.keyDown(editField, { key: 'Enter', keyCode: 13 });
|
||||
expect(onEditSubectionSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hides header based on isHeaderVisible flag', async () => {
|
||||
const { queryByTestId } = renderComponent({
|
||||
renderComponent({
|
||||
subsection: {
|
||||
...subsection,
|
||||
isHeaderVisible: false,
|
||||
},
|
||||
});
|
||||
expect(queryByTestId('subsection-card-header')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('subsection-card-header')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides add new, duplicate & delete option based on childAddable, duplicable & deletable action flag', async () => {
|
||||
const { findByTestId, queryByTestId } = renderComponent({
|
||||
renderComponent({
|
||||
subsection: {
|
||||
...subsection,
|
||||
actions: {
|
||||
@@ -211,43 +193,43 @@ describe('<SubsectionCard />', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const element = await findByTestId('subsection-card');
|
||||
const element = await screen.findByTestId('subsection-card');
|
||||
const menu = await within(element).findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menu));
|
||||
expect(within(element).queryByTestId('subsection-card-header__menu-duplicate-button')).not.toBeInTheDocument();
|
||||
expect(within(element).queryByTestId('subsection-card-header__menu-delete-button')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('new-unit-button')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'New unit' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders live status', async () => {
|
||||
const { findByText } = renderComponent();
|
||||
expect(await findByText(cardHeaderMessages.statusBadgeLive.defaultMessage)).toBeInTheDocument();
|
||||
renderComponent();
|
||||
expect(await screen.findByText(cardHeaderMessages.statusBadgeLive.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders published but live status', async () => {
|
||||
const { findByText } = renderComponent({
|
||||
renderComponent({
|
||||
subsection: {
|
||||
...subsection,
|
||||
published: true,
|
||||
visibilityState: 'ready',
|
||||
},
|
||||
});
|
||||
expect(await findByText(cardHeaderMessages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument();
|
||||
expect(await screen.findByText(cardHeaderMessages.statusBadgePublishedNotLive.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders staff status', async () => {
|
||||
const { findByText } = renderComponent({
|
||||
renderComponent({
|
||||
subsection: {
|
||||
...subsection,
|
||||
published: false,
|
||||
visibilityState: 'staff_only',
|
||||
},
|
||||
});
|
||||
expect(await findByText(cardHeaderMessages.statusBadgeStaffOnly.defaultMessage)).toBeInTheDocument();
|
||||
expect(await screen.findByText(cardHeaderMessages.statusBadgeStaffOnly.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders draft status', async () => {
|
||||
const { findByText } = renderComponent({
|
||||
renderComponent({
|
||||
subsection: {
|
||||
...subsection,
|
||||
published: false,
|
||||
@@ -255,24 +237,24 @@ describe('<SubsectionCard />', () => {
|
||||
hasChanges: true,
|
||||
},
|
||||
});
|
||||
expect(await findByText(cardHeaderMessages.statusBadgeDraft.defaultMessage)).toBeInTheDocument();
|
||||
expect(await screen.findByText(cardHeaderMessages.statusBadgeDraft.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('check extended subsection when URL "show" param in subsection', async () => {
|
||||
const { findByTestId } = renderComponent(null, `?show=${unit.id}`);
|
||||
renderComponent(undefined, `?show=${unit.id}`);
|
||||
|
||||
const cardUnits = await findByTestId('subsection-card__units');
|
||||
const newUnitButton = await findByTestId('new-unit-button');
|
||||
const cardUnits = await screen.findByTestId('subsection-card__units');
|
||||
const newUnitButton = await screen.findByRole('button', { name: 'New unit' });
|
||||
expect(cardUnits).toBeInTheDocument();
|
||||
expect(newUnitButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('check not extended subsection when URL "show" param not in subsection', async () => {
|
||||
const randomId = 'random-id';
|
||||
const { queryByTestId } = renderComponent(null, `?show=${randomId}`);
|
||||
renderComponent(undefined, `?show=${randomId}`);
|
||||
|
||||
const cardUnits = await queryByTestId('subsection-card__units');
|
||||
const newUnitButton = await queryByTestId('new-unit-button');
|
||||
const cardUnits = screen.queryByTestId('subsection-card__units');
|
||||
const newUnitButton = screen.queryByRole('button', { name: 'New unit' });
|
||||
expect(cardUnits).toBeNull();
|
||||
expect(newUnitButton).toBeNull();
|
||||
});
|
||||
@@ -1,32 +1,60 @@
|
||||
// @ts-check
|
||||
import React, {
|
||||
useContext, useEffect, useState, useRef, useCallback,
|
||||
useContext, useEffect, useState, useRef, useCallback, ReactNode,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button, StandardModal, useToggle } from '@openedx/paragon';
|
||||
import { Add as IconAdd } from '@openedx/paragon/icons';
|
||||
import { StandardModal, useToggle } from '@openedx/paragon';
|
||||
import classNames from 'classnames';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
import CourseOutlineSubsectionCardExtraActionsSlot from '../../plugin-slots/CourseOutlineSubsectionCardExtraActionsSlot';
|
||||
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import CardHeader from '../card-header/CardHeader';
|
||||
import SortableItem from '../drag-helper/SortableItem';
|
||||
import { DragContext } from '../drag-helper/DragContextProvider';
|
||||
import { useClipboard, PasteComponent } from '../../generic/clipboard';
|
||||
import TitleButton from '../card-header/TitleButton';
|
||||
import XBlockStatus from '../xblock-status/XBlockStatus';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
|
||||
import CourseOutlineSubsectionCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineSubsectionCardExtraActionsSlot';
|
||||
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import CardHeader from '@src/course-outline/card-header/CardHeader';
|
||||
import SortableItem from '@src/course-outline/drag-helper/SortableItem';
|
||||
import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider';
|
||||
import { useClipboard, PasteComponent } from '@src/generic/clipboard';
|
||||
import TitleButton from '@src/course-outline/card-header/TitleButton';
|
||||
import XBlockStatus from '@src/course-outline/xblock-status/XBlockStatus';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course-outline/utils';
|
||||
import { ComponentPicker, SelectedComponent } from '@src/library-authoring';
|
||||
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
import { ContentType } from '@src/library-authoring/routes';
|
||||
import OutlineAddChildButtons from '@src/course-outline/OutlineAddChildButtons';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import messages from './messages';
|
||||
import { ComponentPicker } from '../../library-authoring';
|
||||
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
|
||||
import { ContainerType } from '../../generic/key-utils';
|
||||
import { ContentType } from '../../library-authoring/routes';
|
||||
import { getStudioHomeData } from '../../studio-home/data/selectors';
|
||||
|
||||
interface SubsectionCardProps {
|
||||
section: XBlock,
|
||||
subsection: XBlock,
|
||||
children: ReactNode
|
||||
isSectionsExpanded: boolean,
|
||||
isSelfPaced: boolean,
|
||||
isCustomRelativeDatesActive: boolean,
|
||||
onOpenPublishModal: () => void,
|
||||
onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void,
|
||||
savingStatus: string,
|
||||
onOpenDeleteModal: () => void,
|
||||
onDuplicateSubmit: () => void,
|
||||
onNewUnitSubmit: (subsectionId: string) => void,
|
||||
onAddUnitFromLibrary: (options: {
|
||||
type: string,
|
||||
category?: string,
|
||||
parentLocator: string,
|
||||
displayName?: string,
|
||||
boilerplate?: string,
|
||||
stagedContent?: string,
|
||||
libraryContentKey: string,
|
||||
}) => void,
|
||||
index: number,
|
||||
getPossibleMoves: (index: number, step: number) => void,
|
||||
onOrderChange: (section: XBlock, moveDetails: any) => void,
|
||||
onOpenConfigureModal: () => void,
|
||||
onPasteClick: (parentLocator: string, sectionId: string) => void,
|
||||
resetScrollState: () => void,
|
||||
}
|
||||
|
||||
const SubsectionCard = ({
|
||||
section,
|
||||
@@ -47,7 +75,8 @@ const SubsectionCard = ({
|
||||
onOrderChange,
|
||||
onOpenConfigureModal,
|
||||
onPasteClick,
|
||||
}) => {
|
||||
resetScrollState,
|
||||
}: SubsectionCardProps) => {
|
||||
const currentRef = useRef(null);
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
@@ -58,12 +87,6 @@ const SubsectionCard = ({
|
||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||
const namePrefix = 'subsection';
|
||||
const { sharedClipboardData, showPasteUnit } = useClipboard();
|
||||
// WARNING: Do not use "useStudioHome" to get "librariesV2Enabled" flag below,
|
||||
// as it has a useEffect that fetches course waffle flags whenever
|
||||
// location.search is updated. Course search updates location.search when
|
||||
// user types, which will then trigger the useEffect and reload the page.
|
||||
// See https://github.com/openedx/frontend-app-authoring/pull/1938.
|
||||
const { librariesV2Enabled } = useSelector(getStudioHomeData);
|
||||
const [
|
||||
isAddLibraryUnitModalOpen,
|
||||
openAddLibraryUnitModal,
|
||||
@@ -121,7 +144,7 @@ const SubsectionCard = ({
|
||||
dispatch(setCurrentItem(subsection));
|
||||
};
|
||||
|
||||
const handleEditSubmit = (titleValue) => {
|
||||
const handleEditSubmit = (titleValue: string) => {
|
||||
if (displayName !== titleValue) {
|
||||
onEditSubmit(id, section.id, titleValue);
|
||||
return;
|
||||
@@ -167,12 +190,11 @@ const SubsectionCard = ({
|
||||
|
||||
useEffect(() => {
|
||||
// if this items has been newly added, scroll to it.
|
||||
// we need to check section.shouldScroll as whole section is fetched when a
|
||||
// subsection is duplicated under it.
|
||||
if (currentRef.current && (section.shouldScroll || subsection.shouldScroll || isScrolledToElement)) {
|
||||
if (currentRef.current && (subsection.shouldScroll || isScrolledToElement)) {
|
||||
// Align element closer to the top of the screen if scrolling for search result
|
||||
const alignWithTop = !!isScrolledToElement;
|
||||
scrollToElement(currentRef.current, alignWithTop);
|
||||
scrollToElement(currentRef.current, alignWithTop, true);
|
||||
resetScrollState();
|
||||
}
|
||||
}, [isScrolledToElement]);
|
||||
|
||||
@@ -194,7 +216,7 @@ const SubsectionCard = ({
|
||||
&& !(isHeaderVisible === false)
|
||||
);
|
||||
|
||||
const handleSelectLibraryUnit = useCallback((selectedUnit) => {
|
||||
const handleSelectLibraryUnit = useCallback((selectedUnit: SelectedComponent) => {
|
||||
onAddUnitFromLibrary({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: ContainerType.Vertical,
|
||||
@@ -202,7 +224,7 @@ const SubsectionCard = ({
|
||||
libraryContentKey: selectedUnit.usageKey,
|
||||
});
|
||||
closeAddLibraryUnitModal();
|
||||
}, []);
|
||||
}, [id, onAddUnitFromLibrary, closeAddLibraryUnitModal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -265,36 +287,19 @@ const SubsectionCard = ({
|
||||
{children}
|
||||
{actions.childAddable && (
|
||||
<>
|
||||
<Button
|
||||
data-testid="new-unit-button"
|
||||
className="mt-4"
|
||||
variant="outline-primary"
|
||||
iconBefore={IconAdd}
|
||||
block
|
||||
onClick={handleNewButtonClick}
|
||||
>
|
||||
{intl.formatMessage(messages.newUnitButton)}
|
||||
</Button>
|
||||
<OutlineAddChildButtons
|
||||
handleNewButtonClick={handleNewButtonClick}
|
||||
handleUseFromLibraryClick={openAddLibraryUnitModal}
|
||||
childType={ContainerType.Unit}
|
||||
/>
|
||||
{enableCopyPasteUnits && showPasteUnit && sharedClipboardData && (
|
||||
<PasteComponent
|
||||
className="mt-4"
|
||||
className="mt-4 border-gray-500 rounded-0"
|
||||
text={intl.formatMessage(messages.pasteButton)}
|
||||
clipboardData={sharedClipboardData}
|
||||
onClick={handlePasteButtonClick}
|
||||
/>
|
||||
)}
|
||||
{librariesV2Enabled && (
|
||||
<Button
|
||||
data-testid="use-unit-from-library"
|
||||
className="mt-4"
|
||||
variant="outline-primary"
|
||||
iconBefore={IconAdd}
|
||||
block
|
||||
onClick={openAddLibraryUnitModal}
|
||||
>
|
||||
{intl.formatMessage(messages.useUnitFromLibraryButton)}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -320,60 +325,4 @@ const SubsectionCard = ({
|
||||
);
|
||||
};
|
||||
|
||||
SubsectionCard.defaultProps = {
|
||||
children: null,
|
||||
};
|
||||
|
||||
SubsectionCard.propTypes = {
|
||||
section: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
published: PropTypes.bool.isRequired,
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
visibilityState: PropTypes.string.isRequired,
|
||||
shouldScroll: PropTypes.bool,
|
||||
}).isRequired,
|
||||
subsection: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
category: PropTypes.string.isRequired,
|
||||
published: PropTypes.bool.isRequired,
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
visibilityState: PropTypes.string.isRequired,
|
||||
shouldScroll: PropTypes.bool,
|
||||
enableCopyPasteUnits: PropTypes.bool,
|
||||
proctoringExamConfigurationLink: PropTypes.string,
|
||||
actions: PropTypes.shape({
|
||||
deletable: PropTypes.bool.isRequired,
|
||||
draggable: PropTypes.bool.isRequired,
|
||||
childAddable: PropTypes.bool.isRequired,
|
||||
duplicable: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
isHeaderVisible: PropTypes.bool,
|
||||
childInfo: PropTypes.shape({
|
||||
children: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
}),
|
||||
).isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
children: PropTypes.node,
|
||||
isSectionsExpanded: PropTypes.bool.isRequired,
|
||||
isSelfPaced: PropTypes.bool.isRequired,
|
||||
isCustomRelativeDatesActive: PropTypes.bool.isRequired,
|
||||
onOpenPublishModal: PropTypes.func.isRequired,
|
||||
onEditSubmit: PropTypes.func.isRequired,
|
||||
savingStatus: PropTypes.string.isRequired,
|
||||
onOpenDeleteModal: PropTypes.func.isRequired,
|
||||
onDuplicateSubmit: PropTypes.func.isRequired,
|
||||
onNewUnitSubmit: PropTypes.func.isRequired,
|
||||
onAddUnitFromLibrary: PropTypes.func.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
getPossibleMoves: PropTypes.func.isRequired,
|
||||
onOrderChange: PropTypes.func.isRequired,
|
||||
onOpenConfigureModal: PropTypes.func.isRequired,
|
||||
onPasteClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default SubsectionCard;
|
||||
@@ -1,21 +1,11 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
newUnitButton: {
|
||||
id: 'course-authoring.course-outline.subsection.button.new-unit',
|
||||
defaultMessage: 'New unit',
|
||||
description: 'Message of the button to create a new unit in a subsection.',
|
||||
},
|
||||
pasteButton: {
|
||||
id: 'course-authoring.course-outline.subsection.button.paste-unit',
|
||||
defaultMessage: 'Paste unit',
|
||||
description: 'Message of the button to paste a new unit in a subsection.',
|
||||
},
|
||||
useUnitFromLibraryButton: {
|
||||
id: 'course-authoring.course-outline.subsection.button.use-unit-from-library',
|
||||
defaultMessage: 'Use unit from library',
|
||||
description: 'Message of the button to add a new unit from a library in a subsection.',
|
||||
},
|
||||
unitPickerModalTitle: {
|
||||
id: 'course-authoring.course-outline.subsection.unit.modal.single-title.text',
|
||||
defaultMessage: 'Select unit',
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import {
|
||||
act, render, fireEvent, within, screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
act, fireEvent, initializeMocks, render, screen, waitFor, within,
|
||||
} from '@src/testUtils';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import UnitCard from './UnitCard';
|
||||
import cardMessages from '../card-header/messages';
|
||||
|
||||
let store;
|
||||
const mockUseAcceptLibraryBlockChanges = jest.fn();
|
||||
const mockUseIgnoreLibraryBlockChanges = jest.fn();
|
||||
|
||||
jest.mock('../../course-unit/data/apiHooks', () => ({
|
||||
jest.mock('@src/course-unit/data/apiHooks', () => ({
|
||||
useAcceptLibraryBlockChanges: () => ({
|
||||
mutateAsync: mockUseAcceptLibraryBlockChanges,
|
||||
}),
|
||||
@@ -31,7 +25,7 @@ const section = {
|
||||
visibilityState: 'live',
|
||||
hasChanges: false,
|
||||
highlights: ['highlight 1', 'highlight 2'],
|
||||
};
|
||||
} as XBlock;
|
||||
|
||||
const subsection = {
|
||||
id: '12',
|
||||
@@ -39,7 +33,7 @@ const subsection = {
|
||||
published: true,
|
||||
visibilityState: 'live',
|
||||
hasChanges: false,
|
||||
};
|
||||
} as XBlock;
|
||||
|
||||
const unit = {
|
||||
id: '123',
|
||||
@@ -60,49 +54,36 @@ const unit = {
|
||||
upstreamRef: 'lct:org1:lib1:unit:1',
|
||||
versionSynced: 1,
|
||||
},
|
||||
};
|
||||
} as XBlock;
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<AppProvider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IntlProvider locale="en">
|
||||
<UnitCard
|
||||
section={section}
|
||||
subsection={subsection}
|
||||
unit={unit}
|
||||
index={1}
|
||||
getPossibleMoves={jest.fn()}
|
||||
onOrderChange={jest.fn()}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSubmit={jest.fn()}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
getTitleLink={(id) => `/some/${id}`}
|
||||
isSelfPaced={false}
|
||||
isCustomRelativeDatesActive={false}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</QueryClientProvider>
|
||||
</AppProvider>,
|
||||
const renderComponent = (props?: object) => render(
|
||||
<UnitCard
|
||||
section={section}
|
||||
subsection={subsection}
|
||||
unit={unit}
|
||||
index={1}
|
||||
getPossibleMoves={jest.fn()}
|
||||
onOrderChange={jest.fn()}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSubmit={jest.fn()}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
getTitleLink={(id) => `/some/${id}`}
|
||||
isSelfPaced={false}
|
||||
isCustomRelativeDatesActive={false}
|
||||
discussionsSettings={{
|
||||
providerType: '',
|
||||
enableGradedUnits: false,
|
||||
}}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe('<UnitCard />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
it('render UnitCard component correctly', async () => {
|
||||
@@ -1,28 +1,49 @@
|
||||
// @ts-check
|
||||
import React, {
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
import CourseOutlineUnitCardExtraActionsSlot from '../../plugin-slots/CourseOutlineUnitCardExtraActionsSlot';
|
||||
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
|
||||
import { fetchCourseSectionQuery } from '../data/thunk';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { isUnitReadOnly } from '../../course-unit/data/utils';
|
||||
import CardHeader from '../card-header/CardHeader';
|
||||
import SortableItem from '../drag-helper/SortableItem';
|
||||
import TitleLink from '../card-header/TitleLink';
|
||||
import XBlockStatus from '../xblock-status/XBlockStatus';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
|
||||
import { useClipboard } from '../../generic/clipboard';
|
||||
import { PreviewLibraryXBlockChanges } from '../../course-unit/preview-changes';
|
||||
import CourseOutlineUnitCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineUnitCardExtraActionsSlot';
|
||||
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice';
|
||||
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import { isUnitReadOnly } from '@src/course-unit/data/utils';
|
||||
import CardHeader from '@src/course-outline/card-header/CardHeader';
|
||||
import SortableItem from '@src/course-outline/drag-helper/SortableItem';
|
||||
import TitleLink from '@src/course-outline/card-header/TitleLink';
|
||||
import XBlockStatus from '@src/course-outline/xblock-status/XBlockStatus';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '@src/course-outline/utils';
|
||||
import { useClipboard } from '@src/generic/clipboard';
|
||||
import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes';
|
||||
import { XBlock } from '@src/data/types';
|
||||
|
||||
interface UnitCardProps {
|
||||
unit: XBlock;
|
||||
subsection: XBlock;
|
||||
section: XBlock;
|
||||
onOpenPublishModal: () => void;
|
||||
onOpenConfigureModal: () => void;
|
||||
onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void,
|
||||
savingStatus: string;
|
||||
onOpenDeleteModal: () => void;
|
||||
onDuplicateSubmit: () => void;
|
||||
getTitleLink: (locator: string) => string;
|
||||
index: number;
|
||||
getPossibleMoves: (index: number, step: number) => void,
|
||||
onOrderChange: (section: XBlock, moveDetails: any) => void,
|
||||
isSelfPaced: boolean;
|
||||
isCustomRelativeDatesActive: boolean;
|
||||
discussionsSettings: {
|
||||
providerType: string;
|
||||
enableGradedUnits: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const UnitCard = ({
|
||||
unit,
|
||||
@@ -41,7 +62,7 @@ const UnitCard = ({
|
||||
getTitleLink,
|
||||
onOrderChange,
|
||||
discussionsSettings,
|
||||
}) => {
|
||||
}: UnitCardProps) => {
|
||||
const currentRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -68,7 +89,7 @@ const UnitCard = ({
|
||||
} = unit;
|
||||
|
||||
const blockSyncData = useMemo(() => {
|
||||
if (!upstreamInfo.readyToSync) {
|
||||
if (!upstreamInfo?.readyToSync) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
@@ -108,7 +129,7 @@ const UnitCard = ({
|
||||
dispatch(setCurrentSubsection(subsection));
|
||||
};
|
||||
|
||||
const handleEditSubmit = (titleValue) => {
|
||||
const handleEditSubmit = (titleValue: string) => {
|
||||
if (displayName !== titleValue) {
|
||||
onEditSubmit(id, section.id, titleValue);
|
||||
return;
|
||||
@@ -129,8 +150,8 @@ const UnitCard = ({
|
||||
copyToClipboard(id);
|
||||
};
|
||||
|
||||
const handleOnPostChangeSync = useCallback(async () => {
|
||||
await dispatch(fetchCourseSectionQuery([section.id]));
|
||||
const handleOnPostChangeSync = useCallback(() => {
|
||||
dispatch(fetchCourseSectionQuery([section.id]));
|
||||
}, [dispatch, section]);
|
||||
|
||||
const titleComponent = (
|
||||
@@ -151,12 +172,10 @@ const UnitCard = ({
|
||||
|
||||
useEffect(() => {
|
||||
// if this items has been newly added, scroll to it.
|
||||
// we need to check section.shouldScroll as whole section is fetched when a
|
||||
// unit is duplicated under it.
|
||||
if (currentRef.current && (section.shouldScroll || unit.shouldScroll || isScrolledToElement)) {
|
||||
if (currentRef.current && (unit.shouldScroll || isScrolledToElement)) {
|
||||
// Align element closer to the top of the screen if scrolling for search result
|
||||
const alignWithTop = !!isScrolledToElement;
|
||||
scrollToElement(currentRef.current, alignWithTop);
|
||||
scrollToElement(currentRef.current, alignWithTop, true);
|
||||
}
|
||||
}, [isScrolledToElement]);
|
||||
|
||||
@@ -218,7 +237,7 @@ const UnitCard = ({
|
||||
discussionsSettings={discussionsSettings}
|
||||
parentInfo={parentInfo}
|
||||
extraActionsComponent={extraActionsComponent}
|
||||
readyToSync={upstreamInfo.readyToSync}
|
||||
readyToSync={upstreamInfo?.readyToSync}
|
||||
/>
|
||||
<div className="unit-card__content item-children" data-testid="unit-card__content">
|
||||
<XBlockStatus
|
||||
@@ -241,68 +260,4 @@ const UnitCard = ({
|
||||
);
|
||||
};
|
||||
|
||||
UnitCard.defaultProps = {
|
||||
discussionsSettings: {},
|
||||
};
|
||||
|
||||
UnitCard.propTypes = {
|
||||
unit: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
category: PropTypes.string.isRequired,
|
||||
published: PropTypes.bool.isRequired,
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
visibilityState: PropTypes.string.isRequired,
|
||||
shouldScroll: PropTypes.bool,
|
||||
actions: PropTypes.shape({
|
||||
deletable: PropTypes.bool.isRequired,
|
||||
draggable: PropTypes.bool.isRequired,
|
||||
childAddable: PropTypes.bool.isRequired,
|
||||
duplicable: PropTypes.bool.isRequired,
|
||||
}).isRequired,
|
||||
isHeaderVisible: PropTypes.bool,
|
||||
enableCopyPasteUnits: PropTypes.bool,
|
||||
discussionEnabled: PropTypes.bool,
|
||||
upstreamInfo: PropTypes.shape({
|
||||
readyToSync: PropTypes.bool.isRequired,
|
||||
upstreamRef: PropTypes.string.isRequired,
|
||||
versionSynced: PropTypes.number.isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
subsection: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
published: PropTypes.bool.isRequired,
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
visibilityState: PropTypes.string.isRequired,
|
||||
shouldScroll: PropTypes.bool,
|
||||
isTimeLimited: PropTypes.bool,
|
||||
graded: PropTypes.bool,
|
||||
}).isRequired,
|
||||
section: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
published: PropTypes.bool.isRequired,
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
visibilityState: PropTypes.string.isRequired,
|
||||
shouldScroll: PropTypes.bool,
|
||||
}).isRequired,
|
||||
onOpenPublishModal: PropTypes.func.isRequired,
|
||||
onOpenConfigureModal: PropTypes.func.isRequired,
|
||||
onEditSubmit: PropTypes.func.isRequired,
|
||||
savingStatus: PropTypes.string.isRequired,
|
||||
onOpenDeleteModal: PropTypes.func.isRequired,
|
||||
onDuplicateSubmit: PropTypes.func.isRequired,
|
||||
getTitleLink: PropTypes.func.isRequired,
|
||||
index: PropTypes.number.isRequired,
|
||||
getPossibleMoves: PropTypes.func.isRequired,
|
||||
onOrderChange: PropTypes.func.isRequired,
|
||||
isSelfPaced: PropTypes.bool.isRequired,
|
||||
isCustomRelativeDatesActive: PropTypes.bool.isRequired,
|
||||
discussionsSettings: PropTypes.shape({
|
||||
providerType: PropTypes.string,
|
||||
enableGradedUnits: PropTypes.bool,
|
||||
}),
|
||||
};
|
||||
|
||||
export default UnitCard;
|
||||
@@ -3,9 +3,9 @@ import {
|
||||
Lock as LockIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import DraftIcon from '../generic/DraftIcon';
|
||||
import DraftIcon from '@src/generic/DraftIcon';
|
||||
import { VisibilityTypes } from '@src/data/constants';
|
||||
import { ITEM_BADGE_STATUS, VIDEO_SHARING_OPTIONS } from './constants';
|
||||
import { VisibilityTypes } from '../data/constants';
|
||||
|
||||
/**
|
||||
* Get section status depended on section info
|
||||
@@ -167,9 +167,11 @@ const getHighlightsFormValues = (currentHighlights) => {
|
||||
* @param {Object} target - DOM Element
|
||||
* @param {boolean} alignWithTop (optional) - Whether top of the target will be aligned to
|
||||
* the top of viewpoint. (default: false)
|
||||
* @param {boolean} highlight (optional) - Whether highlight the target after scrolling.
|
||||
* (default: false)
|
||||
* @returns {undefined}
|
||||
*/
|
||||
const scrollToElement = (target, alignWithTop = false) => {
|
||||
const scrollToElement = (target, alignWithTop = false, highlight = false) => {
|
||||
if (target.getBoundingClientRect().bottom > window.innerHeight) {
|
||||
// if alignWithTop is set, the top of the target will be aligned to the top of visible area
|
||||
// of the scrollable ancestor, Otherwise, the bottom of the target will be aligned to the
|
||||
@@ -186,6 +188,10 @@ const scrollToElement = (target, alignWithTop = false) => {
|
||||
// The top of the target will be aligned to the top of the visible area of the scrollable ancestor
|
||||
target.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
if (highlight && !target.classList.contains('highlight')) {
|
||||
target.classList.add('highlight');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import {
|
||||
@@ -7,8 +6,23 @@ import {
|
||||
Groups as GroupsIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import { UserPartitionInfoTypes, XBlockPrereqs } from '@src/data/types';
|
||||
import messages from './messages';
|
||||
|
||||
interface StatusMessagesProps {
|
||||
isVertical: boolean;
|
||||
staffOnlyMessage?: boolean,
|
||||
prereq?: string,
|
||||
prereqs?: XBlockPrereqs[],
|
||||
userPartitionInfo?: UserPartitionInfoTypes,
|
||||
hasPartitionGroupComponents?: boolean,
|
||||
}
|
||||
|
||||
interface StatusMessagesText {
|
||||
icon: React.ComponentType;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const StatusMessages = ({
|
||||
isVertical,
|
||||
staffOnlyMessage,
|
||||
@@ -16,13 +30,13 @@ const StatusMessages = ({
|
||||
prereqs,
|
||||
userPartitionInfo,
|
||||
hasPartitionGroupComponents,
|
||||
}) => {
|
||||
}: StatusMessagesProps) => {
|
||||
const intl = useIntl();
|
||||
const statusMessages = [];
|
||||
const statusMessages: StatusMessagesText[] = [];
|
||||
|
||||
if (prereq) {
|
||||
let prereqDisplayName = '';
|
||||
prereqs.forEach((block) => {
|
||||
prereqs?.forEach((block) => {
|
||||
if (block.blockUsageKey === prereq) {
|
||||
prereqDisplayName = block.blockDisplayName;
|
||||
}
|
||||
@@ -34,7 +48,7 @@ const StatusMessages = ({
|
||||
}
|
||||
|
||||
if (!staffOnlyMessage && isVertical) {
|
||||
const { selectedPartitionIndex, selectedGroupsLabel } = userPartitionInfo;
|
||||
const { selectedPartitionIndex, selectedGroupsLabel } = userPartitionInfo || {};
|
||||
if (selectedPartitionIndex !== -1 && !Number.isNaN(selectedPartitionIndex)) {
|
||||
statusMessages.push({
|
||||
icon: GroupsIcon,
|
||||
@@ -63,27 +77,4 @@ const StatusMessages = ({
|
||||
return null;
|
||||
};
|
||||
|
||||
StatusMessages.defaultProps = {
|
||||
staffOnlyMessage: false,
|
||||
prereq: '',
|
||||
prereqs: [],
|
||||
userPartitionInfo: {},
|
||||
hasPartitionGroupComponents: false,
|
||||
};
|
||||
|
||||
StatusMessages.propTypes = {
|
||||
isVertical: PropTypes.bool.isRequired,
|
||||
staffOnlyMessage: PropTypes.bool,
|
||||
prereq: PropTypes.string,
|
||||
prereqs: PropTypes.arrayOf(PropTypes.shape({
|
||||
blockUsageKey: PropTypes.string.isRequired,
|
||||
blockDisplayName: PropTypes.string.isRequired,
|
||||
})),
|
||||
userPartitionInfo: PropTypes.shape({
|
||||
selectedPartitionIndex: PropTypes.number,
|
||||
selectedGroupsLabel: PropTypes.string,
|
||||
}),
|
||||
hasPartitionGroupComponents: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default StatusMessages;
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { ShowAnswerTypesKeys } from '@src/editors/data/constants/problem';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
import ReleaseStatus from './ReleaseStatus';
|
||||
import GradingPolicyAlert from './GradingPolicyAlert';
|
||||
@@ -8,13 +7,18 @@ import GradingTypeAndDueDate from './GradingTypeAndDueDate';
|
||||
import StatusMessages from './StatusMessages';
|
||||
import HideAfterDueMessage from './HideAfterDueMessage';
|
||||
import NeverShowAssessmentResultMessage from './NeverShowAssessmentResultMessage';
|
||||
import { ShowAnswerTypesKeys } from '../../editors/data/constants/problem';
|
||||
|
||||
interface XBlockStatusProps {
|
||||
isSelfPaced: boolean;
|
||||
isCustomRelativeDatesActive: boolean,
|
||||
blockData: XBlock,
|
||||
}
|
||||
|
||||
const XBlockStatus = ({
|
||||
isSelfPaced,
|
||||
isCustomRelativeDatesActive,
|
||||
blockData,
|
||||
}) => {
|
||||
}: XBlockStatusProps) => {
|
||||
const {
|
||||
category,
|
||||
explanatoryMessage,
|
||||
@@ -89,41 +93,4 @@ const XBlockStatus = ({
|
||||
);
|
||||
};
|
||||
|
||||
XBlockStatus.defaultProps = {
|
||||
isCustomRelativeDatesActive: false,
|
||||
};
|
||||
|
||||
XBlockStatus.propTypes = {
|
||||
isSelfPaced: PropTypes.bool.isRequired,
|
||||
isCustomRelativeDatesActive: PropTypes.bool,
|
||||
blockData: PropTypes.shape({
|
||||
category: PropTypes.string.isRequired,
|
||||
explanatoryMessage: PropTypes.string,
|
||||
releasedToStudents: PropTypes.bool,
|
||||
releaseDate: PropTypes.string,
|
||||
isProctoredExam: PropTypes.bool,
|
||||
isOnboardingExam: PropTypes.bool,
|
||||
isPracticeExam: PropTypes.bool,
|
||||
prereq: PropTypes.string,
|
||||
prereqs: PropTypes.arrayOf(PropTypes.shape({
|
||||
blockUsageKey: PropTypes.string.isRequired,
|
||||
blockDisplayName: PropTypes.string.isRequired,
|
||||
})),
|
||||
staffOnlyMessage: PropTypes.bool,
|
||||
userPartitionInfo: PropTypes.shape({
|
||||
selectedPartitionIndex: PropTypes.number,
|
||||
selectedGroupsLabel: PropTypes.string,
|
||||
}),
|
||||
hasPartitionGroupComponents: PropTypes.bool,
|
||||
format: PropTypes.string,
|
||||
dueDate: PropTypes.string,
|
||||
relativeWeeksDue: PropTypes.number,
|
||||
isTimeLimited: PropTypes.bool,
|
||||
graded: PropTypes.bool,
|
||||
courseGraders: PropTypes.arrayOf(PropTypes.string.isRequired),
|
||||
hideAfterDue: PropTypes.bool,
|
||||
showCorrectness: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default XBlockStatus;
|
||||
@@ -1,16 +1,4 @@
|
||||
export interface GroupTypes {
|
||||
id: number;
|
||||
name: string;
|
||||
selected: boolean;
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
export interface UserPartitionTypes {
|
||||
id: number;
|
||||
name: string;
|
||||
scheme: string;
|
||||
groups: Array<GroupTypes>;
|
||||
}
|
||||
import { UserPartitionInfoTypes, UserPartitionTypes, XBlockPrereqs } from '@src/data/types';
|
||||
|
||||
export interface XBlockActionsTypes {
|
||||
canCopy: boolean;
|
||||
@@ -50,27 +38,6 @@ export interface XBlockContainerIframeProps {
|
||||
handleConfigureSubmit: (XBlockId: string, ...args: any[]) => void;
|
||||
}
|
||||
|
||||
export type UserPartitionInfoTypes = {
|
||||
selectablePartitions: Array<{
|
||||
groups: Array<{
|
||||
deleted: boolean;
|
||||
id: number;
|
||||
name: string;
|
||||
selected: boolean;
|
||||
}>;
|
||||
id: number;
|
||||
name: string;
|
||||
scheme: string;
|
||||
}>;
|
||||
selectedPartitionIndex: number;
|
||||
selectedGroupsLabel: string;
|
||||
};
|
||||
|
||||
export type PrereqTypes = {
|
||||
blockDisplayName: string;
|
||||
blockUsageKey: string;
|
||||
};
|
||||
|
||||
export type AccessManagedXBlockDataTypes = {
|
||||
id: string;
|
||||
displayName?: string;
|
||||
@@ -88,7 +55,7 @@ export type AccessManagedXBlockDataTypes = {
|
||||
userPartitionInfo?: UserPartitionInfoTypes;
|
||||
ancestorHasStaffLock?: boolean;
|
||||
isPrereq?: boolean;
|
||||
prereqs?: PrereqTypes[];
|
||||
prereqs?: XBlockPrereqs[];
|
||||
prereq?: number;
|
||||
prereqMinScore?: number;
|
||||
prereqMinCompletion?: number;
|
||||
|
||||
110
src/data/types.ts
Normal file
110
src/data/types.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
export interface GroupTypes {
|
||||
id: number;
|
||||
name: string;
|
||||
selected: boolean;
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
export interface UserPartitionTypes {
|
||||
id: number;
|
||||
name: string;
|
||||
scheme: string;
|
||||
groups: Array<GroupTypes>;
|
||||
}
|
||||
|
||||
export type UserPartitionInfoTypes = {
|
||||
selectablePartitions: Array<{
|
||||
groups: Array<{
|
||||
deleted: boolean;
|
||||
id: number;
|
||||
name: string;
|
||||
selected: boolean;
|
||||
}>;
|
||||
id: number;
|
||||
name: string;
|
||||
scheme: string;
|
||||
}>;
|
||||
selectedPartitionIndex: number;
|
||||
selectedGroupsLabel: string;
|
||||
};
|
||||
|
||||
export interface XBlockActions {
|
||||
deletable: boolean;
|
||||
draggable: boolean;
|
||||
childAddable: boolean;
|
||||
duplicable: boolean;
|
||||
allowMoveDown?: boolean;
|
||||
allowMoveUp?: boolean;
|
||||
}
|
||||
|
||||
export interface XblockChildInfo {
|
||||
displayName: string;
|
||||
children: Array<XBlock>;
|
||||
}
|
||||
|
||||
export interface XBlockPrereqs {
|
||||
blockUsageKey: string;
|
||||
blockDisplayName: string;
|
||||
}
|
||||
|
||||
export interface UpstreeamInfo {
|
||||
readyToSync: boolean,
|
||||
upstreamRef: string,
|
||||
versionSynced: number,
|
||||
}
|
||||
|
||||
export interface XBlock {
|
||||
id: string;
|
||||
locator: string;
|
||||
usageKey: string;
|
||||
displayName: string;
|
||||
category: string;
|
||||
hasChildren: boolean;
|
||||
editedOn: string;
|
||||
published: boolean;
|
||||
publishedOn: string;
|
||||
studioUrl: string;
|
||||
releasedToStudents: boolean;
|
||||
releaseDate: string;
|
||||
visibilityState: string;
|
||||
hasExplicitStaffLock: boolean;
|
||||
start: string;
|
||||
graded: boolean;
|
||||
dueDate: string;
|
||||
due?: string;
|
||||
relativeWeeksDue?: number;
|
||||
format?: string;
|
||||
courseGraders: string[];
|
||||
hasChanges: boolean;
|
||||
actions: XBlockActions;
|
||||
explanatoryMessage?: string;
|
||||
userPartitions: UserPartitionTypes[];
|
||||
showCorrectness: string;
|
||||
highlights: string[];
|
||||
highlightsEnabled: boolean;
|
||||
highlightsPreviewOnly: boolean;
|
||||
highlightsDocUrl: string;
|
||||
childInfo: XblockChildInfo;
|
||||
ancestorHasStaffLock: boolean;
|
||||
staffOnlyMessage: boolean;
|
||||
hasPartitionGroupComponents: boolean;
|
||||
userPartitionInfo?: UserPartitionInfoTypes;
|
||||
enableCopyPasteUnits: boolean;
|
||||
shouldScroll: boolean;
|
||||
isHeaderVisible: boolean;
|
||||
proctoringExamConfigurationLink?: string;
|
||||
isTimeLimited?: boolean;
|
||||
defaultTimeLimitMinutes?: number;
|
||||
hideAfterDue?: boolean;
|
||||
isProctoredExam?: boolean;
|
||||
isPracticeExam?: boolean;
|
||||
isOnboardingExam?: boolean;
|
||||
examReviewRules?: string;
|
||||
isPrereq?: boolean;
|
||||
prereq?: string;
|
||||
prereqs?: XBlockPrereqs[];
|
||||
prereqMinScore?: number;
|
||||
prereqMinCompletion?: number;
|
||||
discussionEnabled?: boolean;
|
||||
upstreamInfo?: UpstreeamInfo;
|
||||
}
|
||||
@@ -257,7 +257,8 @@ const LibraryAuthoringPage = ({
|
||||
// or when inside a specific Section or Subsection.
|
||||
const onlyOneType = (
|
||||
insideCollections || insideUnits || insideSections || insideSubsections
|
||||
|| insideSection || insideSubsection
|
||||
|| insideSection || insideSubsection
|
||||
|| !([ContentType.home, ContentType.components].includes(activeKey))
|
||||
);
|
||||
const overrideTypesFilter = onlyOneType
|
||||
? new TypesFilterData()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { default as LibraryLayout } from './LibraryLayout';
|
||||
export { ComponentPicker } from './component-picker';
|
||||
export { type SelectedComponent } from './common/context/ComponentPickerContext';
|
||||
export { CreateLibrary } from './create-library';
|
||||
export { libraryAuthoringQueryKeys, useContentLibraryV2List } from './data/apiHooks';
|
||||
export { default as PreviewChangesEmbed } from './legacy-integration/PreviewChangesEmbed';
|
||||
|
||||
@@ -4,6 +4,8 @@ const { createConfig } = require('@openedx/frontend-build');
|
||||
const config = createConfig('webpack-prod', {
|
||||
resolve: {
|
||||
alias: {
|
||||
// Within this app, we can use '@src/foo instead of relative URLs like '../../../foo'
|
||||
'@src': path.resolve(__dirname, 'src/'),
|
||||
// Plugins can use 'CourseAuthoring' as an import alias for this app:
|
||||
CourseAuthoring: path.resolve(__dirname, 'src/'),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user