diff --git a/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx b/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx
index 8914ad6de..c4dbc4619 100644
--- a/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx
+++ b/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx
@@ -1,6 +1,11 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
-import { fireEvent, render as baseRender, screen } from '@testing-library/react';
+import {
+ act,
+ fireEvent,
+ render as baseRender,
+ screen,
+} from '@testing-library/react';
import { InplaceTextEditor } from '.';
const mockOnSave = jest.fn();
@@ -24,8 +29,8 @@ describe('', () => {
expect(screen.queryByRole('button', { name: /edit/ })).not.toBeInTheDocument();
});
- it('should render the edit button if alwaysShowEditButton is true', () => {
- render();
+ it('should render the edit button', () => {
+ render();
expect(screen.getByText('Test text')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
@@ -36,7 +41,10 @@ describe('', () => {
const title = screen.getByText('Test text');
expect(title).toBeInTheDocument();
- fireEvent.click(title);
+
+ const editButton = screen.getByRole('button', { name: /edit/i });
+ expect(editButton).toBeInTheDocument();
+ fireEvent.click(editButton);
const textBox = screen.getByRole('textbox');
@@ -52,7 +60,10 @@ describe('', () => {
const title = screen.getByText('Test text');
expect(title).toBeInTheDocument();
- fireEvent.click(title);
+
+ const editButton = screen.getByRole('button', { name: /edit/i });
+ expect(editButton).toBeInTheDocument();
+ fireEvent.click(editButton);
const textBox = screen.getByRole('textbox');
@@ -62,4 +73,41 @@ describe('', () => {
expect(textBox).not.toBeInTheDocument();
expect(mockOnSave).not.toHaveBeenCalled();
});
+
+ it('should show the new text while processing and roolback in case of error', async () => {
+ let rejecter: (err: Error) => void;
+ const longMockOnSave = jest.fn().mockReturnValue(
+ new Promise((_resolve, reject) => {
+ rejecter = reject;
+ }),
+ );
+ render();
+
+ const text = screen.getByText('Test text');
+ expect(text).toBeInTheDocument();
+
+ const editButton = screen.getByRole('button', { name: /edit/i });
+ expect(editButton).toBeInTheDocument();
+ fireEvent.click(editButton);
+
+ 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(longMockOnSave).toHaveBeenCalledWith('New text');
+
+ // Show pending new text
+ const newText = screen.getByText('New text');
+ expect(newText).toBeInTheDocument();
+
+ await act(async () => { rejecter(new Error('error')); });
+
+ // Remove pending new text on error
+ expect(newText).not.toBeInTheDocument();
+
+ // Show original text
+ expect(screen.getByText('Test text')).toBeInTheDocument();
+ });
});
diff --git a/src/generic/inplace-text-editor/index.tsx b/src/generic/inplace-text-editor/index.tsx
index 8caecd550..b1bbe531c 100644
--- a/src/generic/inplace-text-editor/index.tsx
+++ b/src/generic/inplace-text-editor/index.tsx
@@ -1,14 +1,11 @@
import React, {
useCallback,
- useEffect,
useState,
- forwardRef,
} from 'react';
import {
Form,
Icon,
IconButton,
- OverlayTrigger,
Stack,
} from '@openedx/paragon';
import { Edit } from '@openedx/paragon/icons';
@@ -16,33 +13,11 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
-interface IconWrapperProps {
- popper: any;
- children: React.ReactNode;
- [key: string]: any;
-}
-
-const IconWrapper = forwardRef(({ popper, children, ...props }, ref) => {
- useEffect(() => {
- // This is a workaround to force the popper to update its position when
- // the editor is opened.
- // Ref: https://react-bootstrap.netlify.app/docs/components/overlays/#updating-position-dynamically
- popper.scheduleUpdate();
- }, [popper, children]);
-
- return (
-
- {children}
-
- );
-});
-
interface InplaceTextEditorProps {
text: string;
- onSave: (newText: string) => void;
+ onSave: (newText: string) => Promise;
readOnly?: boolean;
textClassName?: string;
- alwaysShowEditButton?: boolean;
}
export const InplaceTextEditor: React.FC = ({
@@ -50,18 +25,29 @@ export const InplaceTextEditor: React.FC = ({
onSave,
readOnly = false,
textClassName,
- alwaysShowEditButton = false,
}) => {
const intl = useIntl();
const [inputIsActive, setIsActive] = useState(false);
+ const [pendingSaveText, setPendingSaveText] = useState(); // state with the new text while updating
const handleOnChangeText = useCallback(
- (event) => {
- const newText = event.target.value;
- if (newText && newText !== text) {
- onSave(newText);
- }
+ async (event: React.ChangeEvent | React.KeyboardEvent) => {
+ const inputText = event.currentTarget.value;
setIsActive(false);
+ if (inputText && inputText !== text) {
+ // NOTE: While using react query for optimistic updates would be the best approach,
+ // it could not be possible in some cases. For that reason, we use the `pendingSaveText` state
+ // to show the new text while saving.
+ setPendingSaveText(inputText);
+ try {
+ await onSave(inputText);
+ } catch {
+ // don't propagate the exception
+ } finally {
+ // reset the pending save text
+ setPendingSaveText(undefined);
+ }
+ }
},
[text],
);
@@ -78,86 +64,44 @@ export const InplaceTextEditor: React.FC = ({
}
};
- if (readOnly) {
+ // If we have the `pendingSaveText` state it means that we are in the process of saving the new text.
+ // In that case, we show the new text instead of the original in read-only mode as an optimistic update.
+ if (readOnly || pendingSaveText) {
return (
- {text}
+ {pendingSaveText || text}
);
}
- if (alwaysShowEditButton) {
- return (
-
- {inputIsActive
- ? (
-
- )
- : (
-
- {text}
-
- )}
-
-
- );
- }
-
return (
-
-
-
- )}
+
-