Description:
I have a button in a form. When the button is clicked, the form is submitted to a backend. While the request is in flight, I'd like the button to show a loading signal instead of the usual text. When the request is done, I'd like the form to disappear.
I can do this with useState for the different button forms, but my issue is that just before the form disappears, the text shows again. This is a small issue but it looks unpolished.
Issue:
When the button is clicked, the loading animation appears as intended. When the request finishes, I can see the text re-appear for a split second before the dialog disappears. I don't want the text to reappear until I reopen the dialog.
What I tried:
I currently change the loading state back to false after I call (and wait on) closing the dialog. I tried NOT doing that, but this causes the loading button to be there when I reopen the dialog, which is worse.
To correct that, I tried setting the value of the loading state to false on startup, as follows:
useEffect(() => {
setLoading(false);
}, []);
this had no effect, which leads me to believe that the component only gets mounted once, and that when I close it it doesn't actually get unmounted.
I tried using the LoadingButton component from material-ui/lab, but for a variety of typescript/react reasons it was just throwing errors left and right. Since it's an experimental package I decided to stay away from it.
Code:
Here is my component:
export const NewClientDialog = (props: INewClientDialogProps) => {
// open is a boolean that's true when the dialog is open, close is a function that closes the form dialog
const {open, close} = props;
const [fn, setFn] = useState("");
const [ln, setLn] = useState("");
const [email, setEmail] = useState("");
const [pn, setPn] = useState("");
const [newUser, setNewUser] = useState<INewClientProps>();
const [loading, setLoading] = useState(false);
// this is not optimal, but not the point of this post. It sets the object from the form inputs for the network request
useEffect(() => {
const newClient: INewClientProps = {
firstName: fn,
lastName: ln,
email: email,
phoneNumber: pn
}
setNewUser(newClient)
}, [fn, ln, email, pn])
// this gets triggered when a button is clicked
const onAddClient = () => {
// set the loading animation
setLoading(true)
// this calls the network API and returns a promise
createNewClient(newUser)
// once the promise is completed, close the dialog
.then(() => close())
// then set the loading back to false
.then(() => setLoading(false))
}
// this the button, if loading is true then it is a circular loading icon otherwise it's text
const AddClientButton = () => {
const contents = loading ? ><CircularProgress /> : <div>Create new client</div>
return (
<Button onClick={() => onAddClient()}>
{contents}
</Button>
);
}
return (
<div>
<Dialog onClose={close} open={open}>
<TextField label="First name" onChange={(value) => setFn(value.target.value)}/>
<TextField label="Last name" onChange={(value) => setLn(value.target.value)}/>
<TextField label="Email address" onChange={(value) => setEmail(value.target.value)}/>
<TextField label="Phone number" onChange={(value) => setPn(value.target.value)}/>
<AddClientButton />
</Dialog>
</div>
)
}
Just after writing it, I had an epiphany:
useEffect(() => {
setLoading(false);
}, [open]);
this does exactly what I want it to do - when the dialog is open, set loading to false. I removed the setLoading(false) call in onAddClient.
I will leave this up, hopefully it helps anyone who wants to create dynamic buttons in Material UI.
Related
I'm attempting to test a React component similar to the following:
import React, { useState, useEffect, useRef } from "react";
export default function Tooltip({ children }) {
const [open, setOpen] = useState(false);
const wrapperRef = useRef(null);
const handleClickOutside = (event) => {
if (
open &&
wrapperRef.current &&
!wrapperRef.current.contains(event.target)
) {
setOpen(false);
}
};
useEffect(() => {
document.addEventListener("click", handleClickOutside);
return () => {
document.removeEventListener("click", handleClickOutside);
};
});
const className = `tooltip-wrapper${(open && " open") || ""}`;
return (
<span ref={wrapperRef} className={className}>
<button type="button" onClick={() => setOpen(!open)} />
<span>{children}</span>
<br />
<span>DEBUG: className is {className}</span>
</span>
);
}
Clicking on the tooltip button changes the state to open (changing the className), and clicking again outside of the component changes it to closed.
The component works (with appropriate styling), and all of the React Testing Library (with user-event) tests work except for clicking outside.
it("should close the tooltip on click outside", () => {
// Arrange
render(
<div>
<p>outside</p>
<Tooltip>content</Tooltip>
</div>
);
const button = screen.getByRole("button");
userEvent.click(button);
// Temporary assertion - passes
expect(button.parentElement).toHaveClass("open");
// Act
const outside = screen.getByText("outside");
// Gives should be wrapped into act(...) warning otherwise
act(() => {
userEvent.click(outside);
});
// Assert
expect(button.parentElement).not.toHaveClass("open"); // FAILS
});
I don't understand why I had to wrap the click event in act - that's generally not necessary with React Testing Library.
I also don't understand why the final assertion fails. The click handler is called twice, but open is true both times.
There are a bunch of articles about limitations of React synthetic events, but it's not clear to me how to put all of this together.
I finally got it working.
it("should close the tooltip on click outside", async () => {
// Arrange
render(
<div>
<p data-testid="outside">outside</p>
<Tooltip>content</Tooltip>
</div>
);
const button = screen.getByRole("button");
userEvent.click(button);
// Verify initial state
expect(button.parentElement).toHaveClass("open");
const outside = screen.getByTestId("outside");
// Act
userEvent.click(outside);
// Assert
await waitFor(() => expect(button.parentElement).not.toHaveClass("open"));
});
The key seems to be to be sure that all activity completes before the test ends.
Say a test triggers a click event that in turn sets state. Setting state typically causes a rerender, and your test will need to wait for that to occur. Normally you do that by waiting for the new state to be displayed.
In this particular case waitFor was appropriate.
codesandbox here: https://codesandbox.io/s/restless-haze-v01wv?file=/src/App.js
I have a Users component which (when simplified) looks something like this:
const Users = () => {
const [toastOpen, setToastOpen] = useState(false)
// functions to handle toast closing
return (
<EditUser />
<Toast />
)
}
const EditUser = () => {
[user, setUser] = useState(null)
useEffect(() => {
const fetchedUser = await fetchUser()
setUser(fetchedUser)
}, [])
// this approach results in UserForm's username resetting when the toast closes
const Content = () => {
if (user) return <UserForm user={user} />
else return <div>Loading...</div>
}
return <Content />
// if I do this instead, everything's fine
return (
<div>
{
user ? <UserForm user={user} /> : <div>Loading...</div>
}
</div>
)
}
const UserForm = ({ user }) => {
const [username, setUsername] = useState(user.name)
return <input value={username}, onChange={e => setUsername(e.target.value)} />
}
While viewing the UserForm page while a Toast is still open, the UserForm state is reset when the Toast closes.
I've figured out that the issue is the Content component defined inside of EditUser, but I'm not quite clear on why this is an issue. I'd love a walkthrough of what's happening under React's hood here, and what happens in a "happy path"
You have defined Content inside EditUser component which we never do with React Components, because in this situtaion, Content will be re-created every time the EditUser is re-rendered. (surely, EditUser is going to be re-rendered few/many times).
So, a re-created Content component means the old Content will be destroyed (unmounted) and the new Content will be mounted.
That's why it is be being mounted many times and hence resetting the state values to initial values.
So, the solution is to just define it (Content) outside - not inside any other react component.
The culprit was EditUser's Content function, which predictably returns a brand new instance of each time it's called.
I am creating some custom inline editing functionality and am trying to reset the data when the user hit cancel after making edits to specific columns. I'm using cellRenderers for this so I don't have access to the editing params that a cellEditor would.
Currently, what I am doing is saving the specific row's data in React state originalRowInfo when that row is clicked:
const moreInfoClick = (event, toggleMoreInfo, rowData) => {
event.persist();
setMoreInfoLocation({ x: event.clientX, y: event.clientY });
setRowInfo(rowData);
setOriginalRowInfo({ ...rowData });
toggleMoreInfo(true);
};
I am getting rowData via params.data when I click on a row
The reason I can't just use the rowInfo state is because that gets changed by ag-grid whenever I make an edit to the column. But using {...rowData} seems to fix that.
My only issue now is one of my column values is an array with objects inside and when I edit that column, it gets changed inside originalRowInfo as well. I would imagine this has something to do with object referencing.
The column looks something like this:
[
{position: 1, heat_num: 100},
{position: 2, heat_num: 200}
]
And what actually gets edited is the heat_num
The only thing I can think of is to refetch the data from DB on Cancel but I would rather not have to do that.
You don't need originalRowInfo to reset to the original state if the user cancels the editing, but you may rather do the opposite: save the new value inputValue as temporary state when the user editing and:
If the user submit the result: call ICellRendererParams.setValue(inputValue) to commit the result.
Otherwise, if the user abort: simply discard the result by resetting inputValue to ICellRendererParams.value which is the original value.
function EditableCellRenderer(params: ICellRendererParams) {
const [editable, setEditable] = React.useState(false);
const [originalData] = React.useState(params.value);
const [inputValue, setInputValue] = React.useState(params.value);
const inputRef = React.useRef(null);
const onEdit = () => {
setEditable(true);
setTimeout(() => {
inputRef.current?.focus();
});
};
const onCancel = () => {
setInputValue(originalData);
setEditable(false);
};
const onSubmit = () => {
params.setValue(inputValue);
setEditable(false);
};
return (
<>
<input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
disabled={!editable}
/>
<button onClick={onEdit}>E</button>
<button onClick={onCancel}>X</button>
<button onClick={onSubmit}>S</button>
</>
);
}
Live Demo
Is there some way to instruct react-select to select an option on menu close or select blur, but only if it is the one created (not from default list)?
Context:
I have a list of e-mail addresses and want to allow user to select from the list or type new e-mail address and then hit Submit button. I do the select part with react-select's Creatable component and it works.
import CreatableSelect from 'react-select/creatable';
<CreatableSelect
options={options}
isMulti={true}
isSearchable={true}
name={'emailAddresses'}
hideSelectedOptions={true}
isValidNewOption={(inputValue) => validateEmail(inputValue)}
/>
But what happens to my users is that they type new e-mail address, do not understand they need to click the newly created option in dropdown menu and directly hit the Submit button of the form. Thus the menu closes because select's focus is stolen and form is submitted with no e-mail address selected.
I look for a way how can I select the created option before the menu is closed and the typed option disappears.
You can keep track of the inputValue and add the inputValue as a new option when the onMenuClose and onBlur callbacks are triggered.
Keep in mind that both onBlur and onMenuClose will fire if you click anywhere outside of the select area. onMenuClose can also fire alone without onBlur if you press Esc key so you will need to write additional logic to handle that extra edge case.
function MySelect() {
const [value, setValue] = React.useState([]);
const [inputValue, setInputValue] = React.useState("");
const isInputPreviouslyBlurred = React.useRef(false);
const createOptionFromInputValue = () => {
if (!inputValue) return;
setValue((v) => {
return [...(v ? v : []), { label: inputValue, value: inputValue }];
});
};
const onInputBlur = () => {
isInputPreviouslyBlurred.current = true;
createOptionFromInputValue();
};
const onMenuClose = () => {
if (!isInputPreviouslyBlurred.current) {
createOptionFromInputValue();
}
else {} // option's already been created from the input blur event. Skip.
isInputPreviouslyBlurred.current = false;
};
return (
<CreatableSelect
isMulti
value={value}
onChange={setValue}
inputValue={inputValue}
onInputChange={setInputValue}
options={options}
onMenuClose={onMenuClose}
onBlur={onInputBlur}
/>
);
}
Live Demo
I have implemented the click-outside hook to close my menu component on mousedown on the document:
const useClickOutside = onClickOutside => {
const ref = useRef(null);
useEffect(
() => {
const handleClickOutside = e => {
if (ref.current && !ref.current.contains(e.target)) {
onClickOutside(e);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
},
[onClickOutside, ref]
);
return ref;
};
The menu has an input which attaches an onBlur event handler:
const Input = ({ onValueEnter }) => {
const [value, setValue] = useState("");
const handleValueChange = e => setValue(e.target.value);
const handleBlur = () => onValueEnter(value);
return (
<input onBlur={handleBlur} value={value} onChange={handleValueChange} />
);
};
const Menu = ({ onClose }) => {
const ref = useClickOutside(onClose);
return (
<div ref={ref} className="menu">
<h1>Enter value</h1>
<Input onValueEnter={handleValueEnter} />
</div>
);
};
The problem is that the onBlur event on the input never fires if I have focus inside the input and click outside the menu. Codesandbox example is here.
Apparently since react has implemented its own event delegation system by attaching events to the top level instead of the actual dom nodes, global event handlers (like those registered with document.addEventListener) run before the react event handlers (github issue).
So my question is, how to work around this problem? Is it even possible to somehow make the onBlur event handler run first and then run the global event handler?
EDIT: I am already using a hack with setTimeout inside the onClose to temporarily make it work but I would really like to avoid it and find a better solution instead.
I hacked something together to make it work. It revolves around using forwardRef and imperativeHandle to access the value from child when closing. I don't know if that solves your question though.
https://codesandbox.io/embed/zw7jjopqq4