diff --git a/package-lock.json b/package-lock.json index 27b2104..ee211de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "opinion-ate", "version": "0.1.0", "dependencies": { + "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@emotion/react": "^11.10.4", "@emotion/styled": "^11.10.4", "@mui/material": "^5.10.7", @@ -670,9 +671,16 @@ } }, "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "version": "7.21.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.11.tgz", + "integrity": "sha512-0QZ8qP/3RLDVBwBFoWAwCtgcDZJVwA5LUJRZU8x2YFfKNuFq161wK3cuGrALu5yiPu+vzwTAg/sMWVNeWeNyaw==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-property-in-object instead.", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-create-class-features-plugin": "^7.21.0", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + }, "engines": { "node": ">=6.9.0" }, @@ -1915,6 +1923,17 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/preset-env/node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/preset-env/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", diff --git a/package.json b/package.json index 7992844..d7a1bf3 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.1.0", "private": true, "dependencies": { + "@babel/plugin-proposal-private-property-in-object": "^7.21.11", "@emotion/react": "^11.10.4", "@emotion/styled": "^11.10.4", "@mui/material": "^5.10.7", @@ -47,4 +48,4 @@ "redux": "^4.2.0", "redux-thunk": "^2.4.1" } -} +} \ No newline at end of file diff --git a/src/api.js b/src/api.js index e3965fd..418f1d9 100644 --- a/src/api.js +++ b/src/api.js @@ -4,7 +4,8 @@ const client = axios.create({ baseURL: 'https://api.outsidein.dev/Sy0NsAyaS8uuURS1zyCDp3Fzxcpg25iw', }); const api = { - async loadRestaurants(){ + async loadRestaurants() + { const response = await client.get('/restaurants'); return response.data; }, diff --git a/src/components/RestaurantList.js b/src/components/RestaurantList.js index 51ec86c..a782617 100644 --- a/src/components/RestaurantList.js +++ b/src/components/RestaurantList.js @@ -1,30 +1,34 @@ import { useEffect } from "react"; import { connect } from "react-redux"; -import List from '@mui/material/List'; -import ListItem from '@mui/material/ListItem'; -import ListItemText from '@mui/material/ListItemText'; +import { CircularProgress, List, ListItem, ListItemText, Alert } from "@mui/material"; import { loadRestaurants } from '../store/restaurants/actions'; -export function RestaurantList({ loadRestaurants, restaurants }) +export function RestaurantList({ loadRestaurants, restaurants, loading, loadError }) { useEffect(() => { loadRestaurants(); }, [loadRestaurants]); return ( - - {restaurants.map(restaurant => - ( - - {restaurant.name} - - ))} - + <> + {loading && } + {loadError && (Restaurants could not be loaded.)} + + {restaurants.map(restaurant => + ( + + {restaurant.name} + + ))} + + ); }; const mapStateToProps = state => ({ restaurants: state.restaurants.records, + loading: state.restaurants.loading, + loadError: state.restaurants.loadError, }); const mapDispatchToProps = { loadRestaurants }; diff --git a/src/components/RestaurantList.spec.js b/src/components/RestaurantList.spec.js index 0e74dec..caa7813 100644 --- a/src/components/RestaurantList.spec.js +++ b/src/components/RestaurantList.spec.js @@ -1,29 +1,64 @@ import { render, screen } from '@testing-library/react'; import { RestaurantList } from './RestaurantList'; -describe('RestaurantList', () => { - let loadRestaurants; - const restaurants = [ - {id: 1, name: 'Sushi Place'}, - {id: 2, name: 'Pizza Place'} - ]; - function renderComponent(){ - loadRestaurants = jest.fn().mockName('loadRestaurnats'); - render( - - ) - } +let loadRestaurants; - it('loads restaurants on first render', () => { - renderComponent(); +const restaurants = [ + { id: 1, name: 'Sushi Place' }, + { id: 2, name: 'Pizza Place' } +]; + +function renderComponent(propsOverride = {}) +{ + const props = { + loadRestaurants: jest.fn().mockName('loadRestaurnats'), + restaurants, + loading: false, + ...propsOverride, + }; + loadRestaurants = props.loadRestaurants; + + render(); +} + +describe('When Loading Succeeds', () => +{ + it('loads restaurants on first render', () => + { + renderComponent(); expect(loadRestaurants).toHaveBeenCalled(); }); - it('displays the restaurants', () => { + + it('does not display the error message', () => + { + renderComponent(); + expect(screen.queryByText('Restautants could not be loaded.')).not.toBeInTheDocument(); + }); + + it('displays the restaurants', () => + { renderComponent(); expect(screen.getByText('Sushi Place')).toBeInTheDocument(); expect(screen.getByText('Pizza Place')).toBeInTheDocument(); - }) -}); \ No newline at end of file + }); + + it('displays the loading indicator while loading', () => + { + renderComponent({ loading: true }); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('does not display the loading indicator while not loading', () => + { + renderComponent(); + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); +}); +describe('When Loading Fails', () => +{ + it('displays the error message', () => + { + renderComponent({ loadError: true }); + expect(screen.getByText('Restaurants could not be loaded.'),).toBeInTheDocument(); + }); +}); diff --git a/src/store/restaurants.spec.js b/src/store/restaurants.spec.js index 47135f3..437f374 100644 --- a/src/store/restaurants.spec.js +++ b/src/store/restaurants.spec.js @@ -3,30 +3,125 @@ import thunk from 'redux-thunk'; import restaurantReducer from './restaurants/reducers'; import { loadRestaurants } from "./restaurants/actions"; -describe('restaurants', () => { - describe('loadRestaurants action', () => { - it('stores the restaurants', async () => { - const records = [ - {id: 1, name: 'Sushi Place'}, - {id: 2, name: 'Pizza Place'} - ]; +describe('restaurants', () => +{ + describe('initially', () => + { + let store; - const api = { - loadRestaurants: () => Promise.resolve(records), - }; - const initialState = { - records: [], - }; - const store = createStore( + beforeEach(() => + { + const initialState = {}; + store = createStore( restaurantReducer, initialState, - applyMiddleware( - thunk.withExtraArgument(api), - ), + applyMiddleware(thunk), ); - await store.dispatch(loadRestaurants()); + }); + it('does not have the loading flag set', () => + { + expect(store.getState().loading).toEqual(false); + }); + it('does not have the error flag set', () => + { + expect(store.getState().loadError).toEqual(false); + }); + }); + describe('loadRestaurants Action', () => + { + describe('When Loading Succeeds', () => + { + const records = [ + { id: 1, name: 'Sushi Place' }, + { id: 2, name: 'Pizza Place' } + ]; + let store; - expect(store.getState().records).toEqual(records) + beforeEach(() => + { + const api = { + loadRestaurants: () => Promise.resolve(records), + }; + const initialState = { + records: [], + }; + store = createStore( + restaurantReducer, + initialState, + applyMiddleware( + thunk.withExtraArgument(api), + ), + ); + return store.dispatch(loadRestaurants()); + }) + + it('stores the restaurants', async () => + { + expect(store.getState().records).toEqual(records) + }); + it('clears the loading flag', () => + { + expect(store.getState().loading).toEqual(false); + }); + }); + describe('While Loading', () => + { + let store; + + beforeEach(() => + { + const api = { + loadRestaurants: () => new Promise(() => { }), + } + const initialState = { loadError: true }; + store = createStore( + restaurantReducer, + initialState, + applyMiddleware( + thunk.withExtraArgument(api), + ), + ); + store.dispatch(loadRestaurants()); + }); + it('sets a loading flag', () => + { + expect(store.getState().loading).toEqual(true); + }); + it('clears the error flag', () => + { + expect(store.getState().loadError).toEqual(false); + }); + }); + describe('When Loading Fails', () => + { + let store; + + beforeEach(() => + { + const api = { + loadRestaurants: () => Promise.reject(), + }; + + const initialState = {}; + + store = createStore( + restaurantReducer, + initialState, + applyMiddleware( + thunk.withExtraArgument(api), + ), + ); + return store.dispatch(loadRestaurants()); + }); + + it('sets an error flag', () => + { + expect(store.getState().loadError).toEqual(true); + }); + it('clears the loading flag', () => + { + expect(store.getState().loading).toEqual(false); + }); }); }); }); \ No newline at end of file diff --git a/src/store/restaurants/actions.js b/src/store/restaurants/actions.js index 9da3494..67a8342 100644 --- a/src/store/restaurants/actions.js +++ b/src/store/restaurants/actions.js @@ -1,11 +1,24 @@ export const STORE_RESTAURANTS = 'STORE_RESTAURANTS'; +export const START_LOADING = 'START_LOADING'; +export const RECORD_LOADING_ERROR = 'RECORD_LOADING_ERROR'; -export const loadRestaurants = () => async (dispatch, getState, api) => { - const records = await api.loadRestaurants(); - dispatch(storeRestaurants(records)); +export const loadRestaurants = () => async (dispatch, getState, api) => +{ + try + { + dispatch(startLoading()); + const records = await api.loadRestaurants(); + dispatch(storeRestaurants(records)); + } catch + { + dispatch(recordLoadingError()) + } }; +const startLoading = () => ({ type: START_LOADING }) + const storeRestaurants = records => ({ type: STORE_RESTAURANTS, records -}); \ No newline at end of file +}); +const recordLoadingError = () => ({ type: RECORD_LOADING_ERROR }); \ No newline at end of file diff --git a/src/store/restaurants/reducers.js b/src/store/restaurants/reducers.js index db8ea93..691699d 100644 --- a/src/store/restaurants/reducers.js +++ b/src/store/restaurants/reducers.js @@ -1,15 +1,45 @@ import { combineReducers } from 'redux'; -import { STORE_RESTAURANTS } from './actions'; +import { RECORD_LOADING_ERROR, STORE_RESTAURANTS, START_LOADING } from './actions'; -function records(state = [], action) { - switch(action.type) { +function records(state = [], action) +{ + switch (action.type) + { case STORE_RESTAURANTS: return action.records; default: return state; } +}; +function loading(state = false, action) +{ + switch (action.type) + { + case START_LOADING: + return true; + case STORE_RESTAURANTS: + return false; + case RECORD_LOADING_ERROR: + return false; + default: + return state; + } +} +function loadError(state = false, action) +{ + switch (action.type) + { + case START_LOADING: + return false; + case RECORD_LOADING_ERROR: + return true; + default: + return state; + } } export default combineReducers({ records, + loading, + loadError, }) \ No newline at end of file