feat: edit Text components within content libraries [FC-0062] (#1240)

This commit is contained in:
Braden MacDonald
2024-09-12 19:39:42 -07:00
committed by GitHub
parent 9b61037311
commit fd48fef299
36 changed files with 724 additions and 233 deletions

View File

@@ -88,7 +88,7 @@ const CourseAuthoringRoutes = () => {
/>
<Route
path="editor/:blockType/:blockId?"
element={<PageWrap><EditorContainer courseId={courseId} /></PageWrap>}
element={<PageWrap><EditorContainer learningContextId={courseId} /></PageWrap>}
/>
<Route
path="settings/details"

View File

@@ -1,28 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useParams } from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import EditorPage from './EditorPage';
const EditorContainer = ({
courseId,
}) => {
const { blockType, blockId } = useParams();
return (
<div className="editor-page">
<EditorPage
courseId={courseId}
blockType={blockType}
blockId={blockId}
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
lmsEndpointUrl={getConfig().LMS_BASE_URL}
/>
</div>
);
};
EditorContainer.propTypes = {
courseId: PropTypes.string.isRequired,
};
export default EditorContainer;

View File

@@ -10,7 +10,7 @@ jest.mock('react-router', () => ({
}),
}));
const props = { courseId: 'cOuRsEId' };
const props = { learningContextId: 'cOuRsEId' };
describe('Editor Container', () => {
describe('snapshots', () => {

View File

@@ -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<string, any>) => void;
}
const EditorContainer: React.FC<Props> = ({
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 <div>Error: missing URL parameters</div>;
}
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 (
<div className="editor-page">
<EditorPage
courseId={learningContextId}
blockType={blockType}
blockId={blockId}
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
lmsEndpointUrl={getConfig().LMS_BASE_URL}
onClose={onClose}
returnFunction={afterSave}
/>
</div>
);
};
export default EditorContainer;

View File

@@ -51,6 +51,7 @@ exports[`EditorContainer component render snapshot: initialized. enable save and
/>
</h2>
<IconButton
alt="Exit the editor"
iconAs="Icon"
onClick={[MockFunction openCancelConfirmModal]}
src={[MockFunction icons.Close]}
@@ -132,6 +133,7 @@ exports[`EditorContainer component render snapshot: not initialized. disable sav
/>
</h2>
<IconButton
alt="Exit the editor"
iconAs="Icon"
onClick={[MockFunction openCancelConfirmModal]}
src={[MockFunction icons.Close]}

View File

@@ -63,6 +63,7 @@ const EditorContainer = ({
src={Close}
iconAs={Icon}
onClick={openCancelConfirmModal}
alt={intl.formatMessage(messages.exitButtonAlt)}
/>
</div>
</ModalDialog.Header>

View File

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

View File

@@ -24,11 +24,7 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = `
onClose={[MockFunction hooks.nullMethod]}
show={true}
>
<FormattedMessage
defaultMessage="Error: Could Not Load Text Content"
description="Error Message Dispayed When HTML content fails to Load"
id="authoring.texteditor.load.error"
/>
Error: Could Not Load Text Content
</Toast>
<TinyMceWidget
disabled={false}
@@ -81,11 +77,7 @@ exports[`TextEditor snapshots loaded, raw editor 1`] = `
onClose={[MockFunction hooks.nullMethod]}
show={false}
>
<FormattedMessage
defaultMessage="Error: Could Not Load Text Content"
description="Error Message Dispayed When HTML content fails to Load"
id="authoring.texteditor.load.error"
/>
Error: Could Not Load Text Content
</Toast>
<RawEditor
content={
@@ -132,11 +124,7 @@ exports[`TextEditor snapshots not yet loaded, Spinner appears 1`] = `
onClose={[MockFunction hooks.nullMethod]}
show={false}
>
<FormattedMessage
defaultMessage="Error: Could Not Load Text Content"
description="Error Message Dispayed When HTML content fails to Load"
id="authoring.texteditor.load.error"
/>
Error: Could Not Load Text Content
</Toast>
<div
className="text-center p-6"
@@ -175,11 +163,7 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = `
onClose={[MockFunction hooks.nullMethod]}
show={false}
>
<FormattedMessage
defaultMessage="Error: Could Not Load Text Content"
description="Error Message Dispayed When HTML content fails to Load"
id="authoring.texteditor.load.error"
/>
Error: Could Not Load Text Content
</Toast>
<TinyMceWidget
disabled={false}
@@ -232,11 +216,7 @@ exports[`TextEditor snapshots renders static images with relative paths 1`] = `
onClose={[MockFunction hooks.nullMethod]}
show={false}
>
<FormattedMessage
defaultMessage="Error: Could Not Load Text Content"
description="Error Message Dispayed When HTML content fails to Load"
id="authoring.texteditor.load.error"
/>
Error: Could Not Load Text Content
</Toast>
<TinyMceWidget
disabled={false}

View File

@@ -6,7 +6,7 @@ import {
Spinner,
Toast,
} from '@openedx/paragon';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { actions, selectors } from '../../data/redux';
import { RequestKeys } from '../../data/constants/requests';
@@ -78,7 +78,7 @@ const TextEditor = ({
>
<div className="editor-body h-75 overflow-auto">
<Toast show={blockFailed} onClose={hooks.nullMethod}>
<FormattedMessage {...messages.couldNotLoadTextContext} />
{ intl.formatMessage(messages.couldNotLoadTextContext) }
</Toast>
{(!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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<api.ClipboardStatus> {
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<api.ClipboardStatus> {
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);
}

View File

@@ -47,10 +47,27 @@ export async function createOrRerunCourse(courseData: Object): Promise<unknown>
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<unknown> {
export async function getClipboard(): Promise<ClipboardStatus> {
const { data } = await getAuthenticatedHttpClient()
.get(getClipboardUrl());
@@ -60,7 +77,7 @@ export async function getClipboard(): Promise<unknown> {
/**
* Updates user's clipboard.
*/
export async function updateClipboard(usageKey: string): Promise<unknown> {
export async function updateClipboard(usageKey: string): Promise<ClipboardStatus> {
const { data } = await getAuthenticatedHttpClient()
.post(getClipboardUrl(), { usage_key: usageKey });

View File

@@ -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<ToastContextData>({
toastMessage: null,
showToast: () => {},
closeToast: () => {},
} as ToastContextData);
});
/**
* React component to provide `ToastContext` to the app

View File

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

View File

@@ -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('<LibraryAuthoringPage />', () => {
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('<LibraryAuthoringPage />', () => {
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');

View File

@@ -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 (
<div className="d-flex overflow-auto">
<div className="flex-grow-1 align-content-center">
<div className="d-flex">
<div className="flex-grow-1">
<Header
number={libraryData.slug}
title={libraryData.title}

View File

@@ -1,11 +1,56 @@
import React from 'react';
import {
Route,
Routes,
useNavigate,
useParams,
} from 'react-router-dom';
import { PageWrap } from '@edx/frontend-platform/react';
import { useQueryClient } from '@tanstack/react-query';
import EditorContainer from '../editors/EditorContainer';
import LibraryAuthoringPage from './LibraryAuthoringPage';
import { LibraryProvider } from './common/context';
import { invalidateComponentData } from './data/apiHooks';
const LibraryLayout = () => (
<LibraryProvider>
<LibraryAuthoringPage />
</LibraryProvider>
);
const LibraryLayout = () => {
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);
};
}, []);
return (
<LibraryProvider>
<Routes>
<Route
path="editor/:blockType/:blockId?"
element={(
<PageWrap>
<EditorContainer learningContextId={libraryId} onClose={goBack} afterSave={goBack} />
</PageWrap>
)}
/>
<Route
path="*"
element={<LibraryAuthoringPage />}
/>
</Routes>
</LibraryProvider>
);
};
export default LibraryLayout;

View File

@@ -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 { mockBroadcastChannel, mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock';
import AddContentContainer from './AddContentContainer';
import { clipboardXBlock } from '../../__mocks__';
mockBroadcastChannel();
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(),
};
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<AddContentContainer />
</QueryClientProvider>
</IntlProvider>
</AppProvider>
);
const { libraryId } = mockContentLibrary;
const renderOpts = { path: '/library/:libraryId/*', params: { libraryId } };
describe('<AddContentContainer />', () => {
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(<RootWrapper />);
initializeMocks();
mockClipboardEmpty.applyMock();
render(<AddContentContainer />);
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('<AddContentContainer />', () => {
});
it('should create a content', async () => {
const { axiosMock } = initializeMocks();
mockClipboardEmpty.applyMock();
const url = getCreateLibraryBlockUrl(libraryId);
axiosMock.onPost(url).reply(200);
render(<RootWrapper />);
render(<AddContentContainer />, renderOpts);
const textButton = screen.getByRole('button', { name: /text/i });
fireEvent.click(textButton);
@@ -95,47 +45,47 @@ describe('<AddContentContainer />', () => {
});
it('should render paste button if clipboard contains pastable xblock', async () => {
const url = getClipboardUrl();
axiosMock.onGet(url).reply(200, clipboardXBlock);
render(<RootWrapper />);
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(<AddContentContainer />, 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(<RootWrapper />);
render(<AddContentContainer />, 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(<RootWrapper />);
render(<AddContentContainer />, 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?!
});
});

View File

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

View File

@@ -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(() => () => '<p>Edited HTML content</p>');
jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' }));
const { libraryId } = mockContentLibrary;
const renderOpts = {
// Mount the <LibraryLayout /> 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(<LibraryLayout />, 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(<LibraryLayout />, 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.'));
});
});

View File

@@ -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<Props> = ({ usageKey }) => {
const { data: olx, isLoading: isOLXLoading } = useXBlockOLX(usageKey);
return (
<>
<hr />
<h3 className="h5">Developer Component Details</h3>
<p><small>(This panel is only visible in development builds.)</small></p>
<dl>
<dt>Usage key</dt>
<dd><code>{usageKey}</code></dd>
<dt>OLX</dt>
<dd>
{
olx ? <code className="micro">{olx}</code> : // eslint-disable-line
isOLXLoading ? <LoadingSpinner /> : // eslint-disable-line
<span>Error</span>
}
</dd>
</dl>
</>
);
};

View File

@@ -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 (
<Stack>
<div className="d-flex flex-wrap">
<Button disabled variant="outline-primary" className="m-1 text-nowrap flex-grow-1">
<Button
{...(editUrl ? { as: Link, to: editUrl } : { disabled: true, to: '#' })}
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
>
{intl.formatMessage(messages.editComponentButtonTitle)}
</Button>
<Button disabled variant="outline-primary" className="m-1 text-nowrap flex-grow-1">
@@ -41,6 +49,10 @@ const ComponentInfo = ({ usageKey } : ComponentInfoProps) => {
</Tab>
<Tab eventKey="details" title={intl.formatMessage(messages.detailsTabTitle)}>
Details tab placeholder
{
(process.env.NODE_ENV === 'development' ? <ComponentDeveloperInfo usageKey={usageKey} /> : null)
}
</Tab>
</Tabs>
</Stack>

View File

@@ -24,9 +24,9 @@ const ComponentInfoHeader = ({ library, usageKey }: ComponentInfoHeaderProps) =>
const {
data: xblockFields,
} = useXBlockFields(library.id, usageKey);
} = useXBlockFields(usageKey);
const updateMutation = useUpdateXBlockFields(library.id, usageKey);
const updateMutation = useUpdateXBlockFields(usageKey);
const { showToast } = useContext(ToastContext);
const handleSaveDisplayName = useCallback(

View File

@@ -1,5 +1,5 @@
import React, { useContext, useState } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Icon,
@@ -7,6 +7,7 @@ import {
Dropdown,
} from '@openedx/paragon';
import { MoreVert } from '@openedx/paragon/icons';
import { Link } from 'react-router-dom';
import { updateClipboard } from '../../generic/data/api';
import { ToastContext } from '../../generic/toast-context';
@@ -14,6 +15,7 @@ import { type ContentHit } from '../../search-manager';
import { LibraryContext } from '../common/context';
import messages from './messages';
import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants';
import { getEditUrl } from './utils';
import BaseComponentCard from './BaseComponentCard';
type ComponentCardProps = {
@@ -23,6 +25,7 @@ type ComponentCardProps = {
export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
const intl = useIntl();
const editUrl = usageKey && getEditUrl(usageKey);
const { showToast } = useContext(ToastContext);
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
const updateClipboardClick = () => {
@@ -46,14 +49,14 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
data-testid="component-card-menu-toggle"
/>
<Dropdown.Menu>
<Dropdown.Item disabled>
{intl.formatMessage(messages.menuEdit)}
<Dropdown.Item {...(editUrl ? { as: Link, to: editUrl } : { disabled: true, to: '#' })}>
<FormattedMessage {...messages.menuEdit} />
</Dropdown.Item>
<Dropdown.Item onClick={updateClipboardClick}>
{intl.formatMessage(messages.menuCopyToClipboard)}
<FormattedMessage {...messages.menuCopyToClipboard} />
</Dropdown.Item>
<Dropdown.Item disabled>
{intl.formatMessage(messages.menuAddToCollection)}
<FormattedMessage {...messages.menuAddToCollection} />
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>

View File

@@ -0,0 +1,63 @@
import { getBlockType, getEditUrl, getLibraryId } from './utils';
describe('component utils', () => {
describe('getBlockType', () => {
for (const [input, expected] of [
['lb:org:lib:html:id', 'html'],
['lb:OpenCraftX:ALPHA:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'html'],
['lb:Axim:beta:problem:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'problem'],
]) {
it(`returns '${expected}' for usage key '${input}'`, () => {
expect(getBlockType(input)).toStrictEqual(expected);
});
}
for (const input of ['', undefined, null, 'not a key', 'lb:foo']) {
it(`throws an exception for usage key '${input}'`, () => {
expect(() => getBlockType(input as any)).toThrow(`Invalid usageKey: ${input}`);
});
}
});
describe('getLibraryId', () => {
for (const [input, expected] of [
['lb:org:lib:html:id', 'lib:org:lib'],
['lb:OpenCraftX:ALPHA:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'lib:OpenCraftX:ALPHA'],
['lb:Axim:beta:problem:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'lib:Axim:beta'],
]) {
it(`returns '${expected}' for usage key '${input}'`, () => {
expect(getLibraryId(input)).toStrictEqual(expected);
});
}
for (const input of ['', undefined, null, 'not a key', 'lb:foo']) {
it(`throws an exception for usage key '${input}'`, () => {
expect(() => getLibraryId(input as any)).toThrow(`Invalid usageKey: ${input}`);
});
}
});
describe('getEditUrl', () => {
it('returns the right URL for an HTML (Text) block', () => {
const usageKey = 'lb:org:ALPHA:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
expect(getEditUrl(usageKey)).toStrictEqual(`/library/lib:org:ALPHA/editor/html/${usageKey}`);
});
it('doesn\'t yet allow editing a problem block', () => {
const usageKey = 'lb:org:beta:problem:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
expect(getEditUrl(usageKey)).toBeUndefined();
});
it('doesn\'t yet allow editing a video block', () => {
const usageKey = 'lb:org:beta:video:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
expect(getEditUrl(usageKey)).toBeUndefined();
});
it('doesn\'t yet allow editing a drag-and-drop-v2 block', () => {
const usageKey = 'lb:org:beta:drag-and-drop-v2:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
expect(getEditUrl(usageKey)).toBeUndefined();
});
it('returns undefined for an invalid key', () => {
expect(getEditUrl('foobar')).toBeUndefined();
expect(getEditUrl('')).toBeUndefined();
expect(getEditUrl('lb:unknown:unknown:unknown')).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,47 @@
/**
* Given a usage key like `lb:org:lib:html:id`, get the type (e.g. `html`)
* @param usageKey e.g. `lb:org:lib:html:id`
* @returns The block type as a string
*/
export function getBlockType(usageKey: string): string {
if (usageKey && usageKey.startsWith('lb:')) {
const blockType = usageKey.split(':')[3];
if (blockType) {
return blockType;
}
}
throw new Error(`Invalid usageKey: ${usageKey}`);
}
/**
* Given a usage key like `lb:org:lib:html:id`, get the library key
* @param usageKey e.g. `lb:org:lib:html:id`
* @returns The library key, e.g. `lib:org:lib`
*/
export function getLibraryId(usageKey: string): string {
if (usageKey && usageKey.startsWith('lb:')) {
const org = usageKey.split(':')[1];
const lib = usageKey.split(':')[2];
if (org && lib) {
return `lib:${org}:${lib}`;
}
}
throw new Error(`Invalid usageKey: ${usageKey}`);
}
export function getEditUrl(usageKey: string): string | undefined {
let blockType: string;
let libraryId: string;
try {
blockType = getBlockType(usageKey);
libraryId = getLibraryId(usageKey);
} catch {
return undefined;
}
const mfeEditorTypes = ['html'];
if (mfeEditorTypes.includes(blockType)) {
return `/library/${libraryId}/editor/${blockType}/${usageKey}`;
}
return undefined;
}

View File

@@ -104,7 +104,42 @@ mockContentLibrary.libraryIdReadOnly = 'lib:Axim:readOnly';
mockContentLibrary.libraryIdThatNeverLoads = 'lib:Axim:infiniteLoading';
mockContentLibrary.library404 = 'lib:Axim:error404';
mockContentLibrary.library500 = 'lib:Axim:error500';
mockContentLibrary.applyMock = () => { jest.spyOn(api, 'getContentLibrary').mockImplementation(mockContentLibrary); };
mockContentLibrary.applyMock = () => jest.spyOn(api, 'getContentLibrary').mockImplementation(mockContentLibrary);
/**
* Mock for `createLibraryBlock()`
*/
export async function mockCreateLibraryBlock(
args: api.CreateBlockDataRequest,
): ReturnType<typeof api.createLibraryBlock> {
if (args.blockType === 'html' && args.libraryId === mockContentLibrary.libraryId) {
return mockCreateLibraryBlock.newHtmlData;
}
if (args.blockType === 'problem' && args.libraryId === mockContentLibrary.libraryId) {
return mockCreateLibraryBlock.newProblemData;
}
throw new Error(`mockCreateLibraryBlock doesn't know how to mock ${JSON.stringify(args)}`);
}
mockCreateLibraryBlock.newHtmlData = {
id: 'lb:Axim:TEST:html:123',
defKey: '123',
blockType: 'html',
displayName: 'New Text Component',
hasUnpublishedChanges: true,
tagsCount: 0,
} satisfies api.CreateBlockDataResponse;
mockCreateLibraryBlock.newProblemData = {
id: 'lb:Axim:TEST:problem:prob1',
defKey: 'prob1',
blockType: 'problem',
displayName: 'New Problem',
hasUnpublishedChanges: true,
tagsCount: 0,
} satisfies api.CreateBlockDataResponse;
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockCreateLibraryBlock.applyMock = () => (
jest.spyOn(api, 'createLibraryBlock').mockImplementation(mockCreateLibraryBlock)
);
/**
* Mock for `getXBlockFields()`
@@ -117,13 +152,23 @@ export async function mockXBlockFields(usageKey: string): Promise<api.XBlockFiel
const thisMock = mockXBlockFields;
switch (usageKey) {
case thisMock.usageKeyHtml: return thisMock.dataHtml;
case thisMock.usageKeyNewHtml: return thisMock.dataNewHtml;
default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`);
}
}
// Mock of a "regular" HTML (Text) block:
mockXBlockFields.usageKeyHtml = 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
mockXBlockFields.dataHtml = {
displayName: 'Introduction to Testing',
data: '<p>This is a text component which uses <strong>HTML</strong>.</p>',
metadata: { displayName: 'Introduction to Testing' },
} satisfies api.XBlockFields;
mockXBlockFields.applyMock = () => { jest.spyOn(api, 'getXBlockFields').mockImplementation(mockXBlockFields); };
// Mock of a blank/new HTML (Text) block:
mockXBlockFields.usageKeyNewHtml = 'lb:Axim:TEST:html:123';
mockXBlockFields.dataNewHtml = {
displayName: 'New Text Component',
data: '',
metadata: { displayName: 'New Text Component' },
} satisfies api.XBlockFields;
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockXBlockFields.applyMock = () => jest.spyOn(api, 'getXBlockFields').mockImplementation(mockXBlockFields);

View File

@@ -25,9 +25,13 @@ export const getCommitLibraryChangesUrl = (libraryId: string) => `${getApiBaseUr
*/
export const getLibraryPasteClipboardUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/paste_clipboard/`;
/**
* Get the URL for the xblock metadata API.
* Get the URL for the xblock fields/metadata API.
*/
export const getXBlockFieldsApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/xblock/v2/xblocks/${usageKey}/fields/`;
/**
* Get the URL for the xblock OLX API
*/
export const getXBlockOLXApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/libraries/v2/blocks/${usageKey}/olx/`;
export interface ContentLibrary {
id: string;
@@ -156,7 +160,7 @@ export async function createLibraryBlock({
definition_id: definitionId,
},
);
return data;
return camelCaseObject(data);
}
/**
@@ -236,3 +240,11 @@ export async function updateXBlockFields(usageKey:string, xblockData: UpdateXBlo
const client = getAuthenticatedHttpClient();
await client.post(getXBlockFieldsApiUrl(usageKey), xblockData);
}
/**
* Fetch the OLX for the given XBlock.
*/
export async function getXBlockOLX(usageKey: string): Promise<string> {
const { data } = await getAuthenticatedHttpClient().get(getXBlockOLXApiUrl(usageKey));
return data.olx;
}

View File

@@ -1,8 +1,13 @@
import { camelCaseObject } from '@edx/frontend-platform';
import {
useQuery, useMutation, useQueryClient, type Query,
useQuery,
useMutation,
useQueryClient,
type Query,
type QueryClient,
} from '@tanstack/react-query';
import { getLibraryId } from '../components/utils';
import {
type GetLibrariesV2CustomParams,
type ContentLibrary,
@@ -18,6 +23,7 @@ import {
libraryPasteClipboard,
getXBlockFields,
updateXBlockFields,
getXBlockOLX,
} from './api';
const libraryQueryPredicate = (query: Query, libraryId: string): boolean => {
@@ -52,15 +58,36 @@ export const libraryAuthoringQueryKeys = {
'content',
'libraryBlockTypes',
],
xblockFields: (contentLibraryId: string, usageKey: string) => [
...libraryAuthoringQueryKeys.all,
...libraryAuthoringQueryKeys.contentLibrary(contentLibraryId),
'content',
'xblockFields',
usageKey,
],
};
export const xblockQueryKeys = {
all: ['xblock'],
/**
* Base key for data specific to a xblock
*/
xblock: (usageKey?: string) => [...xblockQueryKeys.all, usageKey],
/** Fields (i.e. the content, display name, etc.) of an XBlock */
xblockFields: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'fields'],
/** OLX (XML representation of the fields/content) */
xblockOLX: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'OLX'],
};
/**
* Tell react-query to refresh its cache of any data related to the given
* component (XBlock).
*
* Note that technically it's possible to derive the library key from the
* usageKey, so we could refactor this to only require the usageKey.
*
* @param queryClient The query client - get it via useQueryClient()
* @param contentLibraryId The ID of library that holds the XBlock ("lib:...")
* @param usageKey The usage ID of the XBlock ("lb:...")
*/
export function invalidateComponentData(queryClient: QueryClient, contentLibraryId: string, usageKey: string) {
queryClient.invalidateQueries({ queryKey: xblockQueryKeys.xblockFields(usageKey) });
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, contentLibraryId) });
}
/**
* Hook to fetch a content library by its ID.
*/
@@ -168,20 +195,21 @@ export const useLibraryPasteClipboard = () => {
});
};
export const useXBlockFields = (contentLibrayId: string, usageKey: string) => (
export const useXBlockFields = (usageKey: string) => (
useQuery({
queryKey: libraryAuthoringQueryKeys.xblockFields(contentLibrayId, usageKey),
queryKey: xblockQueryKeys.xblockFields(usageKey),
queryFn: () => getXBlockFields(usageKey),
enabled: !!usageKey,
})
);
export const useUpdateXBlockFields = (contentLibraryId: string, usageKey: string) => {
export const useUpdateXBlockFields = (usageKey: string) => {
const contentLibraryId = getLibraryId(usageKey);
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateXBlockFieldsRequest) => updateXBlockFields(usageKey, data),
onMutate: async (data) => {
const queryKey = libraryAuthoringQueryKeys.xblockFields(contentLibraryId, usageKey);
const queryKey = xblockQueryKeys.xblockFields(usageKey);
const previousBlockData = queryClient.getQueriesData(queryKey)[0][1] as XBlockFields;
const formatedData = camelCaseObject(data);
@@ -200,13 +228,21 @@ export const useUpdateXBlockFields = (contentLibraryId: string, usageKey: string
},
onError: (_err, _data, context) => {
queryClient.setQueryData(
libraryAuthoringQueryKeys.xblockFields(contentLibraryId, usageKey),
xblockQueryKeys.xblockFields(usageKey),
context?.previousBlockData,
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.xblockFields(contentLibraryId, usageKey) });
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, contentLibraryId) });
invalidateComponentData(queryClient, contentLibraryId, usageKey);
},
});
};
/* istanbul ignore next */ // This is only used in developer builds, and the associated UI doesn't work in test or prod
export const useXBlockOLX = (usageKey: string) => (
useQuery({
queryKey: xblockQueryKeys.xblockOLX(usageKey),
queryFn: () => getXBlockOLX(usageKey),
enabled: !!usageKey,
})
);

View File

@@ -0,0 +1,42 @@
/* istanbul ignore file */
// eslint-disable-next-line import/no-extraneous-dependencies
import fetchMock from 'fetch-mock-jest';
import type { MultiSearchResponse } from 'meilisearch';
import * as api from './api';
/**
* Mock getContentSearchConfig()
*/
export async function mockContentSearchConfig(): ReturnType<typeof api.getContentSearchConfig> {
return {
url: 'http://mock.meilisearch.local',
indexName: 'studio',
apiKey: 'test-key',
};
}
mockContentSearchConfig.searchEndpointUrl = 'http://mock.meilisearch.local/multi-search';
mockContentSearchConfig.applyMock = () => (
jest.spyOn(api, 'getContentSearchConfig').mockImplementation(mockContentSearchConfig)
);
/**
* Mock all future Meilisearch searches with the given response.
*
* For a given test suite, this mock will stay in effect until you call it with
* a different mock response, or you call `fetchMock.mockReset()`
*/
export function mockSearchResult(mockResponse: MultiSearchResponse) {
fetchMock.post(mockContentSearchConfig.searchEndpointUrl, (_url, 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 Instantsearch will update the UI and change the query,
// leading to unexpected results in the test cases.
const newMockResponse = { ...mockResponse };
newMockResponse.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
mockResponse.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
return newMockResponse;
}, { overwriteRoutes: true });
}

View File

@@ -6,6 +6,7 @@
*/
import React from 'react';
import { AxiosError } from 'axios';
import { jest } from '@jest/globals';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
@@ -20,6 +21,7 @@ import {
Routes,
} from 'react-router-dom';
import { ToastContext, type ToastContextData } from './generic/toast-context';
import initializeReduxStore from './store';
/** @deprecated Use React Query and/or regular React Context instead of redux */
@@ -27,6 +29,13 @@ let reduxStore;
let queryClient;
let axiosMock: MockAdapter;
/** To use this: `const { mockShowToast } = initializeMocks()` and `expect(mockShowToast).toHaveBeenCalled()` */
let mockToastContext: ToastContextData = {
showToast: jest.fn(),
closeToast: jest.fn(),
toastMessage: null,
};
export interface RouteOptions {
/** The URL path, like '/libraries/:libraryId' */
path?: string;
@@ -106,9 +115,11 @@ function makeWrapper({ ...routeArgs }: RouteOptions) {
<AppProvider store={reduxStore} wrapWithRouter={false}>
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<RouterAndRoute {...routeArgs}>
{children}
</RouterAndRoute>
<ToastContext.Provider value={mockToastContext}>
<RouterAndRoute {...routeArgs}>
{children}
</RouterAndRoute>
</ToastContext.Provider>
</QueryClientProvider>
</IntlProvider>
</AppProvider>
@@ -152,9 +163,17 @@ export function initializeMocks({ user = defaultUser } = {}) {
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
// Reset `mockToastContext` for this current test
mockToastContext = {
showToast: jest.fn(),
closeToast: jest.fn(),
toastMessage: null,
};
return {
reduxStore,
axiosMock,
mockShowToast: mockToastContext.showToast,
};
}