diff --git a/src/editors/containers/PdfEditor/api.ts b/src/editors/containers/PdfEditor/api.ts index 039f27be57..e2465a9f9b 100644 --- a/src/editors/containers/PdfEditor/api.ts +++ b/src/editors/containers/PdfEditor/api.ts @@ -1,4 +1,4 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { useSelector } from 'react-redux'; import { selectors } from '@src/editors/data/redux'; import { camelizeKeys } from '@src/editors/utils'; @@ -72,3 +72,24 @@ export const useBlockHandlerData = ({ }, }); }; + +export const usePdfConversion = (blockId: string) => { + const studioEndpointUrl = useSelector(selectors.app.studioEndpointUrl)!; + const isLibrary = useSelector(selectors.app.isLibrary); + const client = getAuthenticatedHttpClient(); + return useMutation({ + mutationFn: async (url: string) => { + const result = await client.post( + await deriveHandlerUrl({ + blockId, + studioEndpointUrl, + handlerName: 'convert_pdf', + isLibrary, + client, + }), + { url }, + ); + return result.data.url as string; + }, + }); +}; diff --git a/src/editors/containers/PdfEditor/components/PdfEditingModal.tsx b/src/editors/containers/PdfEditor/components/PdfEditingModal.tsx index 5ca2369750..e3f27f1d89 100644 --- a/src/editors/containers/PdfEditor/components/PdfEditingModal.tsx +++ b/src/editors/containers/PdfEditor/components/PdfEditingModal.tsx @@ -14,6 +14,8 @@ import { UploadWidget } from '@src/editors/sharedComponents/UploadWidget'; import { Spinner } from '@openedx/paragon'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; +import { FieldSaverArgs } from '@src/editors/sharedComponents/UploadWidget/UploadWidget'; +import { usePdfConversion } from '@src/editors/containers/PdfEditor/api'; const EditorWrapper: React.FC = ({ children }) => { const intl = useIntl(); @@ -44,6 +46,7 @@ const PdfEditingModal: React.FC = (props) => { const { fields, blockId, isLibrary } = useContext(PdfBlockContext); const originalState = useRef({ ...fields }); const { values, setValues } = useFormikContext(); + const mutation = usePdfConversion(blockId); useEffect(() => { // Form is initialized before we get these values, so we have to set them @@ -57,7 +60,33 @@ const PdfEditingModal: React.FC = (props) => { const settings = { ...values }; // disableAllDownload is not a setting we control, but a backend flag. Have to remove it or the // backend will reject. - return Object.fromEntries(Object.entries(settings).filter(([key]) => key !== 'disableAllDownload')); + const ignored = ['disableAllDownload', 'conversionAvailable']; + return Object.fromEntries(Object.entries(settings).filter(([key]) => !ignored.includes(key))); + }; + + const supportedFormats = ['application/pdf']; + if (values.conversionAvailable) { + supportedFormats.push( + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.oasis.opendocument.presentation', + 'application/vnd.oasis.opendocument.spreadsheet', + 'application/vnd.oasis.opendocument.text', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'application/rtf', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + } + + const saver = async (args: FieldSaverArgs) => { + if (!values.conversionAvailable || args.sourceFile.type == 'application/pdf') { + // No conversion means no swapping of file values-- just set the field value and be done. + return args.control.setValue(args.value); + } + const pdfUrl = await mutation.mutateAsync(args.value); + await setValues({ ...values, url: pdfUrl, sourceUrl: args.value }); }; return ( @@ -65,11 +94,12 @@ const PdfEditingModal: React.FC = (props) => {
diff --git a/src/editors/containers/PdfEditor/components/sections/DownloadOptions.tsx b/src/editors/containers/PdfEditor/components/sections/DownloadOptions.tsx index 415e70c993..2000f1d0fe 100644 --- a/src/editors/containers/PdfEditor/components/sections/DownloadOptions.tsx +++ b/src/editors/containers/PdfEditor/components/sections/DownloadOptions.tsx @@ -1,6 +1,6 @@ -import React from 'react'; +import React, { useContext } from 'react'; import { useFormikContext } from 'formik'; -import { PdfState } from '@src/editors/containers/PdfEditor/contexts'; +import { PdfBlockContext, PdfState } from '@src/editors/containers/PdfEditor/contexts'; import { optional, useUrlValidator } from '@src/editors/utils/validators'; import { useIntl } from '@edx/frontend-platform/i18n'; import CheckboxField from '@src/editors/sharedComponents/CheckboxField'; @@ -10,7 +10,8 @@ import messages from './messages'; const DownloadOptions: React.FC = () => { const intl = useIntl(); const { values } = useFormikContext(); - const urlValidator = optional(useUrlValidator()); + const { isLibrary } = useContext(PdfBlockContext); + const urlValidator = optional(useUrlValidator({ allowAbsolute: isLibrary })); if (values.disableAllDownload) { // Download configuration is disabled at the instance-level, so don't even show these options. return <>; // eslint-disable-line react/jsx-no-useless-fragment diff --git a/src/editors/containers/PdfEditor/components/sections/messages.ts b/src/editors/containers/PdfEditor/components/sections/messages.ts index bf794d2eed..04c8ae1191 100644 --- a/src/editors/containers/PdfEditor/components/sections/messages.ts +++ b/src/editors/containers/PdfEditor/components/sections/messages.ts @@ -31,7 +31,9 @@ const messages = defineMessages({ }, sourceUrlHint: { id: 'authoring.pdfEditor.formGroups.downloadOptions.sourceUrl.hint', - defaultMessage: 'Add a link to the original or editable file (e.g. Word or PowerPoint). Appears as a separate link.', + defaultMessage: 'Add a link to the original or editable file (e.g. Word or PowerPoint). Appears as a separate ' + + 'link. You are encouraged to provide this link when auto-generating PDFs, and to ensure this source document ' + + 'conforms to accessibility standards.', description: 'Hint for the field used to specify the URL of a source document a PDF was generated from.', }, }); diff --git a/src/editors/containers/PdfEditor/contexts.tsx b/src/editors/containers/PdfEditor/contexts.tsx index 5fade7dc4d..fb4eb29557 100644 --- a/src/editors/containers/PdfEditor/contexts.tsx +++ b/src/editors/containers/PdfEditor/contexts.tsx @@ -16,8 +16,10 @@ export interface PdfState { allowDownload: boolean; sourceText: string; sourceUrl: string; - // Note: Not a field, so can't be set. + // Note: The following are not fields, so can't be set. + // They're indicators of backend settings. disableAllDownload: boolean; + conversionAvailable: boolean; } declare interface PdfBlockContextInterface { @@ -35,6 +37,7 @@ export const initialPdfState: () => PdfState = () => ({ sourceText: '', sourceUrl: '', disableAllDownload: false, + conversionAvailable: false, }); export const PdfBlockContext = createContext({ diff --git a/src/editors/containers/PdfEditor/index.test.tsx b/src/editors/containers/PdfEditor/index.test.tsx index 5e26a8dc70..8733eca5f3 100644 --- a/src/editors/containers/PdfEditor/index.test.tsx +++ b/src/editors/containers/PdfEditor/index.test.tsx @@ -1,82 +1,87 @@ import MockAdapter from 'axios-mock-adapter'; import { initializeMocks } from '@src/testUtils'; import PdfEditor from '@src/editors/containers/PdfEditor/index'; -import { editorRender } from '@src/editors/editorTestRender'; +import { editorRender, EditorRenderState } from '@src/editors/editorTestRender'; import { initialPdfState, PdfState } from '@src/editors/containers/PdfEditor/contexts'; import messages from '@src/editors/containers/PdfEditor/components/messages'; import downloadMessages from '@src/editors/containers/PdfEditor/components/sections/messages'; import uploadMessages from '@src/editors/sharedComponents/UploadWidget/messages'; import editorMessages from '@src/editors/containers/EditorContainer/messages'; -import { fireEvent, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { fireEvent, RenderResult, waitFor } from '@testing-library/react'; +import userEvent, { UserEvent } from '@testing-library/user-event'; +import fileMessage from '@src/files-and-videos/generic/messages'; -const render = () => - editorRender( - undefined} returnFunction={() => () => undefined} />, - { - initialState: { - app: { - blockValue: { - data: { - id: 'pdf-block-id', +const reduxState = () => ( + { + initialState: { + app: { + blockValue: { + data: { + id: 'pdf-block-id', + display_name: 'PDF', + category: 'pdf', + has_children: false, + has_changes: null, + explanatory_message: null, + group_access: {}, + data: '', + metadata: { display_name: 'PDF', - category: 'pdf', - has_children: false, - has_changes: null, - explanatory_message: null, - group_access: {}, - data: '', - metadata: { - display_name: 'PDF', - }, }, }, - unitUrl: { - data: { - ancestors: [ - { - id: 'block-v1:Test+TS102+2026+type@vertical+block@29f73003508e47e0af00b495ecdc66f1', - display_name: 'Unit', - category: 'vertical', - has_children: true, - }, - { - id: 'block-v1:Test+TS102+2026+type@sequential+block@a9f3bc6ad94a4e108449b5c84a46f7ba', - display_name: 'Subsection', - category: 'sequential', - has_children: true, - }, - { - id: 'block-v1:Test+TS102+2026+type@chapter+block@606d3cab05a94551b71c5abbd0009baf', - display_name: 'Section', - category: 'chapter', - has_children: true, - }, - { - id: 'block-v1:Test+TS102+2026+type@course+block@course', - display_name: 'New Test Course', - category: 'course', - has_children: true, - unit_level_discussions: true, - }, - ], - }, + }, + unitUrl: { + data: { + ancestors: [ + { + id: 'block-v1:course-v1:Org+COURSE+RUN+type@vertical+block@29f73003508e47e0af00b495ecdc66f1', + display_name: 'Unit', + category: 'vertical', + has_children: true, + }, + { + id: 'block-v1:course-v1:Org+COURSE+RUN+type@sequential+block@a9f3bc6ad94a4e108449b5c84a46f7ba', + display_name: 'Subsection', + category: 'sequential', + has_children: true, + }, + { + id: 'block-v1:course-v1:Org+COURSE+RUN+type@chapter+block@606d3cab05a94551b71c5abbd0009baf', + display_name: 'Section', + category: 'chapter', + has_children: true, + }, + { + id: 'block-v1:course-v1:Org+COURSE+RUN+type@course+block@course', + display_name: 'New Test Course', + category: 'course', + has_children: true, + unit_level_discussions: true, + }, + ], }, - blockId: 'pdf-block-id', - blockTitle: 'PDF', - blockType: 'pdf', - learningContextId: 'course-v1:Test+TS102+2026', - editorInitialized: false, - studioEndpointUrl: 'https://studio.local', - lmsEndpointUrl: 'http://local.openedx.io:8000', - images: {}, - imageCount: 0, - videos: {}, - courseDetails: {}, - showRawEditor: false, }, + blockId: 'pdf-block-id', + blockTitle: 'PDF', + blockType: 'pdf', + learningContextId: 'course-v1:Org+COURSE+RUN', + editorInitialized: false, + studioEndpointUrl: 'https://studio.local', + lmsEndpointUrl: 'http://local.openedx.io:8000', + images: {}, + imageCount: 0, + videos: {}, + courseDetails: {}, + showRawEditor: false, }, }, + } as EditorRenderState +); + +const render = (state?: Partial) => + editorRender( + undefined} returnFunction={() => () => undefined} />, + { ...reduxState(), ...state }, ); describe('PdfEditor', () => { @@ -85,9 +90,9 @@ describe('PdfEditor', () => { axiosMock = initializeMocks().axiosMock; }); - const setBlock = (state?: Partial) => { + const setBlock = (state?: Partial, blockId: string = 'pdf-block-id') => { axiosMock.onGet( - 'https://studio.local/xblock/pdf-block-id/handler/load_pdf', + `https://studio.local/xblock/${blockId}/handler/load_pdf`, ).reply(200, { ...initialPdfState(), url: 'https://example.com/example.pdf', ...state }); }; @@ -140,4 +145,116 @@ describe('PdfEditor', () => { const request = axiosMock.history.post[0]; expect(JSON.parse(request.data).metadata.url).toEqual('https://somewhere.com/stuff.pdf'); }); + + const prepForUpload = async (screen: RenderResult, user: UserEvent): Promise => { + const dropdown = screen.getByLabelText(uploadMessages.actionsDropdown.defaultMessage); + const input = screen.getByLabelText(fileMessage.fileInputAriaLabel.defaultMessage); + const spy = jest.spyOn(input, 'click'); + await user.click(dropdown); + await user.click(screen.getByText(uploadMessages.replaceFile.defaultMessage)); + await waitFor(() => expect(spy).toHaveBeenCalled()); + return input; + }; + + it('Handles a PDF without triggering autoconversion', async () => { + axiosMock.onPost('https://studio.local/assets/course-v1:Org+COURSE+RUN/').withDelayInMs(500).reply( + 201, + { + asset: { + external_url: 'https://studio.local/asset-v1:course-v1:Org+COURSE+RUN+type@asset+block@my-test-doc.pdf', + }, + }, + ); + const user = userEvent.setup(); + setBlock({ conversionAvailable: true }); + const screen = render(); + await waitFor(() => screen.getByText(uploadMessages.courseFileHint.defaultMessage)); + const input = await prepForUpload(screen, user); + await user.upload( + input, + new File( + ['beep'], + 'my-test-doc.pdf', + { type: 'application/pdf' }, + ), + ); + await waitFor(() => screen.getByText(uploadMessages.uploading.defaultMessage)); + await waitFor(() => screen.getByText('my-test-doc.pdf')); + }); + it('Autoconverts in courses', async () => { + axiosMock.onPost('https://studio.local/assets/course-v1:Org+COURSE+RUN/').withDelayInMs(500).reply( + 201, + { + asset: { + external_url: 'https://studio.local/asset-v1:course-v1:Org+COURSE+RUN+type@asset+block@my-test-doc.docx', + }, + }, + ); + axiosMock.onPost( + 'https://studio.local/xblock/pdf-block-id/handler/convert_pdf', + ).withDelayInMs(200).reply(200, { url: 'https://example.com/path/to/revised.pdf' }); + const user = userEvent.setup(); + setBlock({ conversionAvailable: true }); + const screen = render(); + await waitFor(() => screen.getByText(uploadMessages.courseFileHint.defaultMessage)); + const input = await prepForUpload(screen, user); + await user.upload( + input, + new File( + ['beep'], + 'my-test-doc.docx', + { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, + ), + ); + await waitFor(() => screen.getByText(uploadMessages.uploading.defaultMessage)); + await waitFor(() => screen.getByText('revised.pdf')); + screen.getByDisplayValue( + 'https://studio.local/asset-v1:course-v1:Org+COURSE+RUN+type@asset+block@my-test-doc.docx', + ); + }); + it('Autoconverts in libraries', async () => { + axiosMock.onPut( + 'http://localhost:18010/api/libraries/v2/blocks/lb:Test:TL100:pdf:pdf-block/assets/static/my-test-doc.docx', + ) + .withDelayInMs(500).reply( + 201, + { + path: 'static/my-test-doc.docx', + }, + ); + axiosMock.onGet( + 'https://studio.local/api/xblock/v2/xblocks/lb:Test:TL100:pdf:pdf-block/handler_url/load_pdf/', + ).reply( + 200, + { handler_url: 'https://studio.local/xblock/resolved_handler/lb:Test:TL100:pdf:pdf-block/handler/load_pdf' }, + ); + axiosMock.onGet( + 'https://studio.local/api/xblock/v2/xblocks/lb:Test:TL100:pdf:pdf-block/handler_url/convert_pdf/', + ).reply( + 200, + { handler_url: 'https://studio.local/xblock/resolved_handler/lb:Test:TL100:pdf:pdf-block/handler/convert_pdf' }, + ); + axiosMock.onPost( + 'https://studio.local/xblock/resolved_handler/lb:Test:TL100:pdf:pdf-block/handler/convert_pdf', + ).withDelayInMs(200).reply(200, { url: '/static/revised.pdf' }); + const user = userEvent.setup(); + setBlock({ conversionAvailable: true }, 'resolved_handler/lb:Test:TL100:pdf:pdf-block'); + const loadedState = reduxState(); + loadedState.learningContextId = 'lib:Test:TL100'; + loadedState.initialState!.app!.blockId = 'lb:Test:TL100:pdf:pdf-block'; + const screen = render(loadedState); + await waitFor(() => screen.getByText(uploadMessages.libraryFileHint.defaultMessage)); + const input = await prepForUpload(screen, user); + await user.upload( + input, + new File( + ['beep'], + 'my-test-doc.docx', + { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }, + ), + ); + await waitFor(() => screen.getByText(uploadMessages.uploading.defaultMessage)); + await waitFor(() => screen.getByText('revised.pdf')); + screen.getByDisplayValue('/static/my-test-doc.docx'); + }); }); diff --git a/src/editors/editorTestRender.tsx b/src/editors/editorTestRender.tsx index 4dac3774a6..77ddae7969 100644 --- a/src/editors/editorTestRender.tsx +++ b/src/editors/editorTestRender.tsx @@ -11,6 +11,11 @@ import { type EditorState } from './data/redux'; // type. let editorStore: Store; +export type EditorRenderState = + & Omit + & RouteOptions + & { initialState?: PartialEditorState; learningContextId?: string; }; + /** * Custom render function for testing React components with the editor context and Redux store. * @@ -23,10 +28,7 @@ export const editorRender = ( initialState = {}, learningContextId = 'course-v1:Org+COURSE+RUN', ...options - }: - & Omit - & RouteOptions - & { initialState?: PartialEditorState; learningContextId?: string; } = {}, + }: EditorRenderState = {}, ) => { editorStore = createStore(initialState); diff --git a/src/editors/sharedComponents/UploadWidget/UploadWidget.test.tsx b/src/editors/sharedComponents/UploadWidget/UploadWidget.test.tsx index 6a319fa874..5f4f8c0905 100644 --- a/src/editors/sharedComponents/UploadWidget/UploadWidget.test.tsx +++ b/src/editors/sharedComponents/UploadWidget/UploadWidget.test.tsx @@ -77,7 +77,7 @@ describe('UploadWidget', () => { .withDelayInMs(1000).reply( 201, { - path: '/static/my-saved-pdf.pdf', + path: 'static/my-saved-pdf.pdf', }, ); const screen = renderWidget({ props: defaultProps({ isLibrary: true }), formikProps: defaultFormikProps() }); diff --git a/src/editors/sharedComponents/UploadWidget/UploadWidget.tsx b/src/editors/sharedComponents/UploadWidget/UploadWidget.tsx index 15b755ebf1..bd5ded8c9a 100644 --- a/src/editors/sharedComponents/UploadWidget/UploadWidget.tsx +++ b/src/editors/sharedComponents/UploadWidget/UploadWidget.tsx @@ -13,13 +13,21 @@ import { } from '@openedx/paragon'; import { MoreHoriz } from '@openedx/paragon/icons'; import React, { useState } from 'react'; -import { useField } from 'formik'; +import { useField, FieldInputProps, FieldMetaProps, FieldHelperProps } from 'formik'; import type { AxiosResponse } from 'axios'; import TextField from '@src/editors/sharedComponents/TextField'; import { useAssetUpload } from '@src/editors/api'; import defaultMessages from './messages'; -export interface UploadWidgetProps { +export interface FieldSaverArgs { + field: FieldInputProps; + meta: FieldMetaProps; + control: FieldHelperProps; + sourceFile: File; + value: T; +} + +export interface UploadWidgetProps { id: string; label: string; supportedFileFormats?: string | string[] | Record; @@ -27,6 +35,7 @@ export interface UploadWidgetProps { messages?: typeof defaultMessages; blockId: string; isLibrary: boolean; + saveField?: (args: FieldSaverArgs) => Promise; } type LibraryAsset = { path: string; }; @@ -43,13 +52,16 @@ const UploadWidget = ({ messages = defaultMessages, blockId, isLibrary, -}: UploadWidgetProps) => { + saveField, +}: UploadWidgetProps) => { const intl = useIntl(); const [manualMode, setManualMode] = useState(false); const [urlField, urlFieldMeta, urlFieldControl] = useField(urlFieldName); const setSelectedRows = () => undefined; const setAddOpen = () => undefined; const mutation = useAssetUpload({ blockId, isLibrary }); + const saver = saveField || + ((args: FieldSaverArgs) => void args.control.setValue(args.value)); // eslint-disable-line no-void const onAddFile = (files: File[]) => { const file = files[0]; @@ -59,15 +71,17 @@ const UploadWidget = ({ return; } mutation.mutateAsync(file).then((result: AssetResponse) => { + let value: string; if (isLibrary) { // This will be a path like /static/something.pdf. Some post-processing in the LMS's views converts // the URL to the appropriate one after rendering the fragment. // // It is not clear how this would work in the case of a React-based student view. - void urlFieldControl.setValue(`/${(result.data as LibraryAsset).path}`); // eslint-disable-line no-void + value = `/${(result.data as LibraryAsset).path}`; } else { - void urlFieldControl.setValue((result.data as CourseAsset).asset.external_url); // eslint-disable-line no-void + value = (result.data as CourseAsset).asset.external_url; } + return saver({ field: urlField, meta: urlFieldMeta, control: urlFieldControl, sourceFile: file, value }); }).catch(() => { urlFieldControl.setError(intl.formatMessage(messages.uploadError)); }).finally(() => { diff --git a/src/editors/utils/validators.ts b/src/editors/utils/validators.ts index 54d1741ce4..2573ee0d10 100644 --- a/src/editors/utils/validators.ts +++ b/src/editors/utils/validators.ts @@ -3,9 +3,12 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import type { FieldValidator } from 'formik'; import messages from '../sharedComponents/UploadWidget/messages'; -export const useUrlValidator = () => { +export const useUrlValidator = ({ allowAbsolute = false }: { allowAbsolute?: boolean; }) => { const intl = useIntl(); const validator: FieldValidator = (url: string) => { + if (allowAbsolute && url.startsWith('/')) { + url = 'https://example.com' + url; + } try { new URL(url); /* eslint-disable-line no-new */ } catch {