Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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",
Comment thread
bradenmacdonald marked this conversation as resolved.
"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