Merge pull request 'error and loading' (#3) from edge-cases into main

Reviewed-on: #3
This commit is contained in:
Branden Wayne Jones 2024-04-30 13:46:03 +02:00
commit 85682daf45
8 changed files with 261 additions and 63 deletions

25
package-lock.json generated
View File

@ -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",

View File

@ -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"
}
}
}

View File

@ -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;
},

View File

@ -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 (
<List>
{restaurants.map(restaurant =>
(
<ListItem key={restaurant.id}>
<ListItemText>{restaurant.name}</ListItemText>
</ListItem>
))}
</List>
<>
{loading && <CircularProgress />}
{loadError && (<Alert severity="error">Restaurants could not be loaded.</Alert>)}
<List>
{restaurants.map(restaurant =>
(
<ListItem key={restaurant.id}>
<ListItemText>{restaurant.name}</ListItemText>
</ListItem>
))}
</List>
</>
);
};
const mapStateToProps = state => ({
restaurants: state.restaurants.records,
loading: state.restaurants.loading,
loadError: state.restaurants.loadError,
});
const mapDispatchToProps = { loadRestaurants };

View File

@ -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(
<RestaurantList
loadRestaurants={loadRestaurants}
restaurants={restaurants}
/>
)
}
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(<RestaurantList {...props} />);
}
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();
})
});
});
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();
});
});

View File

@ -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);
});
});
});
});

View File

@ -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
});
});
const recordLoadingError = () => ({ type: RECORD_LOADING_ERROR });

View File

@ -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,
})