Repository: openedx/frontend-app-authoring
Pull request #1953: fix: change InplaceTextEditor style and add optimistic update
Author: login: rpenido
Labels: name: open-source-contribution
description: PR author is not from Axim or 2U
Created: 2025-05-14T14:12:57Z | Merged: 2025-05-19T16:46:54Z

## Description

This PR adds the following:
- Optimistic update for renaming Components, Collections and Containers
- Change the InplaceTextEditor to show the new text until the `onSave` promise resolves
- Change the InplaceTextEditor style to: Always show the rename button

## Supporting information

- Related to https://github.com/openedx/frontend-app-authoring/issues/1849

## Testing instructions
Check the style and behaviour on the following:
- UnitPage > Rename unit title
- UnitPage > Rename component title
- Sidebar > Rename unit title
- Sidebar > Rename collection title
- Sidebar > Rename component title

___
Private ref: [FAL-4160](https://tasks.opencraft.com/browse/FAL-4160)


Diff:
diff --git a/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx b/src/generic/inplace-text-editor/InplaceTextEditor.test.tsx
index 8914ad6de5..c4dbc46191 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('<InplaceTextEditor />', () => {
     expect(screen.queryByRole('button', { name: /edit/ })).not.toBeInTheDocument();
   });
 
-  it('should render the edit button if alwaysShowEditButton is true', () => {
-    render(<InplaceTextEditor text="Test text" onSave={mockOnSave} alwaysShowEditButton />);
+  it('should render the edit button', () => {
+    render(<InplaceTextEditor text="Test text" onSave={mockOnSave} />);
 
     expect(screen.getByText('Test text')).toBeInTheDocument();
     expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
@@ -36,7 +41,10 @@ describe('<InplaceTextEditor />', () => {
 
     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('<InplaceTextEditor />', () => {
 
     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('<InplaceTextEditor />', () => {
     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<void>((_resolve, reject) => {
+        rejecter = reject;
+      }),
+    );
+    render(<InplaceTextEditor text="Test text" onSave={longMockOnSave} />);
+
+    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 8caecd550f..b1bbe531cc 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<HTMLDivElement, IconWrapperProps>(({ 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 (
-    <div ref={ref} {...props}>
-      {children}
-    </div>
-  );
-});
-
 interface InplaceTextEditorProps {
   text: string;
-  onSave: (newText: string) => void;
+  onSave: (newText: string) => Promise<void>;
   readOnly?: boolean;
   textClassName?: string;
-  alwaysShowEditButton?: boolean;
 }
 
 export const InplaceTextEditor: React.FC<InplaceTextEditorProps> = ({
@@ -50,18 +25,29 @@ export const InplaceTextEditor: React.FC<InplaceTextEditorProps> = ({
   onSave,
   readOnly = false,
   textClassName,
-  alwaysShowEditButton = false,
 }) => {
   const intl = useIntl();
   const [inputIsActive, setIsActive] = useState(false);
+  const [pendingSaveText, setPendingSaveText] = useState<string>(); // 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<HTMLInputElement> | React.KeyboardEvent<HTMLInputElement>) => {
+      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<InplaceTextEditorProps> = ({
     }
   };
 
-  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 (
       <span className={textClassName}>
-        {text}
+        {pendingSaveText || text}
       </span>
     );
   }
 
-  if (alwaysShowEditButton) {
-    return (
-      <Stack
-        direction="horizontal"
-        gap={1}
-      >
-        {inputIsActive
-          ? (
-            <Form.Control
-              autoFocus
-              type="text"
-              aria-label="Text input"
-              defaultValue={text}
-              onBlur={handleOnChangeText}
-              onKeyDown={handleOnKeyDown}
-            />
-          )
-          : (
-            <span className={textClassName}>
-              {text}
-            </span>
-          )}
-        <IconButton
-          src={Edit}
-          iconAs={Icon}
-          alt={intl.formatMessage(messages.editTextButtonAlt)}
-          onClick={handleEdit}
-          size="inline"
-        />
-      </Stack>
-    );
-  }
-
   return (
-    <OverlayTrigger
-      trigger={['hover', 'focus']}
-      placement="right"
-      overlay={(
-        <IconWrapper>
-          <Icon
-            id="edit-text-icon"
-            src={Edit}
-            className="ml-1.5"
-            onClick={handleEdit}
-          />
-        </IconWrapper>
-      )}
+    <Stack
+      direction="horizontal"
+      gap={1}
     >
-      <div>
-        {inputIsActive
-          ? (
-            <Form.Control
-              autoFocus
-              type="text"
-              aria-label="Text input"
-              defaultValue={text}
-              onBlur={handleOnChangeText}
-              onKeyDown={handleOnKeyDown}
-            />
-          )
-          : (
-            <span
-              onClick={handleEdit}
-              onKeyDown={handleEdit}
-              className={textClassName}
-              role="button"
-              tabIndex={0}
-            >
-              {text}
-            </span>
-          )}
-      </div>
-    </OverlayTrigger>
+      {inputIsActive
+        ? (
+          <Form.Control
+            autoFocus
+            type="text"
+            aria-label="Text input"
+            defaultValue={text}
+            onBlur={handleOnChangeText}
+            onKeyDown={handleOnKeyDown}
+          />
+        )
+        : (
+          <span className={textClassName}>
+            {text}
+          </span>
+        )}
+      <IconButton
+        src={Edit}
+        iconAs={Icon}
+        alt={intl.formatMessage(messages.editTextButtonAlt)}
+        onClick={handleEdit}
+        size="inline"
+      />
+    </Stack>
   );
 };
diff --git a/src/library-authoring/collections/CollectionInfoHeader.tsx b/src/library-authoring/collections/CollectionInfoHeader.tsx
index 8f476d35ef..b885d72da2 100644
--- a/src/library-authoring/collections/CollectionInfoHeader.tsx
+++ b/src/library-authoring/collections/CollectionInfoHeader.tsx
@@ -26,14 +26,16 @@ const CollectionInfoHeader = () => {
   const updateMutation = useUpdateCollection(libraryId, collectionId);
   const { showToast } = useContext(ToastContext);
 
-  const handleSaveTitle = (newTitle: string) => {
-    updateMutation.mutateAsync({
-      title: newTitle,
-    }).then(() => {
+  const handleSaveTitle = async (newTitle: string) => {
+    try {
+      await updateMutation.mutateAsync({
+        title: newTitle,
+      });
       showToast(intl.formatMessage(messages.updateCollectionSuccessMsg));
-    }).catch(() => {
+    } catch (err) {
       showToast(intl.formatMessage(messages.updateCollectionErrorMsg));
-    });
+      throw err;
+    }
   };
 
   if (!collection) {
@@ -46,7 +48,6 @@ const CollectionInfoHeader = () => {
       text={collection.title}
       readOnly={readOnly}
       textClassName="font-weight-bold m-1.5"
-      alwaysShowEditButton
     />
   );
 };
diff --git a/src/library-authoring/component-info/ComponentInfoHeader.tsx b/src/library-authoring/component-info/ComponentInfoHeader.tsx
index 0757c9775d..11b3256945 100644
--- a/src/library-authoring/component-info/ComponentInfoHeader.tsx
+++ b/src/library-authoring/component-info/ComponentInfoHeader.tsx
@@ -26,16 +26,18 @@ const ComponentInfoHeader = () => {
   const updateMutation = useUpdateXBlockFields(usageKey);
   const { showToast } = useContext(ToastContext);
 
-  const handleSaveDisplayName = (newDisplayName: string) => {
-    updateMutation.mutateAsync({
-      metadata: {
-        display_name: newDisplayName,
-      },
-    }).then(() => {
+  const handleSaveDisplayName = async (newDisplayName: string) => {
+    try {
+      await updateMutation.mutateAsync({
+        metadata: {
+          display_name: newDisplayName,
+        },
+      });
       showToast(intl.formatMessage(messages.updateComponentSuccessMsg));
-    }).catch(() => {
+    } catch (err) {
       showToast(intl.formatMessage(messages.updateComponentErrorMsg));
-    });
+      throw err;
+    }
   };
 
   if (!xblockFields) {
@@ -48,7 +50,6 @@ const ComponentInfoHeader = () => {
       text={xblockFields?.displayName}
       readOnly={readOnly}
       textClassName="font-weight-bold m-1.5"
-      alwaysShowEditButton
     />
   );
 };
diff --git a/src/library-authoring/containers/ContainerInfoHeader.tsx b/src/library-authoring/containers/ContainerInfoHeader.tsx
index 39d590db6c..65357c0f14 100644
--- a/src/library-authoring/containers/ContainerInfoHeader.tsx
+++ b/src/library-authoring/containers/ContainerInfoHeader.tsx
@@ -25,14 +25,16 @@ const ContainerInfoHeader = () => {
   const updateMutation = useUpdateContainer(containerId);
   const { showToast } = useContext(ToastContext);
 
-  const handleSaveDisplayName = (newDisplayName: string) => {
-    updateMutation.mutateAsync({
-      displayName: newDisplayName,
-    }).then(() => {
+  const handleSaveDisplayName = async (newDisplayName: string) => {
+    try {
+      await updateMutation.mutateAsync({
+        displayName: newDisplayName,
+      });
       showToast(intl.formatMessage(messages.updateContainerSuccessMsg));
-    }).catch(() => {
+    } catch (err) {
       showToast(intl.formatMessage(messages.updateContainerErrorMsg));
-    });
+      throw err;
+    }
   };
 
   if (!container) {
@@ -45,7 +47,6 @@ const ContainerInfoHeader = () => {
       text={container.displayName}
       readOnly={readOnly}
       textClassName="font-weight-bold m-1.5"
-      alwaysShowEditButton
     />
   );
 };
diff --git a/src/library-authoring/containers/UnitInfo.test.tsx b/src/library-authoring/containers/UnitInfo.test.tsx
index 9b154d13c3..7bca1a7c97 100644
--- a/src/library-authoring/containers/UnitInfo.test.tsx
+++ b/src/library-authoring/containers/UnitInfo.test.tsx
@@ -101,6 +101,6 @@ describe('<UnitInfo />', () => {
   it('show only published content', async () => {
     render(true);
     expect(await screen.findByTestId('unit-info-menu-toggle')).toBeInTheDocument();
-    expect(screen.getByRole('button', { name: /text block published 1/i })).toBeInTheDocument();
+    expect(screen.getByText(/text block published 1/i)).toBeInTheDocument();
   });
 });
diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts
index 01dd97c3eb..65b9445ad1 100644
--- a/src/library-authoring/data/apiHooks.ts
+++ b/src/library-authoring/data/apiHooks.ts
@@ -472,15 +472,28 @@ export const useCollection = (libraryId: string, collectionId: string) => (
  */
 export const useUpdateCollection = (libraryId: string, collectionId: string) => {
   const queryClient = useQueryClient();
+  const collectionQueryKey = libraryAuthoringQueryKeys.collection(libraryId, collectionId);
   return useMutation({
     mutationFn: (data: api.UpdateCollectionComponentsRequest) => (
       api.updateCollectionMetadata(libraryId, collectionId, data)
     ),
+    onMutate: (data) => {
+      const previousData = queryClient.getQueryData(collectionQueryKey) as api.CollectionMetadata;
+      queryClient.setQueryData(collectionQueryKey, {
+        ...previousData,
+        ...data,
+      });
+
+      return { previousData };
+    },
+    onError: (_err, _data, context) => {
+      queryClient.setQueryData(collectionQueryKey, context?.previousData);
+    },
     onSettled: () => {
       // NOTE: We invalidate the library query here because we need to update the library's
       // collection list.
       queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
-      queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.collection(libraryId, collectionId) });
+      queryClient.invalidateQueries({ queryKey: collectionQueryKey });
     },
   });
 };
@@ -598,13 +611,26 @@ export const useContainer = (containerId?: string) => (
 export const useUpdateContainer = (containerId: string) => {
   const libraryId = getLibraryId(containerId);
   const queryClient = useQueryClient();
+  const containerQueryKey = libraryAuthoringQueryKeys.container(containerId);
   return useMutation({
     mutationFn: (data: api.UpdateContainerDataRequest) => api.updateContainerMetadata(containerId, data),
+    onMutate: (data) => {
+      const previousData = queryClient.getQueryData(containerQueryKey) as api.CollectionMetadata;
+      queryClient.setQueryData(containerQueryKey, {
+        ...previousData,
+        ...data,
+      });
+
+      return { previousData };
+    },
+    onError: (_err, _data, context) => {
+      queryClient.setQueryData(containerQueryKey, context?.previousData);
+    },
     onSettled: () => {
       // NOTE: We invalidate the library query here because we need to update the library's
       // container list.
       queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
-      queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.container(containerId) });
+      queryClient.invalidateQueries({ queryKey: containerQueryKey });
     },
   });
 };
diff --git a/src/library-authoring/units/LibraryUnitBlocks.tsx b/src/library-authoring/units/LibraryUnitBlocks.tsx
index 4d42bf2a45..a8a5d064a0 100644
--- a/src/library-authoring/units/LibraryUnitBlocks.tsx
+++ b/src/library-authoring/units/LibraryUnitBlocks.tsx
@@ -63,16 +63,18 @@ const BlockHeader = ({ block }: ComponentBlockProps) => {
 
   const updateMutation = useUpdateXBlockFields(block.originalId);
 
-  const handleSaveDisplayName = (newDisplayName: string) => {
-    updateMutation.mutateAsync({
-      metadata: {
-        display_name: newDisplayName,
-      },
-    }).then(() => {
+  const handleSaveDisplayName = async (newDisplayName: string) => {
+    try {
+      await updateMutation.mutateAsync({
+        metadata: {
+          display_name: newDisplayName,
+        },
+      });
       showToast(intl.formatMessage(messages.updateComponentSuccessMsg));
-    }).catch(() => {
+    } catch (err) {
       showToast(intl.formatMessage(messages.updateComponentErrorMsg));
-    });
+      throw err;
+    }
   };
 
   /* istanbul ignore next */
diff --git a/src/library-authoring/units/LibraryUnitPage.test.tsx b/src/library-authoring/units/LibraryUnitPage.test.tsx
index 6d4ea7e31c..18671ca562 100644
--- a/src/library-authoring/units/LibraryUnitPage.test.tsx
+++ b/src/library-authoring/units/LibraryUnitPage.test.tsx
@@ -106,12 +106,12 @@ describe('<LibraryUnitPage />', () => {
   it('can rename unit', async () => {
     renderLibraryUnitPage();
     expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
-    // Unit title
-    const unitTitle = screen.getAllByRole(
+
+    const editUnitTitleButton = screen.getAllByRole(
       'button',
-      { name: mockGetContainerMetadata.containerData.displayName },
-    )[0];
-    fireEvent.click(unitTitle);
+      { name: /edit/i },
+    )[0]; // 0 is the Unit Title, 1 is the first component on the list
+    fireEvent.click(editUnitTitleButton);
 
     const url = getLibraryContainerApiUrl(mockGetContainerMetadata.containerId);
     axiosMock.onPatch(url).reply(200);
@@ -137,12 +137,12 @@ describe('<LibraryUnitPage />', () => {
   it('show error if renaming unit fails', async () => {
     renderLibraryUnitPage();
     expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument();
-    // Unit title
-    const unitTitle = screen.getAllByRole(
+
+    const editUnitTitleButton = screen.getAllByRole(
       'button',
-      { name: mockGetContainerMetadata.containerData.displayName },
-    )[0];
-    fireEvent.click(unitTitle);
+      { name: /edit/i },
+    )[0]; // 0 is the Unit Title, 1 is the first component on the list
+    fireEvent.click(editUnitTitleButton);
 
     const url = getLibraryContainerApiUrl(mockGetContainerMetadata.containerId);
     axiosMock.onPatch(url).reply(400);
@@ -210,11 +210,11 @@ describe('<LibraryUnitPage />', () => {
     // Wait loading of the component
     await screen.findByText('text block 0');
 
-    const componentTitle = screen.getAllByRole(
+    const editButton = screen.getAllByRole(
       'button',
-      { name: 'text block 0' },
-    )[0];
-    fireEvent.click(componentTitle);
+      { name: /edit/i },
+    )[1]; // 0 is the Unit Title, 1 is the first component on the list
+    fireEvent.click(editButton);
 
     await waitFor(() => {
       expect(screen.getByRole('textbox', { name: /text input/i })).toBeInTheDocument();
@@ -244,11 +244,11 @@ describe('<LibraryUnitPage />', () => {
     // Wait loading of the component
     await screen.findByText('text block 0');
 
-    const componentTitle = screen.getAllByRole(
+    const editButton = screen.getAllByRole(
       'button',
-      { name: 'text block 0' },
-    )[0];
-    fireEvent.click(componentTitle);
+      { name: /edit/i },
+    )[1]; // 0 is the Unit Title, 1 is the first component on the list
+    fireEvent.click(editButton);
 
     await waitFor(() => {
       expect(screen.getByRole('textbox', { name: /text input/i })).toBeInTheDocument();
diff --git a/src/library-authoring/units/LibraryUnitPage.tsx b/src/library-authoring/units/LibraryUnitPage.tsx
index a103a98966..c8d337314b 100644
--- a/src/library-authoring/units/LibraryUnitPage.tsx
+++ b/src/library-authoring/units/LibraryUnitPage.tsx
@@ -41,14 +41,16 @@ const EditableTitle = ({ unitId }: EditableTitleProps) => {
   const updateMutation = useUpdateContainer(unitId);
   const { showToast } = useContext(ToastContext);
 
-  const handleSaveDisplayName = (newDisplayName: string) => {
-    updateMutation.mutateAsync({
-      displayName: newDisplayName,
-    }).then(() => {
+  const handleSaveDisplayName = async (newDisplayName: string) => {
+    try {
+      await updateMutation.mutateAsync({
+        displayName: newDisplayName,
+      });
       showToast(intl.formatMessage(messages.updateContainerSuccessMsg));
-    }).catch(() => {
+    } catch (err) {
       showToast(intl.formatMessage(messages.updateContainerErrorMsg));
-    });
+      throw err;
+    }
   };
 
   // istanbul ignore if: this should never happen


Reviews:
user: login: rpenido
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T20:07:35Z
user: login: ChrisChV
body: Looks good :+1: I left one nit. I have not been able to reproduce the issue N° 3.

- [X] I tested this: I followed the testing instructions and verified the bugs are gone. I have not been able to reproduce error 3.
- [X] I read through the code and considered the security, stability and performance implications of the changes.
- [X] I tested that the UI can be used with a keyboard only (tab order, keyboard controls).
- [X] Includes tests for bugfixes and/or features added.
- [X] Includes documentation
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T20:58:45Z
user: login: rpenido
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T21:35:58Z
user: login: pomegranited
body: :+1: This is working well, thank you @rpenido !

- [x] I tested this on my tutor dev stack by editing collections, components, unit, and unit block titles. Also checked that the `[esc]` key still reverts any changes made without saving them.
- [x] I read through the code
- [x] I checked for accessibility issues by using my keyboard to navigate -- and checked the hover/active states are consistent using keyboard as well.
- [x] Includes documentation -- I've requested another comment.
- [x] User-facing strings are extracted for translation
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T00:49:04Z

Review comments:
diff_hunk: @@ -1,67 +1,49 @@
 import React, {
   useCallback,
-  useEffect,
   useState,
-  forwardRef,
 } from 'react';
 import {
   Form,
   Icon,
   IconButton,
-  OverlayTrigger,
   Stack,
 } from '@openedx/paragon';
 import { Edit } from '@openedx/paragon/icons';
 import { useIntl } from '@edx/frontend-platform/i18n';
 
 import messages from './messages';
 
-interface IconWrapperProps {
-  popper: any;
-  children: React.ReactNode;
-  [key: string]: any;
-}
-
-const IconWrapper = forwardRef<HTMLDivElement, IconWrapperProps>(({ 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 (
-    <div ref={ref} {...props}>
-      {children}
-    </div>
-  );
-});
-
 interface InplaceTextEditorProps {
   text: string;
-  onSave: (newText: string) => void;
+  onSave: (newText: string) => Promise<void>;
   readOnly?: boolean;
   textClassName?: string;
-  alwaysShowEditButton?: boolean;
 }
 
 export const InplaceTextEditor: React.FC<InplaceTextEditorProps> = ({
   text,
   onSave,
   readOnly = false,
   textClassName,
-  alwaysShowEditButton = false,
 }) => {
   const intl = useIntl();
   const [inputIsActive, setIsActive] = useState(false);
+  const [newText, setNewText] = useState<string>(); // 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<HTMLInputElement> | React.KeyboardEvent<HTMLInputElement>) => {
+      const inputText = event.currentTarget.value;
       setIsActive(false);
+      if (inputText && inputText !== text) {
+        setNewText(inputText);
+        try {
+          await onSave(inputText);
+        } catch {
+          // don't propagate the exception
+        } finally {
+          setNewText(undefined);
+        }
path: src/generic/inplace-text-editor/index.tsx
user: login: rpenido
body: Using the optimistic update from react query works for most cases, but sometimes the updated data is too deep in the cache (the title of a block inside a unit is stored on the container children array).
To help with these cases, we are using the `newText` state here to show the pending new text until the `onSave` promise resolves.
created_at: 2025-05-14T20:07:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -137,12 +137,12 @@ describe('<LibraryUnitPage />', () => {
   it('show error if renaming unit fails', async () => {
path: src/library-authoring/units/LibraryUnitPage.test.tsx
user: login: ChrisChV
body: @rpenido I think we should check if the title returns to the previous value.
created_at: 2025-05-14T20:43:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -137,12 +137,12 @@ describe('<LibraryUnitPage />', () => {
   it('show error if renaming unit fails', async () => {
path: src/library-authoring/units/LibraryUnitPage.test.tsx
user: login: rpenido
body: Great @ChrisChV! Added here on the component tests: [3095249](https://github.com/openedx/frontend-app-authoring/pull/1953/commits/3095249856b81af695f326ceaef8f61029cf6284)
created_at: 2025-05-14T21:35:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,67 +1,49 @@
 import React, {
   useCallback,
-  useEffect,
   useState,
-  forwardRef,
 } from 'react';
 import {
   Form,
   Icon,
   IconButton,
-  OverlayTrigger,
   Stack,
 } from '@openedx/paragon';
 import { Edit } from '@openedx/paragon/icons';
 import { useIntl } from '@edx/frontend-platform/i18n';
 
 import messages from './messages';
 
-interface IconWrapperProps {
-  popper: any;
-  children: React.ReactNode;
-  [key: string]: any;
-}
-
-const IconWrapper = forwardRef<HTMLDivElement, IconWrapperProps>(({ 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 (
-    <div ref={ref} {...props}>
-      {children}
-    </div>
-  );
-});
-
 interface InplaceTextEditorProps {
   text: string;
-  onSave: (newText: string) => void;
+  onSave: (newText: string) => Promise<void>;
   readOnly?: boolean;
   textClassName?: string;
-  alwaysShowEditButton?: boolean;
 }
 
 export const InplaceTextEditor: React.FC<InplaceTextEditorProps> = ({
   text,
   onSave,
   readOnly = false,
   textClassName,
-  alwaysShowEditButton = false,
 }) => {
   const intl = useIntl();
   const [inputIsActive, setIsActive] = useState(false);
+  const [newText, setNewText] = useState<string>(); // 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<HTMLInputElement> | React.KeyboardEvent<HTMLInputElement>) => {
+      const inputText = event.currentTarget.value;
       setIsActive(false);
+      if (inputText && inputText !== text) {
+        setNewText(inputText);
+        try {
+          await onSave(inputText);
+        } catch {
+          // don't propagate the exception
+        } finally {
+          setNewText(undefined);
+        }
path: src/generic/inplace-text-editor/index.tsx
user: login: pomegranited
body: Could you add a comment here to explain this? Otherwise we might be scratching our heads about it later :)
created_at: 2025-05-15T00:24:26Z
author_association: CONTRIBUTOR

PR comments:
user: login: openedx-webhooks
created_at: 2025-05-14T14:13:02Z
body: Thanks for the pull request, @rpenido!


This repository is currently maintained by `@bradenmacdonald`.


Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

<details><summary>:radio_button: Get product approval</summary>

If you haven't already, [check this list](https://openedx.atlassian.net/wiki/spaces/COMM/pages/3875962884/How+to+submit+an+open+source+contribution+for+Product+Review#Does-my-contribution-require-Product-Review%3F) to see if your contribution needs to go through the product review process.

- If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the [Product Working Group](https://openedx.atlassian.net/wiki/spaces/COMM/pages/3449028609/Product+Working+Group).
    - This process (including the steps you'll need to take) is documented [here](https://openedx.atlassian.net/wiki/spaces/COMM/pages/3875962884/How+to+submit+an+open+source+contribution+for+Product+Review#Product-Review-Process).
- If it doesn't, simply proceed with the next step.
</details>

<details><summary>:radio_button: Provide context</summary>

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

- Dependencies
  > This PR must be merged before / after / at the same time as ...
- Blockers
  > This PR is waiting for OEP-1234 to be accepted.
- Timeline information
  > This PR must be merged by XX date **because** ...
- Partner information
  > This is for a course on edx.org.
- Supporting documentation
- Relevant [Open edX discussion forum](https://discuss.openedx.org/) threads
</details>





<details><summary>:radio_button: Get a green build</summary>

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.
</details><details>


---

<details><summary>Where can I find more information?</summary>

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

- [Overview of Review Process for Community Contributions](https://docs.openedx.org/en/latest/developers/references/developer_guide/process/FAQ-about-pull-requests.html)
- [Pull Request Status Guide](https://docs.openedx.org/en/latest/developers/references/developer_guide/process/pull-request-statuses.html)
- [Making changes to your pull request](https://docs.openedx.org/en/latest/documentors/how-tos/make_changes_to_your_pull_request.html)
</details>

<details><summary>When can I expect my changes to be merged?</summary>

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

- The size and impact of the changes that it introduces
- The need for product review
- Maintenance status of the parent repository

:bulb: *As a result it may take up to several weeks or months to complete a review and merge your PR.*
</details>
<!-- comment:external_pr -->
<!-- data: eyJkcmFmdCI6IGZhbHNlfQ== -->

author_association: NONE
user: login: codecov[bot]
created_at: 2025-05-14T17:54:59Z
body: ## [Codecov](https://app.codecov.io/gh/openedx/frontend-app-authoring/pull/1953?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 93.89%. Comparing base [(`88aa4c1`)](https://app.codecov.io/gh/openedx/frontend-app-authoring/commit/88aa4c15246469bc8994a74feca8b25a37e03c8f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx) to head [(`14804b8`)](https://app.codecov.io/gh/openedx/frontend-app-authoring/commit/14804b88972a5328f7eedddad196d249b2fed8cd?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx).
> Report is 3 commits behind head on master.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #1953      +/-   ##
==========================================
- Coverage   93.90%   93.89%   -0.02%     
==========================================
  Files        1153     1153              
  Lines       24053    24062       +9     
  Branches     5198     5091     -107     
==========================================
+ Hits        22586    22592       +6     
- Misses       1399     1402       +3     
  Partials       68       68              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/openedx/frontend-app-authoring/pull/1953?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openedx).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
author_association: NONE
user: login: rpenido
created_at: 2025-05-15T14:15:23Z
body: Done @pomegranited! [14804b8](https://github.com/openedx/frontend-app-authoring/pull/1953/commits/14804b88972a5328f7eedddad196d249b2fed8cd)
author_association: CONTRIBUTOR
user: login: pomegranited
created_at: 2025-05-16T01:05:44Z
body: Thanks @rpenido , ready for your merge @ChrisChV  :)
author_association: CONTRIBUTOR