feat: renames unit in LibraryUnitPage and adds InplaceTextEditor component (#1810)
This commit is contained in:
65
src/generic/inplace-text-editor/InplaceTextEditor.test.tsx
Normal file
65
src/generic/inplace-text-editor/InplaceTextEditor.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
91
src/generic/inplace-text-editor/index.tsx
Normal file
91
src/generic/inplace-text-editor/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
11
src/generic/inplace-text-editor/messages.tsx
Normal file
11
src/generic/inplace-text-editor/messages.tsx
Normal 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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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}');
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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}');
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user