From cacde9c00f64c14167270e51f3c3a4a97c29417d Mon Sep 17 00:00:00 2001 From: Tommy Wiebell Date: Tue, 21 Jan 2020 16:24:35 -0600 Subject: [PATCH] [Cart v2] ProductListing Component (#2094) * Create basic stub of new product list layout * Get configurable mocked data working before hooking up network calls * Hook everything up with data fetches * - Add remove item logic - Leverage fragments to auto update the cache * Give item removal a mask while in flight * ProductListing tests * Finish up unit tests for Product * - Handle item remove error state - Replace fragment usage in mutation to refetchQueries - Make a sub-component of CartPage - Fixup tests * Fixup some component interactions after mainline merge * Re-add fetch policies as TODO items to be removed later * - Validate cart id before fetching data - Update tests * - Make detail content flow better based on UX feedback - Update test snapshots with new element * Adjust product details line-height Co-authored-by: Jimmy Sanford Co-authored-by: Devagouda <40405790+dpatil-magento@users.noreply.github.com> --- .../CartPage/PriceSummary/usePriceSummary.js | 4 +- .../CartPage/ProductListing/useProduct.js | 80 +++ .../ProductListing/useProductListing.js | 45 ++ .../__snapshots__/product.spec.js.snap | 631 ++++++++++++++++++ .../__snapshots__/productListing.spec.js.snap | 23 + .../ProductListing/__tests__/product.spec.js | 95 +++ .../__tests__/productListing.spec.js | 71 ++ .../CartPage/ProductListing/index.js | 1 + .../CartPage/ProductListing/product.css | 84 +++ .../CartPage/ProductListing/product.js | 101 +++ .../ProductListing/productListing.css | 4 + .../CartPage/ProductListing/productListing.js | 68 ++ .../lib/components/CartPage/cartPage.css | 8 - .../lib/components/CartPage/cartPage.js | 5 +- .../lib/components/MiniCart/kebab.css | 2 +- 15 files changed, 1209 insertions(+), 13 deletions(-) create mode 100644 packages/peregrine/lib/talons/CartPage/ProductListing/useProduct.js create mode 100644 packages/peregrine/lib/talons/CartPage/ProductListing/useProductListing.js create mode 100644 packages/venia-ui/lib/components/CartPage/ProductListing/__tests__/__snapshots__/product.spec.js.snap create mode 100644 packages/venia-ui/lib/components/CartPage/ProductListing/__tests__/__snapshots__/productListing.spec.js.snap create mode 100644 packages/venia-ui/lib/components/CartPage/ProductListing/__tests__/product.spec.js create mode 100644 packages/venia-ui/lib/components/CartPage/ProductListing/__tests__/productListing.spec.js create mode 100644 packages/venia-ui/lib/components/CartPage/ProductListing/index.js create mode 100644 packages/venia-ui/lib/components/CartPage/ProductListing/product.css create mode 100644 packages/venia-ui/lib/components/CartPage/ProductListing/product.js create mode 100644 packages/venia-ui/lib/components/CartPage/ProductListing/productListing.css create mode 100644 packages/venia-ui/lib/components/CartPage/ProductListing/productListing.js diff --git a/packages/peregrine/lib/talons/CartPage/PriceSummary/usePriceSummary.js b/packages/peregrine/lib/talons/CartPage/PriceSummary/usePriceSummary.js index de710477d0..0dc399957b 100644 --- a/packages/peregrine/lib/talons/CartPage/PriceSummary/usePriceSummary.js +++ b/packages/peregrine/lib/talons/CartPage/PriceSummary/usePriceSummary.js @@ -27,7 +27,9 @@ export const usePriceSummary = props => { const [fetchPriceSummary, { error, loading, data }] = useLazyQuery( props.query, { - fetchPolicy: 'no-cache' + // TODO: Purposely overfetch and hit the network until all components + // are correctly updating the cache. Will be fixed by PWA-321. + fetchPolicy: 'cache-and-network' } ); diff --git a/packages/peregrine/lib/talons/CartPage/ProductListing/useProduct.js b/packages/peregrine/lib/talons/CartPage/ProductListing/useProduct.js new file mode 100644 index 0000000000..b8d0a914fe --- /dev/null +++ b/packages/peregrine/lib/talons/CartPage/ProductListing/useProduct.js @@ -0,0 +1,80 @@ +import { useCallback, useState } from 'react'; +import { useMutation } from '@apollo/react-hooks'; + +import { useCartContext } from '../../../context/cart'; + +export const useProduct = props => { + const { + item, + refetchCartQuery, + refetchPriceQuery, + removeItemMutation + } = props; + + const flatProduct = flattenProduct(item); + const [removeItem] = useMutation(removeItemMutation); + + const [{ cartId }] = useCartContext(); + + const [isRemoving, setIsRemoving] = useState(false); + const [isFavorite, setIsFavorite] = useState(false); + + const handleToggleFavorites = useCallback(() => { + setIsFavorite(!isFavorite); + }, [isFavorite]); + + const handleEditItem = useCallback(() => { + // Edit Item action to be completed by PWA-272. + }, []); + + const handleRemoveFromCart = useCallback(async () => { + setIsRemoving(true); + const { error } = await removeItem({ + variables: { + cartId, + itemId: item.id + }, + refetchQueries: [ + { + query: refetchCartQuery, + variables: { cartId } + }, + { + query: refetchPriceQuery, + variables: { cartId } + } + ] + }); + + if (error) { + setIsRemoving(false); + console.error('Cart Item Removal Error', error); + } + }, [cartId, item.id, refetchCartQuery, refetchPriceQuery, removeItem]); + + return { + handleEditItem, + handleRemoveFromCart, + handleToggleFavorites, + isFavorite, + isRemoving, + product: flatProduct + }; +}; + +const flattenProduct = item => { + const { + configurable_options: options = [], + prices, + product, + quantity + } = item; + + const { price } = prices; + const { value: unitPrice, currency } = price; + + const { name, small_image } = product; + const { url: image } = small_image; + + return { currency, image, name, options, quantity, unitPrice }; +}; diff --git a/packages/peregrine/lib/talons/CartPage/ProductListing/useProductListing.js b/packages/peregrine/lib/talons/CartPage/ProductListing/useProductListing.js new file mode 100644 index 0000000000..be20b19101 --- /dev/null +++ b/packages/peregrine/lib/talons/CartPage/ProductListing/useProductListing.js @@ -0,0 +1,45 @@ +import { useLazyQuery } from '@apollo/react-hooks'; + +import { useCartContext } from '../../../context/cart'; +import { useEffect } from 'react'; + +export const useProductListing = props => { + const { query } = props; + + const [{ cartId }] = useCartContext(); + + const [ + fetchProductListing, + { called, data, error, loading } + ] = useLazyQuery(query, { + // TODO: Purposely overfetch and hit the network until all components + // are correctly updating the cache. Will be fixed by PWA-321. + fetchPolicy: 'cache-and-network' + }); + + useEffect(() => { + if (cartId) { + fetchProductListing({ + variables: { + cartId + } + }); + } + }, [cartId, fetchProductListing]); + + useEffect(() => { + if (error) { + console.error(error); + } + }, [error]); + + let items = []; + if (called && !error && !loading) { + items = data.cart.items; + } + + return { + isLoading: !!loading, + items + }; +}; diff --git a/packages/venia-ui/lib/components/CartPage/ProductListing/__tests__/__snapshots__/product.spec.js.snap b/packages/venia-ui/lib/components/CartPage/ProductListing/__tests__/__snapshots__/product.spec.js.snap new file mode 100644 index 0000000000..9664e9ee9e --- /dev/null +++ b/packages/venia-ui/lib/components/CartPage/ProductListing/__tests__/__snapshots__/product.spec.js.snap @@ -0,0 +1,631 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders configurable product correctly 1`] = ` +
  • +
    + Unit Test Product + Unit Test Product +
    +
    + + Unit Test Product + +
    +
    + Option 1 + : + Value 1 +
    +
    + Option 2 + : + Value 2 +
    +
    + + + $100 + + ea. + +
    + - + 1 + + +
    +
    +
    + +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    +
    +
  • +`; + +exports[`renders mask on removal 1`] = ` +
  • +
    + Unit Test Product + Unit Test Product +
    +
    + + Unit Test Product + + + + $100 + + ea. + +
    + - + 1 + + +
    +
    +
    + +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    +
    +
  • +`; + +exports[`renders simple product correctly 1`] = ` +
  • +
    + Unit Test Product + Unit Test Product +
    +
    + + Unit Test Product + + + + $100 + + ea. + +
    + - + 1 + + +
    +
    +
    + +
      +
    • + +
    • +
    • + +
    • +
    • + +
    • +
    +
    +
  • +`; diff --git a/packages/venia-ui/lib/components/CartPage/ProductListing/__tests__/__snapshots__/productListing.spec.js.snap b/packages/venia-ui/lib/components/CartPage/ProductListing/__tests__/__snapshots__/productListing.spec.js.snap new file mode 100644 index 0000000000..ff5e25e24f --- /dev/null +++ b/packages/venia-ui/lib/components/CartPage/ProductListing/__tests__/__snapshots__/productListing.spec.js.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders list of products with items in cart 1`] = ` + +`; + +exports[`renders string with no items in cart 1`] = ` +

    + There are no items in your cart. +

    +`; diff --git a/packages/venia-ui/lib/components/CartPage/ProductListing/__tests__/product.spec.js b/packages/venia-ui/lib/components/CartPage/ProductListing/__tests__/product.spec.js new file mode 100644 index 0000000000..15ff475e6f --- /dev/null +++ b/packages/venia-ui/lib/components/CartPage/ProductListing/__tests__/product.spec.js @@ -0,0 +1,95 @@ +import React from 'react'; +import { act } from 'react-test-renderer'; +import { createTestInstance } from '@magento/peregrine'; + +import Product from '../product'; + +jest.mock('../../../../classify'); +jest.mock('@apollo/react-hooks', () => { + const executeMutation = jest.fn(() => ({ error: null })); + const useMutation = jest.fn(() => [executeMutation]); + + return { useMutation }; +}); + +jest.mock('@magento/peregrine/lib/context/cart', () => { + const state = { cartId: 'cart123' }; + const api = {}; + const useCartContext = jest.fn(() => [state, api]); + + return { useCartContext }; +}); + +jest.mock('@magento/peregrine', () => { + const Price = props => {`$${props.value}`}; + + return { + ...jest.requireActual('@magento/peregrine'), + Price + }; +}); + +const props = { + item: { + id: '123', + product: { + name: 'Unit Test Product', + small_image: { + url: 'unittest.jpg' + } + }, + prices: { + price: { + currency: 'USD', + value: 100 + } + }, + quantity: 1 + } +}; + +test('renders simple product correctly', () => { + const tree = createTestInstance(); + + expect(tree.toJSON()).toMatchSnapshot(); +}); + +test('renders mask on removal', () => { + const propsWithClass = { + ...props, + classes: { + root: 'root', + mask: 'mask' + } + }; + const tree = createTestInstance(); + const { root } = tree; + const { onClick } = root.findByProps({ text: 'Remove from cart' }).props; + + act(() => { + onClick(); + }); + + expect(tree.toJSON()).toMatchSnapshot(); +}); + +test('renders configurable product correctly', () => { + const configurableProps = { + item: { + ...props.item, + configurable_options: [ + { + option_label: 'Option 1', + value_label: 'Value 1' + }, + { + option_label: 'Option 2', + value_label: 'Value 2' + } + ] + } + }; + const tree = createTestInstance(); + + expect(tree.toJSON()).toMatchSnapshot(); +}); diff --git a/packages/venia-ui/lib/components/CartPage/ProductListing/__tests__/productListing.spec.js b/packages/venia-ui/lib/components/CartPage/ProductListing/__tests__/productListing.spec.js new file mode 100644 index 0000000000..a72c7076d5 --- /dev/null +++ b/packages/venia-ui/lib/components/CartPage/ProductListing/__tests__/productListing.spec.js @@ -0,0 +1,71 @@ +import React from 'react'; +import { useLazyQuery } from '@apollo/react-hooks'; +import { createTestInstance } from '@magento/peregrine'; + +import LoadingIndicator from '../../../LoadingIndicator'; +import ProductListing from '../productListing'; + +jest.mock('../../../../classify'); +jest.mock('@apollo/react-hooks', () => { + return { useLazyQuery: jest.fn() }; +}); + +jest.mock('@magento/peregrine/lib/context/cart', () => { + const state = { cartId: 'cart123' }; + const api = {}; + const useCartContext = jest.fn(() => [state, api]); + + return { useCartContext }; +}); + +jest.mock('../product', () => 'Product'); + +test('renders loading indicator while data fetching', () => { + useLazyQuery.mockReturnValueOnce([ + () => {}, + { + loading: true + } + ]); + + const { root } = createTestInstance(); + expect(root.findByType(LoadingIndicator)).toBeDefined(); +}); + +test('renders string with no items in cart', () => { + useLazyQuery.mockReturnValueOnce([ + () => {}, + { + called: true, + loading: false, + error: null, + data: { + cart: { + items: [] + } + } + } + ]); + const tree = createTestInstance(); + + expect(tree.toJSON()).toMatchSnapshot(); +}); + +test('renders list of products with items in cart', () => { + useLazyQuery.mockReturnValueOnce([ + () => {}, + { + called: true, + loading: false, + error: null, + data: { + cart: { + items: ['1', '2', '3'] + } + } + } + ]); + const tree = createTestInstance(); + + expect(tree.toJSON()).toMatchSnapshot(); +}); diff --git a/packages/venia-ui/lib/components/CartPage/ProductListing/index.js b/packages/venia-ui/lib/components/CartPage/ProductListing/index.js new file mode 100644 index 0000000000..27b5dd8429 --- /dev/null +++ b/packages/venia-ui/lib/components/CartPage/ProductListing/index.js @@ -0,0 +1 @@ +export { default } from './productListing'; diff --git a/packages/venia-ui/lib/components/CartPage/ProductListing/product.css b/packages/venia-ui/lib/components/CartPage/ProductListing/product.css new file mode 100644 index 0000000000..7a0d8366f4 --- /dev/null +++ b/packages/venia-ui/lib/components/CartPage/ProductListing/product.css @@ -0,0 +1,84 @@ +.root { + display: grid; + grid-gap: 0.5rem 1rem; + grid-template-areas: 'image details kebab'; + grid-template-columns: 100px 1fr min-content; +} + +.mask { + opacity: 0.5; + pointer-events: none; +} + +.imageContainer { + grid-area: image; +} + +.image { + background-color: rgb(var(--venia-grey)); + border: solid 1px rgb(var(--venia-border)); + border-radius: 2px; +} + +.details { + display: grid; + grid-area: details; + grid-gap: 0.5rem; + grid-template-areas: + 'name quantity' + 'options quantity' + 'price quantity'; + grid-template-rows: repeat(3, min-content); + grid-template-columns: 2fr 1fr; + line-height: 1.5; +} + +.name { + grid-area: name; + font-weight: 600; +} + +.price { + grid-area: price; + font-size: 0.875rem; +} + +.quantity { + grid-area: quantity; + display: flex; + align-items: center; + justify-content: center; +} + +.kebab { + grid-area: kebab; + position: relative; +} + +.sectionText { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + pointer-events: none; +} + +.options { + grid-area: options; + font-size: 0.875rem; + display: grid; + grid-gap: 0.125rem; +} + +.optionLabel { +} + +@media (max-width: 640px) { + .details { + grid-template-columns: auto min-content; + } + + .quantity { + grid-column: 1; + grid-row: 4; + justify-self: left; + } +} diff --git a/packages/venia-ui/lib/components/CartPage/ProductListing/product.js b/packages/venia-ui/lib/components/CartPage/ProductListing/product.js new file mode 100644 index 0000000000..dae3961e72 --- /dev/null +++ b/packages/venia-ui/lib/components/CartPage/ProductListing/product.js @@ -0,0 +1,101 @@ +import React from 'react'; +import gql from 'graphql-tag'; +import { useProduct } from '@magento/peregrine/lib/talons/CartPage/ProductListing/useProduct'; +import { Price } from '@magento/peregrine'; + +import { mergeClasses } from '../../../classify'; +import Kebab from '../../MiniCart/kebab'; +import ProductOptions from '../../MiniCart/productOptions'; +import Section from '../../MiniCart/section'; +import Image from '../../Image'; +import defaultClasses from './product.css'; +import { GET_PRODUCT_LISTING } from './productListing'; +import { PriceSummaryQuery } from '../PriceSummary/priceSummary'; + +const IMAGE_SIZE = 100; + +const Product = props => { + const { item } = props; + const talonProps = useProduct({ + item, + refetchPriceQuery: PriceSummaryQuery, + refetchCartQuery: GET_PRODUCT_LISTING, + removeItemMutation: REMOVE_ITEM_MUTATION + }); + const { + handleEditItem, + handleRemoveFromCart, + handleToggleFavorites, + isFavorite, + isRemoving, + product + } = talonProps; + const { currency, image, name, options, quantity, unitPrice } = product; + + const classes = mergeClasses(defaultClasses, props.classes); + const rowMask = isRemoving ? classes.mask : ''; + + return ( +
  • + {name} +
    + {name} + + + + {' ea.'} + + {/** Quantity Selection to be completed by PWA-119. */} +
    - {quantity} +
    +
    + +
    +
    +
    + +
  • + ); +}; + +export default Product; + +export const REMOVE_ITEM_MUTATION = gql` + mutation removeItem($cartId: String!, $itemId: Int!) { + removeItemFromCart(input: { cart_id: $cartId, cart_item_id: $itemId }) { + cart { + id + } + } + } +`; diff --git a/packages/venia-ui/lib/components/CartPage/ProductListing/productListing.css b/packages/venia-ui/lib/components/CartPage/ProductListing/productListing.css new file mode 100644 index 0000000000..75feb40fc1 --- /dev/null +++ b/packages/venia-ui/lib/components/CartPage/ProductListing/productListing.css @@ -0,0 +1,4 @@ +.root { + display: grid; + grid-gap: 2rem 1rem; +} diff --git a/packages/venia-ui/lib/components/CartPage/ProductListing/productListing.js b/packages/venia-ui/lib/components/CartPage/ProductListing/productListing.js new file mode 100644 index 0000000000..7d58616ec4 --- /dev/null +++ b/packages/venia-ui/lib/components/CartPage/ProductListing/productListing.js @@ -0,0 +1,68 @@ +import React from 'react'; +import gql from 'graphql-tag'; +import { useProductListing } from '@magento/peregrine/lib/talons/CartPage/ProductListing/useProductListing'; + +import { mergeClasses } from '../../../classify'; +import LoadingIndicator from '../../LoadingIndicator'; +import defaultClasses from './productListing.css'; +import Product from './product'; + +const ProductListing = props => { + const talonProps = useProductListing({ query: GET_PRODUCT_LISTING }); + const { isLoading, items } = talonProps; + + const classes = mergeClasses(defaultClasses, props.classes); + + if (isLoading) { + return {`Fetching Cart...`}; + } + + if (items.length) { + const productComponents = items.map(product => ( + + )); + + return
      {productComponents}
    ; + } else { + return

    There are no items in your cart.

    ; + } +}; + +export default ProductListing; + +export const CartBody = gql` + fragment CartBody on Cart { + id + items { + id + product { + name + small_image { + url + } + } + prices { + price { + currency + value + } + } + quantity + ... on ConfigurableCartItem { + configurable_options { + option_label + value_label + } + } + } + } +`; + +export const GET_PRODUCT_LISTING = gql` + query getProductListing($cartId: String!) { + cart(cart_id: $cartId) { + ...CartBody + } + ${CartBody} + } +`; diff --git a/packages/venia-ui/lib/components/CartPage/cartPage.css b/packages/venia-ui/lib/components/CartPage/cartPage.css index 77e0eb6f24..93af08e5fa 100644 --- a/packages/venia-ui/lib/components/CartPage/cartPage.css +++ b/packages/venia-ui/lib/components/CartPage/cartPage.css @@ -28,14 +28,6 @@ .items_container { grid-area: items; - /* TEMPORARY STYLES FOR SCROLLABLE SUMMARY, CAN BE REMOVED */ - align-items: center; - border: 1px solid rgb(var(--venia-border)); - border-radius: 0.5rem; - color: rgb(var(--venia-text-hint)); - display: flex; - height: 40rem; - justify-content: center; } .price_adjustments_container { diff --git a/packages/venia-ui/lib/components/CartPage/cartPage.js b/packages/venia-ui/lib/components/CartPage/cartPage.js index 9f8fc48f0c..a674795826 100644 --- a/packages/venia-ui/lib/components/CartPage/cartPage.js +++ b/packages/venia-ui/lib/components/CartPage/cartPage.js @@ -5,6 +5,7 @@ import { useCartPage } from '@magento/peregrine/lib/talons/CartPage/useCartPage' import Button from '../Button'; import PriceAdjustments from './PriceAdjustments'; import PriceSummary from './PriceSummary'; +import ProductListing from './ProductListing'; import { mergeClasses } from '../../classify'; import defaultClasses from './cartPage.css'; @@ -33,9 +34,7 @@ const CartPage = props => {
    diff --git a/packages/venia-ui/lib/components/MiniCart/kebab.css b/packages/venia-ui/lib/components/MiniCart/kebab.css index 48c2ec24ee..1f0ca93775 100644 --- a/packages/venia-ui/lib/components/MiniCart/kebab.css +++ b/packages/venia-ui/lib/components/MiniCart/kebab.css @@ -13,7 +13,7 @@ box-shadow: 0 0 1px rgb(0, 0, 0, 0.2); display: grid; position: absolute; - right: 2px; + right: 20px; top: 0; transition: 256ms ease-out; transform: scale(0);