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