feat: Libraries v2: Advanced Component Info & OLX Editor (#1346)

This commit is contained in:
Braden MacDonald
2024-10-08 09:41:21 -07:00
committed by GitHub
parent 85b5730114
commit 75f937e11a
12 changed files with 515 additions and 54 deletions

View File

@@ -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<EditorAccessor | undefined>;
}
export const CodeEditor: React.FC<Props> = ({
readOnly = false,
children = '',
editorRef,
}) => {
const divRef = React.useRef<HTMLDivElement>(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 <div ref={divRef} />;
};

View File

@@ -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 }) => (
<LibraryProvider libraryId={libraryId}>{children}</LibraryProvider>
),
});
describe('<ComponentAdvancedInfo />', () => {
it('should display nothing when collapsed', async () => {
initializeMocks();
render(<ComponentAdvancedInfo usageKey={mockLibraryBlockMetadata.usageKeyPublished} />, 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(<ComponentAdvancedInfo usageKey={mockLibraryBlockMetadata.usageKeyPublished} />, 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(<ComponentAdvancedInfo usageKey={mockLibraryBlockMetadata.usageKeyPublished} />, 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(<ComponentAdvancedInfo usageKey={mockXBlockOLX.usageKeyHtml} />, 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(
<ComponentAdvancedInfo usageKey={mockXBlockOLX.usageKeyHtml} />,
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(<ComponentAdvancedInfo usageKey={mockLibraryBlockMetadata.usageKeyPublished} />, 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(<ComponentAdvancedInfo usageKey={mockLibraryBlockMetadata.usageKeyPublished} />, 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();
});
});

View File

@@ -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<Props> = ({ 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<EditorAccessor | undefined>(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 <Alert> is shown below. We catch here to avoid the error propagating up.
});
}, [editorRef, olxUpdater, intl]);
return (
<Collapsible
styling="basic"
title={intl.formatMessage(messages.advancedDetailsTitle)}
>
<dl>
<h3 className="h5"><FormattedMessage {...messages.advancedDetailsUsageKey} /></h3>
<p className="text-monospace small">{usageKey}</p>
<h3 className="h5"><FormattedMessage {...messages.advancedDetailsOLX} /></h3>
{(() => {
if (isOLXLoading) { return <LoadingSpinner />; }
if (!olx) { return <FormattedMessage {...messages.advancedDetailsOLXError} />; }
return (
<div className="mb-4">
{olxUpdater.error && (
<Alert variant="danger">
<p><strong><FormattedMessage {...messages.advancedDetailsOLXEditFailed} /></strong></p>
{/*
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<string, any>)?.customAttributes?.httpErrorResponseData.errorMessage
*/}
</Alert>
)}
<CodeEditor key={usageKey} readOnly={!isEditingOLX} editorRef={editorRef}>{olx}</CodeEditor>
{
isEditingOLX ? (
<>
<Button variant="primary" onClick={updateOlx} disabled={olxUpdater.isLoading}>
<FormattedMessage {...messages.advancedDetailsOLXSaveButton} />
</Button>
<Button variant="link" onClick={() => setEditingOLX(false)} disabled={olxUpdater.isLoading}>
<FormattedMessage {...messages.advancedDetailsOLXCancelButton} />
</Button>
</>
) : canEditLibrary ? (
<OverlayTrigger
placement="bottom-start"
overlay={(
<Tooltip id="olx-edit-button">
<FormattedMessage {...messages.advancedDetailsOLXEditWarning} />
</Tooltip>
)}
>
<Button variant="link" onClick={() => setEditingOLX(true)}>
<FormattedMessage {...messages.advancedDetailsOLXEditButton} />
</Button>
</OverlayTrigger>
) : (
null
)
}
</div>
);
})()}
<h3 className="h5"><FormattedMessage {...messages.advancedDetailsAssets} /></h3>
<ul>
{ areAssetsLoading ? <li><LoadingSpinner /></li> : null }
{ assets?.map(a => (
<li key={a.path}>
<a href={a.url}>{a.path}</a>{' '}
(<FormattedNumber value={a.size} notation="compact" unit="byte" unitDisplay="narrow" />)
</li>
)) }
</ul>
</dl>
</Collapsible>
);
};

View File

@@ -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 }) => (
<LibraryProvider libraryId={libraryId}>{children}</LibraryProvider>
),
});
describe('<ComponentDetails />', () => {
it('should render the component details loading', async () => {
beforeEach(() => {
initializeMocks();
mockLibraryBlockMetadata.applyMock();
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyThatNeverLoads} />);
});
it('should render the component details loading', async () => {
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyThatNeverLoads} />, withLibraryId());
expect(await screen.findByText('Loading...')).toBeInTheDocument();
});
it('should render the component details error', async () => {
initializeMocks();
mockLibraryBlockMetadata.applyMock();
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyError404} />);
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyError404} />, withLibraryId());
expect(await screen.findByText(/Mocked request failed with status code 404/)).toBeInTheDocument();
});
it('should render the component usage', async () => {
initializeMocks();
mockLibraryBlockMetadata.applyMock();
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />, 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(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />, withLibraryId());
// Show created date
expect(await screen.findByText('June 20, 2024')).toBeInTheDocument();
// Show modified date

View File

@@ -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}
/>
</div>
{
// istanbul ignore next: this is only shown in development
(process.env.NODE_ENV === 'development' ? <ComponentDeveloperInfo usageKey={usageKey} /> : null)
}
<ComponentAdvancedInfo usageKey={usageKey} />
</Stack>
);
};

View File

@@ -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<Props> = ({ usageKey }) => {
const { data: olx, isLoading: isOLXLoading } = useXBlockOLX(usageKey);
return (
<>
<hr className="w-100" />
<h3 className="h5">Developer Component Details</h3>
<p><small>(This panel is only visible in development builds.)</small></p>
<dl>
<dt>Usage key</dt>
<dd><code>{usageKey}</code></dd>
<dt>OLX</dt>
<dd>
{
olx ? <code className="micro">{olx}</code> : // eslint-disable-line
isOLXLoading ? <LoadingSpinner /> : // eslint-disable-line
<span>Error</span>
}
</dd>
</dl>
</>
);
};

View File

@@ -0,0 +1,35 @@
import {
fireEvent,
initializeMocks,
render,
screen,
} from '../../testUtils';
import { mockLibraryBlockMetadata } from '../data/api.mocks';
import ComponentPreview from './ComponentPreview';
mockLibraryBlockMetadata.applyMock();
describe('<ComponentPreview />', () => {
it('renders a preview of the component', async () => {
initializeMocks();
const usageKey = mockLibraryBlockMetadata.usageKeyPublished;
render(<ComponentPreview usageKey={usageKey} />);
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(<ComponentPreview usageKey={usageKey} />);
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/`);
});
});

View File

@@ -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)}
</Button>
<LibraryBlock usageKey={usageKey} />
{
// key=modified below is used to auto-refresh the preview when changes are made, e.g. via OLX editor
componentMetadata
? <LibraryBlock usageKey={usageKey} key={componentMetadata.modified} />
: null
}
</div>
<ModalComponentPreview isOpen={isModalOpen} close={closeModal} usageKey={usageKey} />
</>

View File

@@ -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',

View File

@@ -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<string> {
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}"</${blockType}>`;
}
}
}
// Mock of a "regular" HTML (Text) block:
mockXBlockOLX.usageKeyHtml = mockXBlockFields.usageKeyHtml;
mockXBlockOLX.olxHtml = `
<html display_name="${mockXBlockFields.dataHtml.displayName}">
${mockXBlockFields.dataHtml.data}
</html>
`;
/** 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<string> {
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<typeof api['getXBlockAssets']> {
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);

View File

@@ -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<string> {
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<string> {
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.
*/

View File

@@ -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
*/