diff --git a/src/generic/CodeEditor.tsx b/src/generic/CodeEditor.tsx new file mode 100644 index 0000000000..f98c0bd768 --- /dev/null +++ b/src/generic/CodeEditor.tsx @@ -0,0 +1,55 @@ +/* eslint-disable import/prefer-default-export */ +import React from 'react'; +import { basicSetup, EditorView } from 'codemirror'; +import { EditorState, Compartment } from '@codemirror/state'; +import { xml } from '@codemirror/lang-xml'; + +export type EditorAccessor = EditorView; + +interface Props { + readOnly?: boolean; + children?: string; + editorRef?: React.MutableRefObject; +} + +export const CodeEditor: React.FC = ({ + readOnly = false, + children = '', + editorRef, +}) => { + const divRef = React.useRef(null); + const language = React.useMemo(() => new Compartment(), []); + const tabSize = React.useMemo(() => new Compartment(), []); + + React.useEffect(() => { + if (!divRef.current) { return; } + const state = EditorState.create({ + doc: children, + extensions: [ + basicSetup, + language.of(xml()), + tabSize.of(EditorState.tabSize.of(2)), + EditorState.readOnly.of(readOnly), + ], + }); + + const view = new EditorView({ + state, + parent: divRef.current, + }); + if (editorRef) { + // eslint-disable-next-line no-param-reassign + editorRef.current = view; + } + // eslint-disable-next-line consistent-return + return () => { + if (editorRef) { + // eslint-disable-next-line no-param-reassign + editorRef.current = undefined; + } + view.destroy(); // Clean up + }; + }, [divRef.current, readOnly, editorRef]); + + return
; +}; diff --git a/src/library-authoring/component-info/ComponentAdvancedInfo.tsx b/src/library-authoring/component-info/ComponentAdvancedInfo.tsx index f45d3a249f..38eccb2a07 100644 --- a/src/library-authoring/component-info/ComponentAdvancedInfo.tsx +++ b/src/library-authoring/component-info/ComponentAdvancedInfo.tsx @@ -1,10 +1,16 @@ /* eslint-disable import/prefer-default-export */ import React from 'react'; -import { Collapsible } from '@openedx/paragon'; +import { + Button, + Collapsible, + OverlayTrigger, + Tooltip, +} from '@openedx/paragon'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { LoadingSpinner } from '../../generic/Loading'; -import { useXBlockOLX } from '../data/apiHooks'; +import { CodeEditor, EditorAccessor } from '../../generic/CodeEditor'; +import { useUpdateXBlockOLX, useXBlockOLX } from '../data/apiHooks'; import messages from './messages'; interface Props { @@ -13,7 +19,27 @@ interface Props { export const ComponentAdvancedInfo: React.FC = ({ usageKey }) => { const intl = useIntl(); + // TODO: hide the "Edit" button if the library is read only const { data: olx, isLoading: isOLXLoading } = useXBlockOLX(usageKey); + const editorRef = React.useRef(undefined); + const [isEditingOLX, setEditingOLX] = React.useState(false); + const olxUpdater = useUpdateXBlockOLX(usageKey); + const updateOlx = React.useCallback(() => { + const newOLX = editorRef.current?.state.doc.toString(); + if (!newOLX) { + /* istanbul ignore next */ + throw new Error('Unable to get OLX string from codemirror.'); // Shouldn't happen. + } + olxUpdater.mutateAsync(newOLX).catch(err => { + // eslint-disable-next-line no-console + console.error(err); + // eslint-disable-next-line no-alert + alert(intl.formatMessage(messages.advancedDetailsOLXEditFailed)); + }).then(() => { + // Only if we succeeded: + setEditingOLX(false); + }); + }, [editorRef, olxUpdater, intl]); return ( = ({ usageKey }) => {
{usageKey}
-
OLX Source
-
- { - olx ? {olx} : // eslint-disable-line - isOLXLoading ? : // eslint-disable-line - Error - } +
+
{(() => { + if (isOLXLoading) { return ; } + if (!olx) { return ; } + return ( + <> + {olx} + { + isEditingOLX + ? ( + <> + + + + ) + : ( + + + + )} + > + + + ) + } + + ); + })()}
diff --git a/src/library-authoring/component-info/messages.ts b/src/library-authoring/component-info/messages.ts index 0043639827..f7b00d2144 100644 --- a/src/library-authoring/component-info/messages.ts +++ b/src/library-authoring/component-info/messages.ts @@ -11,6 +11,26 @@ const messages = defineMessages({ defaultMessage: 'OLX Source', description: 'Heading for the component\'s OLX source code', }, + advancedDetailsOLXEditButton: { + id: 'course-authoring.library-authoring.component.advanced.olx-edit', + defaultMessage: 'Edit OLX', + description: 'Heading for the component\'s OLX source code', + }, + advancedDetailsOLXEditWarning: { + id: 'course-authoring.library-authoring.component.advanced.olx-warning', + defaultMessage: 'Be careful! This is an advanced feature and errors may break the component.', + description: 'Warning for users about editing OLX directly.', + }, + advancedDetailsOLXEditFailed: { + id: 'course-authoring.library-authoring.component.advanced.olx-failed', + defaultMessage: 'An error occurred and the OLX could not be saved.', + description: 'Error message shown when saving the OLX fails.', + }, + advancedDetailsOLXError: { + id: 'course-authoring.library-authoring.component.advanced.olx-error', + defaultMessage: 'Unable to load OLX', + description: 'Error message if OLX is unavailable', + }, advancedDetailsUsageKey: { id: 'course-authoring.library-authoring.component.advanced.usage-key', defaultMessage: 'ID (Usage key)', diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 5c96176763..7d935eaa1f 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -318,6 +318,15 @@ export async function getXBlockOLX(usageKey: string): Promise { return data.olx; } +/** + * Set the OLX for the given XBlock. + * Returns the OLX as it was actually saved. + */ +export async function setXBlockOLX(usageKey: string, newOLX: string): Promise { + const { data } = await getAuthenticatedHttpClient().post(getXBlockOLXApiUrl(usageKey), { olx: newOLX }); + return data.olx; +} + /** * Get the collection metadata. */ diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 40601356b4..947da1c6da 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -31,6 +31,7 @@ import { updateCollectionComponents, type CreateLibraryCollectionDataRequest, getCollectionMetadata, + setXBlockOLX, } from './api'; export const libraryQueryPredicate = (query: Query, libraryId: string): boolean => { @@ -272,7 +273,7 @@ export const useCreateLibraryCollection = (libraryId: string) => { }); }; -/* istanbul ignore next */ // This is only used in developer builds, and the associated UI doesn't work in test or prod +/** Get the OLX source of a library component */ export const useXBlockOLX = (usageKey: string) => ( useQuery({ queryKey: xblockQueryKeys.xblockOLX(usageKey), @@ -281,6 +282,25 @@ export const useXBlockOLX = (usageKey: string) => ( }) ); +/** + * Update the OLX of a library component (advanced feature) + */ +export const useUpdateXBlockOLX = (usageKey: string) => { + const contentLibraryId = getLibraryId(usageKey); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (newOLX: string) => setXBlockOLX(usageKey, newOLX), + onSuccess: (olxFromServer) => { + queryClient.setQueryData(xblockQueryKeys.xblockOLX(usageKey), olxFromServer); + // Reload the other data for this component: + invalidateComponentData(queryClient, contentLibraryId, usageKey); + // And the description and display name etc. may have changed, so refresh everything in the library too: + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(contentLibraryId) }); + queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, contentLibraryId) }); + }, + }); +}; + /** * Get the metadata for a collection in a library */