Skip to content

Commit 14f6985

Browse files
committed
feat: pdf authoring
1 parent 4d7d91b commit 14f6985

File tree

56 files changed

+1149
-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.

56 files changed

+1149
-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/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;
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { defineMessages } from '@edx/frontend-platform/i18n';
2+
3+
export const messages = 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+
});
15+
16+
export const fileUploadMessages = defineMessages({
17+
urlFieldLabel: {
18+
id: 'authoring.pdfEditor.urlFieldLabel',
19+
defaultMessage: 'PDF URL',
20+
description: 'Label for the PDF URL field',
21+
},
22+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Form } from '@openedx/paragon';
2+
import CollapsibleFormWidget
3+
from '@src/editors/sharedComponents/CollapsibleFormWidget/CollapsibleFormWidget';
4+
import { useFormikContext } from 'formik';
5+
import { PdfState } from '@src/editors/containers/PdfEditor/contexts';
6+
import { optional, useUrlValidator } from '@src/editors/utils/validators';
7+
import { useIntl } from '@edx/frontend-platform/i18n';
8+
import CheckboxField from '@src/editors/sharedComponents/CheckboxField';
9+
import TextField from '@src/editors/sharedComponents/TextField';
10+
import messages from './messages';
11+
12+
const DownloadOptions = () => {
13+
const intl = useIntl();
14+
const { errors, values } = useFormikContext<PdfState>();
15+
const isError = !!(errors.allowDownload?.length || errors.sourceText?.length || errors.sourceUrl?.length);
16+
const urlValidator = optional(useUrlValidator());
17+
if (values.disableAllDownload) {
18+
// Download configuration is disabled at the instance-level, so don't even show these options.
19+
return <></>; // eslint-disable-line react/jsx-no-useless-fragment
20+
}
21+
return (
22+
<CollapsibleFormWidget
23+
fontSize="x-small"
24+
title={intl.formatMessage(messages.downloadOptions)}
25+
isError={isError}
26+
>
27+
<Form.Group>
28+
<CheckboxField
29+
label={intl.formatMessage(messages.allowDownloadLabel)}
30+
id="pdf-allow-download"
31+
hint={intl.formatMessage(messages.allowDownloadHint)}
32+
fieldConfig="allowDownload"
33+
/>
34+
<TextField
35+
label={intl.formatMessage(messages.sourceDocumentButtonTextLabel)}
36+
id="pdf-source-text"
37+
placeholder={intl.formatMessage(messages.sourceDocumentButtonTextPlaceholder)}
38+
name="sourceText"
39+
disabled={!values.allowDownload}
40+
/>
41+
<TextField
42+
label={intl.formatMessage(messages.sourceUrlLabel)}
43+
name="sourceUrl"
44+
id="pdf-source-url"
45+
hint={intl.formatMessage(messages.sourceUrlHint)}
46+
disabled={!values.allowDownload}
47+
fieldConfig={{ validate: urlValidator }}
48+
/>
49+
</Form.Group>
50+
</CollapsibleFormWidget>
51+
);
52+
};
53+
54+
export default DownloadOptions;

0 commit comments

Comments
 (0)