Skip to content

Commit 2930265

Browse files
authored
feat: new PDF XBlock editor built in to this Authoring MFE (#2916)
1 parent bce8843 commit 2930265

59 files changed

Lines changed: 1316 additions & 175 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"start:with-theme": "paragon install-theme && npm start && npm install",
2121
"dev": "PUBLIC_PATH=/authoring/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
2222
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests",
23+
"test:dev": "TZ=UTC fedx-scripts jest --coverage --watch --passWithNoTests",
2324
"test:ci": "TZ=UTC fedx-scripts jest --silent --coverage --passWithNoTests",
2425
"types": "tsc --noEmit"
2526
},

src/course-unit/__mocks__/courseSectionVertical.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ export default {
8282
tab: 'common',
8383
support_level: true,
8484
},
85+
{
86+
display_name: 'PDF',
87+
category: 'pdf',
88+
boilerplate_name: null,
89+
hinted: false,
90+
tab: 'common',
91+
support_level: true,
92+
},
8593
],
8694
display_name: 'Advanced',
8795
support_legend: {

src/course-unit/add-component/AddComponent.test.tsx

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
// oxlint-disable unicorn/no-useless-spread
22
/* eslint-disable react/prop-types */
3-
import userEvent from '@testing-library/user-event';
3+
import userEvent, { UserEvent } from '@testing-library/user-event';
44

5+
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
6+
import { RenderResult } from '@testing-library/react';
57
import {
68
act,
79
render,
@@ -319,6 +321,52 @@ describe('<AddComponent />', () => {
319321
});
320322
});
321323

324+
const createPdfBlock = async (
325+
{ getByRole, queryAllByRole, user }: {
326+
getByRole: RenderResult['getByRole']
327+
queryAllByRole: RenderResult['queryAllByRole'],
328+
user: UserEvent,
329+
},
330+
) => {
331+
const advancedBtn = getByRole('button', {
332+
name: new RegExp(`${messages.buttonText.defaultMessage} Advanced`, 'i'),
333+
});
334+
335+
await user.click(advancedBtn);
336+
337+
const dialog = getByRole('dialog');
338+
const pdfOption = within(dialog).getByLabelText('PDF');
339+
await user.click(pdfOption);
340+
const confirmation = within(dialog).getByText('Select');
341+
await user.click(confirmation);
342+
await waitFor(() => expect(queryAllByRole('dialog')).toEqual([]));
343+
};
344+
345+
it('adds a PDF block from the advanced selection in modal as an mfe-editable block', async () => {
346+
const user = userEvent.setup();
347+
const { getByRole, queryAllByRole } = renderComponent();
348+
await createPdfBlock({ getByRole, queryAllByRole, user });
349+
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled();
350+
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({
351+
parentLocator: '123',
352+
type: COMPONENT_TYPES.pdf,
353+
}, expect.any(Function));
354+
});
355+
356+
it('adds a PDF block and launches the legacy iframe editor', async () => {
357+
const user = userEvent.setup();
358+
mockWaffleFlags({ useNewPdfEditor: false });
359+
const { getByRole, queryAllByRole } = renderComponent();
360+
await createPdfBlock({ getByRole, queryAllByRole, user });
361+
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled();
362+
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({
363+
parentLocator: '123',
364+
type: COMPONENT_TYPES.pdf,
365+
// Setting the category and not supplying an additional function launches the traditional editor.
366+
category: COMPONENT_TYPES.pdf,
367+
});
368+
});
369+
322370
it('verifies "Text" component selection in modal', async () => {
323371
const user = userEvent.setup();
324372
const { getByRole, getByText } = renderComponent();

src/course-unit/add-component/AddComponent.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ const AddComponent = ({
8383
const [selectedComponents, setSelectedComponents] = useState<SelectedComponent[]>([]);
8484
const [usageId, setUsageId] = useState(null);
8585
const { sendMessageToIframe } = useIframe();
86-
const { useVideoGalleryFlow } = useWaffleFlags(courseId ?? undefined);
86+
const { useVideoGalleryFlow, useNewPdfEditor } = useWaffleFlags(courseId ?? undefined);
8787

8888
const courseUnit = useSelector(getCourseUnitData);
8989
const sequenceId = courseUnit?.ancestorInfo?.ancestors?.[0]?.id;
@@ -170,7 +170,28 @@ const AddComponent = ({
170170
showAddLibraryContentModal();
171171
break;
172172
case COMPONENT_TYPES.advanced:
173-
handleCreateNewCourseXBlock({ type: moduleName, category: moduleName, parentLocator: blockId });
173+
// TODO: The 'advanced components' concept warrants examination.
174+
// 'Advanced' is a bucket where we chuck all the blocks that are
175+
// uncommon, or third-party installs. Until now, none of these have
176+
// had special editors in this MFE. This is the first.
177+
// The fact that advanced modules are handled as a special category
178+
// *in code* and not just in UI seems like a mistake in retrospect.
179+
//
180+
// There will be more of these, and soon.
181+
if (moduleName === COMPONENT_TYPES.pdf && useNewPdfEditor) {
182+
handleCreateNewCourseXBlock(
183+
{ type: moduleName, parentLocator: blockId },
184+
/* istanbul ignore next */
185+
({ courseKey, locator }) => {
186+
setCourseId(courseKey);
187+
setBlockType(moduleName);
188+
setNewBlockId(locator);
189+
showXBlockEditorModal();
190+
},
191+
);
192+
} else {
193+
handleCreateNewCourseXBlock({ type: moduleName, category: moduleName, parentLocator: blockId });
194+
}
174195
break;
175196
case COMPONENT_TYPES.openassessment:
176197
handleCreateNewCourseXBlock({ boilerplate: moduleName, category: type, parentLocator: blockId });

src/data/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export const waffleFlagDefaults = {
8787
useNewUnitPage: false,
8888
useNewCertificatesPage: true,
8989
useNewTextbooksPage: true,
90+
useNewPdfEditor: true,
9091
useReactMarkdownEditor: true,
9192
useVideoGalleryFlow: false,
9293
enableAuthzCourseAuthoring: false,

src/editors/api.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/* Shared react-query hooks for editors. */
2+
import { useSelector } from 'react-redux';
3+
import { EditorState, selectors } from '@src/editors/data/redux';
4+
import { useEditorContext } from '@src/editors/EditorContext';
5+
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
6+
import { useMutation } from '@tanstack/react-query';
7+
import * as urls from '@src/editors/data/services/cms/urls';
8+
9+
export const useAssetUpload = ({ blockId, isLibrary }: { blockId: string, isLibrary: boolean }) => {
10+
const studioEndpointUrl = useSelector((state: EditorState) => selectors.app.studioEndpointUrl(state))!;
11+
const { learningContextId } = useEditorContext();
12+
const client = getAuthenticatedHttpClient();
13+
return useMutation({
14+
mutationFn: async (file: File) => {
15+
const data = new FormData();
16+
if (isLibrary) {
17+
data.append('content', file);
18+
return client.put(
19+
urls.libraryAssets({
20+
studioEndpointUrl, learningContextId, blockId, assetName: file.name,
21+
}),
22+
data,
23+
);
24+
}
25+
data.append('file', file);
26+
return client.post(
27+
urls.courseAssets({ studioEndpointUrl, learningContextId }),
28+
data,
29+
);
30+
},
31+
});
32+
};
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { useQuery } from '@tanstack/react-query';
2+
import { useSelector } from 'react-redux';
3+
import { selectors } from '@src/editors/data/redux';
4+
import { camelizeKeys } from '@src/editors/utils';
5+
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
6+
import type { Axios, AxiosResponse } from 'axios';
7+
import * as urls from '@src/editors/data/services/cms/urls';
8+
9+
interface UseBlockDataParams<T> {
10+
blockId: string,
11+
uniqueId: string,
12+
handlerName: string,
13+
defaultData: T,
14+
}
15+
16+
interface DeriveHandlerUrlParams {
17+
studioEndpointUrl: string,
18+
blockId: string,
19+
handlerName: string,
20+
isLibrary: boolean,
21+
client: Axios,
22+
}
23+
24+
export const immediate = <T>(val: T) => new Promise((resolve) => { resolve(val); });
25+
26+
const deriveHandlerUrl = async ({
27+
studioEndpointUrl, blockId, handlerName, isLibrary, client,
28+
}: DeriveHandlerUrlParams) => {
29+
if (isLibrary) {
30+
return client.get(urls.boundHandlerUrl({ studioEndpointUrl, blockId, handlerName })).then(
31+
(response: AxiosResponse<{ handler_url: string }>) => response.data.handler_url,
32+
);
33+
}
34+
return urls.handlerUrl({ blockId, studioEndpointUrl, handlerName });
35+
};
36+
37+
// Unique ID required due to intractable race conditions. See ./contexts.tsx file.
38+
export const useBlockHandlerData = <T>({
39+
blockId, uniqueId, handlerName, defaultData,
40+
}: UseBlockDataParams<T>) => {
41+
const studioEndpointUrl = useSelector(selectors.app.studioEndpointUrl)!;
42+
const isLibrary = useSelector(selectors.app.isLibrary);
43+
const client = getAuthenticatedHttpClient();
44+
return useQuery<T>({
45+
queryKey: ['blockHandlerData', blockId, uniqueId, handlerName],
46+
staleTime: Infinity,
47+
queryFn: async ({ signal }) => {
48+
if (!blockId) {
49+
// No blockId is set yet, so there's nothing to fetch.
50+
return immediate(defaultData);
51+
}
52+
return client.get(
53+
await deriveHandlerUrl({
54+
blockId, studioEndpointUrl, handlerName, isLibrary, client,
55+
}),
56+
{ cancelSource: signal },
57+
).then((res: AxiosResponse<unknown>) => camelizeKeys(res.data) as T);
58+
},
59+
});
60+
};
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { EditorComponent } from '@src/editors/EditorComponent';
2+
import { useFormikContext } from 'formik';
3+
import React, {
4+
PropsWithChildren, useContext, useEffect, useRef,
5+
} from 'react';
6+
import EditorContainer from '@src/editors/containers/EditorContainer';
7+
import { PdfBlockContext, PdfState } from '@src/editors/containers/PdfEditor/contexts';
8+
import { isEqual } from 'lodash';
9+
import DownloadOptions from '@src/editors/containers/PdfEditor/components/sections/DownloadOptions';
10+
import { UploadWidget } from '@src/editors/sharedComponents/UploadWidget';
11+
import { Spinner } from '@openedx/paragon';
12+
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
13+
import messages from './messages';
14+
15+
const EditorWrapper: React.FC<PropsWithChildren> = ({ children }) => {
16+
const intl = useIntl();
17+
const { isPending, fetchError } = useContext(PdfBlockContext);
18+
if (fetchError) {
19+
return (
20+
<div className="text-center p-6">
21+
<FormattedMessage {...messages.blockFailed} />
22+
</div>
23+
);
24+
}
25+
if (isPending) {
26+
return (
27+
<div className="text-center p-6">
28+
<Spinner
29+
animation="border"
30+
className="m-3"
31+
screenReaderText={intl.formatMessage(messages.blockLoading)}
32+
/>
33+
</div>
34+
);
35+
}
36+
return <>{children}</>; /* eslint-disable-line react/jsx-no-useless-fragment */
37+
};
38+
39+
const PdfEditingModal: React.FC<EditorComponent> = (props) => {
40+
const intl = useIntl();
41+
const { fields, blockId, isLibrary } = useContext(PdfBlockContext);
42+
const originalState = useRef({ ...fields });
43+
const { values, setValues } = useFormikContext<PdfState>();
44+
45+
useEffect(() => {
46+
// Form is initialized before we get these values, so we have to set them
47+
// when they arrive.
48+
void setValues(fields); // eslint-disable-line no-void
49+
}, [fields]);
50+
51+
const isDirty = () => isEqual(originalState, values);
52+
53+
const getContent = () => {
54+
const settings = { ...values };
55+
// disableAllDownload is not a setting we control, but a backend flag. Have to remove it or the
56+
// backend will reject.
57+
return Object.fromEntries(Object.entries(settings).filter(([key]) => key !== 'disableAllDownload'));
58+
};
59+
60+
return (
61+
<EditorContainer {...props} isDirty={isDirty} getContent={getContent}>
62+
<EditorWrapper>
63+
<div className="mt-2">
64+
<UploadWidget
65+
supportedFileFormats="application/pdf"
66+
urlFieldName="url"
67+
label={intl.formatMessage(messages.urlFieldLabel)}
68+
blockId={blockId}
69+
isLibrary={isLibrary}
70+
id="pdf-url"
71+
/>
72+
</div>
73+
<DownloadOptions />
74+
</EditorWrapper>
75+
</EditorContainer>
76+
);
77+
};
78+
79+
export default PdfEditingModal;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import React, { useContext } from 'react';
2+
import { PdfBlockContext } from '@src/editors/containers/PdfEditor/contexts';
3+
import { Formik } from 'formik';
4+
import { EditorComponent } from '@src/editors/EditorComponent';
5+
import PdfEditingModal from '@src/editors/containers/PdfEditor/components/PdfEditingModal';
6+
7+
const PdfEditorContainer: React.FC<EditorComponent> = (props) => {
8+
const { fields } = useContext(PdfBlockContext);
9+
return (
10+
<Formik initialValues={fields} onSubmit={() => undefined}>
11+
<PdfEditingModal {...props} />
12+
</Formik>
13+
);
14+
};
15+
16+
export default PdfEditorContainer;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { defineMessages } from '@edx/frontend-platform/i18n';
2+
3+
export default defineMessages({
4+
blockFailed: {
5+
id: 'authoring.pdfEditor.blockFailed',
6+
defaultMessage: 'PDF block failed to load',
7+
description: 'Error message for PDF block failing to load',
8+
},
9+
blockLoading: {
10+
id: 'authoring.pdfEditor.blockLoading',
11+
defaultMessage: 'Loading PDF Editor',
12+
description: 'Message shown to screen readers when the PDF block is loading.',
13+
},
14+
urlFieldLabel: {
15+
id: 'authoring.pdfEditor.urlFieldLabel',
16+
defaultMessage: 'File',
17+
description: 'Label for the PDF URL field',
18+
},
19+
});

0 commit comments

Comments
 (0)