Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Content Libraries: Advanced Component Info & OLX Editor [FC-0062] #1346

Merged
54 changes: 54 additions & 0 deletions src/generic/CodeEditor.tsx
Original file line number Diff line number Diff line change
@@ -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} />;
};
113 changes: 113 additions & 0 deletions src/library-authoring/component-info/ComponentAdvancedInfo.test.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 119 additions & 0 deletions src/library-authoring/component-info/ComponentAdvancedInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/* eslint-disable no-nested-ternary */
/* eslint-disable import/prefer-default-export */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not directly related here, but is there a reason why we don't remove this lint rule upstream?
Personally, I also prefer named exports.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I guess I've just been lazy, but it's time to do that. openedx/eslint-config#164

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>
);
};
39 changes: 26 additions & 13 deletions src/library-authoring/component-info/ComponentDetails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 2 additions & 5 deletions src/library-authoring/component-info/ComponentDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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>
);
};
Expand Down
Loading