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-8/src/assets/README.md b/apps/exercise-8/src/assets/README.md index d89e5b0..ca65693 100644 --- a/apps/exercise-8/src/assets/README.md +++ b/apps/exercise-8/src/assets/README.md @@ -13,10 +13,20 @@ Let's create the checkout module ! -We need a Checkout page that needs a logged in user. So we also need a Login page. +We need a Checkout page that expects a logged in user. So we also need a Login page. We'll mock the user api and authentication process for now. We need to bo redirected to the login page on some routes, not all. ## Step by step ### src/modules/checkout/checkout.component.js + +### src/modules/checkout/components/review.component.js + +### src/modules/checkout/components/addressForm.component.js + +### src/modules/checkout/components/paymentForm.component.js + +### src/App.js + +### src/pages/checkout.page.js 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/checkout/checkout.component.js b/apps/exercise-9/src/app/modules/checkout/checkout.component.js new file mode 100644 index 0000000..925c060 --- /dev/null +++ b/apps/exercise-9/src/app/modules/checkout/checkout.component.js @@ -0,0 +1,121 @@ +import React, { memo } from 'react'; +import { makeStyles } from '@material-ui/core/styles'; +import Container from '@material-ui/core/Container'; +import Paper from '@material-ui/core/Paper'; +import Stepper from '@material-ui/core/Stepper'; +import Step from '@material-ui/core/Step'; +import StepLabel from '@material-ui/core/StepLabel'; +import Button from '@material-ui/core/Button'; +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 { SHIPPING, PAYMENT, REVIEW, steps } from './checkout.constants'; + +const useStyles = makeStyles(theme => ({ + appBar: { + position: 'relative', + borderBottom: `1px solid ${theme.palette.divider}`, + }, + main: { + marginBottom: theme.spacing(4), + }, + paper: { + marginTop: theme.spacing(3), + marginBottom: theme.spacing(3), + padding: theme.spacing(2), + [theme.breakpoints.up('md')]: { + marginTop: theme.spacing(4), + marginBottom: theme.spacing(4), + padding: theme.spacing(3), + }, + }, + stepper: { + padding: theme.spacing(3, 0, 5), + }, + buttons: { + display: 'flex', + justifyContent: 'flex-end', + }, + button: { + marginTop: theme.spacing(3), + marginLeft: theme.spacing(1), + }, +})); + +function getStepContent(step) { + switch (step) { + case SHIPPING: + return ; + case PAYMENT: + return ; + case REVIEW: + return ; + default: + throw new Error('Unknown step'); + } +} + +function Checkout() { + const classes = useStyles(); + const [activeStep, setActiveStep] = React.useState(0); + + const handleNext = () => { + setActiveStep(activeStep + 1); + }; + + const handleBack = () => { + setActiveStep(activeStep - 1); + }; + + return ( + + + + Checkout + + + {steps.map(label => ( + + {label} + + ))} + + {activeStep === steps.length ? ( + + + Thank you for your order. + + + Your order number is #2001539. We have emailed your order + confirmation, and will send you an update when your order has + shipped. + + + ) : ( + + {getStepContent(steps[activeStep])} +
+ {activeStep !== 0 && ( + + )} + + +
+
+ )} +
+
+ ); +} + +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..e6e8cf8 --- /dev/null +++ b/apps/exercise-9/src/app/modules/checkout/components/review.component.js @@ -0,0 +1,102 @@ +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), + }, +})); + +export default function Review({ formState }) { + const classes = useStyles(); + const [{ articles, total }] = useCart(); + + 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/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..cf6154a 100644 --- a/apps/exercise-9/src/assets/README.md +++ b/apps/exercise-9/src/assets/README.md @@ -1,20 +1,55 @@ -# 3/ Wrapping pages, building layout with Material-UI +# 9/ Controlled Forms -| 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 | +| ------ | -------------------------------------------------------- | ------------- | +| 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 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. +Let's create the controlled forms ! + +The **Stepper** gave us a nice UX for combining forms, now it's time to display the inputs and store their values in order to display those in the **Review** component. + +Let's ask ourselves some questions: + +- Where should we locate the state ? +- What other options do we have ? +- How can we reduce the re-renders ? + +### :baguette_bread: Disclaimer: validation of this exercise doesn't include counting re-renders + +You can use whatever state management method you want, now it's time to give you some slack building your desired solution. In fact, witnessing multiple re-renders on forms is a pretty common thing and doesn't necessarily affect the user experience, unless the user device capabilities are really low. Sometimes, even on low devices, using memoization technics (like memo, useCallback or useMemo) to avoid re-renders is degrading the user experience even more than letting those re-renders happening in the first place. + +:point_left: Always build your solution before optimizing it. Sometimes, only do optimize it if you see the need. + +:point_down: I'll only try to give hints before I dive into my chosen solution in the following instructions. ## 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 `