Skip to content

Commit

Permalink
feat: add library component sidebar
Browse files Browse the repository at this point in the history
  • Loading branch information
rpenido committed Aug 16, 2024
1 parent 95ac098 commit dfb46e4
Show file tree
Hide file tree
Showing 13 changed files with 582 additions and 60 deletions.
101 changes: 56 additions & 45 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,20 @@ 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
// Call 2: To fetch the recently modified components only
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, 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,17 +208,9 @@ describe('<LibraryAuthoringPage />', () => {
});

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

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

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

expect(await findByText('Content library')).toBeInTheDocument();
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
Expand Down Expand Up @@ -265,10 +280,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 @@ -327,10 +339,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 @@ -347,10 +356,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 @@ -366,10 +372,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,17 +392,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 />);

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

expect(getByText('Content library')).toBeInTheDocument();
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
Expand Down Expand Up @@ -458,13 +453,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, getByText, getByTitle,
} = render(<RootWrapper />);
} = await renderLibraryPage();

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

Expand Down Expand Up @@ -504,4 +495,24 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.click(getAllByText('Clear Filters')[0]);
await testSortOption('', '');
});

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

// Click on the first component
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());
});
});
31 changes: 28 additions & 3 deletions src/library-authoring/common/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,67 @@ import React from 'react';
export enum SidebarBodyComponentId {
AddContent = 'add-content',
Info = 'info',
ComponentInfo = 'component-info',
}

export interface LibraryContextData {
sidebarBodyComponent: SidebarBodyComponentId | null;
closeLibrarySidebar: () => void;
openAddContentSidebar: () => void;
openInfoSidebar: () => void;
openComponentInfoSidebar: (usageKey: string) => void;
currentComponentUsageKey?: string;
}

export const LibraryContext = React.createContext({
sidebarBodyComponent: null,
closeLibrarySidebar: () => {},
openAddContentSidebar: () => {},
openInfoSidebar: () => {},
openComponentInfoSidebar: (_usageKey: string) => {}, // eslint-disable-line @typescript-eslint/no-unused-vars
} as LibraryContextData);

/**
* React component to provide `LibraryContext`
*/
export const LibraryProvider = (props: { children?: React.ReactNode }) => {
const [sidebarBodyComponent, setSidebarBodyComponent] = React.useState<SidebarBodyComponentId | null>(null);
const [currentComponentUsageKey, setCurrentComponentUsageKey] = React.useState<string>();

const closeLibrarySidebar = React.useCallback(() => setSidebarBodyComponent(null), []);
const openAddContentSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.AddContent), []);
const openInfoSidebar = React.useCallback(() => setSidebarBodyComponent(SidebarBodyComponentId.Info), []);
const closeLibrarySidebar = React.useCallback(() => {
setSidebarBodyComponent(null);
setCurrentComponentUsageKey(undefined);
}, []);
const openAddContentSidebar = React.useCallback(() => {
setCurrentComponentUsageKey(undefined);
setSidebarBodyComponent(SidebarBodyComponentId.AddContent);
}, []);
const openInfoSidebar = React.useCallback(() => {
setCurrentComponentUsageKey(undefined);
setSidebarBodyComponent(SidebarBodyComponentId.Info);
}, []);
const openComponentInfoSidebar = React.useCallback(
(usageKey: string) => {
setCurrentComponentUsageKey(usageKey);
setSidebarBodyComponent(SidebarBodyComponentId.ComponentInfo);
},
[],
);

const context = React.useMemo(() => ({
sidebarBodyComponent,
closeLibrarySidebar,
openAddContentSidebar,
openInfoSidebar,
openComponentInfoSidebar,
currentComponentUsageKey,
}), [
sidebarBodyComponent,
closeLibrarySidebar,
openAddContentSidebar,
openInfoSidebar,
openComponentInfoSidebar,
currentComponentUsageKey,
]);

return (
Expand Down
51 changes: 51 additions & 0 deletions src/library-authoring/component-info/ComponentInfo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/* eslint-disable react/require-default-props */
import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button,
Tab,
Tabs,
Stack,
} from '@openedx/paragon';

import { ComponentMenu } from '../components';
import messages from './messages';

interface ComponentInfoProps {
usageKey: string;
}

const ComponentInfo = ({ usageKey } : ComponentInfoProps) => {
const intl = useIntl();

return (
<Stack>
<Stack direction="horizontal" className="d-flex justify-content-around">
<Button disabled variant="outline-primary rounded-0">
{intl.formatMessage(messages.editComponentButtonTitle)}
</Button>
<Button disabled variant="outline-primary rounded-0">
{intl.formatMessage(messages.publishComponentButtonTitle)}
</Button>
<ComponentMenu usageKey={usageKey} />
</Stack>
<Tabs
variant="tabs"
className="my-3 d-flex justify-content-around"
defaultActiveKey="preview"
>
<Tab eventKey="preview" title={intl.formatMessage(messages.previewTabTitle)}>
Preview tab placeholder
</Tab>
<Tab eventKey="manage" title={intl.formatMessage(messages.manageTabTitle)}>
Manage tab placeholder
</Tab>
<Tab eventKey="details" title={intl.formatMessage(messages.detailsTabTitle)}>
Details tab placeholder
</Tab>
</Tabs>
</Stack>
);
};

export default ComponentInfo;
Loading

0 comments on commit dfb46e4

Please sign in to comment.