From eaa075464c187e915245cc0a2d96302ef638f958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 23 Apr 2025 02:16:38 -0300 Subject: [PATCH] feat: rename component on library unit page (#1823) --- .../InplaceTextEditor.test.tsx | 4 +- src/generic/inplace-text-editor/index.tsx | 134 ++++++++++++++---- .../collections/CollectionInfoHeader.tsx | 2 +- .../ComponentInfoHeader.test.tsx | 4 +- .../component-info/ComponentInfoHeader.tsx | 2 +- .../containers/ContainerInfoHeader.tsx | 2 +- src/library-authoring/data/apiHooks.test.tsx | 6 +- src/library-authoring/data/apiHooks.ts | 8 +- .../units/LibraryUnitBlocks.tsx | 78 ++++++---- .../units/LibraryUnitPage.test.tsx | 74 +++++++++- src/library-authoring/units/messages.ts | 10 ++ 11 files changed, 255 insertions(+), 69 deletions(-) diff --git a/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx b/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx index 4d7d7b80e..8914ad6de 100644 --- a/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx +++ b/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx @@ -24,8 +24,8 @@ describe('', () => { expect(screen.queryByRole('button', { name: /edit/ })).not.toBeInTheDocument(); }); - it('should render the edit button if showEditButton is true', () => { - render(); + it('should render the edit button if alwaysShowEditButton is true', () => { + render(); expect(screen.getByText('Test text')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument(); diff --git a/src/generic/inplace-text-editor/index.tsx b/src/generic/inplace-text-editor/index.tsx index 535ee8dd6..8caecd550 100644 --- a/src/generic/inplace-text-editor/index.tsx +++ b/src/generic/inplace-text-editor/index.tsx @@ -1,8 +1,14 @@ -import React, { useCallback, useState } from 'react'; +import React, { + useCallback, + useEffect, + useState, + forwardRef, +} from 'react'; import { Form, Icon, IconButton, + OverlayTrigger, Stack, } from '@openedx/paragon'; import { Edit } from '@openedx/paragon/icons'; @@ -10,12 +16,33 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import messages from './messages'; +interface IconWrapperProps { + popper: any; + children: React.ReactNode; + [key: string]: any; +} + +const IconWrapper = forwardRef(({ popper, children, ...props }, ref) => { + useEffect(() => { + // This is a workaround to force the popper to update its position when + // the editor is opened. + // Ref: https://react-bootstrap.netlify.app/docs/components/overlays/#updating-position-dynamically + popper.scheduleUpdate(); + }, [popper, children]); + + return ( +
+ {children} +
+ ); +}); + interface InplaceTextEditorProps { text: string; onSave: (newText: string) => void; readOnly?: boolean; textClassName?: string; - showEditButton?: boolean; + alwaysShowEditButton?: boolean; } export const InplaceTextEditor: React.FC = ({ @@ -23,7 +50,7 @@ export const InplaceTextEditor: React.FC = ({ onSave, readOnly = false, textClassName, - showEditButton = false, + alwaysShowEditButton = false, }) => { const intl = useIntl(); const [inputIsActive, setIsActive] = useState(false); @@ -39,7 +66,7 @@ export const InplaceTextEditor: React.FC = ({ [text], ); - const handleClick = () => { + const handleEdit = () => { setIsActive(true); }; @@ -51,41 +78,86 @@ export const InplaceTextEditor: React.FC = ({ } }; + if (readOnly) { + return ( + + {text} + + ); + } + + if (alwaysShowEditButton) { + return ( + + {inputIsActive + ? ( + + ) + : ( + + {text} + + )} + + + ); + } + return ( - - {inputIsActive - ? ( - + - ) - : ( - <> + + )} + > +
+ {inputIsActive + ? ( + + ) + : ( {text} - {!readOnly && showEditButton && ( - - )} - - )} - + )} +
+ ); }; diff --git a/src/library-authoring/collections/CollectionInfoHeader.tsx b/src/library-authoring/collections/CollectionInfoHeader.tsx index 1e31aadba..8f476d35e 100644 --- a/src/library-authoring/collections/CollectionInfoHeader.tsx +++ b/src/library-authoring/collections/CollectionInfoHeader.tsx @@ -46,7 +46,7 @@ const CollectionInfoHeader = () => { text={collection.title} readOnly={readOnly} textClassName="font-weight-bold m-1.5" - showEditButton + alwaysShowEditButton /> ); }; diff --git a/src/library-authoring/component-info/ComponentInfoHeader.test.tsx b/src/library-authoring/component-info/ComponentInfoHeader.test.tsx index 027ea60c0..62ad7573d 100644 --- a/src/library-authoring/component-info/ComponentInfoHeader.test.tsx +++ b/src/library-authoring/component-info/ComponentInfoHeader.test.tsx @@ -98,7 +98,7 @@ describe('', () => { }); }); - it('should close edit library title on press Escape', async () => { + it('should close edit component title on press Escape', async () => { const url = getXBlockFieldsVersionApiUrl(usageKey, 'draft'); axiosMock.onPost(url).reply(200); render(); @@ -117,7 +117,7 @@ describe('', () => { await waitFor(() => expect(axiosMock.history.post.length).toEqual(0)); }); - it('should show error on edit library tittle', async () => { + it('should show error on edit component tittle', async () => { const url = getXBlockFieldsApiUrl(usageKey); axiosMock.onPatch(url).reply(500); render(); diff --git a/src/library-authoring/component-info/ComponentInfoHeader.tsx b/src/library-authoring/component-info/ComponentInfoHeader.tsx index 1cabfacd4..0757c9775 100644 --- a/src/library-authoring/component-info/ComponentInfoHeader.tsx +++ b/src/library-authoring/component-info/ComponentInfoHeader.tsx @@ -48,7 +48,7 @@ const ComponentInfoHeader = () => { text={xblockFields?.displayName} readOnly={readOnly} textClassName="font-weight-bold m-1.5" - showEditButton + alwaysShowEditButton /> ); }; diff --git a/src/library-authoring/containers/ContainerInfoHeader.tsx b/src/library-authoring/containers/ContainerInfoHeader.tsx index bb2b2c9d0..39d590db6 100644 --- a/src/library-authoring/containers/ContainerInfoHeader.tsx +++ b/src/library-authoring/containers/ContainerInfoHeader.tsx @@ -45,7 +45,7 @@ const ContainerInfoHeader = () => { text={container.displayName} readOnly={readOnly} textClassName="font-weight-bold m-1.5" - showEditButton + alwaysShowEditButton /> ); }; diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx index 83153de5a..47ee77693 100644 --- a/src/library-authoring/data/apiHooks.test.tsx +++ b/src/library-authoring/data/apiHooks.test.tsx @@ -164,7 +164,7 @@ describe('library api hooks', () => { }); it('should delete a container', async () => { - const containerId = 'lct:org:lib1'; + const containerId = 'lct:org:lib:unit:unit1'; const url = getLibraryContainerApiUrl(containerId); axiosMock.onDelete(url).reply(200); @@ -176,7 +176,7 @@ describe('library api hooks', () => { }); it('should restore a container', async () => { - const containerId = 'lct:org:lib1'; + const containerId = 'lct:org:lib:unit:unit1'; const url = getLibraryContainerRestoreApiUrl(containerId); axiosMock.onPost(url).reply(200); @@ -272,7 +272,7 @@ describe('library api hooks', () => { }); it('should update container children', async () => { - const containerId = 'lct:org:lib1'; + const containerId = 'lct:org:lib:unit:unit-1'; const url = getLibraryContainerChildrenApiUrl(containerId); axiosMock.onPatch(url).reply(200); diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 6de238c99..e9a5202b1 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -329,9 +329,13 @@ export const useUpdateXBlockFields = (usageKey: string) => { mutationFn: (data: api.UpdateXBlockFieldsRequest) => api.updateXBlockFields(usageKey, data), onMutate: async (data) => { const queryKey = xblockQueryKeys.xblockFields(usageKey); - const previousBlockData = queryClient.getQueriesData(queryKey)[0][1] as api.XBlockFields; + const previousBlockData = queryClient.getQueriesData(queryKey)?.[0]?.[1] as api.XBlockFields | undefined; const formatedData = camelCaseObject(data); + if (!previousBlockData) { + return { previousBlockData }; + } + const newBlockData = { ...previousBlockData, ...(formatedData.metadata?.displayName && { displayName: formatedData.metadata.displayName }), @@ -343,7 +347,7 @@ export const useUpdateXBlockFields = (usageKey: string) => { queryClient.setQueryData(queryKey, newBlockData); - return { previousBlockData, newBlockData }; + return { previousBlockData }; }, onError: (_err, _data, context) => { queryClient.setQueryData( diff --git a/src/library-authoring/units/LibraryUnitBlocks.tsx b/src/library-authoring/units/LibraryUnitBlocks.tsx index 87aacfa04..1ef20fa39 100644 --- a/src/library-authoring/units/LibraryUnitBlocks.tsx +++ b/src/library-authoring/units/LibraryUnitBlocks.tsx @@ -14,13 +14,19 @@ import ErrorAlert from '../../generic/alert-error'; import { getItemIcon } from '../../generic/block-type-utils'; import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; import { IframeProvider } from '../../generic/hooks/context/iFrameContext'; +import { InplaceTextEditor } from '../../generic/inplace-text-editor'; import Loading from '../../generic/Loading'; import TagCount from '../../generic/tag-count'; import { useLibraryContext } from '../common/context/LibraryContext'; import { PickLibraryContentModal } from '../add-content'; import ComponentMenu from '../components'; import { LibraryBlockMetadata } from '../data/api'; -import { libraryAuthoringQueryKeys, useContainerChildren, useUpdateContainerChildren } from '../data/apiHooks'; +import { + libraryAuthoringQueryKeys, + useContainerChildren, + useUpdateContainerChildren, + useUpdateXBlockFields, +} from '../data/apiHooks'; import { LibraryBlock } from '../LibraryBlock'; import { useLibraryRoutes } from '../routes'; import messages from './messages'; @@ -43,30 +49,52 @@ interface BlockHeaderProps { } /** Component header, split out to reuse in drag overlay */ -const BlockHeader = ({ block, onTagClick }: BlockHeaderProps) => ( - <> - - - {block.displayName} - - - - {block.hasUnpublishedChanges && ( - - - - - - - )} - - - - -); +const BlockHeader = ({ block, onTagClick }: BlockHeaderProps) => { + const intl = useIntl(); + const { showToast } = useContext(ToastContext); + + const updateMutation = useUpdateXBlockFields(block.id); + + const handleSaveDisplayName = (newDisplayName: string) => { + updateMutation.mutateAsync({ + metadata: { + display_name: newDisplayName, + }, + }).then(() => { + showToast(intl.formatMessage(messages.updateComponentSuccessMsg)); + }).catch(() => { + showToast(intl.formatMessage(messages.updateComponentErrorMsg)); + }); + }; + + return ( + <> + + + + + + + {block.hasUnpublishedChanges && ( + + + + + + + )} + + + + + ); +}; interface LibraryUnitBlocksProps { /** set to true if it is rendered as preview diff --git a/src/library-authoring/units/LibraryUnitPage.test.tsx b/src/library-authoring/units/LibraryUnitPage.test.tsx index 03ac5b566..f90dcedd1 100644 --- a/src/library-authoring/units/LibraryUnitPage.test.tsx +++ b/src/library-authoring/units/LibraryUnitPage.test.tsx @@ -10,7 +10,11 @@ import { waitFor, within, } from '../../testUtils'; -import { getLibraryContainerApiUrl, getLibraryContainerChildrenApiUrl } from '../data/api'; +import { + getLibraryContainerApiUrl, + getLibraryContainerChildrenApiUrl, + getXBlockFieldsApiUrl, +} from '../data/api'; import { mockContentLibrary, mockXBlockFields, @@ -198,6 +202,74 @@ describe('', () => { await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); }); + it('should rename component while clicking on name', async () => { + const url = getXBlockFieldsApiUrl('lb:org1:Demo_course:html:text-0'); + axiosMock.onPost(url).reply(200); + renderLibraryUnitPage(); + + // Wait loading of the component + await screen.findByText('text block 0'); + + const componentTitle = screen.getAllByRole( + 'button', + { name: 'text block 0' }, + )[0]; + fireEvent.click(componentTitle); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: /text input/i })).toBeInTheDocument(); + }); + + const textBox = screen.getByRole('textbox', { name: /text input/i }); + expect(textBox).toBeInTheDocument(); + fireEvent.change(textBox, { target: { value: 'New Component Title' } }); + 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 Title' }, + })); + expect(textBox).not.toBeInTheDocument(); + expect(mockShowToast).toHaveBeenCalledWith('Component updated successfully.'); + }); + + it('should show error while updating component name', async () => { + const url = getXBlockFieldsApiUrl('lb:org1:Demo_course:html:text-0'); + axiosMock.onPost(url).reply(400); + renderLibraryUnitPage(); + + // Wait loading of the component + await screen.findByText('text block 0'); + + const componentTitle = screen.getAllByRole( + 'button', + { name: 'text block 0' }, + )[0]; + fireEvent.click(componentTitle); + + await waitFor(() => { + expect(screen.getByRole('textbox', { name: /text input/i })).toBeInTheDocument(); + }); + + const textBox = screen.getByRole('textbox', { name: /text input/i }); + expect(textBox).toBeInTheDocument(); + fireEvent.change(textBox, { target: { value: 'New Component Title' } }); + 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 Title' }, + })); + expect(textBox).not.toBeInTheDocument(); + expect(mockShowToast).toHaveBeenCalledWith('There was an error updating the component.'); + }); + it('should call update order api on dragging component', async () => { renderLibraryUnitPage(); const firstDragHandle = (await screen.findAllByRole('button', { name: 'Drag to reorder' }))[0]; diff --git a/src/library-authoring/units/messages.ts b/src/library-authoring/units/messages.ts index a15eeb850..3b9a8fdc3 100644 --- a/src/library-authoring/units/messages.ts +++ b/src/library-authoring/units/messages.ts @@ -31,6 +31,16 @@ const messages = defineMessages({ defaultMessage: 'Draft', description: 'Chip in components in unit page that is shown when component has unpublished changes', }, + updateComponentSuccessMsg: { + id: 'course-authoring.library-authoring.unit-component.update.success', + defaultMessage: 'Component updated successfully.', + description: 'Message when the component is updated successfully', + }, + updateComponentErrorMsg: { + id: 'course-authoring.library-authoring.unit-component.update.error', + defaultMessage: 'There was an error updating the component.', + description: 'Message when there is an error when updating the component', + }, updateContainerSuccessMsg: { id: 'course-authoring.library-authoring.update-container-success-msg', defaultMessage: 'Container updated successfully.',