diff --git a/src/components/AddCustomer/AddCustomerForm.spec.tsx b/src/components/AddCustomer/AddCustomerForm.spec.tsx index cc1a2e1..1ff5414 100644 --- a/src/components/AddCustomer/AddCustomerForm.spec.tsx +++ b/src/components/AddCustomer/AddCustomerForm.spec.tsx @@ -1,12 +1,89 @@ -import * as React from "react"; -import { render } from "../../utils/testUtils"; +import userEvent from "@testing-library/user-event"; +import { render, screen, waitFor } from "../../utils/testUtils"; import { AddCustomerForm } from "./AddCustomerForm"; describe("", () => { + const saveCustomer = jest.fn(); + afterEach(() => { + jest.clearAllMocks(); + }); it("should render a ", () => { - const wrapper = render( {}} />); + const wrapper = render(); expect(wrapper.container).toMatchSnapshot(); }); - //@TODO Add tests for entering data and clicking submit + it("should render 3 input fields", () => { + render(); + expect(screen.getAllByRole("textbox").length).toEqual(3); + }); + + it("should submit a form with input value when submit is clicked", async () => { + render(); + + userEvent.type(screen.getByLabelText(/First Name/), "Tom"); + userEvent.type(screen.getByLabelText(/Last Name/), "Smith"); + userEvent.type(screen.getByLabelText(/Phone Number/), "0400 111 222"); + userEvent.click(screen.getByRole("button")); + await waitFor(() => { + expect(saveCustomer).toHaveBeenCalledWith({ + firstName: "Tom", + lastName: "Smith", + phoneNumber: "0400 111 222", + }); + }); + }); + + describe("validation", () => { + it("should display 3 error messages when submitting a blank form", async () => { + render(); + userEvent.click(screen.getByRole("button")); + await waitFor(() => { + expect(screen.getAllByRole("alert").length).toEqual(3); + }); + }); + + it("should display an error message when first name field is touched and left empty", async () => { + render(); + userEvent.click(screen.getByLabelText(/First Name/)); + userEvent.click(screen.getByLabelText(/Last Name/)); + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + "First name is required" + ); + }); + }); + + it("should display an error message when last name field is touched and left empty", async () => { + render(); + userEvent.click(screen.getByLabelText(/Last Name/)); + userEvent.click(screen.getByLabelText(/Phone Number/)); + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + "Last name is required" + ); + }); + }); + + it("should display an error message when phone number field is touched and left empty", async () => { + render(); + userEvent.click(screen.getByLabelText(/Phone Number/)); + userEvent.click(screen.getByLabelText(/First Name/)); + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + "Phone number is required" + ); + }); + }); + + it("should display an error message when inputted an invalid phone number", async () => { + render(); + userEvent.type(screen.getByLabelText(/Phone Number/), "000"); + userEvent.click(screen.getByLabelText(/First Name/)); + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + "Phone number is not valid" + ); + }); + }); + }); }); diff --git a/src/components/AddCustomer/AddCustomerForm.tsx b/src/components/AddCustomer/AddCustomerForm.tsx index 52ebf9e..5e1b310 100644 --- a/src/components/AddCustomer/AddCustomerForm.tsx +++ b/src/components/AddCustomer/AddCustomerForm.tsx @@ -6,13 +6,18 @@ import { StyledInput, StyledLabel, StyledAddButton, + StyledErrorMessage, } from "./StyledAddCustomerForm"; +import { validateEmptyField, validatePattern } from "../../utils/validator"; type Props = { saveCustomer: (customer: ICustomer | any) => void; }; export const AddCustomerForm: React.FC = ({ saveCustomer }) => { + const PHONE_NUMBER_REGEX = + /^(?:\+?(61))? ?(?:\((?=.*\)))?(0?[2-57-8])\)? ?(\d\d(?:[- ](?=\d{3})|(?!\d\d[- ]?\d[- ]))\d\d[- ]?\d[- ]?\d{3})$/; + return ( = ({ saveCustomer }) => { setSubmitting(false); }} > - - First Name - + {({ errors, touched }) => ( + + First Name + + validateEmptyField(value, "First name is required") + } + /> + {errors.firstName && touched.firstName && ( + + {errors.firstName} + + )} - Last Name - + Last Name + + validateEmptyField(value, "Last name is required") + } + /> + {errors.lastName && touched.lastName && ( + + {errors.lastName} + + )} - Phone Number - + Phone Number + + validateEmptyField(value, "Phone number is required") || + validatePattern( + value, + PHONE_NUMBER_REGEX, + "Phone number is not valid" + ) + } + /> + {errors.phoneNumber && touched.phoneNumber && ( + + {errors.phoneNumber} + + )} - Add Customer - + Add Customer + + )} ); }; diff --git a/src/components/AddCustomer/StyledAddCustomerForm.ts b/src/components/AddCustomer/StyledAddCustomerForm.ts index a7a4bb9..ed7a3ac 100644 --- a/src/components/AddCustomer/StyledAddCustomerForm.ts +++ b/src/components/AddCustomer/StyledAddCustomerForm.ts @@ -29,4 +29,9 @@ export const StyledAddButton = styled.button` border: none; border-radius: 4px; font-size: 16px; + cursor: pointer; +`; + +export const StyledErrorMessage = styled.span` + color: red; `; diff --git a/src/components/AddCustomer/__snapshots__/AddCustomerForm.spec.tsx.snap b/src/components/AddCustomer/__snapshots__/AddCustomerForm.spec.tsx.snap index 63bdf1a..94a5119 100644 --- a/src/components/AddCustomer/__snapshots__/AddCustomerForm.spec.tsx.snap +++ b/src/components/AddCustomer/__snapshots__/AddCustomerForm.spec.tsx.snap @@ -42,12 +42,12 @@ exports[` should render a 1`] = ` class="sc-dlnjwi fstXny" id="phoneNumber" name="phoneNumber" - placeholder="john@acme.com" + placeholder="0411 222 333" type="tel" value="" /> diff --git a/src/redux/reducers/customerReducers.spec.ts b/src/redux/reducers/customerReducers.spec.ts index 3b03881..bf37289 100644 --- a/src/redux/reducers/customerReducers.spec.ts +++ b/src/redux/reducers/customerReducers.spec.ts @@ -5,19 +5,19 @@ const initialState = { customers: [ { firstName: "Charles", - id: 1, + id: "1", lastName: "Babbage", phoneNumber: "0412 123 123", }, { firstName: "Alan", - id: 2, + id: "2", lastName: "Turing", phoneNumber: "(03) 9599 1234", }, { firstName: "Ada", - id: 3, + id: "3", lastName: "Lovelace", phoneNumber: "+61 423 345 567", }, @@ -37,7 +37,7 @@ describe("customer reducer", () => { type: ADD_CUSTOMER, customer: { firstName: "Test", - id: 1, + id: "1", lastName: "Dummy", phoneNumber: "000 000 000", }, @@ -46,7 +46,7 @@ describe("customer reducer", () => { customers: [ { firstName: "Test", - id: 1, + id: "1", lastName: "Dummy", phoneNumber: "000 000 000", }, @@ -60,7 +60,7 @@ describe("customer reducer", () => { type: REMOVE_CUSTOMER, customer: { firstName: "Charles", - id: 1, + id: "1", lastName: "Babbage", phoneNumber: "0412 123 123", }, @@ -69,13 +69,13 @@ describe("customer reducer", () => { customers: [ { firstName: "Alan", - id: 2, + id: "2", lastName: "Turing", phoneNumber: "(03) 9599 1234", }, { firstName: "Ada", - id: 3, + id: "3", lastName: "Lovelace", phoneNumber: "+61 423 345 567", }, diff --git a/src/redux/reducers/customerReducers.tsx b/src/redux/reducers/customerReducers.tsx index d6d837e..b66c7c9 100644 --- a/src/redux/reducers/customerReducers.tsx +++ b/src/redux/reducers/customerReducers.tsx @@ -1,22 +1,23 @@ import { CustomerAction, CustomerState, ICustomer } from "../../types/types"; import { ADD_CUSTOMER, REMOVE_CUSTOMER } from "../actions/customerTypes"; +import { v4 as uuidv4 } from "uuid"; export const initialState: CustomerState = { customers: [ { - id: 1, + id: "1", firstName: "Charles", lastName: "Babbage", phoneNumber: "0412 123 123", }, { - id: 2, + id: "2", firstName: "Alan", lastName: "Turing", phoneNumber: "(03) 9599 1234", }, { - id: 3, + id: "3", firstName: "Ada", lastName: "Lovelace", phoneNumber: "+61 423 345 567", @@ -31,14 +32,14 @@ export const customerReducer = ( switch (action.type) { case ADD_CUSTOMER: const newCustomer: ICustomer = { - id: action.customer.id ?? Math.random(), // not really unique but it's just an example + id: action.customer.id ?? uuidv4(), firstName: action.customer.firstName, lastName: action.customer.lastName, phoneNumber: action.customer.phoneNumber, }; return { ...state, - customers: state.customers.concat(newCustomer), + customers: [...state.customers, newCustomer], }; case REMOVE_CUSTOMER: const updatedCustomers: ICustomer[] = state.customers.filter( diff --git a/src/types/types.ts b/src/types/types.ts index 83e2415..1b781b4 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -5,7 +5,7 @@ export interface Customer { } export interface ICustomer extends Customer { - id: number; + id: string; } export type CustomerState = { diff --git a/src/utils/validator.ts b/src/utils/validator.ts new file mode 100644 index 0000000..a2ca360 --- /dev/null +++ b/src/utils/validator.ts @@ -0,0 +1,7 @@ +export const validateEmptyField = (value:string, errorMessage: string) => { + return !value ? errorMessage : undefined + } + +export const validatePattern = (value: string, pattern: RegExp, errorMessage: string) => { + return !pattern.test(value) ? errorMessage : undefined + } \ No newline at end of file diff --git a/src/views/Home.spec.tsx b/src/views/Home.spec.tsx new file mode 100644 index 0000000..5a69d1f --- /dev/null +++ b/src/views/Home.spec.tsx @@ -0,0 +1,16 @@ +import userEvent from "@testing-library/user-event"; +import { render, screen } from "../utils/testUtils"; +import Home from "./Home"; + +describe("", () => { + it("should render three customer's details as default", () => { + render(); + expect(screen.getAllByRole("heading").length).toEqual(3); + }); + + it("should show customer Alan Turning only if Alan is inputted in search box", () => { + render(); + userEvent.type(screen.getByRole("searchbox"), "alan"); + expect(screen.getByRole("heading")).toHaveTextContent("Alan Turing"); + }); +}); diff --git a/src/views/Home.tsx b/src/views/Home.tsx index 8911b39..9add968 100644 --- a/src/views/Home.tsx +++ b/src/views/Home.tsx @@ -1,32 +1,63 @@ -import * as React from "react"; +import { useState, useCallback, useEffect, useMemo } from "react"; import { useSelector, shallowEqual, useDispatch } from "react-redux"; import { Customer } from "../components/Customer/Customer"; import { AddCustomerForm } from "../components/AddCustomer/AddCustomerForm"; import { Dispatch } from "redux"; import { CustomerState, ICustomer } from "../types/types"; import { addCustomer, removeCustomer } from "../redux/actions/customerActions"; +import { StyledInput } from "./StyledHome"; const Home: React.FC = () => { + const [input, setInput] = useState(""); + const [customersForDisplay, setCustomersForDisplay] = useState( + [] + ); const customers: readonly ICustomer[] = useSelector( (state: CustomerState) => state.customers, shallowEqual ); + const memoedCustomers = useMemo(() => customers, [customers]); const dispatch: Dispatch = useDispatch(); - const saveCustomer = React.useCallback( + const saveCustomer = useCallback( (customer: ICustomer) => dispatch(addCustomer(customer)), [dispatch] ); + const deleteCustomer = useCallback( + (customer: ICustomer) => dispatch(removeCustomer(customer)), + [dispatch] + ); + + const filterCustomers = (customers: readonly ICustomer[], input: string) => + customers.filter( + (customer) => + customer.firstName.toLowerCase().includes(input.toLowerCase()) || + customer.lastName.toLowerCase().includes(input.toLowerCase()) || + customer.phoneNumber.toLowerCase().includes(input.toLowerCase()) + ); + + useEffect(() => { + setCustomersForDisplay(filterCustomers(memoedCustomers, input)); + }, [memoedCustomers, input]); + return ( <> - {customers.map((customer: ICustomer) => ( + { + setInput(e.target.value); + }} + /> + {customersForDisplay.map((customer: ICustomer) => ( ))} diff --git a/src/views/StyledHome.ts b/src/views/StyledHome.ts new file mode 100644 index 0000000..4c14461 --- /dev/null +++ b/src/views/StyledHome.ts @@ -0,0 +1,10 @@ +import styled from "styled-components"; + +export const StyledInput = styled.input` + margin: 1rem 0 1rem; + padding: 0.5rem; + font-size: 16px; + align-self: flex-start; + width: 20rem; + text-align: center; +`;