feat: add library component sidebar [FC-0062] (#1217)

This commit is contained in:
Rômulo Penido
2024-08-29 09:22:13 -03:00
committed by GitHub
parent 64ffaddf3c
commit 48e0ec1f70
16 changed files with 679 additions and 142 deletions

View File

@@ -9,3 +9,9 @@
}
}
}
.library-authoring-sidebar {
min-width: 300px;
max-width: map-get($grid-breakpoints, "sm");
z-index: 1001; // to appear over header
}

View File

@@ -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);

View File

@@ -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>
);
};

View File

@@ -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 (

View 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;

View File

@@ -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();
});
});
});

View 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;

View File

@@ -0,0 +1,2 @@
export { default as ComponentInfo } from './ComponentInfo';
export { default as ComponentInfoHeader } from './ComponentInfoHeader';

View 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;

View File

@@ -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>
)}
/>

View File

@@ -72,7 +72,7 @@ const LibraryComponents = ({ libraryId, variant }: LibraryComponentsProps) => {
<CardGrid
columnSizes={{
sm: 12,
md: 5,
md: 6,
lg: 4,
xl: 3,
}}

View File

@@ -1,2 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as LibraryComponents } from './LibraryComponents';
export { ComponentMenu } from './ComponentCard';

View File

@@ -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);
}

View File

@@ -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) });
},
});
};

View File

@@ -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';

View File

@@ -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