Skip to content

Commit 9a1ed3c

Browse files
committed
feat: pdf authoring
1 parent 9076a09 commit 9a1ed3c

61 files changed

Lines changed: 1165 additions & 169 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: 50 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,55 @@ 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 {
326+
getByRole, queryAllByRole, queryAllByText,
327+
} = renderComponent();
328+
const advancedBtn = getByRole('button', {
329+
name: new RegExp(`${messages.buttonText.defaultMessage} Advanced`, 'i'),
330+
});
331+
332+
await user.click(advancedBtn);
333+
334+
const dialog = getByRole('dialog');
335+
const pdfOption = within(dialog).getByLabelText('PDF');
336+
await user.click(pdfOption);
337+
const confirmation = within(dialog).getByText('Select');
338+
await user.click(confirmation);
339+
await waitFor(() => expect(queryAllByRole('dialog')).toEqual([]));
340+
expect(queryAllByText('PDF')).toEqual([]);
341+
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled();
342+
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({
343+
parentLocator: '123',
344+
type: COMPONENT_TYPES.pdf,
345+
}, expect.any(Function));
346+
});
347+
348+
it('adds a PDF block from the advanced selection in modal as a traditional block', async () => {
349+
const user = userEvent.setup();
350+
mockWaffleFlags({ useNewPdfEditor: false });
351+
const { getByRole, queryAllByRole } = renderComponent();
352+
const advancedBtn = getByRole('button', {
353+
name: new RegExp(`${messages.buttonText.defaultMessage} Advanced`, 'i'),
354+
});
355+
356+
await user.click(advancedBtn);
357+
358+
const dialog = getByRole('dialog');
359+
const pdfOption = within(dialog).getByLabelText('PDF');
360+
await user.click(pdfOption);
361+
const confirmation = within(dialog).getByText('Select');
362+
await user.click(confirmation);
363+
await waitFor(() => expect(queryAllByRole('dialog')).toEqual([]));
364+
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled();
365+
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({
366+
parentLocator: '123',
367+
type: COMPONENT_TYPES.pdf,
368+
category: COMPONENT_TYPES.pdf,
369+
});
370+
});
371+
322372
it('verifies "Text" component selection in modal', async () => {
323373
const user = userEvent.setup();
324374
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/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
@@ -85,6 +85,7 @@ export const waffleFlagDefaults = {
8585
useNewUnitPage: false,
8686
useNewCertificatesPage: true,
8787
useNewTextbooksPage: true,
88+
useNewPdfEditor: true,
8889
useReactMarkdownEditor: true,
8990
useVideoGalleryFlow: false,
9091
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: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
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 } = 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+
id="pdf-url"
69+
/>
70+
</div>
71+
<DownloadOptions />
72+
</EditorWrapper>
73+
</EditorContainer>
74+
);
75+
};
76+
77+
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)