Skip to content

Commit 2e47927

Browse files
committed
feat: pdf authoring
1 parent 4d7d91b commit 2e47927

File tree

58 files changed

+1241
-139
lines changed

Some content is hidden

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

58 files changed

+1241
-139
lines changed

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: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
/* eslint-disable react/prop-types */
33
import userEvent from '@testing-library/user-event';
44

5+
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
56
import {
67
act,
78
render,
@@ -319,6 +320,52 @@ describe('<AddComponent />', () => {
319320
});
320321
});
321322

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

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

Lines changed: 19 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,24 @@ 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({ type: moduleName, parentLocator: blockId }, ({ courseKey, locator }) => {
183+
setCourseId(courseKey);
184+
setBlockType(moduleName);
185+
setNewBlockId(locator);
186+
showXBlockEditorModal();
187+
});
188+
} else {
189+
handleCreateNewCourseXBlock({ type: moduleName, category: moduleName, parentLocator: blockId });
190+
}
174191
break;
175192
case COMPONENT_TYPES.openassessment:
176193
handleCreateNewCourseXBlock({ boilerplate: moduleName, category: type, parentLocator: blockId });

src/course-unit/xblock-container-iframe/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getConfig } from '@edx/frontend-platform';
22
import {
3-
FC, useEffect, useState, useMemo, useCallback,
3+
FC, useEffect, useState, useMemo, useCallback, Fragment,
44
} from 'react';
55
import { useIntl } from '@edx/frontend-platform/i18n';
66
import { useToggle, Sheet, StandardModal } from '@openedx/paragon';

src/data/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export const waffleFlagDefaults = {
9090
useNewCertificatesPage: true,
9191
useNewTextbooksPage: true,
9292
useNewGroupConfigurationsPage: true,
93+
useNewPdfEditor: true,
9394
useReactMarkdownEditor: true,
9495
useVideoGalleryFlow: false,
9596
enableAuthzCourseAuthoring: false,

src/editors/api.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
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 useCourseAssetUpload = () => {
10+
const studioEndpointUrl = useSelector((state: EditorState) => selectors.app.studioEndpointUrl(state))!;
11+
const { learningContextId } = useEditorContext();
12+
const client = getAuthenticatedHttpClient();
13+
return useMutation({
14+
mutationFn: (file: File) => {
15+
const data = new FormData();
16+
data.append('file', file);
17+
return client.post(
18+
urls.courseAssets({ studioEndpointUrl, learningContextId }),
19+
data,
20+
);
21+
},
22+
});
23+
};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { useQuery } from '@tanstack/react-query';
2+
import { useSelector } from 'react-redux';
3+
import { EditorState, selectors } from '@src/editors/data/redux';
4+
import { camelizeKeys } from '@src/editors/utils';
5+
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
6+
import type { AxiosResponse } from 'axios';
7+
import * as urls from '@src/editors/data/services/cms/urls';
8+
9+
interface UseBlockDataParams {
10+
blockId: string,
11+
uniqueId: string,
12+
handlerName: string,
13+
}
14+
15+
// Unique ID required due to intractable race conditions. See ./contexts.tsx file.
16+
export const useBlockData = <T>({ blockId, uniqueId, handlerName }: UseBlockDataParams) => {
17+
const studioEndpointUrl = useSelector((state: EditorState) => selectors.app.studioEndpointUrl(state))!;
18+
const client = getAuthenticatedHttpClient();
19+
return useQuery<T>({
20+
queryKey: ['blockData', blockId, uniqueId],
21+
staleTime: Infinity,
22+
queryFn: ({ signal }) => client.get(
23+
urls.xblockHandlerUrl({ blockId, studioEndpointUrl, handlerName }),
24+
{ cancelSource: signal },
25+
).then((res: AxiosResponse<unknown>) => camelizeKeys(res.data) as T),
26+
});
27+
};
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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, defaultUploadMessages } from '@src/editors/sharedComponents/UploadWidget';
11+
import { Spinner } from '@openedx/paragon';
12+
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
13+
import { messages, fileUploadMessages } 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 uploadMessages = { ...defaultUploadMessages, ...fileUploadMessages };
40+
41+
const PdfEditingModal: React.FC<EditorComponent> = (props) => {
42+
const { fields } = useContext(PdfBlockContext);
43+
const originalState = useRef({ ...fields });
44+
const { values, setValues } = useFormikContext<PdfState>();
45+
46+
useEffect(() => {
47+
// Form is initialized before we get these values, so we have to set them
48+
// when they arrive.
49+
void setValues(fields); // eslint-disable-line no-void
50+
}, [fields]);
51+
52+
const isDirty = () => isEqual(originalState, values);
53+
54+
const getContent = () => {
55+
const settings = { ...values };
56+
// disableAllDownload is not a setting we control, but a backend flag. Have to remove it or the
57+
// backend will reject.
58+
return Object.fromEntries(Object.entries(settings).filter(([key]) => key !== 'disableAllDownload'));
59+
};
60+
61+
return (
62+
<EditorContainer {...props} isDirty={isDirty} getContent={getContent}>
63+
<EditorWrapper>
64+
<UploadWidget supportedFileFormats="application/pdf" urlFieldName="url" messages={uploadMessages} />
65+
<DownloadOptions />
66+
</EditorWrapper>
67+
</EditorContainer>
68+
);
69+
};
70+
71+
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;

0 commit comments

Comments
 (0)