Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"start:with-theme": "paragon install-theme && npm start && npm install",
"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",
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests",
"test:dev": "TZ=UTC fedx-scripts jest --coverage --watch --passWithNoTests",
"test:ci": "TZ=UTC fedx-scripts jest --silent --coverage --passWithNoTests",
"types": "tsc --noEmit"
},
Expand Down
8 changes: 8 additions & 0 deletions src/course-unit/__mocks__/courseSectionVertical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ export default {
tab: 'common',
support_level: true,
},
{
display_name: 'PDF',
category: 'pdf',
boilerplate_name: null,
hinted: false,
tab: 'common',
support_level: true,
},
],
display_name: 'Advanced',
support_legend: {
Expand Down
50 changes: 49 additions & 1 deletion src/course-unit/add-component/AddComponent.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// oxlint-disable unicorn/no-useless-spread
/* eslint-disable react/prop-types */
import userEvent from '@testing-library/user-event';
import userEvent, { UserEvent } from '@testing-library/user-event';

import { mockWaffleFlags } from '@src/data/apiHooks.mock';
import { RenderResult } from '@testing-library/react';
import {
act,
render,
Expand Down Expand Up @@ -319,6 +321,52 @@ describe('<AddComponent />', () => {
});
});

const createPdfBlock = async (
{ getByRole, queryAllByRole, user }: {
getByRole: RenderResult['getByRole']
queryAllByRole: RenderResult['queryAllByRole'],
user: UserEvent,
},
) => {
const advancedBtn = getByRole('button', {
name: new RegExp(`${messages.buttonText.defaultMessage} Advanced`, 'i'),
});

await user.click(advancedBtn);

const dialog = getByRole('dialog');
const pdfOption = within(dialog).getByLabelText('PDF');
await user.click(pdfOption);
const confirmation = within(dialog).getByText('Select');
await user.click(confirmation);
await waitFor(() => expect(queryAllByRole('dialog')).toEqual([]));
};

it('adds a PDF block from the advanced selection in modal as an mfe-editable block', async () => {
const user = userEvent.setup();
const { getByRole, queryAllByRole } = renderComponent();
await createPdfBlock({ getByRole, queryAllByRole, user });
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled();
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({
parentLocator: '123',
type: COMPONENT_TYPES.pdf,
}, expect.any(Function));
});

it('adds a PDF block and launches the legacy iframe editor', async () => {
const user = userEvent.setup();
mockWaffleFlags({ useNewPdfEditor: false });
const { getByRole, queryAllByRole } = renderComponent();
await createPdfBlock({ getByRole, queryAllByRole, user });
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalled();
expect(handleCreateNewCourseXBlockMock).toHaveBeenCalledWith({
parentLocator: '123',
type: COMPONENT_TYPES.pdf,
// Setting the category and not supplying an additional function launches the traditional editor.
category: COMPONENT_TYPES.pdf,
});
});

it('verifies "Text" component selection in modal', async () => {
const user = userEvent.setup();
const { getByRole, getByText } = renderComponent();
Expand Down
25 changes: 23 additions & 2 deletions src/course-unit/add-component/AddComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ const AddComponent = ({
const [selectedComponents, setSelectedComponents] = useState<SelectedComponent[]>([]);
const [usageId, setUsageId] = useState(null);
const { sendMessageToIframe } = useIframe();
const { useVideoGalleryFlow } = useWaffleFlags(courseId ?? undefined);
const { useVideoGalleryFlow, useNewPdfEditor } = useWaffleFlags(courseId ?? undefined);

const courseUnit = useSelector(getCourseUnitData);
const sequenceId = courseUnit?.ancestorInfo?.ancestors?.[0]?.id;
Expand Down Expand Up @@ -170,7 +170,28 @@ const AddComponent = ({
showAddLibraryContentModal();
break;
case COMPONENT_TYPES.advanced:
handleCreateNewCourseXBlock({ type: moduleName, category: moduleName, parentLocator: blockId });
// TODO: The 'advanced components' concept warrants examination.
// 'Advanced' is a bucket where we chuck all the blocks that are
// uncommon, or third-party installs. Until now, none of these have
// had special editors in this MFE. This is the first.
// The fact that advanced modules are handled as a special category
// *in code* and not just in UI seems like a mistake in retrospect.
//
// There will be more of these, and soon.
if (moduleName === COMPONENT_TYPES.pdf && useNewPdfEditor) {
handleCreateNewCourseXBlock(
{ type: moduleName, parentLocator: blockId },
/* istanbul ignore next */
({ courseKey, locator }) => {
setCourseId(courseKey);
setBlockType(moduleName);
setNewBlockId(locator);
showXBlockEditorModal();
},
);
} else {
handleCreateNewCourseXBlock({ type: moduleName, category: moduleName, parentLocator: blockId });
}
break;
case COMPONENT_TYPES.openassessment:
handleCreateNewCourseXBlock({ boilerplate: moduleName, category: type, parentLocator: blockId });
Expand Down
1 change: 1 addition & 0 deletions src/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export const waffleFlagDefaults = {
useNewUnitPage: false,
useNewCertificatesPage: true,
useNewTextbooksPage: true,
useNewPdfEditor: true,
useReactMarkdownEditor: true,
useVideoGalleryFlow: false,
enableAuthzCourseAuthoring: false,
Expand Down
32 changes: 32 additions & 0 deletions src/editors/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/* Shared react-query hooks for editors. */
import { useSelector } from 'react-redux';
import { EditorState, selectors } from '@src/editors/data/redux';
import { useEditorContext } from '@src/editors/EditorContext';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { useMutation } from '@tanstack/react-query';
import * as urls from '@src/editors/data/services/cms/urls';

export const useAssetUpload = ({ blockId, isLibrary }: { blockId: string, isLibrary: boolean }) => {
const studioEndpointUrl = useSelector((state: EditorState) => selectors.app.studioEndpointUrl(state))!;
const { learningContextId } = useEditorContext();
const client = getAuthenticatedHttpClient();
return useMutation({
mutationFn: async (file: File) => {
const data = new FormData();
if (isLibrary) {
data.append('content', file);
return client.put(
urls.libraryAssets({
studioEndpointUrl, learningContextId, blockId, assetName: file.name,
}),
data,
);
}
data.append('file', file);
return client.post(
urls.courseAssets({ studioEndpointUrl, learningContextId }),
data,
);
},
});
};
60 changes: 60 additions & 0 deletions src/editors/containers/PdfEditor/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useQuery } from '@tanstack/react-query';
import { useSelector } from 'react-redux';
import { selectors } from '@src/editors/data/redux';
import { camelizeKeys } from '@src/editors/utils';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import type { Axios, AxiosResponse } from 'axios';
import * as urls from '@src/editors/data/services/cms/urls';

interface UseBlockDataParams<T> {
blockId: string,
uniqueId: string,
handlerName: string,
defaultData: T,
}

interface DeriveHandlerUrlParams {
studioEndpointUrl: string,
blockId: string,
handlerName: string,
isLibrary: boolean,
client: Axios,
}

export const immediate = <T>(val: T) => new Promise((resolve) => { resolve(val); });

const deriveHandlerUrl = async ({
studioEndpointUrl, blockId, handlerName, isLibrary, client,
}: DeriveHandlerUrlParams) => {
if (isLibrary) {
return client.get(urls.boundHandlerUrl({ studioEndpointUrl, blockId, handlerName })).then(
(response: AxiosResponse<{ handler_url: string }>) => response.data.handler_url,
);
}
return urls.handlerUrl({ blockId, studioEndpointUrl, handlerName });
};

// Unique ID required due to intractable race conditions. See ./contexts.tsx file.
export const useBlockHandlerData = <T>({
blockId, uniqueId, handlerName, defaultData,
}: UseBlockDataParams<T>) => {
const studioEndpointUrl = useSelector(selectors.app.studioEndpointUrl)!;
const isLibrary = useSelector(selectors.app.isLibrary);
const client = getAuthenticatedHttpClient();
return useQuery<T>({
queryKey: ['blockHandlerData', blockId, uniqueId, handlerName],
staleTime: Infinity,
queryFn: async ({ signal }) => {
if (!blockId) {
// No blockId is set yet, so there's nothing to fetch.
return immediate(defaultData);
}
Comment thread
bradenmacdonald marked this conversation as resolved.
return client.get(
await deriveHandlerUrl({
blockId, studioEndpointUrl, handlerName, isLibrary, client,
}),
{ cancelSource: signal },
).then((res: AxiosResponse<unknown>) => camelizeKeys(res.data) as T);
},
});
};
79 changes: 79 additions & 0 deletions src/editors/containers/PdfEditor/components/PdfEditingModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { EditorComponent } from '@src/editors/EditorComponent';
import { useFormikContext } from 'formik';
import React, {
PropsWithChildren, useContext, useEffect, useRef,
} from 'react';
import EditorContainer from '@src/editors/containers/EditorContainer';
import { PdfBlockContext, PdfState } from '@src/editors/containers/PdfEditor/contexts';
import { isEqual } from 'lodash';
import DownloadOptions from '@src/editors/containers/PdfEditor/components/sections/DownloadOptions';
import { UploadWidget } from '@src/editors/sharedComponents/UploadWidget';
import { Spinner } from '@openedx/paragon';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';

const EditorWrapper: React.FC<PropsWithChildren> = ({ children }) => {
const intl = useIntl();
const { isPending, fetchError } = useContext(PdfBlockContext);
if (fetchError) {
return (
<div className="text-center p-6">
<FormattedMessage {...messages.blockFailed} />
</div>
);
}
if (isPending) {
return (
<div className="text-center p-6">
<Spinner
animation="border"
className="m-3"
screenReaderText={intl.formatMessage(messages.blockLoading)}
/>
</div>
);
}
return <>{children}</>; /* eslint-disable-line react/jsx-no-useless-fragment */
};

const PdfEditingModal: React.FC<EditorComponent> = (props) => {
const intl = useIntl();
const { fields, blockId, isLibrary } = useContext(PdfBlockContext);
const originalState = useRef({ ...fields });
const { values, setValues } = useFormikContext<PdfState>();

useEffect(() => {
// Form is initialized before we get these values, so we have to set them
// when they arrive.
void setValues(fields); // eslint-disable-line no-void
}, [fields]);

const isDirty = () => isEqual(originalState, values);

const getContent = () => {
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'));
};

return (
<EditorContainer {...props} isDirty={isDirty} getContent={getContent}>
<EditorWrapper>
<div className="mt-2">
<UploadWidget
supportedFileFormats="application/pdf"
urlFieldName="url"
label={intl.formatMessage(messages.urlFieldLabel)}
blockId={blockId}
isLibrary={isLibrary}
id="pdf-url"
/>
</div>
<DownloadOptions />
</EditorWrapper>
</EditorContainer>
);
};

export default PdfEditingModal;
16 changes: 16 additions & 0 deletions src/editors/containers/PdfEditor/components/PdfEditorContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React, { useContext } from 'react';
import { PdfBlockContext } from '@src/editors/containers/PdfEditor/contexts';
import { Formik } from 'formik';
import { EditorComponent } from '@src/editors/EditorComponent';
import PdfEditingModal from '@src/editors/containers/PdfEditor/components/PdfEditingModal';

const PdfEditorContainer: React.FC<EditorComponent> = (props) => {
const { fields } = useContext(PdfBlockContext);
return (
<Formik initialValues={fields} onSubmit={() => undefined}>
<PdfEditingModal {...props} />
</Formik>
);
};

export default PdfEditorContainer;
19 changes: 19 additions & 0 deletions src/editors/containers/PdfEditor/components/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { defineMessages } from '@edx/frontend-platform/i18n';

export default defineMessages({
blockFailed: {
id: 'authoring.pdfEditor.blockFailed',
defaultMessage: 'PDF block failed to load',
description: 'Error message for PDF block failing to load',
},
blockLoading: {
id: 'authoring.pdfEditor.blockLoading',
defaultMessage: 'Loading PDF Editor',
description: 'Message shown to screen readers when the PDF block is loading.',
},
urlFieldLabel: {
id: 'authoring.pdfEditor.urlFieldLabel',
defaultMessage: 'File',
description: 'Label for the PDF URL field',
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React from 'react';
import { useFormikContext } from 'formik';
import { 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';
import TextField from '@src/editors/sharedComponents/TextField';
import messages from './messages';

const DownloadOptions: React.FC = () => {
const intl = useIntl();
const { values } = useFormikContext<PdfState>();
const urlValidator = optional(useUrlValidator());
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
}
return (
<>
<div className="mt-5 mb-4">
<CheckboxField
label={intl.formatMessage(messages.allowDownloadLabel)}
id="pdf-allow-download"
hint={intl.formatMessage(messages.allowDownloadHint)}
fieldConfig="allowDownload"
/>
</div>
<TextField
label={intl.formatMessage(messages.sourceUrlLabel)}
name="sourceUrl"
id="pdf-source-url"
hint={intl.formatMessage(messages.sourceUrlHint)}
fieldConfig={{ validate: urlValidator }}
/>
<TextField
label={intl.formatMessage(messages.sourceDocumentButtonTextLabel)}
id="pdf-source-text"
placeholder={intl.formatMessage(messages.sourceDocumentButtonTextPlaceholder)}
name="sourceText"
/>
</>
);
};

export default DownloadOptions;
Loading