diff --git a/src/CourseAuthoringRoutes.jsx b/src/CourseAuthoringRoutes.jsx index 51599317e6..0c9d2a1680 100644 --- a/src/CourseAuthoringRoutes.jsx +++ b/src/CourseAuthoringRoutes.jsx @@ -88,7 +88,7 @@ const CourseAuthoringRoutes = () => { /> } + element={} /> { - const { blockType, blockId } = useParams(); - return ( -
- -
- ); -}; -EditorContainer.propTypes = { - courseId: PropTypes.string.isRequired, -}; - -export default EditorContainer; diff --git a/src/editors/EditorContainer.test.jsx b/src/editors/EditorContainer.test.jsx index a6186050ae..d57d14c6b1 100644 --- a/src/editors/EditorContainer.test.jsx +++ b/src/editors/EditorContainer.test.jsx @@ -10,7 +10,7 @@ jest.mock('react-router', () => ({ }), })); -const props = { courseId: 'cOuRsEId' }; +const props = { learningContextId: 'cOuRsEId' }; describe('Editor Container', () => { describe('snapshots', () => { diff --git a/src/editors/EditorContainer.tsx b/src/editors/EditorContainer.tsx new file mode 100644 index 0000000000..fc6fa417c1 --- /dev/null +++ b/src/editors/EditorContainer.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { getConfig } from '@edx/frontend-platform'; + +import EditorPage from './EditorPage'; + +interface Props { + /** Course ID or Library ID */ + learningContextId: string; + /** Event handler for when user cancels out of the editor page */ + onClose?: () => void; + /** Event handler called after when user saves their changes using an editor */ + afterSave?: () => (newData: Record) => void; +} + +const EditorContainer: React.FC = ({ + learningContextId, + onClose, + afterSave, +}) => { + const { blockType, blockId } = useParams(); + if (blockType === undefined || blockId === undefined) { + // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. + return
Error: missing URL parameters
; + } + if (!!onClose !== !!afterSave) { + /* istanbul ignore next */ + throw new Error('You must specify both onClose and afterSave or neither.'); + // These parameters are a bit messy so I'm trying to help make it more + // consistent here. For example, if you specify onClose, then returnFunction + // is only called if the save is successful. But if you leave onClose + // undefined, then returnFunction is called in either case, and with + // different arguments. The underlying EditorPage should be refactored to + // have more clear events like onCancel and onSaveSuccess + } + return ( +
+ +
+ ); +}; + +export default EditorContainer; diff --git a/src/editors/containers/EditorContainer/__snapshots__/index.test.jsx.snap b/src/editors/containers/EditorContainer/__snapshots__/index.test.jsx.snap index 49598b47ee..02c89e55d7 100644 --- a/src/editors/containers/EditorContainer/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/EditorContainer/__snapshots__/index.test.jsx.snap @@ -51,6 +51,7 @@ exports[`EditorContainer component render snapshot: initialized. enable save and /> diff --git a/src/editors/containers/EditorContainer/messages.js b/src/editors/containers/EditorContainer/messages.js index b8301ca810..a6f1754fb2 100644 --- a/src/editors/containers/EditorContainer/messages.js +++ b/src/editors/containers/EditorContainer/messages.js @@ -12,6 +12,11 @@ const messages = defineMessages({ defaultMessage: 'Are you sure you want to exit the editor? Any unsaved changes will be lost.', description: 'Description text for modal confirming cancellation', }, + exitButtonAlt: { + id: 'authoring.editorContainer.exitButton.alt', + defaultMessage: 'Exit the editor', + description: 'Alt text for the Exit button', + }, okButtonLabel: { id: 'authoring.editorContainer.okButton.label', defaultMessage: 'OK', diff --git a/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap b/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap index c7a8f48e8d..32b7e20300 100644 --- a/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap +++ b/src/editors/containers/TextEditor/__snapshots__/index.test.jsx.snap @@ -24,11 +24,7 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = ` onClose={[MockFunction hooks.nullMethod]} show={true} > - + Error: Could Not Load Text Content - + Error: Could Not Load Text Content - + Error: Could Not Load Text Content
- + Error: Could Not Load Text Content - + Error: Could Not Load Text Content
- + { intl.formatMessage(messages.couldNotLoadTextContext) } {(!blockFinished) @@ -111,7 +111,7 @@ TextEditor.propTypes = { initializeEditor: PropTypes.func.isRequired, showRawEditor: PropTypes.bool.isRequired, blockFinished: PropTypes.bool, - learningContextId: PropTypes.string.isRequired, + learningContextId: PropTypes.string, // This should be required but is NULL when the store is in initial state :/ images: PropTypes.shape({}).isRequired, isLibrary: PropTypes.bool.isRequired, // inject diff --git a/src/editors/data/redux/app/selectors.js b/src/editors/data/redux/app/selectors.js index 9976eee19f..d1c7ffd666 100644 --- a/src/editors/data/redux/app/selectors.js +++ b/src/editors/data/redux/app/selectors.js @@ -42,10 +42,9 @@ export const returnUrl = createSelector( export const isInitialized = createSelector( [ - module.simpleSelectors.unitUrl, module.simpleSelectors.blockValue, ], - (unitUrl, blockValue) => !!(unitUrl && blockValue), + (blockValue) => !!(blockValue), ); export const displayTitle = createSelector( diff --git a/src/editors/data/redux/app/selectors.test.js b/src/editors/data/redux/app/selectors.test.js index 33a022e0b6..6a9fd2c94c 100644 --- a/src/editors/data/redux/app/selectors.test.js +++ b/src/editors/data/redux/app/selectors.test.js @@ -78,23 +78,20 @@ describe('app selectors unit tests', () => { }); }); describe('isInitialized selector', () => { - it('is memoized based on unitUrl, editorInitialized, and blockValue', () => { + it('is memoized based on editorInitialized and blockValue', () => { expect(selectors.isInitialized.preSelectors).toEqual([ - simpleSelectors.unitUrl, simpleSelectors.blockValue, ]); }); - it('returns true iff unitUrl, blockValue, and editorInitialized are all truthy', () => { + it('returns true iff blockValue and editorInitialized are truthy', () => { const { cb } = selectors.isInitialized; const truthy = { - url: { url: 'data' }, blockValue: { block: 'value' }, }; [ - [[null, truthy.blockValue], false], - [[truthy.url, null], false], - [[truthy.url, truthy.blockValue], true], + [[truthy.blockValue], true], + [[null], false], ].map(([args, expected]) => expect(cb(...args)).toEqual(expected)); }); }); diff --git a/src/editors/data/redux/thunkActions/app.js b/src/editors/data/redux/thunkActions/app.js index fa50c91a06..2e210bb80d 100644 --- a/src/editors/data/redux/thunkActions/app.js +++ b/src/editors/data/redux/thunkActions/app.js @@ -89,7 +89,9 @@ export const initialize = (data) => (dispatch) => { const editorType = data.blockType; dispatch(actions.app.initialize(data)); dispatch(module.fetchBlock()); - dispatch(module.fetchUnit()); + if (data.blockId?.startsWith('block-v1:')) { + dispatch(module.fetchUnit()); + } switch (editorType) { case 'problem': dispatch(module.fetchImages({ pageNumber: 0 })); @@ -100,7 +102,12 @@ export const initialize = (data) => (dispatch) => { dispatch(module.fetchCourseDetails()); break; case 'html': - dispatch(module.fetchImages({ pageNumber: 0 })); + if (data.learningContextId?.startsWith('lib:')) { + // eslint-disable-next-line no-console + console.log('Not fetching image assets - not implemented yet for content libraries.'); + } else { + dispatch(module.fetchImages({ pageNumber: 0 })); + } break; default: break; diff --git a/src/editors/data/redux/thunkActions/app.test.js b/src/editors/data/redux/thunkActions/app.test.js index 2c962b2853..5cf52f7941 100644 --- a/src/editors/data/redux/thunkActions/app.test.js +++ b/src/editors/data/redux/thunkActions/app.test.js @@ -187,7 +187,6 @@ describe('app thunkActions', () => { expect(dispatch.mock.calls).toEqual([ [actions.app.initialize(testValue)], [thunkActions.fetchBlock()], - [thunkActions.fetchUnit()], ]); thunkActions.fetchBlock = fetchBlock; thunkActions.fetchUnit = fetchUnit; @@ -216,6 +215,8 @@ describe('app thunkActions', () => { const data = { ...testValue, blockType: 'html', + blockId: 'block-v1:UniversityX+PHYS+1+type@problem+block@123', + learningContextId: 'course-v1:UniversityX+PHYS+1', }; thunkActions.initialize(data)(dispatch); expect(dispatch.mock.calls).toEqual([ @@ -251,6 +252,8 @@ describe('app thunkActions', () => { const data = { ...testValue, blockType: 'problem', + blockId: 'block-v1:UniversityX+PHYS+1+type@problem+block@123', + learningContextId: 'course-v1:UniversityX+PHYS+1', }; thunkActions.initialize(data)(dispatch); expect(dispatch.mock.calls).toEqual([ @@ -286,6 +289,8 @@ describe('app thunkActions', () => { const data = { ...testValue, blockType: 'video', + blockId: 'block-v1:UniversityX+PHYS+1+type@problem+block@123', + learningContextId: 'course-v1:UniversityX+PHYS+1', }; thunkActions.initialize(data)(dispatch); expect(dispatch.mock.calls).toEqual([ diff --git a/src/editors/data/services/cms/urls.js b/src/editors/data/services/cms/urls.js index de4e9f158a..d101ff9241 100644 --- a/src/editors/data/services/cms/urls.js +++ b/src/editors/data/services/cms/urls.js @@ -38,11 +38,7 @@ export const blockAncestor = ({ studioEndpointUrl, blockId }) => { if (blockId.includes('block-v1')) { return `${block({ studioEndpointUrl, blockId })}?fields=ancestorInfo`; } - // this url only need to get info to build the return url, which isn't used by V2 blocks - // (temporary) don't throw error, just return empty url. it will fail it's network connection but otherwise - // the app will run - // throw new Error('Block ancestor not available (and not needed) for V2 blocks'); - return ''; + throw new Error('Block ancestor not available (and not needed) for V2 blocks'); }; export const blockStudioView = ({ studioEndpointUrl, blockId }) => ( diff --git a/src/editors/data/services/cms/urls.test.js b/src/editors/data/services/cms/urls.test.js index bbaf1cbcff..94b39828c7 100644 --- a/src/editors/data/services/cms/urls.test.js +++ b/src/editors/data/services/cms/urls.test.js @@ -95,14 +95,9 @@ describe('cms url methods', () => { expect(blockAncestor({ studioEndpointUrl, blockId })) .toEqual(`${block({ studioEndpointUrl, blockId })}?fields=ancestorInfo`); }); - // This test will probably be used in the future - // it('throws error with studioEndpointUrl, v2 blockId and ancestor query', () => { - // expect(() => { blockAncestor({ studioEndpointUrl, blockId: v2BlockId }); }) - // .toThrow('Block ancestor not available (and not needed) for V2 blocks'); - // }); - it('returns blank url with studioEndpointUrl, v2 blockId and ancestor query', () => { - expect(blockAncestor({ studioEndpointUrl, blockId: v2BlockId })) - .toEqual(''); + it('throws error with studioEndpointUrl, v2 blockId and ancestor query', () => { + expect(() => { blockAncestor({ studioEndpointUrl, blockId: v2BlockId }); }) + .toThrow('Block ancestor not available (and not needed) for V2 blocks'); }); }); describe('blockStudioView', () => { diff --git a/src/generic/data/api.mock.ts b/src/generic/data/api.mock.ts new file mode 100644 index 0000000000..cc01da38fd --- /dev/null +++ b/src/generic/data/api.mock.ts @@ -0,0 +1,49 @@ +/* istanbul ignore file */ +import * as api from './api'; + +/** + * Mock for `getClipboard()` that simulates an empty clipboard + */ +export async function mockClipboardEmpty(): Promise { + return { + content: null, + sourceUsageKey: '', + sourceContextTitle: '', + sourceEditUrl: '', + }; +} +mockClipboardEmpty.applyMock = () => jest.spyOn(api, 'getClipboard').mockImplementation(mockClipboardEmpty); +mockClipboardEmpty.applyMockOnce = () => jest.spyOn(api, 'getClipboard').mockImplementationOnce(mockClipboardEmpty); + +/** + * Mock for `getClipboard()` that simulates a copied HTML component + */ +export async function mockClipboardHtml(): Promise { + return { + content: { + id: 69, + userId: 3, + created: '2024-01-16T13:33:21.314439Z', + purpose: 'clipboard', + status: 'ready', + blockType: 'html', + blockTypeDisplay: 'Text', + olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/69/olx', + displayName: 'Blank HTML Page', + }, + sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@html+block@html1', + sourceContextTitle: 'Demonstration Course', + sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical1', + }; +} +mockClipboardHtml.applyMock = () => jest.spyOn(api, 'getClipboard').mockImplementation(mockClipboardHtml); +mockClipboardHtml.applyMockOnce = () => jest.spyOn(api, 'getClipboard').mockImplementationOnce(mockClipboardHtml); + +/** Mock the DOM `BroadcastChannel` API which the clipboard code uses */ +export function mockBroadcastChannel() { + const clipboardBroadcastChannelMock = { + postMessage: jest.fn(), + close: jest.fn(), + }; + (global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); +} diff --git a/src/generic/data/api.ts b/src/generic/data/api.ts index fbec8b55bf..5fc5802eaa 100644 --- a/src/generic/data/api.ts +++ b/src/generic/data/api.ts @@ -47,10 +47,27 @@ export async function createOrRerunCourse(courseData: Object): Promise return camelCaseObject(data); } +export interface ClipboardStatus { + content: { + id: number; + userId: number; + created: string; // e.g. '2024-08-28T19:02:08.272192Z' + purpose: 'clipboard'; + status: 'ready' | 'loading' | 'expired' | 'error'; + blockType: string; + blockTypeDisplay: string; + olxUrl: string; + displayName: string; + } | null; + sourceUsageKey: string; // May be an empty string + sourceContextTitle: string; // May be an empty string + sourceEditUrl: string; // May be an empty string +} + /** * Retrieves user's clipboard. */ -export async function getClipboard(): Promise { +export async function getClipboard(): Promise { const { data } = await getAuthenticatedHttpClient() .get(getClipboardUrl()); @@ -60,7 +77,7 @@ export async function getClipboard(): Promise { /** * Updates user's clipboard. */ -export async function updateClipboard(usageKey: string): Promise { +export async function updateClipboard(usageKey: string): Promise { const { data } = await getAuthenticatedHttpClient() .post(getClipboardUrl(), { usage_key: usageKey }); diff --git a/src/generic/toast-context/index.tsx b/src/generic/toast-context/index.tsx index f4fd2aa332..40145068fa 100644 --- a/src/generic/toast-context/index.tsx +++ b/src/generic/toast-context/index.tsx @@ -16,11 +16,11 @@ export interface ToastProviderProps { * Global context to keep track of popup message(s) that appears to user after * they take an action like creating or deleting something. */ -export const ToastContext = React.createContext({ +export const ToastContext = React.createContext({ toastMessage: null, showToast: () => {}, closeToast: () => {}, -} as ToastContextData); +}); /** * React component to provide `ToastContext` to the app diff --git a/src/library-authoring/LibraryAuthoringPage.scss b/src/library-authoring/LibraryAuthoringPage.scss index eaf87428b8..03a167b370 100644 --- a/src/library-authoring/LibraryAuthoringPage.scss +++ b/src/library-authoring/LibraryAuthoringPage.scss @@ -14,4 +14,9 @@ min-width: 300px; max-width: map-get($grid-breakpoints, "sm"); z-index: 1001; // to appear over header + position: sticky; + top: 0; + right: 0; + height: 100vh; + overflow-y: auto; } diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index e3c7c2d093..a848db358b 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -7,15 +7,18 @@ import { waitFor, within, } from '../testUtils'; -import { getContentSearchConfigUrl } from '../search-manager/data/api'; import mockResult from './__mocks__/library-search.json'; import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json'; import { mockContentLibrary, mockLibraryBlockTypes, mockXBlockFields } from './data/api.mocks'; +import { mockContentSearchConfig } from '../search-manager/data/api.mock'; +import { mockBroadcastChannel } from '../generic/data/api.mock'; import { LibraryLayout } from '.'; +mockContentSearchConfig.applyMock(); mockContentLibrary.applyMock(); mockLibraryBlockTypes.applyMock(); mockXBlockFields.applyMock(); +mockBroadcastChannel(); const searchEndpoint = 'http://mock.meilisearch.local/multi-search'; @@ -62,26 +65,12 @@ const returnLowNumberResults = (_url, req) => { return newMockResult; }; -const clipboardBroadcastChannelMock = { - postMessage: jest.fn(), - close: jest.fn(), -}; - -(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); - const path = '/library/:libraryId/*'; const libraryTitle = mockContentLibrary.libraryData.title; describe('', () => { beforeEach(() => { - const { axiosMock } = initializeMocks(); - - // 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', - }); + initializeMocks(); // The Meilisearch client-side API uses fetch, not Axios. fetchMock.post(searchEndpoint, (_url, req) => { @@ -432,8 +421,7 @@ describe('', () => { expect(mockResult0.display_name).toStrictEqual(displayName); await renderLibraryPage(); - // Click on the first component - expect((await screen.findAllByText(displayName))[0]).toBeInTheDocument(); + // Click on the first component. It should appear twice, in both "Recently Modified" and "Components" fireEvent.click((await screen.findAllByText(displayName))[0]); const sidebar = screen.getByTestId('library-sidebar'); diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index c74943a755..50b4047a34 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -120,6 +120,7 @@ const LibraryAuthoringPage = () => { const { libraryId } = useParams(); if (!libraryId) { + // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. throw new Error('Rendered without libraryId URL parameter'); } const { data: libraryData, isLoading } = useContentLibrary(libraryId); @@ -153,8 +154,8 @@ const LibraryAuthoringPage = () => { }; return ( -
-
+
+
{ + const { libraryId } = useParams(); + const queryClient = useQueryClient(); + + if (libraryId === undefined) { + // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. + throw new Error('Error: route is missing libraryId.'); + } + + const navigate = useNavigate(); + const goBack = React.useCallback(() => { + // Go back to the library + navigate(`/library/${libraryId}`); + // The following function is called only if changes are saved: + return ({ id: usageKey }) => { + // invalidate any queries that involve this XBlock: + invalidateComponentData(queryClient, libraryId, usageKey); + }; + }, []); -const LibraryLayout = () => ( - - - -); + return ( + + + + + + )} + /> + } + /> + + + ); +}; export default LibraryLayout; diff --git a/src/library-authoring/add-content/AddContentContainer.test.tsx b/src/library-authoring/add-content/AddContentContainer.test.tsx index 6db80f15b5..f0f721c393 100644 --- a/src/library-authoring/add-content/AddContentContainer.test.tsx +++ b/src/library-authoring/add-content/AddContentContainer.test.tsx @@ -1,77 +1,25 @@ -import React from 'react'; -import { initializeMockApp } from '@edx/frontend-platform'; -import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { - render, screen, fireEvent, waitFor, -} from '@testing-library/react'; -import { AppProvider } from '@edx/frontend-platform/react'; -import MockAdapter from 'axios-mock-adapter'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import AddContentContainer from './AddContentContainer'; -import initializeStore from '../../store'; + fireEvent, + render, + screen, + waitFor, + initializeMocks, +} from '../../testUtils'; +import { mockContentLibrary } from '../data/api.mocks'; import { getCreateLibraryBlockUrl, getLibraryPasteClipboardUrl } from '../data/api'; -import { getClipboardUrl } from '../../generic/data/api'; - -import { clipboardXBlock } from '../../__mocks__'; - -const mockUseParams = jest.fn(); -let axiosMock; - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts - useParams: () => mockUseParams(), -})); - -const libraryId = '1'; -let store; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, -}); - -const clipboardBroadcastChannelMock = { - postMessage: jest.fn(), - close: jest.fn(), -}; +import { mockBroadcastChannel, mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock'; +import AddContentContainer from './AddContentContainer'; -(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); +mockBroadcastChannel(); -const RootWrapper = () => ( - - - - - - - -); +const { libraryId } = mockContentLibrary; +const renderOpts = { path: '/library/:libraryId/*', params: { libraryId } }; describe('', () => { - beforeEach(() => { - initializeMockApp({ - authenticatedUser: { - userId: 3, - username: 'abc123', - administrator: true, - roles: [], - }, - }); - store = initializeStore(); - axiosMock = new MockAdapter(getAuthenticatedHttpClient()); - mockUseParams.mockReturnValue({ libraryId }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - it('should render content buttons', () => { - render(); + initializeMocks(); + mockClipboardEmpty.applyMock(); + render(); expect(screen.getByRole('button', { name: /collection/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /text/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /problem/i })).toBeInTheDocument(); @@ -83,10 +31,12 @@ describe('', () => { }); it('should create a content', async () => { + const { axiosMock } = initializeMocks(); + mockClipboardEmpty.applyMock(); const url = getCreateLibraryBlockUrl(libraryId); axiosMock.onPost(url).reply(200); - render(); + render(, renderOpts); const textButton = screen.getByRole('button', { name: /text/i }); fireEvent.click(textButton); @@ -95,47 +45,47 @@ describe('', () => { }); it('should render paste button if clipboard contains pastable xblock', async () => { - const url = getClipboardUrl(); - axiosMock.onGet(url).reply(200, clipboardXBlock); - - render(); - - await waitFor(() => expect(axiosMock.history.get[0].url).toEqual(url)); - - expect(screen.getByRole('button', { name: /paste from clipboard/i })).toBeInTheDocument(); + initializeMocks(); + // Simulate having an HTML block in the clipboard: + const getClipboardSpy = mockClipboardHtml.applyMock(); + const doc = render(, renderOpts); + expect(getClipboardSpy).toHaveBeenCalled(); // Hmm, this is getting called three times! Refactor to use react-query. + await waitFor(() => expect(doc.queryByRole('button', { name: /paste from clipboard/i })).toBeInTheDocument()); }); it('should paste content', async () => { - const clipboardUrl = getClipboardUrl(); - axiosMock.onGet(clipboardUrl).reply(200, clipboardXBlock); + const { axiosMock } = initializeMocks(); + // Simulate having an HTML block in the clipboard: + const getClipboardSpy = mockClipboardHtml.applyMock(); const pasteUrl = getLibraryPasteClipboardUrl(libraryId); axiosMock.onPost(pasteUrl).reply(200); - render(); + render(, renderOpts); - await waitFor(() => expect(axiosMock.history.get[0].url).toEqual(clipboardUrl)); + expect(getClipboardSpy).toHaveBeenCalled(); // Hmm, this is getting called four times! Refactor to use react-query. - const pasteButton = screen.getByRole('button', { name: /paste from clipboard/i }); + const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i }); fireEvent.click(pasteButton); await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl)); }); - it('should fail pasting content', async () => { - const clipboardUrl = getClipboardUrl(); - axiosMock.onGet(clipboardUrl).reply(200, clipboardXBlock); + it('should handle failure to paste content', async () => { + const { axiosMock } = initializeMocks(); + // Simulate having an HTML block in the clipboard: + mockClipboardHtml.applyMock(); const pasteUrl = getLibraryPasteClipboardUrl(libraryId); axiosMock.onPost(pasteUrl).reply(400); - render(); + render(, renderOpts); - await waitFor(() => expect(axiosMock.history.get[0].url).toEqual(clipboardUrl)); - - const pasteButton = screen.getByRole('button', { name: /paste from clipboard/i }); + const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i }); fireEvent.click(pasteButton); await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl)); + + // TODO: check that an actual error message is shown?! }); }); diff --git a/src/library-authoring/add-content/AddContentContainer.tsx b/src/library-authoring/add-content/AddContentContainer.tsx index 421c81be68..f13023dac3 100644 --- a/src/library-authoring/add-content/AddContentContainer.tsx +++ b/src/library-authoring/add-content/AddContentContainer.tsx @@ -16,16 +16,19 @@ import { ContentPaste, } from '@openedx/paragon/icons'; import { v4 as uuid4 } from 'uuid'; -import { useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; + import { ToastContext } from '../../generic/toast-context'; import { useCopyToClipboard } from '../../generic/clipboard'; import { getCanEdit } from '../../course-unit/data/selectors'; import { useCreateLibraryBlock, useLibraryPasteClipboard } from '../data/apiHooks'; +import { getEditUrl } from '../components/utils'; import messages from './messages'; const AddContentContainer = () => { const intl = useIntl(); + const navigate = useNavigate(); const { libraryId } = useParams(); const createBlockMutation = useCreateLibraryBlock(); const pasteClipboardMutation = useLibraryPasteClipboard(); @@ -100,8 +103,14 @@ const AddContentContainer = () => { libraryId, blockType, definitionId: `${uuid4()}`, - }).then(() => { - showToast(intl.formatMessage(messages.successCreateMessage)); + }).then((data) => { + const editUrl = getEditUrl(data.id); + if (editUrl) { + navigate(editUrl); + } else { + // We can't start editing this right away so just show a toast message: + showToast(intl.formatMessage(messages.successCreateMessage)); + } }).catch(() => { showToast(intl.formatMessage(messages.errorCreateMessage)); }); diff --git a/src/library-authoring/add-content/AddContentWorkflow.test.tsx b/src/library-authoring/add-content/AddContentWorkflow.test.tsx new file mode 100644 index 0000000000..5a77c92953 --- /dev/null +++ b/src/library-authoring/add-content/AddContentWorkflow.test.tsx @@ -0,0 +1,104 @@ +/** + * Test the whole workflow of adding content, editing it, saving it + */ +import { snakeCaseObject } from '@edx/frontend-platform'; +import { + fireEvent, + render, + waitFor, + screen, + initializeMocks, +} from '../../testUtils'; +import mockResult from '../__mocks__/library-search.json'; +import editorCmsApi from '../../editors/data/services/cms/api'; +import * as textEditorHooks from '../../editors/containers/TextEditor/hooks'; +import { + mockContentLibrary, + mockCreateLibraryBlock, + mockLibraryBlockTypes, + mockXBlockFields, +} from '../data/api.mocks'; +import { mockBroadcastChannel, mockClipboardEmpty } from '../../generic/data/api.mock'; +import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock'; +import LibraryLayout from '../LibraryLayout'; + +mockContentSearchConfig.applyMock(); +mockLibraryBlockTypes.applyMock(); +mockClipboardEmpty.applyMock(); +mockBroadcastChannel(); +mockContentLibrary.applyMock(); +mockCreateLibraryBlock.applyMock(); +mockSearchResult(mockResult); +// Mocking the redux APIs in the src/editors/ folder is a bit more involved: +jest.spyOn(editorCmsApi as any, 'fetchBlockById').mockImplementation( + async (args: { blockId: string }) => ( + { status: 200, data: snakeCaseObject(await mockXBlockFields(args.blockId)) } + ), +); +jest.spyOn(textEditorHooks, 'getContent').mockImplementation(() => () => '

Edited HTML content

'); +jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' })); + +const { libraryId } = mockContentLibrary; +const renderOpts = { + // Mount the on this route, to simulate how it's mounted in the real app: + path: '/library/:libraryId/*', + // And set the current URL to the following: + routerProps: { initialEntries: [`/library/${libraryId}/components`] }, +}; + +describe('AddContentWorkflow test', () => { + it('can create an HTML component', async () => { + initializeMocks(); + render(, renderOpts); + + // Click "New [Component]" + const newComponentButton = await screen.findByRole('button', { name: /New/ }); + fireEvent.click(newComponentButton); + + // Click "Text" to create a text component + fireEvent.click(await screen.findByRole('button', { name: /Text/ })); + + // Then the editor should open + expect(await screen.findByRole('heading', { name: /New Text Component/ })).toBeInTheDocument(); + + // Edit the title + fireEvent.click(screen.getByRole('button', { name: /Edit Title/ })); + const titleInput = screen.getByPlaceholderText('Title'); + fireEvent.change(titleInput, { target: { value: 'A customized title' } }); + fireEvent.blur(titleInput); + await waitFor(() => expect(screen.queryByRole('heading', { name: /New Text Component/ })).not.toBeInTheDocument()); + expect(screen.getByRole('heading', { name: /A customized title/ })); + + // Note that TinyMCE doesn't really load properly in our test environment + // so we can't really edit the text, but we have getContent() mocked to simulate + // using TinyMCE to enter some new HTML. + + // Mock the save() REST API method: + const saveSpy = jest.spyOn(editorCmsApi as any, 'saveBlock').mockImplementationOnce(async () => ({ + status: 200, data: { id: mockXBlockFields.usageKeyNewHtml }, + })); + + // Click Save + const saveButton = screen.getByLabelText('Save changes and return to learning context'); + fireEvent.click(saveButton); + expect(saveSpy).toHaveBeenCalledTimes(1); + }); + + it('can create a Problem component', async () => { + const { mockShowToast } = initializeMocks(); + render(, renderOpts); + + // Click "New [Component]" + const newComponentButton = await screen.findByRole('button', { name: /New/ }); + fireEvent.click(newComponentButton); + + // Pre-condition - this is NOT shown yet: + expect(screen.queryByText('Content created successfully.')).not.toBeInTheDocument(); + + // Click "Problem" to create a capa problem component + fireEvent.click(await screen.findByRole('button', { name: /Problem/ })); + + // We haven't yet implemented the problem editor, so we expect only a toast to appear + await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('Content created successfully.')); + }); +}); diff --git a/src/library-authoring/component-info/ComponentDeveloperInfo.tsx b/src/library-authoring/component-info/ComponentDeveloperInfo.tsx new file mode 100644 index 0000000000..430d9a7636 --- /dev/null +++ b/src/library-authoring/component-info/ComponentDeveloperInfo.tsx @@ -0,0 +1,34 @@ +/* istanbul ignore file */ +/* eslint-disable import/prefer-default-export */ +// This file doesn't need test coverage nor i18n because it's only seen by devs +import React from 'react'; +import { LoadingSpinner } from '../../generic/Loading'; +import { useXBlockOLX } from '../data/apiHooks'; + +interface Props { + usageKey: string; +} + +/* istanbul ignore next */ +export const ComponentDeveloperInfo: React.FC = ({ usageKey }) => { + const { data: olx, isLoading: isOLXLoading } = useXBlockOLX(usageKey); + return ( + <> +
+

Developer Component Details

+

(This panel is only visible in development builds.)

+
+
Usage key
+
{usageKey}
+
OLX
+
+ { + olx ? {olx} : // eslint-disable-line + isOLXLoading ? : // eslint-disable-line + Error + } +
+
+ + ); +}; diff --git a/src/library-authoring/component-info/ComponentInfo.tsx b/src/library-authoring/component-info/ComponentInfo.tsx index 4234722687..f17dbff3e4 100644 --- a/src/library-authoring/component-info/ComponentInfo.tsx +++ b/src/library-authoring/component-info/ComponentInfo.tsx @@ -6,8 +6,11 @@ import { Tabs, Stack, } from '@openedx/paragon'; +import { Link } from 'react-router-dom'; +import { getEditUrl } from '../components/utils'; import { ComponentMenu } from '../components'; +import { ComponentDeveloperInfo } from './ComponentDeveloperInfo'; import messages from './messages'; interface ComponentInfoProps { @@ -16,11 +19,16 @@ interface ComponentInfoProps { const ComponentInfo = ({ usageKey } : ComponentInfoProps) => { const intl = useIntl(); + const editUrl = getEditUrl(usageKey); return (
-