feat: renames unit in LibraryUnitPage and adds InplaceTextEditor component (#1810)

This commit is contained in:
Rômulo Penido
2025-04-15 17:42:36 -03:00
committed by GitHub
parent afecd8ba83
commit 990073cb38
16 changed files with 396 additions and 264 deletions

View File

@@ -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 }) => (
<IntlProvider locale="en">
{children}
</IntlProvider>
);
const render = (component: React.ReactNode) => baseRender(component, { wrapper: RootWrapper });
describe('<InplaceTextEditor />', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should render the text', () => {
render(<InplaceTextEditor text="Test text" onSave={mockOnSave} />);
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(<InplaceTextEditor text="Test text" onSave={mockOnSave} showEditButton />);
expect(screen.getByText('Test text')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
});
it('should edit the text', () => {
render(<InplaceTextEditor text="Test text" onSave={mockOnSave} />);
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(<InplaceTextEditor text="Test text" onSave={mockOnSave} />);
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();
});
});

View File

@@ -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<InplaceTextEditorProps> = ({
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<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleOnChangeText(event);
} else if (event.key === 'Escape') {
setIsActive(false);
}
};
return (
<Stack direction="horizontal">
{inputIsActive
? (
<Form.Control
autoFocus
type="text"
aria-label="Text input"
defaultValue={text}
onBlur={handleOnChangeText}
onKeyDown={handleOnKeyDown}
/>
)
: (
<>
<span
className={textClassName}
role="button"
onClick={!readOnly ? handleClick : undefined}
onKeyDown={!readOnly ? handleClick : undefined}
tabIndex={0}
>
{text}
</span>
{!readOnly && showEditButton && (
<IconButton
src={Edit}
iconAs={Icon}
alt={intl.formatMessage(messages.editTextButtonAlt)}
onClick={handleClick}
size="inline"
/>
)}
</>
)}
</Stack>
);
};

View File

@@ -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;

View File

@@ -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();

View File

@@ -58,14 +58,14 @@ describe('<CollectionInfoHeader />', () => {
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('<CollectionInfoHeader />', () => {
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('<CollectionInfoHeader />', () => {
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('<CollectionInfoHeader />', () => {
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('<CollectionInfoHeader />', () => {
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('<CollectionInfoHeader />', () => {
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}');

View File

@@ -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<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleSaveDisplayName(event);
} else if (event.key === 'Escape') {
setIsActive(false);
}
};
return (
<Stack direction="horizontal">
{inputIsActive
? (
<Form.Control
autoFocus
name="title"
id="title"
type="text"
aria-label="Title input"
defaultValue={collection.title}
onBlur={handleSaveDisplayName}
onKeyDown={handleOnKeyDown}
/>
)
: (
<>
<span className="font-weight-bold m-1.5">
{collection.title}
</span>
{!readOnly && (
<IconButton
src={Edit}
iconAs={Icon}
alt={intl.formatMessage(messages.editTitleButtonAlt)}
onClick={handleClick}
size="inline"
/>
)}
</>
)}
</Stack>
<InplaceTextEditor
onSave={handleSaveTitle}
text={collection.title}
readOnly={readOnly}
textClassName="font-weight-bold m-1.5"
showEditButton
/>
);
};

View File

@@ -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',

View File

@@ -61,7 +61,7 @@ describe('<ComponentInfoHeader />', () => {
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('<ComponentInfoHeader />', () => {
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('<ComponentInfoHeader />', () => {
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('<ComponentInfoHeader />', () => {
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('<ComponentInfoHeader />', () => {
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 });

View File

@@ -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<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleSaveDisplayName(event);
} else if (event.key === 'Escape') {
setIsActive(false);
}
};
if (!xblockFields) {
return null;
}
return (
<Stack direction="horizontal">
{inputIsActive
? (
<Form.Control
autoFocus
name="displayName"
id="displayName"
type="text"
aria-label="Display name input"
defaultValue={xblockFields?.displayName}
onBlur={handleSaveDisplayName}
onKeyDown={hanldeOnKeyDown}
/>
)
: (
<>
<span className="font-weight-bold m-1.5">
{xblockFields?.displayName}
</span>
{!readOnly && (
<IconButton
src={Edit}
iconAs={Icon}
alt={intl.formatMessage(messages.editNameButtonAlt)}
onClick={handleClick}
size="inline"
/>
)}
</>
)}
</Stack>
<InplaceTextEditor
onSave={handleSaveDisplayName}
text={xblockFields?.displayName}
readOnly={readOnly}
textClassName="font-weight-bold m-1.5"
showEditButton
/>
);
};

View File

@@ -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.',

View File

@@ -58,14 +58,14 @@ describe('<ContainerInfoHeader />', () => {
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('<ContainerInfoHeader />', () => {
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('<ContainerInfoHeader />', () => {
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('<ContainerInfoHeader />', () => {
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('<ContainerInfoHeader />', () => {
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('<ContainerInfoHeader />', () => {
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}');

View File

@@ -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<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleSaveDisplayName(event);
} else if (event.key === 'Escape') {
setIsActive(false);
}
};
return (
<Stack direction="horizontal">
{inputIsActive
? (
<Form.Control
autoFocus
name="title"
id="title"
type="text"
aria-label="Title input"
defaultValue={container.displayName}
onBlur={handleSaveDisplayName}
onKeyDown={handleOnKeyDown}
/>
)
: (
<>
<span className="font-weight-bold m-1.5">
{container.displayName}
</span>
{!readOnly && (
<IconButton
src={Edit}
iconAs={Icon}
alt={intl.formatMessage(messages.editTitleButtonAlt)}
onClick={handleClick}
size="inline"
/>
)}
</>
)}
</Stack>
<InplaceTextEditor
onSave={handleSaveDisplayName}
text={container.displayName}
readOnly={readOnly}
textClassName="font-weight-bold m-1.5"
showEditButton
/>
);
};

View File

@@ -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;

View File

@@ -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('<LibraryUnitPage />', () => {
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('<LibraryUnitPage />', () => {
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();

View File

@@ -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 (
<InplaceTextEditor
onSave={handleSaveDisplayName}
text={container.displayName}
readOnly={readOnly}
/>
);
};
const HeaderActions = () => {
const intl = useIntl();
@@ -172,7 +212,7 @@ export const LibraryUnitPage = () => {
<Container className="px-0 mt-4 mb-5 library-authoring-page bg-white">
<div className="px-4 bg-light-200 border-bottom mb-2">
<SubHeader
title={<SubHeaderTitle title={unitData.displayName} />}
title={<SubHeaderTitle title={<EditableTitle unitId={unitId} />} />}
headerActions={<HeaderActions />}
breadcrumbs={breadcrumbs}
hideBorder

View File

@@ -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;