diff --git a/src/generic/CodeEditor.tsx b/src/generic/CodeEditor.tsx new file mode 100644 index 000000000..fececa421 --- /dev/null +++ b/src/generic/CodeEditor.tsx @@ -0,0 +1,54 @@ +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.test.tsx b/src/library-authoring/component-info/ComponentAdvancedInfo.test.tsx new file mode 100644 index 000000000..228a8434f --- /dev/null +++ b/src/library-authoring/component-info/ComponentAdvancedInfo.test.tsx @@ -0,0 +1,113 @@ +import { + fireEvent, + initializeMocks, + render, + screen, + waitFor, +} from '../../testUtils'; +import { + mockContentLibrary, + mockLibraryBlockMetadata, + mockSetXBlockOLX, + mockXBlockAssets, + mockXBlockOLX, +} from '../data/api.mocks'; +import { LibraryProvider } from '../common/context'; +import { ComponentAdvancedInfo } from './ComponentAdvancedInfo'; + +mockContentLibrary.applyMock(); +mockLibraryBlockMetadata.applyMock(); +mockXBlockAssets.applyMock(); +mockXBlockOLX.applyMock(); +const setOLXspy = mockSetXBlockOLX.applyMock(); + +const withLibraryId = (libraryId: string = mockContentLibrary.libraryId) => ({ + extraWrapper: ({ children }: { children: React.ReactNode }) => ( + {children} + ), +}); + +describe('', () => { + it('should display nothing when collapsed', async () => { + initializeMocks(); + render(, withLibraryId()); + const expandButton = await screen.findByRole('button', { name: /Advanced details/ }); + expect(expandButton).toBeInTheDocument(); + expect(screen.queryByText(mockLibraryBlockMetadata.usageKeyPublished)).not.toBeInTheDocument(); + }); + + it('should display the usage key of the block (when expanded)', async () => { + initializeMocks(); + render(, withLibraryId()); + const expandButton = await screen.findByRole('button', { name: /Advanced details/ }); + fireEvent.click(expandButton); + expect(await screen.findByText(mockLibraryBlockMetadata.usageKeyPublished)).toBeInTheDocument(); + }); + + it('should display the static assets of the block (when expanded)', async () => { + initializeMocks(); + render(, withLibraryId()); + const expandButton = await screen.findByRole('button', { name: /Advanced details/ }); + fireEvent.click(expandButton); + expect(await screen.findByText(/static\/image1\.png/)).toBeInTheDocument(); + expect(await screen.findByText(/\(12M\)/)).toBeInTheDocument(); // size of the above file + expect(await screen.findByText(/static\/data\.csv/)).toBeInTheDocument(); + expect(await screen.findByText(/\(8K\)/)).toBeInTheDocument(); // size of the above file + }); + + it('should display the OLX source of the block (when expanded)', async () => { + initializeMocks(); + render(, withLibraryId()); + const expandButton = await screen.findByRole('button', { name: /Advanced details/ }); + fireEvent.click(expandButton); + // Because of syntax highlighting, the OLX will be borken up by many different tags so we need to search for + // just a substring: + const olxPart = /This is a text component which uses/; + expect(await screen.findByText(olxPart)).toBeInTheDocument(); + }); + + it('does not display "Edit OLX" button when the library is read-only', async () => { + initializeMocks(); + render( + , + withLibraryId(mockContentLibrary.libraryIdReadOnly), + ); + const expandButton = await screen.findByRole('button', { name: /Advanced details/ }); + fireEvent.click(expandButton); + expect(screen.queryByRole('button', { name: /Edit OLX/ })).not.toBeInTheDocument(); + }); + + it('can edit the OLX', async () => { + initializeMocks(); + render(, withLibraryId()); + const expandButton = await screen.findByRole('button', { name: /Advanced details/ }); + fireEvent.click(expandButton); + const editButton = await screen.findByRole('button', { name: /Edit OLX/ }); + fireEvent.click(editButton); + + expect(setOLXspy).not.toHaveBeenCalled(); + + const saveButton = await screen.findByRole('button', { name: /Save/ }); + fireEvent.click(saveButton); + + await waitFor(() => expect(setOLXspy).toHaveBeenCalled()); + }); + + it('displays an error if editing the OLX failed', async () => { + initializeMocks(); + + setOLXspy.mockImplementation(async () => { + throw new Error('Example error - setting OLX failed'); + }); + + render(, withLibraryId()); + const expandButton = await screen.findByRole('button', { name: /Advanced details/ }); + fireEvent.click(expandButton); + const editButton = await screen.findByRole('button', { name: /Edit OLX/ }); + fireEvent.click(editButton); + const saveButton = await screen.findByRole('button', { name: /Save/ }); + fireEvent.click(saveButton); + + expect(await screen.findByText(/An error occurred and the OLX could not be saved./)).toBeInTheDocument(); + }); +}); diff --git a/src/library-authoring/component-info/ComponentAdvancedInfo.tsx b/src/library-authoring/component-info/ComponentAdvancedInfo.tsx new file mode 100644 index 000000000..2b221bea5 --- /dev/null +++ b/src/library-authoring/component-info/ComponentAdvancedInfo.tsx @@ -0,0 +1,119 @@ +/* eslint-disable no-nested-ternary */ +/* eslint-disable import/prefer-default-export */ +import React from 'react'; +import { + Alert, + Button, + Collapsible, + OverlayTrigger, + Tooltip, +} from '@openedx/paragon'; +import { FormattedMessage, FormattedNumber, useIntl } from '@edx/frontend-platform/i18n'; + +import { LoadingSpinner } from '../../generic/Loading'; +import { CodeEditor, EditorAccessor } from '../../generic/CodeEditor'; +import { useLibraryContext } from '../common/context'; +import { + useContentLibrary, + useUpdateXBlockOLX, + useXBlockAssets, + useXBlockOLX, +} from '../data/apiHooks'; +import messages from './messages'; + +interface Props { + usageKey: string; +} + +export const ComponentAdvancedInfo: React.FC = ({ usageKey }) => { + const intl = useIntl(); + const { libraryId } = useLibraryContext(); + const { data: library } = useContentLibrary(libraryId); + const canEditLibrary = library?.canEditLibrary ?? false; + const { data: olx, isLoading: isOLXLoading } = useXBlockOLX(usageKey); + const { data: assets, isLoading: areAssetsLoading } = useXBlockAssets(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).then(() => { + // Only if we succeeded: + setEditingOLX(false); + }).catch(() => { + // On error, an is shown below. We catch here to avoid the error propagating up. + }); + }, [editorRef, olxUpdater, intl]); + return ( + +
+

+

{usageKey}

+

+ {(() => { + if (isOLXLoading) { return ; } + if (!olx) { return ; } + return ( +
+ {olxUpdater.error && ( + +

+ {/* + TODO: fix the API so it returns 400 errors in a JSON object, not HTML 500 errors. Then display + a useful error message here like "parsing the XML failed on line 3". + (olxUpdater.error as Record)?.customAttributes?.httpErrorResponseData.errorMessage + */} +
+ )} + {olx} + { + isEditingOLX ? ( + <> + + + + ) : canEditLibrary ? ( + + + + )} + > + + + ) : ( + null + ) + } +
+ ); + })()} +

+
    + { areAssetsLoading ?
  • : null } + { assets?.map(a => ( +
  • + {a.path}{' '} + () +
  • + )) } +
+
+
+ ); +}; diff --git a/src/library-authoring/component-info/ComponentDetails.test.tsx b/src/library-authoring/component-info/ComponentDetails.test.tsx index b885cf7ef..30bf17e65 100644 --- a/src/library-authoring/component-info/ComponentDetails.test.tsx +++ b/src/library-authoring/component-info/ComponentDetails.test.tsx @@ -3,37 +3,50 @@ import { render, screen, } from '../../testUtils'; -import { mockLibraryBlockMetadata } from '../data/api.mocks'; +import { + mockContentLibrary, + mockLibraryBlockMetadata, + mockXBlockAssets, + mockXBlockOLX, +} from '../data/api.mocks'; +import { LibraryProvider } from '../common/context'; import ComponentDetails from './ComponentDetails'; +mockContentLibrary.applyMock(); +mockLibraryBlockMetadata.applyMock(); +mockXBlockAssets.applyMock(); +mockXBlockOLX.applyMock(); + +const withLibraryId = (libraryId: string = mockContentLibrary.libraryId) => ({ + extraWrapper: ({ children }: { children: React.ReactNode }) => ( + {children} + ), +}); + describe('', () => { - it('should render the component details loading', async () => { + beforeEach(() => { initializeMocks(); - mockLibraryBlockMetadata.applyMock(); - render(); + }); + + it('should render the component details loading', async () => { + render(, withLibraryId()); expect(await screen.findByText('Loading...')).toBeInTheDocument(); }); it('should render the component details error', async () => { - initializeMocks(); - mockLibraryBlockMetadata.applyMock(); - render(); + render(, withLibraryId()); expect(await screen.findByText(/Mocked request failed with status code 404/)).toBeInTheDocument(); }); it('should render the component usage', async () => { - initializeMocks(); - mockLibraryBlockMetadata.applyMock(); - render(); + render(, withLibraryId()); expect(await screen.findByText('Component Usage')).toBeInTheDocument(); // TODO: replace with actual data when implement tag list expect(screen.queryByText('This will show the courses that use this component.')).toBeInTheDocument(); }); it('should render the component history', async () => { - initializeMocks(); - mockLibraryBlockMetadata.applyMock(); - render(); + render(, withLibraryId()); // Show created date expect(await screen.findByText('June 20, 2024')).toBeInTheDocument(); // Show modified date diff --git a/src/library-authoring/component-info/ComponentDetails.tsx b/src/library-authoring/component-info/ComponentDetails.tsx index a5481eb22..a851e73d6 100644 --- a/src/library-authoring/component-info/ComponentDetails.tsx +++ b/src/library-authoring/component-info/ComponentDetails.tsx @@ -5,7 +5,7 @@ import AlertError from '../../generic/alert-error'; import Loading from '../../generic/Loading'; import { useLibraryBlockMetadata } from '../data/apiHooks'; import HistoryWidget from '../generic/history-widget'; -import { ComponentDeveloperInfo } from './ComponentDeveloperInfo'; +import { ComponentAdvancedInfo } from './ComponentAdvancedInfo'; import messages from './messages'; interface ComponentDetailsProps { @@ -46,10 +46,7 @@ const ComponentDetails = ({ usageKey }: ComponentDetailsProps) => { {...componentMetadata} />
- { - // istanbul ignore next: this is only shown in development - (process.env.NODE_ENV === 'development' ? : null) - } + ); }; diff --git a/src/library-authoring/component-info/ComponentDeveloperInfo.tsx b/src/library-authoring/component-info/ComponentDeveloperInfo.tsx deleted file mode 100644 index 8e73d1fdf..000000000 --- a/src/library-authoring/component-info/ComponentDeveloperInfo.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* istanbul ignore file */ -/* eslint-disable import/prefer-default-export */ -// This file doesn't need test coverage nor i18n because it's only seen by devs -import React from 'react'; -import { LoadingSpinner } from '../../generic/Loading'; -import { useXBlockOLX } from '../data/apiHooks'; - -interface Props { - usageKey: string; -} - -/* istanbul ignore next */ -export const ComponentDeveloperInfo: React.FC = ({ usageKey }) => { - const { data: olx, isLoading: isOLXLoading } = useXBlockOLX(usageKey); - return ( - <> -
-

Developer Component Details

-

(This panel is only visible in development builds.)

-
-
Usage key
-
{usageKey}
-
OLX
-
- { - olx ? {olx} : // eslint-disable-line - isOLXLoading ? : // eslint-disable-line - Error - } -
-
- - ); -}; diff --git a/src/library-authoring/component-info/ComponentPreview.test.tsx b/src/library-authoring/component-info/ComponentPreview.test.tsx new file mode 100644 index 000000000..859eb6cd1 --- /dev/null +++ b/src/library-authoring/component-info/ComponentPreview.test.tsx @@ -0,0 +1,35 @@ +import { + fireEvent, + initializeMocks, + render, + screen, +} from '../../testUtils'; +import { mockLibraryBlockMetadata } from '../data/api.mocks'; +import ComponentPreview from './ComponentPreview'; + +mockLibraryBlockMetadata.applyMock(); + +describe('', () => { + it('renders a preview of the component', async () => { + initializeMocks(); + const usageKey = mockLibraryBlockMetadata.usageKeyPublished; + render(); + const iframe = (await screen.findByTitle('Preview')) as HTMLIFrameElement; + expect(iframe.src).toEqual(`http://localhost:18000/xblocks/v2/${usageKey}/embed/student_view/`); + }); + + it('shows an expanded preview of the component', async () => { + initializeMocks(); + const usageKey = mockLibraryBlockMetadata.usageKeyPublished; + render(); + await screen.findByTitle('Preview'); // Wait for the preview to appear + const expandButton = screen.getByRole('button', { name: /Expand/ }); + fireEvent.click(expandButton); + + const dialog = await screen.findByRole('dialog', { name: /component preview/i }); + const dialogIframe = dialog.querySelector('iframe')!; + expect(dialogIframe).not.toBeNull(); + expect(dialogIframe).toHaveAttribute('title', 'Preview'); + expect(dialogIframe.src).toEqual(`http://localhost:18000/xblocks/v2/${usageKey}/embed/student_view/`); + }); +}); diff --git a/src/library-authoring/component-info/ComponentPreview.tsx b/src/library-authoring/component-info/ComponentPreview.tsx index fe71e94d6..19390063d 100644 --- a/src/library-authoring/component-info/ComponentPreview.tsx +++ b/src/library-authoring/component-info/ComponentPreview.tsx @@ -5,6 +5,7 @@ import { OpenInFull } from '@openedx/paragon/icons'; import { LibraryBlock } from '../LibraryBlock'; import messages from './messages'; +import { useLibraryBlockMetadata } from '../data/apiHooks'; interface ModalComponentPreviewProps { isOpen: boolean; @@ -36,6 +37,7 @@ const ComponentPreview = ({ usageKey }: ComponentPreviewProps) => { const intl = useIntl(); const [isModalOpen, openModal, closeModal] = useToggle(); + const { data: componentMetadata } = useLibraryBlockMetadata(usageKey); return ( <> @@ -49,7 +51,12 @@ const ComponentPreview = ({ usageKey }: ComponentPreviewProps) => { > {intl.formatMessage(messages.previewExpandButtonTitle)} - + { + // key=modified below is used to auto-refresh the preview when changes are made, e.g. via OLX editor + componentMetadata + ? + : null + } diff --git a/src/library-authoring/component-info/messages.ts b/src/library-authoring/component-info/messages.ts index 2847ab913..8f52cdfef 100644 --- a/src/library-authoring/component-info/messages.ts +++ b/src/library-authoring/component-info/messages.ts @@ -1,6 +1,56 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ + advancedDetailsTitle: { + id: 'course-authoring.library-authoring.component.advanced.title', + defaultMessage: 'Advanced details', + description: 'Heading for the advanced technical details of a component', + }, + advancedDetailsAssets: { + id: 'course-authoring.library-authoring.component.advanced.assets', + defaultMessage: 'Assets (Files)', + description: 'Heading for files attached to the component', + }, + advancedDetailsOLX: { + id: 'course-authoring.library-authoring.component.advanced.olx', + 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: 'Label for button to enable editing the OLX', + }, + advancedDetailsOLXSaveButton: { + id: 'course-authoring.library-authoring.component.advanced.olx-save', + defaultMessage: 'Save', + description: 'Button to save changes to the OLX', + }, + advancedDetailsOLXCancelButton: { + id: 'course-authoring.library-authoring.component.advanced.olx-save', + defaultMessage: 'Cancel', + description: 'Button to cancel changes to the OLX', + }, + 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)', + description: 'Heading for the component\'s ID', + }, editNameButtonAlt: { id: 'course-authoring.library-authoring.component.edit-name.alt', defaultMessage: 'Edit component name', diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index 34b027950..804b22405 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -1,5 +1,6 @@ /* istanbul ignore file */ import { mockContentTaxonomyTagsData } from '../../content-tags-drawer/data/api.mocks'; +import { getBlockType } from '../../generic/key-utils'; import { createAxiosError } from '../../testUtils'; import * as api from './api'; @@ -283,3 +284,53 @@ mockGetCollectionMetadata.collectionData = { mockGetCollectionMetadata.applyMock = () => { jest.spyOn(api, 'getCollectionMetadata').mockImplementation(mockGetCollectionMetadata); }; + +/** + * Mock for `getXBlockOLX()` + * + * This mock returns different data/responses depending on the ID of the block + * that you request. Use `mockXBlockOLX.applyMock()` to apply it to the whole + * test suite. + */ +export async function mockXBlockOLX(usageKey: string): Promise { + const thisMock = mockXBlockOLX; + switch (usageKey) { + case thisMock.usageKeyHtml: return thisMock.olxHtml; + default: { + const blockType = getBlockType(usageKey); + return `<${blockType}>This is mock OLX for usageKey "${usageKey}"`; + } + } +} +// Mock of a "regular" HTML (Text) block: +mockXBlockOLX.usageKeyHtml = mockXBlockFields.usageKeyHtml; +mockXBlockOLX.olxHtml = ` + + ${mockXBlockFields.dataHtml.data} + +`; +/** Apply this mock. Returns a spy object that can tell you if it's been called. */ +mockXBlockOLX.applyMock = () => jest.spyOn(api, 'getXBlockOLX').mockImplementation(mockXBlockOLX); + +/** + * Mock for `setXBlockOLX()` + */ +export async function mockSetXBlockOLX(_usageKey: string, newOLX: string): Promise { + return newOLX; +} +/** Apply this mock. Returns a spy object that can tell you if it's been called. */ +mockSetXBlockOLX.applyMock = () => jest.spyOn(api, 'setXBlockOLX').mockImplementation(mockSetXBlockOLX); + +/** + * Mock for `getXBlockAssets()` + * + * Use `getXBlockAssets.applyMock()` to apply it to the whole test suite. + */ +export async function mockXBlockAssets(): ReturnType { + return [ + { path: 'static/image1.png', url: 'https://cdn.test.none/image1.png', size: 12_345_000 }, + { path: 'static/data.csv', url: 'https://cdn.test.none/data.csv', size: 8_000 }, + ]; +} +/** Apply this mock. Returns a spy object that can tell you if it's been called. */ +mockXBlockAssets.applyMock = () => jest.spyOn(api, 'getXBlockAssets').mockImplementation(mockXBlockAssets); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 225acddb8..d549e673c 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -41,6 +41,10 @@ export const getXBlockFieldsApiUrl = (usageKey: string) => `${getApiBaseUrl()}/a * Get the URL for the xblock OLX API */ export const getXBlockOLXApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/libraries/v2/blocks/${usageKey}/olx/`; +/** + * Get the URL for the xblock Assets List API + */ +export const getXBlockAssetsApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/libraries/v2/blocks/${usageKey}/assets/`; /** * Get the URL for the Library Collections API. */ @@ -300,11 +304,31 @@ export async function createCollection(libraryId: string, collectionData: Create /** * Fetch the OLX for the given XBlock. */ +// istanbul ignore next export async function getXBlockOLX(usageKey: string): Promise { const { data } = await getAuthenticatedHttpClient().get(getXBlockOLXApiUrl(usageKey)); return data.olx; } +/** + * Set the OLX for the given XBlock. + * Returns the OLX as it was actually saved. + */ +// istanbul ignore next +export async function setXBlockOLX(usageKey: string, newOLX: string): Promise { + const { data } = await getAuthenticatedHttpClient().post(getXBlockOLXApiUrl(usageKey), { olx: newOLX }); + return data.olx; +} + +/** + * Fetch the asset (static file) list for the given XBlock. + */ +// istanbul ignore next +export async function getXBlockAssets(usageKey: string): Promise<{ path: string; url: string; size: number }[]> { + const { data } = await getAuthenticatedHttpClient().get(getXBlockAssetsApiUrl(usageKey)); + return data.files; +} + /** * Get the collection metadata. */ diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 27fad4302..752e91659 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -30,6 +30,8 @@ import { updateCollectionComponents, type CreateLibraryCollectionDataRequest, getCollectionMetadata, + setXBlockOLX, + getXBlockAssets, } from './api'; export const libraryQueryPredicate = (query: Query, libraryId: string): boolean => { @@ -75,6 +77,8 @@ export const xblockQueryKeys = { xblockFields: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'fields'], /** OLX (XML representation of the fields/content) */ xblockOLX: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'OLX'], + /** assets (static files) */ + xblockAssets: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'assets'], componentMetadata: (usageKey: string) => [...xblockQueryKeys.xblock(usageKey), 'componentMetadata'], }; @@ -255,7 +259,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), @@ -264,6 +268,34 @@ 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 list of assets (static files) attached to a library component */ +export const useXBlockAssets = (usageKey: string) => ( + useQuery({ + queryKey: xblockQueryKeys.xblockAssets(usageKey), + queryFn: () => getXBlockAssets(usageKey), + enabled: !!usageKey, + }) +); + /** * Get the metadata for a collection in a library */