Skip to content

Commit 75f937e

Browse files
feat: Libraries v2: Advanced Component Info & OLX Editor (#1346)
1 parent 85b5730 commit 75f937e

12 files changed

Lines changed: 515 additions & 54 deletions

src/generic/CodeEditor.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import React from 'react';
2+
import { basicSetup, EditorView } from 'codemirror';
3+
import { EditorState, Compartment } from '@codemirror/state';
4+
import { xml } from '@codemirror/lang-xml';
5+
6+
export type EditorAccessor = EditorView;
7+
8+
interface Props {
9+
readOnly?: boolean;
10+
children?: string;
11+
editorRef?: React.MutableRefObject<EditorAccessor | undefined>;
12+
}
13+
14+
export const CodeEditor: React.FC<Props> = ({
15+
readOnly = false,
16+
children = '',
17+
editorRef,
18+
}) => {
19+
const divRef = React.useRef<HTMLDivElement>(null);
20+
const language = React.useMemo(() => new Compartment(), []);
21+
const tabSize = React.useMemo(() => new Compartment(), []);
22+
23+
React.useEffect(() => {
24+
if (!divRef.current) { return; }
25+
const state = EditorState.create({
26+
doc: children,
27+
extensions: [
28+
basicSetup,
29+
language.of(xml()),
30+
tabSize.of(EditorState.tabSize.of(2)),
31+
EditorState.readOnly.of(readOnly),
32+
],
33+
});
34+
35+
const view = new EditorView({
36+
state,
37+
parent: divRef.current,
38+
});
39+
if (editorRef) {
40+
// eslint-disable-next-line no-param-reassign
41+
editorRef.current = view;
42+
}
43+
// eslint-disable-next-line consistent-return
44+
return () => {
45+
if (editorRef) {
46+
// eslint-disable-next-line no-param-reassign
47+
editorRef.current = undefined;
48+
}
49+
view.destroy(); // Clean up
50+
};
51+
}, [divRef.current, readOnly, editorRef]);
52+
53+
return <div ref={divRef} />;
54+
};
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import {
2+
fireEvent,
3+
initializeMocks,
4+
render,
5+
screen,
6+
waitFor,
7+
} from '../../testUtils';
8+
import {
9+
mockContentLibrary,
10+
mockLibraryBlockMetadata,
11+
mockSetXBlockOLX,
12+
mockXBlockAssets,
13+
mockXBlockOLX,
14+
} from '../data/api.mocks';
15+
import { LibraryProvider } from '../common/context';
16+
import { ComponentAdvancedInfo } from './ComponentAdvancedInfo';
17+
18+
mockContentLibrary.applyMock();
19+
mockLibraryBlockMetadata.applyMock();
20+
mockXBlockAssets.applyMock();
21+
mockXBlockOLX.applyMock();
22+
const setOLXspy = mockSetXBlockOLX.applyMock();
23+
24+
const withLibraryId = (libraryId: string = mockContentLibrary.libraryId) => ({
25+
extraWrapper: ({ children }: { children: React.ReactNode }) => (
26+
<LibraryProvider libraryId={libraryId}>{children}</LibraryProvider>
27+
),
28+
});
29+
30+
describe('<ComponentAdvancedInfo />', () => {
31+
it('should display nothing when collapsed', async () => {
32+
initializeMocks();
33+
render(<ComponentAdvancedInfo usageKey={mockLibraryBlockMetadata.usageKeyPublished} />, withLibraryId());
34+
const expandButton = await screen.findByRole('button', { name: /Advanced details/ });
35+
expect(expandButton).toBeInTheDocument();
36+
expect(screen.queryByText(mockLibraryBlockMetadata.usageKeyPublished)).not.toBeInTheDocument();
37+
});
38+
39+
it('should display the usage key of the block (when expanded)', async () => {
40+
initializeMocks();
41+
render(<ComponentAdvancedInfo usageKey={mockLibraryBlockMetadata.usageKeyPublished} />, withLibraryId());
42+
const expandButton = await screen.findByRole('button', { name: /Advanced details/ });
43+
fireEvent.click(expandButton);
44+
expect(await screen.findByText(mockLibraryBlockMetadata.usageKeyPublished)).toBeInTheDocument();
45+
});
46+
47+
it('should display the static assets of the block (when expanded)', async () => {
48+
initializeMocks();
49+
render(<ComponentAdvancedInfo usageKey={mockLibraryBlockMetadata.usageKeyPublished} />, withLibraryId());
50+
const expandButton = await screen.findByRole('button', { name: /Advanced details/ });
51+
fireEvent.click(expandButton);
52+
expect(await screen.findByText(/static\/image1\.png/)).toBeInTheDocument();
53+
expect(await screen.findByText(/\(12M\)/)).toBeInTheDocument(); // size of the above file
54+
expect(await screen.findByText(/static\/data\.csv/)).toBeInTheDocument();
55+
expect(await screen.findByText(/\(8K\)/)).toBeInTheDocument(); // size of the above file
56+
});
57+
58+
it('should display the OLX source of the block (when expanded)', async () => {
59+
initializeMocks();
60+
render(<ComponentAdvancedInfo usageKey={mockXBlockOLX.usageKeyHtml} />, withLibraryId());
61+
const expandButton = await screen.findByRole('button', { name: /Advanced details/ });
62+
fireEvent.click(expandButton);
63+
// Because of syntax highlighting, the OLX will be borken up by many different tags so we need to search for
64+
// just a substring:
65+
const olxPart = /This is a text component which uses/;
66+
expect(await screen.findByText(olxPart)).toBeInTheDocument();
67+
});
68+
69+
it('does not display "Edit OLX" button when the library is read-only', async () => {
70+
initializeMocks();
71+
render(
72+
<ComponentAdvancedInfo usageKey={mockXBlockOLX.usageKeyHtml} />,
73+
withLibraryId(mockContentLibrary.libraryIdReadOnly),
74+
);
75+
const expandButton = await screen.findByRole('button', { name: /Advanced details/ });
76+
fireEvent.click(expandButton);
77+
expect(screen.queryByRole('button', { name: /Edit OLX/ })).not.toBeInTheDocument();
78+
});
79+
80+
it('can edit the OLX', async () => {
81+
initializeMocks();
82+
render(<ComponentAdvancedInfo usageKey={mockLibraryBlockMetadata.usageKeyPublished} />, withLibraryId());
83+
const expandButton = await screen.findByRole('button', { name: /Advanced details/ });
84+
fireEvent.click(expandButton);
85+
const editButton = await screen.findByRole('button', { name: /Edit OLX/ });
86+
fireEvent.click(editButton);
87+
88+
expect(setOLXspy).not.toHaveBeenCalled();
89+
90+
const saveButton = await screen.findByRole('button', { name: /Save/ });
91+
fireEvent.click(saveButton);
92+
93+
await waitFor(() => expect(setOLXspy).toHaveBeenCalled());
94+
});
95+
96+
it('displays an error if editing the OLX failed', async () => {
97+
initializeMocks();
98+
99+
setOLXspy.mockImplementation(async () => {
100+
throw new Error('Example error - setting OLX failed');
101+
});
102+
103+
render(<ComponentAdvancedInfo usageKey={mockLibraryBlockMetadata.usageKeyPublished} />, withLibraryId());
104+
const expandButton = await screen.findByRole('button', { name: /Advanced details/ });
105+
fireEvent.click(expandButton);
106+
const editButton = await screen.findByRole('button', { name: /Edit OLX/ });
107+
fireEvent.click(editButton);
108+
const saveButton = await screen.findByRole('button', { name: /Save/ });
109+
fireEvent.click(saveButton);
110+
111+
expect(await screen.findByText(/An error occurred and the OLX could not be saved./)).toBeInTheDocument();
112+
});
113+
});
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/* eslint-disable no-nested-ternary */
2+
/* eslint-disable import/prefer-default-export */
3+
import React from 'react';
4+
import {
5+
Alert,
6+
Button,
7+
Collapsible,
8+
OverlayTrigger,
9+
Tooltip,
10+
} from '@openedx/paragon';
11+
import { FormattedMessage, FormattedNumber, useIntl } from '@edx/frontend-platform/i18n';
12+
13+
import { LoadingSpinner } from '../../generic/Loading';
14+
import { CodeEditor, EditorAccessor } from '../../generic/CodeEditor';
15+
import { useLibraryContext } from '../common/context';
16+
import {
17+
useContentLibrary,
18+
useUpdateXBlockOLX,
19+
useXBlockAssets,
20+
useXBlockOLX,
21+
} from '../data/apiHooks';
22+
import messages from './messages';
23+
24+
interface Props {
25+
usageKey: string;
26+
}
27+
28+
export const ComponentAdvancedInfo: React.FC<Props> = ({ usageKey }) => {
29+
const intl = useIntl();
30+
const { libraryId } = useLibraryContext();
31+
const { data: library } = useContentLibrary(libraryId);
32+
const canEditLibrary = library?.canEditLibrary ?? false;
33+
const { data: olx, isLoading: isOLXLoading } = useXBlockOLX(usageKey);
34+
const { data: assets, isLoading: areAssetsLoading } = useXBlockAssets(usageKey);
35+
const editorRef = React.useRef<EditorAccessor | undefined>(undefined);
36+
const [isEditingOLX, setEditingOLX] = React.useState(false);
37+
const olxUpdater = useUpdateXBlockOLX(usageKey);
38+
const updateOlx = React.useCallback(() => {
39+
const newOLX = editorRef.current?.state.doc.toString();
40+
if (!newOLX) {
41+
/* istanbul ignore next */
42+
throw new Error('Unable to get OLX string from codemirror.'); // Shouldn't happen.
43+
}
44+
olxUpdater.mutateAsync(newOLX).then(() => {
45+
// Only if we succeeded:
46+
setEditingOLX(false);
47+
}).catch(() => {
48+
// On error, an <Alert> is shown below. We catch here to avoid the error propagating up.
49+
});
50+
}, [editorRef, olxUpdater, intl]);
51+
return (
52+
<Collapsible
53+
styling="basic"
54+
title={intl.formatMessage(messages.advancedDetailsTitle)}
55+
>
56+
<dl>
57+
<h3 className="h5"><FormattedMessage {...messages.advancedDetailsUsageKey} /></h3>
58+
<p className="text-monospace small">{usageKey}</p>
59+
<h3 className="h5"><FormattedMessage {...messages.advancedDetailsOLX} /></h3>
60+
{(() => {
61+
if (isOLXLoading) { return <LoadingSpinner />; }
62+
if (!olx) { return <FormattedMessage {...messages.advancedDetailsOLXError} />; }
63+
return (
64+
<div className="mb-4">
65+
{olxUpdater.error && (
66+
<Alert variant="danger">
67+
<p><strong><FormattedMessage {...messages.advancedDetailsOLXEditFailed} /></strong></p>
68+
{/*
69+
TODO: fix the API so it returns 400 errors in a JSON object, not HTML 500 errors. Then display
70+
a useful error message here like "parsing the XML failed on line 3".
71+
(olxUpdater.error as Record<string, any>)?.customAttributes?.httpErrorResponseData.errorMessage
72+
*/}
73+
</Alert>
74+
)}
75+
<CodeEditor key={usageKey} readOnly={!isEditingOLX} editorRef={editorRef}>{olx}</CodeEditor>
76+
{
77+
isEditingOLX ? (
78+
<>
79+
<Button variant="primary" onClick={updateOlx} disabled={olxUpdater.isLoading}>
80+
<FormattedMessage {...messages.advancedDetailsOLXSaveButton} />
81+
</Button>
82+
<Button variant="link" onClick={() => setEditingOLX(false)} disabled={olxUpdater.isLoading}>
83+
<FormattedMessage {...messages.advancedDetailsOLXCancelButton} />
84+
</Button>
85+
</>
86+
) : canEditLibrary ? (
87+
<OverlayTrigger
88+
placement="bottom-start"
89+
overlay={(
90+
<Tooltip id="olx-edit-button">
91+
<FormattedMessage {...messages.advancedDetailsOLXEditWarning} />
92+
</Tooltip>
93+
)}
94+
>
95+
<Button variant="link" onClick={() => setEditingOLX(true)}>
96+
<FormattedMessage {...messages.advancedDetailsOLXEditButton} />
97+
</Button>
98+
</OverlayTrigger>
99+
) : (
100+
null
101+
)
102+
}
103+
</div>
104+
);
105+
})()}
106+
<h3 className="h5"><FormattedMessage {...messages.advancedDetailsAssets} /></h3>
107+
<ul>
108+
{ areAssetsLoading ? <li><LoadingSpinner /></li> : null }
109+
{ assets?.map(a => (
110+
<li key={a.path}>
111+
<a href={a.url}>{a.path}</a>{' '}
112+
(<FormattedNumber value={a.size} notation="compact" unit="byte" unitDisplay="narrow" />)
113+
</li>
114+
)) }
115+
</ul>
116+
</dl>
117+
</Collapsible>
118+
);
119+
};

src/library-authoring/component-info/ComponentDetails.test.tsx

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,37 +3,50 @@ import {
33
render,
44
screen,
55
} from '../../testUtils';
6-
import { mockLibraryBlockMetadata } from '../data/api.mocks';
6+
import {
7+
mockContentLibrary,
8+
mockLibraryBlockMetadata,
9+
mockXBlockAssets,
10+
mockXBlockOLX,
11+
} from '../data/api.mocks';
12+
import { LibraryProvider } from '../common/context';
713
import ComponentDetails from './ComponentDetails';
814

15+
mockContentLibrary.applyMock();
16+
mockLibraryBlockMetadata.applyMock();
17+
mockXBlockAssets.applyMock();
18+
mockXBlockOLX.applyMock();
19+
20+
const withLibraryId = (libraryId: string = mockContentLibrary.libraryId) => ({
21+
extraWrapper: ({ children }: { children: React.ReactNode }) => (
22+
<LibraryProvider libraryId={libraryId}>{children}</LibraryProvider>
23+
),
24+
});
25+
926
describe('<ComponentDetails />', () => {
10-
it('should render the component details loading', async () => {
27+
beforeEach(() => {
1128
initializeMocks();
12-
mockLibraryBlockMetadata.applyMock();
13-
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyThatNeverLoads} />);
29+
});
30+
31+
it('should render the component details loading', async () => {
32+
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyThatNeverLoads} />, withLibraryId());
1433
expect(await screen.findByText('Loading...')).toBeInTheDocument();
1534
});
1635

1736
it('should render the component details error', async () => {
18-
initializeMocks();
19-
mockLibraryBlockMetadata.applyMock();
20-
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyError404} />);
37+
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyError404} />, withLibraryId());
2138
expect(await screen.findByText(/Mocked request failed with status code 404/)).toBeInTheDocument();
2239
});
2340

2441
it('should render the component usage', async () => {
25-
initializeMocks();
26-
mockLibraryBlockMetadata.applyMock();
27-
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
42+
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />, withLibraryId());
2843
expect(await screen.findByText('Component Usage')).toBeInTheDocument();
2944
// TODO: replace with actual data when implement tag list
3045
expect(screen.queryByText('This will show the courses that use this component.')).toBeInTheDocument();
3146
});
3247

3348
it('should render the component history', async () => {
34-
initializeMocks();
35-
mockLibraryBlockMetadata.applyMock();
36-
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
49+
render(<ComponentDetails usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />, withLibraryId());
3750
// Show created date
3851
expect(await screen.findByText('June 20, 2024')).toBeInTheDocument();
3952
// Show modified date

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

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import AlertError from '../../generic/alert-error';
55
import Loading from '../../generic/Loading';
66
import { useLibraryBlockMetadata } from '../data/apiHooks';
77
import HistoryWidget from '../generic/history-widget';
8-
import { ComponentDeveloperInfo } from './ComponentDeveloperInfo';
8+
import { ComponentAdvancedInfo } from './ComponentAdvancedInfo';
99
import messages from './messages';
1010

1111
interface ComponentDetailsProps {
@@ -46,10 +46,7 @@ const ComponentDetails = ({ usageKey }: ComponentDetailsProps) => {
4646
{...componentMetadata}
4747
/>
4848
</div>
49-
{
50-
// istanbul ignore next: this is only shown in development
51-
(process.env.NODE_ENV === 'development' ? <ComponentDeveloperInfo usageKey={usageKey} /> : null)
52-
}
49+
<ComponentAdvancedInfo usageKey={usageKey} />
5350
</Stack>
5451
);
5552
};

0 commit comments

Comments
 (0)