Skip to content

Commit

Permalink
feat: add library component sidebar [FC-0062] (#1217)
Browse files Browse the repository at this point in the history
  • Loading branch information
rpenido authored Aug 29, 2024
1 parent 64ffadd commit 48e0ec1
Show file tree
Hide file tree
Showing 16 changed files with 679 additions and 142 deletions.
6 changes: 6 additions & 0 deletions src/library-authoring/LibraryAuthoringPage.scss
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,9 @@
}
}
}

.library-authoring-sidebar {
min-width: 300px;
max-width: map-get($grid-breakpoints, "sm");
z-index: 1001; // to appear over header
}
95 changes: 57 additions & 38 deletions src/library-authoring/LibraryAuthoringPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ import {
render,
waitFor,
screen,
within,
} from '@testing-library/react';
import fetchMock from 'fetch-mock-jest';
import initializeStore from '../store';
import { getContentSearchConfigUrl } from '../search-manager/data/api';
import mockResult from '../search-modal/__mocks__/search-result.json';
import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json';
import { getContentLibraryApiUrl, type ContentLibrary } from './data/api';
import { getContentLibraryApiUrl, getXBlockFieldsApiUrl, type ContentLibrary } from './data/api';
import { LibraryLayout } from '.';

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

const libraryData: ContentLibrary = {
Expand All @@ -97,6 +99,13 @@ const libraryData: ContentLibrary = {
updated: '2024-07-20',
};

const xBlockFields = {
display_name: 'Test HTML Block',
metadata: {
display_name: 'Test HTML Block',
},
};

const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
Expand Down Expand Up @@ -158,6 +167,19 @@ describe('<LibraryAuthoringPage />', () => {
queryClient.clear();
});

const renderLibraryPage = async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);

const result = render(<RootWrapper />);

// Ensure the search endpoint is called:
// Call 1: To fetch searchable/filterable/sortable library data
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });

return result;
};

it('shows the spinner before the query is complete', () => {
mockUseParams.mockReturnValue({ libraryId: '1' });
// @ts-ignore Use unresolved promise to keep the Loading visible
Expand Down Expand Up @@ -185,12 +207,9 @@ describe('<LibraryAuthoringPage />', () => {
});

it('show library data', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);

const {
getByRole, getAllByText, getByText, queryByText, findByText, findAllByText,
} = render(<RootWrapper />);
} = await renderLibraryPage();

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

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

it('show new content button', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);

render(<RootWrapper />);
await renderLibraryPage();

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

it('should open and close new content sidebar', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);

render(<RootWrapper />);
await renderLibraryPage();

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

it('should open Library Info by default', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);

render(<RootWrapper />);
await renderLibraryPage();

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

it('should close and open Library Info', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);

render(<RootWrapper />);
await renderLibraryPage();

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

it('show the "View All" button when viewing library with many components', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);

const {
getByRole, getByText, queryByText, getAllByText, findAllByText,
} = render(<RootWrapper />);

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

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

it('sort library components', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });

const {
findByTitle, getAllByText, getByRole, getByTitle,
} = render(<RootWrapper />);
} = await renderLibraryPage();

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

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

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

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

it('should open and close the component sidebar', async () => {
const usageKey = mockResult.results[0].hits[0].usage_key;
const { getAllByText, queryByTestId, queryByText } = await renderLibraryPage();
axiosMock.onGet(getXBlockFieldsApiUrl(usageKey)).reply(200, xBlockFields);

// Click on the first component
waitFor(() => expect(queryByText('Test HTML Block')).toBeInTheDocument());
fireEvent.click(getAllByText('Test HTML Block')[0]);

const sidebar = screen.getByTestId('library-sidebar');

const { getByRole, getByText } = within(sidebar);

await waitFor(() => expect(getByText('Test HTML Block')).toBeInTheDocument());

const closeButton = getByRole('button', { name: /close/i });
fireEvent.click(closeButton);

await waitFor(() => expect(queryByTestId('library-sidebar')).not.toBeInTheDocument());
});

it('filter by capa problem type', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
Expand Down
134 changes: 65 additions & 69 deletions src/library-authoring/LibraryAuthoringPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import {
Badge,
Button,
Col,
Container,
Row,
Stack,
Tab,
Tabs,
Expand Down Expand Up @@ -152,78 +150,76 @@ const LibraryAuthoringPage = () => {
};

return (
<Container className="library-authoring-page">
<Row>
<Col>
<Header
number={libraryData.slug}
title={libraryData.title}
org={libraryData.org}
contextId={libraryId}
isLibrary
/>
<div className="d-flex overflow-auto">
<div className="flex-grow-1 align-content-center">
<Header
number={libraryData.slug}
title={libraryData.title}
org={libraryData.org}
contextId={libraryId}
isLibrary
/>
<Container size="xl" className="px-4 mt-4 mb-5 library-authoring-page">
<SearchContextProvider
extraFilter={`context_key = "${libraryId}"`}
>
<Container size="xl" className="p-4 mt-3">
<SubHeader
title={<SubHeaderTitle title={libraryData.title} canEditLibrary={libraryData.canEditLibrary} />}
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={<HeaderActions canEditLibrary={libraryData.canEditLibrary} />}
<SubHeader
title={<SubHeaderTitle title={libraryData.title} canEditLibrary={libraryData.canEditLibrary} />}
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={<HeaderActions canEditLibrary={libraryData.canEditLibrary} />}
/>
<SearchKeywordsField className="w-50" />
<div className="d-flex mt-3 align-items-center">
<FilterByTags />
<FilterByBlockType />
<ClearFiltersButton />
<div className="flex-grow-1" />
<SearchSortWidget />
</div>
<Tabs
variant="tabs"
activeKey={activeKey}
onSelect={handleTabChange}
className="my-3"
>
<Tab eventKey={TabList.home} title={intl.formatMessage(messages.homeTab)} />
<Tab eventKey={TabList.components} title={intl.formatMessage(messages.componentsTab)} />
<Tab eventKey={TabList.collections} title={intl.formatMessage(messages.collectionsTab)} />
</Tabs>
<Routes>
<Route
path={TabList.home}
element={(
<LibraryHome
libraryId={libraryId}
tabList={TabList}
handleTabChange={handleTabChange}
/>
)}
/>
<SearchKeywordsField className="w-50" />
<div className="d-flex mt-3 align-items-center">
<FilterByTags />
<FilterByBlockType />
<ClearFiltersButton />
<div className="flex-grow-1" />
<SearchSortWidget />
</div>
<Tabs
variant="tabs"
activeKey={activeKey}
onSelect={handleTabChange}
className="my-3"
>
<Tab eventKey={TabList.home} title={intl.formatMessage(messages.homeTab)} />
<Tab eventKey={TabList.components} title={intl.formatMessage(messages.componentsTab)} />
<Tab eventKey={TabList.collections} title={intl.formatMessage(messages.collectionsTab)} />
</Tabs>
<Routes>
<Route
path={TabList.home}
element={(
<LibraryHome
libraryId={libraryId}
tabList={TabList}
handleTabChange={handleTabChange}
/>
)}
/>
<Route
path={TabList.components}
element={<LibraryComponents libraryId={libraryId} variant="full" />}
/>
<Route
path={TabList.collections}
element={<LibraryCollections />}
/>
<Route
path="*"
element={<NotFoundAlert />}
/>
</Routes>
</Container>
<Route
path={TabList.components}
element={<LibraryComponents libraryId={libraryId} variant="full" />}
/>
<Route
path={TabList.collections}
element={<LibraryCollections />}
/>
<Route
path="*"
element={<NotFoundAlert />}
/>
</Routes>
</SearchContextProvider>
<StudioFooter />
</Col>
{ sidebarBodyComponent !== null && (
<Col xs={3} md={3} className="box-shadow-left-1">
<LibrarySidebar library={libraryData} />
</Col>
)}
</Row>
</Container>
</Container>
<StudioFooter />
</div>
{ !!sidebarBodyComponent && (
<div className="library-authoring-sidebar box-shadow-left-1 bg-white" data-testid="library-sidebar">
<LibrarySidebar library={libraryData} />
</div>
)}
</div>
);
};

Expand Down
Loading

0 comments on commit 48e0ec1

Please sign in to comment.