Skip to content

Commit bc07fcb

Browse files
feat: OLX editing
1 parent b4a1861 commit bc07fcb

File tree

5 files changed

+164
-10
lines changed

5 files changed

+164
-10
lines changed

src/generic/CodeEditor.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/* eslint-disable import/prefer-default-export */
2+
import React from 'react';
3+
import { basicSetup, EditorView } from 'codemirror';
4+
import { EditorState, Compartment } from '@codemirror/state';
5+
import { xml } from '@codemirror/lang-xml';
6+
7+
export type EditorAccessor = EditorView;
8+
9+
interface Props {
10+
readOnly?: boolean;
11+
children?: string;
12+
editorRef?: React.MutableRefObject<EditorAccessor | undefined>;
13+
}
14+
15+
export const CodeEditor: React.FC<Props> = ({
16+
readOnly = false,
17+
children = '',
18+
editorRef,
19+
}) => {
20+
const divRef = React.useRef<HTMLDivElement>(null);
21+
const language = React.useMemo(() => new Compartment(), []);
22+
const tabSize = React.useMemo(() => new Compartment(), []);
23+
24+
React.useEffect(() => {
25+
if (!divRef.current) { return; }
26+
const state = EditorState.create({
27+
doc: children,
28+
extensions: [
29+
basicSetup,
30+
language.of(xml()),
31+
tabSize.of(EditorState.tabSize.of(2)),
32+
EditorState.readOnly.of(readOnly),
33+
],
34+
});
35+
36+
const view = new EditorView({
37+
state,
38+
parent: divRef.current,
39+
});
40+
if (editorRef) {
41+
// eslint-disable-next-line no-param-reassign
42+
editorRef.current = view;
43+
}
44+
// eslint-disable-next-line consistent-return
45+
return () => {
46+
if (editorRef) {
47+
// eslint-disable-next-line no-param-reassign
48+
editorRef.current = undefined;
49+
}
50+
view.destroy(); // Clean up
51+
};
52+
}, [divRef.current, readOnly, editorRef]);
53+
54+
return <div ref={divRef} />;
55+
};

src/library-authoring/component-info/ComponentAdvancedInfo.tsx

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
/* eslint-disable import/prefer-default-export */
22
import React from 'react';
3-
import { Collapsible } from '@openedx/paragon';
3+
import {
4+
Button,
5+
Collapsible,
6+
OverlayTrigger,
7+
Tooltip,
8+
} from '@openedx/paragon';
49
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
510

611
import { LoadingSpinner } from '../../generic/Loading';
7-
import { useXBlockOLX } from '../data/apiHooks';
12+
import { CodeEditor, EditorAccessor } from '../../generic/CodeEditor';
13+
import { useUpdateXBlockOLX, useXBlockOLX } from '../data/apiHooks';
814
import messages from './messages';
915

1016
interface Props {
@@ -13,7 +19,27 @@ interface Props {
1319

1420
export const ComponentAdvancedInfo: React.FC<Props> = ({ usageKey }) => {
1521
const intl = useIntl();
22+
// TODO: hide the "Edit" button if the library is read only
1623
const { data: olx, isLoading: isOLXLoading } = useXBlockOLX(usageKey);
24+
const editorRef = React.useRef<EditorAccessor | undefined>(undefined);
25+
const [isEditingOLX, setEditingOLX] = React.useState(false);
26+
const olxUpdater = useUpdateXBlockOLX(usageKey);
27+
const updateOlx = React.useCallback(() => {
28+
const newOLX = editorRef.current?.state.doc.toString();
29+
if (!newOLX) {
30+
/* istanbul ignore next */
31+
throw new Error('Unable to get OLX string from codemirror.'); // Shouldn't happen.
32+
}
33+
olxUpdater.mutateAsync(newOLX).catch(err => {
34+
// eslint-disable-next-line no-console
35+
console.error(err);
36+
// eslint-disable-next-line no-alert
37+
alert(intl.formatMessage(messages.advancedDetailsOLXEditFailed));
38+
}).then(() => {
39+
// Only if we succeeded:
40+
setEditingOLX(false);
41+
});
42+
}, [editorRef, olxUpdater, intl]);
1743
return (
1844
<Collapsible
1945
styling="basic"
@@ -22,13 +48,37 @@ export const ComponentAdvancedInfo: React.FC<Props> = ({ usageKey }) => {
2248
<dl>
2349
<dt><FormattedMessage {...messages.advancedDetailsUsageKey} /></dt>
2450
<dd className="text-monospace small">{usageKey}</dd>
25-
<dt>OLX Source</dt>
26-
<dd>
27-
{
28-
olx ? <code className="micro">{olx}</code> : // eslint-disable-line
29-
isOLXLoading ? <LoadingSpinner /> : // eslint-disable-line
30-
<span>Error</span>
31-
}
51+
<dt><FormattedMessage {...messages.advancedDetailsOLX} /></dt>
52+
<dd>{(() => {
53+
if (isOLXLoading) { return <LoadingSpinner />; }
54+
if (!olx) { return <FormattedMessage {...messages.advancedDetailsOLXError} />; }
55+
return (
56+
<>
57+
<CodeEditor readOnly={!isEditingOLX} editorRef={editorRef}>{olx}</CodeEditor>
58+
{
59+
isEditingOLX
60+
? (
61+
<>
62+
<Button variant="primary" onClick={updateOlx} disabled={olxUpdater.isLoading}>Save</Button>
63+
<Button variant="link" onClick={() => setEditingOLX(false)} disabled={olxUpdater.isLoading}>Cancel</Button>
64+
</>
65+
)
66+
: (
67+
<OverlayTrigger
68+
placement="bottom-start"
69+
overlay={(
70+
<Tooltip id="olx-edit-button">
71+
<FormattedMessage {...messages.advancedDetailsOLXEditWarning} />
72+
</Tooltip>
73+
)}
74+
>
75+
<Button variant="link" onClick={() => setEditingOLX(true)}><FormattedMessage {...messages.advancedDetailsOLXEditButton} /></Button>
76+
</OverlayTrigger>
77+
)
78+
}
79+
</>
80+
);
81+
})()}
3282
</dd>
3383
</dl>
3484
</Collapsible>

src/library-authoring/component-info/messages.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,26 @@ const messages = defineMessages({
1111
defaultMessage: 'OLX Source',
1212
description: 'Heading for the component\'s OLX source code',
1313
},
14+
advancedDetailsOLXEditButton: {
15+
id: 'course-authoring.library-authoring.component.advanced.olx-edit',
16+
defaultMessage: 'Edit OLX',
17+
description: 'Heading for the component\'s OLX source code',
18+
},
19+
advancedDetailsOLXEditWarning: {
20+
id: 'course-authoring.library-authoring.component.advanced.olx-warning',
21+
defaultMessage: 'Be careful! This is an advanced feature and errors may break the component.',
22+
description: 'Warning for users about editing OLX directly.',
23+
},
24+
advancedDetailsOLXEditFailed: {
25+
id: 'course-authoring.library-authoring.component.advanced.olx-failed',
26+
defaultMessage: 'An error occurred and the OLX could not be saved.',
27+
description: 'Error message shown when saving the OLX fails.',
28+
},
29+
advancedDetailsOLXError: {
30+
id: 'course-authoring.library-authoring.component.advanced.olx-error',
31+
defaultMessage: 'Unable to load OLX',
32+
description: 'Error message if OLX is unavailable',
33+
},
1434
advancedDetailsUsageKey: {
1535
id: 'course-authoring.library-authoring.component.advanced.usage-key',
1636
defaultMessage: 'ID (Usage key)',

src/library-authoring/data/api.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,15 @@ export async function getXBlockOLX(usageKey: string): Promise<string> {
318318
return data.olx;
319319
}
320320

321+
/**
322+
* Set the OLX for the given XBlock.
323+
* Returns the OLX as it was actually saved.
324+
*/
325+
export async function setXBlockOLX(usageKey: string, newOLX: string): Promise<string> {
326+
const { data } = await getAuthenticatedHttpClient().post(getXBlockOLXApiUrl(usageKey), { olx: newOLX });
327+
return data.olx;
328+
}
329+
321330
/**
322331
* Get the collection metadata.
323332
*/

src/library-authoring/data/apiHooks.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
updateCollectionComponents,
3232
type CreateLibraryCollectionDataRequest,
3333
getCollectionMetadata,
34+
setXBlockOLX,
3435
} from './api';
3536

3637
export const libraryQueryPredicate = (query: Query, libraryId: string): boolean => {
@@ -272,7 +273,7 @@ export const useCreateLibraryCollection = (libraryId: string) => {
272273
});
273274
};
274275

275-
/* istanbul ignore next */ // This is only used in developer builds, and the associated UI doesn't work in test or prod
276+
/** Get the OLX source of a library component */
276277
export const useXBlockOLX = (usageKey: string) => (
277278
useQuery({
278279
queryKey: xblockQueryKeys.xblockOLX(usageKey),
@@ -281,6 +282,25 @@ export const useXBlockOLX = (usageKey: string) => (
281282
})
282283
);
283284

285+
/**
286+
* Update the OLX of a library component (advanced feature)
287+
*/
288+
export const useUpdateXBlockOLX = (usageKey: string) => {
289+
const contentLibraryId = getLibraryId(usageKey);
290+
const queryClient = useQueryClient();
291+
return useMutation({
292+
mutationFn: (newOLX: string) => setXBlockOLX(usageKey, newOLX),
293+
onSuccess: (olxFromServer) => {
294+
queryClient.setQueryData(xblockQueryKeys.xblockOLX(usageKey), olxFromServer);
295+
// Reload the other data for this component:
296+
invalidateComponentData(queryClient, contentLibraryId, usageKey);
297+
// And the description and display name etc. may have changed, so refresh everything in the library too:
298+
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(contentLibraryId) });
299+
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, contentLibraryId) });
300+
},
301+
});
302+
};
303+
284304
/**
285305
* Get the metadata for a collection in a library
286306
*/

0 commit comments

Comments
 (0)