diff --git a/package.json b/package.json index 185bbd5..27cc6bc 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ ] }, "devDependencies": { - "jest-fetch-mock": "^3.0.3" + "@types/react-css-modules": "^4.6.8", + "jest-fetch-mock": "^3.0.3", + "react-router-dom": "^6.22.3" } } diff --git a/src/App.tsx b/src/App.tsx index 6e1f19b..07cd007 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,17 @@ -import React, { Component } from "react"; +import { Component } from "react"; import HomePage from "./components/HomePage"; - +import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'; +import { EstablishmentDetailPage } from './components/EstablishmentDetailPage'; class App extends Component { render() { - return ; - } + return( + + + } /> + } /> + + + )} } export default App; diff --git a/src/api/ratingsAPI.test.tsx b/src/api/ratingsAPI.test.tsx index eb9bfeb..0537c8f 100644 --- a/src/api/ratingsAPI.test.tsx +++ b/src/api/ratingsAPI.test.tsx @@ -1,5 +1,10 @@ import { enableFetchMocks } from "jest-fetch-mock"; -import { getEstablishmentRatings } from "./ratingsAPI"; +import { + getEstablishmentRatings, + getEstablishmentDetails, + getAuthorities, + filterEstablishmentsByAuthority +} from "./ratingsAPI"; import fetch from "jest-fetch-mock"; enableFetchMocks(); @@ -24,4 +29,46 @@ describe("Ratings API", () => { `http://api.ratings.food.gov.uk/Establishments/basic/${pageNum}/10` ); }); + + it("call the ratings api with the provided id and returns the data", async () => { + const id = "123"; + const expected = { testing: "test detail" }; + fetch.mockResponseOnce(JSON.stringify(expected)); + + const actual = await getEstablishmentDetails(id); + + expect(actual).toEqual(expected); + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0]).toEqual( + `http://api.ratings.food.gov.uk/establishments/${id}` + ); + }); + + it("call the ratings api Authorities and returns the data", async () => { + const expected = { authorities: "test authority" }; + fetch.mockResponseOnce(JSON.stringify(expected)); + + const actual = await getAuthorities(); + + expect(actual).toEqual(expected); + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0]).toEqual( + `http://api.ratings.food.gov.uk/Authorities` + ); + }); + + it("call the ratings api with provided pageNumber, authorityId and filters the establisments", async () => { + const authorityId = 4321; + const pageNum = 1; + const expected = { testing: "test" }; + fetch.mockResponseOnce(JSON.stringify(expected)); + + const actual = await filterEstablishmentsByAuthority(authorityId, pageNum); + + expect(actual).toEqual(expected); + expect(fetch.mock.calls.length).toEqual(1); + expect(fetch.mock.calls[0][0]).toEqual( + `http://api.ratings.food.gov.uk/Establishments?pageNumber=${pageNum}&pageSize=10&localAuthorityId=${authorityId}` + ); + }); }); diff --git a/src/api/ratingsAPI.ts b/src/api/ratingsAPI.ts index 1d4080c..9c9e457 100644 --- a/src/api/ratingsAPI.ts +++ b/src/api/ratingsAPI.ts @@ -18,7 +18,42 @@ export type EstablishmentsType = { ]; }; -export function getEstablishmentRatings( +export type AuthoritiesType = { + authorities: {}[]; + meta: { + dataSource: string; + extractDate: string; + itemCount: number; + returncode: string; + totalCount: number; + totalPages: number; + pageSize: number; + pageNumber: number; + }; + links: [ + { + rel: string; + href: string; + } + ]; +}; + +export type EstablishmentDetailType = { + AddressLine1: string, + AddressLine2: string, + AddressLine3: string, + AddressLine4: string, + BusinessName: string, + businessTypeID: number, + FHRSID: number, + localAuthorityBusinessID: string, + PostCode: string, + RatingDate: string, + RatingValue: string, + scores: {} +} + +export async function getEstablishmentRatings( pageNum: number ): Promise { return fetch( @@ -26,3 +61,29 @@ export function getEstablishmentRatings( { headers: { "x-api-version": "2" } } ).then((res) => res.json()); } + +export async function getEstablishmentDetails( + id: string | undefined +): Promise { + return fetch( + `http://api.ratings.food.gov.uk/establishments/${id}`, + { headers: { "x-api-version": "2" } } + ).then((res) => res.json()); +} + +export async function getAuthorities(): Promise { + return fetch( + `http://api.ratings.food.gov.uk/Authorities`, + { headers: { "x-api-version": "2" } } + ).then((res) => res.json()); +} + +export async function filterEstablishmentsByAuthority( + authorityId: number, + pageNum: number +): Promise { + return fetch( + `http://api.ratings.food.gov.uk/Establishments?pageNumber=${pageNum}&pageSize=10&localAuthorityId=${authorityId}`, + { headers: { "x-api-version": "2" } } + ).then((res) => res.json()); +} \ No newline at end of file diff --git a/src/components/DropdownFilter.test.tsx b/src/components/DropdownFilter.test.tsx new file mode 100644 index 0000000..83ea6c8 --- /dev/null +++ b/src/components/DropdownFilter.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import Dropdown from './DropdownFilter'; + +describe("DropdownFilter ", ()=> { + interface Option { + label: string; + value: string; + } + + const options: Option[] = [ + { label: 'Option 1', value: 'value1' }, + { label: 'Option 2', value: 'value2' }, + { label: 'Option 3', value: 'value3' }, + ]; + +it('renders dropdown with options and selects default value', () => { + const mockOnChange = jest.fn(); + render(); + + const select = screen.getByRole('listbox'); + expect(select).toBeInTheDocument(); + expect(select).toHaveValue('value2'); +}); + +it('handles user selection and calls onChange callback', () => { + const mockOnChange = jest.fn(); + render(); + + const select = screen.getByRole('listbox'); + userEvent.selectOptions(select, 'value3'); + + expect(select).toHaveValue('value3'); + expect(mockOnChange).toHaveBeenCalledTimes(1); + expect(mockOnChange).toHaveBeenCalledWith('value3'); +}); + +}) + diff --git a/src/components/DropdownFilter.tsx b/src/components/DropdownFilter.tsx new file mode 100644 index 0000000..b4be326 --- /dev/null +++ b/src/components/DropdownFilter.tsx @@ -0,0 +1,33 @@ +import React, { useState } from 'react'; +import styles from '../styles/DropdownFilter.module.css' +interface Option { + label: string; + value: string; +} + +interface DropdownProps { + options: Option[]; + defaultValue?: string; + onChange: (value: string) => void; +} + +const Dropdown: React.FC = ({ options, defaultValue, onChange }) => { + const [selectedValue, setSelectedValue] = useState(defaultValue || options[0].value); + + const handleChange = (event: React.ChangeEvent) => { + setSelectedValue(event.target.value); + onChange(event.target.value); + }; + + return ( + + ); +}; + +export default Dropdown; \ No newline at end of file diff --git a/src/components/EstablishmentDetailPage.test.tsx b/src/components/EstablishmentDetailPage.test.tsx new file mode 100644 index 0000000..0ac6630 --- /dev/null +++ b/src/components/EstablishmentDetailPage.test.tsx @@ -0,0 +1,62 @@ +import { render, screen, waitFor, act } from "@testing-library/react"; +import { EstablishmentDetailPage } from "./EstablishmentDetailPage"; +import { MemoryRouter } from "react-router-dom"; +import { enableFetchMocks } from "jest-fetch-mock"; +import fetch from "jest-fetch-mock"; + +describe("EstablishmentDetailPage", () => { + beforeEach(() => { + enableFetchMocks(); + fetch.resetMocks(); + }); + + it("renders EstablishmentDetailPage and checks loading indicator", () => { + act(() => { + render( + + + + ); + }) + + const spinner = screen.getByRole("status"); + expect(spinner).toBeInTheDocument(); + }); + + it("renders EstablishmentDetailPage with details", async () => { + const expected = { + AddressLine1: "Camden Street", + AddressLine2: "192-198 Camden", + AddressLine3: "", + AddressLine4: "London", + BusinessName: "Test Establisment", + PostCode: "123456", + RatingDate: "2023-10-12T00:00:00", + RatingValue: "2", + scores: { + Hygiene: 15, + Structural: 10, + ConfidenceInManagement: 10, + }, + }; + fetch.mockResponseOnce(JSON.stringify(expected)); + + await act(async () => { + render( + + + + ); + await waitFor(() => expect(screen.getByTestId("detailPage")).toBeInTheDocument()); + }) + + expect(screen.getByTestId("detailPage")).toMatchSnapshot(); + expect(screen.getByTestId("fullAddress").textContent).toEqual( + "Address: Camden Street 192-198 Camden, 123456 London" + ); + expect(screen.getByTestId("dateOfInspection").textContent).toEqual( + "Date of inspection: 10/12/2023" + ); + expect(screen.getByTestId("rating").textContent).toEqual("Rating: 2"); + }); +}); diff --git a/src/components/EstablishmentDetailPage.tsx b/src/components/EstablishmentDetailPage.tsx new file mode 100644 index 0000000..4095ccd --- /dev/null +++ b/src/components/EstablishmentDetailPage.tsx @@ -0,0 +1,79 @@ +import { useState, useEffect } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import styles from "../styles/EstablishmentDetailPage.module.css"; +import { + getEstablishmentDetails, + EstablishmentDetailType, +} from "../api/ratingsAPI"; +import LoadingSpinner from "./LoadingSpinner"; +import GenericButton from "./GenericButton"; + +export const EstablishmentDetailPage = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<{ + message: string; + [key: string]: string; + }>(); + const [details, setDetails] = useState(); + + useEffect(() => { + getEstablishmentDetails(id) + .then( + (result) => { + setDetails(result); + }, + (error) => { + setError(error); + } + ) + .finally(() => { + setLoading(false); + }); + }, []); + + const navigateBack = () => { + navigate("/"); + }; + const formatedDate = (date: string | undefined) => { + return date ? new Date(date).toLocaleDateString() : "N/A"; + }; + + const displayRating = (values: {} | undefined) => { + const entries = values ? Object.entries(values) : []; + return ( +
    + {entries.map((entry) => ( +
  • +

    {entry[0]}: {entry[1]}

    +
  • + ))} +
+ ); + }; + + return ( +
+ {loading ? ( + + ) : ( +
+

{details?.BusinessName}

+

+ Address:{` ${details?.AddressLine1} ${details?.AddressLine2}${details?.AddressLine3.toString() === "" ? "," : " "+details?.AddressLine3+","} ${details?.PostCode} ${details?.AddressLine4}`} +

+

+ Date of inspection: {formatedDate(details?.RatingDate)} +

+
+

Rating:{" "}{details?.RatingValue}

+
+ {displayRating(details?.scores)} + + +
+ )} +
+ ); +}; diff --git a/src/components/EstablishmentsTable.test.tsx b/src/components/EstablishmentsTable.test.tsx new file mode 100644 index 0000000..34991cd --- /dev/null +++ b/src/components/EstablishmentsTable.test.tsx @@ -0,0 +1,51 @@ +import { render, screen, act } from "@testing-library/react"; +import { EstablishmentsTable } from "./EstablishmentsTable"; +import { TypeOfTable } from "../constants"; +import { FavouriteItemsContext } from "../context/favouriteItems"; +import { MemoryRouter } from "react-router-dom"; + +describe("EstablishmentsTable", () => { + const establishments = [ + { BusinessName: "Business 1", RatingValue: "4", favourite: "1" }, + { BusinessName: "Business 2", RatingValue: "3", favourite: "0" }, + ]; + it("renders table with establishments", () => { + const mockContext = { + Provider: ({ children }: any) => ( + + {children} + + ), + }; + + act(() => { + render( + + + + + + ); + }); + + const table = screen.getByRole("table"); + const tableRows = table.querySelectorAll("tr"); + + expect(tableRows.length).toBe(3); + expect(screen.getByText("Business Name")).toBeInTheDocument(); + expect(screen.getByText("Rating Value")).toBeInTheDocument(); + expect(screen.getByText("Favourite")).toBeInTheDocument(); + + expect(screen.getByText("Business 1")).toBeInTheDocument(); + expect(screen.getByText("4")).toBeInTheDocument(); + }); +}); diff --git a/src/components/EstablishmentsTable.tsx b/src/components/EstablishmentsTable.tsx index 56df19f..504507b 100644 --- a/src/components/EstablishmentsTable.tsx +++ b/src/components/EstablishmentsTable.tsx @@ -1,22 +1,20 @@ import React from "react"; import { EstablishmentsTableRow } from "./EstablishmentsTableRow"; import PropTypes from "prop-types"; - -const headerStyle: { [key: string]: string | number } = { - paddingBottom: "10px", - textAlign: "left", - fontSize: "20px", -}; +import { TypeOfTable } from "../constants"; +import styles from '../styles/EstablishmentsTable.module.css'; export const EstablishmentsTable: React.FC<{ establishments: { [key: string]: string }[] | null | undefined; -}> = ({ establishments }) => { + type: TypeOfTable +}> = ({ establishments, type }) => { return ( - - + + + {establishments && establishments?.map( @@ -27,6 +25,7 @@ export const EstablishmentsTable: React.FC<{ ) )} diff --git a/src/components/EstablishmentsTableNavigation.test.tsx b/src/components/EstablishmentsTableNavigation.test.tsx new file mode 100644 index 0000000..e695e42 --- /dev/null +++ b/src/components/EstablishmentsTableNavigation.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import { EstablishmentsTableNavigation } from "./EstablishmentsTableNavigation"; + +describe("EstablishmentsTableNavigation ", () => { + it("renders navigation with buttons", () => { + const props = { + pageNum: 1, + pageCount: 5, + onPreviousPage: jest.fn(), + onNextPage: jest.fn(), + }; + + render(); + + const previousButton = screen.getByText("-"); + const nextButton = screen.getByText("+"); + const pageNumber = screen.getByText("1"); + + expect(previousButton).toBeInTheDocument(); + expect(nextButton).toBeInTheDocument(); + expect(pageNumber).toBeInTheDocument(); + }); +}); diff --git a/src/components/EstablishmentsTableRow.test.tsx b/src/components/EstablishmentsTableRow.test.tsx new file mode 100644 index 0000000..3e6b67c --- /dev/null +++ b/src/components/EstablishmentsTableRow.test.tsx @@ -0,0 +1,78 @@ +import { render, screen, act } from "@testing-library/react"; +import { TypeOfTable } from "../constants"; +import { FavouriteItemsContext } from "../context/favouriteItems"; +import { EstablishmentsTableRow } from "./EstablishmentsTableRow"; +import { MemoryRouter } from "react-router-dom"; + +describe("EstablishmentsTableRow", () => { + const mockEstablishment = { + FHRSID: "12345", + BusinessName: "Test Establishment", + RatingValue: "5", + }; + const mockContext = { + Provider: ({ children }: any) => ( + + {children} + + ), + }; + + it("renders table row with link and rating for paginated table", () => { + act(() => { + render( + + + + + + ); + }); + + const link = screen.getByText(mockEstablishment.BusinessName); + const rating = screen.getByText(mockEstablishment.RatingValue); + const checkbox = screen.queryByRole("checkbox"); + const removeButton = screen.queryByText("Remove"); + + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", `/detail/${mockEstablishment.FHRSID}`); + expect(rating).toBeInTheDocument(); + expect(checkbox).toBeInTheDocument(); + expect(removeButton).not.toBeInTheDocument(); + }); + + it("renders table row with link and rating for favourite table", () => { + act(() => { + render( + + + + + + ); + }); + + const link = screen.getByText(mockEstablishment.BusinessName); + const rating = screen.getByText(mockEstablishment.RatingValue); + const checkbox = screen.queryByRole("checkbox"); + const removeButton = screen.queryByText("Remove"); + + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", `/detail/${mockEstablishment.FHRSID}`); + expect(rating).toBeInTheDocument(); + expect(checkbox).not.toBeInTheDocument(); + expect(removeButton).toBeInTheDocument(); + }); +}); diff --git a/src/components/EstablishmentsTableRow.tsx b/src/components/EstablishmentsTableRow.tsx index c4cf644..5d3c2dc 100644 --- a/src/components/EstablishmentsTableRow.tsx +++ b/src/components/EstablishmentsTableRow.tsx @@ -1,10 +1,54 @@ +import styles from "../styles/EstablishmentsTableRow.module.css"; +import { Link } from "react-router-dom"; +import FavouriteCheckbox from "./FavouriteCheckbox"; +import { TypeOfTable } from "../constants"; +import GenericButton from "./GenericButton"; +import { useContext } from "react"; +import { FavouriteItemsContext } from "../context/favouriteItems"; + export const EstablishmentsTableRow: React.FC<{ establishment: { [key: string]: string } | null | undefined; -}> = ({ establishment }) => { + typeOfTable: TypeOfTable; +}> = ({ establishment, typeOfTable }) => { + const favoritedEstablishments = useContext(FavouriteItemsContext); + + const handleOnChange = ( + item: { [key: string]: string } | null | undefined, + checked: boolean + ) => { + checked + ? favoritedEstablishments?.saveFavouriteItem(item) + : favoritedEstablishments?.removeItem(item?.FHRSID || ""); + }; + + const removeFromFavorite = (id: string | undefined) => { + favoritedEstablishments?.removeItem(id || ""); + }; + return ( - - + + + {typeOfTable === TypeOfTable.Favourite ? ( + + ) : ( + + )} ); }; diff --git a/src/components/FavouriteCheckbox.test.tsx b/src/components/FavouriteCheckbox.test.tsx new file mode 100644 index 0000000..a1f98fd --- /dev/null +++ b/src/components/FavouriteCheckbox.test.tsx @@ -0,0 +1,32 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import FavouriteCheckbox from "./FavouriteCheckbox"; + +describe("FavouriteCheckbox", () => { + const mockOnChange = jest.fn(); + it("renders checkbox and reflects initial checked state", () => { + render(); + + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).toBeChecked(); + }); + + it("renders checkbox and reflects unchecked state", () => { + render(); + + const checkbox = screen.getByRole("checkbox"); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).not.toBeChecked(); + }); + + test("handles checkbox change and calls onChange callback", () => { + render(); + + const checkbox = screen.getByRole("checkbox"); + fireEvent.click(checkbox); + + expect(checkbox).toBeChecked(); + expect(mockOnChange).toHaveBeenCalledTimes(1); + expect(mockOnChange).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/components/FavouriteCheckbox.tsx b/src/components/FavouriteCheckbox.tsx new file mode 100644 index 0000000..2bde747 --- /dev/null +++ b/src/components/FavouriteCheckbox.tsx @@ -0,0 +1,31 @@ +import React, { useState } from "react"; +import styles from "../styles/FavouriteCheckbox.module.css"; + +interface FavouriteCheckboxProps { + isChecked: boolean; + onChange: (checked: boolean) => void; +} + +const FavouriteCheckbox: React.FC = ({ + isChecked, + onChange, +}) => { + const [checked, setChecked] = useState(isChecked); + const handleCheckboxChange = (event: React.ChangeEvent) => { + setChecked(event.target.checked); + onChange && onChange(event.target.checked); + }; + + return ( + + ); +}; + +export default FavouriteCheckbox; diff --git a/src/components/FavouritedEstablishmentsTable.test.tsx b/src/components/FavouritedEstablishmentsTable.test.tsx new file mode 100644 index 0000000..96b20d7 --- /dev/null +++ b/src/components/FavouritedEstablishmentsTable.test.tsx @@ -0,0 +1,67 @@ +import { render, screen, act } from "@testing-library/react"; +import { FavouritedEstablishmentsTable } from "./FavouritedEstablishmentsTable"; +import { FavouriteItemsContext } from "../context/favouriteItems"; +import { MemoryRouter } from "react-router-dom"; +describe("FavouritedEstablishmentsTable ", () => { + it("renders table when favourite items are present", () => { + const favouriteItems = [{ id: "1", name: "Test Establishment" }]; + + const mockContext = { + Provider: ({ children }: any) => ( + + {children} + + ), + }; + + act(() => { + render( + + + + + + ); + }); + + const tableHeader = screen.getByText("Favourite table"); + const establishmentsTable = screen.getByRole("table"); + + expect(tableHeader).toBeInTheDocument(); + expect(establishmentsTable).toBeInTheDocument(); + }); + + it("renders empty div when no favourite items", () => { + const mockContext = { + Provider: ({ children }: any) => ( + + {children} + + ), + }; + + act(() => { + render( + + + + + + ); + }); + + expect(screen.queryByText("Favourite table")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/FavouritedEstablishmentsTable.tsx b/src/components/FavouritedEstablishmentsTable.tsx new file mode 100644 index 0000000..08f7010 --- /dev/null +++ b/src/components/FavouritedEstablishmentsTable.tsx @@ -0,0 +1,20 @@ +import { useContext } from "react"; +import styles from "../styles/FavouritedEstablishmentsTable.module.css"; +import { EstablishmentsTable } from "./EstablishmentsTable"; +import { TypeOfTable } from "../constants"; +import { FavouriteItemsContext } from "../context/favouriteItems"; + +export const FavouritedEstablishmentsTable = () => { + const establishments = useContext(FavouriteItemsContext); + if(establishments?.favouriteItem && establishments?.favouriteItem?.length > 0) { + return ( +
+

Favourite table

+ +
+ ) + } else { + return
+ } + +}; diff --git a/src/components/GenericButton.test.tsx b/src/components/GenericButton.test.tsx new file mode 100644 index 0000000..26dcc3a --- /dev/null +++ b/src/components/GenericButton.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import GenericButton from './GenericButton'; + +describe("GenericButton", () => { + + it('renders button with text and handles click event', () => { + const mockOnClick = jest.fn(); + render(); + + const button = screen.getByRole('button', { name: /Click Me/i }); + expect(button).toBeInTheDocument(); + + userEvent.click(button); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it('renders button with custom classes', () => { + const customClasses = ['custom-class1', 'custom-class2']; + render(); + + const button = screen.getByRole('button', { name: /Custom Button/i }); + expect(button).toBeInTheDocument(); + expect(button).toHaveClass('button custom-class1 custom-class2'); + + }); + +}) diff --git a/src/components/GenericButton.tsx b/src/components/GenericButton.tsx new file mode 100644 index 0000000..c6766b7 --- /dev/null +++ b/src/components/GenericButton.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import styles from "../styles/GenericButton.module.css"; + +const GenericButton: React.FC<{ text: string; onClick?: () => void; classes?: Array | undefined }> = ({ + text, + onClick, + classes +}) => { + + let buttonClasses = [styles.button] + if(classes) { + buttonClasses = buttonClasses.concat(classes); + } + return ( + + ); +}; + +export default GenericButton; diff --git a/src/components/HomePage.test.tsx b/src/components/HomePage.test.tsx new file mode 100644 index 0000000..8d33289 --- /dev/null +++ b/src/components/HomePage.test.tsx @@ -0,0 +1,95 @@ +import { render, screen, act } from "@testing-library/react"; +import HomePage from "./HomePage"; +import { FavouriteItemsContext } from "../context/favouriteItems"; +import { MemoryRouter } from "react-router-dom"; +import userEvent from "@testing-library/user-event"; +import { enableFetchMocks } from "jest-fetch-mock"; +import fetch from "jest-fetch-mock"; +describe("HomePage", () => { + const mockAuthorities = { + authorities: [ + { + LocalAuthorityId: 197, + LocalAuthorityIdCode: "760", + Name: "Aberdeen City", + FriendlyName: "aberdeen-city", + RegionName: "Scotland", + CreationDate: "2010-08-17T15:30:24.87", + LastPublishedDate: "2024-03-30T00:36:08.86", + }, + { + LocalAuthorityId: 198, + LocalAuthorityIdCode: "761", + Name: "Aberdeenshire", + FriendlyName: "aberdeenshire", + RegionName: "Scotland", + CreationDate: "2010-08-17T15:30:24.87", + LastPublishedDate: "2024-03-28T00:43:56.76", + }, + ], + }; + const mockEstablishment = { + establishments: [ + { + FHRSID: 1549111, + LocalAuthorityBusinessID: "201744", + BusinessName: "Test Establishment", + BusinessType: "Takeaway/sandwich shop", + RatingValue: "2", + RatingDate: "2023-10-12T00:00:00", + }, + { + FHRSID: 23456, + LocalAuthorityBusinessID: "201744", + BusinessName: "Test Establishment 2", + BusinessType: "Takeaway/sandwich shop", + RatingValue: "2", + RatingDate: "2023-10-12T00:00:00", + }, + ], + }; + + beforeEach(() => { + enableFetchMocks(); + fetch.resetMocks(); + }); + + it("renders HomePage component", async () => { + fetch + .mockResponseOnce(JSON.stringify(mockAuthorities)) + .mockResponseOnce(JSON.stringify(mockEstablishment)); + const favouriteItem: { [key: string]: string }[] = []; + const saveFavouriteItem = jest.fn(); + const removeItem = jest.fn(); + const mockContext = { + favouriteItem, + saveFavouriteItem, + removeItem, + } + await act(async () => { + render( + + + + + + ); + }); + expect(screen.getByRole("img")).toBeInTheDocument(); + expect(screen.getByText("Food Hygiene Ratings")).toBeInTheDocument(); + + const establishmentToSave = mockEstablishment.establishments[0]; + const table = screen.getByRole("table"); + const tableFirstRows = table.querySelectorAll("tr")[1]; + const checkbox = tableFirstRows.querySelector("input"); + + await act(async () => { + if (checkbox) userEvent.click(checkbox); + }); + + // Assert that saveFavouriteItem is called with correct data + // await expect(mockContext.saveFavouriteItem).toHaveBeenCalled(); + // expect(mockContext.removeItem).toHaveBeenCalled(); + }); +}); diff --git a/src/components/HomePage.tsx b/src/components/HomePage.tsx index 24e4a1f..99549dd 100644 --- a/src/components/HomePage.tsx +++ b/src/components/HomePage.tsx @@ -1,5 +1,10 @@ import { PaginatedEstablishmentsTable } from "./PaginatedEstablishmentsTable"; import Background from "../static/logo.svg"; +import { FavouritedEstablishmentsTable } from "./FavouritedEstablishmentsTable"; +import { useState } from "react"; +import { + FavouriteItemsContext +} from "../context/favouriteItems"; const logoStyle: { [key: string]: string | number } = { width: "640px", @@ -9,11 +14,27 @@ const logoStyle: { [key: string]: string | number } = { }; const HomePage = () => { + const [favouriteItem, setFavouriteItem] = + useState<{ [key: string]: string }[]>([]); + const saveFavouriteItem = (newItem: { [key: string]: string}| null | undefined) => { + const newTodo: { [key: string]: string } = { + ...newItem, + favourite: "1", + } + setFavouriteItem([...favouriteItem, newTodo]) + } + + const removeItem = (id: string) => { + const newFavourites: { [key: string]: string }[] = favouriteItem.filter(item=> item.FHRSID !== id); + setFavouriteItem(newFavourites); + } + return ( -
-
+ +
-
+ + ); }; diff --git a/src/components/LoadingSpinner.test.tsx b/src/components/LoadingSpinner.test.tsx new file mode 100644 index 0000000..fe8d38a --- /dev/null +++ b/src/components/LoadingSpinner.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react'; +import Loading from './LoadingSpinner'; + +describe("Loading", () => { + it('renders loading spinner without message', () => { + render(); + + const spinner = screen.getByRole('status'); + expect(spinner).toBeInTheDocument(); + expect(spinner).not.toHaveTextContent('Loading...'); + }); + + it('renders loading spinner with custom message', () => { + const customMessage = 'Fetching data...'; + render(); + + const spinner = screen.getByRole('status'); + expect(spinner).toBeInTheDocument(); + expect(spinner).toHaveTextContent(customMessage); + }); +}) + diff --git a/src/components/LoadingSpinner.tsx b/src/components/LoadingSpinner.tsx new file mode 100644 index 0000000..c7b276b --- /dev/null +++ b/src/components/LoadingSpinner.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import styles from '../styles/LoadingSpinner.module.css' + +interface LoadingProps { + message?: string; +} + +const Loading: React.FC = ({ message }) => { + return ( +
+
+ {message &&

{message}

} +
+ ); +}; + +export default Loading; \ No newline at end of file diff --git a/src/components/PaginatedEstablishmentsTable.test.tsx b/src/components/PaginatedEstablishmentsTable.test.tsx new file mode 100644 index 0000000..28cf108 --- /dev/null +++ b/src/components/PaginatedEstablishmentsTable.test.tsx @@ -0,0 +1,182 @@ +import { render, screen, waitFor, act } from "@testing-library/react"; +import { PaginatedEstablishmentsTable } from "./PaginatedEstablishmentsTable"; +import { FavouriteItemsContext } from "../context/favouriteItems"; +import userEvent from "@testing-library/user-event"; +import { enableFetchMocks } from "jest-fetch-mock"; +import fetch from "jest-fetch-mock"; +import { MemoryRouter } from "react-router-dom"; + +describe("PaginatedEstablishmentsTable", () => { + const mockContext = { + Provider: ({ children }: any) => ( + + {children} + + ), + }; + const mockAuthorities = { + authorities: [ + { + LocalAuthorityId: 197, + LocalAuthorityIdCode: "760", + Name: "Aberdeen City", + FriendlyName: "aberdeen-city", + RegionName: "Scotland", + CreationDate: "2010-08-17T15:30:24.87", + LastPublishedDate: "2024-03-30T00:36:08.86", + }, + { + LocalAuthorityId: 198, + LocalAuthorityIdCode: "761", + Name: "Aberdeenshire", + FriendlyName: "aberdeenshire", + RegionName: "Scotland", + CreationDate: "2010-08-17T15:30:24.87", + LastPublishedDate: "2024-03-28T00:43:56.76", + }, + ], + }; + const mockEstablishment = { + establishments: [ + { + FHRSID: 1549111, + LocalAuthorityBusinessID: "201744", + BusinessName: "Test Establishment", + BusinessType: "Takeaway/sandwich shop", + RatingValue: "2", + RatingDate: "2023-10-12T00:00:00", + }, + ], + }; + beforeEach(() => { + enableFetchMocks(); + fetch.resetMocks(); + }); + + it("renders loading spinner initially", async () => { + fetch + .mockResponseOnce(JSON.stringify(mockAuthorities)) + .mockResponseOnce(JSON.stringify(mockEstablishment)); + + act(() => { + render( + + + + + + ); + }); + const loadingSpinner = screen.getByRole("status"); + expect(loadingSpinner).toBeInTheDocument(); + }); + + it("renders error message on API failure", async () => { + fetch.mockRejectOnce(new Error("Api error")); + act(() => { + render( + + + + + + ); + }); + await waitFor(() => { + expect(screen.getByText("Error: Api error")).toBeInTheDocument(); + }); + }); + + it("fetches data and renders establishments table with pagination", async () => { + fetch + .mockResponseOnce(JSON.stringify(mockAuthorities)) + .mockResponseOnce(JSON.stringify(mockEstablishment)); + + await act(async () => { + render( + + + + + + ); + await waitFor(() => { + expect(screen.getByText("Test Establishment")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /[+]/i }) + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /[-]/i })).toBeDisabled(); + }); + }); + }); + + it("filters establishments by selected authority", async () => { + fetch + .mockResponseOnce(JSON.stringify(mockAuthorities)) + .mockResponseOnce(JSON.stringify(mockEstablishment)); + + await act(async () => { + render( + + + + + + ); + }); + await waitFor(async () => { + const authorityDropdown = screen.getByRole("listbox"); + fetch.mockResponseOnce(JSON.stringify({})); + userEvent.selectOptions(authorityDropdown, "Aberdeenshire"); + }); + await waitFor(() => { + const table = screen.getByRole("table"); + const tableRows = table.querySelectorAll("td"); + + expect(tableRows.length).toBe(0); + }); + }); + + it("navigates to previous and next pages", async () => { + fetch + .mockResponseOnce(JSON.stringify(mockAuthorities)) + .mockResponseOnce(JSON.stringify(mockEstablishment)); + await act(async () => { + render( + + + + + + ); + }); + + await waitFor(() => { + fetch + .mockResponseOnce(JSON.stringify(mockAuthorities)) + .mockResponseOnce(JSON.stringify(mockEstablishment)); + userEvent.click(screen.getByRole("button", { name: /[+]/i })); + + }); + await waitFor(() => { + const table = screen.getByRole("table"); + const tableRows = table.querySelectorAll("td"); + expect(tableRows.length).toBe(0); + }); + await waitFor(() => { + userEvent.click(screen.getByRole("button", { name: /[-]/i })); + fetch + .mockResponseOnce(JSON.stringify(mockAuthorities)) + .mockResponseOnce(JSON.stringify(mockEstablishment)); + }); + await waitFor(() => { + expect(screen.getByText("Test Establishment")).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/PaginatedEstablishmentsTable.tsx b/src/components/PaginatedEstablishmentsTable.tsx index af6d64a..dd78366 100644 --- a/src/components/PaginatedEstablishmentsTable.tsx +++ b/src/components/PaginatedEstablishmentsTable.tsx @@ -1,8 +1,22 @@ import { useState, useEffect } from "react"; import { EstablishmentsTable } from "./EstablishmentsTable"; import { EstablishmentsTableNavigation } from "./EstablishmentsTableNavigation"; -import { getEstablishmentRatings } from "../api/ratingsAPI"; +import { + getAuthorities, + getEstablishmentRatings, + filterEstablishmentsByAuthority, +} from "../api/ratingsAPI"; +import LoadingSpinner from "./LoadingSpinner"; +import Dropdown from "./DropdownFilter"; +import styles from "../styles/PaginatedEstablishmentsTable.module.css"; +import { TypeOfTable } from "../constants"; +import { useContext } from "react"; +import { FavouriteItemsContext } from "../context/favouriteItems"; +interface Option { + label: string; + value: string; +} const tableStyle = { background: "rgba(51, 51, 51, 0.9)", padding: "10px", @@ -12,49 +26,121 @@ const tableStyle = { }; export const PaginatedEstablishmentsTable = () => { - const [error, setError] = - useState<{ message: string; [key: string]: string }>(); + const favoritedEstablishments = useContext(FavouriteItemsContext); + const [error, setError] = useState<{ + message: string; + [key: string]: string; + }>(); const [establishments, setEstablishments] = useState< { [key: string]: string }[] >([]); const [pageNum, setPageNum] = useState(1); const [pageCount] = useState(100); - + const [currentFilter, setCurrentFilter] = useState("all"); + const [loading, setLoading] = useState(true); + const [authoritiesDropdownOptions, setAuthoritiesDropdownOptions] = useState< + Option[] + >([{ label: "All", value: "all" }]); useEffect(() => { - getEstablishmentRatings(pageNum).then( + getAuthorities().then( (result) => { - setEstablishments(result?.establishments); + handleAuthoritiesOptions(result?.authorities); }, (error) => { setError(error); } ); + callApiEstablishmentsRatings(pageNum); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + const favouritedId = favoritedEstablishments?.favouriteItem?.map( + (element) => element.FHRSID + ); + handleEstablisments(favouritedId); + }, [favoritedEstablishments]); + + const handleEstablisments = (favouritedId: string[] | undefined) => { + const newEstablisments: { [key: string]: string }[] = [...establishments]; + for (const establishment of newEstablisments) { + if (favouritedId?.includes(establishment.FHRSID)) { + establishment.favourite = "1"; + } else { + establishment.favourite = "0"; + } + } + setEstablishments(newEstablisments); + }; + const handleAuthoritiesOptions = ( + authoritiesList: { [key: string]: string }[] + ) => { + const filteredOptions = authoritiesDropdownOptions; + authoritiesList.forEach((authority) => { + filteredOptions.push({ + label: authority.Name, + value: authority.LocalAuthorityId, + }); + }); + setAuthoritiesDropdownOptions(filteredOptions); + }; + async function handlePreviousPage() { + setLoading(true); pageNum > 1 && setPageNum(pageNum - 1); - getEstablishmentRatings(pageNum).then( - (result) => { - setEstablishments(result.establishments); - }, - (error) => { - setError(error); - } - ); + if (currentFilter === "all") { + callApiEstablishmentsRatings(pageNum); + } else { + callApiFilterEstablishments(currentFilter, pageNum); + } } - async function handleNextPage() { + setLoading(true); pageNum < pageCount && setPageNum(pageNum + 1); - getEstablishmentRatings(pageNum).then( - (result) => { - setEstablishments(result.establishments); - }, - (error) => { - setError(error); - } - ); + if (currentFilter === "all") { + callApiEstablishmentsRatings(pageNum); + } else { + callApiFilterEstablishments(currentFilter, pageNum); + } } + const handleChangeFilterAuthorities = (value: string) => { + setLoading(true); + setCurrentFilter(value); + value === "all" + ? callApiEstablishmentsRatings(pageNum) + : callApiFilterEstablishments(value, pageNum); + }; + + const callApiEstablishmentsRatings = async (pageNum: number) => { + getEstablishmentRatings(pageNum) + .then( + (result) => { + setEstablishments(result.establishments); + }, + (error) => { + setError(error); + } + ) + .finally(() => { + setLoading(false); + }); + }; + + const callApiFilterEstablishments = async (id: string, pageNum: number) => { + filterEstablishmentsByAuthority(Number.parseInt(id), pageNum) + .then( + (result) => { + setEstablishments(result?.establishments); + }, + (error) => { + setError(error); + } + ) + .finally(() => { + setLoading(false); + }); + }; if (error) { return
Error: {error.message}
; @@ -62,13 +148,29 @@ export const PaginatedEstablishmentsTable = () => { return (

Food Hygiene Ratings

- - +
+ Filter by authorities + +
+ {loading ? ( + + ) : ( +
+ + +
+ )}
); } diff --git a/src/components/__snapshots__/EstablishmentDetailPage.test.tsx.snap b/src/components/__snapshots__/EstablishmentDetailPage.test.tsx.snap new file mode 100644 index 0000000..d4396ba --- /dev/null +++ b/src/components/__snapshots__/EstablishmentDetailPage.test.tsx.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EstablishmentDetailPage renders EstablishmentDetailPage with details 1`] = ` +
+

+ + Test Establisment + +

+

+ + Address: + + Camden Street 192-198 Camden, 123456 London +

+

+ + Date of inspection: + + + 10/12/2023 +

+
+

+ + Rating: + + + 2 +

+
+
    +
  • +

    + + Hygiene + : + 15 +

    +
  • +
  • +

    + + Structural + : + 10 +

    +
  • +
  • +

    + + ConfidenceInManagement + : + 10 +

    +
  • +
+ +
+`; diff --git a/src/constants.tsx b/src/constants.tsx new file mode 100644 index 0000000..7e4476c --- /dev/null +++ b/src/constants.tsx @@ -0,0 +1,4 @@ +export enum TypeOfTable { + Favourite = 'favourite', + Paginated = 'paginated' +} \ No newline at end of file diff --git a/src/context/favouriteItems.tsx b/src/context/favouriteItems.tsx new file mode 100644 index 0000000..203e52a --- /dev/null +++ b/src/context/favouriteItems.tsx @@ -0,0 +1,8 @@ +import { createContext } from "react"; +export interface FavouriteItemsContextType { + favouriteItem: { [key: string]: string }[] | null | undefined; + saveFavouriteItem: (newData: { [key: string]: string} | null | undefined) => void; + removeItem: (id: string) => void; +} + +export const FavouriteItemsContext = createContext(null); \ No newline at end of file diff --git a/src/routing/routesConfig.tsx b/src/routing/routesConfig.tsx new file mode 100644 index 0000000..128a7b4 --- /dev/null +++ b/src/routing/routesConfig.tsx @@ -0,0 +1,12 @@ +import App from '../App'; +import { EstablishmentDetailPage } from '../components/EstablishmentDetailPage'; + +interface RouteConfig { + path: string; + element: React.ReactElement; +} + +export const routes: RouteConfig[] = [ + { path: '/', element: }, + { path: '/detail/:id', element: } +]; \ No newline at end of file diff --git a/src/styles/DropdownFilter.module.css b/src/styles/DropdownFilter.module.css new file mode 100644 index 0000000..dbfd790 --- /dev/null +++ b/src/styles/DropdownFilter.module.css @@ -0,0 +1,52 @@ +select { + background-color: white; + border: thin solid #3c3d3d; + border-radius: 4px; + display: inline-block; + + font: inherit; + line-height: 1.5em; + padding: 0.5em 3.5em 0.5em 1em; + + /* reset */ + + margin: 0; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + -webkit-appearance: none; + -moz-appearance: none; +} +.minimal { + background-image: + linear-gradient(45deg, transparent 50%, gray 50%), + linear-gradient(135deg, gray 50%, transparent 50%), + linear-gradient(to right, #ccc, #ccc); + background-position: + calc(100% - 20px) calc(1em + 2px), + calc(100% - 15px) calc(1em + 2px), + calc(100% - 2.5em) 0.5em; + background-size: + 5px 5px, + 5px 5px, + 1px 1.5em; + background-repeat: no-repeat; + font-size: 20px; +} +.minimal:focus { + background-image: + linear-gradient(45deg, green 50%, transparent 50%), + linear-gradient(135deg, transparent 50%, green 50%), + linear-gradient(to right, #ccc, #ccc); + background-position: + calc(100% - 15px) 1em, + calc(100% - 20px) 1em, + calc(100% - 2.5em) 0.5em; + background-size: + 5px 5px, + 5px 5px, + 1px 1.5em; + background-repeat: no-repeat; + border-color: #d14508; + outline: 0; +} \ No newline at end of file diff --git a/src/styles/EstablishmentDetailPage.module.css b/src/styles/EstablishmentDetailPage.module.css new file mode 100644 index 0000000..2068e92 --- /dev/null +++ b/src/styles/EstablishmentDetailPage.module.css @@ -0,0 +1,11 @@ + .detailTable { + background-color: rgba(51, 51, 51, 0.9); + padding: 10px; + width: max-content; + margin: 65px 0px 0px 50px; + color: white; + } + + .details { + font-size: 20px; + } \ No newline at end of file diff --git a/src/styles/EstablishmentsTable.module.css b/src/styles/EstablishmentsTable.module.css new file mode 100644 index 0000000..e2fddf0 --- /dev/null +++ b/src/styles/EstablishmentsTable.module.css @@ -0,0 +1,6 @@ +.headerStyle { + padding-bottom: 10px; + text-align: left; + font-size: 20px; + padding-right: 12px; +}; \ No newline at end of file diff --git a/src/styles/EstablishmentsTableRow.module.css b/src/styles/EstablishmentsTableRow.module.css new file mode 100644 index 0000000..f11b067 --- /dev/null +++ b/src/styles/EstablishmentsTableRow.module.css @@ -0,0 +1,12 @@ +.tableRow { + font-size: 20px; + padding-right: 12px; +} +.link{ + text-decoration: none; + color: inherit; +} + +.buttonFavouriteTable { + padding: 6px 12px !important; +} \ No newline at end of file diff --git a/src/styles/FavouriteCheckbox.module.css b/src/styles/FavouriteCheckbox.module.css new file mode 100644 index 0000000..762ff99 --- /dev/null +++ b/src/styles/FavouriteCheckbox.module.css @@ -0,0 +1,24 @@ +.styledCheckbox { + display: flex; + align-items: center; + justify-content: center; +} + +.styledCheckbox input { + display: none; +} + +.styledCheckbox span.styledDot { + width: 12px; + height: 8px; + background-repeat: no-repeat; + background-position: center; + background-color: #3c3d3d; + cursor: pointer; + border-radius: 18px; +} + +.styledCheckbox input:checked + span.styledDot { + background-color: #d14508; +} + diff --git a/src/styles/FavouritedEstablishmentsTable.module.css b/src/styles/FavouritedEstablishmentsTable.module.css new file mode 100644 index 0000000..6072887 --- /dev/null +++ b/src/styles/FavouritedEstablishmentsTable.module.css @@ -0,0 +1,7 @@ +.favouriteTable { + background-color: rgba(51, 51, 51, 0.9); + padding: 10px; + width: max-content; + margin: 25px 0px 0px 50px; + color: white; + } \ No newline at end of file diff --git a/src/styles/GenericButton.module.css b/src/styles/GenericButton.module.css new file mode 100644 index 0000000..8b60d24 --- /dev/null +++ b/src/styles/GenericButton.module.css @@ -0,0 +1,16 @@ +.button { + background-color: #d14508; + border: none; + color: white; + padding: 15px 32px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 16px; + margin: 4px 2px; + cursor: pointer; +} + +.button:hover { + background-color: #d144089b; +} \ No newline at end of file diff --git a/src/styles/LoadingSpinner.module.css b/src/styles/LoadingSpinner.module.css new file mode 100644 index 0000000..f336c8e --- /dev/null +++ b/src/styles/LoadingSpinner.module.css @@ -0,0 +1,22 @@ +.loading { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + flex-direction: column +} + +.spinner { + width: 50px; + height: 50px; + border: 5px solid #ccc; + border-radius: 50%; + border-top-color: #333; + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} \ No newline at end of file diff --git a/src/styles/PaginatedEstablishmentsTable.module.css b/src/styles/PaginatedEstablishmentsTable.module.css new file mode 100644 index 0000000..68cf838 --- /dev/null +++ b/src/styles/PaginatedEstablishmentsTable.module.css @@ -0,0 +1,11 @@ +.filterAuthorities { + padding: 10px 0px 20px 0px; + display: flex; + flex-direction: column; +} + +span { +font-size: 20px; +padding-bottom: 10px; +padding-left: 5px; +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 9508d03..a9e4ed3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1448,6 +1448,11 @@ schema-utils "^2.6.5" source-map "^0.7.3" +"@remix-run/router@1.15.3": + version "1.15.3" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.15.3.tgz#d2509048d69dbb72d5389a14945339f1430b2d3c" + integrity sha512-Oy8rmScVrVxWZVOpEF57ovlnhpZ8CCPlnIIumVcV9nFdiSIrus99+Lw78ekXyGvVDlIsFJbSfmSovJUhCWYV3w== + "@rollup/plugin-node-resolve@^7.1.1": version "7.1.3" resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-7.1.3.tgz#80de384edfbd7bfc9101164910f86078151a3eca" @@ -1811,6 +1816,11 @@ resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24" integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug== +"@types/react-css-modules@^4.6.8": + version "4.6.8" + resolved "https://registry.yarnpkg.com/@types/react-css-modules/-/react-css-modules-4.6.8.tgz#70063b911200f4f3e6631e4b867977e22fd45f87" + integrity sha512-rgnrIOTUE1ZULMNH6I994AZCZ09iEQVcoiLDVBRgbNaCEcW4mT5At1aB5k3XW09KT/nLrvo6FqgjECj27i+Fkg== + "@types/react-dom@^17.0.0": version "17.0.11" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.11.tgz#e1eadc3c5e86bdb5f7684e00274ae228e7bcc466" @@ -9107,6 +9117,21 @@ react-refresh@^0.8.3: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f" integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg== +react-router-dom@^6.22.3: + version "6.22.3" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.22.3.tgz#9781415667fd1361a475146c5826d9f16752a691" + integrity sha512-7ZILI7HjcE+p31oQvwbokjk6OA/bnFxrhJ19n82Ex9Ph8fNAq+Hm/7KchpMGlTgWhUxRHMMCut+vEtNpWpowKw== + dependencies: + "@remix-run/router" "1.15.3" + react-router "6.22.3" + +react-router@6.22.3: + version "6.22.3" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.22.3.tgz#9d9142f35e08be08c736a2082db5f0c9540a885e" + integrity sha512-dr2eb3Mj5zK2YISHK++foM9w4eBnO23eKnZEDs7c880P6oKbrjz/Svg9+nxqtHQK+oMW4OtjZca0RqPglXxguQ== + dependencies: + "@remix-run/router" "1.15.3" + react-scripts@4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/react-scripts/-/react-scripts-4.0.3.tgz#b1cafed7c3fa603e7628ba0f187787964cb5d345"
Business NameRating ValueBusiness NameRating ValueFavourite
{establishment?.BusinessName}{establishment?.RatingValue} + + {establishment?.BusinessName} + + {establishment?.RatingValue} + removeFromFavorite(establishment?.FHRSID)} + /> + + handleOnChange(establishment, value)} + /> +