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:
Navin Karkera
2025-07-22 02:08:47 +05:30
committed by GitHub
parent 46d5917303
commit 537b3292ee
36 changed files with 1902 additions and 1596 deletions

View File

@@ -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();

View File

@@ -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;

View 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());
});
});
});

View 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;

View File

@@ -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 () => {

View File

@@ -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;

View File

@@ -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);`

View 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);
},
});

View File

@@ -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 {

View File

@@ -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 }));

View 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;
}

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View 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;

View File

@@ -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;

View File

@@ -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,
};
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
});

View 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;

View File

@@ -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;

View File

@@ -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();
});

View File

@@ -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;

View File

@@ -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',

View File

@@ -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 () => {

View File

@@ -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;

View File

@@ -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');
}
};
/**

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
View 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;
}

View File

@@ -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()

View File

@@ -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';

View File

@@ -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/'),
},