Skip to content

Commit

Permalink
feat: Open Editors in a Modal (library components only) [FC-0062] (#1357
Browse files Browse the repository at this point in the history
)

* feat: allow opening editors in modals

* refactor: add an EditorContext

* test: update tests accordingly

* test: make testUtils call clearAllMocks() automatically :)
  • Loading branch information
bradenmacdonald authored Oct 8, 2024
1 parent 83322e2 commit 8c125df
Show file tree
Hide file tree
Showing 51 changed files with 845 additions and 1,220 deletions.
4 changes: 0 additions & 4 deletions src/content-tags-drawer/ContentTagsDrawer.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,6 @@ describe('<ContentTagsDrawer />', () => {
initializeMocks();
});

afterEach(() => {
jest.clearAllMocks();
});

it('should render page and page title correctly', () => {
renderDrawer(stagedTagsId);
expect(screen.getByText('Manage tags')).toBeInTheDocument();
Expand Down
55 changes: 0 additions & 55 deletions src/editors/Editor.test.jsx

This file was deleted.

34 changes: 22 additions & 12 deletions src/editors/Editor.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Note: there is no Editor.test.tsx. This component only works together with
// <EditorPage> as its parent, so they are tested together in EditorPage.test.tsx
import React from 'react';
import { useDispatch } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
Expand All @@ -7,13 +9,15 @@ import * as hooks from './hooks';

import supportedEditors from './supportedEditors';
import type { EditorComponent } from './EditorComponent';
import { useEditorContext } from './EditorContext';

export interface Props extends EditorComponent {
blockType: string;
blockId: string | null;
learningContextId: string | null;
lmsEndpointUrl: string | null;
studioEndpointUrl: string | null;
fullScreen?: boolean;
}

const Editor: React.FC<Props> = ({
Expand All @@ -36,23 +40,29 @@ const Editor: React.FC<Props> = ({
studioEndpointUrl,
},
});
const { fullScreen } = useEditorContext();

const EditorComponent = supportedEditors[blockType];
return (
<div
className="d-flex flex-column"
>
const innerEditor = (EditorComponent !== undefined)
? <EditorComponent {...{ onClose, returnFunction }} />
: <FormattedMessage {...messages.couldNotFindEditor} />;

if (fullScreen) {
return (
<div
className="pgn__modal-fullscreen h-100"
role="dialog"
aria-label={blockType}
className="d-flex flex-column"
>
{(EditorComponent !== undefined)
? <EditorComponent {...{ onClose, returnFunction }} />
: <FormattedMessage {...messages.couldNotFindEditor} />}
<div
className="pgn__modal-fullscreen h-100"
role="dialog"
aria-label={blockType}
>
{innerEditor}
</div>
</div>
</div>
);
);
}
return innerEditor;
};

export default Editor;
39 changes: 39 additions & 0 deletions src/editors/EditorContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import React from 'react';

/**
* Shared context that's used by all our editors.
*
* Note: we're in the process of moving things from redux into this.
*/
export interface EditorContext {
learningContextId: string;
/**
* When editing components in the libraries part of the Authoring MFE, we show
* the editors in a modal (fullScreen = false). This is the preferred approach
* so that authors can see context behind the modal.
* However, when making edits from the legacy course view, we display the
* editors in a fullscreen view. This approach is deprecated.
*/
fullScreen: boolean;
}

const context = React.createContext<EditorContext | undefined>(undefined);

/** Hook to get the editor context (shared context) */
export function useEditorContext() {
const ctx = React.useContext(context);
if (ctx === undefined) {
/* istanbul ignore next */
throw new Error('This component needs to be wrapped in <EditorContextProvider>');
}
return ctx;
}

export const EditorContextProvider: React.FC<{
children: React.ReactNode,
learningContextId: string;
fullScreen: boolean;
}> = ({ children, ...contextData }) => {
const ctx: EditorContext = React.useMemo(() => ({ ...contextData }), []);
return <context.Provider value={ctx}>{children}</context.Provider>;
};
58 changes: 0 additions & 58 deletions src/editors/EditorPage.jsx

This file was deleted.

32 changes: 0 additions & 32 deletions src/editors/EditorPage.test.jsx

This file was deleted.

98 changes: 98 additions & 0 deletions src/editors/EditorPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { snakeCaseObject } from '@edx/frontend-platform';
import {
render,
screen,
initializeMocks,
} from '../testUtils';
import editorCmsApi from './data/services/cms/api';

import EditorPage from './EditorPage';

// Mock this plugins component:
jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' }));
// Always mock out the "fetch course images" endpoint:
jest.spyOn(editorCmsApi, 'fetchImages').mockImplementation(async () => ( // eslint-disable-next-line
{ data: { assets: [], start: 0, end: 0, page: 0, pageSize: 50, totalCount: 0 } }
));
// Mock out the 'get ancestors' API:
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,
}],
},
}));

const defaultPropsHtml = {
blockId: 'block-v1:Org+TS100+24+type@html+block@123456html',
blockType: 'html',
courseId: 'course-v1:Org+TS100+24',
lmsEndpointUrl: 'http://lms.test.none/',
studioEndpointUrl: 'http://cms.test.none/',
onClose: jest.fn(),
fullScreen: false,
};
const fieldsHtml = {
displayName: 'Introduction to Testing',
data: '<p>This is a text component which uses <strong>HTML</strong>.</p>',
metadata: { displayName: 'Introduction to Testing' },
};

describe('EditorPage', () => {
beforeEach(() => {
initializeMocks();
});

test('it can display the Text (html) editor in a modal', async () => {
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => (
{ status: 200, data: snakeCaseObject(fieldsHtml) }
));

render(<EditorPage {...defaultPropsHtml} />);

// Then the editor should open
expect(await screen.findByRole('heading', { name: /Introduction to Testing/ })).toBeInTheDocument();

const modalElement = screen.getByRole('dialog');
expect(modalElement.classList).toContain('pgn__modal');
expect(modalElement.classList).toContain('pgn__modal-xl');
expect(modalElement.classList).not.toContain('pgn__modal-fullscreen');
});

test('it can display the Text (html) editor as a full page (when coming from the legacy UI)', async () => {
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => (
{ status: 200, data: snakeCaseObject(fieldsHtml) }
));

render(<EditorPage {...defaultPropsHtml} fullScreen />);

// Then the editor should open
expect(await screen.findByRole('heading', { name: /Introduction to Testing/ })).toBeInTheDocument();

const modalElement = screen.getByRole('dialog');
expect(modalElement.classList).toContain('pgn__modal-fullscreen');
expect(modalElement.classList).not.toContain('pgn__modal');
expect(modalElement.classList).not.toContain('pgn__modal-xl');
});

test('it shows an error message if there is no corresponding editor', async () => {
// We can edit 'html', 'problem', and 'video' blocks.
// But if we try to edit some other type, say 'fake', we should get an error:
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => ( // eslint-disable-next-line
{ status: 200, data: { display_name: 'Fake Un-editable Block', category: 'fake', metadata: {}, data: '' } }
));

const defaultPropsFake = {
...defaultPropsHtml,
blockId: 'block-v1:Org+TS100+24+type@fake+block@123456fake',
blockType: 'fake',
};
render(<EditorPage {...defaultPropsFake} />);

expect(await screen.findByText('Error: Could Not find Editor')).toBeInTheDocument();
});
});
Loading

0 comments on commit 8c125df

Please sign in to comment.