How to unit test a UI change caused by a user interaction - reactjs

I'm using vitest and react testing library and the situation is the following:
I'm trying to test whether the UI updates after a user interacts with an input checkbox.
Then the question is: how to do it to be sure that when a user clicks the input, the parent component gets a blue border (I'm using tailwind).
The component that I'm testing:
export const AddOn: FC<props> = ({
title,
desc,
price,
type,
handleAdd,
handleRemove,
checked,
}) => {
const [isChecked, toggleCheck] = useState(checked);
useEffect(() => {
isChecked
? handleAdd(title.toLowerCase(), price)
: handleRemove(title.toLowerCase(), price);
}, [isChecked]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div
className={
"relative w-full border border-n-light-gray rounded-md p-3 lg:px-6 lg:py-3 flex gap-4 items-center hover:opacity-70 " +
(**checked ? " border-p-purplish-blue bg-n-alabaster"** : "")
}
*data-testid={`addon-${title}-container`}*
>
<div className="flex gap-4">
<input
autoComplete="off"
className="w-6 h-6 self-center cursor-pointer"
*data-testid={`addon-${title}`}*
defaultChecked={checked}
type="checkbox"
*onClick={() => toggleCheck(!checked)}*
onKeyPress={(e) => {
e.preventDefault();
toggleCheck(!checked);
}}
/>
<div className="w-8/12 sm:w-full text-left">
<h3 className="text-base text-p-marine-blue font-bold">{title}</h3>
<p className="text-n-cool-gray justify-self-start">{desc}</p>
</div>
</div>
<p className="text-p-purplish-blue text-base font-medium absolute right-2 lg:right-6">
{type === "monthly" ? `+$${price}/mo` : `+$${price}/yr`}
</p>
</div>
);
};
And the test I wrote is:
test("UI TEST: should show the container with a blue border after user clicks on", async () => {
render(
<AddOn
checked={false}
desc="test"
handleAdd={() => {}}
handleRemove={() => {}}
price={0}
title="title-test"
type="test"
/>
);
const addOnOnlineService: HTMLInputElement = await screen.findByTestId(
"addon-title-test"
);
await userEvent.click(addOnOnlineService);
const testContainer: HTMLDivElement = await screen.findByTestId(
"addon-title-test-container"
);
await waitFor(() => {
expect(Array.from(testContainer.classList)).toContain(
"border-p-purplish-blue"
);
});
});
I tried running my test but I couldn't see the HTML updated in the test output. I got the same without the class "border-p-purplish-blue bg-n-alabaster" added because of the state change.

My example could help but I did not using your test way.
1.import act
import { act } from "react-dom/test-utils";
2.using dispatch event then check when text changes, testing class name
it("password length", () => {
act(() => {
render(<ChangePasswordState />, container);
});
expect(state).toBeNull();
let buttonChange = document.getElementsByClassName("blue")[2];
let txtPassword = document.getElementsByTagName("input")[0];
let txtConfirmPassword = document.getElementsByTagName("input")[1];
act(() => {
txtPassword.setAttribute("value", "1234567");
txtPassword.dispatchEvent(new CustomEvent("change", { bubbles: true }))
txtConfirmPassword.setAttribute("value", "1234567");
txtConfirmPassword.dispatchEvent(new CustomEvent("change", { bubbles: true }))
});
expect(buttonChange.className).toContain("disabled");
});

Related

Why do I get InternalError: too much recursion with my NextJS Component?

I have a NextJS App that uses Firebase Realtime Database to fetch data. However, I get an InternalError: too much recursion everytime I use the production version of the page. I get no error on the local development server.
Code -
index.js
import { ref, getDatabase, get, child, set } from "firebase/database";
import { useState, useEffect } from "react";
import { app } from "../lib/firebase";
import Subject from "../components/Subject";
export default function Student() {
const dbref = ref(getDatabase(app));
const timetable = {
Monday: ["DAA"],
Tuesday: ["COMP", "ALC"],
Wednesday: ["BEE", "OS"],
Thursday: ["OE", "IT", "DM"],
Friday: ["DBMS", "S&S"],
};
const days = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
const [data, setData] = useState({});
const [date, setDate] = useState(
new Date().toLocaleDateString("en-GB").replaceAll("/", "-")
);
const [day, setDay] = useState(days[new Date().getDay()]);
useEffect(() => {
console.log("Data Fetched!");
get(child(dbref, "data/"))
.then((snapshot) => snapshot.val())
.then((d) => {
if (d) setData(d);
})
.catch((e) => console.error(e));
}, []);
useEffect(() => {
console.log("Data Updated!");
set(child(dbref, "data/"), data).catch((e) => console.error(e));
}, [data]);
return (
<main className="">
<h1 className="text-4xl">Student View : </h1>
<p>Selected Date : {date}</p>
<p>Day : {day}</p>
<p>Selected Day&apos;s subjects : {timetable[day].join(", ")}</p>
<h3>Set Date :</h3>
<input
type={"date"}
id="date"
name="date"
onChange={(e) => {
if (e.target.valueAsDate !== null) {
setDate(
e.target.valueAsDate
.toLocaleDateString("en-GB")
.replaceAll("/", "-")
);
setDay(days[e.target.valueAsDate.getDay()]);
}
}}
/>
{timetable[day].map((e) => (
<Subject key={e} name={e} data={data} setData={setData} date={date} />
))}
</main>
);
}
Subject.jsx
export default function name({ name, data, setData, date }) {
function todayTaken() {
setData((old) => ({
...old,
[date]: { ...old[date], [name]: 1 },
}));
}
function notTaken() {
setData((old) => ({
...old,
[date]: { ...old[date], [name]: 0 },
}));
}
function Cancelled() {
setData((old) => ({
...old,
[date]: { ...old[date], [name]: -1 },
}));
}
return (
<div className="border-2 p-4">
<h2>Subject : {name}</h2>
<div className="flex flex-row gap-4">
<button
className="bg-blue-500 py-2.5 px-4 text-white rounded-md"
onClick={todayTaken}
>
Class Taken Today
</button>
<button
className="bg-red-500 py-2.5 px-4 text-white rounded-md"
onClick={notTaken}
>
Not Taken Today
</button>
<button
className="bg-indigo-500 py-2.5 px-4 text-white rounded-md"
onClick={Cancelled}
>
Cancelled
</button>
<h2>
Today&apos;s status :{" "}
{data[date]===undefined || data[date][name] === undefined
? "Not set"
: data[date][name] === 1
? "Class Taken"
: data[date][name] === 0
? "Class Not Taken"
: data[date][name] === -1
? "Class Cancelled"
: ""}
</h2>
</div>
</div>
);
}
I'm not sure as to what is going wrong, can someone show me what I'm doing incorrectly?
The first useEffect fetches data from Firebase when component mounts, and then when the data is changed then the data is set on Firebase which means that it does not trigger a rerender. I'm not sure what is going wrong here. The console doesn't provide any useful information.
You're creating an infinite loop with these two effects:
useEffect(() => {
console.log("Data Fetched!");
get(child(dbref, "data/"))
.then((snapshot) => snapshot.val())
.then((d) => {
if (d) setData(d);
})
.catch((e) => console.error(e));
}, []);
useEffect(() => {
console.log("Data Updated!");
set(child(dbref, "data/"), data).catch((e) => console.error(e));
}, [data]);
The first effect reads /data from the database and sets it to the state by calling setData.
The second effect responds to whenever the data stats is set by writing that value to the database.
This in turn triggers the first effect again, which once again calls setData.
Which then again triggers the second effect.
This repeats endlessly.
To prevent an endless loop, compare the old and new values in either of the effects and skip further processing if they are the same.

Initial state of component is always using the previous updated state in the previous component

To be more specific, when displaying a list of the same components containing textarea, how to set the initial state of the last component textarea to a specific value?
For example, I have a simple Note app.
It basically just adds, updates and deletes notes.
To make it simple I just use one Note component:
import { useState } from "react";
import { v4 as uuid } from "uuid";
import { MdDeleteForever } from "react-icons/md";
export const Note = ({ info, data, setData, isNewNote }) => {
const [text, setText] = useState(info.text);
console.log("text = ", text, "id = ", info.id);
const onChange = (event) => {
event.preventDefault();
const value = event.currentTarget.value;
setText(value);
};
const onSave = () => {
const newNote = {
id: uuid(),
text: text
};
setData((prev) => [...prev, newNote]);
// Need to reset the text for the unsaved new note
setText("Try to add a new note.");
};
const onDelete = (event) => {
const id = event.currentTarget.getAttribute("data-value");
setData((prev) => data.filter((item) => item.id !== id));
};
return (
<div
className={`m-3 w-56 h-36 ${isNewNote ? "bg-teal-200" : "bg-yellow-300"}`}
>
<textarea
className={`overflow-hidden p-2 bg-transparent focus:outline-0 resize-none border-transparent ${
isNewNote ? "bg-transparent" : "bg-yellow-300"
}`}
name=""
id=""
cols="23"
rows="3"
value={text}
onChange={onChange}
></textarea>
<div className="flex items-center justify-end m-3">
{isNewNote ? (
<button
className="w-20 h-7 drop-shadow-md bg-blue-500 text-slate-100 rounded-md hover:bg-blue-700 transition duration-300"
onClick={onSave}
>
Save
</button>
) : (
<MdDeleteForever
className="text-2xl drop-shadow-md hover:cursor-pointer hover:scale-125 transition duration-500"
onClick={onDelete}
data-value={info.id}
/>
)}
</div>
</div>
);
};
App.js:
import { useState } from "react";
import { Note } from "./components";
import { v4 as uuid } from "uuid";
export default function App() {
const [data, setData] = useState([
{
id: uuid(),
text: "This is my first note."
},
{
id: uuid(),
text: "This is my second note."
},
{
id: uuid(),
text: "This is my third note."
}
]);
return (
<div className="mt-3 flex flex-col items-center justify-center">
<div className="font-bold text-3xl">Note app</div>
<div className="flex items-center justify-center flex-wrap">
{data &&
data.map((note, index) => {
return (
<div key={index}>
<Note info={note} data={data} setData={setData} />
</div>
);
})}
<div key="x1">
<Note
info={{ text: "Try to add a new note." }}
data={data}
setData={setData}
isNewNote={true}
/>
</div>
</div>
</div>
);
}
I pass some "placeholder" text as props to the Note component hoping the component will show the text when the note isn't saved to the list.
In the textarea, the text is decided by the onChange method but first-time display I sent the props to the useState() at the beginning of the component so the text would be what is stored in the data array or whatever "placeholder" text I put in the props.
Every time it displays the last Note component, it is reusing the text from the previous saved Note component, UNLESS I specifically add a setText() at the end of the onSave function to reset the text state to the "placeholder" value.
Why doesn't the last Note component update the text state with the incoming props? I have tried numerous ways to update the text with the "placeholder" text thinking that I could update the text when the last Note component is created but I always failed.
Am I missing the big picture here? How does a component keep its state when we render multiple components with the same code?
Example is here:
https://codesandbox.io/s/note-app-kpxolc?file=/src/components/Note.js
You can pass a different placeholder prop to Note component as
CODESANDBOX DEMO
App.js
<Note
// YOU ARE NOT PASSING info HERE
data={data}
setData={setData}
isNewNote={true}
placeholder='Try to add a new note.' // CHANGE
/>;
This placeholder will be used only in Note where user can save new note or the last one.
Don't confuse placeholder with value. Both are different and has different purpose.
Note.js
You can set state from info object as:
const [text, setText] = useState(info?.text ?? ''); // If info.text is there then take this else take empty string
while onSave function you can reset its state to empty string so that placeholder will show up
const onSave = () => {
const newNote = {
id: uuid(),
text: text,
};
setData((prev) => [...prev, newNote]);
// Need to reset the text for the unsaved new note
setText(''); // CHANGE
};
In JSX
<textarea
className={`overflow-hidden p-2 bg-transparent focus:outline-0 resize-none border-transparent ${
isNewNote ? 'bg-transparent' : 'bg-yellow-300'
}`}
placeholder={placeholder}
name=''
id=''
cols='23'
rows='3'
value={text}
onChange={onChange}
></textarea>;

How to reject oversize file with Dropzone component?

I'm using Dropzone component of React-Dropzone with Nextjs and TypeScript.
I want to reject the upload if the file size is over 30MB (30000000 Bytes). The reject message could be whatever at this point.
Currently, when dropping a big file into the zone - this error appears:
I saw that there is a property called onDropRejected to use with Dropzone component in this documentation but how can we use this one instead of running into the error like above?
Here's how my UI looks like:
My code:
type Props = {
name?: string;
isError?: boolean;
onChange?: (id: string) => void;
};
export const FileUploader = ({ name, isError, onChange }: Props) => {
const [uploading, setUploading] = useState(false);
const [nameFile, setNameFile] = useState<string>('File format: CSV Maximum upload size: 30MB');
const [currId, setCurrId] = useState('');
useEffect(() => {
name && setNameFile(name);
}, [name]);
const handleDrop = async (acceptedFiles: File[]) => {
setUploading(true);
const file = acceptedFiles[0];
const res = await AssetApis.create(file, AssetResourceType.marketingAction);
if (res.id) {
setNameFile(file.name);
onChange?.(res.id);
currId && (await AssetApis.remove(currId));
setCurrId(res.id);
}
setUploading(false);
};
return (
<div>
<Dropzone
onDrop={handleDrop}
multiple={false}
accept={['image/*', 'text/csv']}
disabled={uploading}
maxSize={30000000}>
{({ getRootProps, getInputProps }) => (
<div
{...getRootProps({ className: 'dropzone' })}
className='flex items-center w-full h-40 text-center'>
<input {...getInputProps()} />
<div
className={classNames(
'rounded flex h-full items-center justify-center flex-col flex-1 border border-dashed text-gray-700 mr-2.5 py-6',
isError ? 'border-danger' : 'border-gray'
)}>
<Icon name='upload' size={20} />
<div className='mb-2.5 text-medium'>Drag and drop to upload</div>
<button
type='button'
className='px-2.5 border-gray-700 border rounded-full text-small px-3 py-0.5'>
Select file
</button>
<div className='mt-2 text-gray-500 text-small'>{nameFile}</div>
</div>
</div>
)}
</Dropzone>
</div>
);
};
you can add a function validator :
const fileValidator = (file) => {
if (file.size > 30000000) {
return {
code: "size-too-large",
message: `file is larger than 30MB`,
};
}
return null;
}
And then, add your function in your useDropZone :
const { getRootProps, getInputProps, isDragAccept, isDragReject } =
useDropzone({
onDrop,
validator: fileValidator,
});

How to update the state of with multiple elements by their key in React?

I am trying to submit a form that has 4 form fields.
export type ChapterData = {
chapterNumber: number;
volumeNumber?: number;
chapterName?: string;
chapterImages: ImageListType;
};
I have made a custom hook, useAddChapterForm.
The initial state is as:
const initialState: ChapterData = {
chapterNumber: 0,
volumeNumber: 0,
chapterName: '',
chapterImages: [],
};
I want to update the state of all these elements in a single state and have used this approach.
const [chapterData, setChapterData] = useState<ChapterData>(initialState);
const changeHandler = (e: ChangeEvent<HTMLInputElement>) => {
setChapterData({ ...chapterData, [e.target.name]: e.target.value });
};
But I want to handle the change of the chapterImages differently.
So, my hook looks like so.
export const useAddChapterForm = () => {
const [chapterData, setChapterData] = useState<ChapterData>(initialState);
const [images, setImages] = useState([]);
const maxNumber = 69;
const onChangeImageHandler = (imageList: ImageListType, addUpdateIndex: number[] | undefined) => {
console.log(imageList, addUpdateIndex);
setImages(imageList as never[]);
setChapterData({ ...chapterData, [chapterData.chapterImages]: imageList });
};
const changeHandler = (e: ChangeEvent<HTMLInputElement>) => {
setChapterData({ ...chapterData, [e.target.name]: e.target.value });
};
return {
chapterData,
maxNumber,
changeHandler,
images,
onChangeImageHandler,
};
};
I want to set the value of chapterImages when there is a change in with onChangeImageHandler so I am able to remove the images too.
setChapterData({ ...chapterData, [chapterData.chapterImages]: imageList });
There is a typescript error. So, how do I change the state of this key chapterData.chapterImageswith setState?
Updated the question as per suggested answer:
const onChangeImageHandler = (imageList: ImageListType, addUpdateIndex: number[] | undefined) => {
console.log(imageList, addUpdateIndex);
setImages(imageList as never[]);
setChapterData({ ...chapterData, chapterImages: imageList });
};
const changeHandler = (e: ChangeEvent<HTMLInputElement>) => {
setChapterData({ ...chapterData, [e.target.name]: e.target.value });
};
Here is handle submit:
const { chapterData, changeHandler } = useAddChapterForm();
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
console.log(chapterData);
};
return (
<form onSubmit={handleSubmit}>
...
I am using this component to upload the image.
export const ImageUploadField: FunctionComponent<ImageUploadFieldProps> = (
props: ImageUploadFieldProps
) => {
const { error } = props;
const { images, onChangeImageHandler, maxNumber } = useAddChapterForm();
return (
<FieldWrapper label={'Select cover image'} errorMessage={error}>
<ImageUploading multiple value={images} onChange={onChangeImageHandler} maxNumber={maxNumber}>
{({
imageList,
onImageUpload,
onImageRemoveAll,
onImageUpdate,
onImageRemove,
isDragging,
dragProps,
}) => (
<div className="outline-dashed outline-2 outline-offset-2">
{imageList.length === 0 && (
<button
className="w-full text-center"
style={isDragging ? { color: 'red' } : undefined}
onClick={onImageUpload}
{...dragProps}
>
Click or Drop here
</button>
)}
<div className="flex flex-col justify-center">
{imageList.length > 0 && (
<div className="px-2 py-1 rounded bg-red-500 flex">
<button className=" space-x-2 w-full" onClick={onImageRemoveAll}>
Remove all images
</button>
<XCircle />
</div>
)}
<div className="flex flex-wrap">
{imageList.map((image, index) => (
<div key={index} className="flex">
<div className="flex flex-col p-2">
<Image
src={image.dataURL as string}
alt={image.file?.name}
width={150}
height={200}
quality={65}
className="rounded-tl rounded-bl"
/>
<p>{image.file?.name}</p>
<div className="flex space-x-4">
<div className="flex px-2 py-1 rounded bg-green-500 space-x-2">
<button onClick={() => onImageUpdate(index)}>Update</button>
<PencilEdit />
</div>
<div className="flex px-2 py-1 rounded bg-red-500 space-x-2">
<button onClick={() => onImageRemove(index)}>Remove</button>
<XCircle />
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
)}
</ImageUploading>
</FieldWrapper>
);
};
You dont have to use computed property name for this line
setChapterData({ ...chapterData, [chapterData.chapterImages]: imageList });
You can just use this setter function like this
setChapterData({ ...chapterData, chapterImages: imageList });

Reactjs app doesn't show image preview in safari

I am trying to make an app where a user can upload an image and send it off to an email, it's working fine on all browsers except Safari. For both mobile and web browsers, when I choose an image to upload nothing seems to be previewed nor is it even loaded (ready to be sent). Is there anything I can do to fix this? My code as it stands is really simple:
const EnterDetailPage = props => {
const [imageUrl, setImageUrl] = useState("");
const [imageFile, setImageFile] = useState();
const [upload, setUpload] = useState(null);
const handleUploadChange = async e => {
setLoading(true);
const file = e.target.files[0];
if (!file) {
return;
}
setUpload(URL.createObjectURL(file));
setImageFile(file);
const ref = firebase
.storage()
.ref()
.child(uuid.v4());
const snapshot = await ref.put(file);
let getImageUrl = await snapshot.ref.getDownloadURL();
setImageUrl(getImageUrl);
setLoading(false);
console.log(getImageUrl);
};
let imgPreview = null;
if (upload) {
imgPreview = (
<Avatar
variant="square"
src={upload}
alt="Avatar"
className={classes.bigAvatar}
/>
);
}
return(
<div className="m-auto p-16 sm:px-24 sm:mx-auto max-w-xl">
<input
accept="image/jpeg,image/gif,image/png"
className="hidden"
id="button-file"
type="file"
// onChange={handleUploadChange}
onInput={handleUploadChange}
onClick={event => {
event.target.value = null;
}}
/>
<label
htmlFor="button-file"
className={`${classes.bigAvatar} mt-8 bg-gray-300 m-auto flex items-center justify-center relative w-128 h-128 rounded-4 a-mr-16 a-mb-16 overflow-hidden cursor-pointer shadow-1 hover:shadow-xl`}
>
<div className="absolute flex items-center justify-center w-full h-full z-50">
{imageUrl ? null :
<Icon fontSize="large" color="primary" className="cloud-icon">
cloud_upload
</Icon>}
</div>
{imgPreview}
</label>
);
}:
I compared my code to this article here: https://w3path.com/react-image-upload-or-file-upload-with-preview/
and it seems like I've done exactly the same thing...how come I'm not getting the same results?
There's quite a bit going on your codesandbox example, but by stripping it down its bare bones, I was able to track down the issue...
Safari doesn't seem to support input elements that try to use the onInput event listener -- the callback is never executed. Instead, you can use the onChange event listener.
For the example below, I faked an API call by setting a Promise with a timeout, but this not needed and is only for demonstration purposes. In addition, I like using objects over multiple individual states, especially when the state needs to be synchronous -- it also is cleaner, easier to read, and functions more like a class based component.
Demo: https://jd13t.csb.app/
Source:
components/DetailPage.js
import React, { useRef, useState } from "react";
import { CircularProgress, Icon, Fab } from "#material-ui/core";
const initialState = {
isLoading: false,
imageName: "",
imagePreview: null,
imageSize: 0
};
const EnterDetailPage = () => {
const [state, setState] = useState(initialState);
const uploadInputEl = useRef(null);
const handleUploadChange = async ({ target: { files } }) => {
setState(prevState => ({ ...prevState, isLoading: true }));
const file = files[0];
await new Promise(res => {
setTimeout(() => {
res(
setState(prevState => ({
...prevState,
imageName: file.name,
imagePreview: URL.createObjectURL(file),
imageSize: file.size,
isLoading: false
}))
);
}, 2000);
});
};
const resetUpload = () => {
setState(initialState);
uploadInputEl.current.value = null;
};
const uploadImage = async () => {
if (state.imagePreview)
setState(prevState => ({ ...prevState, isLoading: true }));
await new Promise(res => {
setTimeout(() => {
res(alert(JSON.stringify(state, null, 4)));
resetUpload();
}, 2000);
});
};
const { imagePreview, imageName, imageSize, isLoading } = state;
return (
<div style={{ padding: 20 }}>
<div style={{ textAlign: "center" }}>
<div>
<input
accept="image/jpeg,image/gif,image/png"
className="hidden"
id="button-file"
type="file"
ref={uploadInputEl}
onChange={handleUploadChange}
/>
<label htmlFor="button-file">
<div>
{imagePreview ? (
<>
<img
src={imagePreview}
alt="Avatar"
style={{ margin: "0 auto", maxHeight: 150 }}
/>
<p style={{ margin: "10px 0" }}>
({imageName} - {(imageSize / 1024000).toFixed(2)}MB)
</p>
</>
) : (
<Icon fontSize="large" color="primary" className="cloud-icon">
cloud_upload
</Icon>
)}
</div>
</label>
<Fab
variant="extended"
size="large"
color="primary"
aria-label="add"
className=""
type="button"
onClick={uploadImage}
>
{isLoading ? (
<CircularProgress style={{ color: "white" }} />
) : (
"Submit"
)}
</Fab>
{imagePreview && (
<Fab
variant="extended"
size="large"
color="default"
aria-label="add"
className=""
type="button"
onClick={resetUpload}
>
Cancel
</Fab>
)}
</div>
</div>
</div>
);
};
export default EnterDetailPage;

Resources