Compare commits
21 Commits
test_hyper
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0ec87c969 | ||
|
|
4835f72f2c | ||
|
|
3ab329d373 | ||
|
|
7c97ffecb5 | ||
|
|
90727590dd | ||
|
|
1c82a67364 | ||
|
|
d08ef83659 | ||
|
|
13bce7e034 | ||
|
|
54888d03bc | ||
|
|
e6d9f3a50d | ||
|
|
74b455287e | ||
|
|
e2adb45493 | ||
|
|
d4e9a6bec2 | ||
|
|
e6741496dc | ||
|
|
9304a83bef | ||
|
|
3173f41e63 | ||
|
|
866dd9bd31 | ||
|
|
f10ad9f525 | ||
|
|
81d78b9613 | ||
|
|
4886df7d6f | ||
|
|
62dfb75169 |
22
package-lock.json
generated
22
package-lock.json
generated
@@ -64,8 +64,8 @@
|
|||||||
"react-onclickoutside": "^6.13.0",
|
"react-onclickoutside": "^6.13.0",
|
||||||
"react-redux": "7.2.9",
|
"react-redux": "7.2.9",
|
||||||
"react-responsive": "9.0.2",
|
"react-responsive": "9.0.2",
|
||||||
"react-router": "6.23.1",
|
"react-router": "6.27.0",
|
||||||
"react-router-dom": "6.23.1",
|
"react-router-dom": "6.27.0",
|
||||||
"react-select": "5.8.0",
|
"react-select": "5.8.0",
|
||||||
"react-textarea-autosize": "^8.5.3",
|
"react-textarea-autosize": "^8.5.3",
|
||||||
"react-transition-group": "4.4.5",
|
"react-transition-group": "4.4.5",
|
||||||
@@ -4275,7 +4275,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@remix-run/router": {
|
"node_modules/@remix-run/router": {
|
||||||
"version": "1.16.1",
|
"version": "1.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz",
|
||||||
|
"integrity": "sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
@@ -17514,10 +17516,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "6.23.1",
|
"version": "6.27.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.27.0.tgz",
|
||||||
|
"integrity": "sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/router": "1.16.1"
|
"@remix-run/router": "1.20.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
@@ -17527,11 +17531,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "6.23.1",
|
"version": "6.27.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.27.0.tgz",
|
||||||
|
"integrity": "sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@remix-run/router": "1.16.1",
|
"@remix-run/router": "1.20.0",
|
||||||
"react-router": "6.23.1"
|
"react-router": "6.27.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
|
|||||||
@@ -93,8 +93,8 @@
|
|||||||
"react-onclickoutside": "^6.13.0",
|
"react-onclickoutside": "^6.13.0",
|
||||||
"react-redux": "7.2.9",
|
"react-redux": "7.2.9",
|
||||||
"react-responsive": "9.0.2",
|
"react-responsive": "9.0.2",
|
||||||
"react-router": "6.23.1",
|
"react-router": "6.27.0",
|
||||||
"react-router-dom": "6.23.1",
|
"react-router-dom": "6.27.0",
|
||||||
"react-select": "5.8.0",
|
"react-select": "5.8.0",
|
||||||
"react-textarea-autosize": "^8.5.3",
|
"react-textarea-autosize": "^8.5.3",
|
||||||
"react-transition-group": "4.4.5",
|
"react-transition-group": "4.4.5",
|
||||||
|
|||||||
@@ -125,7 +125,8 @@ const CourseOutline = ({ courseId }) => {
|
|||||||
const [toastMessage, setToastMessage] = useState(/** @type{null|string} */ (null));
|
const [toastMessage, setToastMessage] = useState(/** @type{null|string} */ (null));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (location.hash === '#export-tags') {
|
// Wait for the course data to load before exporting tags.
|
||||||
|
if (courseId && courseName && location.hash === '#export-tags') {
|
||||||
setToastMessage(intl.formatMessage(messages.exportTagsCreatingToastMessage));
|
setToastMessage(intl.formatMessage(messages.exportTagsCreatingToastMessage));
|
||||||
getTagsExportFile(courseId, courseName).then(() => {
|
getTagsExportFile(courseId, courseName).then(() => {
|
||||||
setToastMessage(intl.formatMessage(messages.exportTagsSuccessToastMessage));
|
setToastMessage(intl.formatMessage(messages.exportTagsSuccessToastMessage));
|
||||||
@@ -136,7 +137,7 @@ const CourseOutline = ({ courseId }) => {
|
|||||||
// Delete `#export-tags` from location
|
// Delete `#export-tags` from location
|
||||||
window.location.href = '#';
|
window.location.href = '#';
|
||||||
}
|
}
|
||||||
}, [location]);
|
}, [location, courseId, courseName]);
|
||||||
|
|
||||||
const [sections, setSections] = useState(sectionsList);
|
const [sections, setSections] = useState(sectionsList);
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
import { EditOutline as EditIcon, MoreVert as MoveVertIcon } from '@openedx/paragon/icons';
|
import { EditOutline as EditIcon, MoreVert as MoveVertIcon } from '@openedx/paragon/icons';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { getCanEdit, getCourseId } from 'CourseAuthoring/course-unit/data/selectors';
|
import { getCanEdit, getCourseId } from 'CourseAuthoring/course-unit/data/selectors';
|
||||||
import DeleteModal from '../../generic/delete-modal/DeleteModal';
|
import DeleteModal from '../../generic/delete-modal/DeleteModal';
|
||||||
@@ -19,6 +19,7 @@ import { copyToClipboard } from '../../generic/data/thunks';
|
|||||||
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
|
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
|
||||||
import XBlockMessages from './xblock-messages/XBlockMessages';
|
import XBlockMessages from './xblock-messages/XBlockMessages';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
import { createCorrectInternalRoute } from '../../utils';
|
||||||
|
|
||||||
const CourseXBlock = ({
|
const CourseXBlock = ({
|
||||||
id, title, type, unitXBlockActions, shouldScroll, userPartitionInfo,
|
id, title, type, unitXBlockActions, shouldScroll, userPartitionInfo,
|
||||||
@@ -28,7 +29,6 @@ const CourseXBlock = ({
|
|||||||
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
|
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
|
||||||
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
|
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const navigate = useNavigate();
|
|
||||||
const canEdit = useSelector(getCanEdit);
|
const canEdit = useSelector(getCanEdit);
|
||||||
const courseId = useSelector(getCourseId);
|
const courseId = useSelector(getCourseId);
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -58,7 +58,11 @@ const CourseXBlock = ({
|
|||||||
case COMPONENT_TYPES.html:
|
case COMPONENT_TYPES.html:
|
||||||
case COMPONENT_TYPES.problem:
|
case COMPONENT_TYPES.problem:
|
||||||
case COMPONENT_TYPES.video:
|
case COMPONENT_TYPES.video:
|
||||||
navigate(`/course/${courseId}/editor/${type}/${id}`);
|
// Not using useNavigate from react router to use browser navigation
|
||||||
|
// which allows us to block back button if unsaved changes in editor are present.
|
||||||
|
window.location.assign(
|
||||||
|
createCorrectInternalRoute(`/course/${courseId}/editor/${type}/${id}`),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ const blockId = '567890';
|
|||||||
const handleDeleteMock = jest.fn();
|
const handleDeleteMock = jest.fn();
|
||||||
const handleDuplicateMock = jest.fn();
|
const handleDuplicateMock = jest.fn();
|
||||||
const handleConfigureSubmitMock = jest.fn();
|
const handleConfigureSubmitMock = jest.fn();
|
||||||
const mockedUsedNavigate = jest.fn();
|
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
block_id: id,
|
block_id: id,
|
||||||
@@ -42,11 +41,6 @@ const unitXBlockActionsMock = {
|
|||||||
handleDuplicate: handleDuplicateMock,
|
handleDuplicate: handleDuplicateMock,
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.mock('react-router-dom', () => ({
|
|
||||||
...jest.requireActual('react-router-dom'),
|
|
||||||
useNavigate: () => mockedUsedNavigate,
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('react-redux', () => ({
|
jest.mock('react-redux', () => ({
|
||||||
...jest.requireActual('react-redux'),
|
...jest.requireActual('react-redux'),
|
||||||
useSelector: jest.fn(),
|
useSelector: jest.fn(),
|
||||||
@@ -78,6 +72,16 @@ useSelector.mockImplementation((selector) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('<CourseXBlock />', () => {
|
describe('<CourseXBlock />', () => {
|
||||||
|
const locationTemp = window.location;
|
||||||
|
beforeAll(() => {
|
||||||
|
delete window.location;
|
||||||
|
window.location = {
|
||||||
|
assign: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
afterAll(() => {
|
||||||
|
window.location = locationTemp;
|
||||||
|
});
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
initializeMockApp({
|
initializeMockApp({
|
||||||
authenticatedUser: {
|
authenticatedUser: {
|
||||||
@@ -168,8 +172,8 @@ describe('<CourseXBlock />', () => {
|
|||||||
expect(editButton).toBeInTheDocument();
|
expect(editButton).toBeInTheDocument();
|
||||||
|
|
||||||
userEvent.click(editButton);
|
userEvent.click(editButton);
|
||||||
expect(mockedUsedNavigate).toHaveBeenCalled();
|
expect(window.location.assign).toHaveBeenCalled();
|
||||||
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/editor/html/${id}`);
|
expect(window.location.assign).toHaveBeenCalledWith(`/course/${courseId}/editor/html/${id}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('navigates to editor page on edit Video xblock', () => {
|
it('navigates to editor page on edit Video xblock', () => {
|
||||||
@@ -182,8 +186,8 @@ describe('<CourseXBlock />', () => {
|
|||||||
expect(editButton).toBeInTheDocument();
|
expect(editButton).toBeInTheDocument();
|
||||||
|
|
||||||
userEvent.click(editButton);
|
userEvent.click(editButton);
|
||||||
expect(mockedUsedNavigate).toHaveBeenCalled();
|
expect(window.location.assign).toHaveBeenCalled();
|
||||||
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/editor/video/${id}`);
|
expect(window.location.assign).toHaveBeenCalledWith(`/course/${courseId}/editor/video/${id}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('navigates to editor page on edit Problem xblock', () => {
|
it('navigates to editor page on edit Problem xblock', () => {
|
||||||
@@ -196,8 +200,8 @@ describe('<CourseXBlock />', () => {
|
|||||||
expect(editButton).toBeInTheDocument();
|
expect(editButton).toBeInTheDocument();
|
||||||
|
|
||||||
userEvent.click(editButton);
|
userEvent.click(editButton);
|
||||||
expect(mockedUsedNavigate).toHaveBeenCalled();
|
expect(window.location.assign).toHaveBeenCalled();
|
||||||
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/editor/problem/${id}`);
|
expect(window.location.assign).toHaveBeenCalledWith(`/course/${courseId}/editor/problem/${id}`);
|
||||||
expect(handleDeleteMock).toHaveBeenCalledWith(id);
|
expect(handleDeleteMock).toHaveBeenCalledWith(id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ jest.spyOn(editorCmsApi, 'fetchByUnitId').mockImplementation(async () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const isDirtyMock = jest.fn();
|
||||||
|
jest.mock('../TextEditor/hooks', () => ({
|
||||||
|
...jest.requireActual('../TextEditor/hooks'),
|
||||||
|
isDirty: () => isDirtyMock,
|
||||||
|
}));
|
||||||
|
|
||||||
const defaultPropsHtml = {
|
const defaultPropsHtml = {
|
||||||
blockId: 'block-v1:Org+TS100+24+type@html+block@123456html',
|
blockId: 'block-v1:Org+TS100+24+type@html+block@123456html',
|
||||||
blockType: 'html',
|
blockType: 'html',
|
||||||
@@ -45,15 +51,27 @@ const fieldsHtml = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('EditorContainer', () => {
|
describe('EditorContainer', () => {
|
||||||
|
let mockEvent: Event;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
initializeMocks();
|
initializeMocks();
|
||||||
|
mockEvent = new Event('beforeunload');
|
||||||
|
jest.spyOn(window, 'addEventListener');
|
||||||
|
jest.spyOn(window, 'removeEventListener');
|
||||||
|
jest.spyOn(mockEvent, 'preventDefault');
|
||||||
|
Object.defineProperty(mockEvent, 'returnValue', { writable: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it displays a confirmation dialog when closing the editor modal', async () => {
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it displays a confirmation dialog when closing the editor modal if data is changed', async () => {
|
||||||
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => (
|
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => (
|
||||||
{ status: 200, data: snakeCaseObject(fieldsHtml) }
|
{ status: 200, data: snakeCaseObject(fieldsHtml) }
|
||||||
));
|
));
|
||||||
|
|
||||||
|
isDirtyMock.mockReturnValue(true);
|
||||||
render(<EditorPage {...defaultPropsHtml} />);
|
render(<EditorPage {...defaultPropsHtml} />);
|
||||||
|
|
||||||
// Then the editor should open
|
// Then the editor should open
|
||||||
@@ -68,12 +86,48 @@ describe('EditorContainer', () => {
|
|||||||
fireEvent.click(closeButton);
|
fireEvent.click(closeButton);
|
||||||
// Now we should see the confirmation message:
|
// Now we should see the confirmation message:
|
||||||
expect(await screen.findByText(confirmMessage)).toBeInTheDocument();
|
expect(await screen.findByText(confirmMessage)).toBeInTheDocument();
|
||||||
|
|
||||||
expect(defaultPropsHtml.onClose).not.toHaveBeenCalled();
|
expect(defaultPropsHtml.onClose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Should close modal if cancelled
|
||||||
|
const cancelBtn = await screen.findByRole('button', { name: 'Cancel' });
|
||||||
|
fireEvent.click(cancelBtn);
|
||||||
|
expect(defaultPropsHtml.onClose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// open modal again
|
||||||
|
fireEvent.click(closeButton);
|
||||||
// And can confirm the cancelation:
|
// And can confirm the cancelation:
|
||||||
const confirmButton = await screen.findByRole('button', { name: 'OK' });
|
const confirmButton = await screen.findByRole('button', { name: 'OK' });
|
||||||
fireEvent.click(confirmButton);
|
fireEvent.click(confirmButton);
|
||||||
expect(defaultPropsHtml.onClose).toHaveBeenCalled();
|
expect(defaultPropsHtml.onClose).toHaveBeenCalled();
|
||||||
|
window.dispatchEvent(mockEvent);
|
||||||
|
// should not be blocked by beforeunload event as the page was unloaded using close/cancel option
|
||||||
|
expect(window.removeEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
|
||||||
|
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it does not display any confirmation dialog when closing the editor modal if data is not changed', async () => {
|
||||||
|
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => (
|
||||||
|
{ status: 200, data: snakeCaseObject(fieldsHtml) }
|
||||||
|
));
|
||||||
|
|
||||||
|
isDirtyMock.mockReturnValue(false);
|
||||||
|
render(<EditorPage {...defaultPropsHtml} />);
|
||||||
|
|
||||||
|
// Then the editor should open
|
||||||
|
expect(await screen.findByRole('heading', { name: /Introduction to Testing/ })).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Assert the "are you sure?" message isn't visible yet
|
||||||
|
const confirmMessage = /Are you sure you want to exit the editor/;
|
||||||
|
expect(screen.queryByText(confirmMessage)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Find and click the close button
|
||||||
|
const closeButton = await screen.findByRole('button', { name: 'Exit the editor' });
|
||||||
|
fireEvent.click(closeButton);
|
||||||
|
// Even now we should not see the confirmation message as data is not dirty, i.e. not changed:
|
||||||
|
expect(screen.queryByText(confirmMessage)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// And onClose is directly called
|
||||||
|
expect(defaultPropsHtml.onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('it disables the save button until the fields have been loaded', async () => {
|
test('it disables the save button until the fields have been loaded', async () => {
|
||||||
@@ -94,4 +148,21 @@ describe('EditorContainer', () => {
|
|||||||
// Now the save button should be active:
|
// Now the save button should be active:
|
||||||
await waitFor(() => expect(saveButton).not.toBeDisabled());
|
await waitFor(() => expect(saveButton).not.toBeDisabled());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('beforeunload event is triggered on page unload if data is changed', async () => {
|
||||||
|
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => (
|
||||||
|
{ status: 200, data: snakeCaseObject(fieldsHtml) }
|
||||||
|
));
|
||||||
|
|
||||||
|
isDirtyMock.mockReturnValue(true);
|
||||||
|
render(<EditorPage {...defaultPropsHtml} />);
|
||||||
|
|
||||||
|
// Then the editor should open
|
||||||
|
expect(await screen.findByRole('heading', { name: /Introduction to Testing/ })).toBeInTheDocument();
|
||||||
|
// on beforeunload event block user
|
||||||
|
window.dispatchEvent(mockEvent);
|
||||||
|
expect(window.removeEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
|
||||||
|
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||||
|
expect(mockEvent.returnValue).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import TitleHeader from './components/TitleHeader';
|
|||||||
import * as hooks from './hooks';
|
import * as hooks from './hooks';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
import usePromptIfDirty from '../../../generic/promptIfDirty/usePromptIfDirty';
|
||||||
|
|
||||||
interface WrapperProps {
|
interface WrapperProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -61,32 +62,57 @@ export const FooterWrapper: React.FC<WrapperProps> = ({ children }) => {
|
|||||||
interface Props extends EditorComponent {
|
interface Props extends EditorComponent {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
getContent: Function;
|
getContent: Function;
|
||||||
|
isDirty: () => boolean;
|
||||||
validateEntry?: Function | null;
|
validateEntry?: Function | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EditorContainer: React.FC<Props> = ({
|
const EditorContainer: React.FC<Props> = ({
|
||||||
children,
|
children,
|
||||||
getContent,
|
getContent,
|
||||||
|
isDirty,
|
||||||
onClose = null,
|
onClose = null,
|
||||||
validateEntry = null,
|
validateEntry = null,
|
||||||
returnFunction = null,
|
returnFunction = null,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
// Required to mark data as not dirty on save
|
||||||
|
const [saved, setSaved] = React.useState(false);
|
||||||
const isInitialized = hooks.isInitialized();
|
const isInitialized = hooks.isInitialized();
|
||||||
const { isCancelConfirmOpen, openCancelConfirmModal, closeCancelConfirmModal } = hooks.cancelConfirmModalToggle();
|
const { isCancelConfirmOpen, openCancelConfirmModal, closeCancelConfirmModal } = hooks.cancelConfirmModalToggle();
|
||||||
const handleCancel = hooks.handleCancel({ onClose, returnFunction });
|
const handleCancel = hooks.handleCancel({ onClose, returnFunction });
|
||||||
const disableSave = !isInitialized;
|
const disableSave = !isInitialized;
|
||||||
const saveFailed = hooks.saveFailed();
|
const saveFailed = hooks.saveFailed();
|
||||||
const clearSaveFailed = hooks.clearSaveError({ dispatch });
|
const clearSaveFailed = hooks.clearSaveError({ dispatch });
|
||||||
const onSave = hooks.handleSaveClicked({
|
const handleSave = hooks.handleSaveClicked({
|
||||||
dispatch,
|
dispatch,
|
||||||
getContent,
|
getContent,
|
||||||
validateEntry,
|
validateEntry,
|
||||||
returnFunction,
|
returnFunction,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const onSave = () => {
|
||||||
|
setSaved(true);
|
||||||
|
handleSave();
|
||||||
|
};
|
||||||
|
// Stops user from navigating away if they have unsaved changes.
|
||||||
|
usePromptIfDirty(() => {
|
||||||
|
// Do not block if cancel modal is used or data is saved.
|
||||||
|
if (isCancelConfirmOpen || saved) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isDirty();
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmCancelIfDirty = () => {
|
||||||
|
if (isDirty()) {
|
||||||
|
openCancelConfirmModal();
|
||||||
|
} else {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<EditorModalWrapper onClose={openCancelConfirmModal}>
|
<EditorModalWrapper onClose={confirmCancelIfDirty}>
|
||||||
{saveFailed && (
|
{saveFailed && (
|
||||||
<Toast show onClose={clearSaveFailed}>
|
<Toast show onClose={clearSaveFailed}>
|
||||||
<FormattedMessage {...messages.contentSaveFailed} />
|
<FormattedMessage {...messages.contentSaveFailed} />
|
||||||
@@ -108,7 +134,9 @@ const EditorContainer: React.FC<Props> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
isOpen={isCancelConfirmOpen}
|
isOpen={isCancelConfirmOpen}
|
||||||
close={closeCancelConfirmModal}
|
close={() => {
|
||||||
|
closeCancelConfirmModal();
|
||||||
|
}}
|
||||||
title={intl.formatMessage(messages.cancelConfirmTitle)}
|
title={intl.formatMessage(messages.cancelConfirmTitle)}
|
||||||
>
|
>
|
||||||
<FormattedMessage {...messages.cancelConfirmDescription} />
|
<FormattedMessage {...messages.cancelConfirmDescription} />
|
||||||
@@ -121,7 +149,7 @@ const EditorContainer: React.FC<Props> = ({
|
|||||||
<IconButton
|
<IconButton
|
||||||
src={Close}
|
src={Close}
|
||||||
iconAs={Icon}
|
iconAs={Icon}
|
||||||
onClick={openCancelConfirmModal}
|
onClick={confirmCancelIfDirty}
|
||||||
alt={intl.formatMessage(messages.exitButtonAlt)}
|
alt={intl.formatMessage(messages.exitButtonAlt)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,7 +163,7 @@ const EditorContainer: React.FC<Props> = ({
|
|||||||
<Button
|
<Button
|
||||||
aria-label={intl.formatMessage(messages.cancelButtonAriaLabel)}
|
aria-label={intl.formatMessage(messages.cancelButtonAriaLabel)}
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
onClick={openCancelConfirmModal}
|
onClick={confirmCancelIfDirty}
|
||||||
>
|
>
|
||||||
<FormattedMessage {...messages.cancelButtonLabel} />
|
<FormattedMessage {...messages.cancelButtonLabel} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
exports[`EditorProblemView component renders raw editor 1`] = `
|
exports[`EditorProblemView component renders raw editor 1`] = `
|
||||||
<EditorContainer
|
<EditorContainer
|
||||||
getContent={[Function]}
|
getContent={[Function]}
|
||||||
|
isDirty={[Function]}
|
||||||
returnFunction={null}
|
returnFunction={null}
|
||||||
>
|
>
|
||||||
<AlertModal
|
<AlertModal
|
||||||
@@ -72,6 +73,7 @@ exports[`EditorProblemView component renders raw editor 1`] = `
|
|||||||
exports[`EditorProblemView component renders simple view 1`] = `
|
exports[`EditorProblemView component renders simple view 1`] = `
|
||||||
<EditorContainer
|
<EditorContainer
|
||||||
getContent={[Function]}
|
getContent={[Function]}
|
||||||
|
isDirty={[Function]}
|
||||||
returnFunction={null}
|
returnFunction={null}
|
||||||
>
|
>
|
||||||
<AlertModal
|
<AlertModal
|
||||||
|
|||||||
@@ -20,6 +20,19 @@ export const saveWarningModalToggle = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Checks if any tinymce editor in window is dirty */
|
||||||
|
export const checkIfEditorsDirty = () => {
|
||||||
|
const EditorsArray = window.tinymce.editors;
|
||||||
|
return Object.entries(EditorsArray).some(([id, editor]) => {
|
||||||
|
if (Number.isNaN(parseInt(id, 10))) {
|
||||||
|
if (!editor.isNotDirty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchEditorContent = ({ format }) => {
|
export const fetchEditorContent = ({ format }) => {
|
||||||
const editorObject = { hints: [] };
|
const editorObject = { hints: [] };
|
||||||
const EditorsArray = window.tinymce.editors;
|
const EditorsArray = window.tinymce.editors;
|
||||||
|
|||||||
@@ -362,3 +362,43 @@ describe('EditProblemView hooks parseState', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('checkIfEditorsDirty', () => {
|
||||||
|
let windowSpy;
|
||||||
|
beforeEach(() => {
|
||||||
|
windowSpy = jest.spyOn(window, 'window', 'get');
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
windowSpy.mockRestore();
|
||||||
|
});
|
||||||
|
describe('state hook', () => {
|
||||||
|
test('should return false if none of editors are dirty', () => {
|
||||||
|
windowSpy.mockImplementation(() => ({
|
||||||
|
tinymce: {
|
||||||
|
editors: {
|
||||||
|
some_id: { isNotDirty: true },
|
||||||
|
some_id2: { isNotDirty: true },
|
||||||
|
some_id3: { isNotDirty: true },
|
||||||
|
some_id4: { isNotDirty: true },
|
||||||
|
some_id5: { isNotDirty: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
expect(hooks.checkIfEditorsDirty()).toEqual(false);
|
||||||
|
});
|
||||||
|
test('should return true if any editor is dirty', () => {
|
||||||
|
windowSpy.mockImplementation(() => ({
|
||||||
|
tinymce: {
|
||||||
|
editors: {
|
||||||
|
some_id: { isNotDirty: true },
|
||||||
|
some_id2: { isNotDirty: true },
|
||||||
|
some_id3: { isNotDirty: false },
|
||||||
|
some_id4: { isNotDirty: true },
|
||||||
|
some_id5: { isNotDirty: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
expect(hooks.checkIfEditorsDirty()).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import { selectors } from '../../../../data/redux';
|
|||||||
import RawEditor from '../../../../sharedComponents/RawEditor';
|
import RawEditor from '../../../../sharedComponents/RawEditor';
|
||||||
import { ProblemTypeKeys } from '../../../../data/constants/problem';
|
import { ProblemTypeKeys } from '../../../../data/constants/problem';
|
||||||
|
|
||||||
import { parseState, saveWarningModalToggle, getContent } from './hooks';
|
import {
|
||||||
|
checkIfEditorsDirty, parseState, saveWarningModalToggle, getContent,
|
||||||
|
} from './hooks';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ const EditProblemView = ({
|
|||||||
lmsEndpointUrl,
|
lmsEndpointUrl,
|
||||||
returnUrl,
|
returnUrl,
|
||||||
analytics,
|
analytics,
|
||||||
|
isDirty,
|
||||||
// injected
|
// injected
|
||||||
intl,
|
intl,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -40,6 +43,14 @@ const EditProblemView = ({
|
|||||||
const isAdvancedProblemType = problemType === ProblemTypeKeys.ADVANCED;
|
const isAdvancedProblemType = problemType === ProblemTypeKeys.ADVANCED;
|
||||||
const { isSaveWarningModalOpen, openSaveWarningModal, closeSaveWarningModal } = saveWarningModalToggle();
|
const { isSaveWarningModalOpen, openSaveWarningModal, closeSaveWarningModal } = saveWarningModalToggle();
|
||||||
|
|
||||||
|
const checkIfDirty = () => {
|
||||||
|
if (isAdvancedProblemType && editorRef && editorRef?.current) {
|
||||||
|
/* istanbul ignore next */
|
||||||
|
return editorRef.current.observer?.lastChange !== 0;
|
||||||
|
}
|
||||||
|
return isDirty || checkIfEditorsDirty();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditorContainer
|
<EditorContainer
|
||||||
getContent={() => getContent({
|
getContent={() => getContent({
|
||||||
@@ -49,6 +60,7 @@ const EditProblemView = ({
|
|||||||
editorRef,
|
editorRef,
|
||||||
lmsEndpointUrl,
|
lmsEndpointUrl,
|
||||||
})}
|
})}
|
||||||
|
isDirty={checkIfDirty}
|
||||||
returnFunction={returnFunction}
|
returnFunction={returnFunction}
|
||||||
>
|
>
|
||||||
<AlertModal
|
<AlertModal
|
||||||
@@ -117,6 +129,7 @@ const EditProblemView = ({
|
|||||||
EditProblemView.defaultProps = {
|
EditProblemView.defaultProps = {
|
||||||
lmsEndpointUrl: null,
|
lmsEndpointUrl: null,
|
||||||
returnFunction: null,
|
returnFunction: null,
|
||||||
|
isDirty: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
EditProblemView.propTypes = {
|
EditProblemView.propTypes = {
|
||||||
@@ -127,6 +140,7 @@ EditProblemView.propTypes = {
|
|||||||
analytics: PropTypes.shape({}).isRequired,
|
analytics: PropTypes.shape({}).isRequired,
|
||||||
lmsEndpointUrl: PropTypes.string,
|
lmsEndpointUrl: PropTypes.string,
|
||||||
returnUrl: PropTypes.string.isRequired,
|
returnUrl: PropTypes.string.isRequired,
|
||||||
|
isDirty: PropTypes.bool,
|
||||||
// injected
|
// injected
|
||||||
intl: intlShape.isRequired,
|
intl: intlShape.isRequired,
|
||||||
};
|
};
|
||||||
@@ -137,6 +151,7 @@ export const mapStateToProps = (state) => ({
|
|||||||
returnUrl: selectors.app.returnUrl(state),
|
returnUrl: selectors.app.returnUrl(state),
|
||||||
problemType: selectors.problem.problemType(state),
|
problemType: selectors.problem.problemType(state),
|
||||||
problemState: selectors.problem.completeState(state),
|
problemState: selectors.problem.completeState(state),
|
||||||
|
isDirty: selectors.problem.isDirty(state),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const EditProblemViewInternal = EditProblemView; // For testing only
|
export const EditProblemViewInternal = EditProblemView; // For testing only
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = `
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
isDirty={
|
||||||
|
{
|
||||||
|
"isDirty": {
|
||||||
|
"editorRef": {
|
||||||
|
"current": {
|
||||||
|
"value": "something",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"showRawEditor": false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
onClose={[MockFunction props.onClose]}
|
onClose={[MockFunction props.onClose]}
|
||||||
returnFunction={null}
|
returnFunction={null}
|
||||||
>
|
>
|
||||||
@@ -67,6 +79,18 @@ exports[`TextEditor snapshots loaded, raw editor 1`] = `
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
isDirty={
|
||||||
|
{
|
||||||
|
"isDirty": {
|
||||||
|
"editorRef": {
|
||||||
|
"current": {
|
||||||
|
"value": "something",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"showRawEditor": true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
onClose={[MockFunction props.onClose]}
|
onClose={[MockFunction props.onClose]}
|
||||||
returnFunction={null}
|
returnFunction={null}
|
||||||
>
|
>
|
||||||
@@ -114,6 +138,18 @@ exports[`TextEditor snapshots not yet loaded, Spinner appears 1`] = `
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
isDirty={
|
||||||
|
{
|
||||||
|
"isDirty": {
|
||||||
|
"editorRef": {
|
||||||
|
"current": {
|
||||||
|
"value": "something",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"showRawEditor": false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
onClose={[MockFunction props.onClose]}
|
onClose={[MockFunction props.onClose]}
|
||||||
returnFunction={null}
|
returnFunction={null}
|
||||||
>
|
>
|
||||||
@@ -153,6 +189,18 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = `
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
isDirty={
|
||||||
|
{
|
||||||
|
"isDirty": {
|
||||||
|
"editorRef": {
|
||||||
|
"current": {
|
||||||
|
"value": "something",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"showRawEditor": false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
onClose={[MockFunction props.onClose]}
|
onClose={[MockFunction props.onClose]}
|
||||||
returnFunction={null}
|
returnFunction={null}
|
||||||
>
|
>
|
||||||
@@ -206,6 +254,18 @@ exports[`TextEditor snapshots renders static images with relative paths 1`] = `
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
isDirty={
|
||||||
|
{
|
||||||
|
"isDirty": {
|
||||||
|
"editorRef": {
|
||||||
|
"current": {
|
||||||
|
"value": "something",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"showRawEditor": false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
onClose={[MockFunction props.onClose]}
|
onClose={[MockFunction props.onClose]}
|
||||||
returnFunction={null}
|
returnFunction={null}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,3 +9,14 @@ export const getContent = ({ editorRef, showRawEditor }) => () => {
|
|||||||
: editorRef.current?.getContent());
|
: editorRef.current?.getContent());
|
||||||
return setAssetToStaticUrl({ editorValue: content });
|
return setAssetToStaticUrl({ editorValue: content });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isDirty = ({ editorRef, showRawEditor }) => () => {
|
||||||
|
/* istanbul ignore next */
|
||||||
|
if (!editorRef?.current) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const dirty = (showRawEditor && editorRef && editorRef.current
|
||||||
|
? editorRef.current.observer?.lastChange !== 0
|
||||||
|
: !editorRef.current.isNotDirty);
|
||||||
|
return dirty;
|
||||||
|
};
|
||||||
|
|||||||
@@ -61,5 +61,26 @@ describe('TextEditor hooks', () => {
|
|||||||
expect(getContent).toEqual(rawContent);
|
expect(getContent).toEqual(rawContent);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('isDirty', () => {
|
||||||
|
test('checks isNotDirty flag when showRawEditor is false', () => {
|
||||||
|
const editorRef = {
|
||||||
|
current: {
|
||||||
|
isNotDirty: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const isDirty = module.isDirty({ editorRef, showRawEditor: false })();
|
||||||
|
expect(isDirty).toEqual(true);
|
||||||
|
});
|
||||||
|
test('checks observer.lastChange flag when showRawEditor is true', () => {
|
||||||
|
const editorRef = {
|
||||||
|
current: {
|
||||||
|
observer: { lastChange: 123 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const isDirty = module.isDirty({ editorRef, showRawEditor: true })();
|
||||||
|
expect(isDirty).toEqual(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ const TextEditor = ({
|
|||||||
return (
|
return (
|
||||||
<EditorContainer
|
<EditorContainer
|
||||||
getContent={hooks.getContent({ editorRef, showRawEditor })}
|
getContent={hooks.getContent({ editorRef, showRawEditor })}
|
||||||
|
isDirty={hooks.isDirty({ editorRef, showRawEditor })}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
returnFunction={returnFunction}
|
returnFunction={returnFunction}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ jest.mock('../EditorContainer', () => 'EditorContainer');
|
|||||||
|
|
||||||
jest.mock('./hooks', () => ({
|
jest.mock('./hooks', () => ({
|
||||||
getContent: jest.fn(args => ({ getContent: args })),
|
getContent: jest.fn(args => ({ getContent: args })),
|
||||||
|
isDirty: jest.fn(args => ({ isDirty: args })),
|
||||||
nullMethod: jest.fn().mockName('hooks.nullMethod'),
|
nullMethod: jest.fn().mockName('hooks.nullMethod'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ exports[`VideoEditor snapshots renders as expected with default behavior 1`] = `
|
|||||||
value="hooks.errorsHook.error"
|
value="hooks.errorsHook.error"
|
||||||
>
|
>
|
||||||
<EditorContainer
|
<EditorContainer
|
||||||
|
isDirty={[Function]}
|
||||||
onClose={[MockFunction props.onClose]}
|
onClose={[MockFunction props.onClose]}
|
||||||
validateEntry={[MockFunction validateEntry]}
|
validateEntry={[MockFunction validateEntry]}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import hooks from './hooks';
|
|||||||
import LanguageNamesWidget from './LanguageNamesWidget';
|
import LanguageNamesWidget from './LanguageNamesWidget';
|
||||||
import videoThumbnail from '../../../../../../data/images/videoThumbnail.svg';
|
import videoThumbnail from '../../../../../../data/images/videoThumbnail.svg';
|
||||||
|
|
||||||
const VideoPreviewWidget = ({
|
// Exporting to test this component separately
|
||||||
|
export const VideoPreviewWidget = ({
|
||||||
thumbnail,
|
thumbnail,
|
||||||
videoSource,
|
videoSource,
|
||||||
transcripts,
|
transcripts,
|
||||||
blockTitle,
|
blockTitle,
|
||||||
|
isLibrary,
|
||||||
intl,
|
intl,
|
||||||
}) => {
|
}) => {
|
||||||
const imgRef = React.useRef();
|
const imgRef = React.useRef();
|
||||||
@@ -45,7 +47,10 @@ const VideoPreviewWidget = ({
|
|||||||
/>
|
/>
|
||||||
<Stack gap={1} className="justify-content-center">
|
<Stack gap={1} className="justify-content-center">
|
||||||
<h4 className="text-primary mb-0">{blockTitle}</h4>
|
<h4 className="text-primary mb-0">{blockTitle}</h4>
|
||||||
<LanguageNamesWidget transcripts={transcripts} />
|
{!isLibrary && (
|
||||||
|
// Since content libraries v2 don't support static assets yet, we can't include transcripts.
|
||||||
|
<LanguageNamesWidget transcripts={transcripts} />
|
||||||
|
)}
|
||||||
{videoType && (
|
{videoType && (
|
||||||
<Hyperlink
|
<Hyperlink
|
||||||
className="text-primary x-small"
|
className="text-primary x-small"
|
||||||
@@ -69,6 +74,7 @@ VideoPreviewWidget.propTypes = {
|
|||||||
thumbnail: PropTypes.string.isRequired,
|
thumbnail: PropTypes.string.isRequired,
|
||||||
transcripts: PropTypes.arrayOf(PropTypes.string).isRequired,
|
transcripts: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||||
blockTitle: PropTypes.string.isRequired,
|
blockTitle: PropTypes.string.isRequired,
|
||||||
|
isLibrary: PropTypes.bool.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const mapStateToProps = (state) => ({
|
export const mapStateToProps = (state) => ({
|
||||||
@@ -76,6 +82,7 @@ export const mapStateToProps = (state) => ({
|
|||||||
videoSource: selectors.video.videoSource(state),
|
videoSource: selectors.video.videoSource(state),
|
||||||
thumbnail: selectors.video.thumbnail(state),
|
thumbnail: selectors.video.thumbnail(state),
|
||||||
blockTitle: selectors.app.blockTitle(state),
|
blockTitle: selectors.app.blockTitle(state),
|
||||||
|
isLibrary: selectors.app.isLibrary(state),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default injectIntl(connect(mapStateToProps)(VideoPreviewWidget));
|
export default injectIntl(connect(mapStateToProps)(VideoPreviewWidget));
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
initializeMocks,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
} from '../../../../../../../testUtils';
|
||||||
|
|
||||||
|
import { VideoPreviewWidget } from '.';
|
||||||
|
|
||||||
|
describe('VideoPreviewWidget', () => {
|
||||||
|
const mockIntl = {
|
||||||
|
formatMessage: (message) => message.defaultMessage,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
initializeMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('render', () => {
|
||||||
|
test('renders transcripts section in preview for courses', () => {
|
||||||
|
render(
|
||||||
|
<VideoPreviewWidget
|
||||||
|
videoSource="some-source"
|
||||||
|
isLibrary={false}
|
||||||
|
intl={mockIntl}
|
||||||
|
transcripts={[]}
|
||||||
|
blockTitle="some title"
|
||||||
|
thumbnail=""
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('No transcripts added')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hides transcripts section in preview for libraries', () => {
|
||||||
|
render(
|
||||||
|
<VideoPreviewWidget
|
||||||
|
videoSource="some-source"
|
||||||
|
isLibrary
|
||||||
|
intl={mockIntl}
|
||||||
|
transcripts={[]}
|
||||||
|
blockTitle="some title"
|
||||||
|
thumbnail=""
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('No transcripts added')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,7 +11,8 @@ import LicenseWidget from './components/LicenseWidget';
|
|||||||
import ThumbnailWidget from './components/ThumbnailWidget';
|
import ThumbnailWidget from './components/ThumbnailWidget';
|
||||||
import TranscriptWidget from './components/TranscriptWidget';
|
import TranscriptWidget from './components/TranscriptWidget';
|
||||||
import VideoSourceWidget from './components/VideoSourceWidget';
|
import VideoSourceWidget from './components/VideoSourceWidget';
|
||||||
import VideoPreviewWidget from './components/VideoPreviewWidget';
|
// Using default import to get selectors connected VideoSourceWidget
|
||||||
|
import ConnectedVideoPreviewWidget from './components/VideoPreviewWidget';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
import SocialShareWidget from './components/SocialShareWidget';
|
import SocialShareWidget from './components/SocialShareWidget';
|
||||||
import messages from '../../messages';
|
import messages from '../../messages';
|
||||||
@@ -42,7 +43,7 @@ const VideoSettingsModal: React.FC<Props> = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<ErrorSummary />
|
<ErrorSummary />
|
||||||
<VideoPreviewWidget />
|
<ConnectedVideoPreviewWidget />
|
||||||
<VideoSourceWidget />
|
<VideoSourceWidget />
|
||||||
{!isLibrary && (
|
{!isLibrary && (
|
||||||
<SocialShareWidget />
|
<SocialShareWidget />
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const VideoEditor: React.FC<EditorComponent> = ({
|
|||||||
<ErrorContext.Provider value={error}>
|
<ErrorContext.Provider value={error}>
|
||||||
<EditorContainer
|
<EditorContainer
|
||||||
getContent={fetchVideoContent()}
|
getContent={fetchVideoContent()}
|
||||||
|
isDirty={/* istanbul ignore next */ () => true}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
returnFunction={returnFunction}
|
returnFunction={returnFunction}
|
||||||
validateEntry={validateEntry}
|
validateEntry={validateEntry}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const initialState = {
|
|||||||
generalFeedback: '',
|
generalFeedback: '',
|
||||||
additionalAttributes: {},
|
additionalAttributes: {},
|
||||||
defaultSettings: {},
|
defaultSettings: {},
|
||||||
|
isDirty: false,
|
||||||
settings: {
|
settings: {
|
||||||
randomization: null,
|
randomization: null,
|
||||||
scoring: {
|
scoring: {
|
||||||
@@ -52,6 +53,7 @@ const problem = createSlice({
|
|||||||
updateQuestion: (state, { payload }) => ({
|
updateQuestion: (state, { payload }) => ({
|
||||||
...state,
|
...state,
|
||||||
question: payload,
|
question: payload,
|
||||||
|
isDirty: true,
|
||||||
}),
|
}),
|
||||||
updateAnswer: (state, { payload }) => {
|
updateAnswer: (state, { payload }) => {
|
||||||
const { id, hasSingleAnswer, ...answer } = payload;
|
const { id, hasSingleAnswer, ...answer } = payload;
|
||||||
@@ -77,6 +79,7 @@ const problem = createSlice({
|
|||||||
...state,
|
...state,
|
||||||
correctAnswerCount,
|
correctAnswerCount,
|
||||||
answers,
|
answers,
|
||||||
|
isDirty: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
deleteAnswer: (state, { payload }) => {
|
deleteAnswer: (state, { payload }) => {
|
||||||
@@ -86,6 +89,7 @@ const problem = createSlice({
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
correctAnswerCount: state.problemType === ProblemTypeKeys.NUMERIC ? 1 : 0,
|
correctAnswerCount: state.problemType === ProblemTypeKeys.NUMERIC ? 1 : 0,
|
||||||
|
isDirty: true,
|
||||||
answers: [{
|
answers: [{
|
||||||
id: 'A',
|
id: 'A',
|
||||||
title: '',
|
title: '',
|
||||||
@@ -140,6 +144,7 @@ const problem = createSlice({
|
|||||||
answers,
|
answers,
|
||||||
correctAnswerCount: correct ? state.correctAnswerCount - 1 : state.correctAnswerCount,
|
correctAnswerCount: correct ? state.correctAnswerCount - 1 : state.correctAnswerCount,
|
||||||
groupFeedbackList,
|
groupFeedbackList,
|
||||||
|
isDirty: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
addAnswer: (state) => {
|
addAnswer: (state) => {
|
||||||
@@ -167,6 +172,7 @@ const problem = createSlice({
|
|||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
correctAnswerCount,
|
correctAnswerCount,
|
||||||
|
isDirty: true,
|
||||||
answers,
|
answers,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -185,6 +191,7 @@ const problem = createSlice({
|
|||||||
...state,
|
...state,
|
||||||
correctAnswerCount,
|
correctAnswerCount,
|
||||||
answers: [newOption],
|
answers: [newOption],
|
||||||
|
isDirty: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -194,6 +201,7 @@ const problem = createSlice({
|
|||||||
...state.settings,
|
...state.settings,
|
||||||
...payload,
|
...payload,
|
||||||
},
|
},
|
||||||
|
isDirty: true,
|
||||||
}),
|
}),
|
||||||
load: (state, { payload: { settings: { scoring, showAnswer, ...settings }, ...payload } }) => ({
|
load: (state, { payload: { settings: { scoring, showAnswer, ...settings }, ...payload } }) => ({
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ describe('problem reducer', () => {
|
|||||||
it(`load ${target} from payload`, () => {
|
it(`load ${target} from payload`, () => {
|
||||||
expect(reducer(testingState, actions[action](testValue))).toEqual({
|
expect(reducer(testingState, actions[action](testValue))).toEqual({
|
||||||
...testingState,
|
...testingState,
|
||||||
|
isDirty: true,
|
||||||
[target]: testValue,
|
[target]: testValue,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -62,6 +63,7 @@ describe('problem reducer', () => {
|
|||||||
expect(reducer(testingState, actions.addAnswer(answer))).toEqual({
|
expect(reducer(testingState, actions.addAnswer(answer))).toEqual({
|
||||||
...testingState,
|
...testingState,
|
||||||
answers: [answer],
|
answers: [answer],
|
||||||
|
isDirty: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -79,6 +81,7 @@ describe('problem reducer', () => {
|
|||||||
const payload = { hints: ['soMehInt'] };
|
const payload = { hints: ['soMehInt'] };
|
||||||
expect(reducer(testingState, actions.updateSettings(payload))).toEqual({
|
expect(reducer(testingState, actions.updateSettings(payload))).toEqual({
|
||||||
...testingState,
|
...testingState,
|
||||||
|
isDirty: true,
|
||||||
settings: {
|
settings: {
|
||||||
...testingState.settings,
|
...testingState.settings,
|
||||||
...payload,
|
...payload,
|
||||||
@@ -99,6 +102,7 @@ describe('problem reducer', () => {
|
|||||||
expect(reducer({ ...testingState, problemType: 'choiceresponse' }, actions.addAnswer())).toEqual({
|
expect(reducer({ ...testingState, problemType: 'choiceresponse' }, actions.addAnswer())).toEqual({
|
||||||
...testingState,
|
...testingState,
|
||||||
problemType: 'choiceresponse',
|
problemType: 'choiceresponse',
|
||||||
|
isDirty: true,
|
||||||
answers: [answer],
|
answers: [answer],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -111,6 +115,7 @@ describe('problem reducer', () => {
|
|||||||
expect(reducer(numericTestState, actions.addAnswer())).toEqual({
|
expect(reducer(numericTestState, actions.addAnswer())).toEqual({
|
||||||
...numericTestState,
|
...numericTestState,
|
||||||
correctAnswerCount: 1,
|
correctAnswerCount: 1,
|
||||||
|
isDirty: true,
|
||||||
answers: [{
|
answers: [{
|
||||||
...answer,
|
...answer,
|
||||||
correct: true,
|
correct: true,
|
||||||
@@ -131,6 +136,7 @@ describe('problem reducer', () => {
|
|||||||
expect(reducer({ ...testingState, problemType: ProblemTypeKeys.NUMERIC }, actions.addAnswerRange())).toEqual({
|
expect(reducer({ ...testingState, problemType: ProblemTypeKeys.NUMERIC }, actions.addAnswerRange())).toEqual({
|
||||||
...testingState,
|
...testingState,
|
||||||
correctAnswerCount: 1,
|
correctAnswerCount: 1,
|
||||||
|
isDirty: true,
|
||||||
problemType: ProblemTypeKeys.NUMERIC,
|
problemType: ProblemTypeKeys.NUMERIC,
|
||||||
answers: [answerRange],
|
answers: [answerRange],
|
||||||
});
|
});
|
||||||
@@ -151,6 +157,7 @@ describe('problem reducer', () => {
|
|||||||
)).toEqual({
|
)).toEqual({
|
||||||
...testingState,
|
...testingState,
|
||||||
correctAnswerCount: 1,
|
correctAnswerCount: 1,
|
||||||
|
isDirty: true,
|
||||||
answers: [{ id: 'A', correct: true }],
|
answers: [{ id: 'A', correct: true }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -183,6 +190,7 @@ describe('problem reducer', () => {
|
|||||||
actions.deleteAnswer(payload),
|
actions.deleteAnswer(payload),
|
||||||
)).toEqual({
|
)).toEqual({
|
||||||
...testingState,
|
...testingState,
|
||||||
|
isDirty: true,
|
||||||
correctAnswerCount: 0,
|
correctAnswerCount: 0,
|
||||||
answers: [{
|
answers: [{
|
||||||
id: 'A',
|
id: 'A',
|
||||||
@@ -220,6 +228,7 @@ describe('problem reducer', () => {
|
|||||||
)).toEqual({
|
)).toEqual({
|
||||||
...testingState,
|
...testingState,
|
||||||
correctAnswerCount: 1,
|
correctAnswerCount: 1,
|
||||||
|
isDirty: true,
|
||||||
answers: [{
|
answers: [{
|
||||||
id: 'A',
|
id: 'A',
|
||||||
correct: true,
|
correct: true,
|
||||||
@@ -259,6 +268,7 @@ describe('problem reducer', () => {
|
|||||||
)).toEqual({
|
)).toEqual({
|
||||||
...testingState,
|
...testingState,
|
||||||
problemType: ProblemTypeKeys.SINGLESELECT,
|
problemType: ProblemTypeKeys.SINGLESELECT,
|
||||||
|
isDirty: true,
|
||||||
correctAnswerCount: 1,
|
correctAnswerCount: 1,
|
||||||
answers: [{
|
answers: [{
|
||||||
id: 'A',
|
id: 'A',
|
||||||
@@ -300,6 +310,7 @@ describe('problem reducer', () => {
|
|||||||
)).toEqual({
|
)).toEqual({
|
||||||
...testingState,
|
...testingState,
|
||||||
correctAnswerCount: 1,
|
correctAnswerCount: 1,
|
||||||
|
isDirty: true,
|
||||||
answers: [{
|
answers: [{
|
||||||
id: 'A',
|
id: 'A',
|
||||||
correct: true,
|
correct: true,
|
||||||
@@ -380,6 +391,7 @@ describe('problem reducer', () => {
|
|||||||
)).toEqual({
|
)).toEqual({
|
||||||
...testingState,
|
...testingState,
|
||||||
correctAnswerCount: 1,
|
correctAnswerCount: 1,
|
||||||
|
isDirty: true,
|
||||||
answers: [{
|
answers: [{
|
||||||
id: 'A',
|
id: 'A',
|
||||||
correct: true,
|
correct: true,
|
||||||
@@ -429,6 +441,7 @@ describe('problem reducer', () => {
|
|||||||
...testingState,
|
...testingState,
|
||||||
problemType: ProblemTypeKeys.NUMERIC,
|
problemType: ProblemTypeKeys.NUMERIC,
|
||||||
correctAnswerCount: 1,
|
correctAnswerCount: 1,
|
||||||
|
isDirty: true,
|
||||||
answers: [{
|
answers: [{
|
||||||
id: 'A',
|
id: 'A',
|
||||||
title: '',
|
title: '',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const simpleSelectors = {
|
|||||||
question: mkSimpleSelector(problemData => problemData.question),
|
question: mkSimpleSelector(problemData => problemData.question),
|
||||||
defaultSettings: mkSimpleSelector(problemData => problemData.defaultSettings),
|
defaultSettings: mkSimpleSelector(problemData => problemData.defaultSettings),
|
||||||
completeState: mkSimpleSelector(problemData => problemData),
|
completeState: mkSimpleSelector(problemData => problemData),
|
||||||
|
isDirty: mkSimpleSelector(problemData => problemData.isDirty),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -50,11 +50,19 @@ describe('hooks', () => {
|
|||||||
describe('initializeApp', () => {
|
describe('initializeApp', () => {
|
||||||
test('calls provided function with provided data as args when useEffect is called', () => {
|
test('calls provided function with provided data as args when useEffect is called', () => {
|
||||||
const dispatch = jest.fn();
|
const dispatch = jest.fn();
|
||||||
const fakeData = { some: 'data' };
|
const fakeData = {
|
||||||
|
blockId: 'blockId',
|
||||||
|
studioEndpointUrl: 'studioEndpointUrl',
|
||||||
|
learningContextId: 'learningContextId',
|
||||||
|
};
|
||||||
hooks.initializeApp({ dispatch, data: fakeData });
|
hooks.initializeApp({ dispatch, data: fakeData });
|
||||||
expect(dispatch).not.toHaveBeenCalledWith(fakeData);
|
expect(dispatch).not.toHaveBeenCalledWith(fakeData);
|
||||||
const [cb, prereqs] = useEffect.mock.calls[0];
|
const [cb, prereqs] = useEffect.mock.calls[0];
|
||||||
expect(prereqs).toStrictEqual([fakeData]);
|
expect(prereqs).toStrictEqual([
|
||||||
|
fakeData.blockId,
|
||||||
|
fakeData.studioEndpointUrl,
|
||||||
|
fakeData.learningContextId,
|
||||||
|
]);
|
||||||
cb();
|
cb();
|
||||||
expect(dispatch).toHaveBeenCalledWith(thunkActions.app.initialize(fakeData));
|
expect(dispatch).toHaveBeenCalledWith(thunkActions.app.initialize(fakeData));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { RequestKeys } from './data/constants/requests';
|
|||||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
export const initializeApp = ({ dispatch, data }) => useEffect(
|
export const initializeApp = ({ dispatch, data }) => useEffect(
|
||||||
() => dispatch(thunkActions.app.initialize(data)),
|
() => dispatch(thunkActions.app.initialize(data)),
|
||||||
[data],
|
[data?.blockId, data?.studioEndpointUrl, data?.learningContextId],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const navigateTo = (destination: string | URL) => {
|
export const navigateTo = (destination: string | URL) => {
|
||||||
|
|||||||
@@ -219,7 +219,32 @@ export const setupCustomBehavior = ({
|
|||||||
if (newContent) { updateContent(newContent); }
|
if (newContent) { updateContent(newContent); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
editor.on('ExecCommand', (e) => {
|
|
||||||
|
editor.on('init', /* istanbul ignore next */ () => {
|
||||||
|
// Moving TinyMce aux modal inside the Editor modal
|
||||||
|
// if the editor is on modal mode.
|
||||||
|
// This is to avoid issues using the aux modal:
|
||||||
|
// * Avoid close aux modal when clicking the content inside.
|
||||||
|
// * When the user opens the `Edit Source Code` modal, this adds `data-focus-on-hidden`
|
||||||
|
// to the TinyMce aux modal, making it unusable.
|
||||||
|
const modalLayer = document.querySelector('.pgn__modal-layer');
|
||||||
|
const tinymceAux = document.querySelector('.tox.tox-tinymce-aux');
|
||||||
|
|
||||||
|
if (modalLayer && tinymceAux) {
|
||||||
|
modalLayer.appendChild(tinymceAux);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.on('ExecCommand', /* istanbul ignore next */ (e) => {
|
||||||
|
// Remove `data-focus-on-hidden` and `aria-hidden` on TinyMce aux modal used on emoticons, formulas, etc.
|
||||||
|
// When using the Editor in modal mode, it may happen that the editor modal is rendered
|
||||||
|
// before the TinyMce aux modal, which adds these attributes, making the TinyMce aux modal unusable.
|
||||||
|
const modalElement = document.querySelector('.tox.tox-silver-sink.tox-tinymce-aux');
|
||||||
|
if (modalElement) {
|
||||||
|
modalElement.removeAttribute('data-focus-on-hidden');
|
||||||
|
modalElement.removeAttribute('aria-hidden');
|
||||||
|
}
|
||||||
|
|
||||||
if (editorType === 'text' && e.command === 'mceFocus') {
|
if (editorType === 'text' && e.command === 'mceFocus') {
|
||||||
const initialContent = editor.getContent();
|
const initialContent = editor.getContent();
|
||||||
const newContent = module.replaceStaticWithAsset({
|
const newContent = module.replaceStaticWithAsset({
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { capitalize } from 'lodash';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { initializeMocks, render, screen } from '../../testUtils';
|
import { initializeMocks, render, screen } from '../../testUtils';
|
||||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
|
||||||
import ProcessingNotification from '.';
|
import ProcessingNotification from '.';
|
||||||
|
|
||||||
const mockUndo = jest.fn();
|
const mockUndo = jest.fn();
|
||||||
|
|
||||||
const props = {
|
const props = {
|
||||||
title: NOTIFICATION_MESSAGES.saving,
|
title: 'ThIs IS a Test. OK?',
|
||||||
isShow: true,
|
isShow: true,
|
||||||
action: {
|
action: {
|
||||||
label: 'Undo',
|
label: 'Undo',
|
||||||
@@ -22,16 +20,16 @@ describe('<ProcessingNotification />', () => {
|
|||||||
|
|
||||||
it('renders successfully', () => {
|
it('renders successfully', () => {
|
||||||
render(<ProcessingNotification {...props} close={() => {}} />);
|
render(<ProcessingNotification {...props} close={() => {}} />);
|
||||||
expect(screen.getByText(capitalize(props.title))).toBeInTheDocument();
|
expect(screen.getByText(props.title)).toBeInTheDocument();
|
||||||
expect(screen.getByText('Undo')).toBeInTheDocument();
|
expect(screen.getByText('Undo')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('alert').querySelector('.processing-notification-hide-close-button')).not.toBeInTheDocument();
|
expect(screen.getByRole('alert').querySelector('.processing-notification-hide-close-button')).not.toBeInTheDocument();
|
||||||
userEvent.click(screen.getByText('Undo'));
|
userEvent.click(screen.getByText('Undo'));
|
||||||
expect(mockUndo).toBeCalled();
|
expect(mockUndo).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('add hide-close-button class if no close action is passed', () => {
|
it('add hide-close-button class if no close action is passed', () => {
|
||||||
render(<ProcessingNotification {...props} />);
|
render(<ProcessingNotification {...props} />);
|
||||||
expect(screen.getByText(capitalize(props.title))).toBeInTheDocument();
|
expect(screen.getByText(props.title)).toBeInTheDocument();
|
||||||
expect(screen.getByRole('alert').querySelector('.processing-notification-hide-close-button')).toBeInTheDocument();
|
expect(screen.getByRole('alert').querySelector('.processing-notification-hide-close-button')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
Icon, Toast,
|
Icon, Toast,
|
||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
import { Settings as IconSettings } from '@openedx/paragon/icons';
|
import { Settings as IconSettings } from '@openedx/paragon/icons';
|
||||||
import { capitalize } from 'lodash';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
const ProcessingNotification = ({
|
const ProcessingNotification = ({
|
||||||
@@ -18,7 +17,7 @@ const ProcessingNotification = ({
|
|||||||
>
|
>
|
||||||
<span className="d-flex align-items-center">
|
<span className="d-flex align-items-center">
|
||||||
<Icon className="processing-notification-icon mb-0 mr-2" src={IconSettings} />
|
<Icon className="processing-notification-icon mb-0 mr-2" src={IconSettings} />
|
||||||
<span className="font-weight-bold h4 mb-0 text-white">{capitalize(title)}</span>
|
<span className="font-weight-bold h4 mb-0 text-white">{title}</span>
|
||||||
</span>
|
</span>
|
||||||
</Toast>
|
</Toast>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
import React from 'react';
|
import { renderHook } from '@testing-library/react-hooks';
|
||||||
import { render, unmountComponentAtNode } from 'react-dom';
|
import usePromptIfDirty from './usePromptIfDirty';
|
||||||
import { act } from 'react-dom/test-utils';
|
|
||||||
import PromptIfDirty from './PromptIfDirty';
|
|
||||||
|
|
||||||
describe('PromptIfDirty', () => {
|
describe('usePromptIfDirty', () => {
|
||||||
let container = null;
|
|
||||||
let mockEvent = null;
|
let mockEvent = null;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
container = document.createElement('div');
|
|
||||||
document.body.appendChild(container);
|
|
||||||
mockEvent = new Event('beforeunload');
|
mockEvent = new Event('beforeunload');
|
||||||
jest.spyOn(window, 'addEventListener');
|
jest.spyOn(window, 'addEventListener');
|
||||||
jest.spyOn(window, 'removeEventListener');
|
jest.spyOn(window, 'removeEventListener');
|
||||||
jest.spyOn(mockEvent, 'preventDefault');
|
jest.spyOn(mockEvent, 'preventDefault');
|
||||||
Object.defineProperty(mockEvent, 'returnValue', { writable: true });
|
Object.defineProperty(mockEvent, 'returnValue', { writable: true });
|
||||||
mockEvent.returnValue = '';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -23,49 +17,32 @@ describe('PromptIfDirty', () => {
|
|||||||
window.removeEventListener.mockRestore();
|
window.removeEventListener.mockRestore();
|
||||||
mockEvent.preventDefault.mockRestore();
|
mockEvent.preventDefault.mockRestore();
|
||||||
mockEvent = null;
|
mockEvent = null;
|
||||||
unmountComponentAtNode(container);
|
|
||||||
container.remove();
|
|
||||||
container = null;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add event listener on mount', () => {
|
it('should add event listener on mount', () => {
|
||||||
act(() => {
|
renderHook(() => usePromptIfDirty(() => true));
|
||||||
render(<PromptIfDirty dirty />, container);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(window.addEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
|
expect(window.addEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should remove event listener on unmount', () => {
|
it('should remove event listener on unmount', () => {
|
||||||
act(() => {
|
const { unmount } = renderHook(() => usePromptIfDirty(() => true));
|
||||||
render(<PromptIfDirty dirty />, container);
|
unmount();
|
||||||
});
|
|
||||||
act(() => {
|
|
||||||
unmountComponentAtNode(container);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(window.removeEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
|
expect(window.removeEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call preventDefault and set returnValue when dirty is true', () => {
|
it('should call preventDefault and set returnValue when dirty is true', () => {
|
||||||
act(() => {
|
renderHook(() => usePromptIfDirty(() => true));
|
||||||
render(<PromptIfDirty dirty />, container);
|
window.dispatchEvent(mockEvent);
|
||||||
});
|
|
||||||
act(() => {
|
|
||||||
window.dispatchEvent(mockEvent);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||||
expect(mockEvent.returnValue).toBe('');
|
expect(mockEvent.returnValue).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not call preventDefault when dirty is false', () => {
|
it('should not call preventDefault when dirty is false', () => {
|
||||||
act(() => {
|
renderHook(() => usePromptIfDirty(() => false));
|
||||||
render(<PromptIfDirty dirty={false} />, container);
|
window.dispatchEvent(mockEvent);
|
||||||
});
|
|
||||||
act(() => {
|
|
||||||
window.dispatchEvent(mockEvent);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
|
expect(mockEvent.preventDefault).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
const PromptIfDirty = ({ dirty }) => {
|
const usePromptIfDirty = (checkIfDirty : () => boolean) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// eslint-disable-next-line consistent-return
|
// eslint-disable-next-line consistent-return
|
||||||
const handleBeforeUnload = (event) => {
|
const handleBeforeUnload = (event) => {
|
||||||
if (dirty) {
|
if (checkIfDirty()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
// Included for legacy support, e.g. Chrome/Edge < 119
|
||||||
|
event.returnValue = true; // eslint-disable-line no-param-reassign
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
@@ -14,11 +15,9 @@ const PromptIfDirty = ({ dirty }) => {
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
};
|
};
|
||||||
}, [dirty]);
|
}, [checkIfDirty]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
PromptIfDirty.propTypes = {
|
|
||||||
dirty: PropTypes.bool.isRequired,
|
export default usePromptIfDirty;
|
||||||
};
|
|
||||||
export default PromptIfDirty;
|
|
||||||
@@ -13,7 +13,7 @@ const TestComponentToShow = () => {
|
|||||||
const { showToast } = React.useContext(ToastContext);
|
const { showToast } = React.useContext(ToastContext);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
showToast('This is the toast!');
|
showToast('This is the Toast!');
|
||||||
}, [showToast]);
|
}, [showToast]);
|
||||||
|
|
||||||
return <div>Content</div>;
|
return <div>Content</div>;
|
||||||
@@ -23,7 +23,7 @@ const TestComponentToClose = () => {
|
|||||||
const { showToast, closeToast } = React.useContext(ToastContext);
|
const { showToast, closeToast } = React.useContext(ToastContext);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
showToast('This is the toast!');
|
showToast('This is the Toast!');
|
||||||
closeToast();
|
closeToast();
|
||||||
}, [showToast]);
|
}, [showToast]);
|
||||||
|
|
||||||
@@ -59,19 +59,19 @@ describe('<ToastProvider />', () => {
|
|||||||
|
|
||||||
it('should show toast', async () => {
|
it('should show toast', async () => {
|
||||||
render(<RootWrapper><TestComponentToShow /></RootWrapper>);
|
render(<RootWrapper><TestComponentToShow /></RootWrapper>);
|
||||||
expect(await screen.findByText('This is the toast!')).toBeInTheDocument();
|
expect(await screen.findByText('This is the Toast!')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should close toast after 5000ms', async () => {
|
it('should close toast after 5000ms', async () => {
|
||||||
render(<RootWrapper><TestComponentToShow /></RootWrapper>);
|
render(<RootWrapper><TestComponentToShow /></RootWrapper>);
|
||||||
expect(await screen.findByText('This is the toast!')).toBeInTheDocument();
|
expect(await screen.findByText('This is the Toast!')).toBeInTheDocument();
|
||||||
jest.advanceTimersByTime(6000);
|
jest.advanceTimersByTime(6000);
|
||||||
expect(screen.queryByText('This is the toast!')).not.toBeInTheDocument();
|
expect(screen.queryByText('This is the Toast!')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should close toast', async () => {
|
it('should close toast', async () => {
|
||||||
render(<RootWrapper><TestComponentToClose /></RootWrapper>);
|
render(<RootWrapper><TestComponentToClose /></RootWrapper>);
|
||||||
expect(await screen.findByText('Content')).toBeInTheDocument();
|
expect(await screen.findByText('Content')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('This is the toast!')).not.toBeInTheDocument();
|
expect(screen.queryByText('This is the Toast!')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export const useToolsMenuItems = courseId => {
|
|||||||
},
|
},
|
||||||
...(getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true'
|
...(getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true'
|
||||||
? [{
|
? [{
|
||||||
href: '#export-tags',
|
href: `${studioBaseUrl}/course/${courseId}#export-tags`,
|
||||||
title: intl.formatMessage(messages['header.links.exportTags']),
|
title: intl.formatMessage(messages['header.links.exportTags']),
|
||||||
}] : []
|
}] : []
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.library-authoring-sidebar {
|
.library-authoring-sidebar {
|
||||||
z-index: 1001; // to appear over header
|
z-index: 1000; // same as header
|
||||||
flex: 450px 0 0;
|
flex: 450px 0 0;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -21,6 +21,10 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
z-index: 1001; // over the sidebar
|
||||||
|
}
|
||||||
|
|
||||||
// Reduce breadcrumb bottom margin
|
// Reduce breadcrumb bottom margin
|
||||||
ol.list-inline {
|
ol.list-inline {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
mockXBlockFields,
|
mockXBlockFields,
|
||||||
} from './data/api.mocks';
|
} from './data/api.mocks';
|
||||||
import { mockContentSearchConfig } from '../search-manager/data/api.mock';
|
import { mockContentSearchConfig } from '../search-manager/data/api.mock';
|
||||||
|
import { studioHomeMock } from '../studio-home/__mocks__';
|
||||||
|
import { getStudioHomeApiUrl } from '../studio-home/data/api';
|
||||||
import { mockBroadcastChannel } from '../generic/data/api.mock';
|
import { mockBroadcastChannel } from '../generic/data/api.mock';
|
||||||
import { LibraryLayout } from '.';
|
import { LibraryLayout } from '.';
|
||||||
import { getLibraryCollectionsApiUrl } from './data/api';
|
import { getLibraryCollectionsApiUrl } from './data/api';
|
||||||
@@ -40,46 +42,19 @@ const returnEmptyResult = (_url, req) => {
|
|||||||
// We have to replace the query (search keywords) in the mock results with the actual query,
|
// We have to replace the query (search keywords) in the mock results with the actual query,
|
||||||
// because otherwise we may have an inconsistent state that causes more queries and unexpected results.
|
// because otherwise we may have an inconsistent state that causes more queries and unexpected results.
|
||||||
mockEmptyResult.results[0].query = query;
|
mockEmptyResult.results[0].query = query;
|
||||||
mockEmptyResult.results[2].query = query;
|
|
||||||
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
|
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
|
||||||
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
||||||
mockEmptyResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
mockEmptyResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
||||||
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
|
||||||
mockEmptyResult.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
|
||||||
return mockEmptyResult;
|
return mockEmptyResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns 2 components from the search query.
|
|
||||||
* This lets us test that the StudioHome "View All" button is hidden when a
|
|
||||||
* low number of search results are shown (<=4 by default).
|
|
||||||
*/
|
|
||||||
const returnLowNumberResults = (_url, req) => {
|
|
||||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
|
||||||
const query = requestData?.queries[0]?.q ?? '';
|
|
||||||
const newMockResult = { ...mockResult };
|
|
||||||
// We have to replace the query (search keywords) in the mock results with the actual query,
|
|
||||||
// because otherwise we may have an inconsistent state that causes more queries and unexpected results.
|
|
||||||
newMockResult.results[0].query = query;
|
|
||||||
// Limit number of results to just 2
|
|
||||||
newMockResult.results[0].hits = mockResult.results[0]?.hits.slice(0, 2);
|
|
||||||
newMockResult.results[2].hits = mockResult.results[2]?.hits.slice(0, 2);
|
|
||||||
newMockResult.results[0].estimatedTotalHits = 2;
|
|
||||||
newMockResult.results[2].estimatedTotalHits = 2;
|
|
||||||
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
|
|
||||||
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
|
||||||
newMockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
|
||||||
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
|
||||||
newMockResult.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
|
||||||
return newMockResult;
|
|
||||||
};
|
|
||||||
|
|
||||||
const path = '/library/:libraryId/*';
|
const path = '/library/:libraryId/*';
|
||||||
const libraryTitle = mockContentLibrary.libraryData.title;
|
const libraryTitle = mockContentLibrary.libraryData.title;
|
||||||
|
|
||||||
describe('<LibraryAuthoringPage />', () => {
|
describe('<LibraryAuthoringPage />', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
initializeMocks();
|
const { axiosMock } = initializeMocks();
|
||||||
|
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
|
||||||
|
|
||||||
// The Meilisearch client-side API uses fetch, not Axios.
|
// The Meilisearch client-side API uses fetch, not Axios.
|
||||||
fetchMock.mockReset();
|
fetchMock.mockReset();
|
||||||
@@ -133,35 +108,25 @@ describe('<LibraryAuthoringPage />', () => {
|
|||||||
|
|
||||||
expect(screen.queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();
|
expect(screen.queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();
|
||||||
|
|
||||||
// "Recently Modified" header + sort shown
|
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
|
||||||
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
|
|
||||||
expect(screen.getByText('Collections (6)')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Components (10)')).toBeInTheDocument();
|
|
||||||
expect((await screen.findAllByText('Introduction to Testing'))[0]).toBeInTheDocument();
|
expect((await screen.findAllByText('Introduction to Testing'))[0]).toBeInTheDocument();
|
||||||
|
|
||||||
// Navigate to the components tab
|
// Navigate to the components tab
|
||||||
fireEvent.click(screen.getByRole('tab', { name: 'Components' }));
|
fireEvent.click(screen.getByRole('tab', { name: 'Components' }));
|
||||||
// "Recently Modified" default sort shown
|
// "Recently Modified" default sort shown
|
||||||
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
|
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
|
||||||
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
// Navigate to the collections tab
|
// Navigate to the collections tab
|
||||||
fireEvent.click(screen.getByRole('tab', { name: 'Collections' }));
|
fireEvent.click(screen.getByRole('tab', { name: 'Collections' }));
|
||||||
// "Recently Modified" default sort shown
|
// "Recently Modified" default sort shown
|
||||||
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
|
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
|
||||||
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('There are 10 components in this library')).not.toBeInTheDocument();
|
expect(screen.queryByText('There are 10 components in this library')).not.toBeInTheDocument();
|
||||||
expect((await screen.findAllByText('Collection 1'))[0]).toBeInTheDocument();
|
expect((await screen.findAllByText('Collection 1'))[0]).toBeInTheDocument();
|
||||||
|
|
||||||
// Go back to Home tab
|
// Go back to Home tab
|
||||||
// This step is necessary to avoid the url change leak to other tests
|
// This step is necessary to avoid the url change leak to other tests
|
||||||
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
|
fireEvent.click(screen.getByRole('tab', { name: 'All Content' }));
|
||||||
// "Recently Modified" header + sort shown
|
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
|
||||||
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
|
|
||||||
expect(screen.getByText('Collections (6)')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Components (10)')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows a library without components and collections', async () => {
|
it('shows a library without components and collections', async () => {
|
||||||
@@ -185,7 +150,7 @@ describe('<LibraryAuthoringPage />', () => {
|
|||||||
fireEvent.click(cancelButton);
|
fireEvent.click(cancelButton);
|
||||||
expect(collectionModalHeading).not.toBeInTheDocument();
|
expect(collectionModalHeading).not.toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
|
fireEvent.click(screen.getByRole('tab', { name: 'All Content' }));
|
||||||
expect(screen.getByText('You have not added any content to this library yet.')).toBeInTheDocument();
|
expect(screen.getByText('You have not added any content to this library yet.')).toBeInTheDocument();
|
||||||
|
|
||||||
const addComponentButton = screen.getByRole('button', { name: /add component/i });
|
const addComponentButton = screen.getByRole('button', { name: /add component/i });
|
||||||
@@ -243,7 +208,7 @@ describe('<LibraryAuthoringPage />', () => {
|
|||||||
|
|
||||||
// Go back to Home tab
|
// Go back to Home tab
|
||||||
// This step is necessary to avoid the url change leak to other tests
|
// This step is necessary to avoid the url change leak to other tests
|
||||||
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
|
fireEvent.click(screen.getByRole('tab', { name: 'All Content' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should open and close new content sidebar', async () => {
|
it('should open and close new content sidebar', async () => {
|
||||||
@@ -325,68 +290,6 @@ describe('<LibraryAuthoringPage />', () => {
|
|||||||
expect(manageAccess).not.toBeInTheDocument();
|
expect(manageAccess).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('show the "View All" button when viewing library with many components', async () => {
|
|
||||||
await renderLibraryPage();
|
|
||||||
|
|
||||||
expect(screen.getByText('Content library')).toBeInTheDocument();
|
|
||||||
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
|
|
||||||
|
|
||||||
// "Recently Modified" header + sort shown
|
|
||||||
await waitFor(() => { expect(screen.getAllByText('Recently Modified').length).toEqual(2); });
|
|
||||||
expect(screen.getByText('Collections (6)')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Components (10)')).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
// There should be two "View All" button, since the Components and Collections count
|
|
||||||
// are above the preview limit (4)
|
|
||||||
expect(screen.getAllByText('View All').length).toEqual(2);
|
|
||||||
|
|
||||||
// Clicking on first "View All" button should navigate to the Collections tab
|
|
||||||
fireEvent.click(screen.getAllByText('View All')[0]);
|
|
||||||
// "Recently Modified" default sort shown
|
|
||||||
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
|
|
||||||
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Collection 1')).toBeInTheDocument();
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
|
|
||||||
// Clicking on second "View All" button should navigate to the Components tab
|
|
||||||
fireEvent.click(screen.getAllByText('View All')[1]);
|
|
||||||
// "Recently Modified" default sort shown
|
|
||||||
expect(screen.getAllByText('Recently Modified').length).toEqual(1);
|
|
||||||
expect(screen.queryByText('Collections (6)')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Components (10)')).not.toBeInTheDocument();
|
|
||||||
expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Go back to Home tab
|
|
||||||
// This step is necessary to avoid the url change leak to other tests
|
|
||||||
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
|
|
||||||
// "Recently Modified" header + sort shown
|
|
||||||
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
|
|
||||||
expect(screen.getByText('Collections (6)')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Components (10)')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not show the "View All" button when viewing library with low number of components', async () => {
|
|
||||||
fetchMock.post(searchEndpoint, returnLowNumberResults, { overwriteRoutes: true });
|
|
||||||
await renderLibraryPage();
|
|
||||||
|
|
||||||
expect(screen.getByText('Content library')).toBeInTheDocument();
|
|
||||||
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
|
|
||||||
|
|
||||||
// "Recently Modified" header + sort shown
|
|
||||||
await waitFor(() => { expect(screen.getAllByText('Recently Modified').length).toEqual(2); });
|
|
||||||
expect(screen.getByText('Collections (2)')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Components (2)')).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByText('Introduction to Testing')[0]).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
// There should not be any "View All" button on page since Components count
|
|
||||||
// is less than the preview limit (4)
|
|
||||||
expect(screen.queryByText('View All')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sorts library components', async () => {
|
it('sorts library components', async () => {
|
||||||
await renderLibraryPage();
|
await renderLibraryPage();
|
||||||
|
|
||||||
@@ -441,7 +344,7 @@ describe('<LibraryAuthoringPage />', () => {
|
|||||||
|
|
||||||
// Re-selecting the previous sort option resets sort to default "Recently Modified"
|
// Re-selecting the previous sort option resets sort to default "Recently Modified"
|
||||||
await testSortOption('Recently Published', 'modified:desc', true);
|
await testSortOption('Recently Published', 'modified:desc', true);
|
||||||
expect(screen.getAllByText('Recently Modified').length).toEqual(3);
|
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
|
||||||
|
|
||||||
// Enter a keyword into the search box
|
// Enter a keyword into the search box
|
||||||
const searchBox = screen.getByRole('searchbox');
|
const searchBox = screen.getByRole('searchbox');
|
||||||
@@ -464,7 +367,6 @@ describe('<LibraryAuthoringPage />', () => {
|
|||||||
expect(mockResult0.display_name).toStrictEqual(displayName);
|
expect(mockResult0.display_name).toStrictEqual(displayName);
|
||||||
await renderLibraryPage();
|
await renderLibraryPage();
|
||||||
|
|
||||||
// Click on the first component. It should appear twice, in both "Recently Modified" and "Components"
|
|
||||||
fireEvent.click((await screen.findAllByText(displayName))[0]);
|
fireEvent.click((await screen.findAllByText(displayName))[0]);
|
||||||
|
|
||||||
const sidebar = screen.getByTestId('library-sidebar');
|
const sidebar = screen.getByTestId('library-sidebar');
|
||||||
@@ -576,7 +478,7 @@ describe('<LibraryAuthoringPage />', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate click on Problem type
|
// Validate click on Problem type
|
||||||
const problemMenu = screen.getByText('Problem');
|
const problemMenu = screen.getAllByText('Problem')[0];
|
||||||
expect(problemMenu).toBeInTheDocument();
|
expect(problemMenu).toBeInTheDocument();
|
||||||
fireEvent.click(problemMenu);
|
fireEvent.click(problemMenu);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -644,7 +546,8 @@ describe('<LibraryAuthoringPage />', () => {
|
|||||||
expect(screen.getByText(/add content/i)).toBeInTheDocument();
|
expect(screen.getByText(/add content/i)).toBeInTheDocument();
|
||||||
|
|
||||||
// Open New collection Modal
|
// Open New collection Modal
|
||||||
const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4];
|
const sidebar = screen.getByTestId('library-sidebar');
|
||||||
|
const newCollectionButton = within(sidebar).getAllByRole('button', { name: /collection/i })[0];
|
||||||
fireEvent.click(newCollectionButton);
|
fireEvent.click(newCollectionButton);
|
||||||
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
|
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
|
||||||
expect(collectionModalHeading).toBeInTheDocument();
|
expect(collectionModalHeading).toBeInTheDocument();
|
||||||
@@ -688,7 +591,8 @@ describe('<LibraryAuthoringPage />', () => {
|
|||||||
expect(screen.getByText(/add content/i)).toBeInTheDocument();
|
expect(screen.getByText(/add content/i)).toBeInTheDocument();
|
||||||
|
|
||||||
// Open New collection Modal
|
// Open New collection Modal
|
||||||
const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4];
|
const sidebar = screen.getByTestId('library-sidebar');
|
||||||
|
const newCollectionButton = within(sidebar).getAllByRole('button', { name: /collection/i })[0];
|
||||||
fireEvent.click(newCollectionButton);
|
fireEvent.click(newCollectionButton);
|
||||||
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
|
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
|
||||||
expect(collectionModalHeading).toBeInTheDocument();
|
expect(collectionModalHeading).toBeInTheDocument();
|
||||||
@@ -721,7 +625,8 @@ describe('<LibraryAuthoringPage />', () => {
|
|||||||
expect(screen.getByText(/add content/i)).toBeInTheDocument();
|
expect(screen.getByText(/add content/i)).toBeInTheDocument();
|
||||||
|
|
||||||
// Open New collection Modal
|
// Open New collection Modal
|
||||||
const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4];
|
const sidebar = screen.getByTestId('library-sidebar');
|
||||||
|
const newCollectionButton = within(sidebar).getAllByRole('button', { name: /collection/i })[0];
|
||||||
fireEvent.click(newCollectionButton);
|
fireEvent.click(newCollectionButton);
|
||||||
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
|
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
|
||||||
expect(collectionModalHeading).toBeInTheDocument();
|
expect(collectionModalHeading).toBeInTheDocument();
|
||||||
@@ -736,22 +641,6 @@ describe('<LibraryAuthoringPage />', () => {
|
|||||||
fireEvent.click(createButton);
|
fireEvent.click(createButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows both components and collections in recently modified section', async () => {
|
|
||||||
await renderLibraryPage();
|
|
||||||
|
|
||||||
expect(await screen.findByText('Content library')).toBeInTheDocument();
|
|
||||||
expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
|
|
||||||
|
|
||||||
// "Recently Modified" header + sort shown
|
|
||||||
expect(screen.getAllByText('Recently Modified').length).toEqual(2);
|
|
||||||
const recentModifiedContainer = (await screen.findAllByText('Recently Modified'))[1].parentElement?.parentElement?.parentElement;
|
|
||||||
expect(recentModifiedContainer).toBeTruthy();
|
|
||||||
|
|
||||||
const container = within(recentModifiedContainer!);
|
|
||||||
expect(container.queryAllByText('Text').length).toBeGreaterThan(0);
|
|
||||||
expect(container.queryAllByText('Collection').length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows a single block when usageKey query param is set', async () => {
|
it('shows a single block when usageKey query param is set', async () => {
|
||||||
render(<LibraryLayout />, {
|
render(<LibraryLayout />, {
|
||||||
path,
|
path,
|
||||||
@@ -787,4 +676,16 @@ describe('<LibraryAuthoringPage />', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Shows an error if libraries V2 is disabled', async () => {
|
||||||
|
const { axiosMock } = initializeMocks();
|
||||||
|
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, {
|
||||||
|
...studioHomeMock,
|
||||||
|
libraries_v2_enabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<LibraryLayout />, { path, params: { libraryId: mockContentLibrary.libraryId } });
|
||||||
|
await waitFor(() => { expect(axiosMock.history.get.length).toBe(1); });
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('This page cannot be shown: Libraries v2 are disabled.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import classNames from 'classnames';
|
|||||||
import { StudioFooter } from '@edx/frontend-component-footer';
|
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
Button,
|
Button,
|
||||||
@@ -25,6 +26,7 @@ import Loading from '../generic/Loading';
|
|||||||
import SubHeader from '../generic/sub-header/SubHeader';
|
import SubHeader from '../generic/sub-header/SubHeader';
|
||||||
import Header from '../header';
|
import Header from '../header';
|
||||||
import NotFoundAlert from '../generic/NotFoundAlert';
|
import NotFoundAlert from '../generic/NotFoundAlert';
|
||||||
|
import { useStudioHome } from '../studio-home/hooks';
|
||||||
import {
|
import {
|
||||||
ClearFiltersButton,
|
ClearFiltersButton,
|
||||||
FilterByBlockType,
|
FilterByBlockType,
|
||||||
@@ -33,35 +35,11 @@ import {
|
|||||||
SearchKeywordsField,
|
SearchKeywordsField,
|
||||||
SearchSortWidget,
|
SearchSortWidget,
|
||||||
} from '../search-manager';
|
} from '../search-manager';
|
||||||
import LibraryComponents from './components/LibraryComponents';
|
import LibraryContent, { ContentType } from './LibraryContent';
|
||||||
import LibraryCollections from './collections/LibraryCollections';
|
|
||||||
import LibraryHome from './LibraryHome';
|
|
||||||
import { LibrarySidebar } from './library-sidebar';
|
import { LibrarySidebar } from './library-sidebar';
|
||||||
import { SidebarBodyComponentId, useLibraryContext } from './common/context';
|
import { SidebarBodyComponentId, useLibraryContext } from './common/context';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
enum TabList {
|
|
||||||
home = '',
|
|
||||||
components = 'components',
|
|
||||||
collections = 'collections',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TabContentProps {
|
|
||||||
eventKey: string;
|
|
||||||
handleTabChange: (key: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TabContent = ({ eventKey, handleTabChange }: TabContentProps) => {
|
|
||||||
switch (eventKey) {
|
|
||||||
case TabList.components:
|
|
||||||
return <LibraryComponents variant="full" />;
|
|
||||||
case TabList.collections:
|
|
||||||
return <LibraryCollections variant="full" />;
|
|
||||||
default:
|
|
||||||
return <LibraryHome tabList={TabList} handleTabChange={handleTabChange} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const HeaderActions = () => {
|
const HeaderActions = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const {
|
const {
|
||||||
@@ -143,6 +121,12 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoadingPage: isLoadingStudioHome,
|
||||||
|
isFailedLoadingPage: isFailedLoadingStudioHome,
|
||||||
|
librariesV2Enabled,
|
||||||
|
} = useStudioHome();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
libraryId,
|
libraryId,
|
||||||
libraryData,
|
libraryData,
|
||||||
@@ -154,17 +138,17 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
|
|||||||
openInfoSidebar,
|
openInfoSidebar,
|
||||||
} = useLibraryContext();
|
} = useLibraryContext();
|
||||||
|
|
||||||
const [activeKey, setActiveKey] = useState<string | undefined>('');
|
const [activeKey, setActiveKey] = useState<ContentType | undefined>(ContentType.home);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentPath = location.pathname.split('/').pop();
|
const currentPath = location.pathname.split('/').pop();
|
||||||
|
|
||||||
if (componentPickerMode || currentPath === libraryId || currentPath === '') {
|
if (componentPickerMode || currentPath === libraryId || currentPath === '') {
|
||||||
setActiveKey(TabList.home);
|
setActiveKey(ContentType.home);
|
||||||
} else if (currentPath && currentPath in TabList) {
|
} else if (currentPath && currentPath in ContentType) {
|
||||||
setActiveKey(TabList[currentPath]);
|
setActiveKey(ContentType[currentPath]);
|
||||||
}
|
}
|
||||||
}, [location.pathname]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!componentPickerMode) {
|
if (!componentPickerMode) {
|
||||||
@@ -178,6 +162,14 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
|
|||||||
return <Loading />;
|
return <Loading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isLoadingStudioHome && (!librariesV2Enabled || isFailedLoadingStudioHome)) {
|
||||||
|
return (
|
||||||
|
<Alert variant="danger">
|
||||||
|
{intl.formatMessage(messages.librariesV2DisabledError)}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// istanbul ignore if: this should never happen
|
// istanbul ignore if: this should never happen
|
||||||
if (activeKey === undefined) {
|
if (activeKey === undefined) {
|
||||||
return <NotFoundAlert />;
|
return <NotFoundAlert />;
|
||||||
@@ -187,7 +179,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
|
|||||||
return <NotFoundAlert />;
|
return <NotFoundAlert />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTabChange = (key: string) => {
|
const handleTabChange = (key: ContentType) => {
|
||||||
setActiveKey(key);
|
setActiveKey(key);
|
||||||
if (!componentPickerMode) {
|
if (!componentPickerMode) {
|
||||||
navigate({
|
navigate({
|
||||||
@@ -219,6 +211,14 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
|
|||||||
extraFilter.push('last_published IS NOT NULL');
|
extraFilter.push('last_published IS NOT NULL');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeTypeFilters = {
|
||||||
|
components: 'NOT type = "collection"',
|
||||||
|
collections: 'type = "collection"',
|
||||||
|
};
|
||||||
|
if (activeKey !== ContentType.home) {
|
||||||
|
extraFilter.push(activeTypeFilters[activeKey]);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="d-flex">
|
<div className="d-flex">
|
||||||
<div className="flex-grow-1">
|
<div className="flex-grow-1">
|
||||||
@@ -259,11 +259,11 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
|
|||||||
onSelect={handleTabChange}
|
onSelect={handleTabChange}
|
||||||
className="my-3"
|
className="my-3"
|
||||||
>
|
>
|
||||||
<Tab eventKey={TabList.home} title={intl.formatMessage(messages.homeTab)} />
|
<Tab eventKey={ContentType.home} title={intl.formatMessage(messages.homeTab)} />
|
||||||
<Tab eventKey={TabList.components} title={intl.formatMessage(messages.componentsTab)} />
|
<Tab eventKey={ContentType.components} title={intl.formatMessage(messages.componentsTab)} />
|
||||||
<Tab eventKey={TabList.collections} title={intl.formatMessage(messages.collectionsTab)} />
|
<Tab eventKey={ContentType.collections} title={intl.formatMessage(messages.collectionsTab)} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<TabContent eventKey={activeKey} handleTabChange={handleTabChange} />
|
<LibraryContent contentType={activeKey} />
|
||||||
</SearchContextProvider>
|
</SearchContextProvider>
|
||||||
</Container>
|
</Container>
|
||||||
{!componentPickerMode && <StudioFooter containerProps={{ size: undefined }} />}
|
{!componentPickerMode && <StudioFooter containerProps={{ size: undefined }} />}
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import fetchMock from 'fetch-mock-jest';
|
import fetchMock from 'fetch-mock-jest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
fireEvent,
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
initializeMocks,
|
initializeMocks,
|
||||||
} from '../../testUtils';
|
} from '../testUtils';
|
||||||
import { getContentSearchConfigUrl } from '../../search-manager/data/api';
|
import { getContentSearchConfigUrl } from '../search-manager/data/api';
|
||||||
import { mockContentLibrary } from '../data/api.mocks';
|
import { mockContentLibrary } from './data/api.mocks';
|
||||||
import mockEmptyResult from '../../search-modal/__mocks__/empty-search-result.json';
|
import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json';
|
||||||
import { LibraryProvider } from '../common/context';
|
import { LibraryProvider } from './common/context';
|
||||||
import LibraryCollections from './LibraryCollections';
|
import LibraryContent from './LibraryContent';
|
||||||
|
import { libraryComponentsMock } from './__mocks__';
|
||||||
|
|
||||||
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
|
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
|
||||||
|
|
||||||
@@ -18,8 +20,8 @@ const mockFetchNextPage = jest.fn();
|
|||||||
const mockUseSearchContext = jest.fn();
|
const mockUseSearchContext = jest.fn();
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
totalHits: 1,
|
totalContentAndCollectionHits: 0,
|
||||||
hits: [],
|
contentAndCollectionHits: [],
|
||||||
isFetchingNextPage: false,
|
isFetchingNextPage: false,
|
||||||
hasNextPage: false,
|
hasNextPage: false,
|
||||||
fetchNextPage: mockFetchNextPage,
|
fetchNextPage: mockFetchNextPage,
|
||||||
@@ -40,8 +42,8 @@ const returnEmptyResult = (_url: string, req) => {
|
|||||||
return mockEmptyResult;
|
return mockEmptyResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.mock('../../search-manager', () => ({
|
jest.mock('../search-manager', () => ({
|
||||||
...jest.requireActual('../../search-manager'),
|
...jest.requireActual('../search-manager'),
|
||||||
useSearchContext: () => mockUseSearchContext(),
|
useSearchContext: () => mockUseSearchContext(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -58,7 +60,7 @@ const withLibraryId = (libraryId: string) => ({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('<LibraryCollections />', () => {
|
describe('<LibraryHome />', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const { axiosMock } = initializeMocks();
|
const { axiosMock } = initializeMocks();
|
||||||
|
|
||||||
@@ -83,7 +85,31 @@ describe('<LibraryCollections />', () => {
|
|||||||
isLoading: true,
|
isLoading: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<LibraryCollections variant="full" />, withLibraryId(mockContentLibrary.libraryId));
|
render(<LibraryContent />, withLibraryId(mockContentLibrary.libraryId));
|
||||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should render an empty state when there are no results', async () => {
|
||||||
|
mockUseSearchContext.mockReturnValue({
|
||||||
|
...data,
|
||||||
|
totalHits: 0,
|
||||||
|
});
|
||||||
|
render(<LibraryContent />, withLibraryId(mockContentLibrary.libraryId));
|
||||||
|
expect(screen.getByText('You have not added any content to this library yet.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load more results when the user scrolls to the bottom', async () => {
|
||||||
|
mockUseSearchContext.mockReturnValue({
|
||||||
|
...data,
|
||||||
|
hits: libraryComponentsMock,
|
||||||
|
hasNextPage: true,
|
||||||
|
});
|
||||||
|
render(<LibraryContent />, withLibraryId(mockContentLibrary.libraryId));
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'innerHeight', { value: 800 });
|
||||||
|
Object.defineProperty(document.body, 'scrollHeight', { value: 1600 });
|
||||||
|
|
||||||
|
fireEvent.scroll(window, { target: { scrollY: 1000 } });
|
||||||
|
expect(mockFetchNextPage).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
92
src/library-authoring/LibraryContent.tsx
Normal file
92
src/library-authoring/LibraryContent.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { LoadingSpinner } from '../generic/Loading';
|
||||||
|
import { useSearchContext } from '../search-manager';
|
||||||
|
import { NoComponents, NoSearchResults } from './EmptyStates';
|
||||||
|
import { useLibraryContext } from './common/context';
|
||||||
|
import CollectionCard from './components/CollectionCard';
|
||||||
|
import ComponentCard from './components/ComponentCard';
|
||||||
|
import { useLoadOnScroll } from '../hooks';
|
||||||
|
import messages from './collections/messages';
|
||||||
|
|
||||||
|
export enum ContentType {
|
||||||
|
home = '',
|
||||||
|
components = 'components',
|
||||||
|
collections = 'collections',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Library Content to show content grid
|
||||||
|
*
|
||||||
|
* Use content to:
|
||||||
|
* - 'collections': Suggest to create a collection on empty state.
|
||||||
|
* - Anything else to suggest to add content on empty state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type LibraryContentProps = {
|
||||||
|
contentType?: ContentType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps) => {
|
||||||
|
const {
|
||||||
|
hits,
|
||||||
|
totalHits,
|
||||||
|
isFetchingNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
isLoading,
|
||||||
|
isFiltered,
|
||||||
|
usageKey,
|
||||||
|
} = useSearchContext();
|
||||||
|
const { openAddContentSidebar, openComponentInfoSidebar, openCreateCollectionModal } = useLibraryContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (usageKey) {
|
||||||
|
openComponentInfoSidebar(usageKey);
|
||||||
|
}
|
||||||
|
}, [usageKey]);
|
||||||
|
|
||||||
|
useLoadOnScroll(
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
if (totalHits === 0) {
|
||||||
|
if (contentType === ContentType.collections) {
|
||||||
|
return isFiltered
|
||||||
|
? <NoSearchResults infoText={messages.noSearchResultsCollections} />
|
||||||
|
: (
|
||||||
|
<NoComponents
|
||||||
|
infoText={messages.noCollections}
|
||||||
|
addBtnText={messages.addCollection}
|
||||||
|
handleBtnClick={openCreateCollectionModal}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return isFiltered ? <NoSearchResults /> : <NoComponents handleBtnClick={openAddContentSidebar} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="library-cards-grid">
|
||||||
|
{hits.map((contentHit) => (
|
||||||
|
contentHit.type === 'collection' ? (
|
||||||
|
<CollectionCard
|
||||||
|
key={contentHit.id}
|
||||||
|
collectionHit={contentHit}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ComponentCard
|
||||||
|
key={contentHit.id}
|
||||||
|
contentHit={contentHit}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LibraryContent;
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import { Stack } from '@openedx/paragon';
|
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
|
||||||
|
|
||||||
import { LoadingSpinner } from '../generic/Loading';
|
|
||||||
import { useSearchContext } from '../search-manager';
|
|
||||||
import { NoComponents, NoSearchResults } from './EmptyStates';
|
|
||||||
import LibraryCollections from './collections/LibraryCollections';
|
|
||||||
import { LibraryComponents } from './components';
|
|
||||||
import LibrarySection from './components/LibrarySection';
|
|
||||||
import LibraryRecentlyModified from './LibraryRecentlyModified';
|
|
||||||
import messages from './messages';
|
|
||||||
import { useLibraryContext } from './common/context';
|
|
||||||
|
|
||||||
type LibraryHomeProps = {
|
|
||||||
tabList: { home: string, components: string, collections: string },
|
|
||||||
handleTabChange: (key: string) => void,
|
|
||||||
};
|
|
||||||
|
|
||||||
const LibraryHome = ({ tabList, handleTabChange } : LibraryHomeProps) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const {
|
|
||||||
totalHits: componentCount,
|
|
||||||
totalCollectionHits: collectionCount,
|
|
||||||
isLoading,
|
|
||||||
isFiltered,
|
|
||||||
} = useSearchContext();
|
|
||||||
const { openAddContentSidebar } = useLibraryContext();
|
|
||||||
|
|
||||||
const renderEmptyState = () => {
|
|
||||||
if (isLoading) {
|
|
||||||
return <LoadingSpinner />;
|
|
||||||
}
|
|
||||||
if (componentCount === 0 && collectionCount === 0) {
|
|
||||||
return isFiltered ? <NoSearchResults /> : <NoComponents handleBtnClick={openAddContentSidebar} />;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap={3}>
|
|
||||||
<LibraryRecentlyModified />
|
|
||||||
{
|
|
||||||
renderEmptyState()
|
|
||||||
|| (
|
|
||||||
<>
|
|
||||||
<LibrarySection
|
|
||||||
title={intl.formatMessage(messages.collectionsTitle, { collectionCount })}
|
|
||||||
contentCount={collectionCount}
|
|
||||||
viewAllAction={() => handleTabChange(tabList.collections)}
|
|
||||||
>
|
|
||||||
<LibraryCollections variant="preview" />
|
|
||||||
</LibrarySection>
|
|
||||||
<LibrarySection
|
|
||||||
title={intl.formatMessage(messages.componentsTitle, { componentCount })}
|
|
||||||
contentCount={componentCount}
|
|
||||||
viewAllAction={() => handleTabChange(tabList.components)}
|
|
||||||
>
|
|
||||||
<LibraryComponents variant="preview" />
|
|
||||||
</LibrarySection>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LibraryHome;
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
|
||||||
import { orderBy } from 'lodash';
|
|
||||||
|
|
||||||
import { SearchContextProvider, useSearchContext } from '../search-manager';
|
|
||||||
import { type CollectionHit, type ContentHit, SearchSortOption } from '../search-manager/data/api';
|
|
||||||
import LibrarySection, { LIBRARY_SECTION_PREVIEW_LIMIT } from './components/LibrarySection';
|
|
||||||
import messages from './messages';
|
|
||||||
import ComponentCard from './components/ComponentCard';
|
|
||||||
import CollectionCard from './components/CollectionCard';
|
|
||||||
import { useLibraryContext } from './common/context';
|
|
||||||
|
|
||||||
const RecentlyModified: React.FC<Record<never, never>> = () => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const {
|
|
||||||
hits,
|
|
||||||
collectionHits,
|
|
||||||
totalHits,
|
|
||||||
totalCollectionHits,
|
|
||||||
} = useSearchContext();
|
|
||||||
|
|
||||||
const componentCount = totalHits + totalCollectionHits;
|
|
||||||
// Since we only display a fixed number of items in preview,
|
|
||||||
// only these number of items are use in sort step below
|
|
||||||
const componentList = hits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT);
|
|
||||||
const collectionList = collectionHits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT);
|
|
||||||
// Sort them by `modified` field in reverse and display them
|
|
||||||
const recentItems = orderBy([
|
|
||||||
...componentList,
|
|
||||||
...collectionList,
|
|
||||||
], ['modified'], ['desc']).slice(0, LIBRARY_SECTION_PREVIEW_LIMIT);
|
|
||||||
|
|
||||||
return componentCount > 0
|
|
||||||
? (
|
|
||||||
<LibrarySection
|
|
||||||
title={intl.formatMessage(messages.recentlyModifiedTitle)}
|
|
||||||
contentCount={componentCount}
|
|
||||||
>
|
|
||||||
<div className="library-cards-grid">
|
|
||||||
{recentItems.map((contentHit) => (
|
|
||||||
contentHit.type === 'collection' ? (
|
|
||||||
<CollectionCard
|
|
||||||
key={contentHit.id}
|
|
||||||
collectionHit={contentHit as CollectionHit}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ComponentCard
|
|
||||||
key={contentHit.id}
|
|
||||||
contentHit={contentHit as ContentHit}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</LibrarySection>
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LibraryRecentlyModified: React.FC<Record<never, never>> = () => {
|
|
||||||
const { libraryId, showOnlyPublished } = useLibraryContext();
|
|
||||||
|
|
||||||
const extraFilter = [`context_key = "${libraryId}"`];
|
|
||||||
if (showOnlyPublished) {
|
|
||||||
extraFilter.push('last_published IS NOT NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SearchContextProvider
|
|
||||||
extraFilter={extraFilter}
|
|
||||||
overrideSearchSortOrder={
|
|
||||||
showOnlyPublished
|
|
||||||
? SearchSortOption.RECENTLY_PUBLISHED
|
|
||||||
: SearchSortOption.RECENTLY_MODIFIED
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<RecentlyModified />
|
|
||||||
</SearchContextProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LibraryRecentlyModified;
|
|
||||||
@@ -180,34 +180,7 @@
|
|||||||
"display_name": "Blank Problem",
|
"display_name": "Blank Problem",
|
||||||
"description": "Problem"
|
"description": "Problem"
|
||||||
}
|
}
|
||||||
}
|
|
||||||
],
|
|
||||||
"query": "",
|
|
||||||
"processingTimeMs": 1,
|
|
||||||
"limit": 20,
|
|
||||||
"offset": 0,
|
|
||||||
"estimatedTotalHits": 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"indexUid": "studio_content",
|
|
||||||
"hits": [],
|
|
||||||
"query": "",
|
|
||||||
"processingTimeMs": 0,
|
|
||||||
"limit": 0,
|
|
||||||
"offset": 0,
|
|
||||||
"estimatedTotalHits": 5,
|
|
||||||
"facetDistribution": {
|
|
||||||
"block_type": {
|
|
||||||
"html": 4,
|
|
||||||
"problem": 1
|
|
||||||
},
|
},
|
||||||
"content.problem_types": {}
|
|
||||||
},
|
|
||||||
"facetStats": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"indexUid": "studio_content",
|
|
||||||
"hits": [
|
|
||||||
{
|
{
|
||||||
"display_name": "My first collection",
|
"display_name": "My first collection",
|
||||||
"block_id": "my-first-collection",
|
"block_id": "my-first-collection",
|
||||||
@@ -246,12 +219,30 @@
|
|||||||
"access_id": 16,
|
"access_id": 16,
|
||||||
"num_children": 1
|
"num_children": 1
|
||||||
}
|
}
|
||||||
|
|
||||||
],
|
],
|
||||||
"query": "",
|
"query": "",
|
||||||
"processingTimeMs": 0,
|
"processingTimeMs": 1,
|
||||||
"limit": 1,
|
"limit": 20,
|
||||||
"offset": 0,
|
"offset": 0,
|
||||||
"estimatedTotalHits": 1
|
"estimatedTotalHits": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"indexUid": "studio_content",
|
||||||
|
"hits": [],
|
||||||
|
"query": "",
|
||||||
|
"processingTimeMs": 0,
|
||||||
|
"limit": 0,
|
||||||
|
"offset": 0,
|
||||||
|
"estimatedTotalHits": 5,
|
||||||
|
"facetDistribution": {
|
||||||
|
"block_type": {
|
||||||
|
"html": 4,
|
||||||
|
"problem": 1
|
||||||
|
},
|
||||||
|
"content.problem_types": {}
|
||||||
|
},
|
||||||
|
"facetStats": {}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,199 @@
|
|||||||
"description": "Testing"
|
"description": "Testing"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"display_name": "Collection 1",
|
||||||
|
"block_id": "col1",
|
||||||
|
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer.",
|
||||||
|
"id": 1,
|
||||||
|
"type": "collection",
|
||||||
|
"breadcrumbs": [
|
||||||
|
{
|
||||||
|
"display_name": "CS problems 2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created": 1725534795.628254,
|
||||||
|
"modified": 1725878053.420395,
|
||||||
|
"context_key": "lib:OpenedX:CSPROB2",
|
||||||
|
"org": "OpenedX",
|
||||||
|
"access_id": 16,
|
||||||
|
"_formatted": {
|
||||||
|
"display_name": "Collection 1",
|
||||||
|
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
||||||
|
"id": "1",
|
||||||
|
"type": "collection",
|
||||||
|
"breadcrumbs": [
|
||||||
|
{
|
||||||
|
"display_name": "CS problems 2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created": "1725534795.628254",
|
||||||
|
"modified": "1725534795.628266",
|
||||||
|
"context_key": "lib:OpenedX:CSPROB2",
|
||||||
|
"org": "OpenedX",
|
||||||
|
"access_id": "16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"display_name": "Collection 2",
|
||||||
|
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 58",
|
||||||
|
"id": 2,
|
||||||
|
"type": "collection",
|
||||||
|
"breadcrumbs": [
|
||||||
|
{
|
||||||
|
"display_name": "CS problems 2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created": 1725534795.619101,
|
||||||
|
"modified": 1725534795.619113,
|
||||||
|
"context_key": "lib:OpenedX:CSPROB2",
|
||||||
|
"org": "OpenedX",
|
||||||
|
"access_id": 16,
|
||||||
|
"_formatted": {
|
||||||
|
"display_name": "Collection 2",
|
||||||
|
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
||||||
|
"id": "2",
|
||||||
|
"type": "collection",
|
||||||
|
"breadcrumbs": [
|
||||||
|
{
|
||||||
|
"display_name": "CS problems 2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created": "1725534795.619101",
|
||||||
|
"modified": "1725534795.619113",
|
||||||
|
"context_key": "lib:OpenedX:CSPROB2",
|
||||||
|
"org": "OpenedX",
|
||||||
|
"access_id": "16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"display_name": "Collection 3",
|
||||||
|
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 57",
|
||||||
|
"id": 3,
|
||||||
|
"type": "collection",
|
||||||
|
"breadcrumbs": [
|
||||||
|
{
|
||||||
|
"display_name": "CS problems 2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created": 1725534795.609781,
|
||||||
|
"modified": 1725534795.609794,
|
||||||
|
"context_key": "lib:OpenedX:CSPROB2",
|
||||||
|
"org": "OpenedX",
|
||||||
|
"access_id": 16,
|
||||||
|
"_formatted": {
|
||||||
|
"display_name": "Collection 3",
|
||||||
|
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
||||||
|
"id": "3",
|
||||||
|
"type": "collection",
|
||||||
|
"breadcrumbs": [
|
||||||
|
{
|
||||||
|
"display_name": "CS problems 2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created": "1725534795.609781",
|
||||||
|
"modified": "1725534795.609794",
|
||||||
|
"context_key": "lib:OpenedX:CSPROB2",
|
||||||
|
"org": "OpenedX",
|
||||||
|
"access_id": "16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"display_name": "Collection 4",
|
||||||
|
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 56",
|
||||||
|
"id": 4,
|
||||||
|
"type": "collection",
|
||||||
|
"breadcrumbs": [
|
||||||
|
{
|
||||||
|
"display_name": "CS problems 2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created": 1725534795.596287,
|
||||||
|
"modified": 1725534795.5963,
|
||||||
|
"context_key": "lib:OpenedX:CSPROB2",
|
||||||
|
"org": "OpenedX",
|
||||||
|
"access_id": 16,
|
||||||
|
"_formatted": {
|
||||||
|
"display_name": "Collection 4",
|
||||||
|
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
||||||
|
"id": "4",
|
||||||
|
"type": "collection",
|
||||||
|
"breadcrumbs": [
|
||||||
|
{
|
||||||
|
"display_name": "CS problems 2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created": "1725534795.596287",
|
||||||
|
"modified": "1725534795.5963",
|
||||||
|
"context_key": "lib:OpenedX:CSPROB2",
|
||||||
|
"org": "OpenedX",
|
||||||
|
"access_id": "16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"display_name": "Collection 5",
|
||||||
|
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 55",
|
||||||
|
"id": 5,
|
||||||
|
"type": "collection",
|
||||||
|
"breadcrumbs": [
|
||||||
|
{
|
||||||
|
"display_name": "CS problems 2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created": 1725534795.583068,
|
||||||
|
"modified": 1725534795.583082,
|
||||||
|
"context_key": "lib:OpenedX:CSPROB2",
|
||||||
|
"org": "OpenedX",
|
||||||
|
"access_id": 16,
|
||||||
|
"_formatted": {
|
||||||
|
"display_name": "Collection 5",
|
||||||
|
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
||||||
|
"id": "5",
|
||||||
|
"type": "collection",
|
||||||
|
"breadcrumbs": [
|
||||||
|
{
|
||||||
|
"display_name": "CS problems 2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created": "1725534795.583068",
|
||||||
|
"modified": "1725534795.583082",
|
||||||
|
"context_key": "lib:OpenedX:CSPROB2",
|
||||||
|
"org": "OpenedX",
|
||||||
|
"access_id": "16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"display_name": "Collection 6",
|
||||||
|
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 54",
|
||||||
|
"id": 6,
|
||||||
|
"type": "collection",
|
||||||
|
"breadcrumbs": [
|
||||||
|
{
|
||||||
|
"display_name": "CS problems 2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created": 1725534795.573794,
|
||||||
|
"modified": 1725534795.573808,
|
||||||
|
"context_key": "lib:OpenedX:CSPROB2",
|
||||||
|
"org": "OpenedX",
|
||||||
|
"access_id": 16,
|
||||||
|
"_formatted": {
|
||||||
|
"display_name": "Collection 6",
|
||||||
|
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
||||||
|
"id": "6",
|
||||||
|
"type": "collection",
|
||||||
|
"breadcrumbs": [
|
||||||
|
{
|
||||||
|
"display_name": "CS problems 2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created": "1725534795.573794",
|
||||||
|
"modified": "1725534795.573808",
|
||||||
|
"context_key": "lib:OpenedX:CSPROB2",
|
||||||
|
"org": "OpenedX",
|
||||||
|
"access_id": "16"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "lbaximtesthtml73a22298-bcd9-4f4c-ae34-0bc2b0612480-46b4a7f2",
|
"id": "lbaximtesthtml73a22298-bcd9-4f4c-ae34-0bc2b0612480-46b4a7f2",
|
||||||
"display_name": "Second Text Component",
|
"display_name": "Second Text Component",
|
||||||
@@ -318,209 +511,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"facetStats": {}
|
"facetStats": {}
|
||||||
},
|
|
||||||
{
|
|
||||||
"indexUid": "studio",
|
|
||||||
"hits": [
|
|
||||||
{
|
|
||||||
"display_name": "Collection 1",
|
|
||||||
"block_id": "col1",
|
|
||||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer.",
|
|
||||||
"id": 1,
|
|
||||||
"type": "collection",
|
|
||||||
"breadcrumbs": [
|
|
||||||
{
|
|
||||||
"display_name": "CS problems 2"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created": 1725534795.628254,
|
|
||||||
"modified": 1725878053.420395,
|
|
||||||
"context_key": "lib:OpenedX:CSPROB2",
|
|
||||||
"org": "OpenedX",
|
|
||||||
"access_id": 16,
|
|
||||||
"_formatted": {
|
|
||||||
"display_name": "Collection 1",
|
|
||||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
|
||||||
"id": "1",
|
|
||||||
"type": "collection",
|
|
||||||
"breadcrumbs": [
|
|
||||||
{
|
|
||||||
"display_name": "CS problems 2"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created": "1725534795.628254",
|
|
||||||
"modified": "1725534795.628266",
|
|
||||||
"context_key": "lib:OpenedX:CSPROB2",
|
|
||||||
"org": "OpenedX",
|
|
||||||
"access_id": "16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"display_name": "Collection 2",
|
|
||||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 58",
|
|
||||||
"id": 2,
|
|
||||||
"type": "collection",
|
|
||||||
"breadcrumbs": [
|
|
||||||
{
|
|
||||||
"display_name": "CS problems 2"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created": 1725534795.619101,
|
|
||||||
"modified": 1725534795.619113,
|
|
||||||
"context_key": "lib:OpenedX:CSPROB2",
|
|
||||||
"org": "OpenedX",
|
|
||||||
"access_id": 16,
|
|
||||||
"_formatted": {
|
|
||||||
"display_name": "Collection 2",
|
|
||||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
|
||||||
"id": "2",
|
|
||||||
"type": "collection",
|
|
||||||
"breadcrumbs": [
|
|
||||||
{
|
|
||||||
"display_name": "CS problems 2"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created": "1725534795.619101",
|
|
||||||
"modified": "1725534795.619113",
|
|
||||||
"context_key": "lib:OpenedX:CSPROB2",
|
|
||||||
"org": "OpenedX",
|
|
||||||
"access_id": "16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"display_name": "Collection 3",
|
|
||||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 57",
|
|
||||||
"id": 3,
|
|
||||||
"type": "collection",
|
|
||||||
"breadcrumbs": [
|
|
||||||
{
|
|
||||||
"display_name": "CS problems 2"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created": 1725534795.609781,
|
|
||||||
"modified": 1725534795.609794,
|
|
||||||
"context_key": "lib:OpenedX:CSPROB2",
|
|
||||||
"org": "OpenedX",
|
|
||||||
"access_id": 16,
|
|
||||||
"_formatted": {
|
|
||||||
"display_name": "Collection 3",
|
|
||||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
|
||||||
"id": "3",
|
|
||||||
"type": "collection",
|
|
||||||
"breadcrumbs": [
|
|
||||||
{
|
|
||||||
"display_name": "CS problems 2"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created": "1725534795.609781",
|
|
||||||
"modified": "1725534795.609794",
|
|
||||||
"context_key": "lib:OpenedX:CSPROB2",
|
|
||||||
"org": "OpenedX",
|
|
||||||
"access_id": "16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"display_name": "Collection 4",
|
|
||||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 56",
|
|
||||||
"id": 4,
|
|
||||||
"type": "collection",
|
|
||||||
"breadcrumbs": [
|
|
||||||
{
|
|
||||||
"display_name": "CS problems 2"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created": 1725534795.596287,
|
|
||||||
"modified": 1725534795.5963,
|
|
||||||
"context_key": "lib:OpenedX:CSPROB2",
|
|
||||||
"org": "OpenedX",
|
|
||||||
"access_id": 16,
|
|
||||||
"_formatted": {
|
|
||||||
"display_name": "Collection 4",
|
|
||||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
|
||||||
"id": "4",
|
|
||||||
"type": "collection",
|
|
||||||
"breadcrumbs": [
|
|
||||||
{
|
|
||||||
"display_name": "CS problems 2"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created": "1725534795.596287",
|
|
||||||
"modified": "1725534795.5963",
|
|
||||||
"context_key": "lib:OpenedX:CSPROB2",
|
|
||||||
"org": "OpenedX",
|
|
||||||
"access_id": "16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"display_name": "Collection 5",
|
|
||||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 55",
|
|
||||||
"id": 5,
|
|
||||||
"type": "collection",
|
|
||||||
"breadcrumbs": [
|
|
||||||
{
|
|
||||||
"display_name": "CS problems 2"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created": 1725534795.583068,
|
|
||||||
"modified": 1725534795.583082,
|
|
||||||
"context_key": "lib:OpenedX:CSPROB2",
|
|
||||||
"org": "OpenedX",
|
|
||||||
"access_id": 16,
|
|
||||||
"_formatted": {
|
|
||||||
"display_name": "Collection 5",
|
|
||||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
|
||||||
"id": "5",
|
|
||||||
"type": "collection",
|
|
||||||
"breadcrumbs": [
|
|
||||||
{
|
|
||||||
"display_name": "CS problems 2"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created": "1725534795.583068",
|
|
||||||
"modified": "1725534795.583082",
|
|
||||||
"context_key": "lib:OpenedX:CSPROB2",
|
|
||||||
"org": "OpenedX",
|
|
||||||
"access_id": "16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"display_name": "Collection 6",
|
|
||||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer. Descrition 54",
|
|
||||||
"id": 6,
|
|
||||||
"type": "collection",
|
|
||||||
"breadcrumbs": [
|
|
||||||
{
|
|
||||||
"display_name": "CS problems 2"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created": 1725534795.573794,
|
|
||||||
"modified": 1725534795.573808,
|
|
||||||
"context_key": "lib:OpenedX:CSPROB2",
|
|
||||||
"org": "OpenedX",
|
|
||||||
"access_id": 16,
|
|
||||||
"_formatted": {
|
|
||||||
"display_name": "Collection 6",
|
|
||||||
"description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet…",
|
|
||||||
"id": "6",
|
|
||||||
"type": "collection",
|
|
||||||
"breadcrumbs": [
|
|
||||||
{
|
|
||||||
"display_name": "CS problems 2"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"created": "1725534795.573794",
|
|
||||||
"modified": "1725534795.573808",
|
|
||||||
"context_key": "lib:OpenedX:CSPROB2",
|
|
||||||
"org": "OpenedX",
|
|
||||||
"access_id": "16"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"query": "learn",
|
|
||||||
"processingTimeMs": 1,
|
|
||||||
"limit": 6,
|
|
||||||
"offset": 0,
|
|
||||||
"estimatedTotalHits": 6
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import MockAdapter from 'axios-mock-adapter/types';
|
||||||
|
import { snakeCaseObject } from '@edx/frontend-platform';
|
||||||
import {
|
import {
|
||||||
fireEvent,
|
fireEvent,
|
||||||
render as baseRender,
|
render as baseRender,
|
||||||
@@ -6,23 +8,55 @@ import {
|
|||||||
initializeMocks,
|
initializeMocks,
|
||||||
} from '../../testUtils';
|
} from '../../testUtils';
|
||||||
import { mockContentLibrary } from '../data/api.mocks';
|
import { mockContentLibrary } from '../data/api.mocks';
|
||||||
import { getCreateLibraryBlockUrl, getLibraryPasteClipboardUrl } from '../data/api';
|
import {
|
||||||
|
getContentLibraryApiUrl, getCreateLibraryBlockUrl, getLibraryCollectionComponentApiUrl, getLibraryPasteClipboardUrl,
|
||||||
|
} from '../data/api';
|
||||||
import { mockBroadcastChannel, mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock';
|
import { mockBroadcastChannel, mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock';
|
||||||
import { LibraryProvider } from '../common/context';
|
import { LibraryProvider } from '../common/context';
|
||||||
import AddContentContainer from './AddContentContainer';
|
import AddContentContainer from './AddContentContainer';
|
||||||
|
import { ComponentEditorModal } from '../components/ComponentEditorModal';
|
||||||
|
import editorCmsApi from '../../editors/data/services/cms/api';
|
||||||
|
import { ToastActionData } from '../../generic/toast-context';
|
||||||
|
|
||||||
mockBroadcastChannel();
|
mockBroadcastChannel();
|
||||||
|
|
||||||
|
// Mocks for ComponentEditorModal to work in tests.
|
||||||
|
jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' }));
|
||||||
|
|
||||||
const { libraryId } = mockContentLibrary;
|
const { libraryId } = mockContentLibrary;
|
||||||
const render = () => baseRender(<AddContentContainer />, {
|
const render = (collectionId?: string) => {
|
||||||
path: '/library/:libraryId/*',
|
const params: { libraryId: string, collectionId?: string } = { libraryId };
|
||||||
params: { libraryId },
|
if (collectionId) {
|
||||||
extraWrapper: ({ children }) => <LibraryProvider libraryId={libraryId}>{ children }</LibraryProvider>,
|
params.collectionId = collectionId;
|
||||||
});
|
}
|
||||||
|
return baseRender(<AddContentContainer />, {
|
||||||
|
path: '/library/:libraryId/*',
|
||||||
|
params,
|
||||||
|
extraWrapper: ({ children }) => (
|
||||||
|
<LibraryProvider
|
||||||
|
libraryId={libraryId}
|
||||||
|
collectionId={collectionId}
|
||||||
|
>
|
||||||
|
{ children }
|
||||||
|
<ComponentEditorModal />
|
||||||
|
</LibraryProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let axiosMock: MockAdapter;
|
||||||
|
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
|
||||||
|
|
||||||
describe('<AddContentContainer />', () => {
|
describe('<AddContentContainer />', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const mocks = initializeMocks();
|
||||||
|
axiosMock = mocks.axiosMock;
|
||||||
|
mockShowToast = mocks.mockShowToast;
|
||||||
|
axiosMock.onGet(getContentLibraryApiUrl(libraryId)).reply(200, {});
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
it('should render content buttons', () => {
|
it('should render content buttons', () => {
|
||||||
initializeMocks();
|
|
||||||
mockClipboardEmpty.applyMock();
|
mockClipboardEmpty.applyMock();
|
||||||
render();
|
render();
|
||||||
expect(screen.queryByRole('button', { name: /collection/i })).toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: /collection/i })).toBeInTheDocument();
|
||||||
@@ -36,7 +70,6 @@ describe('<AddContentContainer />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should create a content', async () => {
|
it('should create a content', async () => {
|
||||||
const { axiosMock } = initializeMocks();
|
|
||||||
mockClipboardEmpty.applyMock();
|
mockClipboardEmpty.applyMock();
|
||||||
const url = getCreateLibraryBlockUrl(libraryId);
|
const url = getCreateLibraryBlockUrl(libraryId);
|
||||||
axiosMock.onPost(url).reply(200);
|
axiosMock.onPost(url).reply(200);
|
||||||
@@ -47,10 +80,82 @@ describe('<AddContentContainer />', () => {
|
|||||||
fireEvent.click(textButton);
|
fireEvent.click(textButton);
|
||||||
|
|
||||||
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
|
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
|
||||||
|
await waitFor(() => expect(axiosMock.history.patch.length).toEqual(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a content in a collection for non-editable blocks', async () => {
|
||||||
|
mockClipboardEmpty.applyMock();
|
||||||
|
const collectionId = 'some-collection-id';
|
||||||
|
const url = getCreateLibraryBlockUrl(libraryId);
|
||||||
|
const collectionComponentUrl = getLibraryCollectionComponentApiUrl(
|
||||||
|
libraryId,
|
||||||
|
collectionId,
|
||||||
|
);
|
||||||
|
// having id of block which is not video, html or problem will not trigger editor.
|
||||||
|
axiosMock.onPost(url).reply(200, { id: 'some-component-id' });
|
||||||
|
axiosMock.onPatch(collectionComponentUrl).reply(200);
|
||||||
|
|
||||||
|
render(collectionId);
|
||||||
|
|
||||||
|
const textButton = screen.getByRole('button', { name: /text/i });
|
||||||
|
fireEvent.click(textButton);
|
||||||
|
|
||||||
|
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
|
||||||
|
await waitFor(() => expect(axiosMock.history.patch.length).toEqual(1));
|
||||||
|
await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(collectionComponentUrl));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a content in a collection for editable blocks', async () => {
|
||||||
|
mockClipboardEmpty.applyMock();
|
||||||
|
const collectionId = 'some-collection-id';
|
||||||
|
const url = getCreateLibraryBlockUrl(libraryId);
|
||||||
|
const collectionComponentUrl = getLibraryCollectionComponentApiUrl(
|
||||||
|
libraryId,
|
||||||
|
collectionId,
|
||||||
|
);
|
||||||
|
// Mocks for ComponentEditorModal to work in tests.
|
||||||
|
jest.spyOn(editorCmsApi, 'fetchImages').mockImplementation(async () => ( // eslint-disable-next-line
|
||||||
|
{ data: { assets: [], start: 0, end: 0, page: 0, pageSize: 50, totalCount: 0 } }
|
||||||
|
));
|
||||||
|
jest.spyOn(editorCmsApi, 'fetchByUnitId').mockImplementation(async () => ({
|
||||||
|
status: 200,
|
||||||
|
data: {
|
||||||
|
ancestors: [{
|
||||||
|
id: 'block-v1:Org+TS100+24+type@vertical+block@parent',
|
||||||
|
display_name: 'You-Knit? The Test Unit',
|
||||||
|
category: 'vertical',
|
||||||
|
has_children: true,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
axiosMock.onPost(url).reply(200, {
|
||||||
|
id: 'lb:OpenedX:CSPROB2:html:1a5efd56-4ee5-4df0-b466-44f08fbbf567',
|
||||||
|
});
|
||||||
|
const fieldsHtml = {
|
||||||
|
displayName: 'Introduction to Testing',
|
||||||
|
data: '<p>This is a text component which uses <strong>HTML</strong>.</p>',
|
||||||
|
metadata: { displayName: 'Introduction to Testing' },
|
||||||
|
};
|
||||||
|
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => (
|
||||||
|
{ status: 200, data: snakeCaseObject(fieldsHtml) }
|
||||||
|
));
|
||||||
|
axiosMock.onPatch(collectionComponentUrl).reply(200);
|
||||||
|
|
||||||
|
render(collectionId);
|
||||||
|
|
||||||
|
const textButton = screen.getByRole('button', { name: /text/i });
|
||||||
|
fireEvent.click(textButton);
|
||||||
|
|
||||||
|
// Component should be linked to Collection on closing editor.
|
||||||
|
const closeButton = await screen.findByRole('button', { name: 'Exit the editor' });
|
||||||
|
fireEvent.click(closeButton);
|
||||||
|
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
|
||||||
|
await waitFor(() => expect(axiosMock.history.patch.length).toEqual(1));
|
||||||
|
await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(collectionComponentUrl));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render paste button if clipboard contains pastable xblock', async () => {
|
it('should render paste button if clipboard contains pastable xblock', async () => {
|
||||||
initializeMocks();
|
|
||||||
// Simulate having an HTML block in the clipboard:
|
// Simulate having an HTML block in the clipboard:
|
||||||
const getClipboardSpy = mockClipboardHtml.applyMock();
|
const getClipboardSpy = mockClipboardHtml.applyMock();
|
||||||
render();
|
render();
|
||||||
@@ -59,7 +164,6 @@ describe('<AddContentContainer />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should paste content', async () => {
|
it('should paste content', async () => {
|
||||||
const { axiosMock } = initializeMocks();
|
|
||||||
// Simulate having an HTML block in the clipboard:
|
// Simulate having an HTML block in the clipboard:
|
||||||
const getClipboardSpy = mockClipboardHtml.applyMock();
|
const getClipboardSpy = mockClipboardHtml.applyMock();
|
||||||
|
|
||||||
@@ -76,54 +180,58 @@ describe('<AddContentContainer />', () => {
|
|||||||
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
|
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle failure to paste content', async () => {
|
it('should paste content inside a collection', async () => {
|
||||||
const { axiosMock, mockShowToast } = initializeMocks();
|
|
||||||
// Simulate having an HTML block in the clipboard:
|
// Simulate having an HTML block in the clipboard:
|
||||||
mockClipboardHtml.applyMock();
|
const getClipboardSpy = mockClipboardHtml.applyMock();
|
||||||
|
|
||||||
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
|
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
|
||||||
axiosMock.onPost(pasteUrl).reply(400);
|
const collectionId = 'some-collection-id';
|
||||||
|
const collectionComponentUrl = getLibraryCollectionComponentApiUrl(
|
||||||
|
libraryId,
|
||||||
|
collectionId,
|
||||||
|
);
|
||||||
|
axiosMock.onPatch(collectionComponentUrl).reply(200);
|
||||||
|
axiosMock.onPost(pasteUrl).reply(200, { id: 'some-component-id' });
|
||||||
|
|
||||||
render();
|
render(collectionId);
|
||||||
|
|
||||||
|
expect(getClipboardSpy).toHaveBeenCalled(); // Hmm, this is getting called four times! Refactor to use react-query.
|
||||||
|
|
||||||
const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i });
|
const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i });
|
||||||
fireEvent.click(pasteButton);
|
fireEvent.click(pasteButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
|
||||||
expect(axiosMock.history.post[0].url).toEqual(pasteUrl);
|
await waitFor(() => expect(axiosMock.history.patch.length).toEqual(1));
|
||||||
expect(mockShowToast).toHaveBeenCalledWith('There was an error pasting the content.');
|
await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(collectionComponentUrl));
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle failure to paste content and show server error if available', async () => {
|
it('should show error toast on linking failure', async () => {
|
||||||
const { axiosMock, mockShowToast } = initializeMocks();
|
|
||||||
// Simulate having an HTML block in the clipboard:
|
// Simulate having an HTML block in the clipboard:
|
||||||
mockClipboardHtml.applyMock();
|
const getClipboardSpy = mockClipboardHtml.applyMock();
|
||||||
|
|
||||||
const errMsg = 'Libraries do not support this type of content yet.';
|
|
||||||
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
|
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
|
||||||
|
const collectionId = 'some-collection-id';
|
||||||
|
const collectionComponentUrl = getLibraryCollectionComponentApiUrl(
|
||||||
|
libraryId,
|
||||||
|
collectionId,
|
||||||
|
);
|
||||||
|
axiosMock.onPatch(collectionComponentUrl).reply(500);
|
||||||
|
axiosMock.onPost(pasteUrl).reply(200, { id: 'some-component-id' });
|
||||||
|
|
||||||
// eslint-disable-next-line prefer-promise-reject-errors
|
render(collectionId);
|
||||||
axiosMock.onPost(pasteUrl).reply(() => Promise.reject({
|
|
||||||
customAttributes: {
|
|
||||||
httpErrorStatus: 400,
|
|
||||||
httpErrorResponseData: JSON.stringify({ block_type: errMsg }),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
render();
|
expect(getClipboardSpy).toHaveBeenCalled(); // Hmm, this is getting called four times! Refactor to use react-query.
|
||||||
|
|
||||||
const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i });
|
const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i });
|
||||||
fireEvent.click(pasteButton);
|
fireEvent.click(pasteButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
|
||||||
expect(axiosMock.history.post[0].url).toEqual(pasteUrl);
|
await waitFor(() => expect(axiosMock.history.patch.length).toEqual(1));
|
||||||
expect(mockShowToast).toHaveBeenCalledWith(errMsg);
|
await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(collectionComponentUrl));
|
||||||
});
|
expect(mockShowToast).toHaveBeenCalledWith('There was an error linking the content to this collection.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should stop user from pasting unsupported blocks and show toast', async () => {
|
it('should stop user from pasting unsupported blocks and show toast', async () => {
|
||||||
const { axiosMock, mockShowToast } = initializeMocks();
|
|
||||||
// Simulate having an HTML block in the clipboard:
|
// Simulate having an HTML block in the clipboard:
|
||||||
mockClipboardHtml.applyMock('openassessment');
|
mockClipboardHtml.applyMock('openassessment');
|
||||||
|
|
||||||
@@ -139,4 +247,52 @@ describe('<AddContentContainer />', () => {
|
|||||||
expect(mockShowToast).toHaveBeenCalledWith(errMsg);
|
expect(mockShowToast).toHaveBeenCalledWith(errMsg);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
label: 'should handle failure to paste content',
|
||||||
|
mockUrl: getLibraryPasteClipboardUrl(libraryId),
|
||||||
|
mockResponse: undefined,
|
||||||
|
expectedError: 'There was an error pasting the content.',
|
||||||
|
buttonName: /paste from clipboard/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'should show detailed error in toast on paste failure',
|
||||||
|
mockUrl: getLibraryPasteClipboardUrl(libraryId),
|
||||||
|
mockResponse: ['library cannot have more than 100000 components'],
|
||||||
|
expectedError: 'There was an error pasting the content: library cannot have more than 100000 components',
|
||||||
|
buttonName: /paste from clipboard/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'should handle failure to create content',
|
||||||
|
mockUrl: getCreateLibraryBlockUrl(libraryId),
|
||||||
|
mockResponse: undefined,
|
||||||
|
expectedError: 'There was an error creating the content.',
|
||||||
|
buttonName: /text/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'should show detailed error in toast on create failure',
|
||||||
|
mockUrl: getCreateLibraryBlockUrl(libraryId),
|
||||||
|
mockResponse: 'library cannot have more than 100000 components',
|
||||||
|
expectedError: 'There was an error creating the content: library cannot have more than 100000 components',
|
||||||
|
buttonName: /text/i,
|
||||||
|
},
|
||||||
|
])('$label', async ({
|
||||||
|
mockUrl, mockResponse, buttonName, expectedError,
|
||||||
|
}) => {
|
||||||
|
axiosMock.onPost(mockUrl).reply(400, mockResponse);
|
||||||
|
|
||||||
|
// Simulate having an HTML block in the clipboard:
|
||||||
|
mockClipboardHtml.applyMock();
|
||||||
|
|
||||||
|
render();
|
||||||
|
const button = await screen.findByRole('button', { name: buttonName });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(axiosMock.history.post.length).toEqual(1);
|
||||||
|
expect(axiosMock.history.post[0].url).toEqual(mockUrl);
|
||||||
|
expect(mockShowToast).toHaveBeenCalledWith(expectedError);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useContext } from 'react';
|
import React, { useContext } from 'react';
|
||||||
|
import type { MessageDescriptor } from 'react-intl';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
@@ -80,15 +81,21 @@ const AddContentContainer = () => {
|
|||||||
|
|
||||||
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
|
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
|
||||||
|
|
||||||
const parsePasteErrorMsg = (error: any) => {
|
const parseErrorMsg = (
|
||||||
let errMsg: string;
|
error: any,
|
||||||
|
detailedMessage: MessageDescriptor,
|
||||||
|
defaultMessage: MessageDescriptor,
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { customAttributes: { httpErrorResponseData } } = error;
|
const { response: { data } } = error;
|
||||||
errMsg = JSON.parse(httpErrorResponseData).block_type;
|
const detail = data && (Array.isArray(data) ? data.join() : String(data));
|
||||||
|
if (detail) {
|
||||||
|
return intl.formatMessage(detailedMessage, { detail });
|
||||||
|
}
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
errMsg = intl.formatMessage(messages.errorPasteClipboardMessage);
|
// ignore
|
||||||
}
|
}
|
||||||
return errMsg;
|
return intl.formatMessage(defaultMessage);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isBlockTypeEnabled = (blockType: string) => getConfig().LIBRARY_SUPPORTED_BLOCKS.includes(blockType);
|
const isBlockTypeEnabled = (blockType: string) => getConfig().LIBRARY_SUPPORTED_BLOCKS.includes(blockType);
|
||||||
@@ -158,6 +165,12 @@ const AddContentContainer = () => {
|
|||||||
contentTypes.push(pasteButton);
|
contentTypes.push(pasteButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const linkComponent = (usageKey: string) => {
|
||||||
|
updateComponentsMutation.mutateAsync([usageKey]).catch(() => {
|
||||||
|
showToast(intl.formatMessage(messages.errorAssociateComponentMessage));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const onPaste = () => {
|
const onPaste = () => {
|
||||||
if (!isBlockTypeEnabled(sharedClipboardData.content?.blockType)) {
|
if (!isBlockTypeEnabled(sharedClipboardData.content?.blockType)) {
|
||||||
showToast(intl.formatMessage(messages.unsupportedBlockPasteClipboardMessage));
|
showToast(intl.formatMessage(messages.unsupportedBlockPasteClipboardMessage));
|
||||||
@@ -166,10 +179,15 @@ const AddContentContainer = () => {
|
|||||||
pasteClipboardMutation.mutateAsync({
|
pasteClipboardMutation.mutateAsync({
|
||||||
libraryId,
|
libraryId,
|
||||||
blockId: `${uuid4()}`,
|
blockId: `${uuid4()}`,
|
||||||
}).then(() => {
|
}).then((data) => {
|
||||||
|
linkComponent(data.id);
|
||||||
showToast(intl.formatMessage(messages.successPasteClipboardMessage));
|
showToast(intl.formatMessage(messages.successPasteClipboardMessage));
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
showToast(parsePasteErrorMsg(error));
|
showToast(parseErrorMsg(
|
||||||
|
error,
|
||||||
|
messages.errorPasteClipboardMessageWithDetail,
|
||||||
|
messages.errorPasteClipboardMessage,
|
||||||
|
));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -180,17 +198,20 @@ const AddContentContainer = () => {
|
|||||||
definitionId: `${uuid4()}`,
|
definitionId: `${uuid4()}`,
|
||||||
}).then((data) => {
|
}).then((data) => {
|
||||||
const hasEditor = canEditComponent(data.id);
|
const hasEditor = canEditComponent(data.id);
|
||||||
updateComponentsMutation.mutateAsync([data.id]).catch(() => {
|
|
||||||
showToast(intl.formatMessage(messages.errorAssociateComponentMessage));
|
|
||||||
});
|
|
||||||
if (hasEditor) {
|
if (hasEditor) {
|
||||||
openComponentEditor(data.id);
|
// linkComponent on editor close.
|
||||||
|
openComponentEditor(data.id, () => linkComponent(data.id));
|
||||||
} else {
|
} else {
|
||||||
// We can't start editing this right away so just show a toast message:
|
// We can't start editing this right away so just show a toast message:
|
||||||
showToast(intl.formatMessage(messages.successCreateMessage));
|
showToast(intl.formatMessage(messages.successCreateMessage));
|
||||||
|
linkComponent(data.id);
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch((error) => {
|
||||||
showToast(intl.formatMessage(messages.errorCreateMessage));
|
showToast(parseErrorMsg(
|
||||||
|
error,
|
||||||
|
messages.errorCreateMessageWithDetail,
|
||||||
|
messages.errorCreateMessage,
|
||||||
|
));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -206,6 +227,7 @@ const AddContentContainer = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* istanbul ignore next */
|
||||||
if (pasteClipboardMutation.isLoading) {
|
if (pasteClipboardMutation.isLoading) {
|
||||||
showToast(intl.formatMessage(messages.pastingClipboardMessage));
|
showToast(intl.formatMessage(messages.pastingClipboardMessage));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
} from '../data/api.mocks';
|
} from '../data/api.mocks';
|
||||||
import { mockBroadcastChannel, mockClipboardEmpty } from '../../generic/data/api.mock';
|
import { mockBroadcastChannel, mockClipboardEmpty } from '../../generic/data/api.mock';
|
||||||
import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock';
|
import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock';
|
||||||
|
import { studioHomeMock } from '../../studio-home/__mocks__';
|
||||||
|
import { getStudioHomeApiUrl } from '../../studio-home/data/api';
|
||||||
import LibraryLayout from '../LibraryLayout';
|
import LibraryLayout from '../LibraryLayout';
|
||||||
|
|
||||||
mockContentSearchConfig.applyMock();
|
mockContentSearchConfig.applyMock();
|
||||||
@@ -46,8 +48,12 @@ const renderOpts = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('AddContentWorkflow test', () => {
|
describe('AddContentWorkflow test', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const { axiosMock } = initializeMocks();
|
||||||
|
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
|
||||||
|
});
|
||||||
|
|
||||||
it('can create an HTML component', async () => {
|
it('can create an HTML component', async () => {
|
||||||
initializeMocks();
|
|
||||||
render(<LibraryLayout />, renderOpts);
|
render(<LibraryLayout />, renderOpts);
|
||||||
|
|
||||||
// Click "New [Component]"
|
// Click "New [Component]"
|
||||||
@@ -84,7 +90,6 @@ describe('AddContentWorkflow test', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('can create a Problem component', async () => {
|
it('can create a Problem component', async () => {
|
||||||
initializeMocks();
|
|
||||||
render(<LibraryLayout />, renderOpts);
|
render(<LibraryLayout />, renderOpts);
|
||||||
|
|
||||||
// Click "New [Component]"
|
// Click "New [Component]"
|
||||||
@@ -119,7 +124,6 @@ describe('AddContentWorkflow test', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('can create a Video component', async () => {
|
it('can create a Video component', async () => {
|
||||||
initializeMocks();
|
|
||||||
render(<LibraryLayout />, renderOpts);
|
render(<LibraryLayout />, renderOpts);
|
||||||
|
|
||||||
// Click "New [Component]"
|
// Click "New [Component]"
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
screen,
|
screen,
|
||||||
initializeMocks,
|
initializeMocks,
|
||||||
} from '../../testUtils';
|
} from '../../testUtils';
|
||||||
|
import { studioHomeMock } from '../../studio-home/__mocks__';
|
||||||
|
import { getStudioHomeApiUrl } from '../../studio-home/data/api';
|
||||||
import mockResult from '../__mocks__/library-search.json';
|
import mockResult from '../__mocks__/library-search.json';
|
||||||
import { LibraryProvider } from '../common/context';
|
import { LibraryProvider } from '../common/context';
|
||||||
import { ComponentPickerModal } from '../component-picker';
|
import { ComponentPickerModal } from '../component-picker';
|
||||||
@@ -16,7 +18,6 @@ import {
|
|||||||
} from '../data/api.mocks';
|
} from '../data/api.mocks';
|
||||||
import { PickLibraryContentModal } from './PickLibraryContentModal';
|
import { PickLibraryContentModal } from './PickLibraryContentModal';
|
||||||
|
|
||||||
initializeMocks();
|
|
||||||
mockContentSearchConfig.applyMock();
|
mockContentSearchConfig.applyMock();
|
||||||
mockContentLibrary.applyMock();
|
mockContentLibrary.applyMock();
|
||||||
mockGetCollectionMetadata.applyMock();
|
mockGetCollectionMetadata.applyMock();
|
||||||
@@ -45,6 +46,7 @@ describe('<PickLibraryContentModal />', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const mocks = initializeMocks();
|
const mocks = initializeMocks();
|
||||||
mockShowToast = mocks.mockShowToast;
|
mockShowToast = mocks.mockShowToast;
|
||||||
|
mocks.axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can pick components from the modal', async () => {
|
it('can pick components from the modal', async () => {
|
||||||
|
|||||||
@@ -64,7 +64,15 @@ const messages = defineMessages({
|
|||||||
errorCreateMessage: {
|
errorCreateMessage: {
|
||||||
id: 'course-authoring.library-authoring.add-content.error.text',
|
id: 'course-authoring.library-authoring.add-content.error.text',
|
||||||
defaultMessage: 'There was an error creating the content.',
|
defaultMessage: 'There was an error creating the content.',
|
||||||
description: 'Message when creation of content in library is on error',
|
description: 'Message when creation of content in library is on error.',
|
||||||
|
},
|
||||||
|
errorCreateMessageWithDetail: {
|
||||||
|
id: 'course-authoring.library-authoring.add-content.error.text-detail',
|
||||||
|
defaultMessage: 'There was an error creating the content: {detail}',
|
||||||
|
description: (
|
||||||
|
'Message when creation of content in library is on error.'
|
||||||
|
+ ' The {detail} text provides more information about the error.'
|
||||||
|
),
|
||||||
},
|
},
|
||||||
successAssociateComponentMessage: {
|
successAssociateComponentMessage: {
|
||||||
id: 'course-authoring.library-authoring.associate-collection-content.success.text',
|
id: 'course-authoring.library-authoring.associate-collection-content.success.text',
|
||||||
@@ -91,6 +99,14 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'There was an error pasting the content.',
|
defaultMessage: 'There was an error pasting the content.',
|
||||||
description: 'Message when pasting clipboard in library errors',
|
description: 'Message when pasting clipboard in library errors',
|
||||||
},
|
},
|
||||||
|
errorPasteClipboardMessageWithDetail: {
|
||||||
|
id: 'course-authoring.library-authoring.paste-clipboard.error.text-detail',
|
||||||
|
defaultMessage: 'There was an error pasting the content: {detail}',
|
||||||
|
description: (
|
||||||
|
'Message when pasting clipboard in library errors.'
|
||||||
|
+ ' The {detail} text provides more information about the error.'
|
||||||
|
),
|
||||||
|
},
|
||||||
pastingClipboardMessage: {
|
pastingClipboardMessage: {
|
||||||
id: 'course-authoring.library-authoring.paste-clipboard.loading.text',
|
id: 'course-authoring.library-authoring.paste-clipboard.loading.text',
|
||||||
defaultMessage: 'Pasting content from clipboard...',
|
defaultMessage: 'Pasting content from clipboard...',
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Stack } from '@openedx/paragon';
|
import { Stack } from '@openedx/paragon';
|
||||||
import { NoComponents, NoSearchResults } from '../EmptyStates';
|
import { NoComponents, NoSearchResults } from '../EmptyStates';
|
||||||
import { useSearchContext } from '../../search-manager';
|
import { useSearchContext } from '../../search-manager';
|
||||||
import { LibraryComponents } from '../components';
|
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
import { useLibraryContext } from '../common/context';
|
import { useLibraryContext } from '../common/context';
|
||||||
|
import LibraryContent, { ContentType } from '../LibraryContent';
|
||||||
|
|
||||||
const LibraryCollectionComponents = () => {
|
const LibraryCollectionComponents = () => {
|
||||||
const { totalHits: componentCount, isFiltered } = useSearchContext();
|
const { totalHits: componentCount, isFiltered } = useSearchContext();
|
||||||
@@ -24,7 +24,7 @@ const LibraryCollectionComponents = () => {
|
|||||||
return (
|
return (
|
||||||
<Stack direction="vertical" gap={3}>
|
<Stack direction="vertical" gap={3}>
|
||||||
<h3 className="text-gray">Content ({componentCount})</h3>
|
<h3 className="text-gray">Content ({componentCount})</h3>
|
||||||
<LibraryComponents variant="full" />
|
<LibraryContent contentType={ContentType.collections} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
|
|||||||
const path = '/library/:libraryId/*';
|
const path = '/library/:libraryId/*';
|
||||||
const libraryTitle = mockContentLibrary.libraryData.title;
|
const libraryTitle = mockContentLibrary.libraryData.title;
|
||||||
const mockCollection = {
|
const mockCollection = {
|
||||||
collectionId: mockResult.results[2].hits[0].block_id,
|
collectionId: mockResult.results[0].hits[5].block_id,
|
||||||
collectionNeverLoads: mockGetCollectionMetadata.collectionIdLoading,
|
collectionNeverLoads: mockGetCollectionMetadata.collectionIdLoading,
|
||||||
collectionNoComponents: 'collection-no-components',
|
collectionNoComponents: 'collection-no-components',
|
||||||
collectionEmpty: mockGetCollectionMetadata.collectionIdError,
|
collectionEmpty: mockGetCollectionMetadata.collectionIdError,
|
||||||
@@ -62,23 +62,21 @@ describe('<LibraryCollectionPage />', () => {
|
|||||||
// because otherwise Instantsearch will update the UI and change the query,
|
// because otherwise Instantsearch will update the UI and change the query,
|
||||||
// leading to unexpected results in the test cases.
|
// leading to unexpected results in the test cases.
|
||||||
mockResultCopy.results[0].query = query;
|
mockResultCopy.results[0].query = query;
|
||||||
mockResultCopy.results[2].query = query;
|
|
||||||
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
|
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
|
||||||
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
||||||
mockResultCopy.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
mockResultCopy.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
||||||
const collectionQueryId = requestData?.queries[0]?.filter?.[3]?.split('collections.key = "')[1].split('"')[0];
|
const collectionQueryId = requestData?.queries[0]?.filter?.[2]?.split('collections.key = "')[1].split('"')[0];
|
||||||
switch (collectionQueryId) {
|
switch (collectionQueryId) {
|
||||||
case mockCollection.collectionNeverLoads:
|
case mockCollection.collectionNeverLoads:
|
||||||
return new Promise<any>(() => {});
|
return new Promise<any>(() => {});
|
||||||
case mockCollection.collectionEmpty:
|
case mockCollection.collectionEmpty:
|
||||||
mockResultCopy.results[2].hits = [];
|
mockResultCopy.results[0].hits = [];
|
||||||
mockResultCopy.results[2].estimatedTotalHits = 0;
|
mockResultCopy.results[0].totalHits = 0;
|
||||||
break;
|
break;
|
||||||
case mockCollection.collectionNoComponents:
|
case mockCollection.collectionNoComponents:
|
||||||
mockResultCopy.results[0].hits = [];
|
mockResultCopy.results[0].hits = [];
|
||||||
mockResultCopy.results[0].estimatedTotalHits = 0;
|
mockResultCopy.results[0].totalHits = 0;
|
||||||
mockResultCopy.results[1].facetDistribution.block_type = {};
|
mockResultCopy.results[1].facetDistribution.block_type = {};
|
||||||
mockResultCopy.results[2].hits[0].num_children = 0;
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
@@ -181,7 +179,7 @@ describe('<LibraryCollectionPage />', () => {
|
|||||||
// should not be impacted by the search
|
// should not be impacted by the search
|
||||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
|
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
|
||||||
|
|
||||||
expect(screen.queryByText('No matching components found in this collections.')).toBeInTheDocument();
|
expect(screen.queryByText('No matching components found in this collection.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should open and close new content sidebar', async () => {
|
it('should open and close new content sidebar', async () => {
|
||||||
|
|||||||
@@ -196,7 +196,6 @@ const LibraryCollectionPage = () => {
|
|||||||
<Container size="xl" className="px-4 mt-4 mb-5 library-authoring-page">
|
<Container size="xl" className="px-4 mt-4 mb-5 library-authoring-page">
|
||||||
<SearchContextProvider
|
<SearchContextProvider
|
||||||
extraFilter={extraFilter}
|
extraFilter={extraFilter}
|
||||||
overrideQueries={{ collections: { limit: 0 } }}
|
|
||||||
>
|
>
|
||||||
<SubHeader
|
<SubHeader
|
||||||
title={(
|
title={(
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import { LoadingSpinner } from '../../generic/Loading';
|
|
||||||
import { useLoadOnScroll } from '../../hooks';
|
|
||||||
import { useSearchContext } from '../../search-manager';
|
|
||||||
import { NoComponents, NoSearchResults } from '../EmptyStates';
|
|
||||||
import CollectionCard from '../components/CollectionCard';
|
|
||||||
import { LIBRARY_SECTION_PREVIEW_LIMIT } from '../components/LibrarySection';
|
|
||||||
import messages from './messages';
|
|
||||||
import { useLibraryContext } from '../common/context';
|
|
||||||
|
|
||||||
type LibraryCollectionsProps = {
|
|
||||||
variant: 'full' | 'preview',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Library Collections to show collections grid
|
|
||||||
*
|
|
||||||
* Use style to:
|
|
||||||
* - 'full': Show all collections with Infinite scroll pagination.
|
|
||||||
* - 'preview': Show first 4 collections without pagination.
|
|
||||||
*/
|
|
||||||
const LibraryCollections = ({ variant }: LibraryCollectionsProps) => {
|
|
||||||
const {
|
|
||||||
collectionHits,
|
|
||||||
totalCollectionHits,
|
|
||||||
isFetchingNextPage,
|
|
||||||
hasNextPage,
|
|
||||||
fetchNextPage,
|
|
||||||
isLoading,
|
|
||||||
isFiltered,
|
|
||||||
} = useSearchContext();
|
|
||||||
|
|
||||||
const { openCreateCollectionModal } = useLibraryContext();
|
|
||||||
|
|
||||||
const collectionList = variant === 'preview' ? collectionHits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT) : collectionHits;
|
|
||||||
|
|
||||||
useLoadOnScroll(
|
|
||||||
hasNextPage,
|
|
||||||
isFetchingNextPage,
|
|
||||||
fetchNextPage,
|
|
||||||
variant === 'full',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <LoadingSpinner />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (totalCollectionHits === 0) {
|
|
||||||
return isFiltered
|
|
||||||
? <NoSearchResults infoText={messages.noSearchResultsCollections} />
|
|
||||||
: (
|
|
||||||
<NoComponents
|
|
||||||
infoText={messages.noCollections}
|
|
||||||
addBtnText={messages.addCollection}
|
|
||||||
handleBtnClick={openCreateCollectionModal}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="library-cards-grid">
|
|
||||||
{collectionList.map((collectionHit) => (
|
|
||||||
<CollectionCard
|
|
||||||
key={collectionHit.id}
|
|
||||||
collectionHit={collectionHit}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LibraryCollections;
|
|
||||||
@@ -63,7 +63,7 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
noSearchResultsInCollection: {
|
noSearchResultsInCollection: {
|
||||||
id: 'course-authoring.library-authoring.collections-pag.no-search-results.text',
|
id: 'course-authoring.library-authoring.collections-pag.no-search-results.text',
|
||||||
defaultMessage: 'No matching components found in this collections.',
|
defaultMessage: 'No matching components found in this collection.',
|
||||||
description: 'Message displayed when no matching components are found in collection',
|
description: 'Message displayed when no matching components are found in collection',
|
||||||
},
|
},
|
||||||
newContentButton: {
|
newContentButton: {
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ export interface SidebarComponentInfo {
|
|||||||
additionalAction?: SidebarAdditionalActions;
|
additionalAction?: SidebarAdditionalActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ComponentEditorInfo {
|
||||||
|
usageKey: string;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export enum SidebarAdditionalActions {
|
export enum SidebarAdditionalActions {
|
||||||
JumpToAddCollections = 'jump-to-add-collections',
|
JumpToAddCollections = 'jump-to-add-collections',
|
||||||
}
|
}
|
||||||
@@ -99,9 +104,10 @@ export type LibraryContextData = {
|
|||||||
// Current collection
|
// Current collection
|
||||||
openCollectionInfoSidebar: (collectionId: string, additionalAction?: SidebarAdditionalActions) => void;
|
openCollectionInfoSidebar: (collectionId: string, additionalAction?: SidebarAdditionalActions) => void;
|
||||||
// Editor modal - for editing some component
|
// Editor modal - for editing some component
|
||||||
/** If the editor is open and the user is editing some component, this is its usageKey */
|
/** If the editor is open and the user is editing some component, this is the component being edited. */
|
||||||
componentBeingEdited: string | undefined;
|
componentBeingEdited: ComponentEditorInfo | undefined;
|
||||||
openComponentEditor: (usageKey: string) => void;
|
/** If an onClose callback is provided, it will be called when the editor is closed. */
|
||||||
|
openComponentEditor: (usageKey: string, onClose?: () => void) => void;
|
||||||
closeComponentEditor: () => void;
|
closeComponentEditor: () => void;
|
||||||
resetSidebarAdditionalActions: () => void;
|
resetSidebarAdditionalActions: () => void;
|
||||||
} & ComponentPickerType;
|
} & ComponentPickerType;
|
||||||
@@ -174,8 +180,16 @@ export const LibraryProvider = ({
|
|||||||
);
|
);
|
||||||
const [isLibraryTeamModalOpen, openLibraryTeamModal, closeLibraryTeamModal] = useToggle(false);
|
const [isLibraryTeamModalOpen, openLibraryTeamModal, closeLibraryTeamModal] = useToggle(false);
|
||||||
const [isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal] = useToggle(false);
|
const [isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal] = useToggle(false);
|
||||||
const [componentBeingEdited, openComponentEditor] = useState<string | undefined>();
|
const [componentBeingEdited, setComponentBeingEdited] = useState<ComponentEditorInfo | undefined>();
|
||||||
const closeComponentEditor = useCallback(() => openComponentEditor(undefined), []);
|
const closeComponentEditor = useCallback(() => {
|
||||||
|
setComponentBeingEdited((prev) => {
|
||||||
|
prev?.onClose?.();
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
const openComponentEditor = useCallback((usageKey: string, onClose?: () => void) => {
|
||||||
|
setComponentBeingEdited({ usageKey, onClose });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [selectedComponents, setSelectedComponents] = useState<SelectedComponent[]>([]);
|
const [selectedComponents, setSelectedComponents] = useState<SelectedComponent[]>([]);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
mockXBlockAssets,
|
mockXBlockAssets,
|
||||||
mockXBlockOLX,
|
mockXBlockOLX,
|
||||||
} from '../data/api.mocks';
|
} from '../data/api.mocks';
|
||||||
|
import * as apiHooks from '../data/apiHooks';
|
||||||
import { LibraryProvider, SidebarBodyComponentId } from '../common/context';
|
import { LibraryProvider, SidebarBodyComponentId } from '../common/context';
|
||||||
import { ComponentAdvancedInfo } from './ComponentAdvancedInfo';
|
import { ComponentAdvancedInfo } from './ComponentAdvancedInfo';
|
||||||
import { getXBlockAssetsApiUrl } from '../data/api';
|
import { getXBlockAssetsApiUrl } from '../data/api';
|
||||||
@@ -25,6 +26,7 @@ const setOLXspy = mockSetXBlockOLX.applyMock();
|
|||||||
const render = (
|
const render = (
|
||||||
usageKey: string = mockLibraryBlockMetadata.usageKeyPublished,
|
usageKey: string = mockLibraryBlockMetadata.usageKeyPublished,
|
||||||
libraryId: string = mockContentLibrary.libraryId,
|
libraryId: string = mockContentLibrary.libraryId,
|
||||||
|
showOnlyPublished: boolean = false,
|
||||||
) => baseRender(
|
) => baseRender(
|
||||||
<ComponentAdvancedInfo />,
|
<ComponentAdvancedInfo />,
|
||||||
{
|
{
|
||||||
@@ -35,6 +37,7 @@ const render = (
|
|||||||
id: usageKey,
|
id: usageKey,
|
||||||
type: SidebarBodyComponentId.ComponentInfo,
|
type: SidebarBodyComponentId.ComponentInfo,
|
||||||
}}
|
}}
|
||||||
|
showOnlyPublished={showOnlyPublished}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</LibraryProvider>
|
</LibraryProvider>
|
||||||
@@ -124,13 +127,31 @@ describe('<ComponentAdvancedInfo />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should display the OLX source of the block (when expanded)', async () => {
|
it('should display the OLX source of the block (when expanded)', async () => {
|
||||||
|
const usageKey = mockXBlockOLX.usageKeyHtml;
|
||||||
|
const spy = jest.spyOn(apiHooks, 'useXBlockOLX');
|
||||||
|
|
||||||
render(mockXBlockOLX.usageKeyHtml);
|
render(mockXBlockOLX.usageKeyHtml);
|
||||||
const expandButton = await screen.findByRole('button', { name: /Advanced details/ });
|
const expandButton = await screen.findByRole('button', { name: /Advanced details/ });
|
||||||
fireEvent.click(expandButton);
|
fireEvent.click(expandButton);
|
||||||
// Because of syntax highlighting, the OLX will be borken up by many different tags so we need to search for
|
// Because of syntax highlighting, the OLX will be broken up by many different tags so we need to search for
|
||||||
// just a substring:
|
// just a substring:
|
||||||
const olxPart = /This is a text component which uses/;
|
const olxPart = /This is a text component which uses/;
|
||||||
await waitFor(() => expect(screen.getByText(olxPart)).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText(olxPart)).toBeInTheDocument());
|
||||||
|
expect(spy).toHaveBeenCalledWith(usageKey, 'draft');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display the published OLX source of the block (when expanded)', async () => {
|
||||||
|
const usageKey = mockXBlockOLX.usageKeyHtml;
|
||||||
|
const spy = jest.spyOn(apiHooks, 'useXBlockOLX');
|
||||||
|
|
||||||
|
render(usageKey, undefined, true);
|
||||||
|
const expandButton = await screen.findByRole('button', { name: /Advanced details/ });
|
||||||
|
fireEvent.click(expandButton);
|
||||||
|
// Because of syntax highlighting, the OLX will be broken up by many different tags so we need to search for
|
||||||
|
// just a substring:
|
||||||
|
const olxPart = /This is a text component which uses/;
|
||||||
|
await waitFor(() => expect(screen.getByText(olxPart)).toBeInTheDocument());
|
||||||
|
expect(spy).toHaveBeenCalledWith(usageKey, 'published');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not display "Edit OLX" button and assets dropzone when the library is read-only', async () => {
|
it('does not display "Edit OLX" button and assets dropzone when the library is read-only', async () => {
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ import { ComponentAdvancedAssets } from './ComponentAdvancedAssets';
|
|||||||
|
|
||||||
const ComponentAdvancedInfoInner: React.FC<Record<never, never>> = () => {
|
const ComponentAdvancedInfoInner: React.FC<Record<never, never>> = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { readOnly, sidebarComponentInfo } = useLibraryContext();
|
const {
|
||||||
|
readOnly,
|
||||||
|
sidebarComponentInfo,
|
||||||
|
showOnlyPublished,
|
||||||
|
} = useLibraryContext();
|
||||||
|
|
||||||
const usageKey = sidebarComponentInfo?.id;
|
const usageKey = sidebarComponentInfo?.id;
|
||||||
// istanbul ignore if: this should never happen in production
|
// istanbul ignore if: this should never happen in production
|
||||||
@@ -30,7 +34,10 @@ const ComponentAdvancedInfoInner: React.FC<Record<never, never>> = () => {
|
|||||||
throw new Error('sidebarComponentUsageKey is required to render ComponentAdvancedInfo');
|
throw new Error('sidebarComponentUsageKey is required to render ComponentAdvancedInfo');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: olx, isLoading: isOLXLoading } = useXBlockOLX(usageKey);
|
const { data: olx, isLoading: isOLXLoading } = useXBlockOLX(
|
||||||
|
usageKey,
|
||||||
|
showOnlyPublished ? 'published' : 'draft',
|
||||||
|
);
|
||||||
const editorRef = React.useRef<EditorAccessor | undefined>(undefined);
|
const editorRef = React.useRef<EditorAccessor | undefined>(undefined);
|
||||||
const [isEditingOLX, setEditingOLX] = React.useState(false);
|
const [isEditingOLX, setEditingOLX] = React.useState(false);
|
||||||
const olxUpdater = useUpdateXBlockOLX(usageKey);
|
const olxUpdater = useUpdateXBlockOLX(usageKey);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
} from '@openedx/paragon/icons';
|
} from '@openedx/paragon/icons';
|
||||||
|
|
||||||
import { SidebarAdditionalActions, useLibraryContext } from '../common/context';
|
import { SidebarAdditionalActions, useLibraryContext } from '../common/context';
|
||||||
import { ComponentMenu } from '../components';
|
import ComponentMenu from '../components';
|
||||||
import { canEditComponent } from '../components/ComponentEditorModal';
|
import { canEditComponent } from '../components/ComponentEditorModal';
|
||||||
import ComponentDetails from './ComponentDetails';
|
import ComponentDetails from './ComponentDetails';
|
||||||
import ComponentManagement from './ComponentManagement';
|
import ComponentManagement from './ComponentManagement';
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const ComponentPreview = () => {
|
|||||||
variant="light"
|
variant="light"
|
||||||
iconBefore={OpenInFull}
|
iconBefore={OpenInFull}
|
||||||
onClick={openModal}
|
onClick={openModal}
|
||||||
className="position-absolute right-0 zindex-10 m-1"
|
className="position-absolute right-0 zindex-1 m-1"
|
||||||
>
|
>
|
||||||
{intl.formatMessage(messages.previewExpandButtonTitle)}
|
{intl.formatMessage(messages.previewExpandButtonTitle)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -39,14 +39,14 @@ describe('<ManageCollections />', () => {
|
|||||||
fetchMock.mockReset();
|
fetchMock.mockReset();
|
||||||
fetchMock.post(searchEndpoint, (_url, req) => {
|
fetchMock.post(searchEndpoint, (_url, req) => {
|
||||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||||
const query = requestData?.queries[2]?.q ?? '';
|
const query = requestData?.queries[0]?.q ?? '';
|
||||||
// We have to replace the query (search keywords) in the mock results with the actual query,
|
// We have to replace the query (search keywords) in the mock results with the actual query,
|
||||||
// because otherwise Instantsearch will update the UI and change the query,
|
// because otherwise Instantsearch will update the UI and change the query,
|
||||||
// leading to unexpected results in the test cases.
|
// leading to unexpected results in the test cases.
|
||||||
mockCollectionsResults.results[2].query = query;
|
mockCollectionsResults.results[0].query = query;
|
||||||
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
|
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
|
||||||
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
||||||
mockCollectionsResults.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
mockCollectionsResults.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
||||||
return mockCollectionsResults;
|
return mockCollectionsResults;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ interface CollectionsDrawerProps extends ManageCollectionsProps {
|
|||||||
const CollectionsSelectableBox = ({ usageKey, collections, onClose }: CollectionsDrawerProps) => {
|
const CollectionsSelectableBox = ({ usageKey, collections, onClose }: CollectionsDrawerProps) => {
|
||||||
const type = 'checkbox';
|
const type = 'checkbox';
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { collectionHits } = useSearchContext();
|
const { hits } = useSearchContext();
|
||||||
const { showToast } = useContext(ToastContext);
|
const { showToast } = useContext(ToastContext);
|
||||||
const collectionKeys = collections.map((collection) => collection.key);
|
const collectionKeys = collections.map((collection) => collection.key);
|
||||||
const [selectedCollections, {
|
const [selectedCollections, {
|
||||||
@@ -67,7 +67,7 @@ const CollectionsSelectableBox = ({ usageKey, collections, onClose }: Collection
|
|||||||
columns={1}
|
columns={1}
|
||||||
ariaLabelledby={intl.formatMessage(messages.manageCollectionsSelectionLabel)}
|
ariaLabelledby={intl.formatMessage(messages.manageCollectionsSelectionLabel)}
|
||||||
>
|
>
|
||||||
{collectionHits.map((collectionHit) => (
|
{hits.map((collectionHit) => (
|
||||||
<SelectableBox
|
<SelectableBox
|
||||||
className="d-inline-flex align-items-center shadow-none border border-gray-100"
|
className="d-inline-flex align-items-center shadow-none border border-gray-100"
|
||||||
value={collectionHit.blockId}
|
value={collectionHit.blockId}
|
||||||
@@ -112,12 +112,9 @@ const AddToCollectionsDrawer = ({ usageKey, collections, onClose }: CollectionsD
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SearchContextProvider
|
<SearchContextProvider
|
||||||
overrideQueries={{
|
extraFilter={[`context_key = "${libraryId}"`, 'type = "collection"']}
|
||||||
components: { limit: 0 },
|
|
||||||
blockTypes: { limit: 0 },
|
|
||||||
}}
|
|
||||||
extraFilter={`context_key = "${libraryId}"`}
|
|
||||||
skipUrlUpdate
|
skipUrlUpdate
|
||||||
|
skipBlockTypeFetch
|
||||||
>
|
>
|
||||||
<Stack className="mt-2" gap={3}>
|
<Stack className="mt-2" gap={3}>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ jest.mock('react-router-dom', () => ({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
jest.mock('../../studio-home/hooks', () => ({
|
||||||
|
useStudioHome: () => ({
|
||||||
|
isLoadingPage: false,
|
||||||
|
isFailedLoadingPage: false,
|
||||||
|
librariesV2Enabled: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
mockContentLibrary.applyMock();
|
mockContentLibrary.applyMock();
|
||||||
mockContentSearchConfig.applyMock();
|
mockContentSearchConfig.applyMock();
|
||||||
mockGetCollectionMetadata.applyMock();
|
mockGetCollectionMetadata.applyMock();
|
||||||
@@ -201,9 +208,8 @@ describe('<ComponentPicker />', () => {
|
|||||||
|
|
||||||
onChange.mockClear();
|
onChange.mockClear();
|
||||||
|
|
||||||
// Select another component (the second "Select" button is the same component as the first,
|
// Select another component
|
||||||
// but in the "Components" section instead of the "Recently Changed" section)
|
fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[1]);
|
||||||
fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[2]);
|
|
||||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith([
|
await waitFor(() => expect(onChange).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
usageKey: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd',
|
usageKey: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd',
|
||||||
@@ -260,4 +266,14 @@ describe('<ComponentPicker />', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(onChange).toHaveBeenCalledWith([]));
|
await waitFor(() => expect(onChange).toHaveBeenCalledWith([]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should display an alert banner when showOnlyPublished is true', async () => {
|
||||||
|
render(<ComponentPicker />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i));
|
||||||
|
|
||||||
|
// Wait for the content library to load
|
||||||
|
await screen.findByText(/Only published content is visible and available for reuse./i);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { Stepper } from '@openedx/paragon';
|
import { Alert, Stepper } from '@openedx/paragon';
|
||||||
|
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ComponentSelectedEvent,
|
type ComponentSelectedEvent,
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
import LibraryAuthoringPage from '../LibraryAuthoringPage';
|
import LibraryAuthoringPage from '../LibraryAuthoringPage';
|
||||||
import LibraryCollectionPage from '../collections/LibraryCollectionPage';
|
import LibraryCollectionPage from '../collections/LibraryCollectionPage';
|
||||||
import SelectLibrary from './SelectLibrary';
|
import SelectLibrary from './SelectLibrary';
|
||||||
|
import messages from './messages';
|
||||||
|
|
||||||
interface LibraryComponentPickerProps {
|
interface LibraryComponentPickerProps {
|
||||||
returnToLibrarySelection: () => void;
|
returnToLibrarySelection: () => void;
|
||||||
@@ -65,6 +67,7 @@ export const ComponentPicker: React.FC<ComponentPickerProps> = ({
|
|||||||
|
|
||||||
const queryParams = new URLSearchParams(location.search);
|
const queryParams = new URLSearchParams(location.search);
|
||||||
const variant = queryParams.get('variant') || 'draft';
|
const variant = queryParams.get('variant') || 'draft';
|
||||||
|
const showOnlyPublished = variant === 'published';
|
||||||
|
|
||||||
const handleLibrarySelection = (library: string) => {
|
const handleLibrarySelection = (library: string) => {
|
||||||
setCurrentStep('pick-components');
|
setCurrentStep('pick-components');
|
||||||
@@ -99,9 +102,15 @@ export const ComponentPicker: React.FC<ComponentPickerProps> = ({
|
|||||||
<Stepper.Step eventKey="pick-components" title="Pick some components">
|
<Stepper.Step eventKey="pick-components" title="Pick some components">
|
||||||
<LibraryProvider
|
<LibraryProvider
|
||||||
libraryId={selectedLibrary}
|
libraryId={selectedLibrary}
|
||||||
showOnlyPublished={variant === 'published'}
|
showOnlyPublished={showOnlyPublished}
|
||||||
{...libraryProviderProps}
|
{...libraryProviderProps}
|
||||||
>
|
>
|
||||||
|
{ showOnlyPublished
|
||||||
|
&& (
|
||||||
|
<Alert variant="info" className="m-2">
|
||||||
|
<FormattedMessage {...messages.pickerInfoBanner} />
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<InnerComponentPicker returnToLibrarySelection={returnToLibrarySelection} />
|
<InnerComponentPicker returnToLibrarySelection={returnToLibrarySelection} />
|
||||||
</LibraryProvider>
|
</LibraryProvider>
|
||||||
</Stepper.Step>
|
</Stepper.Step>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
initializeMocks,
|
initializeMocks,
|
||||||
|
fireEvent,
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
} from '../../testUtils';
|
} from '../../testUtils';
|
||||||
@@ -28,10 +29,21 @@ describe('<ComponentPicker />', () => {
|
|||||||
expect(await screen.findByText('Loading...')).toBeInTheDocument();
|
expect(await screen.findByText('Loading...')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render the empty status', async () => {
|
it('should render the no library status', async () => {
|
||||||
mockGetContentLibraryV2List.applyMockEmpty();
|
mockGetContentLibraryV2List.applyMockEmpty();
|
||||||
render(<ComponentPicker />);
|
render(<ComponentPicker />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/you don't have any libraries created yet,/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the no search result status', async () => {
|
||||||
|
mockGetContentLibraryV2List.applyMockEmpty();
|
||||||
|
render(<ComponentPicker />);
|
||||||
|
|
||||||
|
const searchField = await screen.findByPlaceholderText('Search for a library');
|
||||||
|
fireEvent.change(searchField, { target: { value: 'test' } });
|
||||||
|
fireEvent.submit(searchField);
|
||||||
|
|
||||||
expect(await screen.findByText(/there are no libraries with the current filters/i)).toBeInTheDocument();
|
expect(await screen.findByText(/there are no libraries with the current filters/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,25 @@ import AlertError from '../../generic/alert-error';
|
|||||||
import { useContentLibraryV2List } from '../data/apiHooks';
|
import { useContentLibraryV2List } from '../data/apiHooks';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
const EmptyState = () => (
|
interface EmptyStateProps {
|
||||||
|
hasSearchQuery: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmptyState = ({ hasSearchQuery }: EmptyStateProps) => (
|
||||||
<Alert className="mt-4 align-self-center">
|
<Alert className="mt-4 align-self-center">
|
||||||
<Alert.Heading>
|
<Alert.Heading>
|
||||||
<FormattedMessage {...messages.selectLibraryEmptyStateTitle} />
|
{hasSearchQuery ? (
|
||||||
|
<FormattedMessage {...messages.selectLibraryNoSearchResultsTitle} />
|
||||||
|
) : (
|
||||||
|
<FormattedMessage {...messages.selectLibraryNoLibrariesTitle} />
|
||||||
|
)}
|
||||||
</Alert.Heading>
|
</Alert.Heading>
|
||||||
<p>
|
<p>
|
||||||
<FormattedMessage {...messages.selectLibraryEmptyStateMessage} />
|
{hasSearchQuery ? (
|
||||||
|
<FormattedMessage {...messages.selectLibraryNoSearchResultsMessage} />
|
||||||
|
) : (
|
||||||
|
<FormattedMessage {...messages.selectLibraryNoLibrariesMessage} />
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
@@ -71,43 +83,45 @@ const SelectLibrary = ({ selectedLibrary, setSelectedLibrary }: SelectLibraryPro
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
placeholder={intl.formatMessage(messages.selectLibrarySearchPlaceholder)}
|
placeholder={intl.formatMessage(messages.selectLibrarySearchPlaceholder)}
|
||||||
/>
|
/>
|
||||||
<div>
|
{data.results.length === 0 ? (<EmptyState hasSearchQuery={!!searchQuery} />) : (
|
||||||
{data.results.length === 0 && (<EmptyState />)}
|
<>
|
||||||
<Form.RadioSet
|
<Form.RadioSet
|
||||||
name="selected-library"
|
name="selected-library"
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSelectedLibrary(e.target.value)}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSelectedLibrary(e.target.value)}
|
||||||
value={selectedLibrary}
|
value={selectedLibrary}
|
||||||
>
|
className="mt-4"
|
||||||
{data.results.map((library) => (
|
>
|
||||||
<Card
|
{data.results.map((library) => (
|
||||||
key={library.id}
|
<Card
|
||||||
isClickable
|
key={library.id}
|
||||||
onClick={() => setSelectedLibrary(library.id)}
|
isClickable
|
||||||
className="card-item"
|
onClick={() => setSelectedLibrary(library.id)}
|
||||||
>
|
className="card-item"
|
||||||
<Card.Header
|
>
|
||||||
size="sm"
|
<Card.Header
|
||||||
title={<span className="card-item-title">{library.title}</span>}
|
size="sm"
|
||||||
subtitle={`${library.org} / ${library.slug}`}
|
title={<span className="card-item-title">{library.title}</span>}
|
||||||
actions={(
|
subtitle={`${library.org} / ${library.slug}`}
|
||||||
<Form.Radio value={library.id} name={`select-library-${library.id}`}>{' '}</Form.Radio>
|
actions={(
|
||||||
)}
|
<Form.Radio value={library.id} name={`select-library-${library.id}`}>{' '}</Form.Radio>
|
||||||
/>
|
)}
|
||||||
<Card.Body>
|
/>
|
||||||
<p>{library.description}</p>
|
<Card.Body>
|
||||||
</Card.Body>
|
<p>{library.description}</p>
|
||||||
</Card>
|
</Card.Body>
|
||||||
))}
|
</Card>
|
||||||
</Form.RadioSet>
|
))}
|
||||||
</div>
|
</Form.RadioSet>
|
||||||
<Pagination
|
<Pagination
|
||||||
paginationLabel={intl.formatMessage(messages.selectLibraryPaginationLabel)}
|
paginationLabel={intl.formatMessage(messages.selectLibraryPaginationLabel)}
|
||||||
pageCount={data!.numPages}
|
pageCount={data!.numPages}
|
||||||
currentPage={data!.currentPage}
|
currentPage={data!.currentPage}
|
||||||
onPageSelect={(page: number) => setCurrentPage(page)}
|
onPageSelect={setCurrentPage}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="align-self-center"
|
className="align-self-center"
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,21 +16,37 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Library pagination',
|
defaultMessage: 'Library pagination',
|
||||||
description: 'The pagination label for the select library component',
|
description: 'The pagination label for the select library component',
|
||||||
},
|
},
|
||||||
selectLibraryEmptyStateTitle: {
|
selectLibraryNoSearchResultsTitle: {
|
||||||
id: 'course-authoring.library-authoring.pick-components.select-library.empty-state.title',
|
id: 'course-authoring.library-authoring.pick-components.select-library.no-search.results.title',
|
||||||
defaultMessage: 'We could not find any result',
|
defaultMessage: 'We could not find any result',
|
||||||
description: 'The title for the empty state in the select library component',
|
description: 'The title for the no search results state in the select library component',
|
||||||
},
|
},
|
||||||
selectLibraryEmptyStateMessage: {
|
selectLibraryNoSearchResultsMessage: {
|
||||||
id: 'course-authoring.library-authoring.pick-components.select-library.empty-state.message',
|
id: 'course-authoring.library-authoring.pick-components.select-library.no-search.message',
|
||||||
defaultMessage: 'There are no libraries with the current filters.',
|
defaultMessage: 'There are no libraries with the current filters.',
|
||||||
description: 'The message for the empty state in the select library component',
|
description: 'The message for the no search results state in the select library component',
|
||||||
|
},
|
||||||
|
selectLibraryNoLibrariesTitle: {
|
||||||
|
id: 'course-authoring.library-authoring.pick-components.select-library.no-libraries.title',
|
||||||
|
defaultMessage: 'No libraries found',
|
||||||
|
description: 'The title for the no libraries state in the select library component',
|
||||||
|
},
|
||||||
|
selectLibraryNoLibrariesMessage: {
|
||||||
|
id: 'course-authoring.library-authoring.pick-components.select-library.no-libraries.message',
|
||||||
|
defaultMessage: 'You don\'t have any libraries created yet, or you don\'t have access to any libraries. To '
|
||||||
|
+ 'create a new library, go to Studio Home or contact your system administrator.',
|
||||||
|
description: 'The message for the no libraries state in the select library component',
|
||||||
},
|
},
|
||||||
selectLibraryNextButton: {
|
selectLibraryNextButton: {
|
||||||
id: 'course-authoring.library-authoring.pick-components.select-library.next-button',
|
id: 'course-authoring.library-authoring.pick-components.select-library.next-button',
|
||||||
defaultMessage: 'Next',
|
defaultMessage: 'Next',
|
||||||
description: 'The text for the next button in the select library component',
|
description: 'The text for the next button in the select library component',
|
||||||
},
|
},
|
||||||
|
pickerInfoBanner: {
|
||||||
|
id: 'course-authoring.library-authoring.pick-components.component-picker.information-alert',
|
||||||
|
defaultMessage: 'Only published content is visible and available for reuse.',
|
||||||
|
description: 'The alert text on top of component-picker if only published content is visible.',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default messages;
|
export default messages;
|
||||||
|
|||||||
@@ -27,14 +27,24 @@ const CollectionHitSample: CollectionHit = {
|
|||||||
created: 1722434322294,
|
created: 1722434322294,
|
||||||
modified: 1722434322294,
|
modified: 1722434322294,
|
||||||
numChildren: 2,
|
numChildren: 2,
|
||||||
|
published: {
|
||||||
|
numChildren: 1,
|
||||||
|
},
|
||||||
tags: {},
|
tags: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
let axiosMock: MockAdapter;
|
let axiosMock: MockAdapter;
|
||||||
let mockShowToast;
|
let mockShowToast;
|
||||||
|
|
||||||
const render = (ui: React.ReactElement) => baseRender(ui, {
|
const render = (ui: React.ReactElement, showOnlyPublished: boolean = false) => baseRender(ui, {
|
||||||
extraWrapper: ({ children }) => <LibraryProvider libraryId="lib:Axim:TEST">{ children }</LibraryProvider>,
|
extraWrapper: ({ children }) => (
|
||||||
|
<LibraryProvider
|
||||||
|
libraryId="lib:Axim:TEST"
|
||||||
|
showOnlyPublished={showOnlyPublished}
|
||||||
|
>
|
||||||
|
{ children }
|
||||||
|
</LibraryProvider>
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('<CollectionCard />', () => {
|
describe('<CollectionCard />', () => {
|
||||||
@@ -52,6 +62,14 @@ describe('<CollectionCard />', () => {
|
|||||||
expect(screen.queryByText('Collection (2)')).toBeInTheDocument();
|
expect(screen.queryByText('Collection (2)')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should render published content', () => {
|
||||||
|
render(<CollectionCard collectionHit={CollectionHitSample} />, true);
|
||||||
|
|
||||||
|
expect(screen.queryByText('Collection Display Formated Name')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Collection description')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Collection (1)')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('should navigate to the collection if the open menu clicked', async () => {
|
it('should navigate to the collection if the open menu clicked', async () => {
|
||||||
render(<CollectionCard collectionHit={CollectionHitSample} />);
|
render(<CollectionCard collectionHit={CollectionHitSample} />);
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ const CollectionCard = ({ collectionHit } : CollectionCardProps) => {
|
|||||||
const {
|
const {
|
||||||
openCollectionInfoSidebar,
|
openCollectionInfoSidebar,
|
||||||
componentPickerMode,
|
componentPickerMode,
|
||||||
|
showOnlyPublished,
|
||||||
} = useLibraryContext();
|
} = useLibraryContext();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -118,7 +119,13 @@ const CollectionCard = ({ collectionHit } : CollectionCardProps) => {
|
|||||||
formatted,
|
formatted,
|
||||||
tags,
|
tags,
|
||||||
numChildren,
|
numChildren,
|
||||||
|
published,
|
||||||
} = collectionHit;
|
} = collectionHit;
|
||||||
|
|
||||||
|
const numChildrenCount = showOnlyPublished ? (
|
||||||
|
published?.numChildren || 0
|
||||||
|
) : numChildren;
|
||||||
|
|
||||||
const { displayName = '', description = '' } = formatted;
|
const { displayName = '', description = '' } = formatted;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -127,7 +134,7 @@ const CollectionCard = ({ collectionHit } : CollectionCardProps) => {
|
|||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
description={description}
|
description={description}
|
||||||
tags={tags}
|
tags={tags}
|
||||||
numChildren={numChildren}
|
numChildren={numChildrenCount}
|
||||||
actions={!componentPickerMode && (
|
actions={!componentPickerMode && (
|
||||||
<ActionRow>
|
<ActionRow>
|
||||||
<CollectionMenu collectionHit={collectionHit} />
|
<CollectionMenu collectionHit={collectionHit} />
|
||||||
|
|||||||
@@ -28,18 +28,18 @@ export const ComponentEditorModal: React.FC<Record<never, never>> = () => {
|
|||||||
if (componentBeingEdited === undefined) {
|
if (componentBeingEdited === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const blockType = getBlockType(componentBeingEdited);
|
const blockType = getBlockType(componentBeingEdited.usageKey);
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
closeComponentEditor();
|
closeComponentEditor();
|
||||||
invalidateComponentData(queryClient, libraryId, componentBeingEdited);
|
invalidateComponentData(queryClient, libraryId, componentBeingEdited.usageKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditorPage
|
<EditorPage
|
||||||
courseId={libraryId}
|
courseId={libraryId}
|
||||||
blockType={blockType}
|
blockType={blockType}
|
||||||
blockId={componentBeingEdited}
|
blockId={componentBeingEdited.usageKey}
|
||||||
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
|
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
|
||||||
lmsEndpointUrl={getConfig().LMS_BASE_URL}
|
lmsEndpointUrl={getConfig().LMS_BASE_URL}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
import fetchMock from 'fetch-mock-jest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
fireEvent,
|
|
||||||
render,
|
|
||||||
screen,
|
|
||||||
initializeMocks,
|
|
||||||
} from '../../testUtils';
|
|
||||||
import { getContentSearchConfigUrl } from '../../search-manager/data/api';
|
|
||||||
import { mockContentLibrary } from '../data/api.mocks';
|
|
||||||
import mockEmptyResult from '../../search-modal/__mocks__/empty-search-result.json';
|
|
||||||
import { LibraryProvider } from '../common/context';
|
|
||||||
import { libraryComponentsMock } from '../__mocks__';
|
|
||||||
import LibraryComponents from './LibraryComponents';
|
|
||||||
|
|
||||||
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
|
|
||||||
|
|
||||||
mockContentLibrary.applyMock();
|
|
||||||
const mockFetchNextPage = jest.fn();
|
|
||||||
const mockUseSearchContext = jest.fn();
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
totalHits: 1,
|
|
||||||
hits: [],
|
|
||||||
isFetchingNextPage: false,
|
|
||||||
hasNextPage: false,
|
|
||||||
fetchNextPage: mockFetchNextPage,
|
|
||||||
searchKeywords: '',
|
|
||||||
isFiltered: false,
|
|
||||||
isLoading: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const returnEmptyResult = (_url: string, req) => {
|
|
||||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
|
||||||
const query = requestData?.queries[0]?.q ?? '';
|
|
||||||
// We have to replace the query (search keywords) in the mock results with the actual query,
|
|
||||||
// because otherwise we may have an inconsistent state that causes more queries and unexpected results.
|
|
||||||
mockEmptyResult.results[0].query = query;
|
|
||||||
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
|
|
||||||
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
|
||||||
mockEmptyResult.results[0]?.hits.forEach((hit: any) => { hit._formatted = { ...hit }; });
|
|
||||||
return mockEmptyResult;
|
|
||||||
};
|
|
||||||
|
|
||||||
jest.mock('../../search-manager', () => ({
|
|
||||||
...jest.requireActual('../../search-manager'),
|
|
||||||
useSearchContext: () => mockUseSearchContext(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const clipboardBroadcastChannelMock = {
|
|
||||||
postMessage: jest.fn(),
|
|
||||||
close: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
|
||||||
|
|
||||||
const withLibraryId = (libraryId: string) => ({
|
|
||||||
extraWrapper: ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<LibraryProvider libraryId={libraryId}>{children}</LibraryProvider>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('<LibraryComponents />', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
const { axiosMock } = initializeMocks();
|
|
||||||
|
|
||||||
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
|
|
||||||
|
|
||||||
// The API method to get the Meilisearch connection details uses Axios:
|
|
||||||
axiosMock.onGet(getContentSearchConfigUrl()).reply(200, {
|
|
||||||
url: 'http://mock.meilisearch.local',
|
|
||||||
index_name: 'studio',
|
|
||||||
api_key: 'test-key',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
fetchMock.reset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render empty state', async () => {
|
|
||||||
mockUseSearchContext.mockReturnValue({
|
|
||||||
...data,
|
|
||||||
totalHits: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<LibraryComponents variant="full" />, withLibraryId(mockContentLibrary.libraryId));
|
|
||||||
expect(await screen.findByText(/you have not added any content to this library yet\./i));
|
|
||||||
expect(await screen.findByRole('button', { name: /add component/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render empty state without add content button', async () => {
|
|
||||||
mockUseSearchContext.mockReturnValue({
|
|
||||||
...data,
|
|
||||||
totalHits: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<LibraryComponents variant="full" />, withLibraryId(mockContentLibrary.libraryIdReadOnly));
|
|
||||||
expect(await screen.findByText(/you have not added any content to this library yet\./i));
|
|
||||||
expect(screen.queryByRole('button', { name: /add component/i })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render a spinner while loading', async () => {
|
|
||||||
mockUseSearchContext.mockReturnValue({
|
|
||||||
...data,
|
|
||||||
isLoading: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<LibraryComponents variant="full" />, withLibraryId(mockContentLibrary.libraryId));
|
|
||||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render components in full variant', async () => {
|
|
||||||
mockUseSearchContext.mockReturnValue({
|
|
||||||
...data,
|
|
||||||
hits: libraryComponentsMock,
|
|
||||||
});
|
|
||||||
render(<LibraryComponents variant="full" />, withLibraryId(mockContentLibrary.libraryId));
|
|
||||||
|
|
||||||
expect(await screen.findByText('This is a text: ID=1')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('This is a text: ID=2')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Video Component 3')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Video Component 4')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('This is a problem: ID=5')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('This is a problem: ID=6')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render components in preview variant', async () => {
|
|
||||||
mockUseSearchContext.mockReturnValue({
|
|
||||||
...data,
|
|
||||||
hits: libraryComponentsMock,
|
|
||||||
});
|
|
||||||
render(<LibraryComponents variant="preview" />, withLibraryId(mockContentLibrary.libraryId));
|
|
||||||
|
|
||||||
expect(await screen.findByText('This is a text: ID=1')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('This is a text: ID=2')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Video Component 3')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Video Component 4')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('This is a problem: ID=5')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('This is a problem: ID=6')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call `fetchNextPage` on scroll to bottom in full variant', async () => {
|
|
||||||
mockUseSearchContext.mockReturnValue({
|
|
||||||
...data,
|
|
||||||
hits: libraryComponentsMock,
|
|
||||||
hasNextPage: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<LibraryComponents variant="full" />, withLibraryId(mockContentLibrary.libraryId));
|
|
||||||
|
|
||||||
Object.defineProperty(window, 'innerHeight', { value: 800 });
|
|
||||||
Object.defineProperty(document.body, 'scrollHeight', { value: 1600 });
|
|
||||||
|
|
||||||
fireEvent.scroll(window, { target: { scrollY: 1000 } });
|
|
||||||
|
|
||||||
expect(mockFetchNextPage).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not call `fetchNextPage` on scroll to bottom in preview variant', async () => {
|
|
||||||
mockUseSearchContext.mockReturnValue({
|
|
||||||
...data,
|
|
||||||
hits: libraryComponentsMock,
|
|
||||||
hasNextPage: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<LibraryComponents variant="preview" />, withLibraryId(mockContentLibrary.libraryId));
|
|
||||||
|
|
||||||
Object.defineProperty(window, 'innerHeight', { value: 800 });
|
|
||||||
Object.defineProperty(document.body, 'scrollHeight', { value: 1600 });
|
|
||||||
|
|
||||||
fireEvent.scroll(window, { target: { scrollY: 1000 } });
|
|
||||||
|
|
||||||
expect(mockFetchNextPage).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { LoadingSpinner } from '../../generic/Loading';
|
|
||||||
import { useLoadOnScroll } from '../../hooks';
|
|
||||||
import { useSearchContext } from '../../search-manager';
|
|
||||||
import { NoComponents, NoSearchResults } from '../EmptyStates';
|
|
||||||
import ComponentCard from './ComponentCard';
|
|
||||||
import { LIBRARY_SECTION_PREVIEW_LIMIT } from './LibrarySection';
|
|
||||||
import { useLibraryContext } from '../common/context';
|
|
||||||
|
|
||||||
type LibraryComponentsProps = {
|
|
||||||
variant: 'full' | 'preview',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Library Components to show components grid
|
|
||||||
*
|
|
||||||
* Use style to:
|
|
||||||
* - 'full': Show all components with Infinite scroll pagination.
|
|
||||||
* - 'preview': Show first 4 components without pagination.
|
|
||||||
*/
|
|
||||||
const LibraryComponents = ({ variant }: LibraryComponentsProps) => {
|
|
||||||
const {
|
|
||||||
hits,
|
|
||||||
totalHits: componentCount,
|
|
||||||
isFetchingNextPage,
|
|
||||||
hasNextPage,
|
|
||||||
fetchNextPage,
|
|
||||||
isLoading,
|
|
||||||
isFiltered,
|
|
||||||
usageKey,
|
|
||||||
} = useSearchContext();
|
|
||||||
const { openAddContentSidebar, openComponentInfoSidebar } = useLibraryContext();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (usageKey) {
|
|
||||||
openComponentInfoSidebar(usageKey);
|
|
||||||
}
|
|
||||||
}, [usageKey]);
|
|
||||||
|
|
||||||
const componentList = variant === 'preview' ? hits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT) : hits;
|
|
||||||
|
|
||||||
useLoadOnScroll(
|
|
||||||
hasNextPage,
|
|
||||||
isFetchingNextPage,
|
|
||||||
fetchNextPage,
|
|
||||||
variant === 'full',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <LoadingSpinner />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (componentCount === 0) {
|
|
||||||
return isFiltered ? <NoSearchResults /> : <NoComponents handleBtnClick={openAddContentSidebar} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="library-cards-grid">
|
|
||||||
{ componentList.map((contentHit) => (
|
|
||||||
<ComponentCard
|
|
||||||
key={contentHit.id}
|
|
||||||
contentHit={contentHit}
|
|
||||||
/>
|
|
||||||
)) }
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LibraryComponents;
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Card, ActionRow, Button } from '@openedx/paragon';
|
|
||||||
|
|
||||||
export const LIBRARY_SECTION_PREVIEW_LIMIT = 4;
|
|
||||||
|
|
||||||
const LibrarySection: React.FC<{
|
|
||||||
title: string,
|
|
||||||
viewAllAction?: () => void,
|
|
||||||
contentCount: number,
|
|
||||||
previewLimit?: number,
|
|
||||||
children: React.ReactNode,
|
|
||||||
}> = ({
|
|
||||||
title,
|
|
||||||
viewAllAction,
|
|
||||||
contentCount,
|
|
||||||
previewLimit = LIBRARY_SECTION_PREVIEW_LIMIT,
|
|
||||||
children,
|
|
||||||
}) => (
|
|
||||||
<Card>
|
|
||||||
<Card.Header
|
|
||||||
title={title}
|
|
||||||
actions={
|
|
||||||
viewAllAction
|
|
||||||
&& contentCount > previewLimit
|
|
||||||
&& (
|
|
||||||
<ActionRow>
|
|
||||||
<Button variant="tertiary" onClick={viewAllAction}>View All</Button>
|
|
||||||
</ActionRow>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Card.Section>
|
|
||||||
{children}
|
|
||||||
</Card.Section>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default LibrarySection;
|
|
||||||
@@ -1,2 +1 @@
|
|||||||
export { default as LibraryComponents } from './LibraryComponents';
|
export { ComponentMenu as default } from './ComponentCard';
|
||||||
export { ComponentMenu } from './ComponentCard';
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { camelCaseObject, getConfig, snakeCaseObject } from '@edx/frontend-platform';
|
import { camelCaseObject, getConfig, snakeCaseObject } from '@edx/frontend-platform';
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
|
import { VersionSpec } from '../LibraryBlock';
|
||||||
|
|
||||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||||
|
|
||||||
@@ -52,12 +53,14 @@ export const getLibraryPasteClipboardUrl = (libraryId: string) => `${getApiBaseU
|
|||||||
* Get the URL for the xblock fields/metadata API.
|
* Get the URL for the xblock fields/metadata API.
|
||||||
*/
|
*/
|
||||||
export const getXBlockFieldsApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/xblock/v2/xblocks/${usageKey}/fields/`;
|
export const getXBlockFieldsApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/xblock/v2/xblocks/${usageKey}/fields/`;
|
||||||
export const getXBlockFieldsVersionApiUrl = (usageKey: string, version: string) => `${getApiBaseUrl()}/api/xblock/v2/xblocks/${usageKey}@${version}/fields/`;
|
export const getXBlockFieldsVersionApiUrl = (usageKey: string, version: VersionSpec) => `${getApiBaseUrl()}/api/xblock/v2/xblocks/${usageKey}@${version}/fields/`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the URL for the xblock OLX API
|
* Get the URL for the xblock OLX API
|
||||||
*/
|
*/
|
||||||
export const getXBlockOLXApiUrl = (usageKey: string) => `${getLibraryBlockMetadataUrl(usageKey)}olx/`;
|
export const getXBlockOLXApiUrl = (usageKey: string) => `${getLibraryBlockMetadataUrl(usageKey)}olx/`;
|
||||||
|
export const getXBlockOLXVersionApiUrl = (usageKey: string, version: VersionSpec) => `${getApiBaseUrl()}/api/xblock/v2/xblocks/${usageKey}@${version}/olx/`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the URL for the xblock Publish API
|
* Get the URL for the xblock Publish API
|
||||||
*/
|
*/
|
||||||
@@ -385,7 +388,7 @@ export async function getLibraryBlockMetadata(usageKey: string): Promise<Library
|
|||||||
/**
|
/**
|
||||||
* Fetch xblock fields.
|
* Fetch xblock fields.
|
||||||
*/
|
*/
|
||||||
export async function getXBlockFields(usageKey: string, version: string = 'draft'): Promise<XBlockFields> {
|
export async function getXBlockFields(usageKey: string, version: VersionSpec = 'draft'): Promise<XBlockFields> {
|
||||||
const { data } = await getAuthenticatedHttpClient().get(getXBlockFieldsVersionApiUrl(usageKey, version));
|
const { data } = await getAuthenticatedHttpClient().get(getXBlockFieldsVersionApiUrl(usageKey, version));
|
||||||
return camelCaseObject(data);
|
return camelCaseObject(data);
|
||||||
}
|
}
|
||||||
@@ -412,8 +415,8 @@ export async function createCollection(libraryId: string, collectionData: Create
|
|||||||
* Fetch the OLX for the given XBlock.
|
* Fetch the OLX for the given XBlock.
|
||||||
*/
|
*/
|
||||||
// istanbul ignore next
|
// istanbul ignore next
|
||||||
export async function getXBlockOLX(usageKey: string): Promise<string> {
|
export async function getXBlockOLX(usageKey: string, version: VersionSpec = 'draft'): Promise<string> {
|
||||||
const { data } = await getAuthenticatedHttpClient().get(getXBlockOLXApiUrl(usageKey));
|
const { data } = await getAuthenticatedHttpClient().get(getXBlockOLXVersionApiUrl(usageKey, version));
|
||||||
return data.olx;
|
return data.olx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import {
|
|||||||
publishXBlock,
|
publishXBlock,
|
||||||
deleteXBlockAsset,
|
deleteXBlockAsset,
|
||||||
} from './api';
|
} from './api';
|
||||||
|
import { VersionSpec } from '../LibraryBlock';
|
||||||
|
|
||||||
export const libraryQueryPredicate = (query: Query, libraryId: string): boolean => {
|
export const libraryQueryPredicate = (query: Query, libraryId: string): boolean => {
|
||||||
// Invalidate all content queries related to this library.
|
// Invalidate all content queries related to this library.
|
||||||
@@ -91,7 +92,7 @@ export const xblockQueryKeys = {
|
|||||||
*/
|
*/
|
||||||
xblock: (usageKey?: string) => [...xblockQueryKeys.all, usageKey],
|
xblock: (usageKey?: string) => [...xblockQueryKeys.all, usageKey],
|
||||||
/** Fields (i.e. the content, display name, etc.) of an XBlock */
|
/** Fields (i.e. the content, display name, etc.) of an XBlock */
|
||||||
xblockFields: (usageKey: string, version: string = 'draft') => [...xblockQueryKeys.xblock(usageKey), 'fields', version],
|
xblockFields: (usageKey: string, version: VersionSpec = 'draft') => [...xblockQueryKeys.xblock(usageKey), 'fields', version],
|
||||||
/** OLX (XML representation of the fields/content) */
|
/** OLX (XML representation of the fields/content) */
|
||||||
xblockOLX: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'OLX'],
|
xblockOLX: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'OLX'],
|
||||||
/** assets (static files) */
|
/** assets (static files) */
|
||||||
@@ -113,6 +114,8 @@ export const xblockQueryKeys = {
|
|||||||
export function invalidateComponentData(queryClient: QueryClient, contentLibraryId: string, usageKey: string) {
|
export function invalidateComponentData(queryClient: QueryClient, contentLibraryId: string, usageKey: string) {
|
||||||
queryClient.invalidateQueries({ queryKey: xblockQueryKeys.xblockFields(usageKey) });
|
queryClient.invalidateQueries({ queryKey: xblockQueryKeys.xblockFields(usageKey) });
|
||||||
queryClient.invalidateQueries({ queryKey: xblockQueryKeys.componentMetadata(usageKey) });
|
queryClient.invalidateQueries({ queryKey: xblockQueryKeys.componentMetadata(usageKey) });
|
||||||
|
// The description and display name etc. may have changed, so refresh everything in the library too:
|
||||||
|
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(contentLibraryId) });
|
||||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, contentLibraryId) });
|
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, contentLibraryId) });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +153,6 @@ export const useDeleteLibraryBlock = () => {
|
|||||||
mutationFn: deleteLibraryBlock,
|
mutationFn: deleteLibraryBlock,
|
||||||
onSettled: (_data, _error, variables) => {
|
onSettled: (_data, _error, variables) => {
|
||||||
const libraryId = getLibraryId(variables.usageKey);
|
const libraryId = getLibraryId(variables.usageKey);
|
||||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) });
|
|
||||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
|
||||||
invalidateComponentData(queryClient, libraryId, variables.usageKey);
|
invalidateComponentData(queryClient, libraryId, variables.usageKey);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -292,7 +293,7 @@ export const useLibraryBlockMetadata = (usageId: string) => (
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const useXBlockFields = (usageKey: string, version: string = 'draft') => (
|
export const useXBlockFields = (usageKey: string, version: VersionSpec = 'draft') => (
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: xblockQueryKeys.xblockFields(usageKey, version),
|
queryKey: xblockQueryKeys.xblockFields(usageKey, version),
|
||||||
queryFn: () => getXBlockFields(usageKey, version),
|
queryFn: () => getXBlockFields(usageKey, version),
|
||||||
@@ -349,10 +350,10 @@ export const useCreateLibraryCollection = (libraryId: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Get the OLX source of a library component */
|
/** Get the OLX source of a library component */
|
||||||
export const useXBlockOLX = (usageKey: string) => (
|
export const useXBlockOLX = (usageKey: string, version: VersionSpec) => (
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: xblockQueryKeys.xblockOLX(usageKey),
|
queryKey: xblockQueryKeys.xblockOLX(usageKey),
|
||||||
queryFn: () => getXBlockOLX(usageKey),
|
queryFn: () => getXBlockOLX(usageKey, version),
|
||||||
enabled: !!usageKey,
|
enabled: !!usageKey,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -367,11 +368,7 @@ export const useUpdateXBlockOLX = (usageKey: string) => {
|
|||||||
mutationFn: (newOLX: string) => setXBlockOLX(usageKey, newOLX),
|
mutationFn: (newOLX: string) => setXBlockOLX(usageKey, newOLX),
|
||||||
onSuccess: (olxFromServer) => {
|
onSuccess: (olxFromServer) => {
|
||||||
queryClient.setQueryData(xblockQueryKeys.xblockOLX(usageKey), olxFromServer);
|
queryClient.setQueryData(xblockQueryKeys.xblockOLX(usageKey), olxFromServer);
|
||||||
// Reload the other data for this component:
|
|
||||||
invalidateComponentData(queryClient, contentLibraryId, usageKey);
|
invalidateComponentData(queryClient, contentLibraryId, usageKey);
|
||||||
// And the description and display name etc. may have changed, so refresh everything in the library too:
|
|
||||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(contentLibraryId) });
|
|
||||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, contentLibraryId) });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
getLibraryTeamMemberApiUrl,
|
getLibraryTeamMemberApiUrl,
|
||||||
} from '../data/api';
|
} from '../data/api';
|
||||||
import { LibraryProvider } from '../common/context';
|
import { LibraryProvider } from '../common/context';
|
||||||
|
import { ToastProvider } from '../../generic/toast-context';
|
||||||
import LibraryTeam from './LibraryTeam';
|
import LibraryTeam from './LibraryTeam';
|
||||||
|
|
||||||
mockContentLibrary.applyMock();
|
mockContentLibrary.applyMock();
|
||||||
@@ -28,9 +29,11 @@ describe('<LibraryTeam />', () => {
|
|||||||
const { libraryId } = mockContentLibrary;
|
const { libraryId } = mockContentLibrary;
|
||||||
const renderLibraryTeam = async () => {
|
const renderLibraryTeam = async () => {
|
||||||
render(
|
render(
|
||||||
<LibraryProvider libraryId={libraryId}>
|
<ToastProvider>
|
||||||
<LibraryTeam />
|
<LibraryProvider libraryId={libraryId}>
|
||||||
</LibraryProvider>,
|
<LibraryTeam />
|
||||||
|
</LibraryProvider>
|
||||||
|
</ToastProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -176,6 +179,56 @@ describe('<LibraryTeam />', () => {
|
|||||||
`{"library_id":"${libraryId}","email":"another@user.tld","access_level":"read"}`,
|
`{"library_id":"${libraryId}","email":"another@user.tld","access_level":"read"}`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText('Team Member added')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error when user do not exist', async () => {
|
||||||
|
const url = getLibraryTeamApiUrl(libraryId);
|
||||||
|
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
|
axiosMock.onPost(url).reply(400, { email: 'Error' });
|
||||||
|
|
||||||
|
await renderLibraryTeam();
|
||||||
|
|
||||||
|
const addButton = screen.getByRole('button', { name: 'New team member' });
|
||||||
|
userEvent.click(addButton);
|
||||||
|
const emailInput = screen.getByRole('textbox', { name: 'User\'s email address' });
|
||||||
|
userEvent.click(emailInput);
|
||||||
|
userEvent.type(emailInput, 'another@user.tld');
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole('button', { name: /add member/i });
|
||||||
|
userEvent.click(saveButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(axiosMock.history.post.length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText(
|
||||||
|
'Error adding Team Member. Please verify that the email is correct and belongs to a registered user.',
|
||||||
|
)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error', async () => {
|
||||||
|
const url = getLibraryTeamApiUrl(libraryId);
|
||||||
|
const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||||
|
axiosMock.onPost(url).reply(400, {});
|
||||||
|
|
||||||
|
await renderLibraryTeam();
|
||||||
|
|
||||||
|
const addButton = screen.getByRole('button', { name: 'New team member' });
|
||||||
|
userEvent.click(addButton);
|
||||||
|
const emailInput = screen.getByRole('textbox', { name: 'User\'s email address' });
|
||||||
|
userEvent.click(emailInput);
|
||||||
|
userEvent.type(emailInput, 'another@user.tld');
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole('button', { name: /add member/i });
|
||||||
|
userEvent.click(saveButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(axiosMock.history.post.length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText('Error adding Team Member')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows library team member roles to be changed', async () => {
|
it('allows library team member roles to be changed', async () => {
|
||||||
|
|||||||
@@ -65,8 +65,13 @@ const LibraryTeam: React.FC<Record<never, never>> = () => {
|
|||||||
accessLevel: LibraryRole.Reader.toString() as LibraryAccessLevel,
|
accessLevel: LibraryRole.Reader.toString() as LibraryAccessLevel,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
showToast(intl.formatMessage(messages.addMemberSuccess));
|
showToast(intl.formatMessage(messages.addMemberSuccess));
|
||||||
}).catch(() => {
|
}).catch((addMemberError) => {
|
||||||
showToast(intl.formatMessage(messages.addMemberError));
|
const errorData = typeof addMemberError === 'object' ? addMemberError.response?.data : undefined;
|
||||||
|
if (errorData && 'email' in errorData) {
|
||||||
|
showToast(intl.formatMessage(messages.addMemberEmailError));
|
||||||
|
} else {
|
||||||
|
showToast(intl.formatMessage(messages.addMemberError));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
closeAddLibraryTeamMember();
|
closeAddLibraryTeamMember();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -124,6 +124,11 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Error adding Team Member',
|
defaultMessage: 'Error adding Team Member',
|
||||||
description: 'Message shown when an error occurs while adding a Library Team member',
|
description: 'Message shown when an error occurs while adding a Library Team member',
|
||||||
},
|
},
|
||||||
|
addMemberEmailError: {
|
||||||
|
id: 'course-authoring.library-authoring.library-team.add-member-email-error',
|
||||||
|
defaultMessage: 'Error adding Team Member. Please verify that the email is correct and belongs to a registered user.',
|
||||||
|
description: 'Message shown when an error occurs with email while adding a Library Team member.',
|
||||||
|
},
|
||||||
deleteMemberSuccess: {
|
deleteMemberSuccess: {
|
||||||
id: 'course-authoring.library-authoring.library-team.delete-member-success',
|
id: 'course-authoring.library-authoring.library-team.delete-member-success',
|
||||||
defaultMessage: 'Team Member deleted',
|
defaultMessage: 'Team Member deleted',
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
homeTab: {
|
homeTab: {
|
||||||
id: 'course-authoring.library-authoring.home-tab',
|
id: 'course-authoring.library-authoring.home-tab',
|
||||||
defaultMessage: 'Home',
|
defaultMessage: 'All Content',
|
||||||
description: 'Tab label for the home tab',
|
description: 'Tab label for the home tab',
|
||||||
},
|
},
|
||||||
componentsTab: {
|
componentsTab: {
|
||||||
@@ -111,6 +111,11 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Change Library',
|
defaultMessage: 'Change Library',
|
||||||
description: 'Breadcrumbs link to return to library selection',
|
description: 'Breadcrumbs link to return to library selection',
|
||||||
},
|
},
|
||||||
|
librariesV2DisabledError: {
|
||||||
|
id: 'authoring.alert.error.libraries.v2.disabled',
|
||||||
|
defaultMessage: 'This page cannot be shown: Libraries v2 are disabled.',
|
||||||
|
description: 'Error message shown to users when trying to load a libraries V2 page while libraries v2 are disabled.',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default messages;
|
export default messages;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { MeiliSearch, type Filter } from 'meilisearch';
|
|||||||
import { union } from 'lodash';
|
import { union } from 'lodash';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CollectionHit, ContentHit, SearchSortOption, forceArray, OverrideQueries,
|
CollectionHit, ContentHit, SearchSortOption, forceArray,
|
||||||
} from './data/api';
|
} from './data/api';
|
||||||
import { useContentSearchConnection, useContentSearchResults } from './data/apiHooks';
|
import { useContentSearchConnection, useContentSearchResults } from './data/apiHooks';
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ export interface SearchContextData {
|
|||||||
searchSortOrder: SearchSortOption;
|
searchSortOrder: SearchSortOption;
|
||||||
setSearchSortOrder: React.Dispatch<React.SetStateAction<SearchSortOption>>;
|
setSearchSortOrder: React.Dispatch<React.SetStateAction<SearchSortOption>>;
|
||||||
defaultSearchSortOrder: SearchSortOption;
|
defaultSearchSortOrder: SearchSortOption;
|
||||||
hits: ContentHit[];
|
hits: (ContentHit | CollectionHit)[];
|
||||||
totalHits: number;
|
totalHits: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
hasNextPage: boolean | undefined;
|
hasNextPage: boolean | undefined;
|
||||||
@@ -42,8 +42,6 @@ export interface SearchContextData {
|
|||||||
fetchNextPage: () => void;
|
fetchNextPage: () => void;
|
||||||
closeSearchModal: () => void;
|
closeSearchModal: () => void;
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
collectionHits: CollectionHit[];
|
|
||||||
totalCollectionHits: number;
|
|
||||||
usageKey: string;
|
usageKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,10 +91,10 @@ export const SearchContextProvider: React.FC<{
|
|||||||
overrideSearchSortOrder?: SearchSortOption
|
overrideSearchSortOrder?: SearchSortOption
|
||||||
children: React.ReactNode,
|
children: React.ReactNode,
|
||||||
closeSearchModal?: () => void,
|
closeSearchModal?: () => void,
|
||||||
overrideQueries?: OverrideQueries,
|
skipBlockTypeFetch?: boolean,
|
||||||
skipUrlUpdate?: boolean,
|
skipUrlUpdate?: boolean,
|
||||||
}> = ({
|
}> = ({
|
||||||
overrideSearchSortOrder, overrideQueries, skipUrlUpdate, ...props
|
overrideSearchSortOrder, skipBlockTypeFetch, skipUrlUpdate, ...props
|
||||||
}) => {
|
}) => {
|
||||||
const [searchKeywords, setSearchKeywords] = React.useState('');
|
const [searchKeywords, setSearchKeywords] = React.useState('');
|
||||||
const [blockTypesFilter, setBlockTypesFilter] = React.useState<string[]>([]);
|
const [blockTypesFilter, setBlockTypesFilter] = React.useState<string[]>([]);
|
||||||
@@ -149,7 +147,9 @@ export const SearchContextProvider: React.FC<{
|
|||||||
setBlockTypesFilter([]);
|
setBlockTypesFilter([]);
|
||||||
setTagsFilter([]);
|
setTagsFilter([]);
|
||||||
setProblemTypesFilter([]);
|
setProblemTypesFilter([]);
|
||||||
setUsageKey('');
|
if (usageKey !== '') {
|
||||||
|
setUsageKey('');
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Initialize a connection to Meilisearch:
|
// Initialize a connection to Meilisearch:
|
||||||
@@ -165,7 +165,7 @@ export const SearchContextProvider: React.FC<{
|
|||||||
problemTypesFilter,
|
problemTypesFilter,
|
||||||
tagsFilter,
|
tagsFilter,
|
||||||
sort,
|
sort,
|
||||||
overrideQueries,
|
skipBlockTypeFetch,
|
||||||
});
|
});
|
||||||
|
|
||||||
return React.createElement(SearchContext.Provider, {
|
return React.createElement(SearchContext.Provider, {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||||
import type {
|
import type {
|
||||||
Filter, MeiliSearch, MultiSearchQuery, SearchParams,
|
Filter, MeiliSearch, MultiSearchQuery,
|
||||||
} from 'meilisearch';
|
} from 'meilisearch';
|
||||||
|
|
||||||
export const getContentSearchConfigUrl = () => new URL(
|
export const getContentSearchConfigUrl = () => new URL(
|
||||||
@@ -126,6 +126,7 @@ export interface ContentHit extends BaseContentHit {
|
|||||||
* - First one is the name of the course/library itself.
|
* - First one is the name of the course/library itself.
|
||||||
* - After that is the name and usage key of any parent Section/Subsection/Unit/etc.
|
* - After that is the name and usage key of any parent Section/Subsection/Unit/etc.
|
||||||
*/
|
*/
|
||||||
|
type: 'course_block' | 'library_block';
|
||||||
breadcrumbs: [{ displayName: string }, ...Array<{ displayName: string, usageKey: string }>];
|
breadcrumbs: [{ displayName: string }, ...Array<{ displayName: string, usageKey: string }>];
|
||||||
description?: string;
|
description?: string;
|
||||||
content?: ContentDetails;
|
content?: ContentDetails;
|
||||||
@@ -142,6 +143,7 @@ export interface ContentHit extends BaseContentHit {
|
|||||||
export interface ContentPublishedData {
|
export interface ContentPublishedData {
|
||||||
description?: string,
|
description?: string,
|
||||||
displayName?: string,
|
displayName?: string,
|
||||||
|
numChildren?: number,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,8 +151,10 @@ export interface ContentPublishedData {
|
|||||||
* Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py
|
* Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py
|
||||||
*/
|
*/
|
||||||
export interface CollectionHit extends BaseContentHit {
|
export interface CollectionHit extends BaseContentHit {
|
||||||
|
type: 'collection';
|
||||||
description: string;
|
description: string;
|
||||||
numChildren?: number;
|
numChildren?: number;
|
||||||
|
published?: ContentPublishedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -169,29 +173,6 @@ export function formatSearchHit(hit: Record<string, any>): ContentHit | Collecti
|
|||||||
return camelCaseObject(newHit);
|
return camelCaseObject(newHit);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OverrideQueries {
|
|
||||||
components?: SearchParams,
|
|
||||||
blockTypes?: SearchParams,
|
|
||||||
collections?: SearchParams,
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyOverrideQueries(
|
|
||||||
queries: MultiSearchQuery[],
|
|
||||||
overrideQueries?: OverrideQueries,
|
|
||||||
): MultiSearchQuery[] {
|
|
||||||
const newQueries = [...queries];
|
|
||||||
if (overrideQueries?.components) {
|
|
||||||
newQueries[0] = { ...overrideQueries.components, indexUid: queries[0].indexUid };
|
|
||||||
}
|
|
||||||
if (overrideQueries?.blockTypes) {
|
|
||||||
newQueries[1] = { ...overrideQueries.blockTypes, indexUid: queries[1].indexUid };
|
|
||||||
}
|
|
||||||
if (overrideQueries?.collections) {
|
|
||||||
newQueries[2] = { ...overrideQueries.collections, indexUid: queries[2].indexUid };
|
|
||||||
}
|
|
||||||
return newQueries;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FetchSearchParams {
|
interface FetchSearchParams {
|
||||||
client: MeiliSearch,
|
client: MeiliSearch,
|
||||||
indexName: string,
|
indexName: string,
|
||||||
@@ -204,7 +185,7 @@ interface FetchSearchParams {
|
|||||||
sort?: SearchSortOption[],
|
sort?: SearchSortOption[],
|
||||||
/** How many results to skip, e.g. if limit=20 then passing offset=20 gets the second page. */
|
/** How many results to skip, e.g. if limit=20 then passing offset=20 gets the second page. */
|
||||||
offset?: number,
|
offset?: number,
|
||||||
overrideQueries?: OverrideQueries,
|
skipBlockTypeFetch?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSearchResults({
|
export async function fetchSearchResults({
|
||||||
@@ -216,18 +197,16 @@ export async function fetchSearchResults({
|
|||||||
tagsFilter,
|
tagsFilter,
|
||||||
extraFilter,
|
extraFilter,
|
||||||
sort,
|
sort,
|
||||||
overrideQueries,
|
|
||||||
offset = 0,
|
offset = 0,
|
||||||
|
skipBlockTypeFetch = false,
|
||||||
}: FetchSearchParams): Promise<{
|
}: FetchSearchParams): Promise<{
|
||||||
hits: ContentHit[],
|
hits: (ContentHit | CollectionHit)[],
|
||||||
nextOffset: number | undefined,
|
nextOffset: number | undefined,
|
||||||
totalHits: number,
|
totalHits: number,
|
||||||
blockTypes: Record<string, number>,
|
blockTypes: Record<string, number>,
|
||||||
problemTypes: Record<string, number>,
|
problemTypes: Record<string, number>,
|
||||||
collectionHits: CollectionHit[],
|
|
||||||
totalCollectionHits: number,
|
|
||||||
}> {
|
}> {
|
||||||
let queries: MultiSearchQuery[] = [];
|
const queries: MultiSearchQuery[] = [];
|
||||||
|
|
||||||
// Convert 'extraFilter' into an array
|
// Convert 'extraFilter' into an array
|
||||||
const extraFilterFormatted = forceArray(extraFilter);
|
const extraFilterFormatted = forceArray(extraFilter);
|
||||||
@@ -246,8 +225,6 @@ export async function fetchSearchResults({
|
|||||||
...problemTypesFilterFormatted,
|
...problemTypesFilterFormatted,
|
||||||
].flat()];
|
].flat()];
|
||||||
|
|
||||||
const collectionsFilter = 'type = "collection"';
|
|
||||||
|
|
||||||
// First query is always to get the hits, with all the filters applied.
|
// First query is always to get the hits, with all the filters applied.
|
||||||
queries.push({
|
queries.push({
|
||||||
indexUid: indexName,
|
indexUid: indexName,
|
||||||
@@ -255,7 +232,6 @@ export async function fetchSearchResults({
|
|||||||
filter: [
|
filter: [
|
||||||
// top-level entries in the array are AND conditions and must all match
|
// top-level entries in the array are AND conditions and must all match
|
||||||
// Inner arrays are OR conditions, where only one needs to match.
|
// Inner arrays are OR conditions, where only one needs to match.
|
||||||
`NOT ${collectionsFilter}`, // exclude collections
|
|
||||||
...typeFilters,
|
...typeFilters,
|
||||||
...extraFilterFormatted,
|
...extraFilterFormatted,
|
||||||
...tagsFilterFormatted,
|
...tagsFilterFormatted,
|
||||||
@@ -270,52 +246,27 @@ export async function fetchSearchResults({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// The second query is to get the possible values for the "block types" filter
|
// The second query is to get the possible values for the "block types" filter
|
||||||
queries.push({
|
if (!skipBlockTypeFetch) {
|
||||||
indexUid: indexName,
|
queries.push({
|
||||||
q: searchKeywords,
|
indexUid: indexName,
|
||||||
facets: ['block_type', 'content.problem_types'],
|
facets: ['block_type', 'content.problem_types'],
|
||||||
filter: [
|
filter: [
|
||||||
...extraFilterFormatted,
|
...extraFilterFormatted,
|
||||||
// We exclude the block type filter here so we get all the other available options for it.
|
// We exclude the block type filter here so we get all the other available options for it.
|
||||||
...tagsFilterFormatted,
|
...tagsFilterFormatted,
|
||||||
],
|
],
|
||||||
limit: 0, // We don't need any "hits" for this - just the facetDistribution
|
limit: 0, // We don't need any "hits" for this - just the facetDistribution
|
||||||
});
|
});
|
||||||
|
}
|
||||||
// Third query is to get the hits for collections, with all the filters applied.
|
|
||||||
queries.push({
|
|
||||||
indexUid: indexName,
|
|
||||||
q: searchKeywords,
|
|
||||||
filter: [
|
|
||||||
// top-level entries in the array are AND conditions and must all match
|
|
||||||
// Inner arrays are OR conditions, where only one needs to match.
|
|
||||||
collectionsFilter, // include only collections
|
|
||||||
...extraFilterFormatted,
|
|
||||||
// We exclude the block type filter as collections are only of 1 type i.e. collection.
|
|
||||||
...tagsFilterFormatted,
|
|
||||||
],
|
|
||||||
attributesToHighlight: ['display_name', 'description'],
|
|
||||||
highlightPreTag: HIGHLIGHT_PRE_TAG,
|
|
||||||
highlightPostTag: HIGHLIGHT_POST_TAG,
|
|
||||||
attributesToCrop: ['description'],
|
|
||||||
sort,
|
|
||||||
offset,
|
|
||||||
limit,
|
|
||||||
});
|
|
||||||
|
|
||||||
queries = applyOverrideQueries(queries, overrideQueries);
|
|
||||||
|
|
||||||
const { results } = await client.multiSearch(({ queries }));
|
const { results } = await client.multiSearch(({ queries }));
|
||||||
const componentHitLength = results[0].hits.length;
|
const hitLength = results[0].hits.length;
|
||||||
const collectionHitLength = results[2].hits.length;
|
|
||||||
return {
|
return {
|
||||||
hits: results[0].hits.map(formatSearchHit) as ContentHit[],
|
hits: results[0].hits.map(formatSearchHit) as ContentHit[],
|
||||||
totalHits: results[0].totalHits ?? results[0].estimatedTotalHits ?? componentHitLength,
|
totalHits: results[0].totalHits ?? results[0].estimatedTotalHits ?? hitLength,
|
||||||
blockTypes: results[1].facetDistribution?.block_type ?? {},
|
blockTypes: results[1]?.facetDistribution?.block_type ?? {},
|
||||||
problemTypes: results[1].facetDistribution?.['content.problem_types'] ?? {},
|
problemTypes: results[1]?.facetDistribution?.['content.problem_types'] ?? {},
|
||||||
nextOffset: componentHitLength === limit || collectionHitLength === limit ? offset + limit : undefined,
|
nextOffset: hitLength === limit ? offset + limit : undefined,
|
||||||
collectionHits: results[2].hits.map(formatSearchHit) as CollectionHit[],
|
|
||||||
totalCollectionHits: results[2].totalHits ?? results[2].estimatedTotalHits ?? collectionHitLength,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,19 +504,3 @@ export async function fetchTagsThatMatchKeyword({
|
|||||||
|
|
||||||
return { matches: Array.from(matches).map((tagPath) => ({ tagPath })), mayBeMissingResults: hits.length === limit };
|
return { matches: Array.from(matches).map((tagPath) => ({ tagPath })), mayBeMissingResults: hits.length === limit };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch single document by its id
|
|
||||||
*/
|
|
||||||
/* istanbul ignore next */
|
|
||||||
export async function fetchDocumentById({ client, indexName, id } : {
|
|
||||||
/** The Meilisearch client instance */
|
|
||||||
client: MeiliSearch;
|
|
||||||
/** Which index to search */
|
|
||||||
indexName: string;
|
|
||||||
/** document id */
|
|
||||||
id: string | number;
|
|
||||||
}): Promise<ContentHit | CollectionHit> {
|
|
||||||
const doc = await client.index(indexName).getDocument(id);
|
|
||||||
return formatSearchHit(doc);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import {
|
|||||||
fetchSearchResults,
|
fetchSearchResults,
|
||||||
fetchTagsThatMatchKeyword,
|
fetchTagsThatMatchKeyword,
|
||||||
getContentSearchConfig,
|
getContentSearchConfig,
|
||||||
fetchDocumentById,
|
|
||||||
fetchBlockTypes,
|
fetchBlockTypes,
|
||||||
OverrideQueries,
|
|
||||||
} from './api';
|
} from './api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,7 +55,7 @@ export const useContentSearchResults = ({
|
|||||||
problemTypesFilter = [],
|
problemTypesFilter = [],
|
||||||
tagsFilter = [],
|
tagsFilter = [],
|
||||||
sort = [],
|
sort = [],
|
||||||
overrideQueries,
|
skipBlockTypeFetch = false,
|
||||||
}: {
|
}: {
|
||||||
/** The Meilisearch API client */
|
/** The Meilisearch API client */
|
||||||
client?: MeiliSearch;
|
client?: MeiliSearch;
|
||||||
@@ -75,8 +73,8 @@ export const useContentSearchResults = ({
|
|||||||
tagsFilter?: string[];
|
tagsFilter?: string[];
|
||||||
/** Sort search results using these options */
|
/** Sort search results using these options */
|
||||||
sort?: SearchSortOption[];
|
sort?: SearchSortOption[];
|
||||||
/** Set true to fetch collections along with components */
|
/** If true, don't fetch the block types from the server */
|
||||||
overrideQueries?: OverrideQueries,
|
skipBlockTypeFetch?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const query = useInfiniteQuery({
|
const query = useInfiniteQuery({
|
||||||
enabled: client !== undefined && indexName !== undefined,
|
enabled: client !== undefined && indexName !== undefined,
|
||||||
@@ -92,9 +90,9 @@ export const useContentSearchResults = ({
|
|||||||
problemTypesFilter,
|
problemTypesFilter,
|
||||||
tagsFilter,
|
tagsFilter,
|
||||||
sort,
|
sort,
|
||||||
overrideQueries,
|
|
||||||
],
|
],
|
||||||
queryFn: ({ pageParam = 0 }) => {
|
queryFn: ({ pageParam = 0 }) => {
|
||||||
|
// istanbul ignore if: this should never happen
|
||||||
if (client === undefined || indexName === undefined) {
|
if (client === undefined || indexName === undefined) {
|
||||||
throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.');
|
throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.');
|
||||||
}
|
}
|
||||||
@@ -110,7 +108,7 @@ export const useContentSearchResults = ({
|
|||||||
// For infinite pagination of results, we can retrieve additional pages if requested.
|
// For infinite pagination of results, we can retrieve additional pages if requested.
|
||||||
// Note that if there are 20 results per page, the "second page" has offset=20, not 2.
|
// Note that if there are 20 results per page, the "second page" has offset=20, not 2.
|
||||||
offset: pageParam,
|
offset: pageParam,
|
||||||
overrideQueries,
|
skipBlockTypeFetch,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getNextPageParam: (lastPage) => lastPage.nextOffset,
|
getNextPageParam: (lastPage) => lastPage.nextOffset,
|
||||||
@@ -125,14 +123,8 @@ export const useContentSearchResults = ({
|
|||||||
[pages],
|
[pages],
|
||||||
);
|
);
|
||||||
|
|
||||||
const collectionHits = React.useMemo(
|
|
||||||
() => pages?.reduce((allHits, page) => [...allHits, ...page.collectionHits], []) ?? [],
|
|
||||||
[pages],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hits,
|
hits,
|
||||||
collectionHits,
|
|
||||||
// The distribution of block type filter options
|
// The distribution of block type filter options
|
||||||
blockTypes: pages?.[0]?.blockTypes ?? {},
|
blockTypes: pages?.[0]?.blockTypes ?? {},
|
||||||
problemTypes: pages?.[0]?.problemTypes ?? {},
|
problemTypes: pages?.[0]?.problemTypes ?? {},
|
||||||
@@ -147,7 +139,6 @@ export const useContentSearchResults = ({
|
|||||||
hasNextPage: query.hasNextPage,
|
hasNextPage: query.hasNextPage,
|
||||||
// The last page has the most accurate count of total hits
|
// The last page has the most accurate count of total hits
|
||||||
totalHits: pages?.[pages.length - 1]?.totalHits ?? 0,
|
totalHits: pages?.[pages.length - 1]?.totalHits ?? 0,
|
||||||
totalCollectionHits: pages?.[pages.length - 1]?.totalCollectionHits ?? 0,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -186,6 +177,7 @@ export const useTagFilterOptions = (args: {
|
|||||||
],
|
],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const { client, indexName } = args;
|
const { client, indexName } = args;
|
||||||
|
// istanbul ignore if: this should never happen
|
||||||
if (client === undefined || indexName === undefined) {
|
if (client === undefined || indexName === undefined) {
|
||||||
throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.');
|
throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.');
|
||||||
}
|
}
|
||||||
@@ -210,6 +202,7 @@ export const useTagFilterOptions = (args: {
|
|||||||
],
|
],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const { client, indexName } = args;
|
const { client, indexName } = args;
|
||||||
|
// istanbul ignore if: this should never happen
|
||||||
if (client === undefined || indexName === undefined) {
|
if (client === undefined || indexName === undefined) {
|
||||||
throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.');
|
throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.');
|
||||||
}
|
}
|
||||||
@@ -259,27 +252,3 @@ export const useGetBlockTypes = (extraFilters: Filter) => {
|
|||||||
queryFn: () => fetchBlockTypes(client!, indexName!, extraFilters),
|
queryFn: () => fetchBlockTypes(client!, indexName!, extraFilters),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/* istanbul ignore next */
|
|
||||||
export const useGetSingleDocument = ({ client, indexName, id }: {
|
|
||||||
client?: MeiliSearch;
|
|
||||||
indexName?: string;
|
|
||||||
id: string | number;
|
|
||||||
}) => (
|
|
||||||
useQuery({
|
|
||||||
enabled: client !== undefined && indexName !== undefined,
|
|
||||||
queryKey: [
|
|
||||||
'content_search',
|
|
||||||
client?.config.apiKey,
|
|
||||||
client?.config.host,
|
|
||||||
indexName,
|
|
||||||
id,
|
|
||||||
],
|
|
||||||
queryFn: () => {
|
|
||||||
if (client === undefined || indexName === undefined) {
|
|
||||||
throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.');
|
|
||||||
}
|
|
||||||
return fetchDocumentById({ client, indexName, id });
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { StatefulButton } from '@openedx/paragon';
|
import { StatefulButton } from '@openedx/paragon';
|
||||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||||
|
|
||||||
import { useSearchContext } from '../search-manager';
|
import { ContentHit, useSearchContext } from '../search-manager';
|
||||||
import SearchResult from './SearchResult';
|
import SearchResult from './SearchResult';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
@@ -28,7 +28,9 @@ const SearchResults: React.FC<Record<never, never>> = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{hits.map((hit) => <SearchResult key={hit.id} hit={hit} />)}
|
{hits.filter((hit): hit is ContentHit => hit.type !== 'collection').map(
|
||||||
|
(hit) => <SearchResult key={hit.id} hit={hit} />,
|
||||||
|
)}
|
||||||
{hasNextPage
|
{hasNextPage
|
||||||
? (
|
? (
|
||||||
<StatefulButton
|
<StatefulButton
|
||||||
|
|||||||
@@ -96,12 +96,9 @@ describe('<SearchUI />', () => {
|
|||||||
// because otherwise Instantsearch will update the UI and change the query,
|
// because otherwise Instantsearch will update the UI and change the query,
|
||||||
// leading to unexpected results in the test cases.
|
// leading to unexpected results in the test cases.
|
||||||
mockResult.results[0].query = query;
|
mockResult.results[0].query = query;
|
||||||
mockResult.results[2].query = query;
|
|
||||||
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
|
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
|
||||||
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
||||||
mockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
mockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
||||||
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
|
|
||||||
mockResult.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
|
|
||||||
return mockResult;
|
return mockResult;
|
||||||
});
|
});
|
||||||
fetchMock.post(tagsKeywordSearchEndpoint, mockTagsKeywordSearchResult);
|
fetchMock.post(tagsKeywordSearchEndpoint, mockTagsKeywordSearchResult);
|
||||||
@@ -173,8 +170,8 @@ describe('<SearchUI />', () => {
|
|||||||
expect(fetchMock).toHaveLastFetched((_url, req) => {
|
expect(fetchMock).toHaveLastFetched((_url, req) => {
|
||||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||||
const requestedFilter = requestData?.queries[0].filter;
|
const requestedFilter = requestData?.queries[0].filter;
|
||||||
return requestedFilter?.[2] === 'type = "course_block"'
|
return requestedFilter?.[1] === 'type = "course_block"'
|
||||||
&& requestedFilter?.[3] === 'context_key = "course-v1:org+test+123"';
|
&& requestedFilter?.[2] === 'context_key = "course-v1:org+test+123"';
|
||||||
});
|
});
|
||||||
// Now we should see the results:
|
// Now we should see the results:
|
||||||
expect(queryByText('Enter a keyword')).toBeNull();
|
expect(queryByText('Enter a keyword')).toBeNull();
|
||||||
@@ -362,8 +359,8 @@ describe('<SearchUI />', () => {
|
|||||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||||
const requestedFilter = requestData?.queries[0].filter;
|
const requestedFilter = requestData?.queries[0].filter;
|
||||||
// the filter is:
|
// the filter is:
|
||||||
// ['NOT type == "collection"', '', 'type = "course_block"', 'context_key = "course-v1:org+test+123"']
|
// ['', 'type = "course_block"', 'context_key = "course-v1:org+test+123"']
|
||||||
return (requestedFilter?.length === 4);
|
return (requestedFilter?.length === 3);
|
||||||
});
|
});
|
||||||
// Now we should see the results:
|
// Now we should see the results:
|
||||||
expect(getByText('6 results found')).toBeInTheDocument();
|
expect(getByText('6 results found')).toBeInTheDocument();
|
||||||
@@ -389,7 +386,6 @@ describe('<SearchUI />', () => {
|
|||||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||||
const requestedFilter = requestData?.queries[0].filter;
|
const requestedFilter = requestData?.queries[0].filter;
|
||||||
return JSON.stringify(requestedFilter) === JSON.stringify([
|
return JSON.stringify(requestedFilter) === JSON.stringify([
|
||||||
'NOT type = "collection"',
|
|
||||||
[
|
[
|
||||||
'block_type = problem',
|
'block_type = problem',
|
||||||
'content.problem_types = choiceresponse',
|
'content.problem_types = choiceresponse',
|
||||||
@@ -423,7 +419,6 @@ describe('<SearchUI />', () => {
|
|||||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||||
const requestedFilter = requestData?.queries?.[0]?.filter;
|
const requestedFilter = requestData?.queries?.[0]?.filter;
|
||||||
return JSON.stringify(requestedFilter) === JSON.stringify([
|
return JSON.stringify(requestedFilter) === JSON.stringify([
|
||||||
'NOT type = "collection"',
|
|
||||||
[],
|
[],
|
||||||
'type = "course_block"',
|
'type = "course_block"',
|
||||||
'context_key = "course-v1:org+test+123"',
|
'context_key = "course-v1:org+test+123"',
|
||||||
@@ -459,7 +454,6 @@ describe('<SearchUI />', () => {
|
|||||||
const requestData = JSON.parse(req.body?.toString() ?? '');
|
const requestData = JSON.parse(req.body?.toString() ?? '');
|
||||||
const requestedFilter = requestData?.queries?.[0]?.filter;
|
const requestedFilter = requestData?.queries?.[0]?.filter;
|
||||||
return JSON.stringify(requestedFilter) === JSON.stringify([
|
return JSON.stringify(requestedFilter) === JSON.stringify([
|
||||||
'NOT type = "collection"',
|
|
||||||
[],
|
[],
|
||||||
'type = "course_block"',
|
'type = "course_block"',
|
||||||
'context_key = "course-v1:org+test+123"',
|
'context_key = "course-v1:org+test+123"',
|
||||||
|
|||||||
@@ -22,15 +22,6 @@
|
|||||||
"block_type": {}
|
"block_type": {}
|
||||||
},
|
},
|
||||||
"facetStats": {}
|
"facetStats": {}
|
||||||
},
|
|
||||||
{
|
|
||||||
"indexUid": "studio",
|
|
||||||
"hits": [],
|
|
||||||
"query": "noresult",
|
|
||||||
"processingTimeMs": 0,
|
|
||||||
"limit": 20,
|
|
||||||
"offset": 0,
|
|
||||||
"estimatedTotalHits": 0
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const StudioHome = () => {
|
|||||||
setShowNewCourseContainer,
|
setShowNewCourseContainer,
|
||||||
librariesV1Enabled,
|
librariesV1Enabled,
|
||||||
librariesV2Enabled,
|
librariesV2Enabled,
|
||||||
} = useStudioHome(isPaginationCoursesEnabled);
|
} = useStudioHome();
|
||||||
|
|
||||||
const v1LibraryTab = librariesV1Enabled && location?.pathname.split('/').pop() === 'libraries-v1';
|
const v1LibraryTab = librariesV1Enabled && location?.pathname.split('/').pop() === 'libraries-v1';
|
||||||
const showV2LibraryURL = librariesV2Enabled && !v1LibraryTab;
|
const showV2LibraryURL = librariesV2Enabled && !v1LibraryTab;
|
||||||
@@ -57,6 +57,7 @@ const StudioHome = () => {
|
|||||||
studioShortName,
|
studioShortName,
|
||||||
studioRequestEmail,
|
studioRequestEmail,
|
||||||
showNewLibraryButton,
|
showNewLibraryButton,
|
||||||
|
showNewLibraryV2Button,
|
||||||
} = studioHomeData;
|
} = studioHomeData;
|
||||||
|
|
||||||
const getHeaderButtons = useCallback(() => {
|
const getHeaderButtons = useCallback(() => {
|
||||||
@@ -86,7 +87,7 @@ const StudioHome = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showNewLibraryButton || showV2LibraryURL) {
|
if ((showNewLibraryButton && !showV2LibraryURL) || (showV2LibraryURL && showNewLibraryV2Button)) {
|
||||||
const newLibraryClick = () => {
|
const newLibraryClick = () => {
|
||||||
if (showV2LibraryURL) {
|
if (showV2LibraryURL) {
|
||||||
navigate('/library/create');
|
navigate('/library/create');
|
||||||
@@ -101,7 +102,6 @@ const StudioHome = () => {
|
|||||||
variant="outline-primary"
|
variant="outline-primary"
|
||||||
iconBefore={AddIcon}
|
iconBefore={AddIcon}
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={showNewCourseContainer}
|
|
||||||
onClick={newLibraryClick}
|
onClick={newLibraryClick}
|
||||||
data-testid="new-library-button"
|
data-testid="new-library-button"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ module.exports = {
|
|||||||
requestCourseCreatorUrl: '/request_course_creator',
|
requestCourseCreatorUrl: '/request_course_creator',
|
||||||
rerunCreatorStatus: true,
|
rerunCreatorStatus: true,
|
||||||
showNewLibraryButton: true,
|
showNewLibraryButton: true,
|
||||||
|
showNewLibraryV2Button: true,
|
||||||
splitStudioHome: false,
|
splitStudioHome: false,
|
||||||
studioName: 'Studio',
|
studioName: 'Studio',
|
||||||
studioShortName: 'Studio',
|
studioShortName: 'Studio',
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const generateGetStudioHomeDataApiResponse = () => ({
|
|||||||
requestCourseCreatorUrl: '/request_course_creator',
|
requestCourseCreatorUrl: '/request_course_creator',
|
||||||
rerunCreatorStatus: true,
|
rerunCreatorStatus: true,
|
||||||
showNewLibraryButton: true,
|
showNewLibraryButton: true,
|
||||||
|
showNewLibraryV2Button: true,
|
||||||
splitStudioHome: false,
|
splitStudioHome: false,
|
||||||
studioName: 'Studio',
|
studioName: 'Studio',
|
||||||
studioShortName: 'Studio',
|
studioShortName: 'Studio',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
|
|
||||||
import { RequestStatus } from '../data/constants';
|
import { RequestStatus } from '../data/constants';
|
||||||
import { COURSE_CREATOR_STATES } from '../constants';
|
import { COURSE_CREATOR_STATES } from '../constants';
|
||||||
@@ -14,9 +15,10 @@ import {
|
|||||||
} from './data/selectors';
|
} from './data/selectors';
|
||||||
import { updateSavingStatuses } from './data/slice';
|
import { updateSavingStatuses } from './data/slice';
|
||||||
|
|
||||||
const useStudioHome = (isPaginated = false) => {
|
const useStudioHome = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const isPaginated = getConfig().ENABLE_HOME_PAGE_COURSE_API_V2;
|
||||||
const studioHomeData = useSelector(getStudioHomeData);
|
const studioHomeData = useSelector(getStudioHomeData);
|
||||||
const studioHomeCoursesParams = useSelector(getStudioHomeCoursesParams);
|
const studioHomeCoursesParams = useSelector(getStudioHomeCoursesParams);
|
||||||
const { isFiltered } = studioHomeCoursesParams;
|
const { isFiltered } = studioHomeCoursesParams;
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ const CoursesFilters = ({
|
|||||||
|
|
||||||
const handleSearchCoursesDebounced = useCallback(
|
const handleSearchCoursesDebounced = useCallback(
|
||||||
debounce((value) => handleSearchCourses(value), 400),
|
debounce((value) => handleSearchCourses(value), 400),
|
||||||
[],
|
[activeOnly, archivedOnly, order, inputSearchValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import initializeReduxStore from './store';
|
|||||||
|
|
||||||
/** @deprecated Use React Query and/or regular React Context instead of redux */
|
/** @deprecated Use React Query and/or regular React Context instead of redux */
|
||||||
let reduxStore: Store;
|
let reduxStore: Store;
|
||||||
let queryClient;
|
let queryClient: QueryClient;
|
||||||
let axiosMock: MockAdapter;
|
let axiosMock: MockAdapter;
|
||||||
|
|
||||||
/** To use this: `const { mockShowToast } = initializeMocks()` and `expect(mockShowToast).toHaveBeenCalled()` */
|
/** To use this: `const { mockShowToast } = initializeMocks()` and `expect(mockShowToast).toHaveBeenCalled()` */
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
|
|
||||||
import FormikControl from '../../generic/FormikControl';
|
import FormikControl from '../../generic/FormikControl';
|
||||||
import PromptIfDirty from '../../generic/promptIfDirty/PromptIfDirty';
|
import PromptIfDirty from '../../generic/prompt-if-dirty/PromptIfDirty';
|
||||||
import ModalDropzone from '../../generic/modal-dropzone/ModalDropzone';
|
import ModalDropzone from '../../generic/modal-dropzone/ModalDropzone';
|
||||||
import { useModel } from '../../generic/model-store';
|
import { useModel } from '../../generic/model-store';
|
||||||
import { UPLOAD_FILE_MAX_SIZE } from '../../constants';
|
import { UPLOAD_FILE_MAX_SIZE } from '../../constants';
|
||||||
|
|||||||
Reference in New Issue
Block a user