feat: container info sidebar and add sidebar updates (#2830)
* Adds section, subsection and unit sidebar info tab in course outline as described in https://github.com/openedx/frontend-app-authoring/issues/2638 * Updates the sidebar design and behaviour as per https://github.com/openedx/frontend-app-authoring/issues/2826 * Updates course outline to use react query and removes redux store usage as much as possible. Updated parts that require absolutely cannot work without redux without heavy refactoring (will require quiet some time) to work in tandem with react-query.
This commit is contained in:
@@ -1,15 +1,27 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { createContext, useContext, useMemo } from 'react';
|
||||
import {
|
||||
createContext, useContext, useMemo, useState,
|
||||
} from 'react';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { useCreateCourseBlock } from '@src/course-outline/data/apiHooks';
|
||||
import { getCourseItem } from '@src/course-outline/data/api';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { addSection, addSubsection, updateSavingStatus } from '@src/course-outline/data/slice';
|
||||
import {
|
||||
addSection, addSubsection, addUnit, updateSavingStatus,
|
||||
} from '@src/course-outline/data/slice';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { getOutlineIndexData } from '@src/course-outline/data/selectors';
|
||||
import { RequestStatus, RequestStatusType } from './data/constants';
|
||||
import { useCourseDetails, useWaffleFlags } from './data/apiHooks';
|
||||
import { useToggleWithValue } from '@src/hooks';
|
||||
import { SelectionState, type UnitXBlock, type XBlock } from '@src/data/types';
|
||||
import { CourseDetailsData } from './data/api';
|
||||
import { useCourseDetails, useWaffleFlags } from './data/apiHooks';
|
||||
import { RequestStatus, RequestStatusType } from './data/constants';
|
||||
|
||||
type ModalState = {
|
||||
value: XBlock | UnitXBlock;
|
||||
subsectionId?: string;
|
||||
sectionId?: string;
|
||||
};
|
||||
|
||||
export type CourseAuthoringContextData = {
|
||||
/** The ID of the current course */
|
||||
@@ -20,9 +32,20 @@ export type CourseAuthoringContextData = {
|
||||
canChangeProviders: boolean;
|
||||
handleAddSection: ReturnType<typeof useCreateCourseBlock>;
|
||||
handleAddSubsection: ReturnType<typeof useCreateCourseBlock>;
|
||||
handleAddAndOpenUnit: ReturnType<typeof useCreateCourseBlock>;
|
||||
handleAddUnit: ReturnType<typeof useCreateCourseBlock>;
|
||||
openUnitPage: (locator: string) => void;
|
||||
getUnitUrl: (locator: string) => string;
|
||||
isUnlinkModalOpen: boolean;
|
||||
currentUnlinkModalData?: ModalState;
|
||||
openUnlinkModal: (value: ModalState) => void;
|
||||
closeUnlinkModal: () => void;
|
||||
isPublishModalOpen: boolean;
|
||||
currentPublishModalData?: ModalState;
|
||||
openPublishModal: (value: ModalState) => void;
|
||||
closePublishModal: () => void;
|
||||
currentSelection?: SelectionState;
|
||||
setCurrentSelection: React.Dispatch<React.SetStateAction<SelectionState | undefined>>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -50,6 +73,26 @@ export const CourseAuthoringProvider = ({
|
||||
const canChangeProviders = getAuthenticatedUser().administrator || new Date(courseDetails?.start ?? 0) > new Date();
|
||||
const { courseStructure } = useSelector(getOutlineIndexData);
|
||||
const { id: courseUsageKey } = courseStructure || {};
|
||||
const [
|
||||
isUnlinkModalOpen,
|
||||
currentUnlinkModalData,
|
||||
openUnlinkModal,
|
||||
closeUnlinkModal,
|
||||
] = useToggleWithValue<ModalState>();
|
||||
const [
|
||||
isPublishModalOpen,
|
||||
currentPublishModalData,
|
||||
openPublishModal,
|
||||
closePublishModal,
|
||||
] = useToggleWithValue<ModalState>();
|
||||
/**
|
||||
* This will hold the state of current item that is being operated on,
|
||||
* For example:
|
||||
* - the details of container that is being edited.
|
||||
* - the details of container of which see more dropdown is open.
|
||||
* It is mostly used in modals which should be soon be replaced with its equivalent in sidebar.
|
||||
*/
|
||||
const [currentSelection, setCurrentSelection] = useState<SelectionState | undefined>();
|
||||
|
||||
const getUnitUrl = (locator: string) => {
|
||||
if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) {
|
||||
@@ -62,7 +105,7 @@ export const CourseAuthoringProvider = ({
|
||||
/**
|
||||
* Open the unit page for a given locator.
|
||||
*/
|
||||
const openUnitPage = (locator: string) => {
|
||||
const openUnitPage = async (locator: string) => {
|
||||
const url = getUnitUrl(locator);
|
||||
if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) {
|
||||
// instanbul ignore next
|
||||
@@ -72,10 +115,9 @@ export const CourseAuthoringProvider = ({
|
||||
}
|
||||
};
|
||||
|
||||
const addSectionToCourse = async (locator: string) => {
|
||||
const addSectionToCourse = /* istanbul ignore next */ async (locator: string) => {
|
||||
try {
|
||||
const data = await getCourseItem(locator);
|
||||
// instanbul ignore next
|
||||
// Page should scroll to newly added section.
|
||||
data.shouldScroll = true;
|
||||
dispatch(addSection(data));
|
||||
@@ -84,23 +126,35 @@ export const CourseAuthoringProvider = ({
|
||||
}
|
||||
};
|
||||
|
||||
const addSubsectionToCourse = async (locator: string, parentLocator: string) => {
|
||||
const addSubsectionToCourse = /* istanbul ignore next */ async (locator: string, parentLocator: string) => {
|
||||
try {
|
||||
const data = await getCourseItem(locator);
|
||||
data.shouldScroll = true;
|
||||
// Page should scroll to newly added subsection.
|
||||
data.shouldScroll = true;
|
||||
dispatch(addSubsection({ parentLocator, data }));
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
|
||||
const addUnitToCourse = /* istanbul ignore next */ async (locator: string, parentLocator: string) => {
|
||||
try {
|
||||
const data = await getCourseItem(locator);
|
||||
// Page should scroll to newly added subsection.
|
||||
data.shouldScroll = true;
|
||||
dispatch(addUnit({ parentLocator, data }));
|
||||
} catch {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSection = useCreateCourseBlock(addSectionToCourse);
|
||||
const handleAddSubsection = useCreateCourseBlock(addSubsectionToCourse);
|
||||
/**
|
||||
* import a unit block from library and redirect user to this unit page.
|
||||
*/
|
||||
const handleAddUnit = useCreateCourseBlock(openUnitPage);
|
||||
const handleAddAndOpenUnit = useCreateCourseBlock(openUnitPage);
|
||||
const handleAddUnit = useCreateCourseBlock(addUnitToCourse);
|
||||
|
||||
const context = useMemo<CourseAuthoringContextData>(() => ({
|
||||
courseId,
|
||||
@@ -111,8 +165,19 @@ export const CourseAuthoringProvider = ({
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
handleAddAndOpenUnit,
|
||||
getUnitUrl,
|
||||
openUnitPage,
|
||||
isUnlinkModalOpen,
|
||||
openUnlinkModal,
|
||||
closeUnlinkModal,
|
||||
currentUnlinkModalData,
|
||||
isPublishModalOpen,
|
||||
currentPublishModalData,
|
||||
openPublishModal,
|
||||
closePublishModal,
|
||||
currentSelection,
|
||||
setCurrentSelection,
|
||||
}), [
|
||||
courseId,
|
||||
courseUsageKey,
|
||||
@@ -122,8 +187,19 @@ export const CourseAuthoringProvider = ({
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
handleAddAndOpenUnit,
|
||||
getUnitUrl,
|
||||
openUnitPage,
|
||||
isUnlinkModalOpen,
|
||||
openUnlinkModal,
|
||||
closeUnlinkModal,
|
||||
currentUnlinkModalData,
|
||||
isPublishModalOpen,
|
||||
currentPublishModalData,
|
||||
openPublishModal,
|
||||
closePublishModal,
|
||||
currentSelection,
|
||||
setCurrentSelection,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,7 +11,11 @@ import VideoSelectorContainer from './selectors/VideoSelectorContainer';
|
||||
import CustomPages from './custom-pages';
|
||||
import { FilesPage, VideosPage } from './files-and-videos';
|
||||
import { AdvancedSettings } from './advanced-settings';
|
||||
import { CourseOutline, OutlineSidebarPagesProvider } from './course-outline';
|
||||
import {
|
||||
CourseOutline,
|
||||
OutlineSidebarProvider,
|
||||
OutlineSidebarPagesProvider,
|
||||
} from './course-outline';
|
||||
import ScheduleAndDetails from './schedule-and-details';
|
||||
import { GradingSettings } from './grading-settings';
|
||||
import CourseTeam from './course-team/CourseTeam';
|
||||
@@ -61,7 +65,9 @@ const CourseAuthoringRoutes = () => {
|
||||
element={(
|
||||
<PageWrap>
|
||||
<OutlineSidebarPagesProvider>
|
||||
<CourseOutline />
|
||||
<OutlineSidebarProvider>
|
||||
<CourseOutline />
|
||||
</OutlineSidebarProvider>
|
||||
</OutlineSidebarPagesProvider>
|
||||
</PageWrap>
|
||||
)}
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
} from '@src/testUtils';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import { OutlineSidebarProvider } from './outline-sidebar/OutlineSidebarContext';
|
||||
import { OutlineSidebarPagesProvider } from './outline-sidebar/OutlineSidebarPagesContext';
|
||||
import {
|
||||
getCourseBestPracticesApiUrl,
|
||||
getCourseLaunchApiUrl,
|
||||
@@ -46,7 +48,6 @@ import {
|
||||
} from './__mocks__';
|
||||
import { COURSE_BLOCK_NAMES, VIDEO_SHARING_OPTIONS } from './constants';
|
||||
import CourseOutline from './CourseOutline';
|
||||
import { OutlineSidebarPagesProvider } from './outline-sidebar/OutlineSidebarPagesContext';
|
||||
|
||||
import messages from './messages';
|
||||
import headerMessages from './header-navigations/messages';
|
||||
@@ -68,8 +69,18 @@ const mockPathname = '/foo-bar';
|
||||
const courseId = '123';
|
||||
const getContainerKey = jest.fn().mockReturnValue('lct:org:lib:unit:1');
|
||||
const getContainerType = jest.fn().mockReturnValue('unit');
|
||||
const clearSelection = jest.fn();
|
||||
let selectedContainerId: string | undefined;
|
||||
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
|
||||
...jest.requireActual('@src/course-outline/outline-sidebar/OutlineSidebarContext'),
|
||||
useOutlineSidebarContext: () => ({
|
||||
...jest.requireActual('@src/course-outline/outline-sidebar/OutlineSidebarContext').useOutlineSidebarContext(),
|
||||
clearSelection,
|
||||
selectedContainerState: (() => (selectedContainerId ? { currentId: selectedContainerId } : undefined))(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
@@ -141,7 +152,9 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const renderComponent = () => render(
|
||||
<CourseAuthoringProvider courseId={courseId}>
|
||||
<OutlineSidebarPagesProvider>
|
||||
<CourseOutline />
|
||||
<OutlineSidebarProvider>
|
||||
<CourseOutline />
|
||||
</OutlineSidebarProvider>
|
||||
</OutlineSidebarPagesProvider>
|
||||
</CourseAuthoringProvider>,
|
||||
);
|
||||
@@ -149,6 +162,7 @@ const renderComponent = () => render(
|
||||
describe('<CourseOutline />', () => {
|
||||
beforeEach(async () => {
|
||||
const mocks = initializeMocks();
|
||||
selectedContainerId = undefined;
|
||||
|
||||
jest.mocked(useLocation).mockReturnValue({
|
||||
pathname: mockPathname,
|
||||
@@ -434,7 +448,7 @@ describe('<CourseOutline />', () => {
|
||||
axiosMock
|
||||
.onPost(getXBlockBaseApiUrl())
|
||||
.reply(200, {
|
||||
locator: 'some',
|
||||
locator: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@vertical1e842129',
|
||||
});
|
||||
const newUnitButton = await within(subsectionElement).findByRole('button', { name: 'New unit' });
|
||||
await act(async () => fireEvent.click(newUnitButton));
|
||||
@@ -461,7 +475,7 @@ describe('<CourseOutline />', () => {
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl())
|
||||
.reply(200, {
|
||||
locator: 'some',
|
||||
locator: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@vertical1e842129',
|
||||
parent_locator: 'parent',
|
||||
});
|
||||
|
||||
@@ -499,8 +513,8 @@ describe('<CourseOutline />', () => {
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl())
|
||||
.reply(200, {
|
||||
locator: 'some',
|
||||
parent_locator: 'parent',
|
||||
locator: 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@sequential45d4d95a',
|
||||
parent_locator: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@chaptersda1',
|
||||
});
|
||||
|
||||
const addSubsectionFromLibraryButton = within(sectionElement).getByRole('button', {
|
||||
@@ -535,8 +549,8 @@ describe('<CourseOutline />', () => {
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl())
|
||||
.reply(200, {
|
||||
locator: 'some',
|
||||
parent_locator: 'parent',
|
||||
locator: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@chaptersdafdd',
|
||||
courseKey: 'course-v1:UNIX+UX1+2025_T3',
|
||||
});
|
||||
|
||||
const addSectionFromLibraryButton = await screen.findByRole('button', {
|
||||
@@ -692,7 +706,7 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
it('check edit title works for section, subsection and unit', async () => {
|
||||
const { findAllByTestId } = renderComponent();
|
||||
const checkEditTitle = async (section, element, item, newName, elementName) => {
|
||||
const checkEditTitle = async (element, item, newName, elementName) => {
|
||||
axiosMock.reset();
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(item.id))
|
||||
@@ -700,26 +714,10 @@ describe('<CourseOutline />', () => {
|
||||
// mock section, subsection and unit name and check within the elements.
|
||||
// this is done to avoid adding conditions to this mock.
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.onGet(getXBlockApiUrl(item.id))
|
||||
.reply(200, {
|
||||
...section,
|
||||
...item,
|
||||
display_name: newName,
|
||||
childInfo: {
|
||||
children: [
|
||||
{
|
||||
...section.childInfo.children[0],
|
||||
display_name: newName,
|
||||
childInfo: {
|
||||
children: [
|
||||
{
|
||||
...section.childInfo.children[0].childInfo.children[0],
|
||||
display_name: newName,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const editButton = await within(element).findByTestId(`${elementName}-edit-button`);
|
||||
@@ -741,17 +739,17 @@ describe('<CourseOutline />', () => {
|
||||
// check section
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
await checkEditTitle(section, sectionElement, section, 'New section name', 'section');
|
||||
await checkEditTitle(sectionElement, section, 'New section name', 'section');
|
||||
|
||||
// check subsection
|
||||
const [subsection] = section.childInfo.children;
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
await checkEditTitle(section, subsectionElement, subsection, 'New subsection name', 'subsection');
|
||||
await checkEditTitle(subsectionElement, subsection, 'New subsection name', 'subsection');
|
||||
|
||||
// check unit
|
||||
const [unit] = subsection.childInfo.children;
|
||||
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
await checkEditTitle(section, unitElement, unit, 'New unit name', 'unit');
|
||||
await checkEditTitle(unitElement, unit, 'New unit name', 'unit');
|
||||
});
|
||||
|
||||
it('check whether section, subsection and unit is deleted when corresponding delete button is clicked', async () => {
|
||||
@@ -763,6 +761,7 @@ describe('<CourseOutline />', () => {
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const [unit] = subsection.childInfo.children;
|
||||
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
selectedContainerId = section.id;
|
||||
|
||||
const checkDeleteBtn = async (item, element, elementName) => {
|
||||
await waitFor(() => {
|
||||
@@ -790,6 +789,7 @@ describe('<CourseOutline />', () => {
|
||||
await checkDeleteBtn(subsection, subsectionElement, 'subsection');
|
||||
// check section
|
||||
await checkDeleteBtn(section, sectionElement, 'section');
|
||||
expect(clearSelection).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('check whether section, subsection and unit is duplicated successfully', async () => {
|
||||
@@ -877,47 +877,12 @@ describe('<CourseOutline />', () => {
|
||||
publish: 'make_public',
|
||||
})
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
let mockReturnValue = {
|
||||
...section,
|
||||
childInfo: {
|
||||
children: [
|
||||
{
|
||||
...section.childInfo.children[0],
|
||||
published: true,
|
||||
visibilityState: 'live',
|
||||
},
|
||||
...section.childInfo.children.slice(1),
|
||||
],
|
||||
},
|
||||
};
|
||||
if (elementName === 'unit') {
|
||||
mockReturnValue = {
|
||||
...section,
|
||||
childInfo: {
|
||||
children: [
|
||||
{
|
||||
...section.childInfo.children[0],
|
||||
childInfo: {
|
||||
displayName: 'Unit Tests',
|
||||
children: [
|
||||
{
|
||||
...section.childInfo.children[0].childInfo.children[0],
|
||||
published: true,
|
||||
visibilityState: 'live',
|
||||
},
|
||||
...section.childInfo.children[0].childInfo.children.slice(1),
|
||||
],
|
||||
},
|
||||
},
|
||||
...section.childInfo.children.slice(1),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, mockReturnValue);
|
||||
.onGet(getXBlockApiUrl(item.id))
|
||||
.reply(200, {
|
||||
...item,
|
||||
visibilityState: 'live',
|
||||
});
|
||||
|
||||
const menu = await within(element).findByTestId(`${elementName}-card-header__menu-button`);
|
||||
fireEvent.click(menu);
|
||||
@@ -944,6 +909,17 @@ describe('<CourseOutline />', () => {
|
||||
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||
const newReleaseDateIso = '2025-09-10T22:00:00Z';
|
||||
const newReleaseDate = '09/10/2025';
|
||||
|
||||
const [firstSection] = await findAllByTestId('section-card');
|
||||
|
||||
const sectionDropdownButton = await within(firstSection).findByTestId('section-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(sectionDropdownButton));
|
||||
const configureBtn = await within(firstSection).findByTestId('section-card-header__menu-configure-button');
|
||||
await act(async () => fireEvent.click(configureBtn));
|
||||
let releaseDateStack = await findByTestId('release-date-stack');
|
||||
let releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY');
|
||||
expect(releaseDatePicker).toHaveValue('08/10/2023');
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(section.id), {
|
||||
publish: 'republish',
|
||||
@@ -961,16 +937,6 @@ describe('<CourseOutline />', () => {
|
||||
start: newReleaseDateIso,
|
||||
});
|
||||
|
||||
const [firstSection] = await findAllByTestId('section-card');
|
||||
|
||||
const sectionDropdownButton = await within(firstSection).findByTestId('section-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(sectionDropdownButton));
|
||||
const configureBtn = await within(firstSection).findByTestId('section-card-header__menu-configure-button');
|
||||
await act(async () => fireEvent.click(configureBtn));
|
||||
let releaseDateStack = await findByTestId('release-date-stack');
|
||||
let releaseDatePicker = await within(releaseDateStack).findByPlaceholderText('MM/DD/YYYY');
|
||||
expect(releaseDatePicker).toHaveValue('08/10/2023');
|
||||
|
||||
await act(async () => fireEvent.change(releaseDatePicker, { target: { value: newReleaseDate } }));
|
||||
expect(releaseDatePicker).toHaveValue(newReleaseDate);
|
||||
const saveButton = await findByTestId('configure-save-button');
|
||||
@@ -993,6 +959,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check configure modal for subsection', async () => {
|
||||
const user = userEvent.setup();
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByTestId,
|
||||
@@ -1034,14 +1001,14 @@ describe('<CourseOutline />', () => {
|
||||
subsection.isTimeLimited = expectedRequestData.metadata.is_time_limited;
|
||||
subsection.defaultTimeLimitMinutes = expectedRequestData.metadata.default_time_limit_minutes;
|
||||
subsection.hideAfterDue = expectedRequestData.metadata.hide_after_due;
|
||||
section.childInfo.children[0] = subsection;
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, section);
|
||||
.onGet(getXBlockApiUrl(subsection.id))
|
||||
.reply(200, subsection);
|
||||
section.childInfo.children[0] = subsection;
|
||||
|
||||
fireEvent.click(subsectionDropdownButton);
|
||||
await user.click(subsectionDropdownButton);
|
||||
const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button');
|
||||
fireEvent.click(configureBtn);
|
||||
await user.click(configureBtn);
|
||||
|
||||
// update fields
|
||||
let configureModal = await findByTestId('configure-modal');
|
||||
@@ -1061,27 +1028,27 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
// visibility tab
|
||||
const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage });
|
||||
fireEvent.click(visibilityTab);
|
||||
await user.click(visibilityTab);
|
||||
const visibilityRadioButtons = await within(configureModal).findAllByRole('radio');
|
||||
fireEvent.click(visibilityRadioButtons[1]);
|
||||
await user.click(visibilityRadioButtons[1]);
|
||||
|
||||
let advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
|
||||
fireEvent.click(advancedTab);
|
||||
await user.click(advancedTab);
|
||||
let radioButtons = await within(configureModal).findAllByRole('radio');
|
||||
fireEvent.click(radioButtons[1]);
|
||||
await user.click(radioButtons[1]);
|
||||
let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
|
||||
let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
|
||||
fireEvent.change(hours, { target: { value: '54:30' } });
|
||||
const saveButton = await within(configureModal).findByTestId('configure-save-button');
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
await user.click(saveButton);
|
||||
|
||||
// verify request
|
||||
expect(axiosMock.history.post.length).toBe(3);
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify(expectedRequestData));
|
||||
|
||||
// reopen modal and check values
|
||||
await act(async () => fireEvent.click(subsectionDropdownButton));
|
||||
await act(async () => fireEvent.click(configureBtn));
|
||||
await user.click(subsectionDropdownButton);
|
||||
await user.click(configureBtn);
|
||||
|
||||
configureModal = await findByTestId('configure-modal');
|
||||
releaseDateStack = await within(configureModal).findByTestId('release-date-stack');
|
||||
@@ -1098,7 +1065,7 @@ describe('<CourseOutline />', () => {
|
||||
expect(graderTypeDropdown).toHaveValue(expectedRequestData.graderType);
|
||||
|
||||
advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
|
||||
fireEvent.click(advancedTab);
|
||||
await user.click(advancedTab);
|
||||
radioButtons = await within(configureModal).findAllByRole('radio');
|
||||
expect(radioButtons[0]).toHaveProperty('checked', false);
|
||||
expect(radioButtons[1]).toHaveProperty('checked', true);
|
||||
@@ -1108,6 +1075,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check prereq and proctoring settings in configure modal for subsection', async () => {
|
||||
const user = userEvent.setup();
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByTestId,
|
||||
@@ -1155,13 +1123,10 @@ describe('<CourseOutline />', () => {
|
||||
subsection.prereqMinScore = expectedRequestData.prereqMinScore;
|
||||
subsection.prereqMinCompletion = expectedRequestData.prereqMinCompletion;
|
||||
section.childInfo.children[0] = subsection;
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, section);
|
||||
|
||||
fireEvent.click(subsectionDropdownButton);
|
||||
await user.click(subsectionDropdownButton);
|
||||
const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button');
|
||||
fireEvent.click(configureBtn);
|
||||
await user.click(configureBtn);
|
||||
|
||||
// update fields
|
||||
let configureModal = await findByTestId('configure-modal');
|
||||
@@ -1172,13 +1137,13 @@ describe('<CourseOutline />', () => {
|
||||
|
||||
// visibility tab
|
||||
const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage });
|
||||
fireEvent.click(visibilityTab);
|
||||
await user.click(visibilityTab);
|
||||
const visibilityRadioButtons = await within(configureModal).findAllByRole('radio');
|
||||
fireEvent.click(visibilityRadioButtons[2]);
|
||||
await user.click(visibilityRadioButtons[2]);
|
||||
|
||||
fireEvent.click(advancedTab);
|
||||
await user.click(advancedTab);
|
||||
let radioButtons = await within(configureModal).findAllByRole('radio');
|
||||
fireEvent.click(radioButtons[2]);
|
||||
await user.click(radioButtons[2]);
|
||||
let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
|
||||
let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
|
||||
fireEvent.change(hours, { target: { value: '00:30' } });
|
||||
@@ -1200,7 +1165,7 @@ describe('<CourseOutline />', () => {
|
||||
let prereqCheckbox = await within(configureModal).findByLabelText(
|
||||
configureModalMessages.prereqCheckboxLabel.defaultMessage,
|
||||
);
|
||||
fireEvent.click(prereqCheckbox);
|
||||
await user.click(prereqCheckbox);
|
||||
|
||||
// fill some rules for proctored exams
|
||||
let examsRulesInput = await within(configureModal).findByLabelText(
|
||||
@@ -1208,22 +1173,25 @@ describe('<CourseOutline />', () => {
|
||||
);
|
||||
fireEvent.change(examsRulesInput, { target: { value: expectedRequestData.metadata.exam_review_rules } });
|
||||
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(subsection.id))
|
||||
.reply(200, subsection);
|
||||
const saveButton = await within(configureModal).findByTestId('configure-save-button');
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
await user.click(saveButton);
|
||||
|
||||
// verify request
|
||||
expect(axiosMock.history.post.length).toBe(3);
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify(expectedRequestData));
|
||||
|
||||
// reopen modal and check values
|
||||
await act(async () => fireEvent.click(subsectionDropdownButton));
|
||||
await act(async () => fireEvent.click(configureBtn));
|
||||
await user.click(subsectionDropdownButton);
|
||||
await user.click(configureBtn);
|
||||
|
||||
configureModal = await findByTestId('configure-modal');
|
||||
advancedTab = await within(configureModal).findByRole('tab', {
|
||||
name: configureModalMessages.advancedTabTitle.defaultMessage,
|
||||
});
|
||||
fireEvent.click(advancedTab);
|
||||
await user.click(advancedTab);
|
||||
radioButtons = await within(configureModal).findAllByRole('radio');
|
||||
expect(radioButtons[0]).toHaveProperty('checked', false);
|
||||
expect(radioButtons[1]).toHaveProperty('checked', false);
|
||||
@@ -1253,6 +1221,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check practice proctoring settings in configure modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByTestId,
|
||||
@@ -1295,13 +1264,10 @@ describe('<CourseOutline />', () => {
|
||||
subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam;
|
||||
subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules;
|
||||
section.childInfo.children[0] = subsection;
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, section);
|
||||
|
||||
fireEvent.click(subsectionDropdownButton);
|
||||
await user.click(subsectionDropdownButton);
|
||||
const configureBtn = await within(firstSubsection).findByTestId('subsection-card-header__menu-configure-button');
|
||||
fireEvent.click(configureBtn);
|
||||
await user.click(configureBtn);
|
||||
|
||||
// update fields
|
||||
let configureModal = await findByTestId('configure-modal');
|
||||
@@ -1311,14 +1277,14 @@ describe('<CourseOutline />', () => {
|
||||
);
|
||||
// visibility tab
|
||||
const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage });
|
||||
fireEvent.click(visibilityTab);
|
||||
await user.click(visibilityTab);
|
||||
const visibilityRadioButtons = await within(configureModal).findAllByRole('radio');
|
||||
fireEvent.click(visibilityRadioButtons[4]);
|
||||
await user.click(visibilityRadioButtons[4]);
|
||||
|
||||
// advancedTab
|
||||
fireEvent.click(advancedTab);
|
||||
await user.click(advancedTab);
|
||||
let radioButtons = await within(configureModal).findAllByRole('radio');
|
||||
fireEvent.click(radioButtons[3]);
|
||||
await user.click(radioButtons[3]);
|
||||
let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
|
||||
let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
|
||||
fireEvent.change(hours, { target: { value: '00:30' } });
|
||||
@@ -1328,20 +1294,23 @@ describe('<CourseOutline />', () => {
|
||||
configureModalMessages.reviewRulesLabel.defaultMessage,
|
||||
)).not.toBeInTheDocument();
|
||||
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(subsection.id))
|
||||
.reply(200, subsection);
|
||||
const saveButton = await within(configureModal).findByTestId('configure-save-button');
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
await user.click(saveButton);
|
||||
|
||||
// verify request
|
||||
expect(axiosMock.history.post.length).toBe(3);
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify(expectedRequestData));
|
||||
|
||||
// reopen modal and check values
|
||||
await act(async () => fireEvent.click(subsectionDropdownButton));
|
||||
await act(async () => fireEvent.click(configureBtn));
|
||||
await user.click(subsectionDropdownButton);
|
||||
await user.click(configureBtn);
|
||||
|
||||
configureModal = await findByTestId('configure-modal');
|
||||
advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
|
||||
fireEvent.click(advancedTab);
|
||||
await user.click(advancedTab);
|
||||
radioButtons = await within(configureModal).findAllByRole('radio');
|
||||
expect(radioButtons[0]).toHaveProperty('checked', false);
|
||||
expect(radioButtons[1]).toHaveProperty('checked', false);
|
||||
@@ -1353,6 +1322,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check onboarding proctoring settings in configure modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByTestId,
|
||||
@@ -1395,30 +1365,27 @@ describe('<CourseOutline />', () => {
|
||||
subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam;
|
||||
subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules;
|
||||
section.childInfo.children[1] = subsection;
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, section);
|
||||
|
||||
fireEvent.click(subsectionDropdownButton);
|
||||
await user.click(subsectionDropdownButton);
|
||||
const configureBtn = await within(secondSubsection).findByTestId('subsection-card-header__menu-configure-button');
|
||||
fireEvent.click(configureBtn);
|
||||
await user.click(configureBtn);
|
||||
|
||||
// update fields
|
||||
let configureModal = await findByTestId('configure-modal');
|
||||
// visibility tab
|
||||
const visibilityTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.visibilityTabTitle.defaultMessage });
|
||||
fireEvent.click(visibilityTab);
|
||||
await user.click(visibilityTab);
|
||||
const visibilityRadioButtons = await within(configureModal).findAllByRole('radio');
|
||||
fireEvent.click(visibilityRadioButtons[5]);
|
||||
await user.click(visibilityRadioButtons[5]);
|
||||
|
||||
// advancedTab
|
||||
let advancedTab = await within(configureModal).findByRole(
|
||||
'tab',
|
||||
{ name: configureModalMessages.advancedTabTitle.defaultMessage },
|
||||
);
|
||||
fireEvent.click(advancedTab);
|
||||
await user.click(advancedTab);
|
||||
let radioButtons = await within(configureModal).findAllByRole('radio');
|
||||
fireEvent.click(radioButtons[3]);
|
||||
await user.click(radioButtons[3]);
|
||||
let hoursWrapper = await within(configureModal).findByTestId('advanced-tab-hours-picker-wrapper');
|
||||
let hours = await within(hoursWrapper).findByPlaceholderText('HH:MM');
|
||||
fireEvent.change(hours, { target: { value: '00:30' } });
|
||||
@@ -1428,20 +1395,23 @@ describe('<CourseOutline />', () => {
|
||||
configureModalMessages.reviewRulesLabel.defaultMessage,
|
||||
)).not.toBeInTheDocument();
|
||||
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(subsection.id))
|
||||
.reply(200, subsection);
|
||||
const saveButton = await within(configureModal).findByTestId('configure-save-button');
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
await user.click(saveButton);
|
||||
|
||||
// verify request
|
||||
expect(axiosMock.history.post.length).toBe(3);
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify(expectedRequestData));
|
||||
|
||||
// reopen modal and check values
|
||||
await act(async () => fireEvent.click(subsectionDropdownButton));
|
||||
await act(async () => fireEvent.click(configureBtn));
|
||||
await user.click(subsectionDropdownButton);
|
||||
await user.click(configureBtn);
|
||||
|
||||
configureModal = await findByTestId('configure-modal');
|
||||
advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
|
||||
fireEvent.click(advancedTab);
|
||||
await user.click(advancedTab);
|
||||
radioButtons = await within(configureModal).findAllByRole('radio');
|
||||
expect(radioButtons[0]).toHaveProperty('checked', false);
|
||||
expect(radioButtons[1]).toHaveProperty('checked', false);
|
||||
@@ -1453,6 +1423,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check no special exam setting in configure modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
const {
|
||||
findAllByTestId,
|
||||
findByTestId,
|
||||
@@ -1494,13 +1465,10 @@ describe('<CourseOutline />', () => {
|
||||
subsection.isOnboardingExam = expectedRequestData.metadata.is_onboarding_exam;
|
||||
subsection.examReviewRules = expectedRequestData.metadata.exam_review_rules;
|
||||
section.childInfo.children[0] = subsection;
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, section);
|
||||
|
||||
fireEvent.click(subsectionDropdownButton);
|
||||
await user.click(subsectionDropdownButton);
|
||||
const configureBtn = await within(subsectionElement).findByTestId('subsection-card-header__menu-configure-button');
|
||||
fireEvent.click(configureBtn);
|
||||
await user.click(configureBtn);
|
||||
|
||||
// update fields
|
||||
let configureModal = await findByTestId('configure-modal');
|
||||
@@ -1510,9 +1478,9 @@ describe('<CourseOutline />', () => {
|
||||
'tab',
|
||||
{ name: configureModalMessages.advancedTabTitle.defaultMessage },
|
||||
);
|
||||
fireEvent.click(advancedTab);
|
||||
await user.click(advancedTab);
|
||||
let radioButtons = await within(configureModal).findAllByRole('radio');
|
||||
fireEvent.click(radioButtons[0]);
|
||||
await user.click(radioButtons[0]);
|
||||
|
||||
// time box should not be visible
|
||||
expect(within(configureModal).queryByLabelText(
|
||||
@@ -1524,20 +1492,23 @@ describe('<CourseOutline />', () => {
|
||||
configureModalMessages.reviewRulesLabel.defaultMessage,
|
||||
)).not.toBeInTheDocument();
|
||||
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(subsection.id))
|
||||
.reply(200, subsection);
|
||||
const saveButton = await within(configureModal).findByTestId('configure-save-button');
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
await user.click(saveButton);
|
||||
|
||||
// verify request
|
||||
expect(axiosMock.history.post.length).toBe(3);
|
||||
expect(axiosMock.history.post[2].data).toBe(JSON.stringify(expectedRequestData));
|
||||
|
||||
// reopen modal and check values
|
||||
await act(async () => fireEvent.click(subsectionDropdownButton));
|
||||
await act(async () => fireEvent.click(configureBtn));
|
||||
await user.click(subsectionDropdownButton);
|
||||
await user.click(configureBtn);
|
||||
|
||||
configureModal = await findByTestId('configure-modal');
|
||||
advancedTab = await within(configureModal).findByRole('tab', { name: configureModalMessages.advancedTabTitle.defaultMessage });
|
||||
fireEvent.click(advancedTab);
|
||||
await user.click(advancedTab);
|
||||
radioButtons = await within(configureModal).findAllByRole('radio');
|
||||
expect(radioButtons[0]).toHaveProperty('checked', true);
|
||||
expect(radioButtons[1]).toHaveProperty('checked', false);
|
||||
@@ -1546,6 +1517,7 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
|
||||
it('check configure modal for unit', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { findAllByTestId, findByTestId } = renderComponent();
|
||||
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||
const [subsection] = section.childInfo.children;
|
||||
@@ -1605,37 +1577,37 @@ describe('<CourseOutline />', () => {
|
||||
subsection.childInfo.children[0] = unit;
|
||||
section.childInfo.children[0] = subsection;
|
||||
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, section);
|
||||
|
||||
fireEvent.click(unitDropdownButton);
|
||||
await user.click(unitDropdownButton);
|
||||
const configureBtn = await within(firstUnit).findByTestId('unit-card-header__menu-configure-button');
|
||||
fireEvent.click(configureBtn);
|
||||
await user.click(configureBtn);
|
||||
|
||||
let configureModal = await findByTestId('configure-modal');
|
||||
expect(await within(configureModal).findByText(
|
||||
configureModalMessages.unitVisibility.defaultMessage,
|
||||
)).toBeInTheDocument();
|
||||
let visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox');
|
||||
await act(async () => fireEvent.click(visibilityCheckbox));
|
||||
await user.click(visibilityCheckbox);
|
||||
let discussionCheckbox = await within(configureModal).findByLabelText(
|
||||
configureModalMessages.discussionEnabledCheckbox.defaultMessage,
|
||||
);
|
||||
expect(discussionCheckbox).toBeChecked();
|
||||
await act(async () => fireEvent.click(discussionCheckbox));
|
||||
await user.click(discussionCheckbox);
|
||||
|
||||
let groupeType = await within(configureModal).findByTestId('group-type-select');
|
||||
fireEvent.change(groupeType, { target: { value: '0' } });
|
||||
|
||||
let checkboxes = await within(await within(configureModal).findByTestId('group-checkboxes')).findAllByRole('checkbox');
|
||||
fireEvent.click(checkboxes[1]);
|
||||
await user.click(checkboxes[1]);
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(unit.id))
|
||||
.reply(200, unit);
|
||||
|
||||
const saveButton = await within(configureModal).findByTestId('configure-save-button');
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
await user.click(saveButton);
|
||||
|
||||
// reopen modal and check values
|
||||
await act(async () => fireEvent.click(unitDropdownButton));
|
||||
await act(async () => fireEvent.click(configureBtn));
|
||||
await user.click(unitDropdownButton);
|
||||
await user.click(configureBtn);
|
||||
|
||||
configureModal = await findByTestId('configure-modal');
|
||||
visibilityCheckbox = await within(configureModal).findByTestId('unit-visibility-checkbox');
|
||||
|
||||
@@ -36,8 +36,8 @@ import { XBlock } from '@src/data/types';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import LegacyLibContentBlockAlert from '@src/course-libraries/LegacyLibContentBlockAlert';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import {
|
||||
getCurrentItem,
|
||||
getProctoredExamsFlag,
|
||||
getTimedExamsFlag,
|
||||
} from './data/selectors';
|
||||
@@ -61,7 +61,6 @@ import messages from './messages';
|
||||
import headerMessages from './header-navigations/messages';
|
||||
import { getTagsExportFile } from './data/api';
|
||||
import OutlineAddChildButtons from './OutlineAddChildButtons';
|
||||
import { OutlineSidebarProvider } from './outline-sidebar/OutlineSidebarContext';
|
||||
import { StatusBar } from './status-bar/StatusBar';
|
||||
import { LegacyStatusBar } from './status-bar/LegacyStatusBar';
|
||||
import { isOutlineNewDesignEnabled } from './utils';
|
||||
@@ -74,7 +73,11 @@ const CourseOutline = () => {
|
||||
courseUsageKey,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
handleAddAndOpenUnit,
|
||||
handleAddSection,
|
||||
isUnlinkModalOpen,
|
||||
closeUnlinkModal,
|
||||
currentSelection,
|
||||
} = useCourseAuthoringContext();
|
||||
|
||||
const {
|
||||
@@ -93,19 +96,13 @@ const CourseOutline = () => {
|
||||
isInternetConnectionAlertFailed,
|
||||
isDisabledReindexButton,
|
||||
isHighlightsModalOpen,
|
||||
isPublishModalOpen,
|
||||
isConfigureModalOpen,
|
||||
isDeleteModalOpen,
|
||||
isUnlinkModalOpen,
|
||||
closeHighlightsModal,
|
||||
closePublishModal,
|
||||
handleConfigureModalClose,
|
||||
closeDeleteModal,
|
||||
closeUnlinkModal,
|
||||
openPublishModal,
|
||||
openConfigureModal,
|
||||
openDeleteModal,
|
||||
openUnlinkModal,
|
||||
headerNavigationsActions,
|
||||
openEnableHighlightsModal,
|
||||
closeEnableHighlightsModal,
|
||||
@@ -114,10 +111,7 @@ const CourseOutline = () => {
|
||||
handleOpenHighlightsModal,
|
||||
handleHighlightsFormSubmit,
|
||||
handleConfigureItemSubmit,
|
||||
handlePublishItemSubmit,
|
||||
handleEditSubmit,
|
||||
handleDeleteItemSubmit,
|
||||
handleUnlinkItemSubmit,
|
||||
handleDuplicateSectionSubmit,
|
||||
handleDuplicateSubsectionSubmit,
|
||||
handleDuplicateUnitSubmit,
|
||||
@@ -136,6 +130,7 @@ const CourseOutline = () => {
|
||||
handleUnitDragAndDrop,
|
||||
errors,
|
||||
resetScrollState,
|
||||
handleUnlinkItemSubmit,
|
||||
} = useCourseOutline({ courseId });
|
||||
|
||||
// Show the new actions bar if it is enabled in the configuration.
|
||||
@@ -170,9 +165,9 @@ const CourseOutline = () => {
|
||||
title: processingNotificationTitle,
|
||||
} = useSelector(getProcessingNotification);
|
||||
|
||||
const currentItemData = useSelector(getCurrentItem);
|
||||
const { data: currentItemData } = useCourseItemData(currentSelection?.currentId);
|
||||
|
||||
const itemCategory = currentItemData?.category;
|
||||
const itemCategory = currentItemData?.category || '';
|
||||
const itemCategoryName = COURSE_BLOCK_NAMES[itemCategory]?.name.toLowerCase();
|
||||
|
||||
const enableProctoredExams = useSelector(getProctoredExamsFlag);
|
||||
@@ -269,7 +264,7 @@ const CourseOutline = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<OutlineSidebarProvider>
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle))}</title>
|
||||
</Helmet>
|
||||
@@ -338,7 +333,7 @@ const CourseOutline = () => {
|
||||
/>
|
||||
)}
|
||||
<hr className="mt-4 mb-0 w-100 text-light-400" />
|
||||
<div className="d-flex align-items-baseline flex-wrap">
|
||||
<div className="d-flex align-items-baseline">
|
||||
<div className="flex-fill">
|
||||
<article>
|
||||
<div>
|
||||
@@ -385,13 +380,9 @@ const CourseOutline = () => {
|
||||
canMoveItem={canMoveSection(sections)}
|
||||
isSelfPaced={statusBarData.isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
savingStatus={savingStatus}
|
||||
onOpenHighlightsModal={handleOpenHighlightsModal}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onOpenUnlinkModal={openUnlinkModal}
|
||||
onEditSectionSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateSectionSubmit}
|
||||
isSectionsExpanded={isSectionsExpanded}
|
||||
onOrderChange={updateSectionOrderByIndex}
|
||||
@@ -417,11 +408,7 @@ const CourseOutline = () => {
|
||||
isSectionsExpanded={isSectionsExpanded}
|
||||
isSelfPaced={statusBarData.isSelfPaced}
|
||||
isCustomRelativeDatesActive={isCustomRelativeDatesActive}
|
||||
savingStatus={savingStatus}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onOpenUnlinkModal={openUnlinkModal}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onOrderChange={updateSubsectionOrderByIndex}
|
||||
@@ -450,12 +437,8 @@ const CourseOutline = () => {
|
||||
subsection,
|
||||
subsection.childInfo.children,
|
||||
)}
|
||||
savingStatus={savingStatus}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenConfigureModal={openConfigureModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onOpenUnlinkModal={openUnlinkModal}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateUnitSubmit}
|
||||
onOrderChange={updateUnitOrderByIndex}
|
||||
discussionsSettings={discussionsSettings}
|
||||
@@ -473,7 +456,6 @@ const CourseOutline = () => {
|
||||
<OutlineAddChildButtons
|
||||
childType={ContainerType.Section}
|
||||
parentLocator={courseUsageKey}
|
||||
parentTitle={courseName}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -483,7 +465,6 @@ const CourseOutline = () => {
|
||||
<OutlineAddChildButtons
|
||||
childType={ContainerType.Section}
|
||||
parentLocator={courseUsageKey}
|
||||
parentTitle={courseName}
|
||||
btnVariant="primary"
|
||||
btnClasses="mt-1"
|
||||
/>
|
||||
@@ -513,11 +494,7 @@ const CourseOutline = () => {
|
||||
onClose={closeHighlightsModal}
|
||||
onSubmit={handleHighlightsFormSubmit}
|
||||
/>
|
||||
<PublishModal
|
||||
isOpen={isPublishModalOpen}
|
||||
onClose={closePublishModal}
|
||||
onPublishSubmit={handlePublishItemSubmit}
|
||||
/>
|
||||
<PublishModal />
|
||||
<ConfigureModal
|
||||
isOpen={isConfigureModalOpen}
|
||||
onClose={handleConfigureModalClose}
|
||||
@@ -547,6 +524,7 @@ const CourseOutline = () => {
|
||||
isShow={
|
||||
isShowProcessingNotification
|
||||
|| handleAddUnit.isPending
|
||||
|| handleAddAndOpenUnit.isPending
|
||||
|| handleAddSubsection.isPending
|
||||
|| handleAddSection.isPending
|
||||
}
|
||||
@@ -568,7 +546,7 @@ const CourseOutline = () => {
|
||||
{toastMessage}
|
||||
</Toast>
|
||||
)}
|
||||
</OutlineSidebarProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,18 +4,22 @@ import { ContainerType } from '@src/generic/key-utils';
|
||||
import {
|
||||
initializeMocks, render, screen, waitFor,
|
||||
} from '@src/testUtils';
|
||||
import { OutlineFlow, OutlineFlowType, OutlineSidebarProvider } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
|
||||
import { OutlineFlow, OutlineSidebarProvider } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
|
||||
import OutlineAddChildButtons from './OutlineAddChildButtons';
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn().mockReturnValue({ librariesV2Enabled: true }),
|
||||
jest.mock('@src/studio-home/data/selectors', () => ({
|
||||
...jest.requireActual('@src/studio-home/data/selectors'),
|
||||
getStudioHomeData: () => ({
|
||||
librariesV2Enabled: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
const handleAddSection = { mutateAsync: jest.fn() };
|
||||
const handleAddSubsection = { mutateAsync: jest.fn() };
|
||||
const handleAddAndOpenUnit = { mutateAsync: jest.fn() };
|
||||
const handleAddUnit = { mutateAsync: jest.fn() };
|
||||
const courseUsageKey = 'some/usage/key';
|
||||
const setCurrentSelection = jest.fn();
|
||||
jest.mock('@src/CourseAuthoringContext', () => ({
|
||||
useCourseAuthoringContext: () => ({
|
||||
courseId: 5,
|
||||
@@ -23,7 +27,9 @@ jest.mock('@src/CourseAuthoringContext', () => ({
|
||||
getUnitUrl: (id: string) => `/some/${id}`,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddAndOpenUnit,
|
||||
handleAddUnit,
|
||||
setCurrentSelection,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -35,6 +41,7 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
|
||||
...jest.requireActual('@src/course-outline/outline-sidebar/OutlineSidebarContext').useOutlineSidebarContext(),
|
||||
startCurrentFlow,
|
||||
currentFlow,
|
||||
isCurrentFlowOn: !!currentFlow,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -60,7 +67,6 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
|
||||
handleUseFromLibraryClick={useFromLibClickHandler}
|
||||
childType={containerType}
|
||||
parentLocator=""
|
||||
parentTitle=""
|
||||
/>, { extraWrapper: OutlineSidebarProvider });
|
||||
|
||||
const newBtn = await screen.findByRole('button', { name: `New ${containerType}` });
|
||||
@@ -75,11 +81,9 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
|
||||
|
||||
it('calls appropriate new handlers', async () => {
|
||||
const parentLocator = `parent-of-${containerType}`;
|
||||
const parentTitle = `parent-title-of-${containerType}`;
|
||||
render(<OutlineAddChildButtons
|
||||
childType={containerType}
|
||||
parentLocator={parentLocator}
|
||||
parentTitle={parentTitle}
|
||||
/>, { extraWrapper: OutlineSidebarProvider });
|
||||
|
||||
const newBtn = await screen.findByRole('button', { name: `New ${containerType}` });
|
||||
@@ -101,7 +105,7 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
|
||||
}));
|
||||
break;
|
||||
case ContainerType.Unit:
|
||||
await waitFor(() => expect(handleAddUnit.mutateAsync).toHaveBeenCalledWith({
|
||||
await waitFor(() => expect(handleAddAndOpenUnit.mutateAsync).toHaveBeenCalledWith({
|
||||
type: ContainerType.Vertical,
|
||||
parentLocator,
|
||||
displayName: 'Unit',
|
||||
@@ -114,34 +118,28 @@ jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
|
||||
|
||||
it('calls appropriate use handlers', async () => {
|
||||
const parentLocator = `parent-of-${containerType}`;
|
||||
const parentTitle = `parent-title-of-${containerType}`;
|
||||
render(<OutlineAddChildButtons
|
||||
childType={containerType}
|
||||
parentLocator={parentLocator}
|
||||
parentTitle={parentTitle}
|
||||
/>, { extraWrapper: OutlineSidebarProvider });
|
||||
const useBtn = await screen.findByRole('button', { name: `Use ${containerType} from library` });
|
||||
expect(useBtn).toBeInTheDocument();
|
||||
await userEvent.click(useBtn);
|
||||
await waitFor(() => expect(startCurrentFlow).toHaveBeenCalledWith({
|
||||
flowType: `use-${containerType}`,
|
||||
flowType: containerType,
|
||||
parentLocator,
|
||||
parentTitle,
|
||||
}));
|
||||
});
|
||||
|
||||
it('shows appropriate static placeholder', async () => {
|
||||
const parentLocator = `parent-of-${containerType}`;
|
||||
const parentTitle = `parent-title-of-${containerType}`;
|
||||
currentFlow = {
|
||||
flowType: `use-${containerType}` as OutlineFlowType,
|
||||
flowType: containerType,
|
||||
parentLocator,
|
||||
parentTitle,
|
||||
};
|
||||
render(<OutlineAddChildButtons
|
||||
childType={containerType}
|
||||
parentLocator={parentLocator}
|
||||
parentTitle={parentTitle}
|
||||
/>, { extraWrapper: OutlineSidebarProvider });
|
||||
// should show placeholder when use button is clicked
|
||||
expect(await screen.findByRole('heading', {
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 { type OutlineFlowType, useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
|
||||
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { LoadingSpinner } from '@src/generic/Loading';
|
||||
import { useCallback } from 'react';
|
||||
@@ -26,24 +26,25 @@ import messages from './messages';
|
||||
*/
|
||||
const AddPlaceholder = ({ parentLocator }: { parentLocator?: string }) => {
|
||||
const intl = useIntl();
|
||||
const { currentFlow, stopCurrentFlow } = useOutlineSidebarContext();
|
||||
const { isCurrentFlowOn, currentFlow, stopCurrentFlow } = useOutlineSidebarContext();
|
||||
const {
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
handleAddAndOpenUnit,
|
||||
} = useCourseAuthoringContext();
|
||||
|
||||
if (!currentFlow || currentFlow.parentLocator !== parentLocator) {
|
||||
if (!isCurrentFlowOn || currentFlow?.parentLocator !== parentLocator) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getTitle = () => {
|
||||
switch (currentFlow?.flowType) {
|
||||
case 'use-section':
|
||||
case ContainerType.Section:
|
||||
return intl.formatMessage(messages.placeholderSectionText);
|
||||
case 'use-subsection':
|
||||
case ContainerType.Subsection:
|
||||
return intl.formatMessage(messages.placeholderSubsectionText);
|
||||
case 'use-unit':
|
||||
case ContainerType.Unit:
|
||||
return intl.formatMessage(messages.placeholderUnitText);
|
||||
default:
|
||||
// istanbul ignore next: this should never happen
|
||||
@@ -59,6 +60,7 @@ const AddPlaceholder = ({ parentLocator }: { parentLocator?: string }) => {
|
||||
<Stack direction="horizontal" gap={3}>
|
||||
{(handleAddSection.isPending
|
||||
|| handleAddSubsection.isPending
|
||||
|| handleAddAndOpenUnit.isPending
|
||||
|| handleAddUnit.isPending) && (
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
@@ -88,7 +90,7 @@ interface BaseProps {
|
||||
|
||||
interface NewChildButtonsProps extends BaseProps {
|
||||
handleUseFromLibraryClick?: () => void;
|
||||
parentTitle: string;
|
||||
grandParentLocator?: string;
|
||||
}
|
||||
|
||||
const NewOutlineAddChildButtons = ({
|
||||
@@ -100,7 +102,7 @@ const NewOutlineAddChildButtons = ({
|
||||
btnClasses = 'mt-4 border-gray-500 rounded-0',
|
||||
btnSize,
|
||||
parentLocator,
|
||||
parentTitle,
|
||||
grandParentLocator,
|
||||
}: NewChildButtonsProps) => {
|
||||
// WARNING: Do not use "useStudioHome" to get "librariesV2Enabled" flag below,
|
||||
// as it has a useEffect that fetches course waffle flags whenever
|
||||
@@ -113,7 +115,7 @@ const NewOutlineAddChildButtons = ({
|
||||
courseUsageKey,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
handleAddAndOpenUnit,
|
||||
} = useCourseAuthoringContext();
|
||||
const { startCurrentFlow } = useOutlineSidebarContext();
|
||||
let messageMap = {
|
||||
@@ -121,7 +123,7 @@ const NewOutlineAddChildButtons = ({
|
||||
importButton: messages.useUnitFromLibraryButton,
|
||||
};
|
||||
let onNewCreateContent: () => Promise<void>;
|
||||
let flowType: OutlineFlowType;
|
||||
let flowType: ContainerType;
|
||||
|
||||
// Based on the childType, determine the correct action and messages to display.
|
||||
switch (childType) {
|
||||
@@ -135,7 +137,7 @@ const NewOutlineAddChildButtons = ({
|
||||
parentLocator: courseUsageKey,
|
||||
displayName: COURSE_BLOCK_NAMES.chapter.name,
|
||||
});
|
||||
flowType = 'use-section';
|
||||
flowType = ContainerType.Section;
|
||||
break;
|
||||
case ContainerType.Subsection:
|
||||
messageMap = {
|
||||
@@ -147,19 +149,19 @@ const NewOutlineAddChildButtons = ({
|
||||
parentLocator,
|
||||
displayName: COURSE_BLOCK_NAMES.sequential.name,
|
||||
});
|
||||
flowType = 'use-subsection';
|
||||
flowType = ContainerType.Subsection;
|
||||
break;
|
||||
case ContainerType.Unit:
|
||||
messageMap = {
|
||||
newButton: messages.newUnitButton,
|
||||
importButton: messages.useUnitFromLibraryButton,
|
||||
};
|
||||
onNewCreateContent = () => handleAddUnit.mutateAsync({
|
||||
onNewCreateContent = () => handleAddAndOpenUnit.mutateAsync({
|
||||
type: ContainerType.Vertical,
|
||||
parentLocator,
|
||||
displayName: COURSE_BLOCK_NAMES.vertical.name,
|
||||
});
|
||||
flowType = 'use-unit';
|
||||
flowType = ContainerType.Unit;
|
||||
break;
|
||||
default:
|
||||
// istanbul ignore next: unreachable
|
||||
@@ -173,12 +175,12 @@ const NewOutlineAddChildButtons = ({
|
||||
startCurrentFlow({
|
||||
flowType,
|
||||
parentLocator,
|
||||
parentTitle,
|
||||
grandParentLocator,
|
||||
});
|
||||
}, [
|
||||
childType,
|
||||
parentLocator,
|
||||
parentTitle,
|
||||
grandParentLocator,
|
||||
startCurrentFlow,
|
||||
]);
|
||||
|
||||
@@ -237,7 +239,7 @@ const LegacyOutlineAddChildButtons = ({
|
||||
courseUsageKey,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
handleAddAndOpenUnit,
|
||||
} = useCourseAuthoringContext();
|
||||
const [
|
||||
isAddLibrarySectionModalOpen,
|
||||
@@ -301,12 +303,12 @@ const LegacyOutlineAddChildButtons = ({
|
||||
importButton: messages.useUnitFromLibraryButton,
|
||||
modalTitle: messages.unitPickerModalTitle,
|
||||
};
|
||||
onNewCreateContent = () => handleAddUnit.mutateAsync({
|
||||
onNewCreateContent = () => handleAddAndOpenUnit.mutateAsync({
|
||||
type: ContainerType.Vertical,
|
||||
parentLocator,
|
||||
displayName: COURSE_BLOCK_NAMES.vertical.name,
|
||||
});
|
||||
onUseLibraryContent = (selected: SelectedComponent) => handleAddUnit.mutateAsync({
|
||||
onUseLibraryContent = (selected: SelectedComponent) => handleAddAndOpenUnit.mutateAsync({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: ContainerType.Vertical,
|
||||
parentLocator,
|
||||
|
||||
@@ -4,16 +4,17 @@ import { ITEM_BADGE_STATUS } from '@src/course-outline/constants';
|
||||
import {
|
||||
act, fireEvent, initializeMocks, render, screen, waitFor,
|
||||
} from '@src/testUtils';
|
||||
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
|
||||
import { courseId } from '@src/schedule-and-details/__mocks__/courseDetails';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import CardHeader from './CardHeader';
|
||||
import TitleButton from './TitleButton';
|
||||
import messages from './messages';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { OutlineSidebarProvider } from '../outline-sidebar/OutlineSidebarContext';
|
||||
|
||||
const onExpandMock = jest.fn();
|
||||
const onClickMenuButtonMock = jest.fn();
|
||||
const onClickPublishMock = jest.fn();
|
||||
const onClickEditMock = jest.fn();
|
||||
const onClickDeleteMock = jest.fn();
|
||||
const onClickUnlinkMock = jest.fn();
|
||||
const onClickDuplicateMock = jest.fn();
|
||||
@@ -29,6 +30,12 @@ jest.mock('../../generic/data/api', () => ({
|
||||
getTagsCount: () => mockGetTagsCount(),
|
||||
}));
|
||||
|
||||
const useUpdateCourseBlockNameMock = { mutateAsync: jest.fn(), isPending: false };
|
||||
jest.mock('@src/course-outline/data/apiHooks', () => ({
|
||||
...jest.requireActual('@src/course-outline/data/apiHooks'),
|
||||
useUpdateCourseBlockName: () => useUpdateCourseBlockNameMock,
|
||||
}));
|
||||
|
||||
const cardHeaderProps = {
|
||||
title: 'Some title',
|
||||
status: ITEM_BADGE_STATUS.live,
|
||||
@@ -36,8 +43,6 @@ const cardHeaderProps = {
|
||||
hasChanges: false,
|
||||
onClickMenuButton: onClickMenuButtonMock,
|
||||
onClickPublish: onClickPublishMock,
|
||||
onClickEdit: onClickEditMock,
|
||||
isFormOpen: false,
|
||||
onEditSubmit: jest.fn(),
|
||||
closeForm: closeFormMock,
|
||||
isDisabledEditField: false,
|
||||
@@ -80,7 +85,13 @@ const renderComponent = (props?: object, entry = '/') => {
|
||||
routerProps: {
|
||||
initialEntries: [entry],
|
||||
},
|
||||
extraWrapper: OutlineSidebarProvider,
|
||||
extraWrapper: ({ children }) => (
|
||||
<CourseAuthoringProvider courseId={courseId}>
|
||||
<OutlineSidebarProvider>
|
||||
{children}
|
||||
</OutlineSidebarProvider>
|
||||
</CourseAuthoringProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -214,20 +225,21 @@ describe('<CardHeader />', () => {
|
||||
expect(screen.getAllByText('Manage tags').length).toBe(2);
|
||||
});
|
||||
|
||||
it('calls onClickEdit when the button is clicked', async () => {
|
||||
it('calls onClickMenu when the edit button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
const editButton = await screen.findByTestId('subsection-edit-button');
|
||||
await act(async () => fireEvent.click(editButton));
|
||||
expect(onClickEditMock).toHaveBeenCalled();
|
||||
await user.click(editButton);
|
||||
expect(onClickMenuButtonMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('check is field visible when isFormOpen is true', async () => {
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
isFormOpen: true,
|
||||
});
|
||||
it('check is field visible when edit is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
const editButton = await screen.findByTestId('subsection-edit-button');
|
||||
await user.click(editButton);
|
||||
expect(await screen.findByTestId('subsection-edit-field')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('subsection-card-header__expanded-btn')).not.toBeInTheDocument();
|
||||
@@ -248,15 +260,21 @@ describe('<CardHeader />', () => {
|
||||
});
|
||||
|
||||
it('check editing is disabled when saving is in progress', async () => {
|
||||
renderComponent({ ...cardHeaderProps, savingStatus: RequestStatus.IN_PROGRESS });
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
|
||||
});
|
||||
useUpdateCourseBlockNameMock.isPending = true;
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
expect(await screen.findByTestId('subsection-edit-button')).toBeDisabled();
|
||||
expect(await screen.findByLabelText('Rename')).toBeDisabled();
|
||||
|
||||
// Ensure menu items related to editing are disabled
|
||||
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menuButton));
|
||||
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');
|
||||
await user.click(menuButton);
|
||||
expect(await screen.findByText('Configure')).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(await screen.findByText('Manage tags')).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('calls onClickDelete when item is clicked', async () => {
|
||||
|
||||
@@ -21,11 +21,12 @@ import {
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
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 { RequestStatus, RequestStatusType } from '@src/data/constants';
|
||||
import { ContentTagsDrawerSheet } from '@src/content-tags-drawer';
|
||||
import { useUpdateCourseBlockName } from '@src/course-outline/data/apiHooks';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { ITEM_BADGE_STATUS } from '../constants';
|
||||
import { scrollToElement } from '../utils';
|
||||
import CardStatus from './CardStatus';
|
||||
@@ -35,15 +36,11 @@ import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarConte
|
||||
interface CardHeaderProps {
|
||||
title: string;
|
||||
status: string;
|
||||
cardId?: string,
|
||||
cardId: string,
|
||||
hasChanges: boolean;
|
||||
onClickPublish: () => void;
|
||||
onClickConfigure: () => void;
|
||||
onClickMenuButton: () => void;
|
||||
onClickEdit: () => void;
|
||||
isFormOpen: boolean;
|
||||
onEditSubmit: (titleValue: string) => void;
|
||||
closeForm: () => void;
|
||||
onClickDelete: () => void;
|
||||
onClickUnlink: () => void;
|
||||
onClickDuplicate: () => void;
|
||||
@@ -72,7 +69,6 @@ interface CardHeaderProps {
|
||||
extraActionsComponent?: ReactNode,
|
||||
onClickSync?: () => void;
|
||||
readyToSync?: boolean;
|
||||
savingStatus?: RequestStatusType;
|
||||
}
|
||||
|
||||
const CardHeader = ({
|
||||
@@ -83,10 +79,6 @@ const CardHeader = ({
|
||||
onClickPublish,
|
||||
onClickConfigure,
|
||||
onClickMenuButton,
|
||||
onClickEdit,
|
||||
isFormOpen,
|
||||
onEditSubmit,
|
||||
closeForm,
|
||||
onClickDelete,
|
||||
onClickUnlink,
|
||||
onClickDuplicate,
|
||||
@@ -107,7 +99,6 @@ const CardHeader = ({
|
||||
extraActionsComponent,
|
||||
onClickSync,
|
||||
readyToSync,
|
||||
savingStatus,
|
||||
}: CardHeaderProps) => {
|
||||
const intl = useIntl();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -118,12 +109,16 @@ const CardHeader = ({
|
||||
|
||||
const openManageTagsDrawer = useCallback(() => {
|
||||
const showNewSidebar = getConfig().ENABLE_COURSE_OUTLINE_NEW_DESIGN?.toString().toLowerCase() === 'true';
|
||||
if (showNewSidebar) {
|
||||
setCurrentPageKey('align', cardId);
|
||||
const showAlignSidebar = getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true';
|
||||
if (showNewSidebar && showAlignSidebar) {
|
||||
setCurrentPageKey('align');
|
||||
onClickMenuButton();
|
||||
} else {
|
||||
openLegacyTagsDrawer();
|
||||
}
|
||||
}, [setCurrentPageKey, openLegacyTagsDrawer, cardId]);
|
||||
const { courseId, currentSelection } = useCourseAuthoringContext();
|
||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||
|
||||
// Use studio url as base if proctoringExamConfigurationLink is a relative link
|
||||
const fullProctoringExamConfigurationLink = () => (
|
||||
@@ -134,7 +129,11 @@ const CardHeader = ({
|
||||
|| status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges;
|
||||
|
||||
const { data: contentTagCount } = useContentTagsCount(cardId);
|
||||
const isSaving = savingStatus === RequestStatus.IN_PROGRESS;
|
||||
|
||||
const onEditClick = () => {
|
||||
onClickMenuButton();
|
||||
openForm();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const locatorId = searchParams.get('show');
|
||||
@@ -159,13 +158,29 @@ const CardHeader = ({
|
||||
);
|
||||
|
||||
useEscapeClick({
|
||||
onEscape: () => {
|
||||
onEscape: /* istanbul ignore next */ () => {
|
||||
setTitleValue(title);
|
||||
closeForm();
|
||||
},
|
||||
dependency: title,
|
||||
dependency: [title],
|
||||
});
|
||||
|
||||
const editMutation = useUpdateCourseBlockName(courseId);
|
||||
const handleEditSubmit = useCallback(() => {
|
||||
if (title !== titleValue) {
|
||||
editMutation.mutate({
|
||||
itemId: cardId,
|
||||
displayName: titleValue,
|
||||
subsectionId: currentSelection?.subsectionId,
|
||||
sectionId: currentSelection?.sectionId,
|
||||
}, {
|
||||
onSettled: () => closeForm(),
|
||||
});
|
||||
} else {
|
||||
closeForm();
|
||||
}
|
||||
}, [title, titleValue, cardId, editMutation]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
@@ -188,10 +203,10 @@ const CardHeader = ({
|
||||
name="displayName"
|
||||
onChange={(e) => setTitleValue(e.target.value)}
|
||||
aria-label={intl.formatMessage(messages.editFieldAriaLabel)}
|
||||
onBlur={() => onEditSubmit(titleValue)}
|
||||
onBlur={handleEditSubmit}
|
||||
onKeyDown={/* istanbul ignore next */ (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onEditSubmit(titleValue);
|
||||
handleEditSubmit();
|
||||
} else if (e.key === ' ') {
|
||||
// Avoid passing propagation to the `SortableItem` in the card,
|
||||
// which executes a `preventDefault`. If propagation is not prevented,
|
||||
@@ -199,7 +214,7 @@ const CardHeader = ({
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
disabled={isSaving}
|
||||
disabled={editMutation.isPending}
|
||||
/>
|
||||
</Form.Group>
|
||||
) : (
|
||||
@@ -211,9 +226,8 @@ const CardHeader = ({
|
||||
alt={intl.formatMessage(messages.altButtonRename)}
|
||||
tooltipContent={<div>{intl.formatMessage(messages.altButtonRename)}</div>}
|
||||
iconAs={EditIcon}
|
||||
onClick={onClickEdit}
|
||||
// @ts-ignore
|
||||
disabled={isSaving}
|
||||
onClick={onEditClick}
|
||||
disabled={editMutation.isPending}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
@@ -265,7 +279,7 @@ const CardHeader = ({
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-configure-button`}
|
||||
disabled={isSaving}
|
||||
disabled={editMutation.isPending}
|
||||
onClick={onClickConfigure}
|
||||
>
|
||||
{intl.formatMessage(messages.menuConfigure)}
|
||||
@@ -273,7 +287,7 @@ const CardHeader = ({
|
||||
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-manage-tags-button`}
|
||||
disabled={isSaving}
|
||||
disabled={editMutation.isPending}
|
||||
onClick={openManageTagsDrawer}
|
||||
>
|
||||
{intl.formatMessage(messages.menuManageTags)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import { CourseOutline, CourseDetails } from './types';
|
||||
import { CourseOutline, CourseDetails, CourseItemUpdateResult } from './types';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
|
||||
@@ -170,10 +170,8 @@ export async function restartIndexingOnCourse(reindexLink: string): Promise<obje
|
||||
|
||||
/**
|
||||
* Get course Xblock
|
||||
* @param {string} itemId
|
||||
* @returns {Promise<XBlock>}
|
||||
*/
|
||||
export async function getCourseItem(itemId: string): Promise<XBlock> {
|
||||
export async function getCourseItem<T = XBlock>(itemId: string): Promise<T> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getXBlockApiUrl(itemId));
|
||||
return camelCaseObject(data);
|
||||
@@ -201,13 +199,11 @@ export async function updateCourseSectionHighlights(
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish course section
|
||||
* @param {string} sectionId
|
||||
* @returns {Promise<Object>}
|
||||
* Publish course item
|
||||
*/
|
||||
export async function publishCourseSection(sectionId: string): Promise<object> {
|
||||
export async function publishCourseItem(itemId: string): Promise<CourseItemUpdateResult> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(sectionId), {
|
||||
.post(getCourseItemApiUrl(itemId), {
|
||||
publish: 'make_public',
|
||||
});
|
||||
|
||||
@@ -335,14 +331,11 @@ export async function configureCourseUnit(
|
||||
|
||||
/**
|
||||
* Edit course section
|
||||
* @param {string} itemId
|
||||
* @param {string} displayName
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function editItemDisplayName(
|
||||
itemId: string,
|
||||
displayName: string,
|
||||
): Promise<object> {
|
||||
export async function editItemDisplayName({ itemId, displayName }: {
|
||||
itemId: string;
|
||||
displayName: string;
|
||||
}): Promise<CourseItemUpdateResult> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCourseItemApiUrl(itemId), {
|
||||
metadata: {
|
||||
@@ -367,9 +360,6 @@ export async function deleteCourseItem(itemId: string): Promise<object> {
|
||||
|
||||
/**
|
||||
* Duplicate course section
|
||||
* @param {string} itemId
|
||||
* @param {string} parentId
|
||||
* @returns {Promise<XBlock>}
|
||||
*/
|
||||
export async function duplicateCourseItem(itemId: string, parentId: string): Promise<XBlock> {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
@@ -381,6 +371,18 @@ export async function duplicateCourseItem(itemId: string, parentId: string): Pro
|
||||
return data;
|
||||
}
|
||||
|
||||
export type CreateCourseXBlockType = {
|
||||
type: string,
|
||||
/** The category of the XBlock. Defaults to the type if not provided. */
|
||||
category?: string,
|
||||
parentLocator: string,
|
||||
displayName?: string,
|
||||
boilerplate?: string,
|
||||
stagedContent?: string,
|
||||
/** component key from library if being imported. */
|
||||
libraryContentKey?: string,
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new course XBlock. Can be used to create any type of block
|
||||
* and also import a content from library.
|
||||
@@ -393,17 +395,7 @@ export async function createCourseXblock({
|
||||
boilerplate,
|
||||
stagedContent,
|
||||
libraryContentKey,
|
||||
}: {
|
||||
type: string,
|
||||
/** The category of the XBlock. Defaults to the type if not provided. */
|
||||
category?: string,
|
||||
parentLocator: string,
|
||||
displayName?: string,
|
||||
boilerplate?: string,
|
||||
stagedContent?: string,
|
||||
/** component key from library if being imported. */
|
||||
libraryContentKey?: string,
|
||||
}) {
|
||||
}: CreateCourseXBlockType) {
|
||||
const body = {
|
||||
type,
|
||||
boilerplate,
|
||||
|
||||
@@ -1,15 +1,39 @@
|
||||
import { skipToken, useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { createCourseXblock, getCourseDetails, getCourseItem } from './api';
|
||||
import { containerComparisonQueryKeys } from '@src/container-comparison/data/apiHooks';
|
||||
import type { XBlock } from '@src/data/types';
|
||||
import { getCourseKey } from '@src/generic/key-utils';
|
||||
import { handleResponseErrors } from '@src/generic/saving-error-alert';
|
||||
import {
|
||||
QueryClient,
|
||||
skipToken, useMutation, useQuery, useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
import {
|
||||
createCourseXblock,
|
||||
type CreateCourseXBlockType,
|
||||
deleteCourseItem,
|
||||
editItemDisplayName,
|
||||
getCourseDetails,
|
||||
getCourseItem,
|
||||
publishCourseItem,
|
||||
} from './api';
|
||||
|
||||
export const courseOutlineQueryKeys = {
|
||||
all: ['courseOutline'],
|
||||
/**
|
||||
* Base key for data specific to a course in outline
|
||||
*/
|
||||
contentLibrary: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId],
|
||||
courseItemId: (itemId?: string) => [...courseOutlineQueryKeys.all, itemId],
|
||||
courseDetails: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId, 'details'],
|
||||
legacyLibReadyToMigrateBlocks: (courseId: string) => [...courseOutlineQueryKeys.all, courseId, 'legacyLibReadyToMigrateBlocks'],
|
||||
course: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId],
|
||||
courseItemId: (itemId?: string) => [
|
||||
...courseOutlineQueryKeys.course(itemId ? getCourseKey(itemId) : undefined),
|
||||
itemId,
|
||||
],
|
||||
courseDetails: (courseId?: string) => [
|
||||
...courseOutlineQueryKeys.course(courseId),
|
||||
'details',
|
||||
],
|
||||
legacyLibReadyToMigrateBlocks: (courseId: string) => [
|
||||
...courseOutlineQueryKeys.course(courseId),
|
||||
'legacyLibReadyToMigrateBlocks',
|
||||
],
|
||||
legacyLibReadyToMigrateBlocksStatus: (courseId: string, taskId?: string) => [
|
||||
...courseOutlineQueryKeys.legacyLibReadyToMigrateBlocks(courseId),
|
||||
'status',
|
||||
@@ -17,24 +41,54 @@ export const courseOutlineQueryKeys = {
|
||||
],
|
||||
};
|
||||
|
||||
type ParentIds = {
|
||||
/** This id will be used to invalidate data of parent subsection */
|
||||
subsectionId?: string;
|
||||
/** This id will be used to invalidate data of parent section */
|
||||
sectionId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Invalidate parent Subsection and Section data.
|
||||
*/
|
||||
const invalidateParentQueries = async (queryClient: QueryClient, variables: ParentIds) => {
|
||||
if (variables.subsectionId) {
|
||||
await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.subsectionId) });
|
||||
}
|
||||
if (variables.sectionId) {
|
||||
await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.sectionId) });
|
||||
}
|
||||
};
|
||||
|
||||
type CreateCourseXBlockMutationProps = CreateCourseXBlockType & ParentIds;
|
||||
|
||||
/**
|
||||
* 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: { locator: string }, _err, variables) => {
|
||||
callback?.(data.locator, variables.parentLocator);
|
||||
},
|
||||
});
|
||||
callback?: ((locator: string, parentLocator: string) => Promise<void>),
|
||||
) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (variables: CreateCourseXBlockMutationProps) => createCourseXblock(variables),
|
||||
onSettled: async (data: { locator: string; }, _err, variables) => {
|
||||
await callback?.(data.locator, variables.parentLocator);
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.parentLocator) });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(data.locator)),
|
||||
});
|
||||
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCourseItemData = (itemId?: string, enabled: boolean = true) => (
|
||||
export const useCourseItemData = <T = XBlock>(itemId?: string, initialData?: T, enabled: boolean = true) => (
|
||||
useQuery({
|
||||
initialData,
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(itemId),
|
||||
queryFn: enabled && itemId !== undefined ? () => getCourseItem(itemId!) : skipToken,
|
||||
queryFn: enabled && itemId ? () => getCourseItem<T>(itemId!) : skipToken,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -44,3 +98,46 @@ export const useCourseDetails = (courseId?: string, enabled: boolean = true) =>
|
||||
queryFn: enabled && courseId ? () => getCourseDetails(courseId) : skipToken,
|
||||
})
|
||||
);
|
||||
|
||||
export const useUpdateCourseBlockName = (courseId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (variables:{
|
||||
itemId: string;
|
||||
displayName: string;
|
||||
} & ParentIds) => editItemDisplayName({ itemId: variables.itemId, displayName: variables.displayName }),
|
||||
onSuccess: async (_data, variables) => {
|
||||
await queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) });
|
||||
await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(courseId) });
|
||||
await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.itemId) });
|
||||
await invalidateParentQueries(queryClient, variables);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const usePublishCourseItem = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (variables:{
|
||||
itemId: string;
|
||||
} & ParentIds) => publishCourseItem(variables.itemId),
|
||||
onSettled: (_data, _err, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.itemId) });
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.itemId)) });
|
||||
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteCourseItem = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (variables:{
|
||||
itemId: string;
|
||||
} & ParentIds) => deleteCourseItem(variables.itemId),
|
||||
onSettled: (_data, _err, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.itemId)) });
|
||||
invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,9 +3,6 @@ export const getLoadingStatus = (state) => state.courseOutline.loadingStatus;
|
||||
export const getStatusBarData = (state) => state.courseOutline.statusBarData;
|
||||
export const getSavingStatus = (state) => state.courseOutline.savingStatus;
|
||||
export const getSectionsList = (state) => state.courseOutline.sectionsList;
|
||||
export const getCurrentItem = (state) => state.courseOutline.currentItem;
|
||||
export const getCurrentSection = (state) => state.courseOutline.currentSection;
|
||||
export const getCurrentSubsection = (state) => state.courseOutline.currentSubsection;
|
||||
export const getCourseActions = (state) => state.courseOutline.actions;
|
||||
export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive;
|
||||
export const getProctoredExamsFlag = (state) => state.courseOutline.enableProctoredExams;
|
||||
|
||||
@@ -33,13 +33,9 @@ const initialState = {
|
||||
},
|
||||
videoSharingEnabled: false,
|
||||
videoSharingOptions: VIDEO_SHARING_OPTIONS.perVideo,
|
||||
hasChanges: false,
|
||||
},
|
||||
sectionsList: [],
|
||||
isCustomRelativeDatesActive: false,
|
||||
currentSection: {},
|
||||
currentSubsection: {},
|
||||
currentItem: {},
|
||||
actions: {
|
||||
deletable: true,
|
||||
unlinkable: false,
|
||||
@@ -125,21 +121,12 @@ const slice = createSlice({
|
||||
updateSectionList: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => (section.id in payload ? payload[section.id] : section));
|
||||
},
|
||||
setCurrentItem: (state: CourseOutlineState, { payload }) => {
|
||||
state.currentItem = 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: CourseOutlineState, { payload }) => {
|
||||
state.currentSection = payload;
|
||||
},
|
||||
setCurrentSubsection: (state: CourseOutlineState, { payload }) => {
|
||||
state.currentSubsection = payload;
|
||||
},
|
||||
addSection: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = [
|
||||
...state.sectionsList,
|
||||
@@ -183,6 +170,25 @@ const slice = createSlice({
|
||||
return section;
|
||||
});
|
||||
},
|
||||
// FIXME: This is a temporary measure to add unit using redux even while we are
|
||||
// actively trying to get rid of it.
|
||||
// To remove this and other add functions, we need to migrate course outline data
|
||||
// to a react-query and perform optimistic updates to add/remove content.
|
||||
addUnit: /* istanbul ignore next */ (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
section.childInfo.children = section.childInfo.children.map((subsection) => {
|
||||
if (subsection.id !== payload.parentLocator) {
|
||||
return subsection;
|
||||
}
|
||||
subsection.childInfo.children = [
|
||||
...subsection.childInfo.children.filter(({ id }) => id !== payload.data.id),
|
||||
payload.data,
|
||||
];
|
||||
return subsection;
|
||||
});
|
||||
return section;
|
||||
});
|
||||
},
|
||||
deleteUnit: (state: CourseOutlineState, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
if (section.id !== payload.sectionId) {
|
||||
@@ -233,12 +239,10 @@ export const {
|
||||
updateCourseLaunchQueryStatus,
|
||||
updateSavingStatus,
|
||||
updateSectionList,
|
||||
setCurrentItem,
|
||||
setCurrentSection,
|
||||
setCurrentSubsection,
|
||||
deleteSection,
|
||||
deleteSubsection,
|
||||
deleteUnit,
|
||||
addUnit,
|
||||
duplicateSection,
|
||||
reorderSectionList,
|
||||
setPasteFileNotices,
|
||||
|
||||
@@ -11,15 +11,12 @@ import {
|
||||
} from '../utils/getChecklistForStatusBar';
|
||||
import { getErrorDetails } from '../utils/getErrorDetails';
|
||||
import {
|
||||
deleteCourseItem,
|
||||
duplicateCourseItem,
|
||||
editItemDisplayName,
|
||||
enableCourseHighlightsEmails,
|
||||
getCourseBestPractices,
|
||||
getCourseLaunch,
|
||||
getCourseOutlineIndex,
|
||||
getCourseItem,
|
||||
publishCourseSection,
|
||||
configureCourseSection,
|
||||
configureCourseSubsection,
|
||||
configureCourseUnit,
|
||||
@@ -42,9 +39,6 @@ import {
|
||||
updateSavingStatus,
|
||||
updateSectionList,
|
||||
updateFetchSectionLoadingStatus,
|
||||
deleteSection,
|
||||
deleteSubsection,
|
||||
deleteUnit,
|
||||
duplicateSection,
|
||||
reorderSectionList,
|
||||
setPasteFileNotices,
|
||||
@@ -266,26 +260,6 @@ export function updateCourseSectionHighlightsQuery(sectionId: string, highlights
|
||||
};
|
||||
}
|
||||
|
||||
export function publishCourseItemQuery(itemId: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await publishCourseSection(itemId).then(async (result) => {
|
||||
if (result) {
|
||||
await dispatch(fetchCourseSectionQuery([sectionId]));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function configureCourseItemQuery(sectionId: string, configureFn: () => Promise<any>) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
@@ -376,75 +350,6 @@ export function configureCourseUnitQuery(
|
||||
};
|
||||
}
|
||||
|
||||
export function editCourseItemQuery(itemId: string, sectionId: string, displayName: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
|
||||
|
||||
try {
|
||||
await editItemDisplayName(itemId, displayName).then(async (result) => {
|
||||
if (result) {
|
||||
await dispatch(fetchCourseSectionQuery([sectionId]));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic function to delete course item, see below wrapper funcs for specific implementations.
|
||||
* @param {string} itemId
|
||||
* @param {() => {}} deleteItemFn
|
||||
*/
|
||||
function deleteCourseItemQuery(itemId: string, deleteItemFn: () => {}) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
|
||||
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.deleting));
|
||||
|
||||
try {
|
||||
await deleteCourseItem(itemId);
|
||||
dispatch(deleteItemFn());
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
} catch {
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCourseSectionQuery(sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(deleteCourseItemQuery(
|
||||
sectionId,
|
||||
() => deleteSection({ itemId: sectionId }),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCourseSubsectionQuery(subsectionId: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(deleteCourseItemQuery(
|
||||
subsectionId,
|
||||
() => deleteSubsection({ itemId: subsectionId, sectionId }),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCourseUnitQuery(unitId: string, subsectionId: string, sectionId: string) {
|
||||
return async (dispatch) => {
|
||||
dispatch(deleteCourseItemQuery(
|
||||
unitId,
|
||||
() => deleteUnit({ itemId: unitId, subsectionId, sectionId }),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic function to duplicate any course item. See wrapper functions below for specific implementations.
|
||||
* @param {string} itemId
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface CourseDetails {
|
||||
subtitle?: string;
|
||||
org: string;
|
||||
description?: string;
|
||||
hasChanges: boolean;
|
||||
}
|
||||
|
||||
export interface ChecklistType {
|
||||
@@ -50,7 +51,6 @@ export interface CourseOutlineStatusBar {
|
||||
checklist: ChecklistType;
|
||||
videoSharingEnabled: boolean;
|
||||
videoSharingOptions: string;
|
||||
hasChanges: boolean;
|
||||
}
|
||||
|
||||
export interface CourseOutlineState {
|
||||
@@ -71,12 +71,22 @@ export interface CourseOutlineState {
|
||||
statusBarData: CourseOutlineStatusBar;
|
||||
sectionsList: Array<XBlock>;
|
||||
isCustomRelativeDatesActive: boolean;
|
||||
currentSection: XBlock | {};
|
||||
currentSubsection: XBlock | {};
|
||||
currentItem: XBlock | {};
|
||||
actions: XBlockActions;
|
||||
enableProctoredExams: boolean;
|
||||
enableTimedExams: boolean;
|
||||
pasteFileNotices: object;
|
||||
createdOn: null | Date;
|
||||
}
|
||||
|
||||
export interface CourseItemUpdateResult {
|
||||
id: string;
|
||||
data?: object | null;
|
||||
metadata: {
|
||||
downstreamCustomized?: string[];
|
||||
topLevelDownstreamParentKey?: string;
|
||||
upstream?: string;
|
||||
upstreamDisplayName?: string;
|
||||
upstreamVersion?: number;
|
||||
displayName?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ import {
|
||||
fireEvent, initializeMocks, render, screen,
|
||||
} from '@src/testUtils';
|
||||
|
||||
import { OutlineSidebarProvider } from '@src/course-outline';
|
||||
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
|
||||
import messages from './messages';
|
||||
import HeaderActions, { HeaderActionsProps } from './HeaderActions';
|
||||
import { OutlineSidebarProvider } from '../outline-sidebar/OutlineSidebarContext';
|
||||
|
||||
const headerNavigationsActions = {
|
||||
lmsLink: '',
|
||||
@@ -34,7 +35,15 @@ const renderComponent = (props?: Partial<HeaderActionsProps>) => render(
|
||||
courseActions={courseActions}
|
||||
{...props}
|
||||
/>,
|
||||
{ extraWrapper: OutlineSidebarProvider },
|
||||
{
|
||||
extraWrapper: ({ children }) => (
|
||||
<CourseAuthoringProvider courseId="1">
|
||||
<OutlineSidebarProvider>
|
||||
{children}
|
||||
</OutlineSidebarProvider>
|
||||
</CourseAuthoringProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
describe('<HeaderActions />', () => {
|
||||
|
||||
@@ -28,7 +28,13 @@ const HeaderActions = ({
|
||||
const intl = useIntl();
|
||||
const { lmsLink } = actions;
|
||||
|
||||
const { setCurrentPageKey } = useOutlineSidebarContext();
|
||||
const { clearSelection, open, setCurrentPageKey } = useOutlineSidebarContext();
|
||||
|
||||
const handleCourseInfoClick = () => {
|
||||
clearSelection();
|
||||
setCurrentPageKey('info');
|
||||
open();
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack direction="horizontal" gap={3}>
|
||||
@@ -42,7 +48,7 @@ const HeaderActions = ({
|
||||
>
|
||||
<Button
|
||||
iconBefore={InfoOutline}
|
||||
onClick={() => setCurrentPageKey('info')}
|
||||
onClick={handleCourseInfoClick}
|
||||
variant="outline-primary"
|
||||
>
|
||||
{intl.formatMessage(messages.courseInfoButton)}
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
Hyperlink,
|
||||
} from '@openedx/paragon';
|
||||
import { Formik } from 'formik';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import { useHelpUrls } from '../../help-urls/hooks';
|
||||
import FormikControl from '../../generic/FormikControl';
|
||||
import { getCurrentSection } from '../data/selectors';
|
||||
import { HIGHLIGHTS_FIELD_MAX_LENGTH } from '../constants';
|
||||
import { getHighlightsFormValues } from '../utils';
|
||||
import messages from './messages';
|
||||
@@ -23,7 +23,9 @@ const HighlightsModal = ({
|
||||
onSubmit,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { highlights = [], displayName } = useSelector(getCurrentSection);
|
||||
const { currentSelection } = useCourseAuthoringContext();
|
||||
const { data: currentItemData } = useCourseItemData(currentSelection?.currentId);
|
||||
const { highlights = [], displayName } = currentItemData || {};
|
||||
const initialFormValues = getHighlightsFormValues(highlights);
|
||||
|
||||
const {
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
render, fireEvent, act, waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
initializeMocks, render, fireEvent, act, waitFor,
|
||||
} from '@src/testUtils';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import HighlightsModal from './HighlightsModal';
|
||||
import messages from './messages';
|
||||
|
||||
let store;
|
||||
const mockPathname = '/foo-bar';
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn(),
|
||||
const currentItemMock = {
|
||||
highlights: ['Highlight 1', 'Highlight 2'],
|
||||
displayName: 'Test Section',
|
||||
};
|
||||
|
||||
jest.mock('@src/CourseAuthoringContext', () => ({
|
||||
useCourseAuthoringContext: () => ({
|
||||
courseId: 5,
|
||||
courseUsageKey: 'course-usage-key',
|
||||
courseDetails: { name: 'Test course' },
|
||||
currentSelection: { currentId: 1 },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@src/course-outline/data/apiHooks', () => ({
|
||||
useCourseItemData: () => ({
|
||||
data: currentItemMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
@@ -32,39 +40,20 @@ jest.mock('../../help-urls/hooks', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const currentItemMock = {
|
||||
highlights: ['Highlight 1', 'Highlight 2'],
|
||||
displayName: 'Test Section',
|
||||
};
|
||||
|
||||
const onCloseMock = jest.fn();
|
||||
const onSubmitMock = jest.fn();
|
||||
|
||||
const renderComponent = () => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<HighlightsModal
|
||||
isOpen
|
||||
onClose={onCloseMock}
|
||||
onSubmit={onSubmitMock}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
</AppProvider>,
|
||||
<HighlightsModal
|
||||
isOpen
|
||||
onClose={onCloseMock}
|
||||
onSubmit={onSubmitMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe('<HighlightsModal />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
useSelector.mockReturnValue(currentItemMock);
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
it('renders HighlightsModal component correctly', () => {
|
||||
@@ -1,20 +1,23 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import moment from 'moment';
|
||||
import { getSavingStatus as getGenericSavingStatus } from '@src/generic/data/selectors';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import { useUnlinkDownstream } from '@src/generic/unlink-modal';
|
||||
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
import { ContainerType, getBlockType } from '@src/generic/key-utils';
|
||||
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
|
||||
import { useUnlinkDownstream } from '@src/generic/unlink-modal';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { courseOutlineQueryKeys, useDeleteCourseItem } from '@src/course-outline/data/apiHooks';
|
||||
import { COURSE_BLOCK_NAMES } from './constants';
|
||||
import {
|
||||
setCurrentItem,
|
||||
setCurrentSection,
|
||||
deleteSection,
|
||||
deleteSubsection,
|
||||
deleteUnit,
|
||||
resetScrollField,
|
||||
updateSavingStatus,
|
||||
} from './data/slice';
|
||||
@@ -25,18 +28,11 @@ import {
|
||||
getStatusBarData,
|
||||
getSectionsList,
|
||||
getCourseActions,
|
||||
getCurrentItem,
|
||||
getCurrentSection,
|
||||
getCurrentSubsection,
|
||||
getCustomRelativeDatesActiveFlag,
|
||||
getErrors,
|
||||
getCreatedOn,
|
||||
} from './data/selectors';
|
||||
import {
|
||||
deleteCourseSectionQuery,
|
||||
deleteCourseSubsectionQuery,
|
||||
deleteCourseUnitQuery,
|
||||
editCourseItemQuery,
|
||||
duplicateSectionQuery,
|
||||
duplicateSubsectionQuery,
|
||||
duplicateUnitQuery,
|
||||
@@ -45,7 +41,6 @@ import {
|
||||
fetchCourseLaunchQuery,
|
||||
fetchCourseOutlineIndexQuery,
|
||||
fetchCourseReindexQuery,
|
||||
publishCourseItemQuery,
|
||||
updateCourseSectionHighlightsQuery,
|
||||
configureCourseSectionQuery,
|
||||
configureCourseSubsectionQuery,
|
||||
@@ -58,12 +53,17 @@ import {
|
||||
dismissNotificationQuery,
|
||||
syncDiscussionsTopics,
|
||||
} from './data/thunk';
|
||||
import { containerComparisonQueryKeys } from '../container-comparison/data/apiHooks';
|
||||
|
||||
const useCourseOutline = ({ courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
const queryClient = useQueryClient();
|
||||
const { handleAddSection } = useCourseAuthoringContext();
|
||||
const {
|
||||
handleAddSection,
|
||||
setCurrentSelection,
|
||||
currentSelection,
|
||||
currentUnlinkModalData,
|
||||
closeUnlinkModal,
|
||||
} = useCourseAuthoringContext();
|
||||
const { selectedContainerState, clearSelection } = useOutlineSidebarContext();
|
||||
|
||||
const {
|
||||
reindexLink,
|
||||
@@ -84,9 +84,6 @@ const useCourseOutline = ({ courseId }) => {
|
||||
const savingStatus = useSelector(getSavingStatus);
|
||||
const courseActions = useSelector(getCourseActions);
|
||||
const sectionsList = useSelector(getSectionsList);
|
||||
const currentItem = useSelector(getCurrentItem);
|
||||
const currentSection = useSelector(getCurrentSection);
|
||||
const currentSubsection = useSelector(getCurrentSubsection);
|
||||
const isCustomRelativeDatesActive = useSelector(getCustomRelativeDatesActiveFlag);
|
||||
const genericSavingStatus = useSelector(getGenericSavingStatus);
|
||||
const errors = useSelector(getErrors);
|
||||
@@ -96,10 +93,9 @@ const useCourseOutline = ({ courseId }) => {
|
||||
const [isDisabledReindexButton, setDisableReindexButton] = useState(false);
|
||||
const [showSuccessAlert, setShowSuccessAlert] = useState(false);
|
||||
const [isHighlightsModalOpen, openHighlightsModal, closeHighlightsModal] = useToggle(false);
|
||||
const [isPublishModalOpen, openPublishModal, closePublishModal] = useToggle(false);
|
||||
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
|
||||
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
|
||||
const [isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal] = useToggle(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED;
|
||||
|
||||
@@ -144,97 +140,155 @@ const useCourseOutline = ({ courseId }) => {
|
||||
};
|
||||
|
||||
const handleOpenHighlightsModal = (section) => {
|
||||
dispatch(setCurrentItem(section));
|
||||
dispatch(setCurrentSection(section));
|
||||
setCurrentSelection({
|
||||
currentId: section.id,
|
||||
sectionId: section.id,
|
||||
});
|
||||
openHighlightsModal();
|
||||
};
|
||||
|
||||
const handleHighlightsFormSubmit = (highlights) => {
|
||||
const dataToSend = Object.values(highlights).filter(Boolean);
|
||||
dispatch(updateCourseSectionHighlightsQuery(currentItem.id, dataToSend));
|
||||
dispatch(updateCourseSectionHighlightsQuery(currentSelection?.currentId, dataToSend));
|
||||
|
||||
closeHighlightsModal();
|
||||
};
|
||||
|
||||
const handlePublishItemSubmit = () => {
|
||||
dispatch(publishCourseItemQuery(currentItem.id, currentSection.id));
|
||||
|
||||
closePublishModal();
|
||||
};
|
||||
|
||||
const handleConfigureModalClose = () => {
|
||||
closeConfigureModal();
|
||||
// reset the currentItem so the ConfigureModal's state is also reset
|
||||
dispatch(setCurrentItem({}));
|
||||
};
|
||||
|
||||
const handleConfigureItemSubmit = (...arg) => {
|
||||
switch (currentItem.category) {
|
||||
case COURSE_BLOCK_NAMES.chapter.id:
|
||||
dispatch(configureCourseSectionQuery(currentSection.id, ...arg));
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.sequential.id:
|
||||
dispatch(configureCourseSubsectionQuery(currentItem.id, currentSection.id, ...arg));
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.vertical.id:
|
||||
dispatch(configureCourseUnitQuery(currentItem.id, currentSection.id, ...arg));
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
handleConfigureModalClose();
|
||||
};
|
||||
|
||||
const handleEditSubmit = (itemId, sectionId, displayName) => {
|
||||
dispatch(editCourseItemQuery(itemId, sectionId, displayName));
|
||||
// Invalidate container diff queries to update sync diff preview
|
||||
queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) });
|
||||
};
|
||||
|
||||
const handleDeleteItemSubmit = () => {
|
||||
switch (currentItem.category) {
|
||||
case COURSE_BLOCK_NAMES.chapter.id:
|
||||
dispatch(deleteCourseSectionQuery(currentItem.id));
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.sequential.id:
|
||||
dispatch(deleteCourseSubsectionQuery(currentItem.id, currentSection.id));
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.vertical.id:
|
||||
dispatch(deleteCourseUnitQuery(
|
||||
currentItem.id,
|
||||
currentSubsection.id,
|
||||
currentSection.id,
|
||||
));
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
closeDeleteModal();
|
||||
// reset the currentSelection?.current so the ConfigureModal's state is also reset
|
||||
setCurrentSelection(undefined);
|
||||
};
|
||||
|
||||
const { mutateAsync: unlinkDownstream } = useUnlinkDownstream();
|
||||
|
||||
const handleUnlinkItemSubmit = async () => {
|
||||
/** Handle the submit of the item unlinking XBlock from library counterpart. */
|
||||
const handleUnlinkItemSubmit = useCallback(async () => {
|
||||
// istanbul ignore if: this should never happen
|
||||
if (!currentItem.id) {
|
||||
if (!currentUnlinkModalData) {
|
||||
return;
|
||||
}
|
||||
|
||||
await unlinkDownstream(currentItem.id);
|
||||
dispatch(fetchCourseOutlineIndexQuery(courseId));
|
||||
closeUnlinkModal();
|
||||
await unlinkDownstream(currentUnlinkModalData.value.id, {
|
||||
onSuccess: () => {
|
||||
closeUnlinkModal();
|
||||
// istanbul ignore next
|
||||
// refresh child block data
|
||||
currentUnlinkModalData.value.childInfo?.children.forEach((block) => {
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(block.id) });
|
||||
block.childInfo?.children.forEach(({ id: blockId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(blockId) });
|
||||
});
|
||||
});
|
||||
// refresh parent blocks data
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(currentUnlinkModalData?.sectionId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(currentUnlinkModalData?.subsectionId),
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [currentUnlinkModalData, unlinkDownstream, closeUnlinkModal]);
|
||||
|
||||
const handleConfigureItemSubmit = (...arg) => {
|
||||
const category = getBlockType(currentSelection.currentId);
|
||||
switch (category) {
|
||||
case COURSE_BLOCK_NAMES.chapter.id:
|
||||
dispatch(configureCourseSectionQuery(currentSelection?.sectionId, ...arg));
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.sequential.id:
|
||||
dispatch(configureCourseSubsectionQuery(currentSelection?.currentId, currentSelection?.sectionId, ...arg));
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.vertical.id:
|
||||
dispatch(configureCourseUnitQuery(currentSelection?.currentId, currentSelection?.sectionId, ...arg));
|
||||
break;
|
||||
default:
|
||||
// istanbul ignore next
|
||||
throw new Error('Unsupported block type');
|
||||
}
|
||||
handleConfigureModalClose();
|
||||
};
|
||||
|
||||
const deleteMutation = useDeleteCourseItem();
|
||||
|
||||
const handleDeleteItemSubmit = useCallback(async () => {
|
||||
// istanbul ignore if
|
||||
if (!currentSelection) {
|
||||
return;
|
||||
}
|
||||
const category = getBlockType(currentSelection.currentId);
|
||||
switch (category) {
|
||||
case COURSE_BLOCK_NAMES.chapter.id:
|
||||
await deleteMutation.mutateAsync(
|
||||
{ itemId: currentSelection.currentId },
|
||||
{
|
||||
onSettled: () => dispatch(deleteSection({ itemId: currentSelection.currentId })),
|
||||
},
|
||||
);
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.sequential.id:
|
||||
await deleteMutation.mutateAsync(
|
||||
{ itemId: currentSelection.currentId, sectionId: currentSelection.sectionId },
|
||||
{
|
||||
onSettled: () => dispatch(deleteSubsection({
|
||||
itemId: currentSelection.currentId,
|
||||
sectionId: currentSelection.sectionId,
|
||||
})),
|
||||
},
|
||||
);
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.vertical.id:
|
||||
await deleteMutation.mutateAsync(
|
||||
{
|
||||
itemId: currentSelection.currentId,
|
||||
subsectionId: currentSelection.subsectionId,
|
||||
sectionId: currentSelection.sectionId,
|
||||
},
|
||||
{
|
||||
onSettled: () => dispatch(deleteUnit({
|
||||
itemId: currentSelection.currentId,
|
||||
subsectionId: currentSelection.subsectionId,
|
||||
sectionId: currentSelection.sectionId,
|
||||
})),
|
||||
},
|
||||
);
|
||||
break;
|
||||
default:
|
||||
// istanbul ignore next
|
||||
throw new Error(`Unrecognized category ${category}`);
|
||||
}
|
||||
closeDeleteModal();
|
||||
if (selectedContainerState.currentId === currentSelection?.currentId) {
|
||||
clearSelection();
|
||||
}
|
||||
}, [
|
||||
deleteMutation,
|
||||
clearSelection,
|
||||
closeDeleteModal,
|
||||
queryClient,
|
||||
currentSelection,
|
||||
courseOutlineQueryKeys,
|
||||
dispatch,
|
||||
deleteSection,
|
||||
deleteUnit,
|
||||
deleteSubsection,
|
||||
]);
|
||||
|
||||
const handleDuplicateSectionSubmit = () => {
|
||||
dispatch(duplicateSectionQuery(currentSection.id, courseStructure.id));
|
||||
dispatch(duplicateSectionQuery(currentSelection?.sectionId, courseStructure.id));
|
||||
};
|
||||
|
||||
const handleDuplicateSubsectionSubmit = () => {
|
||||
dispatch(duplicateSubsectionQuery(currentSubsection.id, currentSection.id));
|
||||
dispatch(duplicateSubsectionQuery(currentSelection?.subsectionId, currentSelection?.sectionId));
|
||||
};
|
||||
|
||||
const handleDuplicateUnitSubmit = () => {
|
||||
dispatch(duplicateUnitQuery(currentItem.id, currentSubsection.id, currentSection.id));
|
||||
dispatch(duplicateUnitQuery(
|
||||
currentSelection?.currentId,
|
||||
currentSelection?.subsectionId,
|
||||
currentSelection?.sectionId,
|
||||
));
|
||||
};
|
||||
|
||||
const handleVideoSharingOptionChange = (value) => {
|
||||
@@ -314,9 +368,6 @@ const useCourseOutline = ({ courseId }) => {
|
||||
showSuccessAlert,
|
||||
isDisabledReindexButton,
|
||||
isSectionsExpanded,
|
||||
isPublishModalOpen,
|
||||
openPublishModal,
|
||||
closePublishModal,
|
||||
isConfigureModalOpen,
|
||||
openConfigureModal,
|
||||
handleConfigureModalClose,
|
||||
@@ -324,8 +375,6 @@ const useCourseOutline = ({ courseId }) => {
|
||||
handleEnableHighlightsSubmit,
|
||||
handleHighlightsFormSubmit,
|
||||
handleConfigureItemSubmit,
|
||||
handlePublishItemSubmit,
|
||||
handleEditSubmit,
|
||||
statusBarData,
|
||||
isEnableHighlightsModalOpen,
|
||||
openEnableHighlightsModal,
|
||||
@@ -339,11 +388,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
isDeleteModalOpen,
|
||||
closeDeleteModal,
|
||||
openDeleteModal,
|
||||
isUnlinkModalOpen,
|
||||
closeUnlinkModal,
|
||||
openUnlinkModal,
|
||||
handleDeleteItemSubmit,
|
||||
handleUnlinkItemSubmit,
|
||||
handleDuplicateSectionSubmit,
|
||||
handleDuplicateSubsectionSubmit,
|
||||
handleDuplicateUnitSubmit,
|
||||
@@ -363,6 +408,7 @@ const useCourseOutline = ({ courseId }) => {
|
||||
handleUnitDragAndDrop,
|
||||
errors,
|
||||
resetScrollState,
|
||||
handleUnlinkItemSubmit,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as CourseOutline } from './CourseOutline';
|
||||
export { OutlineSidebarPagesProvider } from './outline-sidebar/OutlineSidebarPagesContext';
|
||||
export { OutlineSidebarProvider } from './outline-sidebar/OutlineSidebarContext';
|
||||
|
||||
@@ -14,15 +14,17 @@ import {
|
||||
} from '@src/library-authoring/data/api.mocks';
|
||||
import {
|
||||
type OutlineFlow,
|
||||
type OutlineFlowType,
|
||||
OutlineSidebarProvider,
|
||||
} from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
|
||||
import fetchMock from 'fetch-mock-jest';
|
||||
import type { ContainerType } from '@src/generic/key-utils';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
|
||||
import { snakeCaseKeys } from '@src/editors/utils';
|
||||
import { getXBlockBaseApiUrl } from '@src/course-outline/data/api';
|
||||
import MockAdapter from 'axios-mock-adapter/types';
|
||||
import { AddSidebar } from './AddSidebar';
|
||||
|
||||
const handleAddSection = { mutateAsync: jest.fn() };
|
||||
const handleAddSubsection = { mutateAsync: jest.fn() };
|
||||
const handleAddUnit = { mutateAsync: jest.fn() };
|
||||
mockContentSearchConfig.applyMock();
|
||||
mockContentLibrary.applyMock();
|
||||
mockGetCollectionMetadata.applyMock();
|
||||
@@ -31,20 +33,22 @@ mockLibraryBlockMetadata.applyMock();
|
||||
mockGetContainerMetadata.applyMock();
|
||||
|
||||
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
|
||||
const setCurrentSelection = jest.fn();
|
||||
jest.mock('@src/CourseAuthoringContext', () => ({
|
||||
...jest.requireActual('@src/CourseAuthoringContext'),
|
||||
useCourseAuthoringContext: () => ({
|
||||
...jest.requireActual('@src/CourseAuthoringContext').useCourseAuthoringContext(),
|
||||
courseId: 5,
|
||||
courseUsageKey: 'course-usage-key',
|
||||
courseUsageKey: 'block-v1:UNIX+UX1+2025_T3+type@course+block@course',
|
||||
courseDetails: { name: 'Test course' },
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
setCurrentSelection,
|
||||
}),
|
||||
}));
|
||||
|
||||
let outlineChildren = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn().mockReturnValue(courseOutlineIndexMock.courseStructure.childInfo.children),
|
||||
useSelector: () => outlineChildren,
|
||||
}));
|
||||
|
||||
jest.mock('@src/studio-home/hooks', () => ({
|
||||
@@ -56,15 +60,31 @@ jest.mock('@src/studio-home/hooks', () => ({
|
||||
}));
|
||||
|
||||
let currentFlow: OutlineFlow | null = null;
|
||||
let isCurrentFlowOn = false;
|
||||
let currentItemData: Partial<XBlock> | null;
|
||||
const clearSelection = jest.fn();
|
||||
const stopCurrentFlow = jest.fn();
|
||||
jest.mock('../outline-sidebar/OutlineSidebarContext', () => ({
|
||||
...jest.requireActual('../outline-sidebar/OutlineSidebarContext'),
|
||||
useOutlineSidebarContext: () => ({
|
||||
...jest.requireActual('../outline-sidebar/OutlineSidebarContext').useOutlineSidebarContext(),
|
||||
currentFlow,
|
||||
isCurrentFlowOn,
|
||||
currentItemData,
|
||||
clearSelection,
|
||||
stopCurrentFlow,
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderComponent = () => render(<AddSidebar />, { extraWrapper: OutlineSidebarProvider });
|
||||
const renderComponent = () => render(<AddSidebar />, {
|
||||
extraWrapper: ({ children }) => (
|
||||
<CourseAuthoringProvider courseId="some-course">
|
||||
<OutlineSidebarProvider>
|
||||
{children}
|
||||
</OutlineSidebarProvider>
|
||||
</CourseAuthoringProvider>
|
||||
),
|
||||
});
|
||||
const searchResult = {
|
||||
...mockResult,
|
||||
results: [
|
||||
@@ -77,10 +97,12 @@ const searchResult = {
|
||||
},
|
||||
],
|
||||
};
|
||||
let axiosMock: MockAdapter;
|
||||
|
||||
describe('AddSidebar component', () => {
|
||||
describe('AddSidebar', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
const mocks = initializeMocks();
|
||||
axiosMock = mocks.axiosMock;
|
||||
// The Meilisearch client-side API uses fetch, not Axios.
|
||||
fetchMock.mockReset();
|
||||
fetchMock.post(searchEndpoint, (_url, req) => {
|
||||
@@ -96,6 +118,7 @@ describe('AddSidebar component', () => {
|
||||
newMockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
||||
return newMockResult;
|
||||
});
|
||||
outlineChildren = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
});
|
||||
|
||||
it('renders the AddSidebar component without any errors', async () => {
|
||||
@@ -138,6 +161,8 @@ describe('AddSidebar component', () => {
|
||||
const sectionList = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const lastSection = sectionList[3];
|
||||
const lastSubsection = lastSection.childInfo.children[0];
|
||||
axiosMock.onPost(getXBlockBaseApiUrl())
|
||||
.reply(200, { locator: 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@sequential45d4d95a' });
|
||||
renderComponent();
|
||||
|
||||
// Validate handler for adding section, subsection and unit
|
||||
@@ -145,23 +170,96 @@ describe('AddSidebar component', () => {
|
||||
const subsection = await screen.findByRole('button', { name: 'Subsection' });
|
||||
const unit = await screen.findByRole('button', { name: 'Unit' });
|
||||
await user.click(section);
|
||||
expect(handleAddSection.mutateAsync).toHaveBeenCalledWith({
|
||||
expect(axiosMock.history.post[0].data).toEqual(JSON.stringify(snakeCaseKeys({
|
||||
type: 'chapter',
|
||||
parentLocator: 'course-usage-key',
|
||||
category: 'chapter',
|
||||
parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@course+block@course',
|
||||
displayName: 'Section',
|
||||
});
|
||||
})));
|
||||
await user.click(subsection);
|
||||
expect(handleAddSubsection.mutateAsync).toHaveBeenCalledWith({
|
||||
expect(axiosMock.history.post[1].data).toEqual(JSON.stringify(snakeCaseKeys({
|
||||
type: 'sequential',
|
||||
category: 'sequential',
|
||||
parentLocator: lastSection.id,
|
||||
displayName: 'Subsection',
|
||||
});
|
||||
})));
|
||||
await user.click(unit);
|
||||
expect(handleAddUnit.mutateAsync).toHaveBeenCalledWith({
|
||||
expect(axiosMock.history.post[2].data).toEqual(JSON.stringify(snakeCaseKeys({
|
||||
type: 'vertical',
|
||||
category: 'vertical',
|
||||
parentLocator: lastSubsection.id,
|
||||
displayName: 'Unit',
|
||||
})));
|
||||
});
|
||||
|
||||
it('creates parent section if required', async () => {
|
||||
const user = userEvent.setup();
|
||||
// the course is empty
|
||||
outlineChildren = [];
|
||||
const sectionId = 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@chapter123';
|
||||
axiosMock.onPost(getXBlockBaseApiUrl())
|
||||
.reply(200, { locator: sectionId });
|
||||
renderComponent();
|
||||
|
||||
const subsection = await screen.findByRole('button', { name: 'Subsection' });
|
||||
await user.click(subsection);
|
||||
// should add a section first
|
||||
expect(axiosMock.history.post[0].data).toEqual(JSON.stringify(snakeCaseKeys({
|
||||
type: 'chapter',
|
||||
category: 'chapter',
|
||||
parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@course+block@course',
|
||||
displayName: 'Section',
|
||||
})));
|
||||
// then subsection
|
||||
expect(axiosMock.history.post[1].data).toEqual(JSON.stringify(snakeCaseKeys({
|
||||
type: 'sequential',
|
||||
category: 'sequential',
|
||||
parentLocator: sectionId,
|
||||
displayName: 'Subsection',
|
||||
})));
|
||||
});
|
||||
|
||||
it('creates parent section and subsection if required', async () => {
|
||||
const user = userEvent.setup();
|
||||
// the course is empty
|
||||
outlineChildren = [];
|
||||
const sectionId = 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@chapter123';
|
||||
const subsectionId = 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@sequential234';
|
||||
const unitId = 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@vertical2133';
|
||||
const sectionBody = snakeCaseKeys({
|
||||
type: 'chapter',
|
||||
category: 'chapter',
|
||||
parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@course+block@course',
|
||||
displayName: 'Section',
|
||||
});
|
||||
const subsectionBody = snakeCaseKeys({
|
||||
type: 'sequential',
|
||||
category: 'sequential',
|
||||
parentLocator: sectionId,
|
||||
displayName: 'Subsection',
|
||||
});
|
||||
const unitBody = snakeCaseKeys({
|
||||
type: 'vertical',
|
||||
category: 'vertical',
|
||||
parentLocator: subsectionId,
|
||||
displayName: 'Unit',
|
||||
});
|
||||
axiosMock.onPost(getXBlockBaseApiUrl(), sectionBody)
|
||||
.reply(200, { locator: sectionId });
|
||||
axiosMock.onPost(getXBlockBaseApiUrl(), subsectionBody)
|
||||
.reply(200, { locator: subsectionId });
|
||||
axiosMock.onPost(getXBlockBaseApiUrl(), unitBody)
|
||||
.reply(200, { locator: unitId });
|
||||
renderComponent();
|
||||
|
||||
const unit = await screen.findByRole('button', { name: 'Unit' });
|
||||
await user.click(unit);
|
||||
// should add a section first
|
||||
expect(axiosMock.history.post[0].data).toEqual(JSON.stringify(sectionBody));
|
||||
// then subsection
|
||||
expect(axiosMock.history.post[1].data).toEqual(JSON.stringify(subsectionBody));
|
||||
// then unit
|
||||
expect(axiosMock.history.post[2].data).toEqual(JSON.stringify(unitBody));
|
||||
});
|
||||
|
||||
it('calls appropriate handlers on existing button click', async () => {
|
||||
@@ -169,6 +267,8 @@ describe('AddSidebar component', () => {
|
||||
const sectionList = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const lastSection = sectionList[3];
|
||||
const lastSubsection = lastSection.childInfo.children[0];
|
||||
axiosMock.onPost(getXBlockBaseApiUrl())
|
||||
.reply(200, { locator: 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@sequential45d4d95a' });
|
||||
renderComponent();
|
||||
// Check existing tab content
|
||||
await user.click(await screen.findByRole('tab', { name: 'Add Existing' }));
|
||||
@@ -177,28 +277,28 @@ describe('AddSidebar component', () => {
|
||||
const addBtns = await screen.findAllByRole('button', { name: 'Add' });
|
||||
// first one is unit as per mock
|
||||
await user.click(addBtns[0]);
|
||||
expect(handleAddUnit.mutateAsync).toHaveBeenCalledWith({
|
||||
expect(axiosMock.history.post[0].data).toEqual(JSON.stringify(snakeCaseKeys({
|
||||
type: 'library_v2',
|
||||
category: 'vertical',
|
||||
parentLocator: lastSubsection.id,
|
||||
libraryContentKey: searchResult.results[0].hits[0].usage_key,
|
||||
});
|
||||
})));
|
||||
// second one is subsection as per mock
|
||||
await user.click(addBtns[1]);
|
||||
expect(handleAddSubsection.mutateAsync).toHaveBeenCalledWith({
|
||||
expect(axiosMock.history.post[1].data).toEqual(JSON.stringify(snakeCaseKeys({
|
||||
type: 'library_v2',
|
||||
category: 'sequential',
|
||||
parentLocator: lastSection.id,
|
||||
libraryContentKey: searchResult.results[0].hits[1].usage_key,
|
||||
});
|
||||
})));
|
||||
// third one is section as per mock
|
||||
await user.click(addBtns[2]);
|
||||
expect(handleAddSection.mutateAsync).toHaveBeenCalledWith({
|
||||
expect(axiosMock.history.post[2].data).toEqual(JSON.stringify(snakeCaseKeys({
|
||||
type: 'library_v2',
|
||||
category: 'chapter',
|
||||
parentLocator: 'course-usage-key',
|
||||
parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@course+block@course',
|
||||
libraryContentKey: searchResult.results[0].hits[2].usage_key,
|
||||
});
|
||||
})));
|
||||
});
|
||||
|
||||
['section', 'subsection', 'unit'].forEach((category) => {
|
||||
@@ -208,11 +308,13 @@ describe('AddSidebar component', () => {
|
||||
const firstSection = sectionList[0];
|
||||
const firstSubsection = firstSection.childInfo.children[0];
|
||||
currentFlow = {
|
||||
flowType: `use-${category}` as OutlineFlowType,
|
||||
flowType: category as ContainerType,
|
||||
parentLocator: category === 'subsection' ? firstSection.id : firstSubsection.id,
|
||||
parentTitle: category === 'subsection' ? firstSection.displayName : firstSubsection.displayName!,
|
||||
grandParentLocator: category === 'unit' ? firstSection.id : undefined,
|
||||
};
|
||||
renderComponent();
|
||||
// Check existing tab content
|
||||
await user.click(await screen.findByRole('tab', { name: 'Add Existing' }));
|
||||
// Check existing tab content is rendered by default
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
|
||||
expect(fetchMock).toHaveLastFetched((_url, req) => {
|
||||
@@ -245,4 +347,37 @@ describe('AddSidebar component', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('shows alert when container cannot be added', async () => {
|
||||
const user = userEvent.setup();
|
||||
currentItemData = {
|
||||
displayName: 'Test container',
|
||||
category: 'chapter',
|
||||
actions: {
|
||||
childAddable: false,
|
||||
deletable: true,
|
||||
draggable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
};
|
||||
renderComponent();
|
||||
|
||||
// render existing tab as well
|
||||
await user.click(await screen.findByRole('tab', { name: 'Add Existing' }));
|
||||
// One in new tab and one in existing tab
|
||||
expect((await screen.findAllByText(
|
||||
`${currentItemData.displayName} is a library section. Content cannot be added to Library referenced sections.`,
|
||||
)).length).toEqual(2);
|
||||
});
|
||||
|
||||
it('back button is rendered and works', async () => {
|
||||
const user = userEvent.setup();
|
||||
isCurrentFlowOn = true;
|
||||
renderComponent();
|
||||
|
||||
const back = await screen.findByRole('button', { name: 'Back' });
|
||||
await user.click(back);
|
||||
expect(clearSelection).toHaveBeenCalled();
|
||||
expect(stopCurrentFlow).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { SchoolOutline } from '@openedx/paragon/icons';
|
||||
import { InfoOutline, SchoolOutline } from '@openedx/paragon/icons';
|
||||
|
||||
import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar';
|
||||
|
||||
@@ -10,81 +10,129 @@ import {
|
||||
Button, Icon, Stack, Tab, Tabs,
|
||||
} from '@openedx/paragon';
|
||||
import { getIconBorderStyleColor, getItemIcon } from '@src/generic/block-type-utils';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getSectionsList } from '@src/course-outline/data/selectors';
|
||||
import {
|
||||
useCallback, useEffect, useMemo, useState,
|
||||
} from 'react';
|
||||
import { ComponentSelectedEvent } from '@src/library-authoring/common/context/ComponentPickerContext';
|
||||
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
import type { XBlock } from '@src/data/types';
|
||||
import { ContainerType, normalizeContainerType } from '@src/generic/key-utils';
|
||||
import { ContentType } from '@src/library-authoring/routes';
|
||||
import { ComponentPicker } from '@src/library-authoring';
|
||||
import { MultiLibraryProvider } from '@src/library-authoring/common/context/MultiLibraryContext';
|
||||
import { COURSE_BLOCK_NAMES } from '@src/constants';
|
||||
import messages from './messages';
|
||||
import AlertMessage from '@src/generic/alert-message';
|
||||
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import { useOutlineSidebarContext } from './OutlineSidebarContext';
|
||||
import messages from './messages';
|
||||
|
||||
type ContainerTypes = 'unit' | 'subsection' | 'section';
|
||||
const CannotAddContentAlert = () => {
|
||||
const intl = useIntl();
|
||||
const { currentItemData } = useOutlineSidebarContext();
|
||||
return (
|
||||
<AlertMessage
|
||||
variant="info"
|
||||
description={intl.formatMessage(messages.cannotAddAlertMsg, {
|
||||
name: currentItemData?.displayName,
|
||||
category: normalizeContainerType(currentItemData?.category || ''),
|
||||
})}
|
||||
icon={InfoOutline}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type AddContentButtonProps = {
|
||||
name: string,
|
||||
blockType: ContainerTypes,
|
||||
};
|
||||
|
||||
const getLastEditableParent = (blockList: Array<XBlock>) => {
|
||||
let index = 1;
|
||||
let lastBlock: XBlock;
|
||||
while (index <= blockList.length) {
|
||||
lastBlock = blockList[blockList.length - index];
|
||||
if (lastBlock.actions.childAddable) {
|
||||
return lastBlock;
|
||||
}
|
||||
index++;
|
||||
}
|
||||
return undefined;
|
||||
blockType: ContainerType,
|
||||
};
|
||||
|
||||
/** Add Content Button */
|
||||
const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
|
||||
const sectionsList = useSelector(getSectionsList);
|
||||
const lastSection = getLastEditableParent(sectionsList);
|
||||
const lastSubsection = getLastEditableParent(lastSection?.childInfo.children || []);
|
||||
const { currentFlow, stopCurrentFlow } = useOutlineSidebarContext();
|
||||
const sectionParentId = currentFlow?.parentLocator || lastSection?.id;
|
||||
const subsectionParentId = currentFlow?.parentLocator || lastSubsection?.id;
|
||||
const {
|
||||
courseUsageKey,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
handleAddAndOpenUnit,
|
||||
} = useCourseAuthoringContext();
|
||||
const {
|
||||
currentFlow,
|
||||
stopCurrentFlow,
|
||||
lastEditableSection,
|
||||
lastEditableSubsection,
|
||||
openContainerInfoSidebar,
|
||||
} = useOutlineSidebarContext();
|
||||
let sectionParentId = lastEditableSection?.id;
|
||||
let subsectionParentId = lastEditableSubsection?.data?.id;
|
||||
|
||||
const addSection = (onSuccess?: (data: { locator: string; }) => void) => {
|
||||
handleAddSection.mutate({
|
||||
type: ContainerType.Chapter,
|
||||
parentLocator: courseUsageKey,
|
||||
displayName: COURSE_BLOCK_NAMES.chapter.name,
|
||||
}, {
|
||||
onSuccess: (data: { locator: string; }) => {
|
||||
// istanbul ignore next
|
||||
if (onSuccess) {
|
||||
onSuccess(data);
|
||||
} else {
|
||||
openContainerInfoSidebar(data.locator, undefined, data.locator);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const addSubsection = (sectionId: string, onSuccess?: (data: { locator: string; }) => void) => {
|
||||
handleAddSubsection.mutate({
|
||||
type: ContainerType.Sequential,
|
||||
parentLocator: sectionId,
|
||||
displayName: COURSE_BLOCK_NAMES.sequential.name,
|
||||
}, {
|
||||
onSuccess: (data: { locator: string; }) => {
|
||||
// istanbul ignore next
|
||||
if (onSuccess) {
|
||||
onSuccess(data);
|
||||
} else {
|
||||
openContainerInfoSidebar(data.locator, data.locator, sectionId);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const addUnit = (subsectionId: string, sectionId?: string) => {
|
||||
handleAddAndOpenUnit.mutate({
|
||||
type: ContainerType.Vertical,
|
||||
parentLocator: subsectionId,
|
||||
displayName: COURSE_BLOCK_NAMES.vertical.name,
|
||||
sectionId,
|
||||
});
|
||||
};
|
||||
|
||||
const onCreateContent = useCallback(async () => {
|
||||
switch (blockType) {
|
||||
case 'section':
|
||||
await handleAddSection.mutateAsync({
|
||||
type: ContainerType.Chapter,
|
||||
parentLocator: courseUsageKey,
|
||||
displayName: COURSE_BLOCK_NAMES.chapter.name,
|
||||
});
|
||||
addSection();
|
||||
break;
|
||||
case 'subsection':
|
||||
sectionParentId = currentFlow?.parentLocator || sectionParentId;
|
||||
if (sectionParentId) {
|
||||
await handleAddSubsection.mutateAsync({
|
||||
type: ContainerType.Sequential,
|
||||
parentLocator: sectionParentId,
|
||||
displayName: COURSE_BLOCK_NAMES.sequential.name,
|
||||
});
|
||||
addSubsection(sectionParentId);
|
||||
} else {
|
||||
addSection(({ locator }) => addSubsection(locator));
|
||||
}
|
||||
break;
|
||||
case 'unit':
|
||||
sectionParentId = (
|
||||
currentFlow?.grandParentLocator || lastEditableSubsection?.sectionId || sectionParentId
|
||||
);
|
||||
subsectionParentId = currentFlow?.parentLocator || subsectionParentId;
|
||||
if (subsectionParentId) {
|
||||
await handleAddUnit.mutateAsync({
|
||||
type: ContainerType.Vertical,
|
||||
parentLocator: subsectionParentId,
|
||||
displayName: COURSE_BLOCK_NAMES.vertical.name,
|
||||
addUnit(subsectionParentId, sectionParentId);
|
||||
} else if (sectionParentId) {
|
||||
addSubsection(sectionParentId, ({ locator }) => addUnit(locator));
|
||||
} else {
|
||||
addSection(({ locator: sectionId }) => {
|
||||
addSubsection(sectionId, ({ locator: subsectionId }) => {
|
||||
addUnit(subsectionId, sectionId);
|
||||
});
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -99,18 +147,21 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
|
||||
courseUsageKey,
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
handleAddAndOpenUnit,
|
||||
currentFlow,
|
||||
lastSection,
|
||||
lastSubsection,
|
||||
sectionParentId,
|
||||
subsectionParentId,
|
||||
lastEditableSubsection,
|
||||
]);
|
||||
|
||||
const disabled = handleAddSection.isPending || handleAddSubsection.isPending || handleAddAndOpenUnit.isPending;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="tertiary shadow"
|
||||
className="mx-2 justify-content-start px-4 font-weight-bold"
|
||||
onClick={onCreateContent}
|
||||
disabled={(!lastSection && blockType === 'subsection') || (!lastSubsection && blockType === 'unit')}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Stack direction="horizontal" gap={3}>
|
||||
<span className={`p-2 rounded ${getIconBorderStyleColor(blockType)}`}>
|
||||
@@ -125,50 +176,38 @@ const AddContentButton = ({ name, blockType } : AddContentButtonProps) => {
|
||||
/** Add New Content Tab Section */
|
||||
const AddNewContent = () => {
|
||||
const intl = useIntl();
|
||||
const { currentFlow } = useOutlineSidebarContext();
|
||||
const { isCurrentFlowOn, currentFlow, currentItemData } = useOutlineSidebarContext();
|
||||
const btns = useCallback(() => {
|
||||
switch (currentFlow?.flowType) {
|
||||
case 'use-section':
|
||||
return (
|
||||
<AddContentButton
|
||||
name={intl.formatMessage(contentMessages.sectionButton)}
|
||||
blockType="section"
|
||||
/>
|
||||
);
|
||||
case 'use-subsection':
|
||||
return (
|
||||
<AddContentButton
|
||||
name={intl.formatMessage(contentMessages.subsectionButton)}
|
||||
blockType="subsection"
|
||||
/>
|
||||
);
|
||||
case 'use-unit':
|
||||
return (
|
||||
<AddContentButton
|
||||
name={intl.formatMessage(contentMessages.unitButton)}
|
||||
blockType="unit"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<AddContentButton
|
||||
name={intl.formatMessage(contentMessages.sectionButton)}
|
||||
blockType="section"
|
||||
/>
|
||||
<AddContentButton
|
||||
name={intl.formatMessage(contentMessages.subsectionButton)}
|
||||
blockType="subsection"
|
||||
/>
|
||||
<AddContentButton
|
||||
name={intl.formatMessage(contentMessages.unitButton)}
|
||||
blockType="unit"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
if (currentFlow?.flowType) {
|
||||
return (
|
||||
<AddContentButton
|
||||
name={intl.formatMessage(contentMessages[`${currentFlow.flowType}Button`])}
|
||||
blockType={currentFlow.flowType}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<AddContentButton
|
||||
name={intl.formatMessage(contentMessages.sectionButton)}
|
||||
blockType={ContainerType.Section}
|
||||
/>
|
||||
<AddContentButton
|
||||
name={intl.formatMessage(contentMessages.subsectionButton)}
|
||||
blockType={ContainerType.Subsection}
|
||||
/>
|
||||
<AddContentButton
|
||||
name={intl.formatMessage(contentMessages.unitButton)}
|
||||
blockType={ContainerType.Unit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}, [currentFlow, intl]);
|
||||
|
||||
if (!isCurrentFlowOn && currentItemData && !currentItemData.actions.childAddable) {
|
||||
return <CannotAddContentAlert />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
{btns()}
|
||||
@@ -184,13 +223,18 @@ const ShowLibraryContent = () => {
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
} = useCourseAuthoringContext();
|
||||
const sectionsList: Array<XBlock> = useSelector(getSectionsList);
|
||||
const { currentFlow, stopCurrentFlow } = useOutlineSidebarContext();
|
||||
const {
|
||||
isCurrentFlowOn,
|
||||
currentFlow,
|
||||
stopCurrentFlow,
|
||||
lastEditableSection,
|
||||
lastEditableSubsection,
|
||||
selectedContainerState,
|
||||
currentItemData,
|
||||
} = useOutlineSidebarContext();
|
||||
|
||||
const lastSection = getLastEditableParent(sectionsList);
|
||||
const lastSubsection = getLastEditableParent(lastSection?.childInfo.children || []);
|
||||
const sectionParentId = currentFlow?.parentLocator || lastSection?.id;
|
||||
const subsectionParentId = currentFlow?.parentLocator || lastSubsection?.id;
|
||||
let sectionParentId = lastEditableSection?.id;
|
||||
let subsectionParentId = lastEditableSubsection?.data?.id;
|
||||
|
||||
const onComponentSelected: ComponentSelectedEvent = useCallback(async ({ usageKey, blockType }) => {
|
||||
switch (blockType) {
|
||||
@@ -203,6 +247,7 @@ const ShowLibraryContent = () => {
|
||||
});
|
||||
break;
|
||||
case 'subsection':
|
||||
sectionParentId = currentFlow?.parentLocator || sectionParentId;
|
||||
if (sectionParentId) {
|
||||
await handleAddSubsection.mutateAsync({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
@@ -213,12 +258,17 @@ const ShowLibraryContent = () => {
|
||||
}
|
||||
break;
|
||||
case 'unit':
|
||||
sectionParentId = (
|
||||
currentFlow?.grandParentLocator || lastEditableSubsection?.sectionId || sectionParentId
|
||||
);
|
||||
subsectionParentId = currentFlow?.parentLocator || subsectionParentId;
|
||||
if (subsectionParentId) {
|
||||
await handleAddUnit.mutateAsync({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: ContainerType.Vertical,
|
||||
parentLocator: subsectionParentId,
|
||||
libraryContentKey: usageKey,
|
||||
sectionId: sectionParentId,
|
||||
});
|
||||
}
|
||||
break;
|
||||
@@ -232,33 +282,35 @@ const ShowLibraryContent = () => {
|
||||
handleAddSection,
|
||||
handleAddSubsection,
|
||||
handleAddUnit,
|
||||
lastSection,
|
||||
lastSubsection,
|
||||
lastEditableSection,
|
||||
lastEditableSubsection,
|
||||
currentFlow,
|
||||
stopCurrentFlow,
|
||||
]);
|
||||
|
||||
const allowedBlocks = useMemo(() => {
|
||||
const blocks: ContainerTypes[] = ['section'];
|
||||
switch (currentFlow?.flowType) {
|
||||
case 'use-section':
|
||||
return ['section'];
|
||||
case 'use-subsection':
|
||||
return ['subsection'];
|
||||
case 'use-unit':
|
||||
return ['unit'];
|
||||
default:
|
||||
if (lastSection) { blocks.push('subsection'); }
|
||||
if (lastSubsection) { blocks.push('unit'); }
|
||||
return blocks;
|
||||
const blocks: ContainerType[] = [];
|
||||
if (currentFlow?.flowType) {
|
||||
return [currentFlow.flowType];
|
||||
}
|
||||
}, [lastSection, lastSubsection, sectionsList, currentFlow]);
|
||||
if (!selectedContainerState) { blocks.push(ContainerType.Section); }
|
||||
if (lastEditableSection) { blocks.push(ContainerType.Subsection); }
|
||||
if (lastEditableSubsection) { blocks.push(ContainerType.Unit); }
|
||||
return blocks;
|
||||
}, [lastEditableSection, lastEditableSubsection, currentFlow]);
|
||||
|
||||
if (!isCurrentFlowOn && currentItemData && !currentItemData.actions.childAddable) {
|
||||
return <CannotAddContentAlert />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MultiLibraryProvider>
|
||||
<ComponentPicker
|
||||
showOnlyPublished
|
||||
extraFilter={[`block_type IN ["${allowedBlocks.join('","')}"]`]}
|
||||
extraFilter={[
|
||||
`block_type IN ["${allowedBlocks.join('","')}"]`,
|
||||
'type = "library_container"',
|
||||
]}
|
||||
visibleTabs={[ContentType.home]}
|
||||
FiltersComponent={SidebarFilters}
|
||||
onComponentSelected={onComponentSelected}
|
||||
@@ -270,13 +322,13 @@ const ShowLibraryContent = () => {
|
||||
/** Tabs Component */
|
||||
const AddTabs = () => {
|
||||
const intl = useIntl();
|
||||
const { currentFlow } = useOutlineSidebarContext();
|
||||
const { isCurrentFlowOn } = useOutlineSidebarContext();
|
||||
const [key, setKey] = useState('addNew');
|
||||
useEffect(() => {
|
||||
if (currentFlow) {
|
||||
if (isCurrentFlowOn) {
|
||||
setKey('addExisting');
|
||||
}
|
||||
}, [currentFlow, setKey]);
|
||||
}, [isCurrentFlowOn, setKey]);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
@@ -301,23 +353,44 @@ const AddTabs = () => {
|
||||
export const AddSidebar = () => {
|
||||
const intl = useIntl();
|
||||
const { courseDetails } = useCourseAuthoringContext();
|
||||
const { currentFlow } = useOutlineSidebarContext();
|
||||
const {
|
||||
isCurrentFlowOn,
|
||||
currentFlow,
|
||||
currentItemData,
|
||||
clearSelection,
|
||||
stopCurrentFlow,
|
||||
selectedContainerState,
|
||||
} = useOutlineSidebarContext();
|
||||
const { data: flowData } = useCourseItemData(currentFlow?.parentLocator);
|
||||
const titleAndIcon = useMemo(() => {
|
||||
switch (currentFlow?.flowType) {
|
||||
case 'use-subsection':
|
||||
return { title: currentFlow.parentTitle, icon: getItemIcon('section') };
|
||||
case 'use-unit':
|
||||
return { title: currentFlow.parentTitle, icon: getItemIcon('subsection') };
|
||||
default:
|
||||
return { title: courseDetails?.name || '', icon: SchoolOutline };
|
||||
if (isCurrentFlowOn && currentFlow) {
|
||||
return { title: flowData?.displayName || '', icon: getItemIcon(flowData?.category || '') };
|
||||
}
|
||||
}, [currentFlow, intl, getItemIcon]);
|
||||
if (currentItemData) {
|
||||
return { title: currentItemData.displayName, icon: getItemIcon(currentItemData.category) };
|
||||
}
|
||||
return { title: courseDetails?.name || '', icon: SchoolOutline };
|
||||
}, [
|
||||
isCurrentFlowOn,
|
||||
flowData,
|
||||
currentFlow,
|
||||
intl,
|
||||
getItemIcon,
|
||||
currentItemData,
|
||||
courseDetails,
|
||||
]);
|
||||
|
||||
const handleBack = () => {
|
||||
clearSelection();
|
||||
stopCurrentFlow();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SidebarTitle
|
||||
title={titleAndIcon.title}
|
||||
icon={titleAndIcon.icon}
|
||||
onBackBtnClick={(selectedContainerState || isCurrentFlowOn) ? handleBack : undefined}
|
||||
/>
|
||||
<SidebarContent>
|
||||
<SidebarSection>
|
||||
|
||||
215
src/course-outline/outline-sidebar/LibraryReferenceCard.test.tsx
Normal file
215
src/course-outline/outline-sidebar/LibraryReferenceCard.test.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { getXBlockApiUrl } from '@src/course-outline/data/api';
|
||||
import { render, screen, initializeMocks } from '@src/testUtils';
|
||||
import { userEvent } from '@testing-library/user-event';
|
||||
import { LibraryReferenceCard } from './LibraryReferenceCard';
|
||||
|
||||
let axiosMock;
|
||||
const upstreamData = {
|
||||
id: 'lct:UNIX:CIT1:subsection:99d7e14e2d6f4ab7989dc0d948f917df',
|
||||
name: 'upstream subsection',
|
||||
};
|
||||
const sectionData = {
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@asafd',
|
||||
displayName: 'downstream section',
|
||||
upstreamInfo: {
|
||||
upstreamRef: 'lct:UNIX:CIT1:section:d12323',
|
||||
upstreamName: 'upstream section',
|
||||
downstreamKey: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@asafd',
|
||||
versionAvailable: 2,
|
||||
versionDeclined: null,
|
||||
readyToSync: false,
|
||||
topLevelParentKey: null,
|
||||
downstreamCustomized: [],
|
||||
},
|
||||
};
|
||||
const itemData = {
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@sequential45d4d95a',
|
||||
displayName: 'downstream subsection',
|
||||
upstreamInfo: {
|
||||
upstreamRef: upstreamData.id,
|
||||
upstreamName: upstreamData.name,
|
||||
downstreamKey: 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@sequential45d4d95a',
|
||||
versionAvailable: 2,
|
||||
versionDeclined: null,
|
||||
readyToSync: false,
|
||||
topLevelParentKey: null,
|
||||
downstreamCustomized: [],
|
||||
},
|
||||
};
|
||||
|
||||
const mockUseOutlineSidebarContext = jest.fn().mockReturnValue({
|
||||
selectedContainerState: { currentId: itemData.id, sectionId: sectionData.id },
|
||||
openContainerInfoSidebar: jest.fn(),
|
||||
});
|
||||
const mockUseCourseAuthoringContext = jest.fn().mockReturnValue({
|
||||
openUnlinkModal: jest.fn(),
|
||||
courseId: 'course1',
|
||||
});
|
||||
|
||||
jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
|
||||
useOutlineSidebarContext: () => mockUseOutlineSidebarContext(),
|
||||
}));
|
||||
jest.mock('@src/CourseAuthoringContext', () => ({
|
||||
useCourseAuthoringContext: () => mockUseCourseAuthoringContext(),
|
||||
}));
|
||||
const mockOpenSyncModal = jest.fn();
|
||||
jest.mock('@src/hooks', () => ({
|
||||
useToggleWithValue: () => [false, {}, mockOpenSyncModal, jest.fn()],
|
||||
}));
|
||||
|
||||
describe('LibraryReferenceCard', () => {
|
||||
beforeEach(() => {
|
||||
const mocks = initializeMocks();
|
||||
axiosMock = mocks.axiosMock;
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(sectionData.id))
|
||||
.reply(200, sectionData);
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(itemData.id))
|
||||
.reply(200, itemData);
|
||||
});
|
||||
|
||||
it('renders the LibraryReferenceCard normally', async () => {
|
||||
render(<LibraryReferenceCard itemId={itemData.id} />);
|
||||
expect(await screen.findByText(/Library Reference/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the LibraryReferenceCard error message', async () => {
|
||||
const user = userEvent.setup();
|
||||
const data = {
|
||||
...itemData,
|
||||
upstreamInfo: {
|
||||
...itemData.upstreamInfo,
|
||||
errorMessage: 'some error',
|
||||
},
|
||||
};
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(itemData.id))
|
||||
.reply(200, data);
|
||||
render(<LibraryReferenceCard itemId={itemData.id} />);
|
||||
expect(await screen.findByText(
|
||||
`The link between ${itemData.displayName} and the library version has been broken. To edit or make changes, unlink component.`,
|
||||
)).toBeInTheDocument();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Unlink from library' }));
|
||||
expect(mockUseCourseAuthoringContext().openUnlinkModal).toHaveBeenCalledWith({
|
||||
value: data,
|
||||
sectionId: sectionData.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the LibraryReferenceCard ready to sync', async () => {
|
||||
const user = userEvent.setup();
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(itemData.id))
|
||||
.reply(200, {
|
||||
...itemData,
|
||||
upstreamInfo: {
|
||||
...itemData.upstreamInfo,
|
||||
readyToSync: true,
|
||||
},
|
||||
});
|
||||
render(<LibraryReferenceCard itemId={itemData.id} />);
|
||||
expect(await screen.findByText(
|
||||
`${itemData.displayName} has available updates`,
|
||||
)).toBeInTheDocument();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Review Updates' }));
|
||||
expect(mockOpenSyncModal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the LibraryReferenceCard customized text', async () => {
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(itemData.id))
|
||||
.reply(200, {
|
||||
...itemData,
|
||||
upstreamInfo: {
|
||||
...itemData.upstreamInfo,
|
||||
downstreamCustomized: ['displayName'],
|
||||
},
|
||||
});
|
||||
render(<LibraryReferenceCard itemId={itemData.id} />);
|
||||
expect(await screen.findByText(
|
||||
`${itemData.displayName} has been modified in this course.`,
|
||||
)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the LibraryReferenceCard with top level error message', async () => {
|
||||
const user = userEvent.setup();
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(itemData.id))
|
||||
.reply(200, {
|
||||
...itemData,
|
||||
upstreamInfo: {
|
||||
...itemData.upstreamInfo,
|
||||
topLevelParentKey: sectionData.upstreamInfo.downstreamKey,
|
||||
errorMessage: 'some error',
|
||||
},
|
||||
});
|
||||
render(<LibraryReferenceCard itemId={itemData.id} />);
|
||||
expect(await screen.findByText(
|
||||
`${itemData.displayName} was reused as part of a section which has a broken link. To recieve library updates to this component, unlink the broken link.`,
|
||||
)).toBeInTheDocument();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Unlink section' }));
|
||||
// should call unlink with parent section data
|
||||
expect(mockUseCourseAuthoringContext().openUnlinkModal).toHaveBeenCalledWith({
|
||||
value: sectionData,
|
||||
sectionId: sectionData.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the LibraryReferenceCard with top level ready to sync', async () => {
|
||||
const user = userEvent.setup();
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(itemData.id))
|
||||
.reply(200, {
|
||||
...itemData,
|
||||
upstreamInfo: {
|
||||
...itemData.upstreamInfo,
|
||||
topLevelParentKey: sectionData.upstreamInfo.downstreamKey,
|
||||
},
|
||||
});
|
||||
const parentData = {
|
||||
...sectionData,
|
||||
upstreamInfo: {
|
||||
...sectionData.upstreamInfo,
|
||||
readyToSync: true,
|
||||
},
|
||||
};
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(sectionData.id))
|
||||
.reply(200, parentData);
|
||||
render(<LibraryReferenceCard itemId={itemData.id} />);
|
||||
expect(await screen.findByText(
|
||||
`${itemData.displayName} was reused as part of a section which has updates available.`,
|
||||
)).toBeInTheDocument();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Review Updates' }));
|
||||
expect(mockOpenSyncModal).toHaveBeenCalledWith(parentData);
|
||||
});
|
||||
|
||||
it('renders the LibraryReferenceCard with top level go to parent option', async () => {
|
||||
const user = userEvent.setup();
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(itemData.id))
|
||||
.reply(200, {
|
||||
...itemData,
|
||||
upstreamInfo: {
|
||||
...itemData.upstreamInfo,
|
||||
topLevelParentKey: sectionData.upstreamInfo.downstreamKey,
|
||||
},
|
||||
});
|
||||
render(<LibraryReferenceCard itemId={itemData.id} />);
|
||||
expect(await screen.findByText(
|
||||
`${itemData.displayName} was reused as part of a section.`,
|
||||
)).toBeInTheDocument();
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'View section' }));
|
||||
expect(mockUseOutlineSidebarContext().openContainerInfoSidebar).toHaveBeenCalledWith(
|
||||
sectionData.id,
|
||||
undefined,
|
||||
sectionData.id,
|
||||
);
|
||||
});
|
||||
});
|
||||
257
src/course-outline/outline-sidebar/LibraryReferenceCard.tsx
Normal file
257
src/course-outline/outline-sidebar/LibraryReferenceCard.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Card, Icon, Stack,
|
||||
} from '@openedx/paragon';
|
||||
import { Cached, LinkOff, Newsstand } from '@openedx/paragon/icons';
|
||||
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
|
||||
import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
|
||||
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
|
||||
import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import { ContainerType, getBlockType, normalizeContainerType } from '@src/generic/key-utils';
|
||||
import { useToggleWithValue } from '@src/hooks';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import messages from './messages';
|
||||
|
||||
interface SubProps {
|
||||
blockData: XBlock;
|
||||
displayName: string;
|
||||
openSyncModal: (val: XBlock) => void;
|
||||
}
|
||||
|
||||
const HasTopParentTextAndButton = ({ blockData, displayName, openSyncModal }: SubProps) => {
|
||||
const { upstreamInfo } = blockData;
|
||||
const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext();
|
||||
const { openUnlinkModal } = useCourseAuthoringContext();
|
||||
const { data: parentData, isPending } = useCourseItemData(upstreamInfo?.topLevelParentKey);
|
||||
|
||||
const handleUnlinkClick = () => {
|
||||
// istanbul ignore if
|
||||
if (!selectedContainerState?.sectionId || !parentData) {
|
||||
return;
|
||||
}
|
||||
openUnlinkModal({ value: parentData, sectionId: selectedContainerState.sectionId });
|
||||
};
|
||||
|
||||
const handleSyncClick = () => {
|
||||
// istanbul ignore if
|
||||
if (!parentData) {
|
||||
return;
|
||||
}
|
||||
openSyncModal(parentData);
|
||||
};
|
||||
|
||||
const handleGoToParent = () => {
|
||||
// istanbul ignore if
|
||||
if (!upstreamInfo?.topLevelParentKey) {
|
||||
return null;
|
||||
}
|
||||
const category = getBlockType(upstreamInfo.topLevelParentKey) as ContainerType;
|
||||
if ([ContainerType.Chapter, ContainerType.Section].includes(category)) {
|
||||
return openContainerInfoSidebar(
|
||||
upstreamInfo.topLevelParentKey,
|
||||
undefined,
|
||||
upstreamInfo.topLevelParentKey,
|
||||
);
|
||||
}
|
||||
// Only possible option is sequential or subsection
|
||||
return openContainerInfoSidebar(
|
||||
upstreamInfo.topLevelParentKey,
|
||||
upstreamInfo.topLevelParentKey,
|
||||
selectedContainerState?.sectionId,
|
||||
);
|
||||
};
|
||||
|
||||
if (!upstreamInfo?.topLevelParentKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messageValues = {
|
||||
parentType: normalizeContainerType(getBlockType(upstreamInfo.topLevelParentKey)),
|
||||
name: displayName,
|
||||
};
|
||||
|
||||
if (upstreamInfo.errorMessage) {
|
||||
return (
|
||||
<Stack direction="vertical" gap={2}>
|
||||
<FormattedMessage {...messages.hasTopParentBrokenLinkText} values={messageValues} />
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
iconBefore={LinkOff}
|
||||
disabled={isPending}
|
||||
onClick={handleUnlinkClick}
|
||||
>
|
||||
<FormattedMessage {...messages.hasTopParentBrokenLinkBtn} values={messageValues} />
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (parentData?.upstreamInfo?.readyToSync) {
|
||||
return (
|
||||
<Stack direction="vertical" gap={2}>
|
||||
<FormattedMessage {...messages.hasTopParentReadyToSyncText} values={messageValues} />
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
iconBefore={Cached}
|
||||
onClick={handleSyncClick}
|
||||
>
|
||||
<FormattedMessage {...messages.hasTopParentReadyToSyncBtn} values={messageValues} />
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack direction="vertical" gap={2}>
|
||||
<FormattedMessage {...messages.hasTopParentText} values={messageValues} />
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
onClick={handleGoToParent}
|
||||
>
|
||||
<FormattedMessage {...messages.hasTopParentBtn} values={messageValues} />
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const TopLevelTextAndButton = ({ blockData, displayName, openSyncModal }: SubProps) => {
|
||||
const { upstreamInfo } = blockData;
|
||||
const { selectedContainerState } = useOutlineSidebarContext();
|
||||
const { openUnlinkModal } = useCourseAuthoringContext();
|
||||
const messageValues = {
|
||||
name: displayName,
|
||||
};
|
||||
|
||||
const handleUnlinkClick = () => {
|
||||
// istanbul ignore if
|
||||
if (!selectedContainerState?.sectionId) {
|
||||
return;
|
||||
}
|
||||
openUnlinkModal({ value: blockData, sectionId: selectedContainerState.sectionId });
|
||||
};
|
||||
|
||||
const handleSyncClick = () => {
|
||||
openSyncModal(blockData);
|
||||
};
|
||||
|
||||
if (upstreamInfo?.errorMessage) {
|
||||
return (
|
||||
<Stack direction="vertical" gap={2}>
|
||||
<FormattedMessage {...messages.topParentBrokenLinkText} values={messageValues} />
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
iconBefore={LinkOff}
|
||||
onClick={handleUnlinkClick}
|
||||
>
|
||||
<FormattedMessage {...messages.topParentBrokenLinkBtn} />
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (upstreamInfo?.readyToSync) {
|
||||
return (
|
||||
<Stack direction="vertical" gap={2}>
|
||||
<FormattedMessage {...messages.topParentReaadyToSyncText} values={messageValues} />
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
iconBefore={Cached}
|
||||
onClick={handleSyncClick}
|
||||
>
|
||||
<FormattedMessage {...messages.topParentReaadyToSyncBtn} values={messageValues} />
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if ((upstreamInfo?.downstreamCustomized.length || 0) > 0) {
|
||||
return (
|
||||
<FormattedMessage {...messages.topParentModifiedText} values={messageValues} />
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
itemId?: string;
|
||||
}
|
||||
|
||||
export const LibraryReferenceCard = ({ itemId }: Props) => {
|
||||
const { data: itemData, isPending } = useCourseItemData(itemId);
|
||||
const { selectedContainerState } = useOutlineSidebarContext();
|
||||
const { courseId } = useCourseAuthoringContext();
|
||||
const [isSyncModalOpen, syncModalData, openSyncModal, closeSyncModal] = useToggleWithValue<XBlock>();
|
||||
const dispatch = useDispatch();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const blockSyncData = useMemo(() => {
|
||||
if (!syncModalData?.upstreamInfo?.readyToSync) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
displayName: syncModalData.displayName,
|
||||
downstreamBlockId: syncModalData.id,
|
||||
upstreamBlockId: syncModalData.upstreamInfo.upstreamRef,
|
||||
upstreamBlockVersionSynced: syncModalData.upstreamInfo.versionSynced,
|
||||
isReadyToSyncIndividually: syncModalData.upstreamInfo.isReadyToSyncIndividually,
|
||||
isContainer: ['vertical', 'sequential', 'chapter'].includes(syncModalData.category),
|
||||
blockType: normalizeContainerType(syncModalData.category),
|
||||
};
|
||||
}, [syncModalData]);
|
||||
|
||||
// istanbul ignore next
|
||||
const handleOnPostChangeSync = useCallback(() => {
|
||||
if (selectedContainerState?.sectionId) {
|
||||
dispatch(fetchCourseSectionQuery([selectedContainerState.sectionId]));
|
||||
}
|
||||
if (courseId) {
|
||||
invalidateLinksQuery(queryClient, courseId);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.course(courseId),
|
||||
});
|
||||
}
|
||||
}, [dispatch, selectedContainerState, queryClient, courseId]);
|
||||
|
||||
if (!itemData?.upstreamInfo?.upstreamRef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3">
|
||||
<Card isLoading={isPending} className="my-3">
|
||||
<Card.Section>
|
||||
<Stack gap={3}>
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
<Icon src={Newsstand} />
|
||||
<h4 className="mb-0"><FormattedMessage {...messages.libraryReferenceCardText} /></h4>
|
||||
</Stack>
|
||||
<TopLevelTextAndButton
|
||||
blockData={itemData}
|
||||
displayName={itemData.displayName}
|
||||
openSyncModal={openSyncModal}
|
||||
/>
|
||||
<HasTopParentTextAndButton
|
||||
blockData={itemData}
|
||||
displayName={itemData.displayName}
|
||||
openSyncModal={openSyncModal}
|
||||
/>
|
||||
</Stack>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
{blockSyncData && (
|
||||
<PreviewLibraryXBlockChanges
|
||||
blockData={blockSyncData}
|
||||
isModalOpen={isSyncModalOpen}
|
||||
closeModal={closeSyncModal}
|
||||
postChange={handleOnPostChangeSync}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { initializeMocks, render, screen } from '@src/testUtils';
|
||||
|
||||
import * as CourseAuthoringContext from '@src/CourseAuthoringContext';
|
||||
import * as CourseDetailsApi from '@src/data/apiHooks';
|
||||
@@ -17,6 +16,7 @@ jest.mock('@src/content-tags-drawer', () => ({
|
||||
|
||||
describe('OutlineAlignSidebar', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
jest
|
||||
.spyOn(CourseAuthoringContext, 'useCourseAuthoringContext')
|
||||
.mockReturnValue({
|
||||
@@ -25,8 +25,9 @@ describe('OutlineAlignSidebar', () => {
|
||||
jest
|
||||
.spyOn(OutlineSidebarContext, 'useOutlineSidebarContext')
|
||||
.mockReturnValue({
|
||||
currentContainerId:
|
||||
'block-v1:test+course+run+type@sequential+block@seq1',
|
||||
selectedContainerState: {
|
||||
currentId: 'block-v1:test+course+run+type@sequential+block@seq1',
|
||||
},
|
||||
} as any);
|
||||
jest
|
||||
.spyOn(CourseDetailsApi, 'useCourseDetails')
|
||||
@@ -54,4 +55,25 @@ describe('OutlineAlignSidebar', () => {
|
||||
'drawer-mock-block-v1:test+course+run+type@sequential+block@seq1-component',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders ContentTagsDrawer with the course name', async () => {
|
||||
jest
|
||||
.spyOn(OutlineSidebarContext, 'useOutlineSidebarContext')
|
||||
.mockReturnValue({
|
||||
selectedContainerState: undefined,
|
||||
} as any);
|
||||
jest
|
||||
.spyOn(CourseDetailsApi, 'useCourseDetails')
|
||||
.mockReturnValue({
|
||||
data: { courseDisplayNameWithDefault: 'Test Course' },
|
||||
} as any);
|
||||
jest
|
||||
.spyOn(ContentDataApi, 'useContentData')
|
||||
.mockReturnValue({
|
||||
data: { courseDisplayNameWithDefault: 'Test Course' },
|
||||
} as any);
|
||||
render(<OutlineAlignSidebar />);
|
||||
|
||||
expect(await screen.findByText('Test Course')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,23 +2,26 @@ import { SchoolOutline } from '@openedx/paragon/icons';
|
||||
import { ContentTagsDrawer } from '@src/content-tags-drawer';
|
||||
import { useContentData } from '@src/content-tags-drawer/data/apiHooks';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { useCourseDetails } from '@src/data/apiHooks';
|
||||
import { SidebarTitle } from '@src/generic/sidebar';
|
||||
import { useOutlineSidebarContext } from './OutlineSidebarContext';
|
||||
|
||||
export const OutlineAlignSidebar = () => {
|
||||
const { courseId } = useCourseAuthoringContext();
|
||||
const { currentContainerId } = useOutlineSidebarContext();
|
||||
|
||||
const sidebarContentId = currentContainerId || courseId;
|
||||
|
||||
const {
|
||||
data: courseData,
|
||||
} = useCourseDetails(courseId);
|
||||
courseId,
|
||||
currentSelection,
|
||||
setCurrentSelection,
|
||||
} = useCourseAuthoringContext();
|
||||
const { selectedContainerState, clearSelection } = useOutlineSidebarContext();
|
||||
|
||||
const {
|
||||
data: contentData,
|
||||
} = useContentData(currentContainerId);
|
||||
const sidebarContentId = currentSelection?.currentId || selectedContainerState?.currentId || courseId;
|
||||
|
||||
const { data: contentData } = useContentData(sidebarContentId);
|
||||
|
||||
// istanbul ignore next
|
||||
const handleBack = () => {
|
||||
clearSelection();
|
||||
setCurrentSelection(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -26,9 +29,10 @@ export const OutlineAlignSidebar = () => {
|
||||
title={
|
||||
contentData && 'displayName' in contentData
|
||||
? contentData.displayName
|
||||
: courseData?.name || ''
|
||||
: contentData?.courseDisplayNameWithDefault || ''
|
||||
}
|
||||
icon={SchoolOutline}
|
||||
onBackBtnClick={(sidebarContentId !== courseId) ? handleBack : undefined}
|
||||
/>
|
||||
<ContentTagsDrawer
|
||||
id={sidebarContentId}
|
||||
|
||||
@@ -14,6 +14,7 @@ import OutlineSidebar from './OutlineSidebar';
|
||||
jest.mock('@src/course-outline/data/apiHooks', () => ({
|
||||
useCourseDetails: jest.fn().mockReturnValue({ isPending: false, data: { title: 'Test Course' } }),
|
||||
useCreateCourseBlock: jest.fn(),
|
||||
useCourseItemData: jest.fn().mockReturnValue({ data: {} }),
|
||||
}));
|
||||
|
||||
const courseId = '123';
|
||||
|
||||
@@ -8,73 +8,130 @@ import {
|
||||
} from 'react';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
|
||||
import { useStateWithUrlSearchParam } from '@src/hooks';
|
||||
import { useEscapeClick, useStateWithUrlSearchParam, useToggleWithValue } from '@src/hooks';
|
||||
import { SelectionState, XBlock } from '@src/data/types';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getSectionsList } from '@src/course-outline/data/selectors';
|
||||
import { findLast, findLastIndex } from 'lodash';
|
||||
import { ContainerType } from '@src/generic/key-utils';
|
||||
import { isOutlineNewDesignEnabled } from '../utils';
|
||||
|
||||
export type OutlineSidebarPageKeys = 'help' | 'info' | 'add' | 'align';
|
||||
export type OutlineFlowType = 'use-section' | 'use-subsection' | 'use-unit' | null;
|
||||
export type OutlineFlow = {
|
||||
flowType: 'use-section';
|
||||
parentLocator?: string;
|
||||
parentTitle?: string;
|
||||
} | {
|
||||
flowType: OutlineFlowType;
|
||||
flowType: ContainerType;
|
||||
parentLocator: string;
|
||||
parentTitle: string;
|
||||
grandParentLocator?: string;
|
||||
};
|
||||
|
||||
interface OutlineSidebarContextData {
|
||||
currentPageKey: OutlineSidebarPageKeys;
|
||||
setCurrentPageKey: (pageKey: OutlineSidebarPageKeys, containerId?: string) => void;
|
||||
currentFlow: OutlineFlow | null;
|
||||
setCurrentPageKey: (pageKey: OutlineSidebarPageKeys) => void;
|
||||
isCurrentFlowOn?: boolean;
|
||||
currentFlow?: OutlineFlow;
|
||||
startCurrentFlow: (flow: OutlineFlow) => void;
|
||||
stopCurrentFlow: () => void;
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
toggle: () => void;
|
||||
selectedContainerId?: string;
|
||||
// The Id of the container used in the current sidebar page
|
||||
// The container is not necessarily selected to open a selected sidebar.
|
||||
// Example: Align sidebar
|
||||
currentContainerId?: string;
|
||||
openContainerInfoSidebar: (containerId: string) => void;
|
||||
selectedContainerState?: SelectionState;
|
||||
openContainerInfoSidebar: (containerId: string, subsectionId?: string, sectionId?: string) => void;
|
||||
clearSelection: () => void;
|
||||
/** Stores last section that allows adding subsections inside it. */
|
||||
lastEditableSection?: XBlock;
|
||||
/** Stores last subsection that allows adding units inside it and its parent sectionId */
|
||||
lastEditableSubsection?: { data?: XBlock, sectionId?: string };
|
||||
/** XBlock data of selectedContainerState.currentId */
|
||||
currentItemData?: XBlock;
|
||||
}
|
||||
|
||||
const OutlineSidebarContext = createContext<OutlineSidebarContextData | undefined>(undefined);
|
||||
|
||||
const getLastEditableItem = (blockList: Array<XBlock>) => findLast(blockList, (item) => item.actions.childAddable);
|
||||
|
||||
const getLastEditableSubsection = (
|
||||
blockList: Array<XBlock>,
|
||||
startIndex?: number,
|
||||
): { data: XBlock, sectionId: string } | undefined => {
|
||||
const lastSectionIndex = findLastIndex(blockList, (item) => item.actions.childAddable, startIndex);
|
||||
if (lastSectionIndex !== -1) {
|
||||
const lastSubsectionIndex = findLastIndex(
|
||||
blockList[lastSectionIndex].childInfo.children,
|
||||
(item) => item.actions.childAddable,
|
||||
);
|
||||
if (lastSubsectionIndex !== -1) {
|
||||
return {
|
||||
data: blockList[lastSectionIndex].childInfo.children[lastSubsectionIndex],
|
||||
sectionId: blockList[lastSectionIndex].id,
|
||||
};
|
||||
}
|
||||
if (lastSectionIndex > 0) {
|
||||
return getLastEditableSubsection(blockList, lastSectionIndex - 1);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNode }) => {
|
||||
const [currentContainerId, setCurrentContainerId] = useState<string>();
|
||||
const [currentPageKey, setCurrentPageKeyState] = useStateWithUrlSearchParam<OutlineSidebarPageKeys>(
|
||||
'info',
|
||||
'sidebar',
|
||||
(value: string) => value as OutlineSidebarPageKeys,
|
||||
(value: OutlineSidebarPageKeys) => value,
|
||||
);
|
||||
const [currentFlow, setCurrentFlow] = useState<OutlineFlow | null>(null);
|
||||
const [
|
||||
isCurrentFlowOn,
|
||||
currentFlow,
|
||||
setCurrentFlow,
|
||||
stopCurrentFlow,
|
||||
] = useToggleWithValue<OutlineFlow>();
|
||||
const [isOpen, open, , toggle] = useToggle(true);
|
||||
|
||||
const [selectedContainerId, setSelectedContainerId] = useState<string | undefined>();
|
||||
|
||||
const openContainerInfoSidebar = useCallback((containerId: string) => {
|
||||
if (isOutlineNewDesignEnabled()) {
|
||||
setSelectedContainerId(containerId);
|
||||
}
|
||||
}, [setSelectedContainerId]);
|
||||
/**
|
||||
* Use this to store the selected container's information and should always contain full ancestor info.
|
||||
* If selected container is a section, set containerId and sectionId to same value and subsectionId should
|
||||
* be undefined.
|
||||
* If selected container is a subsection, set containerId and subsectionId to same value and sectionId
|
||||
* should be set to its parent section id.
|
||||
* If selected container is an unit, set containerId as unitId, subsectionId as its parent subsection's id
|
||||
* and sectionId should be set to its top parent section's id.
|
||||
*/
|
||||
const [selectedContainerState, setSelectedContainerState] = useState<SelectionState | undefined>();
|
||||
const { setCurrentSelection } = useCourseAuthoringContext();
|
||||
|
||||
/**
|
||||
* Stops current add content flow.
|
||||
* This will cause the sidebar to switch back to its normal state and clear out any placeholder containers.
|
||||
* Set currentSelection to same as selectedContainerState whenever
|
||||
* selectedContainerState or currentPageKey changes.
|
||||
* This allows us to reset the currentSelection.
|
||||
*/
|
||||
const stopCurrentFlow = useCallback(() => {
|
||||
setCurrentFlow(null);
|
||||
}, [setCurrentFlow]);
|
||||
useEffect(() => {
|
||||
// To allow tag buttons on other cards to jump to align page and not loose its selection
|
||||
if (currentPageKey !== 'align') {
|
||||
setCurrentSelection(selectedContainerState);
|
||||
}
|
||||
}, [currentPageKey, selectedContainerState]);
|
||||
|
||||
const setCurrentPageKey = useCallback((pageKey: OutlineSidebarPageKeys, containerId?: string) => {
|
||||
const setCurrentPageKey = useCallback((pageKey: OutlineSidebarPageKeys) => {
|
||||
setCurrentPageKeyState(pageKey);
|
||||
setCurrentFlow(null);
|
||||
setCurrentContainerId(containerId);
|
||||
stopCurrentFlow();
|
||||
open();
|
||||
}, [open, setCurrentFlow]);
|
||||
}, [open, stopCurrentFlow]);
|
||||
|
||||
const openContainerInfoSidebar = useCallback((
|
||||
containerId: string,
|
||||
subsectionId?: string,
|
||||
sectionId?: string,
|
||||
) => {
|
||||
if (isOutlineNewDesignEnabled()) {
|
||||
setSelectedContainerState({ currentId: containerId, subsectionId, sectionId });
|
||||
setCurrentPageKey('info');
|
||||
}
|
||||
}, [setSelectedContainerState, setCurrentPageKey]);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedContainerState(undefined);
|
||||
}, [selectedContainerState]);
|
||||
|
||||
/**
|
||||
* Starts add content flow.
|
||||
@@ -86,45 +143,73 @@ export const OutlineSidebarProvider = ({ children }: { children?: React.ReactNod
|
||||
setCurrentFlow(flow);
|
||||
}, [setCurrentFlow, setCurrentPageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEsc = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
stopCurrentFlow();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
const { data: currentItemData } = useCourseItemData(selectedContainerState?.currentId);
|
||||
const sectionsList = useSelector(getSectionsList);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleEsc);
|
||||
};
|
||||
}, []);
|
||||
/** Stores last section that allows adding subsections inside it. */
|
||||
const lastEditableSection = useMemo(() => {
|
||||
if (currentItemData?.category === 'chapter' && currentItemData.actions.childAddable) {
|
||||
return currentItemData;
|
||||
}
|
||||
return currentItemData ? undefined : getLastEditableItem(sectionsList);
|
||||
}, [currentItemData, sectionsList]);
|
||||
|
||||
/** Stores last subsection that allows adding units inside it. */
|
||||
const lastEditableSubsection = useMemo(() => {
|
||||
if (currentItemData?.category === 'sequential' && currentItemData.actions.childAddable) {
|
||||
return { data: currentItemData, sectionId: selectedContainerState?.sectionId };
|
||||
}
|
||||
if (currentItemData?.category === 'chapter') {
|
||||
return {
|
||||
data: getLastEditableItem(currentItemData?.childInfo.children || []),
|
||||
sectionId: selectedContainerState?.currentId,
|
||||
};
|
||||
}
|
||||
return currentItemData ? undefined : getLastEditableSubsection(sectionsList);
|
||||
}, [currentItemData, sectionsList, selectedContainerState]);
|
||||
|
||||
useEscapeClick({
|
||||
onEscape: () => {
|
||||
stopCurrentFlow();
|
||||
setSelectedContainerState(undefined);
|
||||
},
|
||||
dependency: [stopCurrentFlow],
|
||||
});
|
||||
|
||||
const context = useMemo<OutlineSidebarContextData>(
|
||||
() => ({
|
||||
currentPageKey,
|
||||
setCurrentPageKey,
|
||||
isCurrentFlowOn,
|
||||
currentFlow,
|
||||
startCurrentFlow,
|
||||
stopCurrentFlow,
|
||||
isOpen,
|
||||
open,
|
||||
toggle,
|
||||
selectedContainerId,
|
||||
currentContainerId,
|
||||
selectedContainerState,
|
||||
openContainerInfoSidebar,
|
||||
clearSelection,
|
||||
lastEditableSection,
|
||||
lastEditableSubsection,
|
||||
currentItemData,
|
||||
}),
|
||||
[
|
||||
currentPageKey,
|
||||
setCurrentPageKey,
|
||||
isCurrentFlowOn,
|
||||
currentFlow,
|
||||
startCurrentFlow,
|
||||
stopCurrentFlow,
|
||||
isOpen,
|
||||
open,
|
||||
toggle,
|
||||
selectedContainerId,
|
||||
currentContainerId,
|
||||
selectedContainerState,
|
||||
openContainerInfoSidebar,
|
||||
clearSelection,
|
||||
lastEditableSection,
|
||||
lastEditableSubsection,
|
||||
currentItemData,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { SidebarPage } from '@src/generic/sidebar';
|
||||
import { AddSidebar } from './AddSidebar';
|
||||
import { OutlineAlignSidebar } from './OutlineAlignSidebar';
|
||||
import OutlineHelpSidebar from './OutlineHelpSidebar';
|
||||
import { OutlineInfoSidebar } from './OutlineInfoSidebar';
|
||||
import { InfoSidebar } from './info-sidebar/InfoSidebar';
|
||||
import messages from './messages';
|
||||
|
||||
export type OutlineSidebarPages = {
|
||||
@@ -19,9 +19,9 @@ export type OutlineSidebarPages = {
|
||||
align?: SidebarPage;
|
||||
};
|
||||
|
||||
const getOutlineSidebarPages = () => ({
|
||||
export const getOutlineSidebarPages = () => ({
|
||||
info: {
|
||||
component: OutlineInfoSidebar,
|
||||
component: InfoSidebar,
|
||||
icon: Info,
|
||||
title: messages.sidebarButtonInfo,
|
||||
},
|
||||
@@ -55,9 +55,9 @@ const getOutlineSidebarPages = () => ({
|
||||
* export function CourseOutlineSidebarWrapper(
|
||||
* { component, pluginProps }: { component: React.ReactNode, pluginProps: CourseOutlineAspectsPageProps },
|
||||
* ) {
|
||||
* const sidebarPages = useOutlineSidebarPagesContext();
|
||||
*
|
||||
* const AnalyticsPage = React.useCallback(() => <CourseOutlineAspectsPage {...pluginProps} />, [pluginProps]);
|
||||
* const sidebarPages = useOutlineSidebarPagesContext();
|
||||
*
|
||||
* const overridedPages = useMemo(() => ({
|
||||
* ...sidebarPages,
|
||||
@@ -72,7 +72,6 @@ const getOutlineSidebarPages = () => ({
|
||||
* <OutlineSidebarPagesContext.Provider value={overridedPages}>
|
||||
* {component}
|
||||
* </OutlineSidebarPagesContext.Provider>
|
||||
* );
|
||||
*}
|
||||
*/
|
||||
export const OutlineSidebarPagesContext = createContext<OutlineSidebarPages | undefined>(undefined);
|
||||
@@ -82,6 +81,8 @@ type OutlineSidebarPagesProviderProps = {
|
||||
};
|
||||
|
||||
export const OutlineSidebarPagesProvider = ({ children }: OutlineSidebarPagesProviderProps) => {
|
||||
// align page is sometimes not added when getOutlineSidebarPages() is called at the top level.
|
||||
// So if we call it inside the hook, getConfig has updated values and align page is added.
|
||||
const sidebarPages = useMemo(getOutlineSidebarPages, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -8,11 +8,11 @@ import { useGetBlockTypes } from '@src/search-manager';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
|
||||
import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar';
|
||||
import { useCourseDetails } from '../data/apiHooks';
|
||||
|
||||
import messages from './messages';
|
||||
import { useCourseDetails } from '@src/course-outline/data/apiHooks';
|
||||
import messages from '../messages';
|
||||
|
||||
export const OutlineInfoSidebar = () => {
|
||||
export const CourseInfoSidebar = () => {
|
||||
const intl = useIntl();
|
||||
const { courseId } = useCourseAuthoringContext();
|
||||
const { data: courseDetails } = useCourseDetails(courseId);
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
import { SchoolOutline, Tag } from '@openedx/paragon/icons';
|
||||
import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer';
|
||||
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import { LibraryReferenceCard } from '@src/course-outline/outline-sidebar/LibraryReferenceCard';
|
||||
import { ComponentCountSnippet, getItemIcon } from '@src/generic/block-type-utils';
|
||||
import { normalizeContainerType } from '@src/generic/key-utils';
|
||||
import { SidebarContent, SidebarSection } from '@src/generic/sidebar';
|
||||
import { useGetBlockTypes } from '@src/search-manager';
|
||||
import messages from '../messages';
|
||||
|
||||
interface Props {
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
export const InfoSection = ({ itemId }: Props) => {
|
||||
const intl = useIntl();
|
||||
const { data: itemData } = useCourseItemData(itemId);
|
||||
const { data: componentData } = useGetBlockTypes(
|
||||
[`breadcrumbs.usage_key = "${itemId}"`],
|
||||
);
|
||||
const category = normalizeContainerType(itemData?.category || '');
|
||||
|
||||
const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<LibraryReferenceCard itemId={itemId} />
|
||||
<SidebarContent>
|
||||
<SidebarSection
|
||||
title={intl.formatMessage(messages[`${category}ContentSummaryText`])}
|
||||
icon={getItemIcon(itemData?.category || SchoolOutline)}
|
||||
>
|
||||
{componentData && <ComponentCountSnippet componentData={componentData} />}
|
||||
</SidebarSection>
|
||||
<SidebarSection
|
||||
title={intl.formatMessage(messages.sidebarSectionTaxonomy)}
|
||||
icon={Tag}
|
||||
actions={[
|
||||
{
|
||||
label: intl.formatMessage(messages.sidebarSectionTaxonomyManageTags),
|
||||
onClick: openManageTagsDrawer,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ContentTagsSnippet contentId={itemId} />
|
||||
</SidebarSection>
|
||||
</SidebarContent>
|
||||
<ContentTagsDrawerSheet
|
||||
id={itemId}
|
||||
onClose={closeManageTagsDrawer}
|
||||
showSheet={isManageTagsDrawerOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import { initializeMocks, render, screen } from '@src/testUtils';
|
||||
import { SelectionState } from '@src/data/types';
|
||||
import { OutlineSidebarProvider } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
|
||||
import { getXBlockApiUrl } from '@src/course-outline/data/api';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { InfoSidebar } from './InfoSidebar';
|
||||
|
||||
let selectedContainerState: SelectionState | undefined;
|
||||
jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({
|
||||
...jest.requireActual('@src/course-outline/outline-sidebar/OutlineSidebarContext'),
|
||||
useOutlineSidebarContext: () => ({
|
||||
...jest.requireActual('@src/course-outline/outline-sidebar/OutlineSidebarContext').useOutlineSidebarContext(),
|
||||
selectedContainerState,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@src/course-outline/data/apiHooks', () => ({
|
||||
...jest.requireActual('@src/course-outline/data/apiHooks'),
|
||||
useCourseDetails: () => ({
|
||||
data: { title: 'Course name' },
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const openPublishModal = jest.fn();
|
||||
jest.mock('@src/CourseAuthoringContext', () => ({
|
||||
useCourseAuthoringContext: () => ({
|
||||
courseId: 5,
|
||||
setCurrentSelection: jest.fn(),
|
||||
openPublishModal,
|
||||
getUnitUrl: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@src/search-manager', () => ({
|
||||
useGetBlockTypes: () => ({ data: [] }),
|
||||
}));
|
||||
|
||||
const renderComponent = () => render(<InfoSidebar />, { extraWrapper: OutlineSidebarProvider });
|
||||
let axiosMock;
|
||||
|
||||
describe('InfoSidebar component', () => {
|
||||
beforeEach(() => {
|
||||
const mocks = initializeMocks();
|
||||
axiosMock = mocks.axiosMock;
|
||||
});
|
||||
|
||||
it('renders InfoSidebar with course info if selectedContainerState is undefined', async () => {
|
||||
renderComponent();
|
||||
expect(await screen.findByText('Course name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders InfoSidebar with section info', async () => {
|
||||
const user = userEvent.setup();
|
||||
selectedContainerState = {
|
||||
currentId: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@123',
|
||||
};
|
||||
const data = {
|
||||
id: selectedContainerState.currentId,
|
||||
displayName: 'section name',
|
||||
category: 'chapter',
|
||||
hasChanges: true,
|
||||
};
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(selectedContainerState.currentId))
|
||||
.reply(200, data);
|
||||
renderComponent();
|
||||
expect(await screen.findByText('section name')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Section Content Summary')).toBeInTheDocument();
|
||||
const btn = await screen.findByRole('button', { name: 'Publish Changes (Draft)' });
|
||||
expect(btn).toBeInTheDocument();
|
||||
await user.click(btn);
|
||||
expect(openPublishModal).toHaveBeenCalledWith({
|
||||
value: data,
|
||||
sectionId: data.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders InfoSidebar with subsection info', async () => {
|
||||
const user = userEvent.setup();
|
||||
selectedContainerState = {
|
||||
currentId: 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@123',
|
||||
sectionId: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@123',
|
||||
};
|
||||
const data = {
|
||||
id: selectedContainerState.currentId,
|
||||
displayName: 'subsection name',
|
||||
category: 'sequential',
|
||||
hasChanges: true,
|
||||
};
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(selectedContainerState.currentId))
|
||||
.reply(200, data);
|
||||
renderComponent();
|
||||
expect(await screen.findByText('subsection name')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Subsection Content Summary')).toBeInTheDocument();
|
||||
const btn = await screen.findByRole('button', { name: 'Publish Changes (Draft)' });
|
||||
expect(btn).toBeInTheDocument();
|
||||
await user.click(btn);
|
||||
expect(openPublishModal).toHaveBeenCalledWith({
|
||||
value: data,
|
||||
sectionId: selectedContainerState.sectionId,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders InfoSidebar with unit info', async () => {
|
||||
const user = userEvent.setup();
|
||||
selectedContainerState = {
|
||||
currentId: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@123',
|
||||
subsectionId: 'block-v1:UNIX+UX1+2025_T3+type@sequential+block@123',
|
||||
sectionId: 'block-v1:UNIX+UX1+2025_T3+type@chapter+block@123',
|
||||
};
|
||||
const data = {
|
||||
id: selectedContainerState.currentId,
|
||||
displayName: 'unit name',
|
||||
category: 'vertical',
|
||||
hasChanges: true,
|
||||
};
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(selectedContainerState.currentId))
|
||||
.reply(200, data);
|
||||
renderComponent();
|
||||
expect(await screen.findByText('unit name')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Unit Content Summary')).toBeInTheDocument();
|
||||
const btn = await screen.findByRole('button', { name: 'Publish Changes (Draft)' });
|
||||
expect(btn).toBeInTheDocument();
|
||||
await user.click(btn);
|
||||
expect(openPublishModal).toHaveBeenCalledWith({
|
||||
value: data,
|
||||
subsectionId: selectedContainerState.subsectionId,
|
||||
sectionId: selectedContainerState.sectionId,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ContainerType, getBlockType } from '@src/generic/key-utils';
|
||||
import { useOutlineSidebarContext } from '../OutlineSidebarContext';
|
||||
import { CourseInfoSidebar } from './CourseInfoSidebar';
|
||||
import { SectionSidebar } from './SectionInfoSidebar';
|
||||
import { SubsectionSidebar } from './SubsectionInfoSidebar';
|
||||
import { UnitSidebar } from './UnitInfoSidebar';
|
||||
|
||||
export const InfoSidebar = () => {
|
||||
const { selectedContainerState } = useOutlineSidebarContext();
|
||||
if (!selectedContainerState) {
|
||||
return (
|
||||
<CourseInfoSidebar />
|
||||
);
|
||||
}
|
||||
const itemType = getBlockType(selectedContainerState.currentId);
|
||||
|
||||
switch (itemType) {
|
||||
case ContainerType.Chapter:
|
||||
case ContainerType.Section:
|
||||
return <SectionSidebar sectionId={selectedContainerState.currentId} />;
|
||||
case ContainerType.Sequential:
|
||||
case ContainerType.Subsection:
|
||||
return <SubsectionSidebar subsectionId={selectedContainerState.currentId} />;
|
||||
case ContainerType.Vertical:
|
||||
case ContainerType.Unit:
|
||||
return <UnitSidebar unitId={selectedContainerState.currentId} />;
|
||||
default:
|
||||
return <CourseInfoSidebar />;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import messages from '../messages';
|
||||
|
||||
interface Props {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const PublishButon = ({ onClick }: Props) => (
|
||||
<Button
|
||||
variant="outline-primary w-100 rounded status-button draft-status"
|
||||
className="m-1"
|
||||
onClick={onClick}
|
||||
>
|
||||
<strong className="mr-1">
|
||||
<FormattedMessage
|
||||
{...messages.publishContainerButton}
|
||||
/>
|
||||
</strong>
|
||||
<FormattedMessage {...messages.draftText} />
|
||||
</Button>
|
||||
);
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Tab, Tabs } from '@openedx/paragon';
|
||||
|
||||
import { getItemIcon } from '@src/generic/block-type-utils';
|
||||
|
||||
import { SidebarTitle } from '@src/generic/sidebar';
|
||||
|
||||
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import Loading from '@src/generic/Loading';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
|
||||
import { InfoSection } from './InfoSection';
|
||||
import messages from '../messages';
|
||||
import { PublishButon } from './PublishButon';
|
||||
|
||||
interface Props {
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
export const SectionSidebar = ({ sectionId }: Props) => {
|
||||
const intl = useIntl();
|
||||
const [tab, setTab] = useState<'info' | 'settings'>('info');
|
||||
const { data: sectionData, isLoading } = useCourseItemData(sectionId);
|
||||
const { openPublishModal } = useCourseAuthoringContext();
|
||||
const { clearSelection } = useOutlineSidebarContext();
|
||||
|
||||
const handlePublish = () => {
|
||||
if (sectionData?.hasChanges) {
|
||||
openPublishModal({
|
||||
value: sectionData,
|
||||
sectionId: sectionData.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarTitle
|
||||
title={sectionData?.displayName || ''}
|
||||
icon={getItemIcon(sectionData?.category || '')}
|
||||
onBackBtnClick={clearSelection}
|
||||
/>
|
||||
{sectionData?.hasChanges && <PublishButon onClick={handlePublish} />}
|
||||
<Tabs
|
||||
variant="tabs"
|
||||
className="my-2 d-flex justify-content-around"
|
||||
id="add-content-tabs"
|
||||
activeKey={tab}
|
||||
onSelect={setTab}
|
||||
mountOnEnter
|
||||
>
|
||||
<Tab eventKey="info" title={intl.formatMessage(messages.infoTabText)}>
|
||||
<InfoSection itemId={sectionId} />
|
||||
</Tab>
|
||||
<Tab eventKey="settings" title={intl.formatMessage(messages.settingsTabText)}>
|
||||
<div>Settings</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Tab, Tabs } from '@openedx/paragon';
|
||||
|
||||
import { getItemIcon } from '@src/generic/block-type-utils';
|
||||
|
||||
import { SidebarTitle } from '@src/generic/sidebar';
|
||||
|
||||
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import Loading from '@src/generic/Loading';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
|
||||
import { InfoSection } from './InfoSection';
|
||||
import { PublishButon } from './PublishButon';
|
||||
import messages from '../messages';
|
||||
|
||||
interface Props {
|
||||
subsectionId: string;
|
||||
}
|
||||
|
||||
export const SubsectionSidebar = ({ subsectionId }: Props) => {
|
||||
const intl = useIntl();
|
||||
const [tab, setTab] = useState<'info' | 'settings'>('info');
|
||||
const { data: subsectionData, isLoading } = useCourseItemData(subsectionId);
|
||||
const { selectedContainerState } = useOutlineSidebarContext();
|
||||
const { openPublishModal } = useCourseAuthoringContext();
|
||||
const { clearSelection } = useOutlineSidebarContext();
|
||||
|
||||
const handlePublish = () => {
|
||||
if (selectedContainerState?.sectionId && subsectionData?.hasChanges) {
|
||||
openPublishModal({
|
||||
value: subsectionData,
|
||||
sectionId: selectedContainerState?.sectionId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarTitle
|
||||
title={subsectionData?.displayName || ''}
|
||||
icon={getItemIcon(subsectionData?.category || '')}
|
||||
onBackBtnClick={clearSelection}
|
||||
/>
|
||||
{subsectionData?.hasChanges && <PublishButon onClick={handlePublish} />}
|
||||
<Tabs
|
||||
variant="tabs"
|
||||
className="my-2 d-flex justify-content-around"
|
||||
id="add-content-tabs"
|
||||
activeKey={tab}
|
||||
onSelect={setTab}
|
||||
mountOnEnter
|
||||
>
|
||||
<Tab eventKey="info" title={intl.formatMessage(messages.infoTabText)}>
|
||||
<InfoSection itemId={subsectionId} />
|
||||
</Tab>
|
||||
<Tab eventKey="settings" title={intl.formatMessage(messages.settingsTabText)}>
|
||||
<div>Settings</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button, Stack, Tab, Tabs,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
OpenInFull,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import { getItemIcon } from '@src/generic/block-type-utils';
|
||||
|
||||
import { SidebarTitle } from '@src/generic/sidebar';
|
||||
|
||||
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import Loading from '@src/generic/Loading';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import XBlockContainerIframe from '@src/course-unit/xblock-container-iframe';
|
||||
import { IframeProvider } from '@src/generic/hooks/context/iFrameContext';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useOutlineSidebarContext } from '../OutlineSidebarContext';
|
||||
import { PublishButon } from './PublishButon';
|
||||
import messages from '../messages';
|
||||
import { InfoSection } from './InfoSection';
|
||||
|
||||
interface Props {
|
||||
unitId: string;
|
||||
}
|
||||
|
||||
export const UnitSidebar = ({ unitId }: Props) => {
|
||||
const intl = useIntl();
|
||||
const [tab, setTab] = useState<'preview' | 'info' | 'settings'>('info');
|
||||
const { data: unitData, isLoading } = useCourseItemData(unitId);
|
||||
const { selectedContainerState, clearSelection } = useOutlineSidebarContext();
|
||||
const { openPublishModal, getUnitUrl, courseId } = useCourseAuthoringContext();
|
||||
|
||||
const handlePublish = () => {
|
||||
if (unitData?.hasChanges) {
|
||||
openPublishModal({
|
||||
value: unitData,
|
||||
sectionId: selectedContainerState?.sectionId,
|
||||
subsectionId: selectedContainerState?.subsectionId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarTitle
|
||||
title={unitData?.displayName || ''}
|
||||
icon={getItemIcon(unitData?.category || '')}
|
||||
onBackBtnClick={clearSelection}
|
||||
/>
|
||||
<Stack direction="horizontal" gap={1} className="mx-2">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
as={Link}
|
||||
to={getUnitUrl(unitId)}
|
||||
iconBefore={OpenInFull}
|
||||
block={!unitData?.hasChanges}
|
||||
>
|
||||
{intl.formatMessage(messages.openUnitPage)}
|
||||
</Button>
|
||||
{unitData?.hasChanges && (
|
||||
<PublishButon onClick={handlePublish} />
|
||||
)}
|
||||
</Stack>
|
||||
<Tabs
|
||||
variant="tabs"
|
||||
className="my-2 d-flex justify-content-around"
|
||||
id="add-content-tabs"
|
||||
activeKey={tab}
|
||||
onSelect={setTab}
|
||||
mountOnEnter
|
||||
>
|
||||
<Tab
|
||||
eventKey="preview"
|
||||
title={intl.formatMessage(messages.previewTabText)}
|
||||
// To make sure that data is fresh
|
||||
unmountOnExit
|
||||
>
|
||||
<IframeProvider>
|
||||
<XBlockContainerIframe
|
||||
courseId={courseId}
|
||||
blockId={unitId}
|
||||
isUnitVerticalType={false}
|
||||
unitXBlockActions={{ handleDelete: () => {}, handleDuplicate: () => {}, handleUnlink: () => {} }}
|
||||
courseVerticalChildren={[]}
|
||||
handleConfigureSubmit={() => {}}
|
||||
readonly
|
||||
/>
|
||||
</IframeProvider>
|
||||
</Tab>
|
||||
<Tab eventKey="info" title={intl.formatMessage(messages.infoTabText)}>
|
||||
<InfoSection itemId={unitId} />
|
||||
</Tab>
|
||||
<Tab eventKey="settings" title={intl.formatMessage(messages.settingsTabText)}>
|
||||
<div>Settings</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -125,6 +125,116 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Adding unit to {name}',
|
||||
description: 'Tab title for adding existing library unit to a specific parent in outline using sidebar',
|
||||
},
|
||||
sectionContentSummaryText: {
|
||||
id: 'course-authoring.course-outline.sidebar.section.content-summary-text',
|
||||
defaultMessage: 'Section Content Summary',
|
||||
description: 'Title of the summary section in the section info sidebar',
|
||||
},
|
||||
subsectionContentSummaryText: {
|
||||
id: 'course-authoring.course-outline.sidebar.subsection.content-summary-text',
|
||||
defaultMessage: 'Subsection Content Summary',
|
||||
description: 'Title of the summary section in the subsection info sidebar',
|
||||
},
|
||||
unitContentSummaryText: {
|
||||
id: 'course-authoring.course-outline.sidebar.unit.content-summary-text',
|
||||
defaultMessage: 'Unit Content Summary',
|
||||
description: 'Title of the summary section in the unit info sidebar',
|
||||
},
|
||||
openUnitPage: {
|
||||
id: 'course-authoring.course-outline.sidebar.unit.open-btn-text',
|
||||
defaultMessage: 'Open',
|
||||
description: 'Button to open unit page from sidebar',
|
||||
},
|
||||
publishContainerButton: {
|
||||
id: 'course-authoring.course-outline.sidebar.generic.publish.button',
|
||||
defaultMessage: 'Publish Changes',
|
||||
description: 'Publish button text',
|
||||
},
|
||||
draftText: {
|
||||
id: 'course-authoring.course-outline.sidebar.generic.draft.button',
|
||||
defaultMessage: '(Draft)',
|
||||
description: 'Draft text in publish button',
|
||||
},
|
||||
previewTabText: {
|
||||
id: 'course-authoring.course-outline.sidebar.generic.preview.tab.text',
|
||||
defaultMessage: 'Preview',
|
||||
description: 'Preview tab title in container sidebar',
|
||||
},
|
||||
infoTabText: {
|
||||
id: 'course-authoring.course-outline.sidebar.generic.info.tab.text',
|
||||
defaultMessage: 'Details',
|
||||
description: 'Information tab title in container sidebar',
|
||||
},
|
||||
settingsTabText: {
|
||||
id: 'course-authoring.course-outline.sidebar.generic.info.settings.text',
|
||||
defaultMessage: 'Settings',
|
||||
description: 'Settings tab title in container sidebar',
|
||||
},
|
||||
libraryReferenceCardText: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.text',
|
||||
defaultMessage: 'Library Reference',
|
||||
description: 'Library reference card text in sidebar',
|
||||
},
|
||||
hasTopParentText: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-text',
|
||||
defaultMessage: '{name} was reused as part of a {parentType}.',
|
||||
description: 'Text displayed in sidebar library reference card when a block was reused as part of a parent block',
|
||||
},
|
||||
hasTopParentBtn: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-btn',
|
||||
defaultMessage: 'View {parentType}',
|
||||
description: 'Text displayed in sidebar library reference card button when a block was reused as part of a parent block',
|
||||
},
|
||||
hasTopParentReadyToSyncText: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-sync-text',
|
||||
defaultMessage: '{name} was reused as part of a {parentType} which has updates available.',
|
||||
description: 'Text displayed in sidebar library reference card when a block has updates available as it was reused as part of a parent block',
|
||||
},
|
||||
hasTopParentReadyToSyncBtn: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-sync-btn',
|
||||
defaultMessage: 'Review Updates',
|
||||
description: 'Text displayed in sidebar library reference card button when a block has updates available as it was reused as part of a parent block',
|
||||
},
|
||||
hasTopParentBrokenLinkText: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-broken-link-text',
|
||||
defaultMessage: '{name} was reused as part of a {parentType} which has a broken link. To recieve library updates to this component, unlink the broken link.',
|
||||
description: 'Text displayed in sidebar library reference card when a block was reused as part of a parent block which has a broken link.',
|
||||
},
|
||||
hasTopParentBrokenLinkBtn: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.has-top-parent-broken-link-btn',
|
||||
defaultMessage: 'Unlink {parentType}',
|
||||
description: 'Text displayed in sidebar library reference card button when a block was reused as part of a parent block which has a broken link.',
|
||||
},
|
||||
topParentBrokenLinkText: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-broken-link-text',
|
||||
defaultMessage: 'The link between {name} and the library version has been broken. To edit or make changes, unlink component.',
|
||||
description: 'Text displayed in sidebar library reference card when a block has a broken link.',
|
||||
},
|
||||
topParentBrokenLinkBtn: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-broken-link-btn',
|
||||
defaultMessage: 'Unlink from library',
|
||||
description: 'Text displayed in sidebar library reference card button when a block has a broken link.',
|
||||
},
|
||||
topParentModifiedText: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-modified-text',
|
||||
defaultMessage: '{name} has been modified in this course.',
|
||||
description: 'Text displayed in sidebar library reference card when it is modified in course.',
|
||||
},
|
||||
topParentReaadyToSyncText: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-ready-to-sync-text',
|
||||
defaultMessage: '{name} has available updates',
|
||||
description: 'Text displayed in sidebar library reference card when it is has updates available.',
|
||||
},
|
||||
topParentReaadyToSyncBtn: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.card.top-parent-ready-to-sync-btn',
|
||||
defaultMessage: 'Review Updates',
|
||||
description: 'Text displayed in sidebar library reference card button when it is has updates available.',
|
||||
},
|
||||
cannotAddAlertMsg: {
|
||||
id: 'course-authoring.course-outline.sidebar.library.reference.add-sidebar.alert.text',
|
||||
defaultMessage: '{name} is a library {category}. Content cannot be added to Library referenced {category}s.',
|
||||
description: 'Alert displayed in sidebar when author tries to add content in library referenced blocks',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
/* eslint-disable import/named */
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ModalDialog,
|
||||
Button,
|
||||
ActionRow,
|
||||
} from '@openedx/paragon';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { getCurrentItem } from '../data/selectors';
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
import messages from './messages';
|
||||
|
||||
const PublishModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onPublishSubmit,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { displayName, childInfo, category } = useSelector(getCurrentItem);
|
||||
const categoryName = COURSE_BLOCK_NAMES[category]?.name.toLowerCase();
|
||||
const children = childInfo?.children || [];
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
className="publish-modal"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
hasCloseButton
|
||||
isFullscreenOnMobile
|
||||
isOverflowVisible={false}
|
||||
>
|
||||
<ModalDialog.Header className="publish-modal__header">
|
||||
<ModalDialog.Title>
|
||||
{intl.formatMessage(messages.title, { title: displayName })}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
<p className="small">
|
||||
{intl.formatMessage(messages.description, { category: categoryName })}
|
||||
</p>
|
||||
{children.filter(child => child.hasChanges).map((child) => {
|
||||
let grandChildren = child.childInfo?.children || [];
|
||||
grandChildren = grandChildren.filter(grandChild => grandChild.hasChanges);
|
||||
|
||||
return grandChildren.length ? (
|
||||
<React.Fragment key={child.id}>
|
||||
<span className="small text-gray-400">{child.displayName}</span>
|
||||
{grandChildren.map((grandChild) => (
|
||||
<div
|
||||
key={grandChild.id}
|
||||
className="small border border-light-400 p-2 publish-modal__subsection"
|
||||
>
|
||||
{grandChild.displayName}
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<div
|
||||
key={child.id}
|
||||
className="small border border-light-400 p-2 publish-modal__subsection"
|
||||
>
|
||||
{child.displayName}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer className="pt-1">
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
{intl.formatMessage(messages.cancelButton)}
|
||||
</ModalDialog.CloseButton>
|
||||
<Button
|
||||
data-testid="publish-confirm-button"
|
||||
onClick={onPublishSubmit}
|
||||
>
|
||||
{intl.formatMessage(messages.publishButton)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</ModalDialog>
|
||||
);
|
||||
};
|
||||
|
||||
PublishModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onPublishSubmit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default PublishModal;
|
||||
@@ -1,131 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import PublishModal from './PublishModal';
|
||||
import messages from './messages';
|
||||
|
||||
let store;
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform/i18n'),
|
||||
useIntl: () => ({
|
||||
formatMessage: (message) => message.defaultMessage,
|
||||
}),
|
||||
}));
|
||||
|
||||
const currentItemMock = {
|
||||
displayName: 'Publish',
|
||||
childInfo: {
|
||||
displayName: 'Subsection',
|
||||
children: [
|
||||
{
|
||||
displayName: 'Subsection 1',
|
||||
id: 1,
|
||||
hasChanges: true,
|
||||
childInfo: {
|
||||
displayName: 'Unit',
|
||||
children: [
|
||||
{
|
||||
id: 11,
|
||||
displayName: 'Subsection_1 Unit 1',
|
||||
hasChanges: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Subsection 2',
|
||||
id: 2,
|
||||
hasChanges: true,
|
||||
childInfo: {
|
||||
displayName: 'Unit',
|
||||
children: [
|
||||
{
|
||||
id: 21,
|
||||
displayName: 'Subsection_2 Unit 1',
|
||||
hasChanges: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Subsection 3',
|
||||
id: 3,
|
||||
childInfo: {
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const onCloseMock = jest.fn();
|
||||
const onPublishSubmitMock = jest.fn();
|
||||
|
||||
const renderComponent = () => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<PublishModal
|
||||
isOpen
|
||||
onClose={onCloseMock}
|
||||
onPublishSubmit={onPublishSubmitMock}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
describe('<PublishModal />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
useSelector.mockReturnValue(currentItemMock);
|
||||
});
|
||||
|
||||
it('renders PublishModal component correctly', () => {
|
||||
const { getByText, getByRole, queryByText } = renderComponent();
|
||||
|
||||
expect(getByText(messages.title.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(messages.description.defaultMessage)).toBeInTheDocument();
|
||||
expect(getByText(/Subsection 1/i)).toBeInTheDocument();
|
||||
expect(getByText(/Subsection_1 Unit 1/i)).toBeInTheDocument();
|
||||
expect(getByText(/Subsection 2/i)).toBeInTheDocument();
|
||||
expect(getByText(/Subsection_2 Unit 1/i)).toBeInTheDocument();
|
||||
expect(queryByText(/Subsection 3/i)).not.toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(getByRole('button', { name: messages.publishButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls the onClose function when the cancel button is clicked', () => {
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
const cancelButton = getByRole('button', { name: messages.cancelButton.defaultMessage });
|
||||
fireEvent.click(cancelButton);
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls the onPublishSubmit function when save button is clicked', async () => {
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
const publishButton = getByRole('button', { name: messages.publishButton.defaultMessage });
|
||||
fireEvent.click(publishButton);
|
||||
expect(onPublishSubmitMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
121
src/course-outline/publish-modal/PublishModal.test.tsx
Normal file
121
src/course-outline/publish-modal/PublishModal.test.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { initializeMocks, screen, render } from '@src/testUtils';
|
||||
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import PublishModal from './PublishModal';
|
||||
import messages from './messages';
|
||||
|
||||
jest.mock('@edx/frontend-platform/i18n', () => ({
|
||||
...jest.requireActual('@edx/frontend-platform/i18n'),
|
||||
useIntl: () => ({
|
||||
formatMessage: (message) => message.defaultMessage,
|
||||
}),
|
||||
}));
|
||||
|
||||
const currentItemMock = {
|
||||
id: 'section-id-1',
|
||||
displayName: 'Publish',
|
||||
childInfo: {
|
||||
displayName: 'Subsection',
|
||||
children: [
|
||||
{
|
||||
displayName: 'Subsection 1',
|
||||
id: 1,
|
||||
hasChanges: true,
|
||||
childInfo: {
|
||||
displayName: 'Unit',
|
||||
children: [
|
||||
{
|
||||
id: 11,
|
||||
displayName: 'Subsection_1 Unit 1',
|
||||
hasChanges: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Subsection 2',
|
||||
id: 2,
|
||||
hasChanges: true,
|
||||
childInfo: {
|
||||
displayName: 'Unit',
|
||||
children: [
|
||||
{
|
||||
id: 21,
|
||||
displayName: 'Subsection_2 Unit 1',
|
||||
hasChanges: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'Subsection 3',
|
||||
id: 3,
|
||||
childInfo: {
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const onCloseMock = jest.fn();
|
||||
const onPublishSubmitMock = jest.fn();
|
||||
|
||||
jest.mock('@src/CourseAuthoringContext', () => ({
|
||||
useCourseAuthoringContext: () => ({
|
||||
courseId: 5,
|
||||
courseUsageKey: 'course-usage-key',
|
||||
isPublishModalOpen: true,
|
||||
currentPublishModalData: { value: currentItemMock },
|
||||
closePublishModal: onCloseMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@src/course-outline/data/apiHooks', () => ({
|
||||
...jest.requireActual('@src/course-outline/data/apiHooks'),
|
||||
usePublishCourseItem: () => ({
|
||||
mutateAsync: onPublishSubmitMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderComponent = () => render(
|
||||
<PublishModal />,
|
||||
);
|
||||
|
||||
describe('<PublishModal />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
it('renders PublishModal component correctly', async () => {
|
||||
renderComponent();
|
||||
|
||||
expect(await screen.findByText(messages.title.defaultMessage)).toBeInTheDocument();
|
||||
expect(await screen.findByText(messages.description.defaultMessage)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Subsection 1/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Subsection_1 Unit 1/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Subsection 2/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Subsection_2 Unit 1/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Subsection 3/i)).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: messages.publishButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls the onClose function when the cancel button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
const cancelButton = await screen.findByRole('button', { name: messages.cancelButton.defaultMessage });
|
||||
await user.click(cancelButton);
|
||||
expect(onCloseMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls the onPublishSubmit function when save button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
const publishButton = await screen.findByRole('button', { name: messages.publishButton.defaultMessage });
|
||||
await user.click(publishButton);
|
||||
expect(onPublishSubmitMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
126
src/course-outline/publish-modal/PublishModal.tsx
Normal file
126
src/course-outline/publish-modal/PublishModal.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/* eslint-disable import/named */
|
||||
import React, { useMemo } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ModalDialog,
|
||||
ActionRow,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import { courseOutlineQueryKeys, usePublishCourseItem } from '@src/course-outline/data/apiHooks';
|
||||
import type { UnitXBlock, XBlock } from '@src/data/types';
|
||||
import LoadingButton from '@src/generic/loading-button';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import messages from './messages';
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
|
||||
const PublishModal = () => {
|
||||
const intl = useIntl();
|
||||
const { isPublishModalOpen, currentPublishModalData, closePublishModal } = useCourseAuthoringContext();
|
||||
const {
|
||||
id, displayName, category,
|
||||
} = currentPublishModalData?.value || {};
|
||||
const categoryName = COURSE_BLOCK_NAMES[category || '']?.name.toLowerCase();
|
||||
const childInfo = (currentPublishModalData?.value && 'childInfo' in currentPublishModalData.value)
|
||||
? currentPublishModalData?.value.childInfo
|
||||
: undefined;
|
||||
const children: Array<XBlock | UnitXBlock> | undefined = childInfo?.children;
|
||||
const publishMutation = usePublishCourseItem();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const childrenIds = useMemo(() => children?.reduce((
|
||||
result: string[],
|
||||
current: XBlock | UnitXBlock,
|
||||
): string[] => {
|
||||
let temp = [...result];
|
||||
if ('childInfo' in current) {
|
||||
const grandChildren = current.childInfo.children.filter((child) => child.hasChanges);
|
||||
temp = [...temp, ...grandChildren.map((child) => child.id)];
|
||||
}
|
||||
if (current.hasChanges) {
|
||||
temp.push(current.id);
|
||||
}
|
||||
return temp;
|
||||
}, []), [children]);
|
||||
|
||||
const onPublishSubmit = async () => {
|
||||
if (id) {
|
||||
await publishMutation.mutateAsync({
|
||||
itemId: id,
|
||||
subsectionId: currentPublishModalData?.subsectionId,
|
||||
sectionId: currentPublishModalData?.sectionId,
|
||||
}, {
|
||||
onSettled: () => {
|
||||
closePublishModal();
|
||||
// Update query client to refresh the data of all children blocks
|
||||
childrenIds?.forEach((blockId) => {
|
||||
queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(blockId) });
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
title={intl.formatMessage(messages.title, { title: displayName })}
|
||||
className="publish-modal"
|
||||
isOpen={isPublishModalOpen}
|
||||
onClose={closePublishModal}
|
||||
hasCloseButton
|
||||
isFullscreenOnMobile
|
||||
isOverflowVisible={false}
|
||||
>
|
||||
<ModalDialog.Header className="publish-modal__header">
|
||||
<ModalDialog.Title>
|
||||
{intl.formatMessage(messages.title, { title: displayName })}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
<p className="small">
|
||||
{intl.formatMessage(messages.description, { category: categoryName })}
|
||||
</p>
|
||||
{children?.filter(child => child.hasChanges).map((child) => {
|
||||
let grandChildren = 'childInfo' in child ? child.childInfo?.children : undefined;
|
||||
grandChildren = grandChildren?.filter(grandChild => grandChild.hasChanges);
|
||||
|
||||
return grandChildren?.length ? (
|
||||
<React.Fragment key={child.id}>
|
||||
<span className="small text-gray-400">{child.displayName}</span>
|
||||
{grandChildren.map((grandChild) => (
|
||||
<div
|
||||
key={grandChild.id}
|
||||
className="small border border-light-400 p-2 publish-modal__subsection"
|
||||
>
|
||||
{grandChild.displayName}
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
) : (
|
||||
<div
|
||||
key={child.id}
|
||||
className="small border border-light-400 p-2 publish-modal__subsection"
|
||||
>
|
||||
{child.displayName}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer className="pt-1">
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
{intl.formatMessage(messages.cancelButton)}
|
||||
</ModalDialog.CloseButton>
|
||||
<LoadingButton
|
||||
data-testid="publish-confirm-button"
|
||||
onClick={onPublishSubmit}
|
||||
label={intl.formatMessage(messages.publishButton)}
|
||||
/>
|
||||
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</ModalDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublishModal;
|
||||
@@ -4,12 +4,15 @@ import {
|
||||
} from '@src/testUtils';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import { Info } from '@openedx/paragon/icons';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { getXBlockApiUrl } from '@src/course-outline/data/api';
|
||||
import { CourseInfoSidebar } from '@src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar';
|
||||
import SectionCard from './SectionCard';
|
||||
import * as OutlineSidebarContext from '../outline-sidebar/OutlineSidebarContext';
|
||||
import { OutlineInfoSidebar } from '../outline-sidebar/OutlineInfoSidebar';
|
||||
|
||||
const mockUseAcceptLibraryBlockChanges = jest.fn();
|
||||
const mockUseIgnoreLibraryBlockChanges = jest.fn();
|
||||
const setCurrentSelection = jest.fn();
|
||||
|
||||
jest.mock('@src/course-unit/data/apiHooks', () => ({
|
||||
useAcceptLibraryBlockChanges: () => ({
|
||||
@@ -25,6 +28,7 @@ jest.mock('@src/CourseAuthoringContext', () => ({
|
||||
courseId: 5,
|
||||
handleAddSubsectionFromLibrary: jest.fn(),
|
||||
handleNewSubsectionSubmit: jest.fn(),
|
||||
setCurrentSelection,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -48,9 +52,7 @@ const subsection = {
|
||||
isHeaderVisible: true,
|
||||
releasedToStudents: true,
|
||||
childInfo: {
|
||||
children: [{
|
||||
id: unit.id,
|
||||
}],
|
||||
children: [unit],
|
||||
} as any, // 'as any' because we are omitting a lot of fields from 'childInfo'
|
||||
} satisfies Partial<XBlock> as XBlock;
|
||||
|
||||
@@ -70,14 +72,7 @@ const section = {
|
||||
},
|
||||
isHeaderVisible: true,
|
||||
childInfo: {
|
||||
children: [{
|
||||
id: subsection.id,
|
||||
childInfo: {
|
||||
children: [{
|
||||
id: unit.id,
|
||||
}],
|
||||
},
|
||||
}],
|
||||
children: [subsection],
|
||||
} as any, // 'as any' because we are omitting a lot of fields from 'childInfo'
|
||||
upstreamInfo: {
|
||||
readyToSync: true,
|
||||
@@ -91,20 +86,15 @@ const section = {
|
||||
},
|
||||
} satisfies Partial<XBlock> as XBlock;
|
||||
|
||||
const onEditSectionSubmit = jest.fn();
|
||||
|
||||
const renderComponent = (props?: object, entry = '/course/:courseId') => render(
|
||||
<SectionCard
|
||||
section={section}
|
||||
index={1}
|
||||
canMoveItem={jest.fn()}
|
||||
onOrderChange={jest.fn()}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenHighlightsModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onOpenUnlinkModal={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
onEditSectionSubmit={onEditSectionSubmit}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
isSectionsExpanded
|
||||
isSelfPaced={false}
|
||||
@@ -123,10 +113,15 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
|
||||
extraWrapper: OutlineSidebarContext.OutlineSidebarProvider,
|
||||
},
|
||||
);
|
||||
let axiosMock;
|
||||
|
||||
describe('<SectionCard />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
const mocks = initializeMocks();
|
||||
axiosMock = mocks.axiosMock;
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, section);
|
||||
});
|
||||
|
||||
it('render SectionCard component correctly', () => {
|
||||
@@ -140,7 +135,8 @@ describe('<SectionCard />', () => {
|
||||
expect(card).not.toHaveClass('outline-card-selected');
|
||||
});
|
||||
|
||||
it('render SectionCard component in selected state', () => {
|
||||
it('render SectionCard component in selected state', async () => {
|
||||
const user = userEvent.setup();
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'true',
|
||||
@@ -150,16 +146,15 @@ describe('<SectionCard />', () => {
|
||||
expect(screen.getByTestId('section-card-header')).toBeInTheDocument();
|
||||
|
||||
// The card is not selected
|
||||
const card = screen.getByTestId('section-card');
|
||||
expect(card).not.toHaveClass('outline-card-selected');
|
||||
expect((await screen.findByTestId('section-card'))).not.toHaveClass('outline-card-selected');
|
||||
|
||||
// Get the <Row> that contains the card and click it to select the card
|
||||
const el = container.querySelector('div.row.mx-0') as HTMLInputElement;
|
||||
expect(el).not.toBeNull();
|
||||
fireEvent.click(el!);
|
||||
await user.click(el!);
|
||||
|
||||
// The card is selected
|
||||
expect(card).toHaveClass('outline-card-selected');
|
||||
expect(await screen.findByTestId('section-card')).toHaveClass('outline-card-selected');
|
||||
});
|
||||
|
||||
it('expands/collapses the card when the expand button is clicked', () => {
|
||||
@@ -175,24 +170,6 @@ describe('<SectionCard />', () => {
|
||||
expect(screen.queryByRole('button', { name: 'New subsection' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('title only updates if changed', async () => {
|
||||
renderComponent();
|
||||
|
||||
let editButton = await screen.findByTestId('section-edit-button');
|
||||
fireEvent.click(editButton);
|
||||
let editField = await screen.findByTestId('section-edit-field');
|
||||
fireEvent.blur(editField);
|
||||
|
||||
expect(onEditSectionSubmit).not.toHaveBeenCalled();
|
||||
|
||||
editButton = await screen.findByTestId('section-edit-button');
|
||||
fireEvent.click(editButton);
|
||||
editField = await screen.findByTestId('section-edit-field');
|
||||
fireEvent.change(editField, { target: { value: 'some random value' } });
|
||||
fireEvent.blur(editField);
|
||||
expect(onEditSectionSubmit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hides header based on isHeaderVisible flag', async () => {
|
||||
const { queryByTestId } = renderComponent({
|
||||
section: {
|
||||
@@ -204,6 +181,17 @@ describe('<SectionCard />', () => {
|
||||
});
|
||||
|
||||
it('hides add new, duplicate & delete option based on childAddable, duplicable & deletable action flag', async () => {
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, {
|
||||
...section,
|
||||
actions: {
|
||||
draggable: true,
|
||||
childAddable: false,
|
||||
deletable: false,
|
||||
duplicable: false,
|
||||
},
|
||||
});
|
||||
renderComponent({
|
||||
section: {
|
||||
...section,
|
||||
@@ -310,6 +298,7 @@ describe('<SectionCard />', () => {
|
||||
});
|
||||
|
||||
it('should open legacy manage tags', async () => {
|
||||
const user = userEvent.setup();
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
|
||||
@@ -318,22 +307,23 @@ describe('<SectionCard />', () => {
|
||||
renderComponent();
|
||||
const element = await screen.findByTestId('section-card');
|
||||
const menu = await within(element).findByTestId('section-card-header__menu-button');
|
||||
fireEvent.click(menu);
|
||||
await user.click(menu);
|
||||
|
||||
const manageTagsBtn = await within(element).findByTestId('section-card-header__menu-manage-tags-button');
|
||||
expect(manageTagsBtn).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(manageTagsBtn);
|
||||
await user.click(manageTagsBtn);
|
||||
|
||||
const drawer = await screen.findByRole('alert');
|
||||
expect(within(drawer).getByText(/manage tags/i));
|
||||
});
|
||||
|
||||
it('should open align sidebar', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockSetCurrentPageKey = jest.fn();
|
||||
|
||||
const testSidebarPage = {
|
||||
component: OutlineInfoSidebar,
|
||||
component: CourseInfoSidebar,
|
||||
icon: Info,
|
||||
title: '',
|
||||
};
|
||||
@@ -351,10 +341,11 @@ describe('<SectionCard />', () => {
|
||||
isOpen: true,
|
||||
open: jest.fn(),
|
||||
toggle: jest.fn(),
|
||||
currentFlow: null,
|
||||
currentFlow: undefined,
|
||||
startCurrentFlow: jest.fn(),
|
||||
stopCurrentFlow: jest.fn(),
|
||||
openContainerInfoSidebar: jest.fn(),
|
||||
clearSelection: jest.fn(),
|
||||
}));
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
@@ -364,15 +355,19 @@ describe('<SectionCard />', () => {
|
||||
renderComponent();
|
||||
const element = await screen.findByTestId('section-card');
|
||||
const menu = await within(element).findByTestId('section-card-header__menu-button');
|
||||
fireEvent.click(menu);
|
||||
await user.click(menu);
|
||||
|
||||
const manageTagsBtn = await within(element).findByTestId('section-card-header__menu-manage-tags-button');
|
||||
expect(manageTagsBtn).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(manageTagsBtn);
|
||||
await user.click(manageTagsBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('align', section.id);
|
||||
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('align');
|
||||
});
|
||||
expect(setCurrentSelection).toHaveBeenCalledWith({
|
||||
currentId: section.id,
|
||||
sectionId: section.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,8 +9,6 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { setCurrentItem, setCurrentSection } from '@src/course-outline/data/slice';
|
||||
import { RequestStatus, RequestStatusType } 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';
|
||||
@@ -26,6 +24,8 @@ import type { XBlock } from '@src/data/types';
|
||||
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
|
||||
import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import moment from 'moment';
|
||||
import messages from './messages';
|
||||
|
||||
interface SectionCardProps {
|
||||
@@ -34,12 +34,8 @@ interface SectionCardProps {
|
||||
isCustomRelativeDatesActive: boolean,
|
||||
children: ReactNode,
|
||||
onOpenHighlightsModal: (section: XBlock) => void,
|
||||
onOpenPublishModal: () => void,
|
||||
onOpenConfigureModal: () => void,
|
||||
onEditSectionSubmit: (itemId: string, sectionId: string, displayName: string) => void,
|
||||
savingStatus?: RequestStatusType,
|
||||
onOpenDeleteModal: () => void,
|
||||
onOpenUnlinkModal: () => void,
|
||||
onDuplicateSubmit: () => void,
|
||||
isSectionsExpanded: boolean,
|
||||
index: number,
|
||||
@@ -49,19 +45,15 @@ interface SectionCardProps {
|
||||
}
|
||||
|
||||
const SectionCard = ({
|
||||
section,
|
||||
section: initialData,
|
||||
isSelfPaced,
|
||||
isCustomRelativeDatesActive,
|
||||
children,
|
||||
index,
|
||||
canMoveItem,
|
||||
onOpenHighlightsModal,
|
||||
onOpenPublishModal,
|
||||
onOpenConfigureModal,
|
||||
onEditSectionSubmit,
|
||||
savingStatus,
|
||||
onOpenDeleteModal,
|
||||
onOpenUnlinkModal,
|
||||
onDuplicateSubmit,
|
||||
isSectionsExpanded,
|
||||
onOrderChange,
|
||||
@@ -70,12 +62,16 @@ const SectionCard = ({
|
||||
const currentRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
const { activeId, overId } = useContext(DragContext);
|
||||
const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext();
|
||||
const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext();
|
||||
const [searchParams] = useSearchParams();
|
||||
const locatorId = searchParams.get('show');
|
||||
const isScrolledToElement = locatorId === section.id;
|
||||
const { courseId } = useCourseAuthoringContext();
|
||||
const {
|
||||
courseId, openUnlinkModal, openPublishModal, setCurrentSelection,
|
||||
} = useCourseAuthoringContext();
|
||||
const queryClient = useQueryClient();
|
||||
// Set initialData state from course outline and subsequently depend on its own state
|
||||
const { data: section = initialData } = useCourseItemData(initialData.id, initialData);
|
||||
const isScrolledToElement = locatorId === section?.id;
|
||||
|
||||
// Expand the section if a search result should be shown/scrolled to
|
||||
const containsSearchResult = () => {
|
||||
@@ -103,7 +99,6 @@ const SectionCard = ({
|
||||
return false;
|
||||
};
|
||||
const [isExpanded, setIsExpanded] = useState(containsSearchResult() || isSectionsExpanded);
|
||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||
const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false);
|
||||
const namePrefix = 'section';
|
||||
|
||||
@@ -111,6 +106,15 @@ const SectionCard = ({
|
||||
setIsExpanded(isSectionsExpanded);
|
||||
}, [isSectionsExpanded]);
|
||||
|
||||
/**
|
||||
Temporary measure to keep the react-query state updated with redux state */
|
||||
useEffect(() => {
|
||||
// istanbul ignore if
|
||||
if (moment(initialData.editedOnRaw).isAfter(moment(section.editedOnRaw))) {
|
||||
queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData);
|
||||
}
|
||||
}, [initialData, section]);
|
||||
|
||||
const {
|
||||
id,
|
||||
category,
|
||||
@@ -189,18 +193,10 @@ const SectionCard = ({
|
||||
};
|
||||
|
||||
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();
|
||||
setCurrentSelection({
|
||||
currentId: section.id,
|
||||
sectionId: section.id,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenHighlightsModal = () => {
|
||||
@@ -215,12 +211,6 @@ const SectionCard = ({
|
||||
onOrderChange(index, index + 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
closeForm();
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
const titleComponent = (
|
||||
<TitleButton
|
||||
title={displayName}
|
||||
@@ -241,7 +231,7 @@ const SectionCard = ({
|
||||
|
||||
const onClickCard = useCallback((e: React.MouseEvent, preventNodeEvents: boolean) => {
|
||||
if (!preventNodeEvents || e.target === e.currentTarget) {
|
||||
openContainerInfoSidebar(section.id);
|
||||
openContainerInfoSidebar(section.id, undefined, section.id);
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [openContainerInfoSidebar]);
|
||||
@@ -268,7 +258,7 @@ const SectionCard = ({
|
||||
'section-card',
|
||||
{
|
||||
highlight: isScrolledToElement,
|
||||
'outline-card-selected': section.id === selectedContainerId,
|
||||
'outline-card-selected': section.id === selectedContainerState?.currentId,
|
||||
},
|
||||
)}
|
||||
data-testid="section-card"
|
||||
@@ -282,19 +272,17 @@ const SectionCard = ({
|
||||
status={sectionStatus}
|
||||
hasChanges={hasChanges}
|
||||
onClickMenuButton={handleClickMenuButton}
|
||||
onClickPublish={onOpenPublishModal}
|
||||
onClickPublish={/* istanbul ignore next */ () => openPublishModal({
|
||||
value: section,
|
||||
sectionId: section.id,
|
||||
})}
|
||||
onClickConfigure={onOpenConfigureModal}
|
||||
onClickEdit={openForm}
|
||||
onClickDelete={onOpenDeleteModal}
|
||||
onClickUnlink={onOpenUnlinkModal}
|
||||
onClickUnlink={() => openUnlinkModal({ value: section, sectionId: section.id })}
|
||||
onClickMoveUp={handleSectionMoveUp}
|
||||
onClickMoveDown={handleSectionMoveDown}
|
||||
onClickSync={openSyncModal}
|
||||
onClickCard={(e) => onClickCard(e, true)}
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
savingStatus={savingStatus}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
@@ -356,7 +344,6 @@ const SectionCard = ({
|
||||
onClickCard={(e) => onClickCard(e, true)}
|
||||
childType={ContainerType.Subsection}
|
||||
parentLocator={section.id}
|
||||
parentTitle={section.displayName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,6 @@ const statusBarData: CourseOutlineStatusBar = {
|
||||
highlightsEnabledForMessaging: true,
|
||||
videoSharingEnabled: true,
|
||||
videoSharingOptions: VIDEO_SHARING_OPTIONS.allOn,
|
||||
hasChanges: true,
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -20,7 +20,6 @@ const statusBarData: CourseOutlineStatusBar = {
|
||||
highlightsEnabledForMessaging: true,
|
||||
videoSharingEnabled: true,
|
||||
videoSharingOptions: VIDEO_SHARING_OPTIONS.allOn,
|
||||
hasChanges: false,
|
||||
};
|
||||
|
||||
jest.mock('@src/course-libraries/data/apiHooks', () => ({
|
||||
@@ -30,6 +29,14 @@ jest.mock('@src/course-libraries/data/apiHooks', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
let mockHasChanges = false;
|
||||
jest.mock('@src/course-outline/data/apiHooks', () => ({
|
||||
useCourseDetails: () => ({
|
||||
data: { hasChanges: mockHasChanges },
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderComponent = (props?: Partial<StatusBarProps>) => render(
|
||||
<StatusBar
|
||||
courseId={courseId}
|
||||
@@ -83,12 +90,8 @@ describe('<StatusBar />', () => {
|
||||
});
|
||||
|
||||
it('renders unpublished badge', async () => {
|
||||
renderComponent({
|
||||
statusBarData: {
|
||||
...statusBarData,
|
||||
hasChanges: true,
|
||||
},
|
||||
});
|
||||
mockHasChanges = true;
|
||||
renderComponent();
|
||||
expect(await screen.findByText('Unpublished Changes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@openedx/paragon/icons';
|
||||
import { useWaffleFlags } from '@src/data/apiHooks';
|
||||
import { useEntityLinksSummaryByDownstreamContext } from '@src/course-libraries/data/apiHooks';
|
||||
import { useCourseDetails } from '@src/course-outline/data/apiHooks';
|
||||
import messages from './messages';
|
||||
import { NotificationStatusIcon } from './NotificationStatusIcon';
|
||||
|
||||
@@ -42,17 +43,23 @@ const CourseBadge = ({ startDate, endDate }: { startDate: Moment, endDate: Momen
|
||||
}
|
||||
};
|
||||
|
||||
const UnpublishedBadgeStatus = () => (
|
||||
<Badge
|
||||
className="px-2 py-2 bg-draft-status text-gray-700 font-weight-normal"
|
||||
variant="light"
|
||||
>
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
<Icon size="xs" src={Description} />
|
||||
<FormattedMessage {...messages.unpublishedBadgeText} />
|
||||
</Stack>
|
||||
</Badge>
|
||||
);
|
||||
const UnpublishedBadgeStatus = ({ courseId }: { courseId: string }) => {
|
||||
const { data } = useCourseDetails(courseId);
|
||||
if (!data?.hasChanges) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
className="px-2 py-2 bg-draft-status text-gray-700 font-weight-normal"
|
||||
variant="light"
|
||||
>
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
<Icon size="xs" src={Description} />
|
||||
<FormattedMessage {...messages.unpublishedBadgeText} />
|
||||
</Stack>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const LibraryUpdates = ({ courseId }: { courseId: string }) => {
|
||||
const { data } = useEntityLinksSummaryByDownstreamContext(courseId);
|
||||
@@ -178,7 +185,6 @@ export const StatusBar = ({
|
||||
endDate,
|
||||
courseReleaseDate,
|
||||
checklist,
|
||||
hasChanges,
|
||||
} = statusBarData;
|
||||
|
||||
const courseReleaseDateObj = moment.utc(courseReleaseDate, 'MMM DD, YYYY [at] HH:mm UTC', true);
|
||||
@@ -192,7 +198,7 @@ export const StatusBar = ({
|
||||
return (
|
||||
<Stack direction="horizontal" gap={4}>
|
||||
<CourseBadge startDate={courseReleaseDateObj} endDate={endDateObj} />
|
||||
{hasChanges && <UnpublishedBadgeStatus />}
|
||||
<UnpublishedBadgeStatus courseId={courseId} />
|
||||
<CourseDates
|
||||
startDate={courseReleaseDateObj}
|
||||
endDate={endDateObj}
|
||||
|
||||
@@ -6,14 +6,14 @@ import {
|
||||
import { XBlock } from '@src/data/types';
|
||||
import { Info } from '@openedx/paragon/icons';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { CourseInfoSidebar } from '@src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar';
|
||||
import cardHeaderMessages from '../card-header/messages';
|
||||
import SubsectionCard from './SubsectionCard';
|
||||
import * as OutlineSidebarContext from '../outline-sidebar/OutlineSidebarContext';
|
||||
import { OutlineInfoSidebar } from '../outline-sidebar/OutlineInfoSidebar';
|
||||
|
||||
let store;
|
||||
const containerKey = 'lct:org:lib:unit:1';
|
||||
const handleOnAddUnitFromLibrary = { mutateAsync: jest.fn(), isPending: false };
|
||||
const setCurrentSelection = jest.fn();
|
||||
|
||||
const mockUseAcceptLibraryBlockChanges = jest.fn();
|
||||
const mockUseIgnoreLibraryBlockChanges = jest.fn();
|
||||
@@ -30,15 +30,16 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({
|
||||
jest.mock('@src/CourseAuthoringContext', () => ({
|
||||
useCourseAuthoringContext: () => ({
|
||||
courseId: 5,
|
||||
handleAddUnit: handleOnAddUnitFromLibrary,
|
||||
handleAddAndOpenUnit: handleOnAddUnitFromLibrary,
|
||||
handleAddSubsection: {},
|
||||
handleAddSection: {},
|
||||
setCurrentSelection,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useSelector: () => ({
|
||||
jest.mock('@src/studio-home/data/selectors', () => ({
|
||||
...jest.requireActual('@src/studio-home/data/selectors'),
|
||||
getStudioHomeData: () => ({
|
||||
librariesV2Enabled: true,
|
||||
}),
|
||||
}));
|
||||
@@ -81,9 +82,7 @@ const subsection: XBlock = {
|
||||
isHeaderVisible: true,
|
||||
releasedToStudents: true,
|
||||
childInfo: {
|
||||
children: [{
|
||||
id: unit.id,
|
||||
}],
|
||||
children: [unit],
|
||||
} as any, // 'as any' because we are omitting a lot of fields from 'childInfo'
|
||||
upstreamInfo: {
|
||||
readyToSync: true,
|
||||
@@ -105,9 +104,7 @@ const section: XBlock = {
|
||||
hasChanges: false,
|
||||
highlights: ['highlight 1', 'highlight 2'],
|
||||
childInfo: {
|
||||
children: [{
|
||||
id: subsection.id,
|
||||
}],
|
||||
children: [subsection],
|
||||
} as any, // 'as any' because we are omitting a lot of fields from 'childInfo'
|
||||
actions: {
|
||||
draggable: true,
|
||||
@@ -117,8 +114,6 @@ const section: XBlock = {
|
||||
},
|
||||
} satisfies Partial<XBlock> as XBlock;
|
||||
|
||||
const onEditSubectionSubmit = jest.fn();
|
||||
|
||||
const renderComponent = (props?: object, entry = '/course/:courseId') => render(
|
||||
<SubsectionCard
|
||||
section={section}
|
||||
@@ -127,11 +122,8 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
|
||||
isSelfPaced={false}
|
||||
getPossibleMoves={jest.fn()}
|
||||
onOrderChange={jest.fn()}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onOpenUnlinkModal={jest.fn()}
|
||||
isCustomRelativeDatesActive={false}
|
||||
onEditSubmit={onEditSubectionSubmit}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
onPasteClick={jest.fn()}
|
||||
@@ -153,8 +145,7 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
|
||||
|
||||
describe('<SubsectionCard />', () => {
|
||||
beforeEach(() => {
|
||||
const mocks = initializeMocks();
|
||||
store = mocks.reduxStore;
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
it('render SubsectionCard component correctly', () => {
|
||||
@@ -207,28 +198,11 @@ describe('<SubsectionCard />', () => {
|
||||
|
||||
const menu = await screen.findByTestId('subsection-card-header__menu');
|
||||
fireEvent.click(menu);
|
||||
const { currentSection, currentSubsection, currentItem } = store.getState().courseOutline;
|
||||
expect(currentSection).toEqual(section);
|
||||
expect(currentSubsection).toEqual(subsection);
|
||||
expect(currentItem).toEqual(subsection);
|
||||
});
|
||||
|
||||
it('title only updates if changed', async () => {
|
||||
renderComponent();
|
||||
|
||||
let editButton = await screen.findByTestId('subsection-edit-button');
|
||||
fireEvent.click(editButton);
|
||||
let editField = await screen.findByTestId('subsection-edit-field');
|
||||
fireEvent.blur(editField);
|
||||
|
||||
expect(onEditSubectionSubmit).not.toHaveBeenCalled();
|
||||
|
||||
editButton = await screen.findByTestId('subsection-edit-button');
|
||||
fireEvent.click(editButton);
|
||||
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();
|
||||
expect(setCurrentSelection).toHaveBeenCalledWith({
|
||||
currentId: subsection.id,
|
||||
subsectionId: subsection.id,
|
||||
sectionId: section.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('hides header based on isHeaderVisible flag', async () => {
|
||||
@@ -440,10 +414,11 @@ describe('<SubsectionCard />', () => {
|
||||
});
|
||||
|
||||
it('should open align sidebar', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockSetCurrentPageKey = jest.fn();
|
||||
|
||||
const testSidebarPage = {
|
||||
component: OutlineInfoSidebar,
|
||||
component: CourseInfoSidebar,
|
||||
icon: Info,
|
||||
title: '',
|
||||
};
|
||||
@@ -461,10 +436,11 @@ describe('<SubsectionCard />', () => {
|
||||
isOpen: true,
|
||||
open: jest.fn(),
|
||||
toggle: jest.fn(),
|
||||
currentFlow: null,
|
||||
currentFlow: undefined,
|
||||
startCurrentFlow: jest.fn(),
|
||||
stopCurrentFlow: jest.fn(),
|
||||
openContainerInfoSidebar: jest.fn(),
|
||||
clearSelection: jest.fn(),
|
||||
}));
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
@@ -474,15 +450,20 @@ describe('<SubsectionCard />', () => {
|
||||
renderComponent();
|
||||
const element = await screen.findByTestId('subsection-card');
|
||||
const menu = await within(element).findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menu);
|
||||
await user.click(menu);
|
||||
|
||||
const manageTagsBtn = await within(element).findByTestId('subsection-card-header__menu-manage-tags-button');
|
||||
expect(manageTagsBtn).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(manageTagsBtn);
|
||||
await user.click(manageTagsBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('align', subsection.id);
|
||||
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('align');
|
||||
});
|
||||
expect(setCurrentSelection).toHaveBeenCalledWith({
|
||||
currentId: subsection.id,
|
||||
subsectionId: subsection.id,
|
||||
sectionId: section.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,8 +10,6 @@ import classNames from 'classnames';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
import CourseOutlineSubsectionCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineSubsectionCardExtraActionsSlot';
|
||||
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice';
|
||||
import { RequestStatus, RequestStatusType } 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';
|
||||
@@ -28,6 +26,8 @@ import type { XBlock } from '@src/data/types';
|
||||
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext';
|
||||
import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import moment from 'moment';
|
||||
import messages from './messages';
|
||||
|
||||
interface SubsectionCardProps {
|
||||
@@ -37,11 +37,7 @@ interface SubsectionCardProps {
|
||||
isSectionsExpanded: boolean,
|
||||
isSelfPaced: boolean,
|
||||
isCustomRelativeDatesActive: boolean,
|
||||
onOpenPublishModal: () => void,
|
||||
onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void,
|
||||
savingStatus?: RequestStatusType,
|
||||
onOpenDeleteModal: () => void,
|
||||
onOpenUnlinkModal: () => void,
|
||||
onDuplicateSubmit: () => void,
|
||||
index: number,
|
||||
getPossibleMoves: (index: number, step: number) => void,
|
||||
@@ -52,19 +48,15 @@ interface SubsectionCardProps {
|
||||
}
|
||||
|
||||
const SubsectionCard = ({
|
||||
section,
|
||||
subsection,
|
||||
section: initialSectionData,
|
||||
subsection: initialData,
|
||||
isSectionsExpanded,
|
||||
isSelfPaced,
|
||||
isCustomRelativeDatesActive,
|
||||
children,
|
||||
index,
|
||||
getPossibleMoves,
|
||||
onOpenPublishModal,
|
||||
onEditSubmit,
|
||||
savingStatus,
|
||||
onOpenDeleteModal,
|
||||
onOpenUnlinkModal,
|
||||
onDuplicateSubmit,
|
||||
onOrderChange,
|
||||
onOpenConfigureModal,
|
||||
@@ -75,16 +67,20 @@ const SubsectionCard = ({
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const { activeId, overId } = useContext(DragContext);
|
||||
const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext();
|
||||
const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext();
|
||||
const [searchParams] = useSearchParams();
|
||||
const locatorId = searchParams.get('show');
|
||||
const isScrolledToElement = locatorId === subsection.id;
|
||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||
const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false);
|
||||
const namePrefix = 'subsection';
|
||||
const { sharedClipboardData, showPasteUnit } = useClipboard();
|
||||
const { courseId } = useCourseAuthoringContext();
|
||||
const {
|
||||
courseId, openUnlinkModal, openPublishModal, setCurrentSelection,
|
||||
} = useCourseAuthoringContext();
|
||||
const queryClient = useQueryClient();
|
||||
// Set initialData state from course outline and subsequently depend on its own state
|
||||
const { data: section = initialSectionData } = useCourseItemData(initialSectionData.id, initialSectionData);
|
||||
const { data: subsection = initialData } = useCourseItemData(initialData.id, initialData);
|
||||
const isScrolledToElement = locatorId === subsection.id;
|
||||
|
||||
const {
|
||||
id,
|
||||
@@ -145,14 +141,25 @@ const SubsectionCard = ({
|
||||
setIsExpanded(isSectionsExpanded);
|
||||
}, [isSectionsExpanded]);
|
||||
|
||||
/**
|
||||
Temporary measure to keep the react-query state updated with redux state */
|
||||
useEffect(() => {
|
||||
// istanbul ignore if
|
||||
if (moment(initialData.editedOnRaw).isAfter(moment(subsection.editedOnRaw))) {
|
||||
queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData);
|
||||
}
|
||||
}, [initialData, subsection]);
|
||||
|
||||
const handleExpandContent = () => {
|
||||
setIsExpanded((prevState) => !prevState);
|
||||
};
|
||||
|
||||
const handleClickMenuButton = () => {
|
||||
dispatch(setCurrentSection(section));
|
||||
dispatch(setCurrentSubsection(subsection));
|
||||
dispatch(setCurrentItem(subsection));
|
||||
setCurrentSelection({
|
||||
currentId: subsection.id,
|
||||
subsectionId: subsection.id,
|
||||
sectionId: section.id,
|
||||
});
|
||||
};
|
||||
|
||||
const handleOnPostChangeSync = useCallback(() => {
|
||||
@@ -162,15 +169,6 @@ const SubsectionCard = ({
|
||||
}
|
||||
}, [dispatch, section, queryClient, courseId]);
|
||||
|
||||
const handleEditSubmit = (titleValue: string) => {
|
||||
if (displayName !== titleValue) {
|
||||
onEditSubmit(id, section.id, titleValue);
|
||||
return;
|
||||
}
|
||||
|
||||
closeForm();
|
||||
};
|
||||
|
||||
const handleSubsectionMoveUp = () => {
|
||||
onOrderChange(section, moveUpDetails);
|
||||
};
|
||||
@@ -228,12 +226,6 @@ const SubsectionCard = ({
|
||||
setIsExpanded((prevState) => (containsSearchResult() || prevState));
|
||||
}, [locatorId, setIsExpanded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
closeForm();
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
const isDraggable = (
|
||||
actions.draggable
|
||||
&& (actions.allowMoveUp || actions.allowMoveDown)
|
||||
@@ -243,7 +235,7 @@ const SubsectionCard = ({
|
||||
|
||||
const onClickCard = useCallback((e: React.MouseEvent, preventNodeEvents: boolean) => {
|
||||
if (!preventNodeEvents || e.target === e.currentTarget) {
|
||||
openContainerInfoSidebar(subsection.id);
|
||||
openContainerInfoSidebar(subsection.id, subsection.id, section.id);
|
||||
setIsExpanded(true);
|
||||
}
|
||||
}, [openContainerInfoSidebar]);
|
||||
@@ -272,7 +264,7 @@ const SubsectionCard = ({
|
||||
'subsection-card',
|
||||
{
|
||||
highlight: isScrolledToElement,
|
||||
'outline-card-selected': subsection.id === selectedContainerId,
|
||||
'outline-card-selected': subsection.id === selectedContainerState?.currentId,
|
||||
},
|
||||
)}
|
||||
data-testid="subsection-card"
|
||||
@@ -286,19 +278,17 @@ const SubsectionCard = ({
|
||||
cardId={id}
|
||||
hasChanges={hasChanges}
|
||||
onClickMenuButton={handleClickMenuButton}
|
||||
onClickPublish={onOpenPublishModal}
|
||||
onClickEdit={openForm}
|
||||
onClickPublish={() => openPublishModal({ value: subsection, sectionId: section.id })}
|
||||
onClickDelete={onOpenDeleteModal}
|
||||
onClickUnlink={onOpenUnlinkModal}
|
||||
onClickUnlink={/* istanbul ignore next */ () => openUnlinkModal({
|
||||
value: subsection,
|
||||
sectionId: section.id,
|
||||
})}
|
||||
onClickMoveUp={handleSubsectionMoveUp}
|
||||
onClickMoveDown={handleSubsectionMoveDown}
|
||||
onClickConfigure={onOpenConfigureModal}
|
||||
onClickSync={openSyncModal}
|
||||
onClickCard={(e) => onClickCard(e, true)}
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
savingStatus={savingStatus}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
@@ -341,7 +331,7 @@ const SubsectionCard = ({
|
||||
onClickCard={(e) => onClickCard(e, true)}
|
||||
childType={ContainerType.Unit}
|
||||
parentLocator={subsection.id}
|
||||
parentTitle={subsection.displayName}
|
||||
grandParentLocator={section.id}
|
||||
/>
|
||||
{enableCopyPasteUnits && showPasteUnit && sharedClipboardData && (
|
||||
<PasteComponent
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { getConfig, setConfig } from '@edx/frontend-platform';
|
||||
import {
|
||||
act, fireEvent, initializeMocks, render, screen, waitFor, within,
|
||||
initializeMocks, render, screen, waitFor, within,
|
||||
} from '@src/testUtils';
|
||||
|
||||
import { XBlock } from '@src/data/types';
|
||||
import { Info } from '@openedx/paragon/icons';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { CourseInfoSidebar } from '@src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar';
|
||||
import UnitCard from './UnitCard';
|
||||
import cardMessages from '../card-header/messages';
|
||||
import * as OutlineSidebarContext from '../outline-sidebar/OutlineSidebarContext';
|
||||
import { OutlineInfoSidebar } from '../outline-sidebar/OutlineInfoSidebar';
|
||||
|
||||
const mockUseAcceptLibraryBlockChanges = jest.fn();
|
||||
const mockUseIgnoreLibraryBlockChanges = jest.fn();
|
||||
const setCurrentSelection = jest.fn();
|
||||
|
||||
jest.mock('@src/course-unit/data/apiHooks', () => ({
|
||||
useAcceptLibraryBlockChanges: () => ({
|
||||
@@ -26,6 +28,7 @@ jest.mock('@src/CourseAuthoringContext', () => ({
|
||||
useCourseAuthoringContext: () => ({
|
||||
courseId: 5,
|
||||
getUnitUrl: (id: string) => `/some/${id}`,
|
||||
setCurrentSelection,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -92,11 +95,8 @@ const renderComponent = (props?: object) => render(
|
||||
index={1}
|
||||
getPossibleMoves={jest.fn()}
|
||||
onOrderChange={jest.fn()}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onOpenUnlinkModal={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
onEditSubmit={jest.fn()}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
isSelfPaced={false}
|
||||
isCustomRelativeDatesActive={false}
|
||||
@@ -132,7 +132,8 @@ describe('<UnitCard />', () => {
|
||||
expect(card).not.toHaveClass('outline-card-selected');
|
||||
});
|
||||
|
||||
it('render UnitCard component in selected state', () => {
|
||||
it('render UnitCard component in selected state', async () => {
|
||||
const user = userEvent.setup();
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_COURSE_OUTLINE_NEW_DESIGN: 'true',
|
||||
@@ -149,7 +150,7 @@ describe('<UnitCard />', () => {
|
||||
// Get the <Row> that contains the card and click it to select the card
|
||||
const el = container.querySelector('div.row.mx-0') as HTMLInputElement;
|
||||
expect(el).not.toBeNull();
|
||||
fireEvent.click(el!);
|
||||
await user.click(el!);
|
||||
|
||||
// The card is selected
|
||||
expect(card).toHaveClass('outline-card-selected');
|
||||
@@ -166,6 +167,7 @@ describe('<UnitCard />', () => {
|
||||
});
|
||||
|
||||
it('hides duplicate & delete option based on duplicable & deletable action flag', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { findByTestId } = renderComponent({
|
||||
unit: {
|
||||
...unit,
|
||||
@@ -179,12 +181,13 @@ describe('<UnitCard />', () => {
|
||||
});
|
||||
const element = await findByTestId('unit-card');
|
||||
const menu = await within(element).findByTestId('unit-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menu));
|
||||
await user.click(menu);
|
||||
expect(within(element).queryByTestId('unit-card-header__menu-duplicate-button')).not.toBeInTheDocument();
|
||||
expect(within(element).queryByTestId('unit-card-header__menu-delete-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides move, duplicate & delete options if parent was imported from library', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { findByTestId } = renderComponent({
|
||||
subsection: {
|
||||
...subsection,
|
||||
@@ -197,7 +200,7 @@ describe('<UnitCard />', () => {
|
||||
});
|
||||
const element = await findByTestId('unit-card');
|
||||
const menu = await within(element).findByTestId('unit-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menu));
|
||||
await user.click(menu);
|
||||
expect(within(element).queryByTestId('unit-card-header__menu-duplicate-button')).not.toBeInTheDocument();
|
||||
expect(within(element).queryByTestId('unit-card-header__menu-delete-button')).not.toBeInTheDocument();
|
||||
expect(
|
||||
@@ -209,6 +212,7 @@ describe('<UnitCard />', () => {
|
||||
});
|
||||
|
||||
it('shows copy option based on enableCopyPasteUnits flag', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { findByTestId } = renderComponent({
|
||||
unit: {
|
||||
...unit,
|
||||
@@ -217,7 +221,7 @@ describe('<UnitCard />', () => {
|
||||
});
|
||||
const element = await findByTestId('unit-card');
|
||||
const menu = await within(element).findByTestId('unit-card-header__menu-button');
|
||||
await act(async () => fireEvent.click(menu));
|
||||
await user.click(menu);
|
||||
expect(within(element).queryByText(cardMessages.menuCopy.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -233,51 +237,54 @@ describe('<UnitCard />', () => {
|
||||
});
|
||||
|
||||
it('should sync unit changes from upstream', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
expect(await screen.findByTestId('unit-card-header')).toBeInTheDocument();
|
||||
|
||||
// Click on sync button
|
||||
const syncButton = screen.getByRole('button', { name: /update available - click to sync/i });
|
||||
fireEvent.click(syncButton);
|
||||
await user.click(syncButton);
|
||||
|
||||
// Should open compare preview modal
|
||||
expect(screen.getByRole('heading', { name: /preview changes: unit name/i })).toBeInTheDocument();
|
||||
|
||||
// Click on accept changes
|
||||
const acceptChangesButton = screen.getByText(/accept changes/i);
|
||||
fireEvent.click(acceptChangesButton);
|
||||
await user.click(acceptChangesButton);
|
||||
|
||||
await waitFor(() => expect(mockUseAcceptLibraryBlockChanges).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('should decline sync unit changes from upstream', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderComponent();
|
||||
|
||||
expect(await screen.findByTestId('unit-card-header')).toBeInTheDocument();
|
||||
|
||||
// Click on sync button
|
||||
const syncButton = screen.getByRole('button', { name: /update available - click to sync/i });
|
||||
fireEvent.click(syncButton);
|
||||
await user.click(syncButton);
|
||||
|
||||
// Should open compare preview modal
|
||||
expect(screen.getByRole('heading', { name: /preview changes: unit name/i })).toBeInTheDocument();
|
||||
|
||||
// Click on ignore changes
|
||||
const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i });
|
||||
fireEvent.click(ignoreChangesButton);
|
||||
await user.click(ignoreChangesButton);
|
||||
|
||||
// Should open the confirmation modal
|
||||
expect(screen.getByRole('heading', { name: /ignore these changes\?/i })).toBeInTheDocument();
|
||||
|
||||
// Click on ignore button
|
||||
const ignoreButton = screen.getByRole('button', { name: /ignore/i });
|
||||
fireEvent.click(ignoreButton);
|
||||
await user.click(ignoreButton);
|
||||
|
||||
await waitFor(() => expect(mockUseIgnoreLibraryBlockChanges).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('should open legacy manage tags', async () => {
|
||||
const user = userEvent.setup();
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
|
||||
@@ -286,22 +293,23 @@ describe('<UnitCard />', () => {
|
||||
renderComponent();
|
||||
const element = await screen.findByTestId('unit-card');
|
||||
const menu = await within(element).findByTestId('unit-card-header__menu-button');
|
||||
fireEvent.click(menu);
|
||||
await user.click(menu);
|
||||
|
||||
const manageTagsBtn = await within(element).findByTestId('unit-card-header__menu-manage-tags-button');
|
||||
expect(manageTagsBtn).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(manageTagsBtn);
|
||||
await user.click(manageTagsBtn);
|
||||
|
||||
const drawer = await screen.findByRole('alert');
|
||||
expect(within(drawer).getByText(/manage tags/i));
|
||||
});
|
||||
|
||||
it('should open align sidebar', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockSetCurrentPageKey = jest.fn();
|
||||
|
||||
const testSidebarPage = {
|
||||
component: OutlineInfoSidebar,
|
||||
component: CourseInfoSidebar,
|
||||
icon: Info,
|
||||
title: '',
|
||||
};
|
||||
@@ -319,10 +327,11 @@ describe('<UnitCard />', () => {
|
||||
isOpen: true,
|
||||
open: jest.fn(),
|
||||
toggle: jest.fn(),
|
||||
currentFlow: null,
|
||||
currentFlow: undefined,
|
||||
startCurrentFlow: jest.fn(),
|
||||
stopCurrentFlow: jest.fn(),
|
||||
openContainerInfoSidebar: jest.fn(),
|
||||
clearSelection: jest.fn(),
|
||||
}));
|
||||
setConfig({
|
||||
...getConfig(),
|
||||
@@ -332,15 +341,20 @@ describe('<UnitCard />', () => {
|
||||
renderComponent();
|
||||
const element = await screen.findByTestId('unit-card');
|
||||
const menu = await within(element).findByTestId('unit-card-header__menu-button');
|
||||
fireEvent.click(menu);
|
||||
await user.click(menu);
|
||||
|
||||
const manageTagsBtn = await within(element).findByTestId('unit-card-header__menu-manage-tags-button');
|
||||
expect(manageTagsBtn).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(manageTagsBtn);
|
||||
await user.click(manageTagsBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('align', unit.id);
|
||||
expect(mockSetCurrentPageKey).toHaveBeenCalledWith('align');
|
||||
});
|
||||
expect(setCurrentSelection).toHaveBeenCalledWith({
|
||||
currentId: unit.id,
|
||||
subsectionId: subsection.id,
|
||||
sectionId: section.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,9 +12,7 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
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, RequestStatusType } from '@src/data/constants';
|
||||
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';
|
||||
@@ -24,20 +22,18 @@ import { useClipboard } from '@src/generic/clipboard';
|
||||
import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon';
|
||||
import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes';
|
||||
import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks';
|
||||
import type { XBlock } from '@src/data/types';
|
||||
import type { UnitXBlock, XBlock } from '@src/data/types';
|
||||
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
|
||||
import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks';
|
||||
import moment from 'moment';
|
||||
import { useOutlineSidebarContext } from '../outline-sidebar/OutlineSidebarContext';
|
||||
|
||||
interface UnitCardProps {
|
||||
unit: XBlock;
|
||||
unit: UnitXBlock;
|
||||
subsection: XBlock;
|
||||
section: XBlock;
|
||||
onOpenPublishModal: () => void;
|
||||
onOpenConfigureModal: () => void;
|
||||
onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void,
|
||||
savingStatus?: RequestStatusType;
|
||||
onOpenDeleteModal: () => void;
|
||||
onOpenUnlinkModal: () => void;
|
||||
onDuplicateSubmit: () => void;
|
||||
index: number;
|
||||
getPossibleMoves: (index: number, step: number) => void,
|
||||
@@ -51,19 +47,15 @@ interface UnitCardProps {
|
||||
}
|
||||
|
||||
const UnitCard = ({
|
||||
unit,
|
||||
subsection,
|
||||
section,
|
||||
unit: initialData,
|
||||
subsection: initialSubsectionData,
|
||||
section: initialSectionData,
|
||||
isSelfPaced,
|
||||
isCustomRelativeDatesActive,
|
||||
index,
|
||||
getPossibleMoves,
|
||||
onOpenPublishModal,
|
||||
onOpenConfigureModal,
|
||||
onEditSubmit,
|
||||
savingStatus,
|
||||
onOpenDeleteModal,
|
||||
onOpenUnlinkModal,
|
||||
onDuplicateSubmit,
|
||||
onOrderChange,
|
||||
discussionsSettings,
|
||||
@@ -71,16 +63,23 @@ const UnitCard = ({
|
||||
const currentRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { selectedContainerId, openContainerInfoSidebar } = useOutlineSidebarContext();
|
||||
const { selectedContainerState, openContainerInfoSidebar } = useOutlineSidebarContext();
|
||||
const locatorId = searchParams.get('show');
|
||||
const isScrolledToElement = locatorId === unit.id;
|
||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||
const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false);
|
||||
const namePrefix = 'unit';
|
||||
|
||||
const { copyToClipboard } = useClipboard();
|
||||
const { courseId, getUnitUrl } = useCourseAuthoringContext();
|
||||
const {
|
||||
courseId, getUnitUrl, openUnlinkModal, openPublishModal, setCurrentSelection,
|
||||
} = useCourseAuthoringContext();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: section = initialSectionData } = useCourseItemData(initialSectionData.id, initialSectionData);
|
||||
const { data: subsection = initialSubsectionData } = useCourseItemData(
|
||||
initialSubsectionData.id,
|
||||
initialSubsectionData,
|
||||
);
|
||||
const { data: unit = initialData } = useCourseItemData<UnitXBlock>(initialData.id, initialData);
|
||||
const isScrolledToElement = locatorId === unit.id;
|
||||
|
||||
const {
|
||||
id,
|
||||
@@ -133,19 +132,12 @@ const UnitCard = ({
|
||||
});
|
||||
const borderStyle = getItemStatusBorder(unitStatus);
|
||||
|
||||
const handleClickMenuButton = () => {
|
||||
dispatch(setCurrentItem(unit));
|
||||
dispatch(setCurrentSection(section));
|
||||
dispatch(setCurrentSubsection(subsection));
|
||||
};
|
||||
|
||||
const handleEditSubmit = (titleValue: string) => {
|
||||
if (displayName !== titleValue) {
|
||||
onEditSubmit(id, section.id, titleValue);
|
||||
return;
|
||||
}
|
||||
|
||||
closeForm();
|
||||
const selectAndTrigger = () => {
|
||||
setCurrentSelection({
|
||||
currentId: unit.id,
|
||||
subsectionId: subsection.id,
|
||||
sectionId: section.id,
|
||||
});
|
||||
};
|
||||
|
||||
const handleUnitMoveUp = () => {
|
||||
@@ -170,7 +162,7 @@ const UnitCard = ({
|
||||
|
||||
const onClickCard = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
openContainerInfoSidebar(unit.id);
|
||||
openContainerInfoSidebar(unit.id, subsection.id, section.id);
|
||||
}
|
||||
}, [openContainerInfoSidebar]);
|
||||
|
||||
@@ -197,6 +189,15 @@ const UnitCard = ({
|
||||
/>
|
||||
);
|
||||
|
||||
/**
|
||||
Temporary measure to keep the react-query state updated with redux state */
|
||||
useEffect(() => {
|
||||
// istanbul ignore if
|
||||
if (moment(initialData.editedOnRaw).isAfter(moment(unit.editedOnRaw))) {
|
||||
queryClient.setQueryData(courseOutlineQueryKeys.courseItemId(initialData.id), initialData);
|
||||
}
|
||||
}, [initialData, unit]);
|
||||
|
||||
useEffect(() => {
|
||||
// if this items has been newly added, scroll to it.
|
||||
if (currentRef.current && (unit.shouldScroll || isScrolledToElement)) {
|
||||
@@ -206,12 +207,6 @@ const UnitCard = ({
|
||||
}
|
||||
}, [isScrolledToElement]);
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
closeForm();
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
if (!isHeaderVisible) {
|
||||
return null;
|
||||
}
|
||||
@@ -245,7 +240,7 @@ const UnitCard = ({
|
||||
'unit-card',
|
||||
{
|
||||
highlight: isScrolledToElement,
|
||||
'outline-card-selected': unit.id === selectedContainerId,
|
||||
'outline-card-selected': unit.id === selectedContainerState?.currentId,
|
||||
},
|
||||
)}
|
||||
data-testid="unit-card"
|
||||
@@ -256,20 +251,23 @@ const UnitCard = ({
|
||||
status={unitStatus}
|
||||
hasChanges={hasChanges}
|
||||
cardId={id}
|
||||
onClickMenuButton={handleClickMenuButton}
|
||||
onClickPublish={onOpenPublishModal}
|
||||
onClickMenuButton={selectAndTrigger}
|
||||
onClickPublish={() => openPublishModal({
|
||||
value: unit,
|
||||
sectionId: section.id,
|
||||
subsectionId: subsection.id,
|
||||
})}
|
||||
onClickConfigure={onOpenConfigureModal}
|
||||
onClickEdit={openForm}
|
||||
onClickDelete={onOpenDeleteModal}
|
||||
onClickUnlink={onOpenUnlinkModal}
|
||||
onClickUnlink={/* istanbul ignore next */ () => openUnlinkModal({
|
||||
value: unit,
|
||||
sectionId: section.id,
|
||||
subsectionId: subsection.id,
|
||||
})}
|
||||
onClickMoveUp={handleUnitMoveUp}
|
||||
onClickMoveDown={handleUnitMoveDown}
|
||||
onClickSync={openSyncModal}
|
||||
onClickCard={onClickCard}
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
savingStatus={savingStatus}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ShowAnswerTypesKeys } from '@src/editors/data/constants/problem';
|
||||
import { XBlock } from '@src/data/types';
|
||||
import type { UnitXBlock, XBlock } from '@src/data/types';
|
||||
import { COURSE_BLOCK_NAMES } from '../constants';
|
||||
import ReleaseStatus from './ReleaseStatus';
|
||||
import GradingPolicyAlert from './GradingPolicyAlert';
|
||||
@@ -11,7 +11,7 @@ import NeverShowAssessmentResultMessage from './NeverShowAssessmentResultMessage
|
||||
interface XBlockStatusProps {
|
||||
isSelfPaced: boolean;
|
||||
isCustomRelativeDatesActive: boolean,
|
||||
blockData: XBlock,
|
||||
blockData: XBlock | UnitXBlock,
|
||||
}
|
||||
|
||||
const XBlockStatus = ({
|
||||
|
||||
@@ -582,12 +582,11 @@ describe('<CourseUnit />', () => {
|
||||
} = courseSectionVerticalMock;
|
||||
|
||||
const viewLiveButton = await screen.findByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage });
|
||||
|
||||
await user.click(viewLiveButton);
|
||||
expect(window.open).toHaveBeenCalled();
|
||||
expect(window.open).toHaveBeenCalledWith(publishedPreviewLink, '_blank');
|
||||
|
||||
const previewButton = screen.getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage });
|
||||
const previewButton = await screen.findByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage });
|
||||
await user.click(previewButton);
|
||||
expect(window.open).toHaveBeenCalled();
|
||||
expect(window.open).toHaveBeenCalledWith(draftPreviewLink, '_blank');
|
||||
@@ -664,16 +663,14 @@ describe('<CourseUnit />', () => {
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId }))
|
||||
.reply(500, {});
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
render(<RootWrapper />);
|
||||
|
||||
await waitFor(async () => {
|
||||
const videoButton = getByRole('button', {
|
||||
name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'),
|
||||
});
|
||||
|
||||
await user.click(videoButton);
|
||||
expect(mockedUsedNavigate).not.toHaveBeenCalledWith(`/course/${courseKey}/editor/video/${locator}`);
|
||||
const videoButton = await screen.findByRole('button', {
|
||||
name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'),
|
||||
});
|
||||
|
||||
await user.click(videoButton);
|
||||
expect(mockedUsedNavigate).not.toHaveBeenCalledWith(`/course/${courseKey}/editor/video/${locator}`);
|
||||
});
|
||||
|
||||
it('handle creating Problem xblock and showing editor modal', async () => {
|
||||
@@ -683,9 +680,7 @@ describe('<CourseUnit />', () => {
|
||||
.reply(200, courseCreateXblockMock);
|
||||
render(<RootWrapper />);
|
||||
|
||||
await waitFor(async () => {
|
||||
await user.click(screen.getByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage }));
|
||||
});
|
||||
await user.click(await screen.findByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage }));
|
||||
|
||||
axiosMock
|
||||
.onPost(getXBlockBaseApiUrl(blockId), {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getXBlockApiUrl } from '../course-outline/data/api';
|
||||
|
||||
let axiosMock;
|
||||
const courseId = '123';
|
||||
const subsectionId = 'block-v1+edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5';
|
||||
const subsectionId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5';
|
||||
const path = '/subsection/:subsectionId';
|
||||
|
||||
const expectedCourseItemDataWithUnit = {
|
||||
|
||||
@@ -41,7 +41,13 @@ import {
|
||||
import { formatAccessManagedXBlockData, getIframeUrl, getLegacyEditModalUrl } from './utils';
|
||||
|
||||
const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, isUnitVerticalType,
|
||||
courseId,
|
||||
blockId,
|
||||
unitXBlockActions,
|
||||
courseVerticalChildren,
|
||||
handleConfigureSubmit,
|
||||
isUnitVerticalType,
|
||||
readonly,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
@@ -210,7 +216,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
handleRefreshIframe,
|
||||
});
|
||||
|
||||
useIframeMessages(messageHandlers);
|
||||
useIframeMessages(readonly ? {} : messageHandlers);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface XBlockContainerIframeProps {
|
||||
};
|
||||
courseVerticalChildren: Array<XBlockTypes>;
|
||||
handleConfigureSubmit: (XBlockId: string, ...args: any[]) => void;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
export type AccessManagedXBlockDataTypes = {
|
||||
@@ -57,7 +58,7 @@ export type AccessManagedXBlockDataTypes = {
|
||||
ancestorHasStaffLock?: boolean;
|
||||
isPrereq?: boolean;
|
||||
prereqs?: XBlockPrereqs[];
|
||||
prereq?: number;
|
||||
prereq?: string;
|
||||
prereqMinScore?: number;
|
||||
prereqMinCompletion?: number;
|
||||
releasedToStudents?: boolean;
|
||||
|
||||
@@ -65,7 +65,7 @@ export interface UpstreamInfo {
|
||||
versionDeclined: number | null,
|
||||
errorMessage: string | null,
|
||||
downstreamCustomized: string[],
|
||||
hasTopLevelParent?: boolean,
|
||||
topLevelParentKey?: string,
|
||||
readyToSyncChildren?: UpstreamChildrenInfo[],
|
||||
isReadyToSyncIndividually?: boolean,
|
||||
}
|
||||
@@ -78,6 +78,7 @@ export interface XBlock {
|
||||
category: string;
|
||||
hasChildren: boolean;
|
||||
editedOn: string;
|
||||
editedOnRaw: string;
|
||||
published: boolean;
|
||||
publishedOn: string;
|
||||
studioUrl: string;
|
||||
@@ -126,6 +127,8 @@ export interface XBlock {
|
||||
upstreamInfo?: UpstreamInfo;
|
||||
}
|
||||
|
||||
export type UnitXBlock = Omit<XBlock, 'childInfo'>;
|
||||
|
||||
interface OutlineError {
|
||||
data?: string;
|
||||
type: string;
|
||||
@@ -153,3 +156,9 @@ export interface UserTaskStatusWithUuid {
|
||||
modified: string;
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
export type SelectionState = {
|
||||
currentId: string;
|
||||
sectionId?: string;
|
||||
subsectionId?: string;
|
||||
};
|
||||
|
||||
@@ -61,7 +61,7 @@ const ConfigureModal = ({
|
||||
showReviewRules,
|
||||
onlineProctoringRules,
|
||||
discussionEnabled,
|
||||
} = currentItemData;
|
||||
} = currentItemData || {};
|
||||
|
||||
const getSelectedGroups = () => {
|
||||
if (userPartitionInfo?.selectedPartitionIndex >= 0) {
|
||||
@@ -361,7 +361,7 @@ ConfigureModal.propTypes = {
|
||||
blockDisplayName: PropTypes.string,
|
||||
blockUsageKey: PropTypes.string,
|
||||
}),
|
||||
prereq: PropTypes.number,
|
||||
prereq: PropTypes.string,
|
||||
prereqMinScore: PropTypes.number,
|
||||
prereqMinCompletion: PropTypes.number,
|
||||
releasedToStudents: PropTypes.bool,
|
||||
@@ -374,7 +374,7 @@ ConfigureModal.propTypes = {
|
||||
showReviewRules: PropTypes.bool,
|
||||
onlineProctoringRules: PropTypes.string,
|
||||
discussionEnabled: PropTypes.bool,
|
||||
}).isRequired,
|
||||
}),
|
||||
isXBlockComponent: PropTypes.bool,
|
||||
isSelfPaced: PropTypes.bool.isRequired,
|
||||
};
|
||||
|
||||
73
src/generic/resizable/Resizable.tsx
Normal file
73
src/generic/resizable/Resizable.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useWindowSize } from '@openedx/paragon';
|
||||
import React, {
|
||||
useRef, useState, useCallback, useMemo,
|
||||
} from 'react';
|
||||
|
||||
const MIN_WIDTH = 440; // px
|
||||
|
||||
interface ResizableBoxProps {
|
||||
children: React.ReactNode;
|
||||
minWidth?: number;
|
||||
maxWidth?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a resizable box that can be dragged to resize the width from the left side.
|
||||
*/
|
||||
export const ResizableBox = ({
|
||||
children,
|
||||
minWidth = MIN_WIDTH,
|
||||
maxWidth,
|
||||
}: ResizableBoxProps) => {
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState<number>(minWidth); // initial width
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
// Store the start values while dragging
|
||||
const startXRef = useRef<number>(0);
|
||||
const startWidthRef = useRef<number>(0);
|
||||
const defaultMaxWidth = useMemo(() => {
|
||||
if (!windowWidth) {
|
||||
return Infinity;
|
||||
}
|
||||
return Math.abs(windowWidth * 0.65);
|
||||
}, [windowWidth]);
|
||||
|
||||
const onMouseMove = useCallback((e: MouseEvent) => {
|
||||
const dx = e.clientX - startXRef.current; // positive = mouse moved right
|
||||
const newWidth = Math.min(
|
||||
Math.max(startWidthRef.current - dx, minWidth),
|
||||
maxWidth || defaultMaxWidth,
|
||||
);
|
||||
setWidth(newWidth);
|
||||
}, [maxWidth, minWidth, defaultMaxWidth]);
|
||||
|
||||
const onMouseUp = useCallback(() => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
}, [onMouseMove]);
|
||||
|
||||
const onMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.preventDefault(); // prevent text selection
|
||||
startXRef.current = e.clientX;
|
||||
startWidthRef.current = width;
|
||||
|
||||
// Attach listeners to the whole document so dragging works even outside the box
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
}, [width]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="resizable"
|
||||
ref={boxRef}
|
||||
style={{ width: `${width}px` }}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex, jsx-a11y/no-static-element-interactions */}
|
||||
<div className="resizable-handle" onMouseDown={onMouseDown} />
|
||||
<div className="w-100">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
21
src/generic/resizable/index.scss
Normal file
21
src/generic/resizable/index.scss
Normal file
@@ -0,0 +1,21 @@
|
||||
/* The box that will be resized */
|
||||
.resizable {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Custom left‑hand handle */
|
||||
.resizable-handle {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
cursor: ew-resize;
|
||||
background: var(--pgn-color-dark-200);
|
||||
}
|
||||
|
||||
.resizable-handle:hover {
|
||||
background: var(--pgn-color-dark-400);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
FormatIndentDecrease,
|
||||
FormatIndentIncrease,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { ResizableBox } from '@src/generic/resizable/Resizable';
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
|
||||
import messages from './messages';
|
||||
@@ -91,33 +92,35 @@ export function Sidebar<T extends SidebarPages>({
|
||||
return (
|
||||
<Stack direction="horizontal" className="sidebar align-items-baseline ml-3" gap={2}>
|
||||
{isOpen && !!currentPageKey && (
|
||||
<div className="sidebar-content p-3 bg-white border-right">
|
||||
<Dropdown data-testid="sidebar-dropdown">
|
||||
<Dropdown.Toggle
|
||||
id="dropdown-toggle-with-iconbutton"
|
||||
as={Button}
|
||||
variant="tertiary"
|
||||
className="x-small text-primary font-weight-bold pl-0"
|
||||
>
|
||||
{intl.formatMessage(pages[currentPageKey].title)}
|
||||
<Icon src={pages[currentPageKey].icon} size="xs" className="ml-2" />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="mt-1">
|
||||
{Object.entries(pages).map(([key, page]) => (
|
||||
<Dropdown.Item
|
||||
key={key}
|
||||
onClick={() => setCurrentPageKey(key)}
|
||||
>
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
<Icon src={page.icon} />
|
||||
{intl.formatMessage(page.title)}
|
||||
</Stack>
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
<SidebarComponent />
|
||||
</div>
|
||||
<ResizableBox>
|
||||
<div className="sidebar-content p-3 bg-white border-right">
|
||||
<Dropdown data-testid="sidebar-dropdown">
|
||||
<Dropdown.Toggle
|
||||
id="dropdown-toggle-with-iconbutton"
|
||||
as={Button}
|
||||
variant="tertiary"
|
||||
className="x-small text-primary font-weight-bold pl-0"
|
||||
>
|
||||
{intl.formatMessage(pages[currentPageKey].title)}
|
||||
<Icon src={pages[currentPageKey].icon} size="xs" className="ml-2" />
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="mt-1">
|
||||
{Object.entries(pages).map(([key, page]) => (
|
||||
<Dropdown.Item
|
||||
key={key}
|
||||
onClick={() => setCurrentPageKey(key)}
|
||||
>
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
<Icon src={page.icon} />
|
||||
{intl.formatMessage(page.title)}
|
||||
</Stack>
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
<SidebarComponent />
|
||||
</div>
|
||||
</ResizableBox>
|
||||
)}
|
||||
<div className="sidebar-toggle" data-testid="sidebar-toggle">
|
||||
<IconButton
|
||||
|
||||
@@ -28,7 +28,7 @@ interface SidebarContentProps {
|
||||
* ```
|
||||
*/
|
||||
export const SidebarContent = ({ children } : SidebarContentProps) => (
|
||||
<Stack gap={1}>
|
||||
<Stack gap={1} className="px-3 py-1">
|
||||
{Array.isArray(children) ? children.map((child, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<React.Fragment key={index}>
|
||||
|
||||
@@ -48,7 +48,7 @@ export const SidebarSection = ({
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
{icon && <Icon src={icon} className="mr-1 text-primary" size="sm" />}
|
||||
{title && (
|
||||
<h3 className="h5 font-weight-bold text-primary mb-0">
|
||||
<h3 className="h5 font-weight-bold text-primary mb-0 mt-1">
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { Icon, Stack } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, IconButton, Stack } from '@openedx/paragon';
|
||||
import { ArrowBack } from '@openedx/paragon/icons';
|
||||
import messages from './messages';
|
||||
|
||||
interface SidebarTitleProps {
|
||||
/** Title of the section */
|
||||
title: string;
|
||||
/** Icon to be displayed in the section title */
|
||||
icon?: React.ComponentType;
|
||||
onBackBtnClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -16,9 +20,24 @@ interface SidebarTitleProps {
|
||||
* This is meant to standardize the look and feel of the sidebar section titles,
|
||||
* so that it can be reused across different parts of the application.
|
||||
*/
|
||||
export const SidebarTitle = ({ title, icon }: SidebarTitleProps) => (
|
||||
<Stack direction="horizontal" gap={2} className="mb-3">
|
||||
<Icon src={icon} className="mr-2 text-primary" />
|
||||
<h2 className="text-primary h3 mb-0">{title}</h2>
|
||||
</Stack>
|
||||
);
|
||||
export const SidebarTitle = ({
|
||||
title,
|
||||
icon,
|
||||
onBackBtnClick,
|
||||
}: SidebarTitleProps) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<Stack direction="horizontal" gap={2} className="mb-3">
|
||||
{onBackBtnClick && (
|
||||
<IconButton
|
||||
onClick={onBackBtnClick}
|
||||
alt={intl.formatMessage(messages.backBtnText)}
|
||||
src={ArrowBack}
|
||||
size="inline"
|
||||
/>
|
||||
)}
|
||||
<Icon src={icon} className="mr-2 text-primary" />
|
||||
<h2 className="text-primary h3 mb-0">{title}</h2>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
.sidebar {
|
||||
.sidebar-content {
|
||||
flex: 0 1 auto;
|
||||
max-width: 700px;
|
||||
min-width: 400px;
|
||||
min-width: 440px;
|
||||
overflow-y: auto;
|
||||
min-height: 100vh;
|
||||
height: 100%;
|
||||
|
||||
@@ -6,6 +6,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Toggle',
|
||||
description: 'Toggle button alt',
|
||||
},
|
||||
backBtnText: {
|
||||
id: 'course-authoring.sidebar.back.btn.alt-text',
|
||||
defaultMessage: 'Back',
|
||||
description: 'Alternate text of Back button in sidebar title',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -17,3 +17,4 @@
|
||||
@import "./inplace-text-editor/InplaceTextEditor";
|
||||
@import "./upstream-info-icon/UpstreamInfoIcon";
|
||||
@import "./sidebar/";
|
||||
@import "./resizable/";
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { courseLibrariesQueryKeys } from '@src/course-libraries';
|
||||
import { getCourseKey } from '@src/generic/key-utils';
|
||||
|
||||
import { courseOutlineQueryKeys } from '@src/course-outline/data/apiHooks';
|
||||
import { unlinkDownstream } from './api';
|
||||
|
||||
export const useUnlinkDownstream = () => {
|
||||
@@ -13,6 +14,12 @@ export const useUnlinkDownstream = () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseLibrariesQueryKeys.courseLibraries(courseKey),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseItemId(contentId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseOutlineQueryKeys.courseDetails(courseKey),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
15
src/hooks.ts
15
src/hooks.ts
@@ -5,6 +5,7 @@ import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||
@@ -213,3 +214,17 @@ export function useStickyState<T>(
|
||||
|
||||
return [value, setValue];
|
||||
}
|
||||
|
||||
export function useToggleWithValue<T>(defaultValue?: T): [
|
||||
isDefined: boolean, value: T | undefined, define: ((val: T) => void), undefine: () => void,
|
||||
] {
|
||||
const [value, setValue] = useState<T | undefined>(defaultValue);
|
||||
const define = useCallback((val: T) => {
|
||||
setValue(val);
|
||||
}, []);
|
||||
const undefine = useCallback(() => {
|
||||
setValue(undefined);
|
||||
}, []);
|
||||
const isDefined = useMemo(() => value !== undefined, [value]);
|
||||
return [isDefined, value, define, undefine];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.status-button {
|
||||
border: 1px solid;
|
||||
border-left: 4px solid;
|
||||
border-left: 6px solid;
|
||||
text-align: center;
|
||||
white-space: pre-wrap;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
%draft-status {
|
||||
background-color: #FDF3E9;
|
||||
border-color: #F4B57B !important;
|
||||
border-color: #B4610E !important;
|
||||
color: #00262B;
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ export const LibraryDropdownFilter = () => {
|
||||
id="library-filter-dropdown"
|
||||
as={ButtonGroup}
|
||||
autoClose="outside"
|
||||
className="flex-fill mw-xs"
|
||||
>
|
||||
<OverlayTrigger
|
||||
placement="auto"
|
||||
|
||||
@@ -19,19 +19,21 @@ export const SidebarFilters = ({ onlyOneType }: FiltersProps) => {
|
||||
|
||||
return (
|
||||
<Stack gap={3} className="my-3">
|
||||
<Stack direction="horizontal" gap={1}>
|
||||
<Stack className="flex-wrap" direction="horizontal" gap={2}>
|
||||
<LibraryDropdownFilter />
|
||||
<SearchKeywordsField />
|
||||
<IconButton
|
||||
onClick={toggle}
|
||||
alt={intl.formatMessage(messages.additionalFilterBtnAltText)}
|
||||
size="md"
|
||||
src={FilterList}
|
||||
className="rounded-sm border ml-2"
|
||||
/>
|
||||
<Stack direction="horizontal" gap={1}>
|
||||
<SearchKeywordsField />
|
||||
<IconButton
|
||||
onClick={toggle}
|
||||
alt={intl.formatMessage(messages.additionalFilterBtnAltText)}
|
||||
size="md"
|
||||
src={FilterList}
|
||||
className="rounded-sm border ml-2"
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{isOn && (
|
||||
<Stack direction="horizontal">
|
||||
<Stack className="flex-wrap" direction="horizontal" gap={2}>
|
||||
{!(onlyOneType) && <FilterByBlockType />}
|
||||
<FilterByTags />
|
||||
<CollectionDropdownFilter />
|
||||
|
||||
@@ -41,7 +41,7 @@ const SearchFilterWidget: React.FC<{
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex mr-3">
|
||||
<div className="d-flex">
|
||||
<Button
|
||||
ref={setTarget}
|
||||
variant={appliedFilters.length ? 'light' : 'outline-primary'}
|
||||
|
||||
Reference in New Issue
Block a user