teodosii / react-notifications-component

Delightful and highly customisable React Component to notify your users
https://teodosii.github.io/react-notifications-component/
MIT License
1.27k stars 73 forks source link

Store.addNotification is getting called and failing in my tests #168

Closed mdodge-ecgrow closed 1 year ago

mdodge-ecgrow commented 1 year ago

I'm fairly new to testing but I'm trying to add tests to one of my components using React Testing Library. The component has a form with a submit button. After the form successfully submits, it shows a message to the user using this code:

Store.addNotification({
    type: 'success',
    title: 'Bag consumption added!',
    message: 'A new bag consumption has been added.',
    insert: 'top',
    container: 'top-center',
    animationIn: ['animated', 'bounceIn'],
    animationOut: ['animated', 'fadeOut'],
    dismiss: {
        duration: 4000,
        onScreen: true,
    },
});

So in my test when I click the submit button, I have figured out how to mock the axios call. But the addNotification is causing this error: this.add is not a function TypeError: this.add is not a function. I even added this import to the top of the test file: import { Store } from 'react-notifications-component';

teodosii commented 1 year ago

I believe that's happening because the Store is not initialized. In order to be initialized it should be called on register method with the needed parameters. Try to log Store to see if it's undefined or if the fields within class are undefined - I suspect the latter to be your issue.

When ran in browser, it calls register on this line - maybe that part is not called in your tests? Hard to say, v1 had plenty of tests but the breaking changes required to discard the old tests and I havent' written any from v2 onwards.

So the solution is to make sure the Container's componentDidMount runs successfully so that you have your store registered - post a more detailed part of the test so I can make an idea over it.

mdodge-ecgrow commented 1 year ago

Sure, here is my full test with the non-relevant parts removed.

import React from 'react';
import {
    render,
    screen,
    within,
    fireEvent,
    cleanup,
    waitFor,
} from '@testing-library/react';
import '@testing-library/jest-dom';
import '@testing-library/react/dont-cleanup-after-each';
import Modal from 'react-modal';
import userEvent from '@testing-library/user-event';
import { Store } from 'react-notifications-component';
import BagModal from './BagModal';
import * as APIFunctions from '../../utils/APIFunctions';

const mockedEmptyFn = jest.fn();

const mockBaseProps = {
    openBagConsumption: true,
    setOpenBagConsumption: mockedEmptyFn,
    activeOrder: {
        bagID: '265-1514b-19',
        bagsOrdered: 20825,
        bagsPerPallet: 49,
        // ... lots more properties in here that don't matter
    },
    customStyles: {
        content: {
            backgroundColor: 'var(--color-primary)',
            border: '1px solid #ccc',
            boxShadow: '-2rem 2rem 2rem rgba(0, 0, 0, 0.5)',
            color: 'rgba(var(--RGB-text), 0.8)',
            filter: 'blur(0)',
            fontSize: '1.1em',
            fontWeight: 'bold',
            margin: '50px auto',
            opacity: 1,
            outline: 0,
            position: 'relative',
            visibility: 'visible',
            width: '500px',
        },
        overlay: {
            backgroundColor: 'rgba(255, 255, 255, 0.9)',
        },
    },
};

const mockBasePropsHidden = {
    ...mockBaseProps,
    openBagConsumption: false,
};

Modal.setAppElement('body');

const Component = (props) => (
    <BagModal {...mockBaseProps} {...props} />
);

const HiddenModal = (props) => (
    <BagModal {...mockBasePropsHidden} {...props} />
);

describe('Bag Modal tests with editable inputs', () => {
    afterAll(() => {
        cleanup();
    });

    // removed all the tests that are passing, this is the only failing test

    test('Submitting a filed form should not show an error message', async (object, method) => {
        render(<Component />);
        jest
            .spyOn(APIFunctions, 'insertBagHistory')
            .mockResolvedValue('success');
        jest.spyOn(Store, 'addNotification').mockResolvedValue('success');
        const bagBatchInput = await screen.findByLabelText(
            /bag batch number/i
        );
        await userEvent.type(bagBatchInput, 'Test');
        const palletNumberInput = await screen.findByLabelText(
            /carton number/i
        );
        await userEvent.type(palletNumberInput, 'Test');

        const submitButton = await screen.getByText(/submit/i);
        await userEvent.click(submitButton);
        expect(screen.queryByTestId('error-msg')).not.toBeInTheDocument();
    });
});

And then here is the BagModal that I am testing:

import ReactModal from 'react-modal';
import React, { useRef, useState } from 'react';
import PropTypes from 'prop-types';
import CloseButton from './CloseButton';
import { Store } from 'react-notifications-component';
import { Scrollbars } from 'rc-scrollbars';
import { insertBagHistory } from '../../utils/APIFunctions';

const initialState = {
    caseID: '',
    palletID: '',
};

const BagModal = (props) => {
    const {
        openBagConsumption,
        setOpenBagConsumption,
        activeOrder,
        customStyles,
    } = props;

    const [userInput, setUserInput] = useState(initialState);
    const [inputError, setInputError] = useState('');

    const form = useRef();

    const handleTextChange = (evt) => {
        setInputError('');
        const { name, value } = evt.target;
        setUserInput({ ...userInput, [name]: value });
    };

    const validate = () => {
        return form.current.reportValidity();
    };

    const handleSubmit = async (evt) => {
        evt.preventDefault();

        // validate input fields
        if (!validate()) {
            setInputError('Please enter the Case ID and the Pallet ID.');
            form.current.querySelectorAll('input').forEach((element) => {
                if (element.checkValidity()) {
                    if (element.type === 'text') {
                        element.classList.remove('error-input');
                    } else if (element.type === 'radio') {
                        const divEl = element.closest('div');
                        divEl.classList.remove('error-input');
                    }
                } else {
                    if (element.type === 'text') {
                        element.classList.add('error-input');
                    } else if (element.type === 'radio') {
                        const divEl = element.closest('div');
                        divEl.classList.add('error-input');
                    }
                }
            });
        } else {
            // form is validated!
            // save to database
            const bagHistoryData = {
                orderID: activeOrder.orderNumber,
                caseID: userInput.caseID,
                palletID: userInput.palletID,
            };
            await insertBagHistory(bagHistoryData);
            Store.addNotification({
                type: 'success',
                title: 'Bag consumption added!',
                message: 'A new bag consumption has been added.',
                insert: 'top',
                container: 'top-center',
                animationIn: ['animated', 'bounceIn'],
                animationOut: ['animated', 'fadeOut'],
                dismiss: {
                    duration: 4000,
                    onScreen: true,
                },
            });
            setOpenBagConsumption(false);
        }
    };

    const cleanUp = () => {
        setUserInput(initialState);
        setInputError('');
    };

    const handleKeyDown = (e) => {
        // if Enter is pressed and the inputs are filled, save the bag consumption
        if (e.key === 'Enter') {
            handleSubmit(e).then(() => {
                // data submitted
            });
        }
    };

    return (
        <ReactModal
            isOpen={openBagConsumption}
            style={customStyles}
            className={'order-details-modal'}
            closeTimeoutMS={1000}
            onAfterClose={cleanUp}
        >
            <CloseButton setOpenModal={setOpenBagConsumption} />
            <h2 className={'title'}>
                Enter Bag Consumption for Item Number: {activeOrder.title}
            </h2>
            <Scrollbars autoHeight autoHeightMin={270} autoHeightMax={400}>
                <form
                    className={'form modal-form'}
                    ref={form}
                    onSubmit={handleSubmit}
                    noValidate
                >
                    <label htmlFor={'caseID'}>D.O.R / Bag Batch Number:</label>
                    <input
                        type={'text'}
                        className={'form-control mb-4'}
                        id={'caseID'}
                        name={'caseID'}
                        maxLength={50}
                        placeholder={'DOR / Bag Batch Number'}
                        value={userInput.caseID}
                        onChange={handleTextChange}
                        autoFocus
                        required
                    />
                    <label htmlFor={'palletID'}>Pallet / Carton Number:</label>
                    <input
                        type={'text'}
                        className={'form-control mb-3'}
                        id={'palletID'}
                        name={'palletID'}
                        maxLength={50}
                        placeholder={'Pallet / Carton Number'}
                        value={userInput.palletID}
                        onChange={handleTextChange}
                        onKeyDown={handleKeyDown}
                        required
                    />
                    {inputError && (
                        <p className={'text-warning'} data-testid={'error-msg'}>
                            {inputError}
                        </p>
                    )}
                    <br />
                    <input
                        className={'btn btn-primary d-block mx-auto mb-2'}
                        type={'submit'}
                        value={'Submit'}
                    />
                </form>
            </Scrollbars>
        </ReactModal>
    );
};

BagModal.propTypes = {
    openBagConsumption: PropTypes.bool.isRequired,
    setOpenBagConsumption: PropTypes.func.isRequired,
    activeOrder: PropTypes.object.isRequired,
    customStyles: PropTypes.object.isRequired,
};

export default BagModal;

The handleSubmit function is where the notification happens.

glennsayers commented 1 year ago

@mdodge-ecgrow I came across this same issue in my tests. For me, the issue was that I wasn't mounting the <ReactNotifications> component. During typical use this is mounted at the top level of the application, however when testing a component this isn't the case as only the component you're testing is being mounted, in your case BagModal. Easy to overlook.

A quick fix is to place your test component alongside the ReactNotification component:

render(
        <>
            <ReactNotifications />
            <EntryForm />
        </>
    );
teodosii commented 1 year ago

^ indeed that's the problem, as long as the root component (aka the Container) doesn't get mounted, then the store doesn't get initialized. In a next version I think I could change the implementation so that it uses portals and gets rid of that container.

Closing the issue for now as it's not a bug on the component's end.