From 6a5fd650a1062e4cb5142e51c088fee414987a51 Mon Sep 17 00:00:00 2001 From: hpierre74 Date: Sun, 3 Oct 2021 15:15:34 +0200 Subject: [PATCH 1/4] refactor(ex-9): migrate exercise 9 --- README.md | 4 + apps/exercise-9/src/app/app.module.css | 128 ---------------- apps/exercise-9/src/app/app.tsx | 26 ++-- .../src/app/components/layout.component.js | 29 ++++ .../src/app/components/navbar.component.js | 142 ++++++++++++++++++ .../src/app/constants/proptypes.constants.js | 7 + .../exercise-9/src/app/hooks/useInput.hook.js | 9 ++ .../__tests__/articles.actions.spec.js | 20 +++ .../__tests__/articles.reducer.spec.js | 36 +++++ .../app/modules/articles/articles.actions.js | 9 ++ .../app/modules/articles/articles.context.js | 52 +++++++ .../app/modules/articles/articles.reducer.js | 16 ++ .../modules/articles/articles.selectors.js | 10 ++ .../articles/components/article.component.js | 16 ++ .../components/articleCard.component.js | 90 +++++++++++ .../components/articlesList.component.js | 19 +++ .../src/app/modules/cart/cart.actions.js | 6 + .../src/app/modules/cart/cart.context.js | 48 ++++++ .../src/app/modules/cart/cart.reducer.js | 75 +++++++++ .../modules/cart/components/cart.component.js | 110 ++++++++++++++ .../cart/components/cartLayout.component.js | 22 +++ .../routing/components/routes.component.js | 50 ++++++ .../app/modules/routing/routing.constants.js | 10 ++ .../src/app/modules/routing/routing.hooks.js | 35 +++++ .../user/__tests__/user.actions.spec.js | 45 ++++++ .../user/__tests__/user.context.spec.js | 23 +++ .../user/__tests__/user.reducer.spec.js | 29 ++++ .../user/__tests__/user.selectors.spec.js | 19 +++ .../user/components/login.component.js | 123 +++++++++++++++ .../src/app/modules/user/user.actions.js | 35 +++++ .../src/app/modules/user/user.context.js | 54 +++++++ .../src/app/modules/user/user.hooks.js | 5 + .../src/app/modules/user/user.reducer.js | 25 +++ .../src/app/modules/user/user.selectors.js | 2 + apps/exercise-9/src/app/pages/about.page.js | 31 +++- apps/exercise-9/src/app/pages/article.page.js | 27 ++++ .../exercise-9/src/app/pages/checkout.page.js | 30 ++++ apps/exercise-9/src/app/pages/contact.page.js | 31 +++- apps/exercise-9/src/app/pages/home.page.js | 40 ++--- apps/exercise-9/src/app/pages/login.page.js | 12 ++ .../exercise-9/src/app/utils/context.utils.js | 33 ++++ apps/exercise-9/src/assets/README.md | 37 +++-- apps/exercise-9/src/index.html | 2 +- .../react/hooks/useInputsBadExample.hook.js | 1 - .../components/addressForm.component.js | 17 +-- .../components/paymentForm.component.js | 18 +-- .../checkout/components/review.component.js | 2 - 47 files changed, 1370 insertions(+), 240 deletions(-) delete mode 100644 apps/exercise-9/src/app/app.module.css create mode 100644 apps/exercise-9/src/app/components/layout.component.js create mode 100644 apps/exercise-9/src/app/components/navbar.component.js create mode 100644 apps/exercise-9/src/app/constants/proptypes.constants.js create mode 100644 apps/exercise-9/src/app/hooks/useInput.hook.js create mode 100644 apps/exercise-9/src/app/modules/articles/__tests__/articles.actions.spec.js create mode 100644 apps/exercise-9/src/app/modules/articles/__tests__/articles.reducer.spec.js create mode 100644 apps/exercise-9/src/app/modules/articles/articles.actions.js create mode 100644 apps/exercise-9/src/app/modules/articles/articles.context.js create mode 100644 apps/exercise-9/src/app/modules/articles/articles.reducer.js create mode 100644 apps/exercise-9/src/app/modules/articles/articles.selectors.js create mode 100644 apps/exercise-9/src/app/modules/articles/components/article.component.js create mode 100644 apps/exercise-9/src/app/modules/articles/components/articleCard.component.js create mode 100644 apps/exercise-9/src/app/modules/articles/components/articlesList.component.js create mode 100644 apps/exercise-9/src/app/modules/cart/cart.actions.js create mode 100644 apps/exercise-9/src/app/modules/cart/cart.context.js create mode 100644 apps/exercise-9/src/app/modules/cart/cart.reducer.js create mode 100644 apps/exercise-9/src/app/modules/cart/components/cart.component.js create mode 100644 apps/exercise-9/src/app/modules/cart/components/cartLayout.component.js create mode 100644 apps/exercise-9/src/app/modules/routing/components/routes.component.js create mode 100644 apps/exercise-9/src/app/modules/routing/routing.constants.js create mode 100644 apps/exercise-9/src/app/modules/routing/routing.hooks.js create mode 100644 apps/exercise-9/src/app/modules/user/__tests__/user.actions.spec.js create mode 100644 apps/exercise-9/src/app/modules/user/__tests__/user.context.spec.js create mode 100644 apps/exercise-9/src/app/modules/user/__tests__/user.reducer.spec.js create mode 100644 apps/exercise-9/src/app/modules/user/__tests__/user.selectors.spec.js create mode 100644 apps/exercise-9/src/app/modules/user/components/login.component.js create mode 100644 apps/exercise-9/src/app/modules/user/user.actions.js create mode 100644 apps/exercise-9/src/app/modules/user/user.context.js create mode 100644 apps/exercise-9/src/app/modules/user/user.hooks.js create mode 100644 apps/exercise-9/src/app/modules/user/user.reducer.js create mode 100644 apps/exercise-9/src/app/modules/user/user.selectors.js create mode 100644 apps/exercise-9/src/app/pages/article.page.js create mode 100644 apps/exercise-9/src/app/pages/checkout.page.js create mode 100644 apps/exercise-9/src/app/pages/login.page.js create mode 100644 apps/exercise-9/src/app/utils/context.utils.js diff --git a/README.md b/README.md index 70a4286..efcb57a 100644 --- a/README.md +++ b/README.md @@ -256,3 +256,7 @@ If you want to add translations to teach this course in your language of choice - Check index.html exercises versions - Remove babel warnings - Finalize instructions +- Move test validation +- Create checkout scenario +- modify App.js to app.tsx in readmes +- diff: git diff --no-index --color "apps/exercise-8" "apps/exercise-9" diff --git a/apps/exercise-9/src/app/app.module.css b/apps/exercise-9/src/app/app.module.css deleted file mode 100644 index 04d9c84..0000000 --- a/apps/exercise-9/src/app/app.module.css +++ /dev/null @@ -1,128 +0,0 @@ -.app { - font-family: sans-serif; - min-width: 300px; - max-width: 600px; - margin: 50px auto; -} - -.app :global(.gutter-left) { - margin-left: 9px; -} - -.app :global(.col-span-2) { - grid-column: span 2; -} - -.app :global(.flex) { - display: flex; - align-items: center; - justify-content: center; -} - -.app :global(header) { - background-color: #143055; - color: white; - padding: 5px; - border-radius: 3px; -} - -.app :global(main) { - padding: 0 36px; -} - -.app :global(p) { - text-align: center; -} - -.app :global(h1) { - text-align: center; - margin-left: 18px; - font-size: 24px; -} - -.app :global(h2) { - text-align: center; - font-size: 20px; - margin: 40px 0 10px 0; -} - -.app :global(.resources) { - text-align: center; - list-style: none; - padding: 0; - display: grid; - grid-gap: 9px; - grid-template-columns: 1fr 1fr; -} - -.app :global(.resource) { - color: #0094ba; - height: 36px; - background-color: rgba(0, 0, 0, 0); - border: 1px solid rgba(0, 0, 0, 0.12); - border-radius: 4px; - padding: 3px 9px; - text-decoration: none; -} - -.app :global(.resource:hover) { - background-color: rgba(68, 138, 255, 0.04); -} - -.app :global(pre) { - padding: 9px; - border-radius: 4px; - background-color: black; - color: #eee; -} - -.app :global(details) { - border-radius: 4px; - color: #333; - background-color: rgba(0, 0, 0, 0); - border: 1px solid rgba(0, 0, 0, 0.12); - padding: 3px 9px; - margin-bottom: 9px; -} - -.app :global(summary) { - outline: none; - height: 36px; - line-height: 36px; -} - -.app :global(.github-star-container) { - margin-top: 12px; - line-height: 20px; -} - -.app :global(.github-star-container a) { - display: flex; - align-items: center; - text-decoration: none; - color: #333; -} - -.app :global(.github-star-badge) { - color: #24292e; - display: flex; - align-items: center; - font-size: 12px; - padding: 3px 10px; - border: 1px solid rgba(27, 31, 35, 0.2); - border-radius: 3px; - background-image: linear-gradient(-180deg, #fafbfc, #eff3f6 90%); - margin-left: 4px; - font-weight: 600; -} - -.app :global(.github-star-badge:hover) { - background-image: linear-gradient(-180deg, #f0f3f6, #e6ebf1 90%); - border-color: rgba(27, 31, 35, 0.35); - background-position: -0.5em; -} -.app :global(.github-star-badge .material-icons) { - height: 16px; - width: 16px; - margin-right: 4px; -} diff --git a/apps/exercise-9/src/app/app.tsx b/apps/exercise-9/src/app/app.tsx index 1611278..5abb118 100644 --- a/apps/exercise-9/src/app/app.tsx +++ b/apps/exercise-9/src/app/app.tsx @@ -1,24 +1,16 @@ import React from 'react'; -import { Switch, Route, BrowserRouter as Router } from 'react-router-dom'; +import { BrowserRouter as Router } from 'react-router-dom'; -import { HomePage } from './pages/home.page'; -import { AboutPage } from './pages/about.page'; -import { ContactPage } from './pages/contact.page'; +import { UserProvider } from './modules/user/user.context'; + +import { AppRoutes } from './modules/routing/components/routes.component'; export default function App() { return ( - - - - - - - - - - - - - + + + + + ); } diff --git a/apps/exercise-9/src/app/components/layout.component.js b/apps/exercise-9/src/app/components/layout.component.js new file mode 100644 index 0000000..d8bede0 --- /dev/null +++ b/apps/exercise-9/src/app/components/layout.component.js @@ -0,0 +1,29 @@ +import React from 'react'; + +import Container from '@material-ui/core/Container'; +import { makeStyles } from '@material-ui/styles'; + +import NavBar from './navbar.component'; + +import { CHILDREN_PROP_TYPES } from '../constants/proptypes.constants'; + +const useStyles = makeStyles({ + container: { + marginTop: '2em', + }, +}); + +export const Layout = ({ children }) => { + const classes = useStyles(); + + return ( + <> + + {children} + + ); +}; + +Layout.propTypes = { + children: CHILDREN_PROP_TYPES, +}; diff --git a/apps/exercise-9/src/app/components/navbar.component.js b/apps/exercise-9/src/app/components/navbar.component.js new file mode 100644 index 0000000..629c19c --- /dev/null +++ b/apps/exercise-9/src/app/components/navbar.component.js @@ -0,0 +1,142 @@ +import React from 'react'; +import { Link, useHistory } from 'react-router-dom'; +import classnames from 'classnames'; + +import { makeStyles } from '@material-ui/core/styles'; +import AppBar from '@material-ui/core/AppBar'; +import Toolbar from '@material-ui/core/Toolbar'; +import Typography from '@material-ui/core/Typography'; +import IconButton from '@material-ui/core/IconButton'; +import MenuIcon from '@material-ui/icons/Menu'; +import MenuItem from '@material-ui/core/MenuItem'; +import Menu from '@material-ui/core/Menu'; +import { PowerSettingsNewOutlined } from '@material-ui/icons'; + +import { ROUTES_PATHS_BY_NAMES } from '../modules/routing/routing.constants'; +import { useUser } from '../modules/user/user.context'; +import { isUserConnected } from '../modules/user/user.selectors'; +import { logout } from '../modules/user/user.actions'; + +const useStyles = makeStyles(theme => ({ + root: { + flexGrow: 1, + }, + menuButton: { + transition: 'all 0.5s', + marginRight: theme.spacing(2), + }, + loginButton: { + color: theme.palette.success.main, + '&:hover': { + background: theme.palette.error.main, + color: 'white', + }, + }, + logoutButton: { + color: theme.palette.error.main, + '&:hover': { + background: theme.palette.success.main, + color: 'white', + }, + }, + title: { + flexGrow: 1, + }, +})); + +export default function NavBar() { + const classes = useStyles(); + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const [userState, dispatch] = useUser(); + const isConnected = isUserConnected(userState); + const { push } = useHistory(); + + const handleMenu = event => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const logInAndOut = () => { + isConnected ? dispatch(logout()) : push(ROUTES_PATHS_BY_NAMES.login); + }; + + return ( + + + + Shopping App + + + + +
+ + + + + + Home + + + Contact + + + About + + +
+
+
+ ); +} diff --git a/apps/exercise-9/src/app/constants/proptypes.constants.js b/apps/exercise-9/src/app/constants/proptypes.constants.js new file mode 100644 index 0000000..bfe9d9a --- /dev/null +++ b/apps/exercise-9/src/app/constants/proptypes.constants.js @@ -0,0 +1,7 @@ +import PropTypes from 'prop-types'; + +export const CHILDREN_PROP_TYPES = PropTypes.oneOfType([ + PropTypes.array.isRequired, + PropTypes.object, + PropTypes.element, +]).isRequired; diff --git a/apps/exercise-9/src/app/hooks/useInput.hook.js b/apps/exercise-9/src/app/hooks/useInput.hook.js new file mode 100644 index 0000000..3b53d0b --- /dev/null +++ b/apps/exercise-9/src/app/hooks/useInput.hook.js @@ -0,0 +1,9 @@ +import { useState } from 'react'; + +export const useInput = () => { + const [inputValue, setInputValue] = useState(''); + + const handleChange = e => setInputValue(e.target.value); + + return [inputValue, handleChange]; +}; diff --git a/apps/exercise-9/src/app/modules/articles/__tests__/articles.actions.spec.js b/apps/exercise-9/src/app/modules/articles/__tests__/articles.actions.spec.js new file mode 100644 index 0000000..9120274 --- /dev/null +++ b/apps/exercise-9/src/app/modules/articles/__tests__/articles.actions.spec.js @@ -0,0 +1,20 @@ +import { RECEIVED_ARTICLES, requestArticles } from '../articles.actions'; + +jest.mock('@react-course-v2/api', () => ({ + getArticles: jest.fn().mockResolvedValue('foo'), +})); + +describe('articles.actions', () => { + let dispatch; + beforeEach(() => { + dispatch = jest.fn(); + }); + + it('should dispatch getArticles result', async () => { + await requestArticles()(dispatch); + expect(dispatch).toBeCalledWith({ + type: RECEIVED_ARTICLES, + articles: 'foo', + }); + }); +}); diff --git a/apps/exercise-9/src/app/modules/articles/__tests__/articles.reducer.spec.js b/apps/exercise-9/src/app/modules/articles/__tests__/articles.reducer.spec.js new file mode 100644 index 0000000..4a9dfc1 --- /dev/null +++ b/apps/exercise-9/src/app/modules/articles/__tests__/articles.reducer.spec.js @@ -0,0 +1,36 @@ +import { RECEIVED_ARTICLES } from '../articles.actions'; +import { articlesReducer, initialState } from '../articles.reducer'; + +describe('articles.reducer', () => { + it('should set articles in the state', () => { + expect( + articlesReducer(initialState, { + type: RECEIVED_ARTICLES, + articles: [1, 2, 3], + }), + ).toMatchObject({ + ...initialState, + articles: [1, 2, 3], + }); + }); + + it('should spread the articles with state ones', () => { + const state = { + ...initialState, + articles: [1, 2, 3], + }; + + expect( + articlesReducer(state, { type: RECEIVED_ARTICLES, articles: [1, 2, 3] }), + ).toMatchObject({ + ...initialState, + articles: [1, 2, 3, 1, 2, 3], + }); + }); + + it('should throw when not passed articles iterable', () => { + expect(() => + articlesReducer(initialState, { type: RECEIVED_ARTICLES }), + ).toThrow(); + }); +}); diff --git a/apps/exercise-9/src/app/modules/articles/articles.actions.js b/apps/exercise-9/src/app/modules/articles/articles.actions.js new file mode 100644 index 0000000..f4cc0e3 --- /dev/null +++ b/apps/exercise-9/src/app/modules/articles/articles.actions.js @@ -0,0 +1,9 @@ +import { getArticles } from '@react-course-v2/api'; + +export const RECEIVED_ARTICLES = 'articles/RECEIVED_ARTICLES'; + +export const requestArticles = () => async dispatch => { + const articles = await getArticles(); + + return dispatch({ type: RECEIVED_ARTICLES, articles }); +}; diff --git a/apps/exercise-9/src/app/modules/articles/articles.context.js b/apps/exercise-9/src/app/modules/articles/articles.context.js new file mode 100644 index 0000000..3f16199 --- /dev/null +++ b/apps/exercise-9/src/app/modules/articles/articles.context.js @@ -0,0 +1,52 @@ +import React from 'react'; + +import { articlesReducer, initialState } from './articles.reducer'; + +import { dispatchThunk } from '../../utils/context.utils'; +import { CHILDREN_PROP_TYPES } from '../../constants/proptypes.constants'; + +const ArticlesStateContext = React.createContext(); +const ArticlesDispatchContext = React.createContext(); + +const ArticlesProvider = ({ children }) => { + const [state, dispatch] = React.useReducer(articlesReducer, initialState); + const getState = React.useCallback(() => state, [state]); + + return ( + + + {children} + + + ); +}; + +ArticlesProvider.propTypes = { + children: CHILDREN_PROP_TYPES, +}; + +function useArticlesState() { + const context = React.useContext(ArticlesStateContext); + if (context === undefined) { + throw new Error('useArticlesState must be used within a ArticlesProvider'); + } + return context; +} + +function useArticlesDispatch() { + const context = React.useContext(ArticlesDispatchContext); + if (context === undefined) { + throw new Error( + 'useArticlesDispatch must be used within a ArticlesProvider', + ); + } + return context; +} + +function useArticles() { + return [useArticlesState(), useArticlesDispatch()]; +} + +export { ArticlesProvider, useArticles, useArticlesState, useArticlesDispatch }; diff --git a/apps/exercise-9/src/app/modules/articles/articles.reducer.js b/apps/exercise-9/src/app/modules/articles/articles.reducer.js new file mode 100644 index 0000000..b6518e5 --- /dev/null +++ b/apps/exercise-9/src/app/modules/articles/articles.reducer.js @@ -0,0 +1,16 @@ +import { RECEIVED_ARTICLES } from './articles.actions'; + +export const initialState = { + articles: [], +}; + +export const articlesReducer = (state, action) => { + switch (action.type) { + case RECEIVED_ARTICLES: { + return { ...state, articles: [...state.articles, ...action.articles] }; + } + default: { + throw new Error(`Unhandled action type: ${action.type}`); + } + } +}; diff --git a/apps/exercise-9/src/app/modules/articles/articles.selectors.js b/apps/exercise-9/src/app/modules/articles/articles.selectors.js new file mode 100644 index 0000000..a5ec396 --- /dev/null +++ b/apps/exercise-9/src/app/modules/articles/articles.selectors.js @@ -0,0 +1,10 @@ +import { useArticles } from './articles.context'; +import { requestArticles } from './articles.actions'; +import { useSelector } from '../../utils/context.utils'; + +export const useArticlesSelector = () => + useSelector(useArticles, ({ articles }) => articles, { + shouldFetch: true, + fetchCondition: articles => articles.length === 0, + fetchAction: requestArticles, + }); diff --git a/apps/exercise-9/src/app/modules/articles/components/article.component.js b/apps/exercise-9/src/app/modules/articles/components/article.component.js new file mode 100644 index 0000000..4ac07c4 --- /dev/null +++ b/apps/exercise-9/src/app/modules/articles/components/article.component.js @@ -0,0 +1,16 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { ArticleCard } from './articleCard.component'; +import { useArticlesSelector } from '../articles.selectors'; + +export const Article = ({ id }) => { + const articles = useArticlesSelector(); + const article = articles.find(item => item.slug === id); + + return article ? : null; +}; + +Article.propTypes = { + id: PropTypes.string.isRequired, +}; diff --git a/apps/exercise-9/src/app/modules/articles/components/articleCard.component.js b/apps/exercise-9/src/app/modules/articles/components/articleCard.component.js new file mode 100644 index 0000000..136d3d1 --- /dev/null +++ b/apps/exercise-9/src/app/modules/articles/components/articleCard.component.js @@ -0,0 +1,90 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; + +import Button from '@material-ui/core/Button'; +import Card from '@material-ui/core/Card'; +import CardActions from '@material-ui/core/CardActions'; +import CardContent from '@material-ui/core/CardContent'; +import CardMedia from '@material-ui/core/CardMedia'; +import Grid from '@material-ui/core/Grid'; +import Typography from '@material-ui/core/Typography'; + +import { makeStyles } from '@material-ui/core/styles'; +import { addToCart } from '../../cart/cart.actions'; +import { useCart } from '../../cart/cart.context'; + +const useStyles = makeStyles({ + card: { + height: '100%', + display: 'flex', + flexDirection: 'column', + }, + cardMedia: { + paddingTop: '56.25%', // 16:9 + }, + cardContent: { + flexGrow: 1, + }, + cardDescription: { + display: 'flex', + justifyContent: 'space-between', + }, +}); + +export function ArticleCard({ article }) { + const { name, year, image, slug, price } = article; + const classes = useStyles(); + const [, dispatch] = useCart(); + + const dispatchAddToCart = () => dispatch(addToCart(article)); + + return ( + + + + + + {name} + +
+ {year} + {price} $ +
+
+ + + + +
+
+ ); +} + +ArticleCard.propTypes = { + article: PropTypes.shape({ + name: PropTypes.string.isRequired, + year: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + image: PropTypes.string.isRequired, + slug: PropTypes.string.isRequired, + price: PropTypes.number.isRequired, + }).isRequired, +}; diff --git a/apps/exercise-9/src/app/modules/articles/components/articlesList.component.js b/apps/exercise-9/src/app/modules/articles/components/articlesList.component.js new file mode 100644 index 0000000..fa8e8a5 --- /dev/null +++ b/apps/exercise-9/src/app/modules/articles/components/articlesList.component.js @@ -0,0 +1,19 @@ +import React from 'react'; + +import Grid from '@material-ui/core/Grid'; + +import { ArticleCard } from './articleCard.component'; + +import { useArticlesSelector } from '../articles.selectors'; + +export function ArticlesList() { + const articles = useArticlesSelector(); + + return ( + + {articles.map(article => ( + + ))} + + ); +} diff --git a/apps/exercise-9/src/app/modules/cart/cart.actions.js b/apps/exercise-9/src/app/modules/cart/cart.actions.js new file mode 100644 index 0000000..e7239d9 --- /dev/null +++ b/apps/exercise-9/src/app/modules/cart/cart.actions.js @@ -0,0 +1,6 @@ +export const ADD_TO_CART = 'cart/ADD_TO_CART'; +export const REMOVE_FROM_CART = 'cart/REMOVE_FROM_CART'; + +export const addToCart = article => ({ type: ADD_TO_CART, article }); + +export const removeFromCart = id => ({ type: REMOVE_FROM_CART, id }); diff --git a/apps/exercise-9/src/app/modules/cart/cart.context.js b/apps/exercise-9/src/app/modules/cart/cart.context.js new file mode 100644 index 0000000..5aa097c --- /dev/null +++ b/apps/exercise-9/src/app/modules/cart/cart.context.js @@ -0,0 +1,48 @@ +import React from 'react'; + +import { cartReducer, initialState } from './cart.reducer'; + +import { dispatchThunk } from '../../utils/context.utils'; +import { CHILDREN_PROP_TYPES } from '../../constants/proptypes.constants'; + +const CartStateContext = React.createContext(); +const CartDispatchContext = React.createContext(); + +const CartProvider = ({ children }) => { + const [state, dispatch] = React.useReducer(cartReducer, initialState); + const getState = React.useCallback(() => state, [state]); + + return ( + + + {children} + + + ); +}; + +CartProvider.propTypes = { + children: CHILDREN_PROP_TYPES, +}; + +function useCartState() { + const context = React.useContext(CartStateContext); + if (context === undefined) { + throw new Error('useCartState must be used within a CartProvider'); + } + return context; +} + +function useCartDispatch() { + const context = React.useContext(CartDispatchContext); + if (context === undefined) { + throw new Error('useCartDispatch must be used within a CartProvider'); + } + return context; +} + +function useCart() { + return [useCartState(), useCartDispatch()]; +} + +export { CartProvider, useCart, useCartState, useCartDispatch }; diff --git a/apps/exercise-9/src/app/modules/cart/cart.reducer.js b/apps/exercise-9/src/app/modules/cart/cart.reducer.js new file mode 100644 index 0000000..dabcb26 --- /dev/null +++ b/apps/exercise-9/src/app/modules/cart/cart.reducer.js @@ -0,0 +1,75 @@ +import { ADD_TO_CART, REMOVE_FROM_CART } from './cart.actions'; + +export const initialState = { + articles: {}, + total: 0, +}; + +export const cartReducer = (state, action) => { + switch (action.type) { + case ADD_TO_CART: { + const { id } = action.article; + + // It doesn't already exist in the cart articles + if (!state.articles[id]) { + return { + ...state, + articles: { ...state.articles, [id]: action.article }, + total: state.total + action.article.price, + }; + } + + // Now, we know we have at least one occurrence of the current article in the cart + const occurrences = state.articles[id].occurrences; + + const incrementedArticle = { + ...action.article, + // if it's undefined we haven't set it yet because we only have one, fallback on 2 + occurrences: occurrences ? occurrences + 1 : 2, + }; + + return { + ...state, + articles: { ...state.articles, [id]: incrementedArticle }, + total: state.total + action.article.price, + }; + } + + case REMOVE_FROM_CART: { + const targetArticle = Object.values(state.articles).find( + article => article.id === action.id, + ); + const targetOccurrences = targetArticle.occurrences; + const isNumber = typeof targetOccurrences === 'number'; + const isSuperiorToOne = targetOccurrences > 1; + const shouldDecrement = isNumber && isSuperiorToOne; + + if (shouldDecrement) { + return { + ...state, + articles: { + ...state.articles, + [action.id]: { + ...targetArticle, + occurrences: targetOccurrences - 1, + }, + }, + total: state.total - targetArticle.price, + }; + } + + return { + ...state, + articles: Object.keys(state.articles).reduce( + (acc, curr) => + action.id === curr ? acc : { ...acc, [curr]: state.articles[curr] }, + {}, + ), + total: state.total - targetArticle.price, + }; + } + default: { + throw new Error(`Unhandled action type: ${action.type}`); + } + } +}; diff --git a/apps/exercise-9/src/app/modules/cart/components/cart.component.js b/apps/exercise-9/src/app/modules/cart/components/cart.component.js new file mode 100644 index 0000000..d1f5ec3 --- /dev/null +++ b/apps/exercise-9/src/app/modules/cart/components/cart.component.js @@ -0,0 +1,110 @@ +import React, { useCallback } from 'react'; +import { Link } from 'react-router-dom'; + +import Button from '@material-ui/core/Button'; +import Card from '@material-ui/core/Card'; +import CardActions from '@material-ui/core/CardActions'; +import CardContent from '@material-ui/core/CardContent'; +import Typography from '@material-ui/core/Typography'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemText from '@material-ui/core/ListItemText'; +import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; +import IconButton from '@material-ui/core/IconButton'; + +import DeleteIcon from '@material-ui/icons/RemoveCircle'; + +import { makeStyles } from '@material-ui/core/styles'; + +import { useCart } from '../cart.context'; +import { removeFromCart } from '../cart.actions'; +import { ROUTES_PATHS_BY_NAMES } from '../../routing/routing.constants'; + +const useStyles = makeStyles({ + card: { + display: 'flex', + flexDirection: 'column', + position: 'sticky', + top: '20px', + }, + cardContent: { + flexGrow: 1, + }, + listItem: { + borderBottom: '1px solid lightgray', + textDecoration: 'none', + color: 'black', + }, +}); + +export function Cart() { + const classes = useStyles(); + const [{ articles, total }, dispatch] = useCart(); + + const removeItemFromList = useCallback( + id => () => dispatch(removeFromCart(id)), + [dispatch], + ); + + return ( + + + + Cart + + + {Object.values(articles).map((article, index) => ( + + + + + + + + + + ))} + + + Total Price: {total} $ + + + + + + + ); +} diff --git a/apps/exercise-9/src/app/modules/cart/components/cartLayout.component.js b/apps/exercise-9/src/app/modules/cart/components/cartLayout.component.js new file mode 100644 index 0000000..af37943 --- /dev/null +++ b/apps/exercise-9/src/app/modules/cart/components/cartLayout.component.js @@ -0,0 +1,22 @@ +import React from 'react'; + +import Grid from '@material-ui/core/Grid'; +import { CHILDREN_PROP_TYPES } from '../../../constants/proptypes.constants'; +import { Cart } from './cart.component'; + +export function CartLayout({ children }) { + return ( + + + {children} + + + + + + ); +} + +CartLayout.propTypes = { + children: CHILDREN_PROP_TYPES, +}; diff --git a/apps/exercise-9/src/app/modules/routing/components/routes.component.js b/apps/exercise-9/src/app/modules/routing/components/routes.component.js new file mode 100644 index 0000000..7aef6e0 --- /dev/null +++ b/apps/exercise-9/src/app/modules/routing/components/routes.component.js @@ -0,0 +1,50 @@ +import React from 'react'; +import { Switch, Route } from 'react-router-dom'; + +import { CartProvider } from '../../cart/cart.context'; +import { ArticlesProvider } from '../../articles/articles.context'; + +import { HomePage } from '../../../pages/home.page'; +import { ArticlePage } from '../../../pages/article.page'; +import { AboutPage } from '../../../pages/about.page'; +import { LoginPage } from '../../../pages/login.page'; +import { ContactPage } from '../../../pages/contact.page'; +import { CheckoutPage } from '../../../pages/checkout.page'; + +import { ROUTES_PATHS_BY_NAMES } from '../routing.constants'; +import { useLoginRedirect } from '../routing.hooks'; + +export function AppRoutes() { + useLoginRedirect(); + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/exercise-9/src/app/modules/routing/routing.constants.js b/apps/exercise-9/src/app/modules/routing/routing.constants.js new file mode 100644 index 0000000..b5e0e63 --- /dev/null +++ b/apps/exercise-9/src/app/modules/routing/routing.constants.js @@ -0,0 +1,10 @@ +export const ROUTES_PATHS_BY_NAMES = { + home: '/', + login: '/login', + about: '/about', + contact: '/contact', + article: '/articles/:id', + checkout: '/checkout', +}; + +export const PROTECTED_PATHS = [ROUTES_PATHS_BY_NAMES.checkout]; diff --git a/apps/exercise-9/src/app/modules/routing/routing.hooks.js b/apps/exercise-9/src/app/modules/routing/routing.hooks.js new file mode 100644 index 0000000..d47402b --- /dev/null +++ b/apps/exercise-9/src/app/modules/routing/routing.hooks.js @@ -0,0 +1,35 @@ +import { useEffect, useState, useMemo } from 'react'; +import { useHistory, useLocation } from 'react-router-dom'; + +import { useUserState } from '../user/user.context'; +import { PROTECTED_PATHS, ROUTES_PATHS_BY_NAMES } from './routing.constants'; +import { isUserConnected } from '../user/user.selectors'; + +const { login: loginPath, home: homePath } = ROUTES_PATHS_BY_NAMES; + +export const useLoginRedirect = () => { + const state = useUserState(); + const isConnected = isUserConnected(state); + const { pathname } = useLocation(); + const { push } = useHistory(); + + const [initialRoute, setInitialRoute] = useState( + pathname === loginPath ? homePath : pathname, + ); + + const isProtectedRoute = PROTECTED_PATHS.includes(pathname); + const isLoginRoute = useMemo(() => pathname === loginPath, [pathname]); + + useEffect(() => { + if (isConnected && isLoginRoute) { + push(initialRoute); + } + }, [isConnected, push, isLoginRoute, initialRoute]); + + useEffect(() => { + if (!isConnected && isProtectedRoute) { + setInitialRoute(pathname); + push(loginPath); + } + }, [isConnected, push, pathname, isProtectedRoute]); +}; diff --git a/apps/exercise-9/src/app/modules/user/__tests__/user.actions.spec.js b/apps/exercise-9/src/app/modules/user/__tests__/user.actions.spec.js new file mode 100644 index 0000000..c5971be --- /dev/null +++ b/apps/exercise-9/src/app/modules/user/__tests__/user.actions.spec.js @@ -0,0 +1,45 @@ +import { signIn, signOut } from '@react-course-v2/api'; +import { LOGIN, login, LOGOUT, logout } from '../user.actions'; + +const user = { id: 'xyz', mail: 'foo@bar.com', name: 'Foo Bar' }; + +jest.mock('@react-course-v2/api'); + +describe('user.actions', () => { + let dispatch, getState; + beforeEach(() => { + jest.clearAllMocks(); + dispatch = jest.fn(); + getState = jest.fn(); + signIn.mockResolvedValue(user); + signOut.mockReturnValue(user); + }); + + describe('login', () => { + it('should dispatch LOGIN', async () => { + await login('foo', 'bar')(dispatch, getState); + return expect(dispatch).toBeCalledWith({ type: LOGIN, user }); + }); + + it('should call signIn', async () => { + await login('foo', 'bar')(dispatch, getState); + return expect(signIn).toBeCalledWith(['foo', 'bar']); + }); + }); + + describe('logout', () => { + beforeEach(() => { + getState.mockReturnValueOnce({ user }); + }); + + it('should dispatch LOGOUT', async () => { + await logout()(dispatch, getState); + return expect(dispatch).toBeCalledWith({ type: LOGOUT, user }); + }); + + it('should call signOut', async () => { + await logout()(dispatch, getState); + return expect(signOut).toBeCalledWith(user); + }); + }); +}); diff --git a/apps/exercise-9/src/app/modules/user/__tests__/user.context.spec.js b/apps/exercise-9/src/app/modules/user/__tests__/user.context.spec.js new file mode 100644 index 0000000..9fcfc5e --- /dev/null +++ b/apps/exercise-9/src/app/modules/user/__tests__/user.context.spec.js @@ -0,0 +1,23 @@ +import React from 'react'; + +import { useUser, useUserState, useUserDispatch } from '../user.context'; + +describe('user.context', () => { + describe('useUserDispatch', () => { + it('should be defined', () => { + expect(typeof useUserDispatch).toBe('function'); + }); + }); + + describe('useUserState', () => { + it('should be defined', () => { + expect(typeof useUserState).toBe('function'); + }); + }); + + describe('useUser', () => { + it('should be defined', () => { + expect(typeof useUser).toBe('function'); + }); + }); +}); diff --git a/apps/exercise-9/src/app/modules/user/__tests__/user.reducer.spec.js b/apps/exercise-9/src/app/modules/user/__tests__/user.reducer.spec.js new file mode 100644 index 0000000..3a23a5d --- /dev/null +++ b/apps/exercise-9/src/app/modules/user/__tests__/user.reducer.spec.js @@ -0,0 +1,29 @@ +import { LOGIN, LOGOUT } from '../user.actions'; +import { userReducer, initialState } from '../user.reducer'; + +describe('user.reducer', () => { + describe('LOGIN', () => { + it('should set user in the state', () => { + expect( + userReducer(initialState, { type: LOGIN, user: { id: 'foo' } }), + ).toMatchObject({ + ...initialState, + user: { id: 'foo' }, + }); + }); + }); + + describe('LOGOUT', () => { + it('should set user to null', () => { + const state = { + ...initialState, + user: { id: 'foo' }, + }; + + expect(userReducer(state, { type: LOGOUT, id: 'foo' })).toMatchObject({ + ...state, + user: null, + }); + }); + }); +}); diff --git a/apps/exercise-9/src/app/modules/user/__tests__/user.selectors.spec.js b/apps/exercise-9/src/app/modules/user/__tests__/user.selectors.spec.js new file mode 100644 index 0000000..5a96502 --- /dev/null +++ b/apps/exercise-9/src/app/modules/user/__tests__/user.selectors.spec.js @@ -0,0 +1,19 @@ +import { getUser, isUserConnected } from '../user.selectors'; + +describe('user.selectors', () => { + describe('getUser', () => { + it('should return user', () => { + expect(getUser({ user: { foo: 'bar' } })).toEqual({ foo: 'bar' }); + }); + }); + + describe('isUserConnected', () => { + it('should return false when user is falsy', () => { + expect(isUserConnected({ user: null })).toBeFalsy(); + }); + + it('should return true when user is truthy', () => { + expect(isUserConnected({ user: {} })).toBeTruthy(); + }); + }); +}); diff --git a/apps/exercise-9/src/app/modules/user/components/login.component.js b/apps/exercise-9/src/app/modules/user/components/login.component.js new file mode 100644 index 0000000..ddfbd9b --- /dev/null +++ b/apps/exercise-9/src/app/modules/user/components/login.component.js @@ -0,0 +1,123 @@ +import React from 'react'; + +import Avatar from '@material-ui/core/Avatar'; +import Button from '@material-ui/core/Button'; +import TextField from '@material-ui/core/TextField'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Checkbox from '@material-ui/core/Checkbox'; +import Link from '@material-ui/core/Link'; +import Grid from '@material-ui/core/Grid'; +import LockOutlinedIcon from '@material-ui/icons/LockOutlined'; +import Typography from '@material-ui/core/Typography'; +import { makeStyles } from '@material-ui/core/styles'; + +import { useUserDispatch } from '../user.context'; +import { login } from '../user.actions'; +import { useInput } from '../../../hooks/useInput.hook'; +import { Container } from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + paper: { + marginTop: theme.spacing(8), + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + }, + avatar: { + margin: theme.spacing(1), + backgroundColor: theme.palette.secondary.main, + }, + form: { + width: '100%', // Fix IE 11 issue. + marginTop: theme.spacing(1), + }, + submit: { + margin: theme.spacing(3, 0, 2), + }, +})); + +export const Login = () => { + const classes = useStyles(); + const dispatch = useUserDispatch(); + + const [email, handleEmailChange] = useInput(); + const [password, handlePasswordChange] = useInput(); + + const handleSubmit = e => { + e.preventDefault(); + dispatch(login(email, password)); + }; + + return ( + +
+ + + + + Sign in + +
+ + + } + label="Remember me" + /> + + + + + Forgot password? + + + + + {"Don't have an account? Sign Up"} + + + + +
+
+ ); +}; diff --git a/apps/exercise-9/src/app/modules/user/user.actions.js b/apps/exercise-9/src/app/modules/user/user.actions.js new file mode 100644 index 0000000..4089a18 --- /dev/null +++ b/apps/exercise-9/src/app/modules/user/user.actions.js @@ -0,0 +1,35 @@ +import { signIn, signOut } from '@react-course-v2/api'; +import { getUser } from './user.selectors'; + +export const LOGIN = 'user/LOGIN'; +export const LOGOUT = 'user/LOGOUT'; + +const encryptUserCredentials = (...args) => [...args]; + +export const login = (email, password) => async dispatch => { + try { + const encryptedUser = encryptUserCredentials(email, password); + const user = await signIn(encryptedUser); + + localStorage.setItem('user', JSON.stringify(user)); + + return dispatch({ type: LOGIN, user }); + } catch (error) { + dispatch({ type: LOGIN, error }); + } +}; + +export const logout = () => async (dispatch, getState) => { + try { + const user = getUser(getState()); + if (!user) return; + + localStorage.removeItem('user'); + + await signOut(user); + + return dispatch({ type: LOGOUT, user }); + } catch (error) { + dispatch({ type: LOGOUT, error }); + } +}; diff --git a/apps/exercise-9/src/app/modules/user/user.context.js b/apps/exercise-9/src/app/modules/user/user.context.js new file mode 100644 index 0000000..de6b9bf --- /dev/null +++ b/apps/exercise-9/src/app/modules/user/user.context.js @@ -0,0 +1,54 @@ +import React from 'react'; + +import { userReducer, initialState } from './user.reducer'; + +import { dispatchThunk } from '../../utils/context.utils'; +import { CHILDREN_PROP_TYPES } from '../../constants/proptypes.constants'; +import { usePersistedUser } from './user.hooks'; + +const UserStateContext = React.createContext(); +const UserDispatchContext = React.createContext(); + +const UserProvider = ({ children }) => { + const user = usePersistedUser(); + const updatedState = user && { user }; + const [state, dispatch] = React.useReducer( + userReducer, + updatedState || initialState, + ); + const getState = React.useCallback(() => state, [state]); + + return ( + + + {children} + + + ); +}; + +UserProvider.propTypes = { + children: CHILDREN_PROP_TYPES, +}; + +function useUserState() { + const context = React.useContext(UserStateContext); + if (context === undefined) { + throw new Error('useUserState must be used within a UserProvider'); + } + return context; +} + +function useUserDispatch() { + const context = React.useContext(UserDispatchContext); + if (context === undefined) { + throw new Error('useUserDispatch must be used within a UserProvider'); + } + return context; +} + +function useUser() { + return [useUserState(), useUserDispatch()]; +} + +export { UserProvider, useUser, useUserState, useUserDispatch }; diff --git a/apps/exercise-9/src/app/modules/user/user.hooks.js b/apps/exercise-9/src/app/modules/user/user.hooks.js new file mode 100644 index 0000000..ba4cad3 --- /dev/null +++ b/apps/exercise-9/src/app/modules/user/user.hooks.js @@ -0,0 +1,5 @@ +export const usePersistedUser = () => { + // You would normally validate the user token here + // and set a new one in case it is not valid anymore + return localStorage.getItem('user'); +}; diff --git a/apps/exercise-9/src/app/modules/user/user.reducer.js b/apps/exercise-9/src/app/modules/user/user.reducer.js new file mode 100644 index 0000000..bb50cc5 --- /dev/null +++ b/apps/exercise-9/src/app/modules/user/user.reducer.js @@ -0,0 +1,25 @@ +import { LOGIN, LOGOUT } from './user.actions'; + +export const initialState = { + user: null, +}; + +export const userReducer = (state, action) => { + if (action.error) { + return { ...state, error: action.error }; + } + + switch (action.type) { + case LOGIN: { + return { ...state, user: action.user }; + } + + case LOGOUT: { + return { ...state, user: null }; + } + + default: { + throw new Error(`Unhandled action type: ${action.type}`); + } + } +}; diff --git a/apps/exercise-9/src/app/modules/user/user.selectors.js b/apps/exercise-9/src/app/modules/user/user.selectors.js new file mode 100644 index 0000000..4d798c1 --- /dev/null +++ b/apps/exercise-9/src/app/modules/user/user.selectors.js @@ -0,0 +1,2 @@ +export const isUserConnected = ({ user }) => !!user; +export const getUser = ({ user }) => user; diff --git a/apps/exercise-9/src/app/pages/about.page.js b/apps/exercise-9/src/app/pages/about.page.js index b1d8f0b..d916d9f 100644 --- a/apps/exercise-9/src/app/pages/about.page.js +++ b/apps/exercise-9/src/app/pages/about.page.js @@ -1,11 +1,26 @@ import React from 'react'; import { Link } from 'react-router-dom'; -export const AboutPage = () => ( -
-

About

- - Return to Home - -
-); +import Button from '@material-ui/core/Button'; +import Box from '@material-ui/core/Box'; + +import { Layout } from '../components/layout.component'; + +export const AboutPage = () => { + return ( + + +

About

+ +
+
+ ); +}; diff --git a/apps/exercise-9/src/app/pages/article.page.js b/apps/exercise-9/src/app/pages/article.page.js new file mode 100644 index 0000000..09193c7 --- /dev/null +++ b/apps/exercise-9/src/app/pages/article.page.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { Link, useParams } from 'react-router-dom'; + +import Button from '@material-ui/core/Button'; +import Box from '@material-ui/core/Box'; + +import { Layout } from '../components/layout.component'; +import { Article } from '../modules/articles/components/article.component'; +import { CartLayout } from '../modules/cart/components/cartLayout.component'; + +export const ArticlePage = () => { + const { id } = useParams(); + + return ( + + +

Article {id}

+ +
+ +
+ + + ); +}; diff --git a/apps/exercise-9/src/app/pages/checkout.page.js b/apps/exercise-9/src/app/pages/checkout.page.js new file mode 100644 index 0000000..4f0be89 --- /dev/null +++ b/apps/exercise-9/src/app/pages/checkout.page.js @@ -0,0 +1,30 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import Button from '@material-ui/core/Button'; +import Box from '@material-ui/core/Box'; + +import { Layout } from '../components/layout.component'; +import { CartLayout } from '../modules/cart/components/cartLayout.component'; + +export const CheckoutPage = () => { + return ( + + +

Checkout

+ +
+ +
Foo page
+
+
+ ); +}; diff --git a/apps/exercise-9/src/app/pages/contact.page.js b/apps/exercise-9/src/app/pages/contact.page.js index 66d243c..4ead657 100644 --- a/apps/exercise-9/src/app/pages/contact.page.js +++ b/apps/exercise-9/src/app/pages/contact.page.js @@ -1,11 +1,26 @@ import React from 'react'; import { Link } from 'react-router-dom'; -export const ContactPage = () => ( -
-

Contact

- - Return to Home - -
-); +import Button from '@material-ui/core/Button'; +import Box from '@material-ui/core/Box'; + +import { Layout } from '../components/layout.component'; + +export const ContactPage = () => { + return ( + + +

Contact

+ +
+
+ ); +}; diff --git a/apps/exercise-9/src/app/pages/home.page.js b/apps/exercise-9/src/app/pages/home.page.js index 081e2d0..9ecdf58 100644 --- a/apps/exercise-9/src/app/pages/home.page.js +++ b/apps/exercise-9/src/app/pages/home.page.js @@ -1,38 +1,16 @@ -import React, { useState, useEffect } from 'react'; -import { Link } from 'react-router-dom'; +import React from 'react'; -import { getArticles } from '@react-course-v2/api'; +import { Layout } from '../components/layout.component'; +import { ArticlesList } from '../modules/articles/components/articlesList.component'; +import { CartLayout } from '../modules/cart/components/cartLayout.component'; export const HomePage = () => { - const [articles, setArticles] = useState([]); - - useEffect(() => { - if (articles.length !== 0) { - return; - } - getArticles().then(setArticles).catch(console.error); - }, [articles]); - return ( -
+

Home Page

- - About Page - - - Contact Page - -
-

Articles

-
    - {articles.length > 0 && - articles.map(({ id, name }) => ( -
  • - {name} -
  • - ))} -
-
-
+ + + + ); }; diff --git a/apps/exercise-9/src/app/pages/login.page.js b/apps/exercise-9/src/app/pages/login.page.js new file mode 100644 index 0000000..97c2092 --- /dev/null +++ b/apps/exercise-9/src/app/pages/login.page.js @@ -0,0 +1,12 @@ +import React from 'react'; + +import { Layout } from '../components/layout.component'; +import { Login } from '../modules/user/components/login.component'; + +export const LoginPage = () => { + return ( + + + + ); +}; diff --git a/apps/exercise-9/src/app/utils/context.utils.js b/apps/exercise-9/src/app/utils/context.utils.js new file mode 100644 index 0000000..8998f4c --- /dev/null +++ b/apps/exercise-9/src/app/utils/context.utils.js @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; + +export const dispatchThunk = (dispatch, getState) => param => { + if (typeof param === 'function') { + return param(dispatch, getState); + } + + return dispatch(param); +}; + +export const useSelector = ( + useReducerHook, + selector = state => state, + { shouldFetch = false, fetchCondition = element => !!element, fetchAction }, +) => { + if (!useReducerHook) { + throw new Error( + 'You need to provide the reducer hook of this resource to get its state and dispatch', + ); + } + + const [state, dispatch] = useReducerHook(); + + const selectedValue = selector(state); + + useEffect(() => { + if (shouldFetch && fetchCondition(selectedValue) && fetchAction) { + dispatch(fetchAction()); + } + }, [dispatch, selectedValue, shouldFetch, fetchCondition, fetchAction]); + + return selectedValue; +}; diff --git a/apps/exercise-9/src/assets/README.md b/apps/exercise-9/src/assets/README.md index e70ea84..1207e71 100644 --- a/apps/exercise-9/src/assets/README.md +++ b/apps/exercise-9/src/assets/README.md @@ -1,20 +1,31 @@ -# 3/ Wrapping pages, building layout with Material-UI +# 9/ Persistance and initialization -| Action | Files | Exports | -| ------ | ---------------------------------- | ------------- | -| Create | src/components/layout.component.js | {Layout} | -| Modify | src/pages/contact.page.js | {ContactPage} | -| Modify | src/pages/about.page.js | {AboutPage} | -| Modify | src/pages/home.page.js | {HomePage} | -| Modify | src/App.js | {App} | +| Action | Files | Exports | +| ------ | -------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| Create | src/hooks/useOnLeave.hook.js | {useOnLeave} | +| Create | src/hooks/useStepperForm.hook.js | {useStepperForm} | +| Create | src/hooks/useStepperFormChild.hook.js | {useStepperFormChild} | +| Modify | src/modules/cart/cart.context.js | {CartProvider, useCart, useCartState, useCartDispatch} | +| Modify | src/modules/cart/cart.actions.js | {restoreCart, addToCart, removeFromCart, ADD_TO_CART, REMOVE_FROM_CART, RESTORE_CART} | +| Modify | src/modules/cart/cart.reducer.js | {initialState, cartReducer} | +| Modify | src/modules/checkout/components/addressForm.component.js | {AddressForm} | +| Modify | src/modules/checkout/components/paymentForm.component.js | {PaymentForm} | +| Modify | src/modules/checkout/components/checkout.component.js | {Checkout} | ## TL;DR -Now we are going to add some structure to the page, we need a page container component that is responsible for displaying the header (navbar) and the body (content) correctly. +Now we want to use an actually working form in the checkout page so we can display a relevant bill (contact details mostly) at the user when the steps are done. ## Step by step -- See the newly created `src/components` directory, with the file `navbar.component.js` from material-ui examples -- Add a `layout.component.js` to the "components" directory, it will export a function `Layout` and directly return a Fragment holding the Navbar and a Material-UI Container rendering children. -- In each page component, replace the top parent div with the Layout Component, it is the pages container -- In about and contact pages, add a Material-UI Box component to wrap the h2 and the Link. Use a MUI ` + )} + + + + + )} + + + ); +} + +export default memo(Checkout); diff --git a/apps/exercise-9/src/app/modules/checkout/checkout.constants.js b/apps/exercise-9/src/app/modules/checkout/checkout.constants.js new file mode 100644 index 0000000..c1959d0 --- /dev/null +++ b/apps/exercise-9/src/app/modules/checkout/checkout.constants.js @@ -0,0 +1,5 @@ +export const SHIPPING = 'Shipping address'; +export const PAYMENT = 'Payment details'; +export const REVIEW = 'Review your order'; + +export const steps = [SHIPPING, PAYMENT, REVIEW]; diff --git a/apps/exercise-9/src/app/modules/checkout/components/addressForm.component.js b/apps/exercise-9/src/app/modules/checkout/components/addressForm.component.js new file mode 100644 index 0000000..40f45c2 --- /dev/null +++ b/apps/exercise-9/src/app/modules/checkout/components/addressForm.component.js @@ -0,0 +1,55 @@ +import React, { memo } from 'react'; + +import Grid from '@material-ui/core/Grid'; +import Typography from '@material-ui/core/Typography'; + +const INPUTS_CONFIG = { + firstName: { + props: { autoComplete: 'given-name', label: 'First name' }, + gridProps: { xs: 12, sm: 6 }, + }, + lastName: { + props: { autoComplete: 'family-name', label: 'Last name' }, + gridProps: { xs: 12, sm: 6 }, + }, + address1: { + props: { autoComplete: 'shipping-address line-1', label: 'Address line 1' }, + gridProps: { xs: 12 }, + }, + address2: { + props: { autoComplete: 'shipping-address line-2', label: 'Address line 2' }, + gridProps: { xs: 12 }, + }, + city: { + props: { autoComplete: 'shipping address-level2', label: 'City' }, + gridProps: { sm: 6, xs: 12 }, + }, + state: { + props: { label: 'Region/State' }, + gridProps: { sm: 6, xs: 12 }, + }, + zip: { + props: { autoComplete: 'shipping postal-code', label: 'Zip code' }, + gridProps: { sm: 6, xs: 12 }, + }, + country: { + props: { autoComplete: 'shipping country', label: 'Country code' }, + gridProps: { xs: 12, sm: 6 }, + }, +}; + +// eslint-disable-next-line +function AddressForm(props) { + return ( + + + Shipping address + + + {/* render inputs here, good luck */} + + + ); +} + +export default memo(AddressForm); diff --git a/apps/exercise-9/src/app/modules/checkout/components/paymentForm.component.js b/apps/exercise-9/src/app/modules/checkout/components/paymentForm.component.js new file mode 100644 index 0000000..7fa1e25 --- /dev/null +++ b/apps/exercise-9/src/app/modules/checkout/components/paymentForm.component.js @@ -0,0 +1,16 @@ +import React from 'react'; +import Typography from '@material-ui/core/Typography'; +import Grid from '@material-ui/core/Grid'; + +export default function PaymentForm(props) { + return ( + + + Payment method + + + {/* render inputs here, good luck */} + + + ); +} diff --git a/apps/exercise-9/src/app/modules/checkout/components/review.component.js b/apps/exercise-9/src/app/modules/checkout/components/review.component.js new file mode 100644 index 0000000..f4a7d2c --- /dev/null +++ b/apps/exercise-9/src/app/modules/checkout/components/review.component.js @@ -0,0 +1,116 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { makeStyles } from '@material-ui/core/styles'; +import Typography from '@material-ui/core/Typography'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemText from '@material-ui/core/ListItemText'; +import Grid from '@material-ui/core/Grid'; + +import { useCart } from '../../cart/cart.context'; +import { PAYMENT, SHIPPING } from '../checkout.constants'; + +const useStyles = makeStyles(theme => ({ + listItem: { + padding: theme.spacing(1, 0), + }, + total: { + fontWeight: 700, + }, + title: { + marginTop: theme.spacing(2), + }, +})); +const defaultObject = {}; + +export const getShippingState = state => { + if (!state[SHIPPING]) return defaultObject; + return state[SHIPPING]; +}; + +export const getPaymentState = state => { + if (!state[PAYMENT]) return defaultObject; + return state[PAYMENT]; +}; + +export default function Review({ formState }) { + const classes = useStyles(); + const [{ articles, total }] = useCart(); + const { firstName, lastName, address1, address2, city, state, zip, country } = + getShippingState(formState); + const { cardName, cardNumber, expDate } = getPaymentState(formState); + + return ( + + + Order summary + + + {Object.values(articles).map(article => ( + + + + $ + {article.occurrences + ? article.occurrences * article.price + : article.price} + + + ))} + + + + + ${total} + + + + + + + {SHIPPING} + + + {firstName} {lastName} + + + {[address1, address2, city, state, zip, country].join(', ')} + + + + + {PAYMENT} + + + + Card Holder + + + {cardName} + + + Card Number + + + {cardNumber} + + + Expires + + + {expDate} + + + + + + ); +} + +Review.propTypes = { + formState: PropTypes.shape({}).isRequired, +}; diff --git a/apps/exercise-9/src/assets/README.md b/apps/exercise-9/src/assets/README.md index 1207e71..6c86c41 100644 --- a/apps/exercise-9/src/assets/README.md +++ b/apps/exercise-9/src/assets/README.md @@ -1,31 +1,26 @@ -# 9/ Persistance and initialization - -| Action | Files | Exports | -| ------ | -------------------------------------------------------- | ------------------------------------------------------------------------------------- | -| Create | src/hooks/useOnLeave.hook.js | {useOnLeave} | -| Create | src/hooks/useStepperForm.hook.js | {useStepperForm} | -| Create | src/hooks/useStepperFormChild.hook.js | {useStepperFormChild} | -| Modify | src/modules/cart/cart.context.js | {CartProvider, useCart, useCartState, useCartDispatch} | -| Modify | src/modules/cart/cart.actions.js | {restoreCart, addToCart, removeFromCart, ADD_TO_CART, REMOVE_FROM_CART, RESTORE_CART} | -| Modify | src/modules/cart/cart.reducer.js | {initialState, cartReducer} | -| Modify | src/modules/checkout/components/addressForm.component.js | {AddressForm} | -| Modify | src/modules/checkout/components/paymentForm.component.js | {PaymentForm} | -| Modify | src/modules/checkout/components/checkout.component.js | {Checkout} | +# 9/ Controlled Forms + +| Action | Files | Exports | +| ------ | -------------------------------------------------------- | ------------- | +| Modify | src/modules/checkout/checkout.component.js | {Checkout} | +| Modify | src/modules/checkout/components/review.component.js | {Review} | +| Modify | src/modules/checkout/components/addressForm.component.js | {AddressForm} | +| Modify | src/modules/checkout/components/paymentForm.component.js | {PaymentForm} | ## TL;DR -Now we want to use an actually working form in the checkout page so we can display a relevant bill (contact details mostly) at the user when the steps are done. +Let's create the controlled forms ! + +The **Stepper** gives a nice UX for combined forms however, outside _Material-UI_ code sample, we need to control every inputs and store their values in order to display it in the **Review** component. +Where would you locate the state then, in the forms' parent **Checkout** ? +What other options do you have ? How would you reduce the re-renders ? ## Step by step ### src/modules/checkout/checkout.component.js -Do this - -### src/modules/checkout/paymentForm.component.js - -Do that +### src/modules/checkout/components/review.component.js -### src/modules/checkout/addressForm.component.js +### src/modules/checkout/components/addressForm.component.js -Do thus +### src/modules/checkout/components/paymentForm.component.js From 7b99bb3d445906500778cc2d57b39b87224760a1 Mon Sep 17 00:00:00 2001 From: hpierre74 Date: Mon, 4 Oct 2021 21:22:10 +0200 Subject: [PATCH 3/4] loul --- .../checkout/components/review.component.js | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/apps/exercise-9/src/app/modules/checkout/components/review.component.js b/apps/exercise-9/src/app/modules/checkout/components/review.component.js index f4a7d2c..e6e8cf8 100644 --- a/apps/exercise-9/src/app/modules/checkout/components/review.component.js +++ b/apps/exercise-9/src/app/modules/checkout/components/review.component.js @@ -22,24 +22,10 @@ const useStyles = makeStyles(theme => ({ marginTop: theme.spacing(2), }, })); -const defaultObject = {}; - -export const getShippingState = state => { - if (!state[SHIPPING]) return defaultObject; - return state[SHIPPING]; -}; - -export const getPaymentState = state => { - if (!state[PAYMENT]) return defaultObject; - return state[PAYMENT]; -}; export default function Review({ formState }) { const classes = useStyles(); const [{ articles, total }] = useCart(); - const { firstName, lastName, address1, address2, city, state, zip, country } = - getShippingState(formState); - const { cardName, cardNumber, expDate } = getPaymentState(formState); return ( From cc4227896ce65740486a37d6e0a2230f3ff05447 Mon Sep 17 00:00:00 2001 From: hpierre74 Date: Sun, 10 Oct 2021 21:44:30 +0200 Subject: [PATCH 4/4] remove some things --- .../modules/checkout/checkout.component.js | 12 ++----- apps/exercise-9/src/assets/README.md | 35 +++++++++++++++++-- .../components/addressForm.component.js | 17 +++++++-- .../components/paymentForm.component.js | 18 ++++++++-- 4 files changed, 65 insertions(+), 17 deletions(-) diff --git a/apps/exercise-9/src/app/modules/checkout/checkout.component.js b/apps/exercise-9/src/app/modules/checkout/checkout.component.js index 1b367fb..925c060 100644 --- a/apps/exercise-9/src/app/modules/checkout/checkout.component.js +++ b/apps/exercise-9/src/app/modules/checkout/checkout.component.js @@ -10,7 +10,6 @@ import Typography from '@material-ui/core/Typography'; import AddressForm from './components/addressForm.component'; import PaymentForm from './components/paymentForm.component'; import Review from './components/review.component'; -import { useStepperForm } from '../../hooks/useStepperForm.hook'; import { SHIPPING, PAYMENT, REVIEW, steps } from './checkout.constants'; const useStyles = makeStyles(theme => ({ @@ -44,7 +43,7 @@ const useStyles = makeStyles(theme => ({ }, })); -function getStepContent(step, [formState, setFormState]) { +function getStepContent(step) { switch (step) { case SHIPPING: return ; @@ -57,16 +56,9 @@ function getStepContent(step, [formState, setFormState]) { } } -export const initialFormState = { - [SHIPPING]: {}, - [PAYMENT]: {}, - [REVIEW]: {}, -}; - function Checkout() { const classes = useStyles(); const [activeStep, setActiveStep] = React.useState(0); - const stepperForm = useStepperForm(initialFormState); const handleNext = () => { setActiveStep(activeStep + 1); @@ -102,7 +94,7 @@ function Checkout() { ) : ( - {getStepContent(steps[activeStep], stepperForm)} + {getStepContent(steps[activeStep])}
{activeStep !== 0 && (