Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import * as React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';

import { Basic } from './InPlaceEditor.stories';
import { Basic, CancelOnBlur, Editor } from './InPlaceEditor.stories';

const DismissibleBasic = () => {
const [isVisible, setIsVisible] = React.useState(true);
return (
<>
{isVisible ? <Basic delay={0} /> : null}
<button onClick={() => setIsVisible(false)}>Next</button>
</>
);
};

describe('InPlaceEditor', () => {
it('should render the field value on mount', async () => {
Expand All @@ -23,6 +33,41 @@ describe('InPlaceEditor', () => {
fireEvent.blur(input);
await screen.findByText('Jane Doe');
});
it('should save when focus moves outside the editor', async () => {
render(
<>
<Basic delay={0} />
<button>Next</button>
</>
);
const value = await screen.findByText('John Doe');
value.click();
const input = await screen.findByDisplayValue('John Doe');
input.focus();
fireEvent.change(input, { target: { value: 'Jane Doe' } });
const nextButton = screen.getByRole('button', { name: 'Next' });
nextButton.focus();
await screen.findByText('Jane Doe');
});
it('should save before an outside action unmounts the editor', async () => {
render(<DismissibleBasic />);
const value = await screen.findByText('John Doe');
value.click();
const input = await screen.findByDisplayValue('John Doe');
input.focus();
fireEvent.change(input, { target: { value: 'Jane Doe' } });
const form = input.closest('form');
if (!form) {
throw new Error('Could not find the InPlaceEditor form');
}
const handleSubmit = jest.fn();
form.addEventListener('submit', handleSubmit);
const nextButton = screen.getByRole('button', { name: 'Next' });
nextButton.focus();
fireEvent.click(nextButton);
expect(screen.queryByDisplayValue('Jane Doe')).toBeNull();
expect(handleSubmit).toHaveBeenCalledTimes(1);
});
it('should revert to the previous version on error', async () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
render(<Basic delay={0} updateFails />);
Expand All @@ -34,6 +79,41 @@ describe('InPlaceEditor', () => {
await screen.findByText('Jane Doe');
await screen.findByText('John Doe');
});
describe('cancelOnBlur', () => {
it('should cancel when focus moves outside the editor', async () => {
render(
<>
<CancelOnBlur />
<button>Next</button>
</>
);
const value = await screen.findByText('John Doe');
value.click();
const input = await screen.findByDisplayValue('John Doe');
input.focus();
fireEvent.change(input, { target: { value: 'Jane Doe' } });
const nextButton = screen.getByRole('button', { name: 'Next' });
nextButton.focus();
await screen.findByText('John Doe');
});
});
describe('editor', () => {
it('should keep editing when focus moves to a portaled control', async () => {
render(<Editor />);
const value = await screen.findByText('Customer');
value.click();
await screen.findByRole('listbox', undefined, {
timeout: 1000,
});
const selectedOption = await screen.findByRole(
'option',
{ name: 'Customer' },
{ timeout: 1000 }
);
expect(document.activeElement).toBe(selectedOption);
await screen.findByRole('combobox', { hidden: true });
});
});
describe('notifyOnSuccess', () => {
it('should show a notification on success', async () => {
render(<Basic delay={0} notifyOnSuccess />);
Expand All @@ -53,5 +133,16 @@ describe('InPlaceEditor', () => {
await screen.findByLabelText('Save');
await screen.findByLabelText('Cancel');
});
it('should keep editing when focus moves to an action button', async () => {
render(<Basic delay={0} showButtons />);
const value = await screen.findByText('John Doe');
value.click();
const input = await screen.findByDisplayValue('John Doe');
input.focus();
fireEvent.change(input, { target: { value: 'Jane Doe' } });
const saveButton = await screen.findByLabelText('Save');
saveButton.focus();
await screen.findByDisplayValue('Jane Doe');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useResourceContext,
useTranslate,
useUpdate,
useEvent,
Form,
RecordContextProvider,
type UseUpdateOptions,
Expand Down Expand Up @@ -101,6 +102,8 @@ export const InPlaceEditor = <
}

const submitButtonRef = useRef<HTMLButtonElement>(null);
const pendingBlurRef = useRef(false);
const focusDocumentRef = useRef<Document | null>(null);

const [state, dispatch] = useReducer<
(
Expand Down Expand Up @@ -195,19 +198,55 @@ export const InPlaceEditor = <
}
};

const handleBlur = (event: React.FocusEvent) => {
if (event.relatedTarget) {
return;
}
const handleBlurAway = useEvent(() => {
if (cancelOnBlur) {
dispatch({ type: 'cancel' });
return;
}
if (state.state === 'editing') {
// trigger the parent form submit
// to save the changes
(submitButtonRef.current as HTMLButtonElement).click();
submitButtonRef.current?.click();
}
});
const handleDocumentFocusIn = useEvent(() => {
focusDocumentRef.current = null;
if (!pendingBlurRef.current) {
return;
}
pendingBlurRef.current = false;
handleBlurAway();
});
React.useEffect(
() => () => {
focusDocumentRef.current?.removeEventListener(
'focusin',
handleDocumentFocusIn
);
},
[handleDocumentFocusIn]
);
const handleBlur = (event: React.FocusEvent) => {
if (!event.relatedTarget) {
handleBlurAway();
return;
}
pendingBlurRef.current = true;
// React focus events from portals bubble through their component tree
// before reaching document, so handleFocus can cancel internal moves.
// External moves are handled here before the destination's click event.
const ownerDocument = event.currentTarget.ownerDocument;
focusDocumentRef.current?.removeEventListener(
'focusin',
handleDocumentFocusIn
);
focusDocumentRef.current = ownerDocument;
ownerDocument.addEventListener('focusin', handleDocumentFocusIn, {
once: true,
});
};
const handleFocus = () => {
pendingBlurRef.current = false;
};

const renderContent = () => {
Expand All @@ -227,6 +266,7 @@ export const InPlaceEditor = <
<Box
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onFocus={handleFocus}
className={InPlaceEditorClasses.editing}
>
{editor}
Expand Down