diff --git a/src/library-authoring/LibraryAuthoringPage.scss b/src/library-authoring/LibraryAuthoringPage.scss index 3ce8cb718a..eaf87428b8 100644 --- a/src/library-authoring/LibraryAuthoringPage.scss +++ b/src/library-authoring/LibraryAuthoringPage.scss @@ -9,3 +9,9 @@ } } } + +.library-authoring-sidebar { + min-width: 300px; + max-width: map-get($grid-breakpoints, "sm"); + z-index: 1001; // to appear over header +} diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index ed13c66cbb..f7b6544355 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,19 @@ 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 + 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('', () => { }); 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(); + } = await renderLibraryPage(); await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); @@ -263,10 +282,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(); @@ -322,10 +338,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(); @@ -342,10 +355,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(); @@ -361,10 +371,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,14 +396,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(); - - 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('', () => { }); 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(); + } = await renderLibraryPage(); expect(await findByTitle('Sort search results')).toBeInTheDocument(); @@ -514,7 +512,7 @@ describe('', () => { // 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('', () => { }); }); + 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); diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index c2cc8bc7f9..5f8c144d34 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -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 ( - - - -
+
+
+
+ - - } - subtitle={intl.formatMessage(messages.headingSubtitle)} - headerActions={} + } + subtitle={intl.formatMessage(messages.headingSubtitle)} + headerActions={} + /> + +
+ + + +
+ +
+ + + + + + + + )} /> - -
- - - -
- -
- - - - - - - - )} - /> - } - /> - } - /> - } - /> - - + } + /> + } + /> + } + /> + - - - { sidebarBodyComponent !== null && ( - - - - )} - - + + +
+ { !!sidebarBodyComponent && ( +
+ +
+ )} +
); }; 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..4234722687 --- /dev/null +++ b/src/library-authoring/component-info/ComponentInfo.tsx @@ -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 ( + +
+ + + +
+ + + 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..a66b57b56e --- /dev/null +++ b/src/library-authoring/component-info/ComponentInfoHeader.test.tsx @@ -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) => ( + + + + + + + + + +); + +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 () => { + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); + }); +}); diff --git a/src/library-authoring/component-info/ComponentInfoHeader.tsx b/src/library-authoring/component-info/ComponentInfoHeader.tsx new file mode 100644 index 0000000000..8f576fe1be --- /dev/null +++ b/src/library-authoring/component-info/ComponentInfoHeader.tsx @@ -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) => { + 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..f460bc3ba4 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)); @@ -38,7 +39,7 @@ const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => { }; return ( - + e.stopPropagation()}> { }; const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps) => { + const { + openComponentInfoSidebar, + } = useContext(LibraryContext); + const { blockType, formatted, @@ -84,7 +89,15 @@ const ComponentCard = ({ contentHit, blockTypeDisplayName } : ComponentCardProps return ( - + openComponentInfoSidebar(usageKey)} + onKeyDown={(e: React.KeyboardEvent) => { + if (['Enter', ' '].includes(e.key)) { + openComponentInfoSidebar(usageKey); + } + }} + > - + )} /> diff --git a/src/library-authoring/components/LibraryComponents.tsx b/src/library-authoring/components/LibraryComponents.tsx index 24140abaca..4065826428 100644 --- a/src/library-authoring/components/LibraryComponents.tsx +++ b/src/library-authoring/components/LibraryComponents.tsx @@ -72,7 +72,7 @@ const LibraryComponents = ({ libraryId, variant }: LibraryComponentsProps) => { `${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 64420a71bb..2ebed19ff9 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -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) }); }, }); }; diff --git a/src/library-authoring/library-info/LibraryInfoHeader.tsx b/src/library-authoring/library-info/LibraryInfoHeader.tsx index 8567f07bde..5f97390795 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 1f8afe782f..64d57838da 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()}