feat: Libraries v2: Advanced Component Info & OLX Editor (#1346)
This commit is contained in:
54
src/generic/CodeEditor.tsx
Normal file
54
src/generic/CodeEditor.tsx
Normal 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} />;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
119
src/library-authoring/component-info/ComponentAdvancedInfo.tsx
Normal file
119
src/library-authoring/component-info/ComponentAdvancedInfo.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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/`);
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
</>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user