Skip to content

Commit 48e0ec1

Browse files
authored
feat: add library component sidebar [FC-0062] (#1217)
1 parent 64ffadd commit 48e0ec1

16 files changed

+679
-142
lines changed

src/library-authoring/LibraryAuthoringPage.scss

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,9 @@
99
}
1010
}
1111
}
12+
13+
.library-authoring-sidebar {
14+
min-width: 300px;
15+
max-width: map-get($grid-breakpoints, "sm");
16+
z-index: 1001; // to appear over header
17+
}

src/library-authoring/LibraryAuthoringPage.test.tsx

Lines changed: 57 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@ import {
1010
render,
1111
waitFor,
1212
screen,
13+
within,
1314
} from '@testing-library/react';
1415
import fetchMock from 'fetch-mock-jest';
1516
import initializeStore from '../store';
1617
import { getContentSearchConfigUrl } from '../search-manager/data/api';
1718
import mockResult from '../search-modal/__mocks__/search-result.json';
1819
import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json';
19-
import { getContentLibraryApiUrl, type ContentLibrary } from './data/api';
20+
import { getContentLibraryApiUrl, getXBlockFieldsApiUrl, type ContentLibrary } from './data/api';
2021
import { LibraryLayout } from '.';
2122

2223
let store;
@@ -61,16 +62,17 @@ const returnEmptyResult = (_url, req) => {
6162
const returnLowNumberResults = (_url, req) => {
6263
const requestData = JSON.parse(req.body?.toString() ?? '');
6364
const query = requestData?.queries[0]?.q ?? '';
65+
const newMockResult = { ...mockResult };
6466
// We have to replace the query (search keywords) in the mock results with the actual query,
6567
// because otherwise we may have an inconsistent state that causes more queries and unexpected results.
66-
mockResult.results[0].query = query;
68+
newMockResult.results[0].query = query;
6769
// Limit number of results to just 2
68-
mockResult.results[0].hits = mockResult.results[0]?.hits.slice(0, 2);
69-
mockResult.results[0].estimatedTotalHits = 2;
70+
newMockResult.results[0].hits = mockResult.results[0]?.hits.slice(0, 2);
71+
newMockResult.results[0].estimatedTotalHits = 2;
7072
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
7173
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
72-
mockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
73-
return mockResult;
74+
newMockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
75+
return newMockResult;
7476
};
7577

7678
const libraryData: ContentLibrary = {
@@ -97,6 +99,13 @@ const libraryData: ContentLibrary = {
9799
updated: '2024-07-20',
98100
};
99101

102+
const xBlockFields = {
103+
display_name: 'Test HTML Block',
104+
metadata: {
105+
display_name: 'Test HTML Block',
106+
},
107+
};
108+
100109
const clipboardBroadcastChannelMock = {
101110
postMessage: jest.fn(),
102111
close: jest.fn(),
@@ -158,6 +167,19 @@ describe('<LibraryAuthoringPage />', () => {
158167
queryClient.clear();
159168
});
160169

170+
const renderLibraryPage = async () => {
171+
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
172+
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
173+
174+
const result = render(<RootWrapper />);
175+
176+
// Ensure the search endpoint is called:
177+
// Call 1: To fetch searchable/filterable/sortable library data
178+
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
179+
180+
return result;
181+
};
182+
161183
it('shows the spinner before the query is complete', () => {
162184
mockUseParams.mockReturnValue({ libraryId: '1' });
163185
// @ts-ignore Use unresolved promise to keep the Loading visible
@@ -185,12 +207,9 @@ describe('<LibraryAuthoringPage />', () => {
185207
});
186208

187209
it('show library data', async () => {
188-
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
189-
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
190-
191210
const {
192211
getByRole, getAllByText, getByText, queryByText, findByText, findAllByText,
193-
} = render(<RootWrapper />);
212+
} = await renderLibraryPage();
194213

195214
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
196215

@@ -263,10 +282,7 @@ describe('<LibraryAuthoringPage />', () => {
263282
});
264283

265284
it('show new content button', async () => {
266-
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
267-
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
268-
269-
render(<RootWrapper />);
285+
await renderLibraryPage();
270286

271287
expect(await screen.findByRole('heading')).toBeInTheDocument();
272288
expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument();
@@ -322,10 +338,7 @@ describe('<LibraryAuthoringPage />', () => {
322338
});
323339

324340
it('should open and close new content sidebar', async () => {
325-
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
326-
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
327-
328-
render(<RootWrapper />);
341+
await renderLibraryPage();
329342

330343
expect(await screen.findByRole('heading')).toBeInTheDocument();
331344
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
@@ -342,10 +355,7 @@ describe('<LibraryAuthoringPage />', () => {
342355
});
343356

344357
it('should open Library Info by default', async () => {
345-
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
346-
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
347-
348-
render(<RootWrapper />);
358+
await renderLibraryPage();
349359

350360
expect(await screen.findByText('Content library')).toBeInTheDocument();
351361
expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument();
@@ -361,10 +371,7 @@ describe('<LibraryAuthoringPage />', () => {
361371
});
362372

363373
it('should close and open Library Info', async () => {
364-
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
365-
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
366-
367-
render(<RootWrapper />);
374+
await renderLibraryPage();
368375

369376
expect(await screen.findByText('Content library')).toBeInTheDocument();
370377
expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument();
@@ -389,14 +396,9 @@ describe('<LibraryAuthoringPage />', () => {
389396
});
390397

391398
it('show the "View All" button when viewing library with many components', async () => {
392-
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
393-
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
394-
395399
const {
396400
getByRole, getByText, queryByText, getAllByText, findAllByText,
397-
} = render(<RootWrapper />);
398-
399-
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
401+
} = await renderLibraryPage();
400402

401403
expect(getByText('Content library')).toBeInTheDocument();
402404
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
@@ -456,13 +458,9 @@ describe('<LibraryAuthoringPage />', () => {
456458
});
457459

458460
it('sort library components', async () => {
459-
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
460-
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
461-
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
462-
463461
const {
464462
findByTitle, getAllByText, getByRole, getByTitle,
465-
} = render(<RootWrapper />);
463+
} = await renderLibraryPage();
466464

467465
expect(await findByTitle('Sort search results')).toBeInTheDocument();
468466

@@ -514,7 +512,7 @@ describe('<LibraryAuthoringPage />', () => {
514512

515513
// Re-selecting the previous sort option resets sort to default "Recently Modified"
516514
await testSortOption('Recently Published', 'modified:desc', true);
517-
expect(getAllByText('Recently Modified').length).toEqual(2);
515+
expect(getAllByText('Recently Modified').length).toEqual(3);
518516

519517
// Enter a keyword into the search box
520518
const searchBox = getByRole('searchbox');
@@ -531,6 +529,27 @@ describe('<LibraryAuthoringPage />', () => {
531529
});
532530
});
533531

532+
it('should open and close the component sidebar', async () => {
533+
const usageKey = mockResult.results[0].hits[0].usage_key;
534+
const { getAllByText, queryByTestId, queryByText } = await renderLibraryPage();
535+
axiosMock.onGet(getXBlockFieldsApiUrl(usageKey)).reply(200, xBlockFields);
536+
537+
// Click on the first component
538+
waitFor(() => expect(queryByText('Test HTML Block')).toBeInTheDocument());
539+
fireEvent.click(getAllByText('Test HTML Block')[0]);
540+
541+
const sidebar = screen.getByTestId('library-sidebar');
542+
543+
const { getByRole, getByText } = within(sidebar);
544+
545+
await waitFor(() => expect(getByText('Test HTML Block')).toBeInTheDocument());
546+
547+
const closeButton = getByRole('button', { name: /close/i });
548+
fireEvent.click(closeButton);
549+
550+
await waitFor(() => expect(queryByTestId('library-sidebar')).not.toBeInTheDocument());
551+
});
552+
534553
it('filter by capa problem type', async () => {
535554
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
536555
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);

src/library-authoring/LibraryAuthoringPage.tsx

Lines changed: 65 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
55
import {
66
Badge,
77
Button,
8-
Col,
98
Container,
10-
Row,
119
Stack,
1210
Tab,
1311
Tabs,
@@ -152,78 +150,76 @@ const LibraryAuthoringPage = () => {
152150
};
153151

154152
return (
155-
<Container className="library-authoring-page">
156-
<Row>
157-
<Col>
158-
<Header
159-
number={libraryData.slug}
160-
title={libraryData.title}
161-
org={libraryData.org}
162-
contextId={libraryId}
163-
isLibrary
164-
/>
153+
<div className="d-flex overflow-auto">
154+
<div className="flex-grow-1 align-content-center">
155+
<Header
156+
number={libraryData.slug}
157+
title={libraryData.title}
158+
org={libraryData.org}
159+
contextId={libraryId}
160+
isLibrary
161+
/>
162+
<Container size="xl" className="px-4 mt-4 mb-5 library-authoring-page">
165163
<SearchContextProvider
166164
extraFilter={`context_key = "${libraryId}"`}
167165
>
168-
<Container size="xl" className="p-4 mt-3">
169-
<SubHeader
170-
title={<SubHeaderTitle title={libraryData.title} canEditLibrary={libraryData.canEditLibrary} />}
171-
subtitle={intl.formatMessage(messages.headingSubtitle)}
172-
headerActions={<HeaderActions canEditLibrary={libraryData.canEditLibrary} />}
166+
<SubHeader
167+
title={<SubHeaderTitle title={libraryData.title} canEditLibrary={libraryData.canEditLibrary} />}
168+
subtitle={intl.formatMessage(messages.headingSubtitle)}
169+
headerActions={<HeaderActions canEditLibrary={libraryData.canEditLibrary} />}
170+
/>
171+
<SearchKeywordsField className="w-50" />
172+
<div className="d-flex mt-3 align-items-center">
173+
<FilterByTags />
174+
<FilterByBlockType />
175+
<ClearFiltersButton />
176+
<div className="flex-grow-1" />
177+
<SearchSortWidget />
178+
</div>
179+
<Tabs
180+
variant="tabs"
181+
activeKey={activeKey}
182+
onSelect={handleTabChange}
183+
className="my-3"
184+
>
185+
<Tab eventKey={TabList.home} title={intl.formatMessage(messages.homeTab)} />
186+
<Tab eventKey={TabList.components} title={intl.formatMessage(messages.componentsTab)} />
187+
<Tab eventKey={TabList.collections} title={intl.formatMessage(messages.collectionsTab)} />
188+
</Tabs>
189+
<Routes>
190+
<Route
191+
path={TabList.home}
192+
element={(
193+
<LibraryHome
194+
libraryId={libraryId}
195+
tabList={TabList}
196+
handleTabChange={handleTabChange}
197+
/>
198+
)}
173199
/>
174-
<SearchKeywordsField className="w-50" />
175-
<div className="d-flex mt-3 align-items-center">
176-
<FilterByTags />
177-
<FilterByBlockType />
178-
<ClearFiltersButton />
179-
<div className="flex-grow-1" />
180-
<SearchSortWidget />
181-
</div>
182-
<Tabs
183-
variant="tabs"
184-
activeKey={activeKey}
185-
onSelect={handleTabChange}
186-
className="my-3"
187-
>
188-
<Tab eventKey={TabList.home} title={intl.formatMessage(messages.homeTab)} />
189-
<Tab eventKey={TabList.components} title={intl.formatMessage(messages.componentsTab)} />
190-
<Tab eventKey={TabList.collections} title={intl.formatMessage(messages.collectionsTab)} />
191-
</Tabs>
192-
<Routes>
193-
<Route
194-
path={TabList.home}
195-
element={(
196-
<LibraryHome
197-
libraryId={libraryId}
198-
tabList={TabList}
199-
handleTabChange={handleTabChange}
200-
/>
201-
)}
202-
/>
203-
<Route
204-
path={TabList.components}
205-
element={<LibraryComponents libraryId={libraryId} variant="full" />}
206-
/>
207-
<Route
208-
path={TabList.collections}
209-
element={<LibraryCollections />}
210-
/>
211-
<Route
212-
path="*"
213-
element={<NotFoundAlert />}
214-
/>
215-
</Routes>
216-
</Container>
200+
<Route
201+
path={TabList.components}
202+
element={<LibraryComponents libraryId={libraryId} variant="full" />}
203+
/>
204+
<Route
205+
path={TabList.collections}
206+
element={<LibraryCollections />}
207+
/>
208+
<Route
209+
path="*"
210+
element={<NotFoundAlert />}
211+
/>
212+
</Routes>
217213
</SearchContextProvider>
218-
<StudioFooter />
219-
</Col>
220-
{ sidebarBodyComponent !== null && (
221-
<Col xs={3} md={3} className="box-shadow-left-1">
222-
<LibrarySidebar library={libraryData} />
223-
</Col>
224-
)}
225-
</Row>
226-
</Container>
214+
</Container>
215+
<StudioFooter />
216+
</div>
217+
{ !!sidebarBodyComponent && (
218+
<div className="library-authoring-sidebar box-shadow-left-1 bg-white" data-testid="library-sidebar">
219+
<LibrarySidebar library={libraryData} />
220+
</div>
221+
)}
222+
</div>
227223
);
228224
};
229225

0 commit comments

Comments
 (0)