Skip to content

Commit 990073c

Browse files
authored
feat: renames unit in LibraryUnitPage and adds InplaceTextEditor component (#1810)
1 parent afecd8b commit 990073c

16 files changed

Lines changed: 396 additions & 264 deletions
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import React from 'react';
2+
import { IntlProvider } from '@edx/frontend-platform/i18n';
3+
import { fireEvent, render as baseRender, screen } from '@testing-library/react';
4+
import { InplaceTextEditor } from '.';
5+
6+
const mockOnSave = jest.fn();
7+
8+
const RootWrapper = ({ children }: { children: React.ReactNode }) => (
9+
<IntlProvider locale="en">
10+
{children}
11+
</IntlProvider>
12+
);
13+
const render = (component: React.ReactNode) => baseRender(component, { wrapper: RootWrapper });
14+
15+
describe('<InplaceTextEditor />', () => {
16+
afterEach(() => {
17+
jest.clearAllMocks();
18+
});
19+
20+
it('should render the text', () => {
21+
render(<InplaceTextEditor text="Test text" onSave={mockOnSave} />);
22+
23+
expect(screen.getByText('Test text')).toBeInTheDocument();
24+
expect(screen.queryByRole('button', { name: /edit/ })).not.toBeInTheDocument();
25+
});
26+
27+
it('should render the edit button if showEditButton is true', () => {
28+
render(<InplaceTextEditor text="Test text" onSave={mockOnSave} showEditButton />);
29+
30+
expect(screen.getByText('Test text')).toBeInTheDocument();
31+
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
32+
});
33+
34+
it('should edit the text', () => {
35+
render(<InplaceTextEditor text="Test text" onSave={mockOnSave} />);
36+
37+
const title = screen.getByText('Test text');
38+
expect(title).toBeInTheDocument();
39+
fireEvent.click(title);
40+
41+
const textBox = screen.getByRole('textbox');
42+
43+
fireEvent.change(textBox, { target: { value: 'New text' } });
44+
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });
45+
46+
expect(textBox).not.toBeInTheDocument();
47+
expect(mockOnSave).toHaveBeenCalledWith('New text');
48+
});
49+
50+
it('should close edit text on press Escape', async () => {
51+
render(<InplaceTextEditor text="Test text" onSave={mockOnSave} />);
52+
53+
const title = screen.getByText('Test text');
54+
expect(title).toBeInTheDocument();
55+
fireEvent.click(title);
56+
57+
const textBox = screen.getByRole('textbox');
58+
59+
fireEvent.change(textBox, { target: { value: 'New text' } });
60+
fireEvent.keyDown(textBox, { key: 'Escape', code: 'Escape', charCode: 27 });
61+
62+
expect(textBox).not.toBeInTheDocument();
63+
expect(mockOnSave).not.toHaveBeenCalled();
64+
});
65+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import React, { useCallback, useState } from 'react';
2+
import {
3+
Form,
4+
Icon,
5+
IconButton,
6+
Stack,
7+
} from '@openedx/paragon';
8+
import { Edit } from '@openedx/paragon/icons';
9+
import { useIntl } from '@edx/frontend-platform/i18n';
10+
11+
import messages from './messages';
12+
13+
interface InplaceTextEditorProps {
14+
text: string;
15+
onSave: (newText: string) => void;
16+
readOnly?: boolean;
17+
textClassName?: string;
18+
showEditButton?: boolean;
19+
}
20+
21+
export const InplaceTextEditor: React.FC<InplaceTextEditorProps> = ({
22+
text,
23+
onSave,
24+
readOnly = false,
25+
textClassName,
26+
showEditButton = false,
27+
}) => {
28+
const intl = useIntl();
29+
const [inputIsActive, setIsActive] = useState(false);
30+
31+
const handleOnChangeText = useCallback(
32+
(event) => {
33+
const newText = event.target.value;
34+
if (newText && newText !== text) {
35+
onSave(newText);
36+
}
37+
setIsActive(false);
38+
},
39+
[text],
40+
);
41+
42+
const handleClick = () => {
43+
setIsActive(true);
44+
};
45+
46+
const handleOnKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
47+
if (event.key === 'Enter') {
48+
handleOnChangeText(event);
49+
} else if (event.key === 'Escape') {
50+
setIsActive(false);
51+
}
52+
};
53+
54+
return (
55+
<Stack direction="horizontal">
56+
{inputIsActive
57+
? (
58+
<Form.Control
59+
autoFocus
60+
type="text"
61+
aria-label="Text input"
62+
defaultValue={text}
63+
onBlur={handleOnChangeText}
64+
onKeyDown={handleOnKeyDown}
65+
/>
66+
)
67+
: (
68+
<>
69+
<span
70+
className={textClassName}
71+
role="button"
72+
onClick={!readOnly ? handleClick : undefined}
73+
onKeyDown={!readOnly ? handleClick : undefined}
74+
tabIndex={0}
75+
>
76+
{text}
77+
</span>
78+
{!readOnly && showEditButton && (
79+
<IconButton
80+
src={Edit}
81+
iconAs={Icon}
82+
alt={intl.formatMessage(messages.editTextButtonAlt)}
83+
onClick={handleClick}
84+
size="inline"
85+
/>
86+
)}
87+
</>
88+
)}
89+
</Stack>
90+
);
91+
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { defineMessages } from '@edx/frontend-platform/i18n';
2+
3+
const messages = defineMessages({
4+
editTextButtonAlt: {
5+
id: 'course-authoring.inplace-text-editor.button.alt',
6+
defaultMessage: 'Edit',
7+
description: 'Alt text for edit text icon button',
8+
},
9+
});
10+
11+
export default messages;

src/library-authoring/LibraryAuthoringPage.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { useCallback, useEffect, useState } from 'react';
1+
import {
2+
type ReactNode,
3+
useCallback,
4+
useEffect,
5+
useState,
6+
} from 'react';
27
import { Helmet } from 'react-helmet';
38
import classNames from 'classnames';
49
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
@@ -100,7 +105,7 @@ const HeaderActions = () => {
100105
);
101106
};
102107

103-
export const SubHeaderTitle = ({ title }: { title: string }) => {
108+
export const SubHeaderTitle = ({ title }: { title: ReactNode }) => {
104109
const intl = useIntl();
105110

106111
const { readOnly } = useLibraryContext();

src/library-authoring/collections/CollectionInfoHeader.test.tsx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,14 @@ describe('<CollectionInfoHeader />', () => {
5858
render();
5959
expect(await screen.findByText('Test Collection')).toBeInTheDocument();
6060

61-
expect(screen.getByRole('button', { name: /edit collection title/i })).toBeInTheDocument();
61+
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
6262
});
6363

6464
it('should not render edit title button without permission', async () => {
6565
render(libraryIdReadOnly);
6666
expect(await screen.findByText('Test Collection')).toBeInTheDocument();
6767

68-
expect(screen.queryByRole('button', { name: /edit collection title/i })).not.toBeInTheDocument();
68+
expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument();
6969
});
7070

7171
it('should update collection title', async () => {
@@ -76,9 +76,9 @@ describe('<CollectionInfoHeader />', () => {
7676
const url = api.getLibraryCollectionApiUrl(mockLibraryId, collectionId);
7777
axiosMock.onPatch(url).reply(200);
7878

79-
fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
79+
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
8080

81-
const textBox = screen.getByRole('textbox', { name: /title input/i });
81+
const textBox = screen.getByRole('textbox', { name: /text input/i });
8282

8383
userEvent.clear(textBox);
8484
userEvent.type(textBox, 'New Collection Title{enter}');
@@ -99,9 +99,9 @@ describe('<CollectionInfoHeader />', () => {
9999
const url = api.getLibraryCollectionApiUrl(mockLibraryId, collectionId);
100100
axiosMock.onPatch(url).reply(200);
101101

102-
fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
102+
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
103103

104-
const textBox = screen.getByRole('textbox', { name: /title input/i });
104+
const textBox = screen.getByRole('textbox', { name: /text input/i });
105105

106106
userEvent.clear(textBox);
107107
userEvent.type(textBox, `${mockGetCollectionMetadata.collectionData.title}{enter}`);
@@ -118,9 +118,9 @@ describe('<CollectionInfoHeader />', () => {
118118
const url = api.getLibraryCollectionApiUrl(mockLibraryId, collectionId);
119119
axiosMock.onPatch(url).reply(200);
120120

121-
fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
121+
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
122122

123-
const textBox = screen.getByRole('textbox', { name: /title input/i });
123+
const textBox = screen.getByRole('textbox', { name: /text input/i });
124124

125125
userEvent.clear(textBox);
126126
userEvent.type(textBox, '{enter}');
@@ -137,9 +137,9 @@ describe('<CollectionInfoHeader />', () => {
137137
const url = api.getLibraryCollectionApiUrl(mockLibraryId, collectionId);
138138
axiosMock.onPatch(url).reply(200);
139139

140-
fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
140+
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
141141

142-
const textBox = screen.getByRole('textbox', { name: /title input/i });
142+
const textBox = screen.getByRole('textbox', { name: /text input/i });
143143

144144
userEvent.clear(textBox);
145145
userEvent.type(textBox, 'New Collection Title{esc}');
@@ -156,9 +156,9 @@ describe('<CollectionInfoHeader />', () => {
156156
const url = api.getLibraryCollectionApiUrl(mockLibraryId, collectionId);
157157
axiosMock.onPatch(url).reply(500);
158158

159-
fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
159+
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
160160

161-
const textBox = screen.getByRole('textbox', { name: /title input/i });
161+
const textBox = screen.getByRole('textbox', { name: /text input/i });
162162

163163
userEvent.clear(textBox);
164164
userEvent.type(textBox, 'New Collection Title{enter}');

src/library-authoring/collections/CollectionInfoHeader.tsx

Lines changed: 19 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
1-
import React, { useState, useContext, useCallback } from 'react';
1+
import { useContext } from 'react';
22
import { useIntl } from '@edx/frontend-platform/i18n';
3-
import {
4-
Icon,
5-
IconButton,
6-
Stack,
7-
Form,
8-
} from '@openedx/paragon';
9-
import { Edit } from '@openedx/paragon/icons';
103

4+
import { InplaceTextEditor } from '../../generic/inplace-text-editor';
115
import { ToastContext } from '../../generic/toast-context';
126
import { useLibraryContext } from '../common/context/LibraryContext';
137
import { useSidebarContext } from '../common/context/SidebarContext';
@@ -16,12 +10,12 @@ import messages from './messages';
1610

1711
const CollectionInfoHeader = () => {
1812
const intl = useIntl();
19-
const [inputIsActive, setIsActive] = useState(false);
2013

2114
const { libraryId, readOnly } = useLibraryContext();
2215
const { sidebarComponentInfo } = useSidebarContext();
2316

2417
const collectionId = sidebarComponentInfo?.id;
18+
2519
// istanbul ignore if: this should never happen
2620
if (!collectionId) {
2721
throw new Error('collectionId is required');
@@ -32,74 +26,28 @@ const CollectionInfoHeader = () => {
3226
const updateMutation = useUpdateCollection(libraryId, collectionId);
3327
const { showToast } = useContext(ToastContext);
3428

35-
const handleSaveDisplayName = useCallback(
36-
(event) => {
37-
const newTitle = event.target.value;
38-
if (newTitle && newTitle !== collection?.title) {
39-
updateMutation.mutateAsync({
40-
title: newTitle,
41-
}).then(() => {
42-
showToast(intl.formatMessage(messages.updateCollectionSuccessMsg));
43-
}).catch(() => {
44-
showToast(intl.formatMessage(messages.updateCollectionErrorMsg));
45-
}).finally(() => {
46-
setIsActive(false);
47-
});
48-
} else {
49-
setIsActive(false);
50-
}
51-
},
52-
[collection, showToast, intl],
53-
);
29+
const handleSaveTitle = (newTitle: string) => {
30+
updateMutation.mutateAsync({
31+
title: newTitle,
32+
}).then(() => {
33+
showToast(intl.formatMessage(messages.updateCollectionSuccessMsg));
34+
}).catch(() => {
35+
showToast(intl.formatMessage(messages.updateCollectionErrorMsg));
36+
});
37+
};
5438

5539
if (!collection) {
5640
return null;
5741
}
5842

59-
const handleClick = () => {
60-
setIsActive(true);
61-
};
62-
63-
const handleOnKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
64-
if (event.key === 'Enter') {
65-
handleSaveDisplayName(event);
66-
} else if (event.key === 'Escape') {
67-
setIsActive(false);
68-
}
69-
};
70-
7143
return (
72-
<Stack direction="horizontal">
73-
{inputIsActive
74-
? (
75-
<Form.Control
76-
autoFocus
77-
name="title"
78-
id="title"
79-
type="text"
80-
aria-label="Title input"
81-
defaultValue={collection.title}
82-
onBlur={handleSaveDisplayName}
83-
onKeyDown={handleOnKeyDown}
84-
/>
85-
)
86-
: (
87-
<>
88-
<span className="font-weight-bold m-1.5">
89-
{collection.title}
90-
</span>
91-
{!readOnly && (
92-
<IconButton
93-
src={Edit}
94-
iconAs={Icon}
95-
alt={intl.formatMessage(messages.editTitleButtonAlt)}
96-
onClick={handleClick}
97-
size="inline"
98-
/>
99-
)}
100-
</>
101-
)}
102-
</Stack>
44+
<InplaceTextEditor
45+
onSave={handleSaveTitle}
46+
text={collection.title}
47+
readOnly={readOnly}
48+
textClassName="font-weight-bold m-1.5"
49+
showEditButton
50+
/>
10351
);
10452
};
10553

src/library-authoring/collections/messages.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,6 @@ const messages = defineMessages({
111111
defaultMessage: 'Failed to update collection.',
112112
description: 'Message displayed when collection update fails',
113113
},
114-
editTitleButtonAlt: {
115-
id: 'course-authoring.library-authoring.collection.sidebar.edit-name.alt',
116-
defaultMessage: 'Edit collection title',
117-
description: 'Alt text for edit collection title icon button',
118-
},
119114
returnToLibrary: {
120115
id: 'course-authoring.library-authoring.collection.component-picker.return-to-library',
121116
defaultMessage: 'Back to Library',

0 commit comments

Comments
 (0)