diff --git a/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx b/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx new file mode 100644 index 000000000..4d7d7b80e --- /dev/null +++ b/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { fireEvent, render as baseRender, screen } from '@testing-library/react'; +import { InplaceTextEditor } from '.'; + +const mockOnSave = jest.fn(); + +const RootWrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); +const render = (component: React.ReactNode) => baseRender(component, { wrapper: RootWrapper }); + +describe('', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render the text', () => { + render(); + + expect(screen.getByText('Test text')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /edit/ })).not.toBeInTheDocument(); + }); + + it('should render the edit button if showEditButton is true', () => { + render(); + + expect(screen.getByText('Test text')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument(); + }); + + it('should edit the text', () => { + render(); + + const title = screen.getByText('Test text'); + expect(title).toBeInTheDocument(); + fireEvent.click(title); + + const textBox = screen.getByRole('textbox'); + + fireEvent.change(textBox, { target: { value: 'New text' } }); + fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 }); + + expect(textBox).not.toBeInTheDocument(); + expect(mockOnSave).toHaveBeenCalledWith('New text'); + }); + + it('should close edit text on press Escape', async () => { + render(); + + const title = screen.getByText('Test text'); + expect(title).toBeInTheDocument(); + fireEvent.click(title); + + const textBox = screen.getByRole('textbox'); + + fireEvent.change(textBox, { target: { value: 'New text' } }); + fireEvent.keyDown(textBox, { key: 'Escape', code: 'Escape', charCode: 27 }); + + expect(textBox).not.toBeInTheDocument(); + expect(mockOnSave).not.toHaveBeenCalled(); + }); +}); diff --git a/src/generic/inplace-text-editor/index.tsx b/src/generic/inplace-text-editor/index.tsx new file mode 100644 index 000000000..535ee8dd6 --- /dev/null +++ b/src/generic/inplace-text-editor/index.tsx @@ -0,0 +1,91 @@ +import React, { useCallback, useState } from 'react'; +import { + Form, + Icon, + IconButton, + Stack, +} from '@openedx/paragon'; +import { Edit } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import messages from './messages'; + +interface InplaceTextEditorProps { + text: string; + onSave: (newText: string) => void; + readOnly?: boolean; + textClassName?: string; + showEditButton?: boolean; +} + +export const InplaceTextEditor: React.FC = ({ + text, + onSave, + readOnly = false, + textClassName, + showEditButton = false, +}) => { + const intl = useIntl(); + const [inputIsActive, setIsActive] = useState(false); + + const handleOnChangeText = useCallback( + (event) => { + const newText = event.target.value; + if (newText && newText !== text) { + onSave(newText); + } + setIsActive(false); + }, + [text], + ); + + const handleClick = () => { + setIsActive(true); + }; + + const handleOnKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + handleOnChangeText(event); + } else if (event.key === 'Escape') { + setIsActive(false); + } + }; + + return ( + + {inputIsActive + ? ( + + ) + : ( + <> + + {text} + + {!readOnly && showEditButton && ( + + )} + + )} + + ); +}; diff --git a/src/generic/inplace-text-editor/messages.tsx b/src/generic/inplace-text-editor/messages.tsx new file mode 100644 index 000000000..232e2b53c --- /dev/null +++ b/src/generic/inplace-text-editor/messages.tsx @@ -0,0 +1,11 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + editTextButtonAlt: { + id: 'course-authoring.inplace-text-editor.button.alt', + defaultMessage: 'Edit', + description: 'Alt text for edit text icon button', + }, +}); + +export default messages; diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index dcb11e8c5..f7c242a74 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -1,4 +1,9 @@ -import { useCallback, useEffect, useState } from 'react'; +import { + type ReactNode, + useCallback, + useEffect, + useState, +} from 'react'; import { Helmet } from 'react-helmet'; import classNames from 'classnames'; import { StudioFooterSlot } from '@openedx/frontend-slot-footer'; @@ -100,7 +105,7 @@ const HeaderActions = () => { ); }; -export const SubHeaderTitle = ({ title }: { title: string }) => { +export const SubHeaderTitle = ({ title }: { title: ReactNode }) => { const intl = useIntl(); const { readOnly } = useLibraryContext(); diff --git a/src/library-authoring/collections/CollectionInfoHeader.test.tsx b/src/library-authoring/collections/CollectionInfoHeader.test.tsx index ac68680c8..13df15c56 100644 --- a/src/library-authoring/collections/CollectionInfoHeader.test.tsx +++ b/src/library-authoring/collections/CollectionInfoHeader.test.tsx @@ -58,14 +58,14 @@ describe('', () => { render(); expect(await screen.findByText('Test Collection')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /edit collection title/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument(); }); it('should not render edit title button without permission', async () => { render(libraryIdReadOnly); expect(await screen.findByText('Test Collection')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /edit collection title/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument(); }); it('should update collection title', async () => { @@ -76,9 +76,9 @@ describe('', () => { const url = api.getLibraryCollectionApiUrl(mockLibraryId, collectionId); axiosMock.onPatch(url).reply(200); - fireEvent.click(screen.getByRole('button', { name: /edit collection title/i })); + fireEvent.click(screen.getByRole('button', { name: /edit/i })); - const textBox = screen.getByRole('textbox', { name: /title input/i }); + const textBox = screen.getByRole('textbox', { name: /text input/i }); userEvent.clear(textBox); userEvent.type(textBox, 'New Collection Title{enter}'); @@ -99,9 +99,9 @@ describe('', () => { const url = api.getLibraryCollectionApiUrl(mockLibraryId, collectionId); axiosMock.onPatch(url).reply(200); - fireEvent.click(screen.getByRole('button', { name: /edit collection title/i })); + fireEvent.click(screen.getByRole('button', { name: /edit/i })); - const textBox = screen.getByRole('textbox', { name: /title input/i }); + const textBox = screen.getByRole('textbox', { name: /text input/i }); userEvent.clear(textBox); userEvent.type(textBox, `${mockGetCollectionMetadata.collectionData.title}{enter}`); @@ -118,9 +118,9 @@ describe('', () => { const url = api.getLibraryCollectionApiUrl(mockLibraryId, collectionId); axiosMock.onPatch(url).reply(200); - fireEvent.click(screen.getByRole('button', { name: /edit collection title/i })); + fireEvent.click(screen.getByRole('button', { name: /edit/i })); - const textBox = screen.getByRole('textbox', { name: /title input/i }); + const textBox = screen.getByRole('textbox', { name: /text input/i }); userEvent.clear(textBox); userEvent.type(textBox, '{enter}'); @@ -137,9 +137,9 @@ describe('', () => { const url = api.getLibraryCollectionApiUrl(mockLibraryId, collectionId); axiosMock.onPatch(url).reply(200); - fireEvent.click(screen.getByRole('button', { name: /edit collection title/i })); + fireEvent.click(screen.getByRole('button', { name: /edit/i })); - const textBox = screen.getByRole('textbox', { name: /title input/i }); + const textBox = screen.getByRole('textbox', { name: /text input/i }); userEvent.clear(textBox); userEvent.type(textBox, 'New Collection Title{esc}'); @@ -156,9 +156,9 @@ describe('', () => { const url = api.getLibraryCollectionApiUrl(mockLibraryId, collectionId); axiosMock.onPatch(url).reply(500); - fireEvent.click(screen.getByRole('button', { name: /edit collection title/i })); + fireEvent.click(screen.getByRole('button', { name: /edit/i })); - const textBox = screen.getByRole('textbox', { name: /title input/i }); + const textBox = screen.getByRole('textbox', { name: /text input/i }); userEvent.clear(textBox); userEvent.type(textBox, 'New Collection Title{enter}'); diff --git a/src/library-authoring/collections/CollectionInfoHeader.tsx b/src/library-authoring/collections/CollectionInfoHeader.tsx index 77fd7d74b..1e31aadba 100644 --- a/src/library-authoring/collections/CollectionInfoHeader.tsx +++ b/src/library-authoring/collections/CollectionInfoHeader.tsx @@ -1,13 +1,7 @@ -import React, { useState, useContext, useCallback } from 'react'; +import { useContext } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { - Icon, - IconButton, - Stack, - Form, -} from '@openedx/paragon'; -import { Edit } from '@openedx/paragon/icons'; +import { InplaceTextEditor } from '../../generic/inplace-text-editor'; import { ToastContext } from '../../generic/toast-context'; import { useLibraryContext } from '../common/context/LibraryContext'; import { useSidebarContext } from '../common/context/SidebarContext'; @@ -16,12 +10,12 @@ import messages from './messages'; const CollectionInfoHeader = () => { const intl = useIntl(); - const [inputIsActive, setIsActive] = useState(false); const { libraryId, readOnly } = useLibraryContext(); const { sidebarComponentInfo } = useSidebarContext(); const collectionId = sidebarComponentInfo?.id; + // istanbul ignore if: this should never happen if (!collectionId) { throw new Error('collectionId is required'); @@ -32,74 +26,28 @@ const CollectionInfoHeader = () => { const updateMutation = useUpdateCollection(libraryId, collectionId); const { showToast } = useContext(ToastContext); - const handleSaveDisplayName = useCallback( - (event) => { - const newTitle = event.target.value; - if (newTitle && newTitle !== collection?.title) { - updateMutation.mutateAsync({ - title: newTitle, - }).then(() => { - showToast(intl.formatMessage(messages.updateCollectionSuccessMsg)); - }).catch(() => { - showToast(intl.formatMessage(messages.updateCollectionErrorMsg)); - }).finally(() => { - setIsActive(false); - }); - } else { - setIsActive(false); - } - }, - [collection, showToast, intl], - ); + const handleSaveTitle = (newTitle: string) => { + updateMutation.mutateAsync({ + title: newTitle, + }).then(() => { + showToast(intl.formatMessage(messages.updateCollectionSuccessMsg)); + }).catch(() => { + showToast(intl.formatMessage(messages.updateCollectionErrorMsg)); + }); + }; if (!collection) { return null; } - const handleClick = () => { - setIsActive(true); - }; - - const handleOnKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter') { - handleSaveDisplayName(event); - } else if (event.key === 'Escape') { - setIsActive(false); - } - }; - return ( - - {inputIsActive - ? ( - - ) - : ( - <> - - {collection.title} - - {!readOnly && ( - - )} - - )} - + ); }; diff --git a/src/library-authoring/collections/messages.ts b/src/library-authoring/collections/messages.ts index ab8cbbc21..5b854b4b6 100644 --- a/src/library-authoring/collections/messages.ts +++ b/src/library-authoring/collections/messages.ts @@ -111,11 +111,6 @@ const messages = defineMessages({ defaultMessage: 'Failed to update collection.', description: 'Message displayed when collection update fails', }, - editTitleButtonAlt: { - id: 'course-authoring.library-authoring.collection.sidebar.edit-name.alt', - defaultMessage: 'Edit collection title', - description: 'Alt text for edit collection title icon button', - }, returnToLibrary: { id: 'course-authoring.library-authoring.collection.component-picker.return-to-library', defaultMessage: 'Back to Library', diff --git a/src/library-authoring/component-info/ComponentInfoHeader.test.tsx b/src/library-authoring/component-info/ComponentInfoHeader.test.tsx index c6b8622cd..027ea60c0 100644 --- a/src/library-authoring/component-info/ComponentInfoHeader.test.tsx +++ b/src/library-authoring/component-info/ComponentInfoHeader.test.tsx @@ -61,7 +61,7 @@ describe('', () => { expect(await screen.findByText('Test HTML Block')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /edit component name/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument(); }); it('should not render edit title button without permission', async () => { @@ -69,7 +69,7 @@ describe('', () => { expect(await screen.findByText('Test HTML Block')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /edit component name/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument(); }); it('should edit component title', async () => { @@ -79,9 +79,9 @@ describe('', () => { expect(await screen.findByText('Test HTML Block')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: /edit component name/i })); + fireEvent.click(screen.getByRole('button', { name: /edit/i })); - const textBox = screen.getByRole('textbox', { name: /display name input/i }); + const textBox = screen.getByRole('textbox', { name: /text input/i }); fireEvent.change(textBox, { target: { value: 'New component name' } }); fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 }); @@ -105,9 +105,9 @@ describe('', () => { expect(await screen.findByText('Test HTML Block')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: /edit component name/i })); + fireEvent.click(screen.getByRole('button', { name: /edit/i })); - const textBox = screen.getByRole('textbox', { name: /display name input/i }); + const textBox = screen.getByRole('textbox', { name: /text input/i }); fireEvent.change(textBox, { target: { value: 'New component name' } }); fireEvent.keyDown(textBox, { key: 'Escape', code: 'Escape', charCode: 27 }); @@ -124,9 +124,9 @@ describe('', () => { expect(await screen.findByText('Test HTML Block')).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: /edit component name/i })); + fireEvent.click(screen.getByRole('button', { name: /edit/i })); - const textBox = screen.getByRole('textbox', { name: /display name input/i }); + const textBox = screen.getByRole('textbox', { name: /text input/i }); fireEvent.change(textBox, { target: { value: 'New component name' } }); fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 }); diff --git a/src/library-authoring/component-info/ComponentInfoHeader.tsx b/src/library-authoring/component-info/ComponentInfoHeader.tsx index ac6317d4a..1cabfacd4 100644 --- a/src/library-authoring/component-info/ComponentInfoHeader.tsx +++ b/src/library-authoring/component-info/ComponentInfoHeader.tsx @@ -1,13 +1,7 @@ -import React, { useState, useContext, useCallback } from 'react'; +import { useContext } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { - Icon, - IconButton, - Stack, - Form, -} from '@openedx/paragon'; -import { Edit } from '@openedx/paragon/icons'; +import { InplaceTextEditor } from '../../generic/inplace-text-editor'; import { ToastContext } from '../../generic/toast-context'; import { useLibraryContext } from '../common/context/LibraryContext'; import { useSidebarContext } from '../common/context/SidebarContext'; @@ -16,7 +10,6 @@ import messages from './messages'; const ComponentInfoHeader = () => { const intl = useIntl(); - const [inputIsActive, setIsActive] = useState(false); const { readOnly, showOnlyPublished } = useLibraryContext(); const { sidebarComponentInfo } = useSidebarContext(); @@ -33,69 +26,30 @@ const ComponentInfoHeader = () => { const updateMutation = useUpdateXBlockFields(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 handleSaveDisplayName = (newDisplayName: string) => { + updateMutation.mutateAsync({ + metadata: { + display_name: newDisplayName, + }, + }).then(() => { + showToast(intl.formatMessage(messages.updateComponentSuccessMsg)); + }).catch(() => { + showToast(intl.formatMessage(messages.updateComponentErrorMsg)); + }); }; - const hanldeOnKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter') { - handleSaveDisplayName(event); - } else if (event.key === 'Escape') { - setIsActive(false); - } - }; + if (!xblockFields) { + return null; + } return ( - - {inputIsActive - ? ( - - ) - : ( - <> - - {xblockFields?.displayName} - - {!readOnly && ( - - )} - - )} - + ); }; diff --git a/src/library-authoring/component-info/messages.ts b/src/library-authoring/component-info/messages.ts index 967444733..6273e2693 100644 --- a/src/library-authoring/component-info/messages.ts +++ b/src/library-authoring/component-info/messages.ts @@ -61,11 +61,6 @@ const messages = defineMessages({ defaultMessage: 'ID (Usage key)', description: 'Heading for the component\'s ID', }, - 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.', diff --git a/src/library-authoring/containers/ContainerInfoHeader.test.tsx b/src/library-authoring/containers/ContainerInfoHeader.test.tsx index 77c792e3b..6349bfcbf 100644 --- a/src/library-authoring/containers/ContainerInfoHeader.test.tsx +++ b/src/library-authoring/containers/ContainerInfoHeader.test.tsx @@ -58,14 +58,14 @@ describe('', () => { render(); expect(await screen.findByText('Test Unit')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /edit container title/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument(); }); it('should not render edit title button without permission', async () => { render(libraryIdReadOnly); expect(await screen.findByText('Test Unit')).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: /edit container title/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument(); }); it('should update container title', async () => { @@ -76,9 +76,9 @@ describe('', () => { const url = api.getLibraryContainerApiUrl(containerId); axiosMock.onPatch(url).reply(200); - fireEvent.click(screen.getByRole('button', { name: /edit container title/i })); + fireEvent.click(screen.getByRole('button', { name: /edit/i })); - const textBox = screen.getByRole('textbox', { name: /title input/i }); + const textBox = screen.getByRole('textbox', { name: /text input/i }); userEvent.clear(textBox); userEvent.type(textBox, 'New Unit Title{enter}'); @@ -99,9 +99,9 @@ describe('', () => { const url = api.getLibraryContainerApiUrl(containerId); axiosMock.onPatch(url).reply(200); - fireEvent.click(screen.getByRole('button', { name: /edit container title/i })); + fireEvent.click(screen.getByRole('button', { name: /edit/i })); - const textBox = screen.getByRole('textbox', { name: /title input/i }); + const textBox = screen.getByRole('textbox', { name: /text input/i }); userEvent.clear(textBox); userEvent.type(textBox, `${mockGetContainerMetadata.containerData.displayName}{enter}`); @@ -118,9 +118,9 @@ describe('', () => { const url = api.getLibraryContainerApiUrl(containerId); axiosMock.onPatch(url).reply(200); - fireEvent.click(screen.getByRole('button', { name: /edit container title/i })); + fireEvent.click(screen.getByRole('button', { name: /edit/i })); - const textBox = screen.getByRole('textbox', { name: /title input/i }); + const textBox = screen.getByRole('textbox', { name: /text input/i }); userEvent.clear(textBox); userEvent.type(textBox, '{enter}'); @@ -137,9 +137,9 @@ describe('', () => { const url = api.getLibraryContainerApiUrl(containerId); axiosMock.onPatch(url).reply(200); - fireEvent.click(screen.getByRole('button', { name: /edit container title/i })); + fireEvent.click(screen.getByRole('button', { name: /edit/i })); - const textBox = screen.getByRole('textbox', { name: /title input/i }); + const textBox = screen.getByRole('textbox', { name: /text input/i }); userEvent.clear(textBox); userEvent.type(textBox, 'New Unit Title{esc}'); @@ -156,9 +156,9 @@ describe('', () => { const url = api.getLibraryContainerApiUrl(containerId); axiosMock.onPatch(url).reply(500); - fireEvent.click(screen.getByRole('button', { name: /edit container title/i })); + fireEvent.click(screen.getByRole('button', { name: /edit/i })); - const textBox = screen.getByRole('textbox', { name: /title input/i }); + const textBox = screen.getByRole('textbox', { name: /text input/i }); userEvent.clear(textBox); userEvent.type(textBox, 'New Unit Title{enter}'); diff --git a/src/library-authoring/containers/ContainerInfoHeader.tsx b/src/library-authoring/containers/ContainerInfoHeader.tsx index 3ac06045a..bb2b2c9d0 100644 --- a/src/library-authoring/containers/ContainerInfoHeader.tsx +++ b/src/library-authoring/containers/ContainerInfoHeader.tsx @@ -1,13 +1,7 @@ -import React, { useState, useContext, useCallback } from 'react'; +import { useContext } from 'react'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { - Icon, - IconButton, - Stack, - Form, -} from '@openedx/paragon'; -import { Edit } from '@openedx/paragon/icons'; +import { InplaceTextEditor } from '../../generic/inplace-text-editor'; import { ToastContext } from '../../generic/toast-context'; import { useLibraryContext } from '../common/context/LibraryContext'; import { useSidebarContext } from '../common/context/SidebarContext'; @@ -16,7 +10,6 @@ import messages from './messages'; const ContainerInfoHeader = () => { const intl = useIntl(); - const [inputIsActive, setIsActive] = useState(false); const { readOnly } = useLibraryContext(); const { sidebarComponentInfo } = useSidebarContext(); @@ -32,74 +25,28 @@ const ContainerInfoHeader = () => { const updateMutation = useUpdateContainer(containerId); const { showToast } = useContext(ToastContext); - const handleSaveDisplayName = useCallback( - (event) => { - const newDisplayName = event.target.value; - if (newDisplayName && newDisplayName !== container?.displayName) { - updateMutation.mutateAsync({ - displayName: newDisplayName, - }).then(() => { - showToast(intl.formatMessage(messages.updateContainerSuccessMsg)); - }).catch(() => { - showToast(intl.formatMessage(messages.updateContainerErrorMsg)); - }).finally(() => { - setIsActive(false); - }); - } else { - setIsActive(false); - } - }, - [container, showToast, intl], - ); + const handleSaveDisplayName = (newDisplayName: string) => { + updateMutation.mutateAsync({ + displayName: newDisplayName, + }).then(() => { + showToast(intl.formatMessage(messages.updateContainerSuccessMsg)); + }).catch(() => { + showToast(intl.formatMessage(messages.updateContainerErrorMsg)); + }); + }; if (!container) { return null; } - const handleClick = () => { - setIsActive(true); - }; - - const handleOnKeyDown = (event: React.KeyboardEvent) => { - if (event.key === 'Enter') { - handleSaveDisplayName(event); - } else if (event.key === 'Escape') { - setIsActive(false); - } - }; - return ( - - {inputIsActive - ? ( - - ) - : ( - <> - - {container.displayName} - - {!readOnly && ( - - )} - - )} - + ); }; diff --git a/src/library-authoring/containers/messages.ts b/src/library-authoring/containers/messages.ts index b47bdebff..f98f39c3f 100644 --- a/src/library-authoring/containers/messages.ts +++ b/src/library-authoring/containers/messages.ts @@ -41,11 +41,6 @@ const messages = defineMessages({ defaultMessage: 'Failed to update container.', description: 'Message displayed when container update fails', }, - editTitleButtonAlt: { - id: 'course-authoring.library-authoring.container.sidebar.edit-name.alt', - defaultMessage: 'Edit container title', - description: 'Alt text for edit container title icon button', - }, }); export default messages; diff --git a/src/library-authoring/units/LibraryUnitPage.test.tsx b/src/library-authoring/units/LibraryUnitPage.test.tsx index 9e039a9da..db1df1026 100644 --- a/src/library-authoring/units/LibraryUnitPage.test.tsx +++ b/src/library-authoring/units/LibraryUnitPage.test.tsx @@ -1,11 +1,15 @@ import userEvent from '@testing-library/user-event'; +import type MockAdapter from 'axios-mock-adapter'; + import { initializeMocks, + fireEvent, render, screen, waitFor, within, } from '../../testUtils'; +import { getLibraryContainerApiUrl } from '../data/api'; import { mockContentLibrary, mockXBlockFields, @@ -20,6 +24,9 @@ import LibraryLayout from '../LibraryLayout'; const path = '/library/:libraryId/*'; const libraryTitle = mockContentLibrary.libraryData.title; +let axiosMock: MockAdapter; +let mockShowToast: (message: string) => void; + mockClipboardEmpty.applyMock(); mockGetContainerMetadata.applyMock(); mockGetContainerChildren.applyMock(); @@ -31,7 +38,14 @@ mockLibraryBlockMetadata.applyMock(); describe('', () => { beforeEach(() => { - initializeMocks(); + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; + mockShowToast = mocks.mockShowToast; + }); + + afterEach(() => { + jest.clearAllMocks(); + axiosMock.restore(); }); const renderLibraryUnitPage = (unitId?: string, libraryId?: string) => { @@ -75,6 +89,68 @@ describe('', () => { expect((await screen.findAllByTestId('block-preview')).length).toEqual(3); }); + it('can rename unit', async () => { + renderLibraryUnitPage(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); + // Unit title + const unitTitle = screen.getAllByRole( + 'button', + { name: mockGetContainerMetadata.containerData.displayName }, + )[0]; + fireEvent.click(unitTitle); + + const url = getLibraryContainerApiUrl(mockGetContainerMetadata.containerId); + axiosMock.onPatch(url).reply(200); + + 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 Unit Title' } }); + fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 }); + + await waitFor(() => { + expect(axiosMock.history.patch[0].url).toEqual(url); + }); + expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ display_name: 'New Unit Title' })); + + expect(textBox).not.toBeInTheDocument(); + expect(mockShowToast).toHaveBeenCalledWith('Container updated successfully.'); + }); + + it('show error if renaming unit fails', async () => { + renderLibraryUnitPage(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); + // Unit title + const unitTitle = screen.getAllByRole( + 'button', + { name: mockGetContainerMetadata.containerData.displayName }, + )[0]; + fireEvent.click(unitTitle); + + const url = getLibraryContainerApiUrl(mockGetContainerMetadata.containerId); + axiosMock.onPatch(url).reply(400); + + 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 Unit Title' } }); + fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 }); + + await waitFor(() => { + expect(axiosMock.history.patch[0].url).toEqual(url); + }); + expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ display_name: 'New Unit Title' })); + + expect(textBox).not.toBeInTheDocument(); + expect(mockShowToast).toHaveBeenCalledWith('Failed to update container.'); + }); + it('should open and close the unit sidebar', async () => { renderLibraryUnitPage(); diff --git a/src/library-authoring/units/LibraryUnitPage.tsx b/src/library-authoring/units/LibraryUnitPage.tsx index 5a46e909e..cde0d77a7 100644 --- a/src/library-authoring/units/LibraryUnitPage.tsx +++ b/src/library-authoring/units/LibraryUnitPage.tsx @@ -1,26 +1,66 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { Breadcrumb, Button, Container } from '@openedx/paragon'; import { Add, InfoOutline } from '@openedx/paragon/icons'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useContext, useEffect } from 'react'; import { Helmet } from 'react-helmet'; import { Link } from 'react-router-dom'; -import ErrorAlert from '../../generic/alert-error'; import Loading from '../../generic/Loading'; import NotFoundAlert from '../../generic/NotFoundAlert'; import SubHeader from '../../generic/sub-header/SubHeader'; +import ErrorAlert from '../../generic/alert-error'; +import { InplaceTextEditor } from '../../generic/inplace-text-editor'; +import { ToastContext } from '../../generic/toast-context'; import Header from '../../header'; import { useLibraryContext } from '../common/context/LibraryContext'; import { COLLECTION_INFO_TABS, COMPONENT_INFO_TABS, SidebarBodyComponentId, UNIT_INFO_TABS, useSidebarContext, } from '../common/context/SidebarContext'; -import { useContainer, useContentLibrary } from '../data/apiHooks'; +import { useContainer, useUpdateContainer, useContentLibrary } from '../data/apiHooks'; import { LibrarySidebar } from '../library-sidebar'; import { SubHeaderTitle } from '../LibraryAuthoringPage'; import { useLibraryRoutes } from '../routes'; import { LibraryUnitBlocks } from './LibraryUnitBlocks'; import messages from './messages'; +interface EditableTitleProps { + unitId: string; +} + +const EditableTitle = ({ unitId }: EditableTitleProps) => { + const intl = useIntl(); + + const { libraryId, readOnly } = useLibraryContext(); + + const { data: container } = useContainer(libraryId, unitId); + + const updateMutation = useUpdateContainer(unitId); + const { showToast } = useContext(ToastContext); + + const handleSaveDisplayName = (newDisplayName: string) => { + updateMutation.mutateAsync({ + displayName: newDisplayName, + }).then(() => { + showToast(intl.formatMessage(messages.updateContainerSuccessMsg)); + }).catch(() => { + showToast(intl.formatMessage(messages.updateContainerErrorMsg)); + }); + }; + + // istanbul ignore if: this should never happen + if (!container) { + return null; + } + + return ( + + ); +}; + const HeaderActions = () => { const intl = useIntl(); @@ -172,7 +212,7 @@ export const LibraryUnitPage = () => {
} + title={} />} headerActions={} breadcrumbs={breadcrumbs} hideBorder diff --git a/src/library-authoring/units/messages.ts b/src/library-authoring/units/messages.ts index 912efd490..e0e288a38 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', }, + updateContainerSuccessMsg: { + id: 'course-authoring.library-authoring.update-container-success-msg', + defaultMessage: 'Container updated successfully.', + description: 'Message displayed when container is updated successfully', + }, + updateContainerErrorMsg: { + id: 'course-authoring.library-authoring.update-container-error-msg', + defaultMessage: 'Failed to update container.', + description: 'Message displayed when container update fails', + }, }); export default messages;