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:
Navin Karkera
2023-12-15 21:02:10 +05:30
committed by Kristin Aoki
parent 1e23ce1062
commit faf90d1fa7
22 changed files with 904 additions and 239 deletions

View File

@@ -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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,10 @@
margin: $spacer;
}
.subsection-card__units {
padding-top: $spacer;
}
.item-card-header__badge-status {
background: $light-100;
}

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

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

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