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", "name": "opinion-ate",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@emotion/react": "^11.10.4", "@emotion/react": "^11.10.4",
"@emotion/styled": "^11.10.4", "@emotion/styled": "^11.10.4",
"@mui/material": "^5.10.7", "@mui/material": "^5.10.7",
@ -670,9 +671,16 @@
} }
}, },
"node_modules/@babel/plugin-proposal-private-property-in-object": { "node_modules/@babel/plugin-proposal-private-property-in-object": {
"version": "7.21.0-placeholder-for-preset-env.2", "version": "7.21.11",
"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", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.11.tgz",
"integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", "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": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
}, },
@ -1915,6 +1923,17 @@
"@babel/core": "^7.0.0-0" "@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": { "node_modules/@babel/preset-env/node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",

View File

@ -3,6 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@emotion/react": "^11.10.4", "@emotion/react": "^11.10.4",
"@emotion/styled": "^11.10.4", "@emotion/styled": "^11.10.4",
"@mui/material": "^5.10.7", "@mui/material": "^5.10.7",
@ -47,4 +48,4 @@
"redux": "^4.2.0", "redux": "^4.2.0",
"redux-thunk": "^2.4.1" "redux-thunk": "^2.4.1"
} }
} }

View File

@ -4,7 +4,8 @@ const client = axios.create({
baseURL: 'https://api.outsidein.dev/Sy0NsAyaS8uuURS1zyCDp3Fzxcpg25iw', baseURL: 'https://api.outsidein.dev/Sy0NsAyaS8uuURS1zyCDp3Fzxcpg25iw',
}); });
const api = { const api = {
async loadRestaurants(){ async loadRestaurants()
{
const response = await client.get('/restaurants'); const response = await client.get('/restaurants');
return response.data; return response.data;
}, },

View File

@ -1,30 +1,34 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import List from '@mui/material/List'; import { CircularProgress, List, ListItem, ListItemText, Alert } from "@mui/material";
import ListItem from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import { loadRestaurants } from '../store/restaurants/actions'; import { loadRestaurants } from '../store/restaurants/actions';
export function RestaurantList({ loadRestaurants, restaurants }) export function RestaurantList({ loadRestaurants, restaurants, loading, loadError })
{ {
useEffect(() => useEffect(() =>
{ {
loadRestaurants(); loadRestaurants();
}, [loadRestaurants]); }, [loadRestaurants]);
return ( return (
<List> <>
{restaurants.map(restaurant => {loading && <CircularProgress />}
( {loadError && (<Alert severity="error">Restaurants could not be loaded.</Alert>)}
<ListItem key={restaurant.id}> <List>
<ListItemText>{restaurant.name}</ListItemText> {restaurants.map(restaurant =>
</ListItem> (
))} <ListItem key={restaurant.id}>
</List> <ListItemText>{restaurant.name}</ListItemText>
</ListItem>
))}
</List>
</>
); );
}; };
const mapStateToProps = state => ({ const mapStateToProps = state => ({
restaurants: state.restaurants.records, restaurants: state.restaurants.records,
loading: state.restaurants.loading,
loadError: state.restaurants.loadError,
}); });
const mapDispatchToProps = { loadRestaurants }; const mapDispatchToProps = { loadRestaurants };

View File

@ -1,29 +1,64 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { RestaurantList } from './RestaurantList'; import { RestaurantList } from './RestaurantList';
describe('RestaurantList', () => { let loadRestaurants;
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}
/>
)
}
it('loads restaurants on first render', () => { const restaurants = [
renderComponent(); { 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(); 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(); renderComponent();
expect(screen.getByText('Sushi Place')).toBeInTheDocument(); expect(screen.getByText('Sushi Place')).toBeInTheDocument();
expect(screen.getByText('Pizza 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 restaurantReducer from './restaurants/reducers';
import { loadRestaurants } from "./restaurants/actions"; import { loadRestaurants } from "./restaurants/actions";
describe('restaurants', () => { describe('restaurants', () =>
describe('loadRestaurants action', () => { {
it('stores the restaurants', async () => { describe('initially', () =>
const records = [ {
{id: 1, name: 'Sushi Place'}, let store;
{id: 2, name: 'Pizza Place'}
];
const api = { beforeEach(() =>
loadRestaurants: () => Promise.resolve(records), {
}; const initialState = {};
const initialState = { store = createStore(
records: [],
};
const store = createStore(
restaurantReducer, restaurantReducer,
initialState, initialState,
applyMiddleware( applyMiddleware(thunk),
thunk.withExtraArgument(api),
),
); );
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 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) => { export const loadRestaurants = () => async (dispatch, getState, api) =>
const records = await api.loadRestaurants(); {
dispatch(storeRestaurants(records)); try
{
dispatch(startLoading());
const records = await api.loadRestaurants();
dispatch(storeRestaurants(records));
} catch
{
dispatch(recordLoadingError())
}
}; };
const startLoading = () => ({ type: START_LOADING })
const storeRestaurants = records => ({ const storeRestaurants = records => ({
type: STORE_RESTAURANTS, type: STORE_RESTAURANTS,
records records
}); });
const recordLoadingError = () => ({ type: RECORD_LOADING_ERROR });

View File

@ -1,15 +1,45 @@
import { combineReducers } from 'redux'; import { combineReducers } from 'redux';
import { STORE_RESTAURANTS } from './actions'; import { RECORD_LOADING_ERROR, STORE_RESTAURANTS, START_LOADING } from './actions';
function records(state = [], action) { function records(state = [], action)
switch(action.type) { {
switch (action.type)
{
case STORE_RESTAURANTS: case STORE_RESTAURANTS:
return action.records; return action.records;
default: default:
return state; 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({ export default combineReducers({
records, records,
loading,
loadError,
}) })