feat: add library component sidebar [FC-0062] (#1217)
This commit is contained in:
@@ -9,3 +9,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.library-authoring-sidebar {
|
||||
min-width: 300px;
|
||||
max-width: map-get($grid-breakpoints, "sm");
|
||||
z-index: 1001; // to appear over header
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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 = {
|
||||
@@ -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(),
|
||||
@@ -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
|
||||
@@ -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'); });
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
@@ -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);
|
||||
|
||||
@@ -5,9 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Col,
|
||||
Container,
|
||||
Row,
|
||||
Stack,
|
||||
Tab,
|
||||
Tabs,
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from 'react';
|
||||
export enum SidebarBodyComponentId {
|
||||
AddContent = 'add-content',
|
||||
Info = 'info',
|
||||
ComponentInfo = 'component-info',
|
||||
}
|
||||
|
||||
export interface LibraryContextData {
|
||||
@@ -11,6 +12,8 @@ export interface LibraryContextData {
|
||||
closeLibrarySidebar: () => void;
|
||||
openAddContentSidebar: () => void;
|
||||
openInfoSidebar: () => void;
|
||||
openComponentInfoSidebar: (usageKey: string) => void;
|
||||
currentComponentUsageKey?: string;
|
||||
}
|
||||
|
||||
export const LibraryContext = React.createContext({
|
||||
@@ -18,6 +21,7 @@ export const LibraryContext = React.createContext({
|
||||
closeLibrarySidebar: () => {},
|
||||
openAddContentSidebar: () => {},
|
||||
openInfoSidebar: () => {},
|
||||
openComponentInfoSidebar: (_usageKey: string) => {}, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
} as LibraryContextData);
|
||||
|
||||
/**
|
||||
@@ -25,21 +29,42 @@ export const LibraryContext = React.createContext({
|
||||
*/
|
||||
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 (
|
||||
|
||||
50
src/library-authoring/component-info/ComponentInfo.tsx
Normal file
50
src/library-authoring/component-info/ComponentInfo.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
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>
|
||||
<div className="d-flex flex-wrap">
|
||||
<Button disabled variant="outline-primary" className="m-1 text-nowrap flex-grow-1">
|
||||
{intl.formatMessage(messages.editComponentButtonTitle)}
|
||||
</Button>
|
||||
<Button disabled variant="outline-primary" className="m-1 text-nowrap flex-grow-1">
|
||||
{intl.formatMessage(messages.publishComponentButtonTitle)}
|
||||
</Button>
|
||||
<ComponentMenu usageKey={usageKey} />
|
||||
</div>
|
||||
<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;
|
||||
@@ -0,0 +1,180 @@
|
||||
/* eslint-disable react/require-default-props */
|
||||
import React from 'react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
render,
|
||||
fireEvent,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { ContentLibrary, getXBlockFieldsApiUrl } from '../data/api';
|
||||
import initializeStore from '../../store';
|
||||
import { ToastProvider } from '../../generic/toast-context';
|
||||
import ComponentInfoHeader from './ComponentInfoHeader';
|
||||
|
||||
let store;
|
||||
let axiosMock;
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const libraryData: ContentLibrary = {
|
||||
id: 'lib:org1:lib1',
|
||||
type: 'complex',
|
||||
org: 'org1',
|
||||
slug: 'lib1',
|
||||
title: 'lib1',
|
||||
description: 'lib1',
|
||||
numBlocks: 2,
|
||||
version: 0,
|
||||
lastPublished: null,
|
||||
lastDraftCreated: '2024-07-22',
|
||||
publishedBy: 'staff',
|
||||
lastDraftCreatedBy: 'staff',
|
||||
allowLti: false,
|
||||
allowPublicLearning: false,
|
||||
allowPublicRead: false,
|
||||
hasUnpublishedChanges: true,
|
||||
hasUnpublishedDeletes: false,
|
||||
canEditLibrary: true,
|
||||
license: '',
|
||||
created: '2024-06-26',
|
||||
updated: '2024-07-20',
|
||||
};
|
||||
|
||||
interface WrapperProps {
|
||||
library?: ContentLibrary,
|
||||
}
|
||||
|
||||
const usageKey = 'lb:org1:library:html:a1fa8bdd-dc67-4976-9bf5-0ea75a9bca3d';
|
||||
const xBlockFields = {
|
||||
display_name: 'Test HTML Block',
|
||||
metadata: {
|
||||
display_name: 'Test HTML Block',
|
||||
},
|
||||
};
|
||||
|
||||
const RootWrapper = ({ library } : WrapperProps) => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<ComponentInfoHeader library={library || libraryData} usageKey={usageKey} />
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<ComponentInfoHeader />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
store = initializeStore();
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock.onGet(getXBlockFieldsApiUrl(usageKey)).reply(200, xBlockFields);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
axiosMock.restore();
|
||||
});
|
||||
|
||||
it('should render component info Header', async () => {
|
||||
render(<RootWrapper />);
|
||||
|
||||
expect(await screen.findByText('Test HTML Block')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /edit component name/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render edit title button without permission', () => {
|
||||
const library = {
|
||||
...libraryData,
|
||||
canEditLibrary: false,
|
||||
};
|
||||
|
||||
render(<RootWrapper library={library} />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /edit component name/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should edit component title', async () => {
|
||||
const url = getXBlockFieldsApiUrl(usageKey);
|
||||
axiosMock.onPost(url).reply(200);
|
||||
render(<RootWrapper />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit component name/i }));
|
||||
|
||||
const textBox = screen.getByRole('textbox', { name: /display name input/i });
|
||||
|
||||
fireEvent.change(textBox, { target: { value: 'New component name' } });
|
||||
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });
|
||||
|
||||
expect(textBox).not.toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post.length).toEqual(1);
|
||||
expect(axiosMock.history.post[0].url).toEqual(url);
|
||||
expect(axiosMock.history.post[0].data).toStrictEqual(JSON.stringify({
|
||||
metadata: { display_name: 'New component name' },
|
||||
}));
|
||||
expect(screen.getByText('Component updated successfully.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should close edit library title on press Escape', async () => {
|
||||
const url = getXBlockFieldsApiUrl(usageKey);
|
||||
axiosMock.onPost(url).reply(200);
|
||||
render(<RootWrapper />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit component name/i }));
|
||||
|
||||
const textBox = screen.getByRole('textbox', { name: /display name input/i });
|
||||
|
||||
fireEvent.change(textBox, { target: { value: 'New component name' } });
|
||||
fireEvent.keyDown(textBox, { key: 'Escape', code: 'Escape', charCode: 27 });
|
||||
|
||||
expect(textBox).not.toBeInTheDocument();
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.post.length).toEqual(0));
|
||||
});
|
||||
|
||||
it('should show error on edit library tittle', async () => {
|
||||
const url = getXBlockFieldsApiUrl(usageKey);
|
||||
axiosMock.onPatch(url).reply(500);
|
||||
|
||||
render(<RootWrapper />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit component name/i }));
|
||||
|
||||
const textBox = screen.getByRole('textbox', { name: /display name input/i });
|
||||
|
||||
fireEvent.change(textBox, { target: { value: 'New component name' } });
|
||||
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post.length).toEqual(1);
|
||||
expect(axiosMock.history.post[0].url).toEqual(url);
|
||||
expect(axiosMock.history.post[0].data).toStrictEqual(JSON.stringify({
|
||||
metadata: { display_name: 'New component name' },
|
||||
}));
|
||||
|
||||
expect(screen.getByText('There was an error updating the component.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
97
src/library-authoring/component-info/ComponentInfoHeader.tsx
Normal file
97
src/library-authoring/component-info/ComponentInfoHeader.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useState, useContext, useCallback } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Icon,
|
||||
IconButton,
|
||||
Stack,
|
||||
Form,
|
||||
} from '@openedx/paragon';
|
||||
import { Edit } from '@openedx/paragon/icons';
|
||||
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import type { ContentLibrary } from '../data/api';
|
||||
import { useUpdateXBlockFields, useXBlockFields } from '../data/apiHooks';
|
||||
import messages from './messages';
|
||||
|
||||
interface ComponentInfoHeaderProps {
|
||||
library: ContentLibrary;
|
||||
usageKey: string;
|
||||
}
|
||||
|
||||
const ComponentInfoHeader = ({ library, usageKey }: ComponentInfoHeaderProps) => {
|
||||
const intl = useIntl();
|
||||
const [inputIsActive, setIsActive] = useState(false);
|
||||
|
||||
const {
|
||||
data: xblockFields,
|
||||
} = useXBlockFields(library.id, usageKey);
|
||||
|
||||
const updateMutation = useUpdateXBlockFields(library.id, usageKey);
|
||||
const { showToast } = useContext(ToastContext);
|
||||
|
||||
const handleSaveDisplayName = useCallback(
|
||||
(event) => {
|
||||
const newDisplayName = event.target.value;
|
||||
if (newDisplayName && newDisplayName !== xblockFields?.displayName) {
|
||||
updateMutation.mutateAsync({
|
||||
metadata: {
|
||||
display_name: newDisplayName,
|
||||
},
|
||||
}).then(() => {
|
||||
showToast(intl.formatMessage(messages.updateComponentSuccessMsg));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.updateComponentErrorMsg));
|
||||
});
|
||||
}
|
||||
setIsActive(false);
|
||||
},
|
||||
[xblockFields, showToast, intl],
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
setIsActive(true);
|
||||
};
|
||||
|
||||
const hanldeOnKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleSaveDisplayName(event);
|
||||
} else if (event.key === 'Escape') {
|
||||
setIsActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack direction="horizontal">
|
||||
{ inputIsActive
|
||||
? (
|
||||
<Form.Control
|
||||
autoFocus
|
||||
name="displayName"
|
||||
id="displayName"
|
||||
type="text"
|
||||
aria-label="Display name input"
|
||||
defaultValue={xblockFields?.displayName}
|
||||
onBlur={handleSaveDisplayName}
|
||||
onKeyDown={hanldeOnKeyDown}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<span className="font-weight-bold m-1.5">
|
||||
{xblockFields?.displayName}
|
||||
</span>
|
||||
{library.canEditLibrary && (
|
||||
<IconButton
|
||||
src={Edit}
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.editNameButtonAlt)}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComponentInfoHeader;
|
||||
2
src/library-authoring/component-info/index.tsx
Normal file
2
src/library-authoring/component-info/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as ComponentInfo } from './ComponentInfo';
|
||||
export { default as ComponentInfoHeader } from './ComponentInfoHeader';
|
||||
50
src/library-authoring/component-info/messages.ts
Normal file
50
src/library-authoring/component-info/messages.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { defineMessages as _defineMessages } from '@edx/frontend-platform/i18n';
|
||||
import type { defineMessages as defineMessagesType } from 'react-intl';
|
||||
|
||||
// frontend-platform currently doesn't provide types... do it ourselves.
|
||||
const defineMessages = _defineMessages as typeof defineMessagesType;
|
||||
|
||||
const messages = defineMessages({
|
||||
editNameButtonAlt: {
|
||||
id: 'course-authoring.library-authoring.component.edit-name.alt',
|
||||
defaultMessage: 'Edit component name',
|
||||
description: 'Alt text for edit component name icon button',
|
||||
},
|
||||
updateComponentSuccessMsg: {
|
||||
id: 'course-authoring.library-authoring.component.update.success',
|
||||
defaultMessage: 'Component updated successfully.',
|
||||
description: 'Message when the component is updated successfully',
|
||||
},
|
||||
updateComponentErrorMsg: {
|
||||
id: 'course-authoring.library-authoring.component.update.error',
|
||||
defaultMessage: 'There was an error updating the component.',
|
||||
description: 'Message when there is an error when updating the component',
|
||||
},
|
||||
editComponentButtonTitle: {
|
||||
id: 'course-authoring.library-authoring.component.edit.title',
|
||||
defaultMessage: 'Edit component',
|
||||
description: 'Title for edit component button',
|
||||
},
|
||||
publishComponentButtonTitle: {
|
||||
id: 'course-authoring.library-authoring.component.publish.title',
|
||||
defaultMessage: 'Publish component',
|
||||
description: 'Title for publish component button',
|
||||
},
|
||||
previewTabTitle: {
|
||||
id: 'course-authoring.library-authoring.component.preview-tab.title',
|
||||
defaultMessage: 'Preview',
|
||||
description: 'Title for preview tab',
|
||||
},
|
||||
manageTabTitle: {
|
||||
id: 'course-authoring.library-authoring.component.manage-tab.title',
|
||||
defaultMessage: 'Manage',
|
||||
description: 'Title for manage tab',
|
||||
},
|
||||
detailsTabTitle: {
|
||||
id: 'course-authoring.library-authoring.component.details-tab.title',
|
||||
defaultMessage: 'Details',
|
||||
description: 'Title for details tab',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -16,6 +16,7 @@ import { updateClipboard } from '../../generic/data/api';
|
||||
import TagCount from '../../generic/tag-count';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { type ContentHit, Highlight } from '../../search-manager';
|
||||
import { LibraryContext } from '../common/context';
|
||||
import messages from './messages';
|
||||
import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants';
|
||||
|
||||
@@ -24,7 +25,7 @@ type ComponentCardProps = {
|
||||
blockTypeDisplayName: string,
|
||||
};
|
||||
|
||||
const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
const intl = useIntl();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
|
||||
@@ -38,7 +39,7 @@ const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown id="component-card-dropdown">
|
||||
<Dropdown id="component-card-dropdown" onClick={(e) => e.stopPropagation()}>
|
||||
<Dropdown.Toggle
|
||||
id="component-card-menu-toggle"
|
||||
as={IconButton}
|
||||
@@ -64,6 +65,10 @@ const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
};
|
||||
|
||||
const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps) => {
|
||||
const {
|
||||
openComponentInfoSidebar,
|
||||
} = useContext(LibraryContext);
|
||||
|
||||
const {
|
||||
blockType,
|
||||
formatted,
|
||||
@@ -84,7 +89,15 @@ const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps
|
||||
|
||||
return (
|
||||
<Container className="library-component-card">
|
||||
<Card>
|
||||
<Card
|
||||
isClickable
|
||||
onClick={() => openComponentInfoSidebar(usageKey)}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (['Enter', ' '].includes(e.key)) {
|
||||
openComponentInfoSidebar(usageKey);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Card.Header
|
||||
className={`library-component-header ${getComponentStyleColor(blockType)}`}
|
||||
title={
|
||||
@@ -92,7 +105,7 @@ const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps
|
||||
}
|
||||
actions={(
|
||||
<ActionRow>
|
||||
<ComponentCardMenu usageKey={usageKey} />
|
||||
<ComponentMenu usageKey={usageKey} />
|
||||
</ActionRow>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -72,7 +72,7 @@ const LibraryComponents = ({ libraryId, variant }: LibraryComponentsProps) => {
|
||||
<CardGrid
|
||||
columnSizes={{
|
||||
sm: 12,
|
||||
md: 5,
|
||||
md: 6,
|
||||
lg: 4,
|
||||
xl: 3,
|
||||
}}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as LibraryComponents } from './LibraryComponents';
|
||||
export { ComponentMenu } from './ComponentCard';
|
||||
|
||||
@@ -24,6 +24,10 @@ export const getCommitLibraryChangesUrl = (libraryId: string) => `${getApiBaseUr
|
||||
* Get the URL for paste clipboard content into library.
|
||||
*/
|
||||
export const getLibraryPasteClipboardUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/paste_clipboard/`;
|
||||
/**
|
||||
* Get the URL for the xblock metadata API.
|
||||
*/
|
||||
export const getXBlockFieldsApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/xblock/v2/xblocks/${usageKey}/fields/`;
|
||||
|
||||
export interface ContentLibrary {
|
||||
id: string;
|
||||
@@ -64,6 +68,12 @@ export interface LibrariesV2Response {
|
||||
results: ContentLibrary[],
|
||||
}
|
||||
|
||||
export interface XBlockFields {
|
||||
displayName: string;
|
||||
metadata: Record<string, unknown>;
|
||||
data: string;
|
||||
}
|
||||
|
||||
/* Additional custom parameters for the API request. */
|
||||
export interface GetLibrariesV2CustomParams {
|
||||
/* (optional) Library type, default `complex` */
|
||||
@@ -110,6 +120,13 @@ export interface LibraryPasteClipboardRequest {
|
||||
blockId: string;
|
||||
}
|
||||
|
||||
export interface UpdateXBlockFieldsRequest {
|
||||
data?: unknown;
|
||||
metadata?: {
|
||||
display_name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch block types of a library
|
||||
*/
|
||||
@@ -211,3 +228,19 @@ export async function libraryPasteClipboard({
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch xblock fields.
|
||||
*/
|
||||
export async function getXBlockFields(usageKey: string): Promise<XBlockFields> {
|
||||
const { data } = await getAuthenticatedHttpClient().get(getXBlockFieldsApiUrl(usageKey));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update xblock fields.
|
||||
*/
|
||||
export async function updateXBlockFields(usageKey:string, xblockData: UpdateXBlockFieldsRequest) {
|
||||
const client = getAuthenticatedHttpClient();
|
||||
await client.post(getXBlockFieldsApiUrl(usageKey), xblockData);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import {
|
||||
useQuery, useMutation, useQueryClient, Query,
|
||||
useQuery, useMutation, useQueryClient, type Query,
|
||||
} from '@tanstack/react-query';
|
||||
|
||||
import {
|
||||
type GetLibrariesV2CustomParams,
|
||||
type ContentLibrary,
|
||||
type XBlockFields,
|
||||
type UpdateXBlockFieldsRequest,
|
||||
getContentLibrary,
|
||||
getLibraryBlockTypes,
|
||||
createLibraryBlock,
|
||||
@@ -11,10 +15,26 @@ import {
|
||||
commitLibraryChanges,
|
||||
revertLibraryChanges,
|
||||
updateLibraryMetadata,
|
||||
ContentLibrary,
|
||||
libraryPasteClipboard,
|
||||
getXBlockFields,
|
||||
updateXBlockFields,
|
||||
} from './api';
|
||||
|
||||
const libraryQueryPredicate = (query: Query, libraryId: string): boolean => {
|
||||
// Invalidate all content queries related to this library.
|
||||
// If we allow searching "all courses and libraries" in the future,
|
||||
// then we'd have to invalidate all `["content_search", "results"]`
|
||||
// queries, and not just the ones for this library, because items from
|
||||
// this library could be included in an "all courses and libraries"
|
||||
// search. For now we only allow searching individual libraries.
|
||||
const extraFilter = query.queryKey[5]; // extraFilter contains library id
|
||||
if (!(Array.isArray(extraFilter) || typeof extraFilter === 'string')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return query.queryKey[0] === 'content_search' && extraFilter?.includes(`context_key = "${libraryId}"`);
|
||||
};
|
||||
|
||||
export const libraryAuthoringQueryKeys = {
|
||||
all: ['contentLibrary'],
|
||||
/**
|
||||
@@ -32,6 +52,13 @@ export const libraryAuthoringQueryKeys = {
|
||||
'content',
|
||||
'libraryBlockTypes',
|
||||
],
|
||||
xblockFields: (contentLibraryId: string, usageKey: string) => [
|
||||
...libraryAuthoringQueryKeys.all,
|
||||
...libraryAuthoringQueryKeys.contentLibrary(contentLibraryId),
|
||||
'content',
|
||||
'xblockFields',
|
||||
usageKey,
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -124,22 +151,7 @@ export const useRevertLibraryChanges = () => {
|
||||
mutationFn: revertLibraryChanges,
|
||||
onSettled: (_data, _error, libraryId) => {
|
||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId) });
|
||||
queryClient.invalidateQueries({
|
||||
// Invalidate all content queries related to this library.
|
||||
// If we allow searching "all courses and libraries" in the future,
|
||||
// then we'd have to invalidate all `["content_search", "results"]`
|
||||
// queries, and not just the ones for this library, because items from
|
||||
// this library could be included in an "all courses and libraries"
|
||||
// search. For now we only allow searching individual libraries.
|
||||
predicate: /* istanbul ignore next */ (query: Query): boolean => {
|
||||
// extraFilter contains library id
|
||||
const extraFilter = query.queryKey[5];
|
||||
if (!(Array.isArray(extraFilter) || typeof extraFilter === 'string')) {
|
||||
return false;
|
||||
}
|
||||
return query.queryKey[0] === 'content_search' && extraFilter?.includes(`context_key = "${libraryId}"`);
|
||||
},
|
||||
});
|
||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -150,7 +162,50 @@ export const useLibraryPasteClipboard = () => {
|
||||
mutationFn: libraryPasteClipboard,
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(variables.libraryId) });
|
||||
queryClient.invalidateQueries({ queryKey: ['content_search'] });
|
||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, variables.libraryId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useXBlockFields = (contentLibrayId: string, usageKey: string) => (
|
||||
useQuery({
|
||||
queryKey: libraryAuthoringQueryKeys.xblockFields(contentLibrayId, usageKey),
|
||||
queryFn: () => getXBlockFields(usageKey),
|
||||
enabled: !!usageKey,
|
||||
})
|
||||
);
|
||||
|
||||
export const useUpdateXBlockFields = (contentLibraryId: string, usageKey: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: UpdateXBlockFieldsRequest) => updateXBlockFields(usageKey, data),
|
||||
onMutate: async (data) => {
|
||||
const queryKey = libraryAuthoringQueryKeys.xblockFields(contentLibraryId, usageKey);
|
||||
const previousBlockData = queryClient.getQueriesData(queryKey)[0][1] as XBlockFields;
|
||||
const formatedData = camelCaseObject(data);
|
||||
|
||||
const newBlockData = {
|
||||
...previousBlockData,
|
||||
...(formatedData.metadata?.displayName && { displayName: formatedData.metadata.displayName }),
|
||||
metadata: {
|
||||
...previousBlockData.metadata,
|
||||
...formatedData.metadata,
|
||||
},
|
||||
};
|
||||
|
||||
queryClient.setQueryData(queryKey, newBlockData);
|
||||
|
||||
return { previousBlockData, newBlockData };
|
||||
},
|
||||
onError: (_err, _data, context) => {
|
||||
queryClient.setQueryData(
|
||||
libraryAuthoringQueryKeys.xblockFields(contentLibraryId, usageKey),
|
||||
context?.previousBlockData,
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.xblockFields(contentLibraryId, usageKey) });
|
||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, contentLibraryId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { Edit } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
import { ContentLibrary } from '../data/api';
|
||||
import type { ContentLibrary } from '../data/api';
|
||||
import { useUpdateLibraryMetadata } from '../data/apiHooks';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import messages from '../messages';
|
||||
import { AddContentContainer, AddContentHeader } from '../add-content';
|
||||
import { LibraryContext, SidebarBodyComponentId } from '../common/context';
|
||||
import { LibraryInfo, LibraryInfoHeader } from '../library-info';
|
||||
import { ComponentInfo, ComponentInfoHeader } from '../component-info';
|
||||
import { ContentLibrary } from '../data/api';
|
||||
|
||||
type LibrarySidebarProps = {
|
||||
@@ -27,25 +28,35 @@ type LibrarySidebarProps = {
|
||||
*/
|
||||
const LibrarySidebar = ({ library }: LibrarySidebarProps) => {
|
||||
const intl = useIntl();
|
||||
const { sidebarBodyComponent, closeLibrarySidebar } = useContext(LibraryContext);
|
||||
const {
|
||||
sidebarBodyComponent,
|
||||
closeLibrarySidebar,
|
||||
currentComponentUsageKey,
|
||||
} = useContext(LibraryContext);
|
||||
|
||||
const bodyComponentMap = {
|
||||
[SidebarBodyComponentId.AddContent]: <AddContentContainer />,
|
||||
[SidebarBodyComponentId.Info]: <LibraryInfo library={library} />,
|
||||
[SidebarBodyComponentId.ComponentInfo]: (
|
||||
currentComponentUsageKey && <ComponentInfo usageKey={currentComponentUsageKey} />
|
||||
),
|
||||
unknown: null,
|
||||
};
|
||||
|
||||
const headerComponentMap = {
|
||||
'add-content': <AddContentHeader />,
|
||||
info: <LibraryInfoHeader library={library} />,
|
||||
[SidebarBodyComponentId.AddContent]: <AddContentHeader />,
|
||||
[SidebarBodyComponentId.Info]: <LibraryInfoHeader library={library} />,
|
||||
[SidebarBodyComponentId.ComponentInfo]: (
|
||||
currentComponentUsageKey && <ComponentInfoHeader library={library} usageKey={currentComponentUsageKey} />
|
||||
),
|
||||
unknown: null,
|
||||
};
|
||||
|
||||
const buildBody = () : React.ReactNode | null => bodyComponentMap[sidebarBodyComponent || 'unknown'];
|
||||
const buildHeader = (): React.ReactNode | null => headerComponentMap[sidebarBodyComponent || 'unknown'];
|
||||
const buildBody = () : React.ReactNode => bodyComponentMap[sidebarBodyComponent || 'unknown'];
|
||||
const buildHeader = (): React.ReactNode => headerComponentMap[sidebarBodyComponent || 'unknown'];
|
||||
|
||||
return (
|
||||
<Stack gap={4} className="p-2 vh-100 text-primary-700">
|
||||
<Stack gap={4} className="p-2 text-primary-700">
|
||||
<Stack direction="horizontal" className="d-flex justify-content-between">
|
||||
{buildHeader()}
|
||||
<IconButton
|
||||
|
||||
Reference in New Issue
Block a user