diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx
index 3dbd737bc5..24c4a7f926 100644
--- a/src/library-authoring/LibraryAuthoringPage.test.tsx
+++ b/src/library-authoring/LibraryAuthoringPage.test.tsx
@@ -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 ... 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,20 @@ describe('', () => {
queryClient.clear();
});
+ const renderLibraryPage = async () => {
+ mockUseParams.mockReturnValue({ libraryId: libraryData.id });
+ axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
+
+ const result = render();
+
+ // 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
@@ -185,17 +208,9 @@ describe('', () => {
});
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();
-
- // 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();
@@ -265,10 +280,7 @@ describe('', () => {
});
it('show new content button', async () => {
- mockUseParams.mockReturnValue({ libraryId: libraryData.id });
- axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
-
- render();
+ await renderLibraryPage();
expect(await screen.findByRole('heading')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument();
@@ -327,10 +339,7 @@ describe('', () => {
});
it('should open and close new content sidebar', async () => {
- mockUseParams.mockReturnValue({ libraryId: libraryData.id });
- axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
-
- render();
+ await renderLibraryPage();
expect(await screen.findByRole('heading')).toBeInTheDocument();
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
@@ -347,10 +356,7 @@ describe('', () => {
});
it('should open Library Info by default', async () => {
- mockUseParams.mockReturnValue({ libraryId: libraryData.id });
- axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
-
- render();
+ await renderLibraryPage();
expect(await screen.findByText('Content library')).toBeInTheDocument();
expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument();
@@ -366,10 +372,7 @@ describe('', () => {
});
it('should close and open Library Info', async () => {
- mockUseParams.mockReturnValue({ libraryId: libraryData.id });
- axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
-
- render();
+ await renderLibraryPage();
expect(await screen.findByText('Content library')).toBeInTheDocument();
expect((await screen.findAllByText(libraryData.title))[0]).toBeInTheDocument();
@@ -389,17 +392,9 @@ describe('', () => {
});
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();
-
- // 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();
@@ -458,13 +453,9 @@ describe('', () => {
});
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();
+ } = await renderLibraryPage();
expect(await findByTitle('Sort search results')).toBeInTheDocument();
@@ -504,4 +495,24 @@ describe('', () => {
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());
+ });
});
diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx
index 548b39aa35..bf7f98e982 100644
--- a/src/library-authoring/common/context.tsx
+++ b/src/library-authoring/common/context.tsx
@@ -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(null);
+ const [currentComponentUsageKey, setCurrentComponentUsageKey] = React.useState();
- 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 (
diff --git a/src/library-authoring/component-info/ComponentInfo.tsx b/src/library-authoring/component-info/ComponentInfo.tsx
new file mode 100644
index 0000000000..62bcc0387d
--- /dev/null
+++ b/src/library-authoring/component-info/ComponentInfo.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ Preview tab placeholder
+
+
+ Manage tab placeholder
+
+
+ Details tab placeholder
+
+
+
+ );
+};
+
+export default ComponentInfo;
diff --git a/src/library-authoring/component-info/ComponentInfoHeader.test.tsx b/src/library-authoring/component-info/ComponentInfoHeader.test.tsx
new file mode 100644
index 0000000000..ea7d794b83
--- /dev/null
+++ b/src/library-authoring/component-info/ComponentInfoHeader.test.tsx
@@ -0,0 +1,178 @@
+/* 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,
+ 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) => (
+
+
+
+
+
+
+
+
+
+);
+
+describe('', () => {
+ 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 () => {
+ const { findByText, getByRole } = render();
+
+ expect(await findByText('Test HTML Block')).toBeInTheDocument();
+ expect(getByRole('button', { name: /edit component name/i })).toBeInTheDocument();
+ });
+
+ it('should not render edit title button without permission', () => {
+ const library = {
+ ...libraryData,
+ canEditLibrary: false,
+ };
+
+ const { queryByRole } = render();
+
+ expect(queryByRole('button', { name: /edit component name/i })).not.toBeInTheDocument();
+ });
+
+ it('should edit component title', async () => {
+ const url = getXBlockFieldsApiUrl(usageKey);
+ axiosMock.onPost(url).reply(200);
+ const { getByRole, getByText } = render();
+
+ fireEvent.click(getByRole('button', { name: /edit component name/i }));
+
+ const textBox = 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(getByText('Component updated successfully.'));
+ });
+ });
+
+ it('should close edit library title on press Escape', async () => {
+ const url = getXBlockFieldsApiUrl(usageKey);
+ axiosMock.onPost(url).reply(200);
+ const { getByRole } = render();
+
+ fireEvent.click(getByRole('button', { name: /edit component name/i }));
+
+ const textBox = 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);
+
+ const { getByRole, getByText } = render();
+
+ fireEvent.click(getByRole('button', { name: /edit component name/i }));
+
+ const textBox = 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(getByText('There was an error updating the component.'));
+ });
+ });
+});
diff --git a/src/library-authoring/component-info/ComponentInfoHeader.tsx b/src/library-authoring/component-info/ComponentInfoHeader.tsx
new file mode 100644
index 0000000000..6f7a5d6f2a
--- /dev/null
+++ b/src/library-authoring/component-info/ComponentInfoHeader.tsx
@@ -0,0 +1,98 @@
+/* eslint-disable react/require-default-props */
+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) => {
+ if (event.key === 'Enter') {
+ handleSaveDisplayName(event);
+ } else if (event.key === 'Escape') {
+ setIsActive(false);
+ }
+ };
+
+ return (
+
+ { inputIsActive
+ ? (
+
+ )
+ : (
+ <>
+
+ {xblockFields?.displayName}
+
+ {library.canEditLibrary && (
+
+ )}
+ >
+ )}
+
+ );
+};
+
+export default ComponentInfoHeader;
diff --git a/src/library-authoring/component-info/index.tsx b/src/library-authoring/component-info/index.tsx
new file mode 100644
index 0000000000..27bb2275f3
--- /dev/null
+++ b/src/library-authoring/component-info/index.tsx
@@ -0,0 +1,2 @@
+export { default as ComponentInfo } from './ComponentInfo';
+export { default as ComponentInfoHeader } from './ComponentInfoHeader';
diff --git a/src/library-authoring/component-info/messages.ts b/src/library-authoring/component-info/messages.ts
new file mode 100644
index 0000000000..32251fce3c
--- /dev/null
+++ b/src/library-authoring/component-info/messages.ts
@@ -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;
diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx
index a24df20de6..2a9c2440f4 100644
--- a/src/library-authoring/components/ComponentCard.tsx
+++ b/src/library-authoring/components/ComponentCard.tsx
@@ -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));
@@ -64,6 +65,10 @@ const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => {
};
const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps) => {
+ const {
+ openComponentInfoSidebar,
+ } = useContext(LibraryContext);
+
const {
blockType,
formatted,
@@ -84,7 +89,10 @@ const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps
return (
-
+ openComponentInfoSidebar(usageKey)}
+ >
-
+
)}
/>
diff --git a/src/library-authoring/components/index.ts b/src/library-authoring/components/index.ts
index 63c42720e0..3a928498c7 100644
--- a/src/library-authoring/components/index.ts
+++ b/src/library-authoring/components/index.ts
@@ -1,2 +1,2 @@
-// eslint-disable-next-line import/prefer-default-export
export { default as LibraryComponents } from './LibraryComponents';
+export { ComponentMenu } from './ComponentCard';
diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts
index 4b933833c0..e622e6addf 100644
--- a/src/library-authoring/data/api.ts
+++ b/src/library-authoring/data/api.ts
@@ -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;
+ 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 {
+ 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);
+}
diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts
index 1e9a92bf84..5f2c89ddc0 100644
--- a/src/library-authoring/data/apiHooks.ts
+++ b/src/library-authoring/data/apiHooks.ts
@@ -1,7 +1,11 @@
+import { camelCaseObject } from '@edx/frontend-platform';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
type GetLibrariesV2CustomParams,
+ type ContentLibrary,
+ type XBlockFields,
+ type UpdateXBlockFieldsRequest,
getContentLibrary,
getLibraryBlockTypes,
createLibraryBlock,
@@ -9,8 +13,9 @@ import {
commitLibraryChanges,
revertLibraryChanges,
updateLibraryMetadata,
- ContentLibrary,
libraryPasteClipboard,
+ getXBlockFields,
+ updateXBlockFields,
} from './api';
export const libraryAuthoringQueryKeys = {
@@ -30,6 +35,13 @@ export const libraryAuthoringQueryKeys = {
'content',
'libraryBlockTypes',
],
+ xblockFields: (contentLibraryId: string, usageKey: string) => [
+ ...libraryAuthoringQueryKeys.all,
+ ...libraryAuthoringQueryKeys.contentLibrary(contentLibraryId),
+ 'content',
+ 'xblockFields',
+ usageKey,
+ ],
};
/**
@@ -136,3 +148,46 @@ export const useLibraryPasteClipboard = () => {
},
});
};
+
+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({ queryKey: ['content_search'] });
+ },
+ });
+};
diff --git a/src/library-authoring/library-info/LibraryInfoHeader.tsx b/src/library-authoring/library-info/LibraryInfoHeader.tsx
index e10fe2ec65..2f8c1ca532 100644
--- a/src/library-authoring/library-info/LibraryInfoHeader.tsx
+++ b/src/library-authoring/library-info/LibraryInfoHeader.tsx
@@ -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';
diff --git a/src/library-authoring/library-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx
index 314de8792a..a1e016dc92 100644
--- a/src/library-authoring/library-sidebar/LibrarySidebar.tsx
+++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx
@@ -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]: ,
[SidebarBodyComponentId.Info]: ,
+ [SidebarBodyComponentId.ComponentInfo]: (
+ currentComponentUsageKey &&
+ ),
unknown: null,
};
const headerComponentMap = {
- 'add-content': ,
- info: ,
+ [SidebarBodyComponentId.AddContent]: ,
+ [SidebarBodyComponentId.Info]: ,
+ [SidebarBodyComponentId.ComponentInfo]: (
+ 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 (
-
+
{buildHeader()}