feat: unit list
refactor: hide tooltip based on arg refactor: card header to include title link feat: delete unit option feat: duplicate unit option refactor: title click handler name and remove unwanted scss properties test: new unit and edit unit option test: add delete unit and combine it with section and subsection test test: add duplicate unit test and combine it with section & subsection test refactor: replace act call by oneline test: add publish unit & subsection test and combine it with section test refactor: add jest-expect-message to add custom msg to tests fix: lint issues test: fix unit card tests refactor: remove unnecessary css and message refactor: pass title as component to card header refactor: extract status badge to a component fix: lint issues refactor: rename status badge component test: fix card header tests refactor: new item button styling feat: show loading spinner while sections are loading refactor: new button style
This commit is contained in:
committed by
Kristin Aoki
parent
1e23ce1062
commit
faf90d1fa7
@@ -2,6 +2,7 @@ const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
setupFilesAfterEnv: [
|
||||
'jest-expect-message',
|
||||
'<rootDir>/src/setupTest.js',
|
||||
],
|
||||
coveragePathIgnorePatterns: [
|
||||
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -66,6 +66,7 @@
|
||||
"glob": "7.2.3",
|
||||
"husky": "7.0.4",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"react-test-renderer": "17.0.2",
|
||||
"reactifex": "1.1.1",
|
||||
"ts-loader": "^9.5.0"
|
||||
@@ -18701,6 +18702,12 @@
|
||||
"node": ">= 10.14.2"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-expect-message": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/jest-expect-message/-/jest-expect-message-1.1.3.tgz",
|
||||
"integrity": "sha512-bTK77T4P+zto+XepAX3low8XVQxDgaEqh3jSTQOG8qvPpD69LsIdyJTa+RmnJh3HNSzJng62/44RPPc7OIlFxg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/jest-get-type": {
|
||||
"version": "26.3.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz",
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
"glob": "7.2.3",
|
||||
"husky": "7.0.4",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-expect-message": "^1.1.3",
|
||||
"react-test-renderer": "17.0.2",
|
||||
"reactifex": "1.1.1",
|
||||
"ts-loader": "^9.5.0"
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Button,
|
||||
Container,
|
||||
Layout,
|
||||
Row,
|
||||
TransitionReplace,
|
||||
} from '@edx/paragon';
|
||||
import { Helmet } from 'react-helmet';
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
ErrorAlert,
|
||||
} from '@edx/frontend-lib-content-components';
|
||||
|
||||
import { LoadingSpinner } from '../generic/Loading';
|
||||
import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
@@ -35,6 +37,7 @@ import StatusBar from './status-bar/StatusBar';
|
||||
import EnableHighlightsModal from './enable-highlights-modal/EnableHighlightsModal';
|
||||
import SectionCard from './section-card/SectionCard';
|
||||
import SubsectionCard from './subsection-card/SubsectionCard';
|
||||
import UnitCard from './unit-card/UnitCard';
|
||||
import HighlightsModal from './highlights-modal/HighlightsModal';
|
||||
import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder';
|
||||
import PublishModal from './publish-modal/PublishModal';
|
||||
@@ -83,8 +86,11 @@ const CourseOutline = ({ courseId }) => {
|
||||
handleDeleteItemSubmit,
|
||||
handleDuplicateSectionSubmit,
|
||||
handleDuplicateSubsectionSubmit,
|
||||
handleDuplicateUnitSubmit,
|
||||
handleNewSectionSubmit,
|
||||
handleNewSubsectionSubmit,
|
||||
handleNewUnitSubmit,
|
||||
getUnitUrl,
|
||||
handleDragNDrop,
|
||||
} = useCourseOutline({ courseId });
|
||||
|
||||
@@ -109,7 +115,11 @@ const CourseOutline = ({ courseId }) => {
|
||||
|
||||
if (isLoading) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
return <></>;
|
||||
return (
|
||||
<Row className="m-0 mt-4 justify-content-center">
|
||||
<LoadingSpinner />
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -207,7 +217,23 @@ const CourseOutline = ({ courseId }) => {
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
|
||||
/>
|
||||
onNewUnitSubmit={handleNewUnitSubmit}
|
||||
>
|
||||
{subsection.childInfo.children.map((unit) => (
|
||||
<UnitCard
|
||||
key={unit.id}
|
||||
unit={unit}
|
||||
subsection={subsection}
|
||||
section={section}
|
||||
savingStatus={savingStatus}
|
||||
onOpenPublishModal={openPublishModal}
|
||||
onOpenDeleteModal={openDeleteModal}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onDuplicateSubmit={handleDuplicateUnitSubmit}
|
||||
getTitleLink={getUnitUrl}
|
||||
/>
|
||||
))}
|
||||
</SubsectionCard>
|
||||
))}
|
||||
</SectionCard>
|
||||
</SortableItem>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@import "./status-bar/StatusBar";
|
||||
@import "./section-card/SectionCard";
|
||||
@import "./subsection-card/SubsectionCard";
|
||||
@import "./unit-card/UnitCard";
|
||||
@import "./card-header/CardHeader";
|
||||
@import "./empty-placeholder/EmptyPlaceholder";
|
||||
@import "./highlights-modal/HighlightsModal";
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
courseSubsectionMock,
|
||||
} from './__mocks__';
|
||||
import { executeThunk } from '../utils';
|
||||
import { COURSE_BLOCK_NAMES } from './constants';
|
||||
import CourseOutline from './CourseOutline';
|
||||
import messages from './messages';
|
||||
import headerMessages from './header-navigations/messages';
|
||||
@@ -120,9 +121,7 @@ describe('<CourseOutline />', () => {
|
||||
.onGet(getCourseReindexApiUrl(courseOutlineIndexMock.reindexLink))
|
||||
.reply(500);
|
||||
const reindexButton = await findByTestId('course-reindex');
|
||||
await act(async () => {
|
||||
fireEvent.click(reindexButton);
|
||||
});
|
||||
await act(async () => fireEvent.click(reindexButton));
|
||||
|
||||
expect(await findByText(messages.alertErrorTitle.defaultMessage)).toBeInTheDocument();
|
||||
});
|
||||
@@ -145,9 +144,7 @@ describe('<CourseOutline />', () => {
|
||||
.onGet(getXBlockApiUrl(courseSectionMock.id))
|
||||
.reply(200, courseSectionMock);
|
||||
const newSectionButton = await findByTestId('new-section-button');
|
||||
await act(async () => {
|
||||
fireEvent.click(newSectionButton);
|
||||
});
|
||||
await act(async () => fireEvent.click(newSectionButton));
|
||||
|
||||
elements = await findAllByTestId('section-card');
|
||||
expect(elements.length).toBe(5);
|
||||
@@ -182,6 +179,32 @@ describe('<CourseOutline />', () => {
|
||||
expect(window.HTMLElement.prototype.scrollIntoView).toBeCalled();
|
||||
});
|
||||
|
||||
it('adds new unit correctly', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||
fireEvent.click(expandBtn);
|
||||
const units = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
expect(units.length).toBe(1);
|
||||
|
||||
axiosMock
|
||||
.onPost(getXBlockBaseApiUrl())
|
||||
.reply(200, {
|
||||
locator: 'some',
|
||||
});
|
||||
const newUnitButton = await within(subsectionElement).findByTestId('new-unit-button');
|
||||
await act(async () => fireEvent.click(newUnitButton));
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [subsection] = section.childInfo.children;
|
||||
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
|
||||
parent_locator: subsection.id,
|
||||
category: COURSE_BLOCK_NAMES.vertical.id,
|
||||
display_name: COURSE_BLOCK_NAMES.vertical.name,
|
||||
}));
|
||||
});
|
||||
|
||||
it('render checklist value correctly', async () => {
|
||||
const { getByText } = render(<RootWrapper />);
|
||||
|
||||
@@ -232,9 +255,7 @@ describe('<CourseOutline />', () => {
|
||||
const enableButton = await findByTestId('highlights-enable-button');
|
||||
fireEvent.click(enableButton);
|
||||
const saveButton = await findByText(enableHighlightsModalMessages.submitButton.defaultMessage);
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButton);
|
||||
});
|
||||
await act(async () => fireEvent.click(saveButton));
|
||||
expect(await findByTestId('highlights-enabled-span')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -271,177 +292,272 @@ describe('<CourseOutline />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('check edit section when edit query is successfully', async () => {
|
||||
const { findAllByTestId, findByText } = render(<RootWrapper />);
|
||||
const newDisplayName = 'New section name';
|
||||
it('check edit title works for section, subsection and unit', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const checkEditTitle = async (section, element, item, newName, elementName) => {
|
||||
axiosMock.reset();
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(item.id, {
|
||||
metadata: {
|
||||
display_name: newName,
|
||||
},
|
||||
}))
|
||||
.reply(200, { dummy: 'value' });
|
||||
// mock section, subsection and unit name and check within the elements.
|
||||
// this is done to avoid adding conditions to this mock.
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, {
|
||||
...section,
|
||||
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 [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(section.id, {
|
||||
const editButton = await within(element).findByTestId(`${elementName}-edit-button`);
|
||||
fireEvent.click(editButton);
|
||||
const editField = await within(element).findByTestId(`${elementName}-edit-field`);
|
||||
fireEvent.change(editField, { target: { value: newName } });
|
||||
await act(async () => fireEvent.blur(editField));
|
||||
expect(
|
||||
axiosMock.history.post[axiosMock.history.post.length - 1].data,
|
||||
`Failed for ${elementName}!`,
|
||||
).toBe(JSON.stringify({
|
||||
metadata: {
|
||||
display_name: newDisplayName,
|
||||
display_name: newName,
|
||||
},
|
||||
}))
|
||||
.reply(200, { dummy: 'value' });
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, {
|
||||
...section,
|
||||
display_name: newDisplayName,
|
||||
});
|
||||
}));
|
||||
const results = await within(element).findAllByText(newName);
|
||||
expect(results.length, `Failed for ${elementName}!`).toBeGreaterThan(0);
|
||||
};
|
||||
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const editButton = await within(sectionElement).findByTestId('section-edit-button');
|
||||
fireEvent.click(editButton);
|
||||
const editField = await within(sectionElement).findByTestId('section-edit-field');
|
||||
fireEvent.change(editField, { target: { value: newDisplayName } });
|
||||
await act(async () => {
|
||||
fireEvent.blur(editField);
|
||||
});
|
||||
|
||||
expect(await findByText(newDisplayName)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('check whether section is deleted when delete button is clicked', async () => {
|
||||
const { findAllByTestId, findByTestId, queryByText } = render(<RootWrapper />);
|
||||
// check section
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
await waitFor(() => {
|
||||
expect(queryByText(section.displayName)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
axiosMock.onDelete(getCourseItemApiUrl(section.id)).reply(200);
|
||||
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const menu = await within(sectionElement).findByTestId('section-card-header__menu-button');
|
||||
fireEvent.click(menu);
|
||||
const deleteButton = await within(sectionElement).findByTestId('section-card-header__menu-delete-button');
|
||||
fireEvent.click(deleteButton);
|
||||
const confirmButton = await findByTestId('delete-confirm-button');
|
||||
await act(async () => {
|
||||
fireEvent.click(confirmButton);
|
||||
});
|
||||
await checkEditTitle(section, sectionElement, section, 'New section name', 'section');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByText(section.displayName)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('check whether subsection is deleted when delete button is clicked', async () => {
|
||||
const { findAllByTestId, findByTestId, queryByText } = render(<RootWrapper />);
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
// check subsection
|
||||
const [subsection] = section.childInfo.children;
|
||||
await waitFor(() => {
|
||||
expect(queryByText(subsection.displayName)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
axiosMock.onDelete(getCourseItemApiUrl(subsection.id)).reply(200);
|
||||
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const menu = await within(subsectionElement).findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menu);
|
||||
const deleteButton = await within(subsectionElement).findByTestId('subsection-card-header__menu-delete-button');
|
||||
fireEvent.click(deleteButton);
|
||||
const confirmButton = await findByTestId('delete-confirm-button');
|
||||
await act(async () => {
|
||||
fireEvent.click(confirmButton);
|
||||
});
|
||||
await checkEditTitle(section, subsectionElement, subsection, 'New subsection name', 'subsection');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByText(subsection.displayName)).not.toBeInTheDocument();
|
||||
});
|
||||
// check unit
|
||||
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||
fireEvent.click(expandBtn);
|
||||
const [unit] = subsection.childInfo.children;
|
||||
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
await checkEditTitle(section, unitElement, unit, 'New unit name', 'unit');
|
||||
});
|
||||
|
||||
it('check whether section is duplicated successfully', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
it('check whether section, subsection and unit is deleted when corresponding delete button is clicked', async () => {
|
||||
const { findAllByTestId, findByTestId, queryByText } = render(<RootWrapper />);
|
||||
// get section, subsection and unit
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
expect(await findAllByTestId('section-card')).toHaveLength(4);
|
||||
|
||||
axiosMock
|
||||
.onPost(getXBlockBaseApiUrl())
|
||||
.reply(200, {
|
||||
locator: courseSectionMock.id,
|
||||
});
|
||||
section.id = courseSectionMock.id;
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, {
|
||||
...section,
|
||||
});
|
||||
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const menu = await within(sectionElement).findByTestId('section-card-header__menu-button');
|
||||
fireEvent.click(menu);
|
||||
const duplicateButton = await within(sectionElement).findByTestId('section-card-header__menu-duplicate-button');
|
||||
await act(async () => {
|
||||
fireEvent.click(duplicateButton);
|
||||
});
|
||||
expect(await findAllByTestId('section-card')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('check whether subsection is duplicated successfully', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
const section = courseOutlineIndexMock.courseStructure.childInfo.children[0];
|
||||
let [sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsection] = section.childInfo.children;
|
||||
let subsections = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
expect(subsections.length).toBe(1);
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||
fireEvent.click(expandBtn);
|
||||
const [unit] = subsection.childInfo.children;
|
||||
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
|
||||
axiosMock
|
||||
.onPost(getXBlockBaseApiUrl())
|
||||
.reply(200, {
|
||||
locator: courseSubsectionMock.id,
|
||||
});
|
||||
subsection.id = courseSubsectionMock.id;
|
||||
section.childInfo.children = [...section.childInfo.children, subsection];
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, {
|
||||
...section,
|
||||
const checkDeleteBtn = async (item, element, elementName) => {
|
||||
await waitFor(() => {
|
||||
expect(queryByText(item.displayName), `Failed for ${elementName}!`).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const menu = await within(subsections[0]).findByTestId('subsection-card-header__menu-button');
|
||||
fireEvent.click(menu);
|
||||
const duplicateButton = await within(subsections[0]).findByTestId('subsection-card-header__menu-duplicate-button');
|
||||
await act(async () => {
|
||||
fireEvent.click(duplicateButton);
|
||||
});
|
||||
axiosMock.onDelete(getCourseItemApiUrl(item.id)).reply(200);
|
||||
|
||||
[sectionElement] = await findAllByTestId('section-card');
|
||||
subsections = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
expect(subsections.length).toBe(2);
|
||||
const menu = await within(element).findByTestId(`${elementName}-card-header__menu-button`);
|
||||
fireEvent.click(menu);
|
||||
const deleteButton = await within(element).findByTestId(`${elementName}-card-header__menu-delete-button`);
|
||||
fireEvent.click(deleteButton);
|
||||
const confirmButton = await findByTestId('delete-confirm-button');
|
||||
await act(async () => fireEvent.click(confirmButton));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByText(item.displayName), `Failed for ${elementName}!`).not.toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
// delete unit, subsection and then section in order.
|
||||
// check unit
|
||||
await checkDeleteBtn(unit, unitElement, 'unit');
|
||||
// check subsection
|
||||
await checkDeleteBtn(subsection, subsectionElement, 'subsection');
|
||||
// check section
|
||||
await checkDeleteBtn(section, sectionElement, 'section');
|
||||
});
|
||||
|
||||
it('check section is published when publish button is clicked', async () => {
|
||||
it('check whether section, subsection and unit is duplicated successfully', async () => {
|
||||
const { findAllByTestId } = render(<RootWrapper />);
|
||||
// get section, subsection and unit
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const { findAllByTestId, findByTestId } = render(<RootWrapper />);
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(section.id), {
|
||||
publish: 'make_public',
|
||||
})
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, {
|
||||
...section,
|
||||
published: true,
|
||||
releasedToStudents: false,
|
||||
});
|
||||
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const menu = await within(sectionElement).findByTestId('section-card-header__menu-button');
|
||||
fireEvent.click(menu);
|
||||
const publishButton = await within(sectionElement).findByTestId('section-card-header__menu-publish-button');
|
||||
await act(async () => fireEvent.click(publishButton));
|
||||
const confirmButton = await findByTestId('publish-confirm-button');
|
||||
await act(async () => fireEvent.click(confirmButton));
|
||||
const [subsection] = section.childInfo.children;
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||
fireEvent.click(expandBtn);
|
||||
const [unit] = subsection.childInfo.children;
|
||||
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
|
||||
expect(
|
||||
sectionElement.querySelector('.item-card-header__badge-status'),
|
||||
).toHaveTextContent(cardHeaderMessages.statusBadgePublishedNotLive.defaultMessage);
|
||||
const checkDuplicateBtn = async (item, parentElement, element, elementName, expectedLength) => {
|
||||
// baseline
|
||||
if (parentElement) {
|
||||
expect(
|
||||
await within(parentElement).findAllByTestId(`${elementName}-card`),
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveLength(expectedLength - 1);
|
||||
} else {
|
||||
expect(
|
||||
await findAllByTestId(`${elementName}-card`),
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveLength(expectedLength - 1);
|
||||
}
|
||||
|
||||
const duplicatedItemId = item.id + elementName;
|
||||
axiosMock
|
||||
.onPost(getXBlockBaseApiUrl())
|
||||
.reply(200, {
|
||||
locator: duplicatedItemId,
|
||||
});
|
||||
if (elementName === 'section') {
|
||||
section.id = duplicatedItemId;
|
||||
} else if (elementName === 'subsection') {
|
||||
section.childInfo.children = [...section.childInfo.children, { ...subsection, id: duplicatedItemId }];
|
||||
} else if (elementName === 'unit') {
|
||||
subsection.childInfo.children = [...subsection.childInfo.children, { ...unit, id: duplicatedItemId }];
|
||||
section.childInfo.children = [subsection, ...section.childInfo.children.slice(1)];
|
||||
}
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, {
|
||||
...section,
|
||||
});
|
||||
|
||||
const menu = await within(element).findByTestId(`${elementName}-card-header__menu-button`);
|
||||
fireEvent.click(menu);
|
||||
const duplicateButton = await within(element).findByTestId(`${elementName}-card-header__menu-duplicate-button`);
|
||||
await act(async () => fireEvent.click(duplicateButton));
|
||||
if (parentElement) {
|
||||
expect(
|
||||
await within(parentElement).findAllByTestId(`${elementName}-card`),
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveLength(expectedLength);
|
||||
} else {
|
||||
expect(
|
||||
await findAllByTestId(`${elementName}-card`),
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveLength(expectedLength);
|
||||
}
|
||||
};
|
||||
|
||||
// duplicate unit, subsection and then section in order.
|
||||
// check unit
|
||||
await checkDuplicateBtn(unit, subsectionElement, unitElement, 'unit', 2);
|
||||
// check subsection
|
||||
await checkDuplicateBtn(subsection, sectionElement, subsectionElement, 'subsection', 2);
|
||||
// check section
|
||||
await checkDuplicateBtn(section, null, sectionElement, 'section', 5);
|
||||
});
|
||||
|
||||
it('check section, subsection & unit is published when publish button is clicked', async () => {
|
||||
const { findAllByTestId, findByTestId } = render(<RootWrapper />);
|
||||
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
|
||||
const [sectionElement] = await findAllByTestId('section-card');
|
||||
const [subsection] = section.childInfo.children;
|
||||
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
|
||||
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
|
||||
fireEvent.click(expandBtn);
|
||||
const [unit] = subsection.childInfo.children;
|
||||
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
|
||||
|
||||
const checkPublishBtn = async (item, element, elementName) => {
|
||||
expect(
|
||||
await within(element).findByTestId(`${elementName}-card-header__badge-status`),
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveTextContent(cardHeaderMessages.statusBadgeDraft.defaultMessage);
|
||||
|
||||
axiosMock
|
||||
.onPost(getCourseItemApiUrl(item.id), {
|
||||
publish: 'make_public',
|
||||
})
|
||||
.reply(200, { dummy: 'value' });
|
||||
|
||||
let mockReturnValue = { ...section, published: true };
|
||||
if (elementName === 'subsection') {
|
||||
mockReturnValue = {
|
||||
...section,
|
||||
childInfo: {
|
||||
children: [
|
||||
{
|
||||
...section.childInfo.children[0],
|
||||
published: true,
|
||||
},
|
||||
...section.childInfo.children.slice(1),
|
||||
],
|
||||
},
|
||||
};
|
||||
} else if (elementName === 'unit') {
|
||||
mockReturnValue = {
|
||||
...section,
|
||||
childInfo: {
|
||||
children: [
|
||||
{
|
||||
...section.childInfo.children[0],
|
||||
childInfo: {
|
||||
children: [
|
||||
{
|
||||
...section.childInfo.children[0].childInfo.children[0],
|
||||
published: true,
|
||||
},
|
||||
...section.childInfo.children[0].childInfo.children.slice(1),
|
||||
],
|
||||
},
|
||||
},
|
||||
...section.childInfo.children.slice(1),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
axiosMock
|
||||
.onGet(getXBlockApiUrl(section.id))
|
||||
.reply(200, mockReturnValue);
|
||||
|
||||
const menu = await within(element).findByTestId(`${elementName}-card-header__menu-button`);
|
||||
fireEvent.click(menu);
|
||||
const publishButton = await within(element).findByTestId(`${elementName}-card-header__menu-publish-button`);
|
||||
await act(async () => fireEvent.click(publishButton));
|
||||
const confirmButton = await findByTestId('publish-confirm-button');
|
||||
await act(async () => fireEvent.click(confirmButton));
|
||||
|
||||
expect(
|
||||
await within(element).findByTestId(`${elementName}-card-header__badge-status`),
|
||||
`Failed for ${elementName}!`,
|
||||
).toHaveTextContent(cardHeaderMessages.statusBadgePublishedNotLive.defaultMessage);
|
||||
};
|
||||
|
||||
// publish unit, subsection and then section in order.
|
||||
// check unit
|
||||
await checkPublishBtn(unit, unitElement, 'unit');
|
||||
// check subsection
|
||||
await checkPublishBtn(subsection, subsectionElement, 'subsection');
|
||||
// check section
|
||||
await checkPublishBtn(section, sectionElement, 'section');
|
||||
});
|
||||
|
||||
it('check configure section when configure query is successful', async () => {
|
||||
|
||||
@@ -75,7 +75,7 @@ module.exports = {
|
||||
published: false,
|
||||
publishedOn: 'Aug 23, 2023 at 12:35 UTC',
|
||||
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40chapter%2Bblock%40d8a6192ade314473a78242dfeedfbf5b',
|
||||
releasedToStudents: true,
|
||||
releasedToStudents: false,
|
||||
releaseDate: 'Aug 10, 2023 at 22:00 UTC',
|
||||
visibilityState: 'staff_only',
|
||||
hasExplicitStaffLock: true,
|
||||
@@ -137,10 +137,10 @@ module.exports = {
|
||||
category: 'sequential',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
published: false,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/course/course-v1:edX+DemoX+Demo_Course?show=block-v1%3AedX%2BDemoX%2BDemo_Course%2Btype%40sequential%2Bblock%40edx_introduction',
|
||||
releasedToStudents: true,
|
||||
releasedToStudents: false,
|
||||
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
|
||||
visibilityState: 'staff_only',
|
||||
hasExplicitStaffLock: false,
|
||||
@@ -207,10 +207,10 @@ module.exports = {
|
||||
category: 'vertical',
|
||||
hasChildren: true,
|
||||
editedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
published: true,
|
||||
published: false,
|
||||
publishedOn: 'Jul 07, 2023 at 11:14 UTC',
|
||||
studioUrl: '/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc',
|
||||
releasedToStudents: true,
|
||||
releasedToStudents: false,
|
||||
releaseDate: 'Jan 01, 1970 at 05:00 UTC',
|
||||
visibilityState: 'staff_only',
|
||||
hasExplicitStaffLock: false,
|
||||
|
||||
43
src/course-outline/card-header/BaseTitleWithStatusBadge.jsx
Normal file
43
src/course-outline/card-header/BaseTitleWithStatusBadge.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, Truncate } from '@edx/paragon';
|
||||
import classNames from 'classnames';
|
||||
import { ITEM_BADGE_STATUS } from '../constants';
|
||||
import { getItemStatusBadgeContent } from '../utils';
|
||||
import messages from './messages';
|
||||
|
||||
const BaseTitleWithStatusBadge = ({
|
||||
title,
|
||||
status,
|
||||
namePrefix,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { badgeTitle, badgeIcon } = getItemStatusBadgeContent(status, messages, intl);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Truncate lines={1} className={`${namePrefix}-card-title mb-0`}>{title}</Truncate>
|
||||
{badgeTitle && (
|
||||
<div className="item-card-header__badge-status" data-testid={`${namePrefix}-card-header__badge-status`}>
|
||||
{badgeIcon && (
|
||||
<Icon
|
||||
src={badgeIcon}
|
||||
size="sm"
|
||||
className={classNames({ 'text-success-500': status === ITEM_BADGE_STATUS.live })}
|
||||
/>
|
||||
)}
|
||||
<span className="small">{badgeTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
BaseTitleWithStatusBadge.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
status: PropTypes.string.isRequired,
|
||||
namePrefix: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default BaseTitleWithStatusBadge;
|
||||
@@ -2,50 +2,40 @@ import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Form,
|
||||
Icon,
|
||||
IconButton,
|
||||
OverlayTrigger,
|
||||
Tooltip,
|
||||
Truncate,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
ArrowDropDown as ArrowDownIcon,
|
||||
ArrowDropUp as ArrowUpIcon,
|
||||
MoreVert as MoveVertIcon,
|
||||
EditOutline as EditIcon,
|
||||
} from '@edx/paragon/icons';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useEscapeClick } from '../../hooks';
|
||||
import { ITEM_BADGE_STATUS } from '../constants';
|
||||
import { getItemStatusBadgeContent } from '../utils';
|
||||
import messages from './messages';
|
||||
|
||||
const CardHeader = ({
|
||||
title,
|
||||
status,
|
||||
hasChanges,
|
||||
isExpanded,
|
||||
onClickPublish,
|
||||
onClickConfigure,
|
||||
onClickMenuButton,
|
||||
onClickEdit,
|
||||
onExpand,
|
||||
isFormOpen,
|
||||
onEditSubmit,
|
||||
closeForm,
|
||||
isDisabledEditField,
|
||||
onClickDelete,
|
||||
onClickDuplicate,
|
||||
titleComponent,
|
||||
namePrefix,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [titleValue, setTitleValue] = useState(title);
|
||||
|
||||
const { badgeTitle, badgeIcon } = getItemStatusBadgeContent(status, messages, intl);
|
||||
const isDisabledPublish = (status === ITEM_BADGE_STATUS.live
|
||||
|| status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges;
|
||||
|
||||
@@ -78,39 +68,7 @@ const CardHeader = ({
|
||||
/>
|
||||
</Form.Group>
|
||||
) : (
|
||||
<OverlayTrigger
|
||||
placement="bottom-start"
|
||||
overlay={(
|
||||
<Tooltip
|
||||
id={intl.formatMessage(messages.expandTooltip)}
|
||||
className="item-card-header-tooltip"
|
||||
>
|
||||
{intl.formatMessage(messages.expandTooltip)}
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
iconBefore={isExpanded ? ArrowUpIcon : ArrowDownIcon}
|
||||
variant="tertiary"
|
||||
data-testid={`${namePrefix}-card-header__expanded-btn`}
|
||||
className="item-card-header__expanded-btn"
|
||||
onClick={() => onExpand((prevState) => !prevState)}
|
||||
>
|
||||
<Truncate lines={1} className={`${namePrefix}-card-title mb-0`}>{title}</Truncate>
|
||||
{badgeTitle && (
|
||||
<div className="item-card-header__badge-status" data-testid={`${namePrefix}-card-header__badge-status`}>
|
||||
{badgeIcon && (
|
||||
<Icon
|
||||
src={badgeIcon}
|
||||
size="sm"
|
||||
className={classNames({ 'text-success-500': status === ITEM_BADGE_STATUS.live })}
|
||||
/>
|
||||
)}
|
||||
<span className="small">{badgeTitle}</span>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
titleComponent
|
||||
)}
|
||||
<div className="ml-auto d-flex">
|
||||
{!isFormOpen && (
|
||||
@@ -168,8 +126,6 @@ CardHeader.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
status: PropTypes.string.isRequired,
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
isExpanded: PropTypes.bool.isRequired,
|
||||
onExpand: PropTypes.func.isRequired,
|
||||
onClickPublish: PropTypes.func.isRequired,
|
||||
onClickConfigure: PropTypes.func.isRequired,
|
||||
onClickMenuButton: PropTypes.func.isRequired,
|
||||
@@ -180,6 +136,7 @@ CardHeader.propTypes = {
|
||||
isDisabledEditField: PropTypes.bool.isRequired,
|
||||
onClickDelete: PropTypes.func.isRequired,
|
||||
onClickDuplicate: PropTypes.func.isRequired,
|
||||
titleComponent: PropTypes.node.isRequired,
|
||||
namePrefix: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
align-items: center;
|
||||
margin-right: -.5rem;
|
||||
|
||||
.item-card-header__expanded-btn {
|
||||
.item-card-header__title-btn {
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
width: 80%;
|
||||
width: fit-content;
|
||||
height: 1.5rem;
|
||||
margin-right: .25rem;
|
||||
background: transparent;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { ITEM_BADGE_STATUS } from '../constants';
|
||||
import CardHeader from './CardHeader';
|
||||
import BaseTitleWithStatusBadge from './BaseTitleWithStatusBadge';
|
||||
import TitleButton from './TitleButton';
|
||||
import messages from './messages';
|
||||
|
||||
const onExpandMock = jest.fn();
|
||||
@@ -18,8 +20,6 @@ const cardHeaderProps = {
|
||||
title: 'Some title',
|
||||
status: ITEM_BADGE_STATUS.live,
|
||||
hasChanges: false,
|
||||
isExpanded: true,
|
||||
onExpand: onExpandMock,
|
||||
onClickMenuButton: onClickMenuButtonMock,
|
||||
onClickPublish: onClickPublishMock,
|
||||
onClickEdit: onClickEditMock,
|
||||
@@ -32,14 +32,33 @@ const cardHeaderProps = {
|
||||
namePrefix: 'section',
|
||||
};
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<IntlProvider locale="en">
|
||||
<CardHeader
|
||||
{...cardHeaderProps}
|
||||
const renderComponent = (props) => {
|
||||
const titleComponent = (
|
||||
<TitleButton
|
||||
isExpanded
|
||||
onTitleClick={onExpandMock}
|
||||
namePrefix={cardHeaderProps.namePrefix}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
);
|
||||
>
|
||||
<BaseTitleWithStatusBadge
|
||||
title={cardHeaderProps.title}
|
||||
status={cardHeaderProps.status}
|
||||
namePrefix={cardHeaderProps.namePrefix}
|
||||
{...props}
|
||||
/>
|
||||
</TitleButton>
|
||||
);
|
||||
|
||||
return render(
|
||||
<IntlProvider locale="en">
|
||||
<CardHeader
|
||||
{...cardHeaderProps}
|
||||
titleComponent={titleComponent}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('<CardHeader />', () => {
|
||||
it('render CardHeader component correctly', async () => {
|
||||
|
||||
59
src/course-outline/card-header/TitleButton.jsx
Normal file
59
src/course-outline/card-header/TitleButton.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button,
|
||||
OverlayTrigger,
|
||||
Tooltip,
|
||||
} from '@edx/paragon';
|
||||
import {
|
||||
ArrowDropDown as ArrowDownIcon,
|
||||
ArrowDropUp as ArrowUpIcon,
|
||||
} from '@edx/paragon/icons';
|
||||
import messages from './messages';
|
||||
|
||||
const TitleButton = ({
|
||||
isExpanded,
|
||||
onTitleClick,
|
||||
namePrefix,
|
||||
children,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const titleTooltipMessage = intl.formatMessage(messages.expandTooltip);
|
||||
|
||||
return (
|
||||
<OverlayTrigger
|
||||
placement="bottom-start"
|
||||
overlay={(
|
||||
<Tooltip
|
||||
id={titleTooltipMessage}
|
||||
className="item-card-header-tooltip"
|
||||
>
|
||||
{titleTooltipMessage}
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
iconBefore={isExpanded ? ArrowUpIcon : ArrowDownIcon}
|
||||
variant="tertiary"
|
||||
data-testid={`${namePrefix}-card-header__expanded-btn`}
|
||||
className="item-card-header__title-btn"
|
||||
onClick={onTitleClick}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</OverlayTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
TitleButton.defaultProps = {
|
||||
children: null,
|
||||
};
|
||||
|
||||
TitleButton.propTypes = {
|
||||
isExpanded: PropTypes.bool.isRequired,
|
||||
onTitleClick: PropTypes.func.isRequired,
|
||||
namePrefix: PropTypes.string.isRequired,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default TitleButton;
|
||||
31
src/course-outline/card-header/TitleLink.jsx
Normal file
31
src/course-outline/card-header/TitleLink.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
const TitleLink = ({
|
||||
titleLink,
|
||||
namePrefix,
|
||||
children,
|
||||
}) => (
|
||||
<Button
|
||||
as={Link}
|
||||
variant="tertiary"
|
||||
data-testid={`${namePrefix}-card-header__title-link`}
|
||||
className="item-card-header__title-btn"
|
||||
to={titleLink}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
|
||||
TitleLink.defaultProps = {
|
||||
children: null,
|
||||
};
|
||||
|
||||
TitleLink.propTypes = {
|
||||
titleLink: PropTypes.string.isRequired,
|
||||
namePrefix: PropTypes.string.isRequired,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default TitleLink;
|
||||
@@ -121,6 +121,23 @@ const slice = createSlice({
|
||||
return section;
|
||||
});
|
||||
},
|
||||
deleteUnit: (state, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.map((section) => {
|
||||
if (section.id !== payload.sectionId) {
|
||||
return section;
|
||||
}
|
||||
section.childInfo.children = section.childInfo.children.map((subsection) => {
|
||||
if (subsection.id !== payload.subsectionId) {
|
||||
return subsection;
|
||||
}
|
||||
subsection.childInfo.children = subsection.childInfo.children.filter(
|
||||
({ id }) => id !== payload.itemId,
|
||||
);
|
||||
return subsection;
|
||||
});
|
||||
return section;
|
||||
});
|
||||
},
|
||||
duplicateSection: (state, { payload }) => {
|
||||
state.sectionsList = state.sectionsList.reduce((result, currentValue) => {
|
||||
if (currentValue.id === payload.id) {
|
||||
@@ -149,6 +166,7 @@ export const {
|
||||
setCurrentSubsection,
|
||||
deleteSection,
|
||||
deleteSubsection,
|
||||
deleteUnit,
|
||||
duplicateSection,
|
||||
reorderSectionList,
|
||||
} = slice.actions;
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
updateFetchSectionLoadingStatus,
|
||||
deleteSection,
|
||||
deleteSubsection,
|
||||
deleteUnit,
|
||||
duplicateSection,
|
||||
reorderSectionList,
|
||||
} from './slice';
|
||||
@@ -264,6 +265,15 @@ export function deleteCourseSubsectionQuery(subsectionId, sectionId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteCourseUnitQuery(unitId, subsectionId, sectionId) {
|
||||
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
|
||||
@@ -316,6 +326,16 @@ export function duplicateSubsectionQuery(subsectionId, sectionId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function duplicateUnitQuery(unitId, subsectionId, sectionId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(duplicateCourseItemQuery(
|
||||
unitId,
|
||||
subsectionId,
|
||||
async () => dispatch(fetchCourseSectionQuery(sectionId, true)),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic function to add any course item. See wrapper functions below for specific implementations.
|
||||
* @param {string} parentLocator
|
||||
@@ -336,10 +356,7 @@ function addNewCourseItemQuery(parentLocator, category, displayName, addItemFn)
|
||||
displayName,
|
||||
).then(async (result) => {
|
||||
if (result) {
|
||||
const data = await getCourseItem(result.locator);
|
||||
// Page should scroll to newly created item.
|
||||
data.shouldScroll = true;
|
||||
dispatch(addItemFn(data));
|
||||
await addItemFn(result);
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
dispatch(hideProcessingNotification());
|
||||
}
|
||||
@@ -357,7 +374,12 @@ export function addNewSectionQuery(parentLocator) {
|
||||
parentLocator,
|
||||
COURSE_BLOCK_NAMES.chapter.id,
|
||||
COURSE_BLOCK_NAMES.chapter.name,
|
||||
(data) => addSection(data),
|
||||
async (result) => {
|
||||
const data = await getCourseItem(result.locator);
|
||||
// Page should scroll to newly created section.
|
||||
data.shouldScroll = true;
|
||||
dispatch(addSection(data));
|
||||
},
|
||||
));
|
||||
};
|
||||
}
|
||||
@@ -368,7 +390,23 @@ export function addNewSubsectionQuery(parentLocator) {
|
||||
parentLocator,
|
||||
COURSE_BLOCK_NAMES.sequential.id,
|
||||
COURSE_BLOCK_NAMES.sequential.name,
|
||||
(data) => addSubsection({ parentLocator, data }),
|
||||
async (result) => {
|
||||
const data = await getCourseItem(result.locator);
|
||||
// Page should scroll to newly created subsection.
|
||||
data.shouldScroll = true;
|
||||
dispatch(addSubsection({ parentLocator, data }));
|
||||
},
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
export function addNewUnitQuery(parentLocator, callback) {
|
||||
return async (dispatch) => {
|
||||
dispatch(addNewCourseItemQuery(
|
||||
parentLocator,
|
||||
COURSE_BLOCK_NAMES.vertical.id,
|
||||
COURSE_BLOCK_NAMES.vertical.name,
|
||||
async (result) => callback(result.locator),
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useToggle } from '@edx/paragon';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
import { RequestStatus } from '../data/constants';
|
||||
import { COURSE_BLOCK_NAMES } from './constants';
|
||||
@@ -22,11 +24,14 @@ import {
|
||||
import {
|
||||
addNewSectionQuery,
|
||||
addNewSubsectionQuery,
|
||||
addNewUnitQuery,
|
||||
deleteCourseSectionQuery,
|
||||
deleteCourseSubsectionQuery,
|
||||
deleteCourseUnitQuery,
|
||||
editCourseItemQuery,
|
||||
duplicateSectionQuery,
|
||||
duplicateSubsectionQuery,
|
||||
duplicateUnitQuery,
|
||||
enableCourseHighlightsEmailsQuery,
|
||||
fetchCourseBestPracticesQuery,
|
||||
fetchCourseLaunchQuery,
|
||||
@@ -40,6 +45,7 @@ import {
|
||||
|
||||
const useCourseOutline = ({ courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { reindexLink, courseStructure, lmsLink } = useSelector(getOutlineIndexData);
|
||||
const { outlineIndexLoadingStatus, reIndexLoadingStatus } = useSelector(getLoadingStatus);
|
||||
@@ -68,6 +74,26 @@ const useCourseOutline = ({ courseId }) => {
|
||||
dispatch(addNewSubsectionQuery(sectionId));
|
||||
};
|
||||
|
||||
const getUnitUrl = (locator) => {
|
||||
if (process.env.ENABLE_UNIT_PAGE === 'true') {
|
||||
return `/course/container/${locator}`;
|
||||
}
|
||||
return `${getConfig().STUDIO_BASE_URL}/container/${locator}`;
|
||||
};
|
||||
|
||||
const openUnitPage = (locator) => {
|
||||
const url = getUnitUrl(locator);
|
||||
if (process.env.ENABLE_UNIT_PAGE === 'true') {
|
||||
navigate(url);
|
||||
} else {
|
||||
window.location.assign(url);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewUnitSubmit = (subsectionId) => {
|
||||
dispatch(addNewUnitQuery(subsectionId, openUnitPage));
|
||||
};
|
||||
|
||||
const headerNavigationsActions = {
|
||||
handleNewSection: handleNewSectionSubmit,
|
||||
handleReIndex: () => {
|
||||
@@ -132,7 +158,11 @@ const useCourseOutline = ({ courseId }) => {
|
||||
dispatch(deleteCourseSubsectionQuery(currentItem.id, currentSection.id));
|
||||
break;
|
||||
case COURSE_BLOCK_NAMES.vertical.id:
|
||||
// delete unit
|
||||
dispatch(deleteCourseUnitQuery(
|
||||
currentItem.id,
|
||||
currentSubsection.id,
|
||||
currentSection.id,
|
||||
));
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
@@ -148,6 +178,10 @@ const useCourseOutline = ({ courseId }) => {
|
||||
dispatch(duplicateSubsectionQuery(currentSubsection.id, currentSection.id));
|
||||
};
|
||||
|
||||
const handleDuplicateUnitSubmit = () => {
|
||||
dispatch(duplicateUnitQuery(currentItem.id, currentSubsection.id, currentSection.id));
|
||||
};
|
||||
|
||||
const handleDragNDrop = (newListId, restoreCallback) => {
|
||||
dispatch(setSectionOrderListQuery(courseId, newListId, restoreCallback));
|
||||
};
|
||||
@@ -205,8 +239,12 @@ const useCourseOutline = ({ courseId }) => {
|
||||
handleDeleteItemSubmit,
|
||||
handleDuplicateSectionSubmit,
|
||||
handleDuplicateSubsectionSubmit,
|
||||
handleDuplicateUnitSubmit,
|
||||
handleNewSectionSubmit,
|
||||
handleNewSubsectionSubmit,
|
||||
getUnitUrl,
|
||||
openUnitPage,
|
||||
handleNewUnitSubmit,
|
||||
handleDragNDrop,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Add as IconAdd } from '@edx/paragon/icons';
|
||||
import { setCurrentItem, setCurrentSection } from '../data/slice';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import CardHeader from '../card-header/CardHeader';
|
||||
import BaseTitleWithStatusBadge from '../card-header/BaseTitleWithStatusBadge';
|
||||
import TitleButton from '../card-header/TitleButton';
|
||||
import { getItemStatus, scrollToElement } from '../utils';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -31,6 +33,7 @@ const SectionCard = ({
|
||||
const dispatch = useDispatch();
|
||||
const [isExpanded, setIsExpanded] = useState(isSectionsExpanded);
|
||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||
const namePrefix = 'section';
|
||||
|
||||
useEffect(() => {
|
||||
setIsExpanded(isSectionsExpanded);
|
||||
@@ -96,6 +99,20 @@ const SectionCard = ({
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
const titleComponent = (
|
||||
<TitleButton
|
||||
isExpanded={isExpanded}
|
||||
onTitleClick={handleExpandContent}
|
||||
namePrefix={namePrefix}
|
||||
>
|
||||
<BaseTitleWithStatusBadge
|
||||
title={displayName}
|
||||
status={sectionStatus}
|
||||
namePrefix={namePrefix}
|
||||
/>
|
||||
</TitleButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="section-card"
|
||||
@@ -108,8 +125,6 @@ const SectionCard = ({
|
||||
title={displayName}
|
||||
status={sectionStatus}
|
||||
hasChanges={hasChanges}
|
||||
isExpanded={isExpanded}
|
||||
onExpand={handleExpandContent}
|
||||
onClickMenuButton={handleClickMenuButton}
|
||||
onClickPublish={onOpenPublishModal}
|
||||
onClickConfigure={onOpenConfigureModal}
|
||||
@@ -120,7 +135,8 @@ const SectionCard = ({
|
||||
onEditSubmit={handleEditSubmit}
|
||||
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
namePrefix="section"
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
/>
|
||||
<div className="section-card__content" data-testid="section-card__content">
|
||||
<div className="outline-section__status">
|
||||
|
||||
@@ -8,6 +8,8 @@ import { Add as IconAdd } from '@edx/paragon/icons';
|
||||
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import CardHeader from '../card-header/CardHeader';
|
||||
import BaseTitleWithStatusBadge from '../card-header/BaseTitleWithStatusBadge';
|
||||
import TitleButton from '../card-header/TitleButton';
|
||||
import { getItemStatus, scrollToElement } from '../utils';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -20,12 +22,14 @@ const SubsectionCard = ({
|
||||
savingStatus,
|
||||
onOpenDeleteModal,
|
||||
onDuplicateSubmit,
|
||||
onNewUnitSubmit,
|
||||
}) => {
|
||||
const currentRef = useRef(null);
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||
const namePrefix = 'subsection';
|
||||
|
||||
const {
|
||||
id,
|
||||
@@ -65,6 +69,22 @@ const SubsectionCard = ({
|
||||
closeForm();
|
||||
};
|
||||
|
||||
const handleNewButtonClick = () => onNewUnitSubmit(id);
|
||||
|
||||
const titleComponent = (
|
||||
<TitleButton
|
||||
isExpanded={isExpanded}
|
||||
onTitleClick={handleExpandContent}
|
||||
namePrefix={namePrefix}
|
||||
>
|
||||
<BaseTitleWithStatusBadge
|
||||
title={displayName}
|
||||
status={subsectionStatus}
|
||||
namePrefix={namePrefix}
|
||||
/>
|
||||
</TitleButton>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// if this items has been newly added, scroll to it.
|
||||
// we need to check section.shouldScroll as whole section is fetched when a
|
||||
@@ -86,8 +106,6 @@ const SubsectionCard = ({
|
||||
title={displayName}
|
||||
status={subsectionStatus}
|
||||
hasChanges={hasChanges}
|
||||
isExpanded={isExpanded}
|
||||
onExpand={handleExpandContent}
|
||||
onClickMenuButton={handleClickMenuButton}
|
||||
onClickPublish={onOpenPublishModal}
|
||||
onClickEdit={openForm}
|
||||
@@ -97,23 +115,23 @@ const SubsectionCard = ({
|
||||
onEditSubmit={handleEditSubmit}
|
||||
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
namePrefix="subsection"
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
/>
|
||||
{isExpanded && (
|
||||
<>
|
||||
<div data-testid="subsection-card__units" className="subsection-card__units">
|
||||
{children}
|
||||
</div>
|
||||
<div data-testid="subsection-card__units" className="subsection-card__units">
|
||||
{children}
|
||||
<Button
|
||||
data-testid="new-unit-button"
|
||||
className="mt-4 bg-white"
|
||||
className="mt-4"
|
||||
variant="outline-primary"
|
||||
iconBefore={IconAdd}
|
||||
block
|
||||
onClick={handleNewButtonClick}
|
||||
>
|
||||
{intl.formatMessage(messages.newUnitButton)}
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -152,6 +170,7 @@ SubsectionCard.propTypes = {
|
||||
savingStatus: PropTypes.string.isRequired,
|
||||
onOpenDeleteModal: PropTypes.func.isRequired,
|
||||
onDuplicateSubmit: PropTypes.func.isRequired,
|
||||
onNewUnitSubmit: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default SubsectionCard;
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
margin: $spacer;
|
||||
}
|
||||
|
||||
.subsection-card__units {
|
||||
padding-top: $spacer;
|
||||
}
|
||||
|
||||
.item-card-header__badge-status {
|
||||
background: $light-100;
|
||||
}
|
||||
|
||||
155
src/course-outline/unit-card/UnitCard.jsx
Normal file
155
src/course-outline/unit-card/UnitCard.jsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useToggle } from '@edx/paragon';
|
||||
|
||||
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import CardHeader from '../card-header/CardHeader';
|
||||
import BaseTitleWithStatusBadge from '../card-header/BaseTitleWithStatusBadge';
|
||||
import TitleLink from '../card-header/TitleLink';
|
||||
import { getItemStatus, scrollToElement } from '../utils';
|
||||
|
||||
const UnitCard = ({
|
||||
unit,
|
||||
subsection,
|
||||
section,
|
||||
onOpenPublishModal,
|
||||
onEditSubmit,
|
||||
savingStatus,
|
||||
onOpenDeleteModal,
|
||||
onDuplicateSubmit,
|
||||
getTitleLink,
|
||||
}) => {
|
||||
const currentRef = useRef(null);
|
||||
const dispatch = useDispatch();
|
||||
const [isFormOpen, openForm, closeForm] = useToggle(false);
|
||||
const namePrefix = 'unit';
|
||||
|
||||
const {
|
||||
id,
|
||||
displayName,
|
||||
hasChanges,
|
||||
published,
|
||||
releasedToStudents,
|
||||
visibleToStaffOnly = false,
|
||||
visibilityState,
|
||||
staffOnlyMessage,
|
||||
} = unit;
|
||||
|
||||
const unitStatus = getItemStatus({
|
||||
published,
|
||||
releasedToStudents,
|
||||
visibleToStaffOnly,
|
||||
visibilityState,
|
||||
staffOnlyMessage,
|
||||
});
|
||||
|
||||
const handleClickMenuButton = () => {
|
||||
dispatch(setCurrentItem(unit));
|
||||
dispatch(setCurrentSection(section));
|
||||
dispatch(setCurrentSubsection(subsection));
|
||||
};
|
||||
|
||||
const handleEditSubmit = (titleValue) => {
|
||||
if (displayName !== titleValue) {
|
||||
onEditSubmit(id, section.id, titleValue);
|
||||
return;
|
||||
}
|
||||
|
||||
closeForm();
|
||||
};
|
||||
|
||||
const titleComponent = (
|
||||
<TitleLink
|
||||
titleLink={getTitleLink(id)}
|
||||
namePrefix={namePrefix}
|
||||
>
|
||||
<BaseTitleWithStatusBadge
|
||||
title={displayName}
|
||||
status={unitStatus}
|
||||
namePrefix={namePrefix}
|
||||
/>
|
||||
</TitleLink>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// if this items has been newly added, scroll to it.
|
||||
// we need to check section.shouldScroll as whole section is fetched when a
|
||||
// unit is duplicated under it.
|
||||
if (currentRef.current && (section.shouldScroll || unit.shouldScroll)) {
|
||||
scrollToElement(currentRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (savingStatus === RequestStatus.SUCCESSFUL) {
|
||||
closeForm();
|
||||
}
|
||||
}, [savingStatus]);
|
||||
|
||||
return (
|
||||
<div className="unit-card" data-testid="unit-card" ref={currentRef}>
|
||||
<CardHeader
|
||||
title={displayName}
|
||||
status={unitStatus}
|
||||
hasChanges={hasChanges}
|
||||
onClickMenuButton={handleClickMenuButton}
|
||||
onClickPublish={onOpenPublishModal}
|
||||
onClickEdit={openForm}
|
||||
onClickDelete={onOpenDeleteModal}
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
UnitCard.propTypes = {
|
||||
unit: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
published: PropTypes.bool.isRequired,
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
releasedToStudents: PropTypes.bool.isRequired,
|
||||
visibleToStaffOnly: PropTypes.bool,
|
||||
visibilityState: PropTypes.string.isRequired,
|
||||
staffOnlyMessage: PropTypes.bool.isRequired,
|
||||
shouldScroll: PropTypes.bool,
|
||||
}).isRequired,
|
||||
subsection: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
published: PropTypes.bool.isRequired,
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
releasedToStudents: PropTypes.bool.isRequired,
|
||||
visibleToStaffOnly: PropTypes.bool,
|
||||
visibilityState: PropTypes.string.isRequired,
|
||||
staffOnlyMessage: PropTypes.bool.isRequired,
|
||||
shouldScroll: PropTypes.bool,
|
||||
}).isRequired,
|
||||
section: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
published: PropTypes.bool.isRequired,
|
||||
hasChanges: PropTypes.bool.isRequired,
|
||||
releasedToStudents: PropTypes.bool.isRequired,
|
||||
visibleToStaffOnly: PropTypes.bool,
|
||||
visibilityState: PropTypes.string.isRequired,
|
||||
staffOnlyMessage: PropTypes.bool.isRequired,
|
||||
shouldScroll: PropTypes.bool,
|
||||
}).isRequired,
|
||||
onOpenPublishModal: PropTypes.func.isRequired,
|
||||
onEditSubmit: PropTypes.func.isRequired,
|
||||
savingStatus: PropTypes.string.isRequired,
|
||||
onOpenDeleteModal: PropTypes.func.isRequired,
|
||||
onDuplicateSubmit: PropTypes.func.isRequired,
|
||||
getTitleLink: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default UnitCard;
|
||||
26
src/course-outline/unit-card/UnitCard.scss
Normal file
26
src/course-outline/unit-card/UnitCard.scss
Normal file
@@ -0,0 +1,26 @@
|
||||
.unit-card {
|
||||
@include pgn-box-shadow(1, "centered");
|
||||
|
||||
padding: $spacer 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
background: $light-100;
|
||||
|
||||
.unit-card__content {
|
||||
margin: $spacer;
|
||||
}
|
||||
|
||||
.item-card-header__badge-status {
|
||||
background: $light-100;
|
||||
}
|
||||
|
||||
// used in src/course-outline/card-header/TitleLink.jsx &
|
||||
// src/course-outline/card-header/TitleButton.jsx as
|
||||
// `${namePrefix}-card-title`
|
||||
.unit-card-title {
|
||||
font-size: $h5-font-size;
|
||||
font-family: $headings-font-family;
|
||||
font-weight: $headings-font-weight;
|
||||
line-height: $headings-line-height;
|
||||
color: $headings-color;
|
||||
}
|
||||
}
|
||||
90
src/course-outline/unit-card/UnitCard.test.jsx
Normal file
90
src/course-outline/unit-card/UnitCard.test.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import initializeStore from '../../store';
|
||||
import UnitCard from './UnitCard';
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
let axiosMock;
|
||||
let store;
|
||||
|
||||
const section = {
|
||||
id: '1',
|
||||
displayName: 'Section Name',
|
||||
published: true,
|
||||
releasedToStudents: true,
|
||||
visibleToStaffOnly: false,
|
||||
visibilityState: 'visible',
|
||||
staffOnlyMessage: false,
|
||||
hasChanges: false,
|
||||
highlights: ['highlight 1', 'highlight 2'],
|
||||
};
|
||||
|
||||
const subsection = {
|
||||
id: '12',
|
||||
displayName: 'Subsection Name',
|
||||
published: true,
|
||||
releasedToStudents: true,
|
||||
visibleToStaffOnly: false,
|
||||
visibilityState: 'visible',
|
||||
staffOnlyMessage: false,
|
||||
hasChanges: false,
|
||||
};
|
||||
|
||||
const unit = {
|
||||
id: '123',
|
||||
displayName: 'unit Name',
|
||||
published: true,
|
||||
releasedToStudents: true,
|
||||
visibleToStaffOnly: false,
|
||||
visibilityState: 'visible',
|
||||
staffOnlyMessage: false,
|
||||
hasChanges: false,
|
||||
};
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
<UnitCard
|
||||
section={section}
|
||||
subsection={subsection}
|
||||
unit={unit}
|
||||
onOpenPublishModal={jest.fn()}
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSubmit={jest.fn()}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
getTitleLink={(id) => `/some/${id}`}
|
||||
{...props}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
</AppProvider>,
|
||||
);
|
||||
|
||||
describe('<UnitCard />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
|
||||
it('render UnitCard component correctly', async () => {
|
||||
const { findByTestId } = renderComponent();
|
||||
|
||||
expect(await findByTestId('unit-card-header')).toBeInTheDocument();
|
||||
expect(await findByTestId('unit-card-header__title-link')).toHaveAttribute('href', '/some/123');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user