React & Ant Design - Submit form before switching tabs - reactjs

I have an ant design tab group with an ant design form in each tab. I would like that upon switching tabs, the user is prompted to submit their changes. If the user selects yes, then the data should be submitted. We switch tabs only if the response comes back as a success, or the user opted not to save.
The forms are all child components, which means the parent needs to somehow indicate that the tab is switching, and tell the child to submit their form.
I can achieve this with the useImperativeHandle hook and forwardRef but I'm wondering if there is some non-imperative way to achieve this?
Here is a stripped down example, not checking if the form is dirty, and just using the native confirm popup. There is also an async function to simulate submitting the form, which will randomly succeed or error.
Stackblitz: https://stackblitz.com/edit/react-ts-zw2cgi?file=my-form.tsx
The forms:
export type FormRef = { submit: (data?: Data) => Promise<boolean> };
export type Data = { someField: string };
const MyForm = (props: {}, ref: Ref<FormRef>) => {
const [form] = useForm<Data>();
useImperativeHandle(ref, () => ({ submit }));
async function submit(data?: Data): Promise<boolean> {
if (!data) data = form.getFieldsValue();
const res = await submitData(data);
return res;
}
return (
<Form form={form} onFinish={submit}>
<Form.Item name="someField">
<Input />
</Form.Item>
<Form.Item>
<Button htmlType="submit">Submit</Button>
</Form.Item>
</Form>
);
};
export default forwardRef(MyForm);
The parent component with the tabs:
const App: FC = () => {
const [activeKey, setActiveKey] = useState('tabOne');
const formOneRef = useRef<FormRef>();
const formTwoRef = useRef<FormRef>();
async function onChange(key: string) {
if (confirm('Save Changes?')) {
if (activeKey === 'tabOne' && (await formOneRef.current.submit()))
setActiveKey(key);
if (activeKey === 'tabTwo' && (await formTwoRef.current.submit()))
setActiveKey(key);
} else setActiveKey(key);
}
const tabs = [
{
label: 'Tab One',
key: 'tabOne',
children: <MyForm ref={formOneRef} />,
},
{
label: 'Tab Two',
key: 'tabTwo',
children: <MyForm ref={formTwoRef} />,
},
];
return <Tabs items={tabs} onChange={onChange} activeKey={activeKey} />;
};
The submit function
export default async function submitData(data: Data) {
console.log('Submitting...', data);
const res = await new Promise<boolean>((resolve) =>
setTimeout(
() => (Math.random() < 0.5 ? resolve(true) : resolve(false)),
1000
)
);
if (res) {
console.log('Success!', data);
return true;
}
if (!res) {
console.error('Fake Error', data);
return false;
}
}
Ant Design Tabs: https://ant.design/components/tabs/
Ant Design Form: https://ant.design/components/form/

I ended up making a state variable to store the submit function, and setting it in the child with useEffect.
A few caveats:
Had to set destroyInactiveTabPane to ensure forms were unmounted and remounted when navigating, so useEffect was called.
Had to wrap the form's submit function in useCallback as it was now a dependency of useEffect.
Had to make sure when calling setSubmitForm I had passed a function that returns the function, else the dispatch just calls submit immediately.
Stackblitz: https://stackblitz.com/edit/react-ts-n8y3kh?file=App.tsx
export type Data = { someField: string };
type Props = {
setSubmitForm: Dispatch<SetStateAction<() => Promise<boolean>>>;
};
const MyForm: FC<Props> = ({ setSubmitForm }) => {
const [form] = useForm<Data>();
const submit = useCallback(
async (data?: Data): Promise<boolean> => {
if (!data) data = form.getFieldsValue();
const res = await submitData(data);
return res;
},
[form]
);
useEffect(() => {
setSubmitForm(() => submit);
}, [setSubmitForm, submit]);
return (
<Form form={form} onFinish={submit}>
<Form.Item name="someField">
<Input />
</Form.Item>
<Form.Item>
<Button htmlType="submit">Submit</Button>
</Form.Item>
</Form>
);
};
const App: FC = () => {
const [activeKey, setActiveKey] = useState('tabOne');
const [submitForm, setSubmitForm] = useState<() => Promise<boolean>>(
async () => true
);
async function onChange(key: string) {
if (confirm('Save Changes?')) {
if (await submitForm()) setActiveKey(key);
} else setActiveKey(key);
}
const tabs = [
{
label: 'Tab One',
key: 'tabOne',
children: <MyForm setSubmitForm={setSubmitForm} />,
},
{
label: 'Tab Two',
key: 'tabTwo',
children: <MyForm setSubmitForm={setSubmitForm} />,
},
];
return (
<Tabs
items={tabs}
onChange={onChange}
activeKey={activeKey}
destroyInactiveTabPane
/>
);
};

Related

How to ensure useState works when mocking custom react hook

I have a component which imports a custom hook. I want to mock returned values of this hook but ensure the useState still works when I fire and event.
component.tsx
export const Component = () => {
const { expanded, text, handleClick, listOfCards } = useComponent();
return (
<div>
<button id="component" aria-controls="content" aria-expanded={expanded}>
{text}
</button>
{expanded && (
<div role="region" aria-labelledby="component" id="content">
{listOfCards.map((card) => (
<p>{card.name}</p>
))}
</div>
)}
</div>
);
};
useComponent.tsx
const useComponent = () => {
const [expanded, setExpanded] = useState(false);
const { listOfCards } = useAnotherCustomHook();
const { translate } = useTranslationTool();
return {
text: translate("id123"),
expanded,
handleClick: () => setExpanded(!expanded),
listOfCards,
};
};
component.test.tsx
jest.mock("./component.hook");
const mockuseComponent = useComponent as jest.Mock<any>;
test("Checks correct attributes are used, and onClick is called when button is clicked", () => {
mockuseComponent.mockImplementation(() => ({
text: "Click to expand",
listOfCards: [{ name: "name1" }, { name: "name2" }],
}));
render(<Component />);
const button = screen.getByRole("button", { name: "Click to expand" });
expect(button).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(button);
expect(button).toHaveAttribute('aria-expanded', 'true');
});
With the above test aria-expanded doesnt get set to true after we fire the event because im mocking the whole hook. So my question is, is there a way to only mock part of the hook and keep the useState functionality?

React TypeError is not a function with Onboarding implementation

I want to expand a demo provided by some tutorial about React Design Patterns, subject: Controlled Onboarding Flows, to implement multiple forms on several steps via Onboarding. But unfortunately the tutor did stop at the exciting part when it comes to having two-directional flows.
So I'm stuck and don't understand how to select the resp. function (marked with "// HOW TO DECIDE?!" in the 2nd code segment here).
So, every time I hit the prev. button, I receive the "Uncaught TypeError: goToPrevious is not a function" message, because both are defined.
Any suggestions on how to handle this?
This is what I got so far.
The idea behind this is to get the data from each form within the respo. Step Component and manage it witihin the parent component - which atm happens to be the App.js file.
Any help, tips, additional sources to learn this would be highly appreciated.
This is my template for the resp. controlled form components I want to use:
export const ControlledGenericForm = ({ formData, onChange }) => {
return (
<form>
{Object.keys(formData).map((formElementKey) => (
<input
key={formElementKey}
value={formData[formElementKey]}
type="text"
id={formElementKey}
onInput={(event) => onChange(event.target.id, event.target.value)}
/>
))}
</form>
);
};
That's my controlled Onboarding component, I want to use:
import React from "react";
export const ControlledOnboardingFlow = ({
children,
currentIndex,
onPrevious,
onNext,
onFinish,
}) => {
const goToNext = (stepData) => {
onNext(stepData);
};
const goToPrevious = (stepData) => {
onPrevious(stepData);
};
const goToFinish = (stepData) => {
onFinish(stepData);
};
const currentChild = React.Children.toArray(children)[currentIndex];
if (currentChild === undefined) goToFinish();
// HOW TO DECIDE?!
if (currentChild && onNext)
return React.cloneElement(currentChild, { goToNext });
if (currentChild && onPrevious)
return React.cloneElement(currentChild, { goToPrevious });
return currentChild;
};
And that's the actual use of this two components within my App:
import { useState } from "react";
import { ControlledOnboardingFlow } from "./ControlledComponents/ControlledOnboardingFlow";
import { ControlledGenericForm } from "./ControlledComponents/ControlledGenericForm";
function App() {
const [onboardingData, setOnboardingData] = useState({
name: "Juh",
age: 22,
hair: "green",
street: "Main Street",
streetNo: 42,
city: "NYC",
});
const [currentIndex, setCurrentIndex] = useState(0);
const formDataPartOne = (({ name, age, hair }) => ({ name, age, hair }))(
onboardingData
);
const formDataPartTwo = (({ street, streetNo, city }) => ({
street,
streetNo,
city,
}))(onboardingData);
const onNext = (stepData) => {
setOnboardingData({ ...onboardingData, ...stepData });
setCurrentIndex(currentIndex + 1);
};
const onPrevious = (stepData) => {
setOnboardingData({ ...onboardingData, ...stepData });
setCurrentIndex(currentIndex - 1);
};
const onFinish = () => {
console.log("Finished");
console.log(onboardingData);
};
const handleFormUpdate = (id, value) => {
setOnboardingData({ ...onboardingData, [id]: value });
};
const StepOne = ({ goToPrevious, goToNext }) => (
<>
<h1>Step 1</h1>
<ControlledGenericForm
formData={formDataPartOne}
onChange={handleFormUpdate}
/>
<button onClick={() => goToPrevious(onboardingData)} >
Prev
</button>
<button onClick={() => goToNext(onboardingData)}>Next</button>
</>
);
const StepTwo = ({ goToPrevious, goToNext }) => (
<>
<h1>Step 2</h1>
<ControlledGenericForm
formData={formDataPartTwo}
onChange={handleFormUpdate}
/>
<button onClick={() => goToPrevious(onboardingData)}>Prev</button>
<button onClick={() => goToNext(onboardingData)}>Next</button>
</>
);
const StepThree = ({ goToPrevious, goToNext }) => (
<>
<h1>Step 3</h1>
<h3>
Congrats {onboardingData.name} for being from, {onboardingData.city}
</h3>
<button onClick={() => goToNext(onboardingData)}>Next</button>
</>
);
return (
<ControlledOnboardingFlow
currentIndex={currentIndex}
onPrevious={onPrevious}
onNext={onNext}
onFinish={onFinish}
>
<StepOne />
<StepTwo />
{onboardingData.city === "NYC" && <StepThree />}
</ControlledOnboardingFlow>
);
}
export default App;
if (currentChild && onNext)
return React.cloneElement(currentChild, { goToNext });
Since onNext exists, this is the code that will run. It clones the element and gives it a goToNext prop, but it does not give it a goToPrevious prop. So when you press the previous button and run code like onClick={() => goToPrevious(onboardingData)}, the exception is thrown.
It looks like you want to pass both functions into the child, which can be done like:
const currentChild = React.Children.toArray(children)[currentIndex];
if (currentChild === undefined) goToFinish();
if (currentChild) {
return React.cloneElement(currentChild, { goToNext, goToPrevious });
}
return currentChild;
If one or both of them happens to be undefined, then the child will get undefined, but that's what you would do anyway with the if/else.

How to test changes made by onClick event that calls a setState function, which is passed from another component and changes UI?

Basically the title.
Here is the overview of the App:
const App = () => {
const [isViewFavoriteImages, setIsViewFavoriteImages] = useState(false);
const toggleIsViewFavoriteImages = () => {
setIsViewFavoriteImages(
(prevToggleIsViewFavoriteImagesState) =>
!prevToggleIsViewFavoriteImagesState
);
};
return (
<div className="App">
<div className="container">
<ToggleImagesViewButton
toggleIsViewFavoriteImages={toggleIsViewFavoriteImages}
isViewFavoriteImages={isViewFavoriteImages}
/>
<ImageList isViewFavoriteImages={isViewFavoriteImages} />
</div>
</div>
);
};
export default App;
The button component:
export interface ToggleImageViewButtonProps {
toggleIsViewFavoriteImages: () => void;
isViewFavoriteImages: boolean;
}
const ToggleImageViewButton: React.FC<ToggleImageViewButtonProps> = ({
toggleIsViewFavoriteImages,
isViewFavoriteImages,
}) => {
return (
<button
onClick={toggleIsViewFavoriteImages}
className="btn btn_toggle-image-view"
data-testid="toggle-image-view"
>
{isViewFavoriteImages ? "view all" : "view favorites"}
</button>
);
};
export default ToggleImageViewButton;
And this is how I am testing it:
function renderToggleImagesViewButton(
props: Partial<ToggleImageViewButtonProps> = {}
) {
const defaultProps: ToggleImageViewButtonProps = {
toggleIsViewFavoriteImages: () => {
return;
},
isViewFavoriteImages: true,
};
return render(<ToggleImageViewButton {...defaultProps} {...props} />);
}
describe("<ToggleImagesViewButton />", () => {
test("button inner text should change to 'view all' when the user clicks the button", async () => {
const onToggle = jest.fn();
const { findByTestId } = renderToggleImagesViewButton({
toggleIsViewFavoriteImages: onToggle,
});
const toggleImagesViewButton = await findByTestId("toggle-image-view");
fireEvent.click(toggleImagesViewButton);
expect(toggleImagesViewButton).toHaveTextContent("view favorites");
});
});
This test fails and "view all" is still getting returned.
ToggleImageViewButton doesn't have internal state - the state was lifted to the parent, so testing state changes should happen in the parent's tests.
You could have the following integration test to verify the correct behaviour of the button when used in App.
test("App test", () => {
render(<App />);
const button = screen.getByTestId("toggle-image-view");
expect(button).toHaveTextContent("view favorites");
fireEvent.click(button);
expect(button).toHaveTextContent("view all");
});
As for the ToggleImageViewButton unit tests, you can simply test that it renders the right text based on isViewFavoriteImages value, and that the callback gets called when the button is clicked.
test("ToggleImageViewButton test", () => {
const onToggle = jest.fn();
render(<ToggleImageViewButton isViewFavoriteImages={false} toggleIsViewFavoriteImages={onToggle}/>);
expect(screen.getByTestId("toggle-image-view")).toHaveTextContent("view favorites");
fireEvent.click(screen.getByTestId("toggle-image-view"));
expect(onToggle).toHaveBeenCalled();
});

React Functional Component - Binding handler to an array

I am trying to bind a handler to an array position that prints the state of the object. The function is being called but the state is always in the initial state.
import React from "react";
export default function MyForm() {
const [state, setState] = React.useState({
buttons: [],
object: null
});
const stateRef = React.useRef(null);
const onExecButtonHandler = (index) => {
console.log("[MyForm][execButton]: " + index);
alert(JSON.stringify(state.object));
alert(JSON.stringify(stateRef.current.object));
};
const onChangeHandler = (event)=>{
let obj = state.object
obj['description'] = event.target.value
console.log(obj)
setState((state) => ({ ...state, object: obj }));
stateRef.current = state;
}
const generateButtons = () => {
let buttons = [];
//buttons.push([name,(event)=>onExecButtonHandler(event,i),obj[i].icon,obj[i].jsFunction])
buttons.push([
"Show Hi",
onExecButtonHandler.bind(this, 1),
"icon",
"custom function"
]);
return buttons;
};
React.useEffect(() => {
console.log("[MyForm][useEffect]");
let buttons = generateButtons();
setState({ buttons: buttons, object: { description: "hi" } });
stateRef.current = state;
}, []);
return (
<>
{state.object && (
<form>
<input text="text" onChange={onChangeHandler} value={state.object.description} />
<input
type="button"
value="Click me"
onClick={() => {
state.buttons[0][1]();
}}
/>
</form>
)}
</>
);
}
You can test here: https://codesandbox.io/s/charming-robinson-g1yuo?fontsize=14&hidenavigation=1&theme=dark
I was able to access the latest state of the object using the "useRef" hook. I'd like to access using the state though. Is there some way to do so?

unable to set react-select-async-paginate initial value

I am using a react-select-async-paginate. It's working on form submit. I am facing problem in edit form. When I click on edit button, I am able to show all the values in their tags except the select. I have made a few attempts to show the selected value but no luck.
export function UserDropdown(props) {
const [value, setValue] = useState(null);
const defaultAdditional = {
page: 1
};
const onChange = (e) => {
props.getUserEmail(e)
setValue(e)
}
<AsyncPaginate
additional={defaultAdditional}
defaultValue={props.value}
loadOptions={loadPageOptions}
onChange={ (e) => onChange(e)}
/>
}
import UserDropdown from './userDropdown'
export class User extends Component {
constructor(props) {
super(props);
this.state = {
userEmail: ''
}
}
onUserChange(e) {
this.setState({userEmail: e.value})
}
<UserDropdown getUserEmail = { (email) => this.onUserChange(email)} value={this.state.userEmail} />
}
in loadPageOptions I am using the API's response.
If you want to give a controlled select a default value you need to make sure that value initially is the default value. You can only use defaultValue if the select is uncontrolled meaning that it manages state of value internally:
const OPTIONS = [
{
value: 1,
label: "Audi"
},
{
value: 2,
label: "Mercedes"
},
{
value: 3,
label: "BMW"
}
];
const fakeData = async () => {
await new Promise(resolve => setTimeout(resolve, 1000));
return {
results: OPTIONS,
has_more: false
};
};
const loadOptions = async () => {
const response = await fakeData();
return {
options: response.results,
hasMore: response.has_more
};
};
export default function App() {
// this has to be initialized with the default value
const [value, setValue] = useState({ value: 1, label: "Audi" });
return (
<div className="App">
<AsyncPaginate
value={value}
loadOptions={loadOptions}
onChange={setValue}
/>
</div>
);
}

Resources