diff --git a/packages/peregrine/lib/talons/CategoryList/useCategoryList.js b/packages/peregrine/lib/talons/CategoryList/useCategoryList.js new file mode 100644 index 0000000000..033e1060e2 --- /dev/null +++ b/packages/peregrine/lib/talons/CategoryList/useCategoryList.js @@ -0,0 +1,40 @@ +import { useEffect } from 'react'; +import { useQuery } from '../../hooks/useQuery'; + +/** + * Returns props necessary to render a CategoryList component. + * + * @param {object} props + * @param {object} props.query - category data + * @param {string} props.id - category id + * @return {{ childCategories: array, error: object }} + */ +export const useCategoryList = props => { + const { query, id } = props; + const [queryResult, queryApi] = useQuery(query); + const { data, error, loading } = queryResult; + const { runQuery, setLoading } = queryApi; + + useEffect(() => { + const fetchCategories = async () => { + setLoading(true); + + await runQuery({ + variables: { + id + } + }); + + setLoading(false); + }; + + fetchCategories(); + }, [runQuery, setLoading, id]); + + return { + childCategories: + (data && data.category && data.category.children) || null, + error, + loading + }; +}; diff --git a/packages/peregrine/lib/talons/CategoryList/useCategoryTile.js b/packages/peregrine/lib/talons/CategoryList/useCategoryTile.js new file mode 100644 index 0000000000..76ca21fab3 --- /dev/null +++ b/packages/peregrine/lib/talons/CategoryList/useCategoryTile.js @@ -0,0 +1,49 @@ +import { useMemo } from 'react'; + +// TODO: get categoryUrlSuffix from graphql storeOptions when it is ready +const categoryUrlSuffix = '.html'; +const previewImageSize = 480; + +/** + * Returns props necessary to render a CategoryTile component. + * + * @returns {Object} props necessary to render a category tile + * @returns {Object} .image - an object containing url, type and width for the category image + * @returns {Object} .item - an object containing name and url for the category tile + */ +export const useCategoryTile = props => { + const { item } = props; + const { image, productImagePreview } = item; + + const imageObj = useMemo(() => { + const previewProduct = productImagePreview.items[0]; + if (image) { + return { + url: image, + type: 'image-category', + width: previewImageSize + }; + } else if (previewProduct) { + return { + url: previewProduct.small_image, + type: 'image-product', + width: previewImageSize + }; + } else { + return null; + } + }, [image, productImagePreview]); + + const itemObject = useMemo( + () => ({ + name: item.name, + url: `/${item.url_key}${categoryUrlSuffix}` + }), + [item] + ); + + return { + image: imageObj, + item: itemObject + }; +}; diff --git a/packages/venia-ui/lib/components/CategoryList/__tests__/categoryList.spec.js b/packages/venia-ui/lib/components/CategoryList/__tests__/categoryList.spec.js index 7c2f8ef038..13ea173bc1 100644 --- a/packages/venia-ui/lib/components/CategoryList/__tests__/categoryList.spec.js +++ b/packages/venia-ui/lib/components/CategoryList/__tests__/categoryList.spec.js @@ -1,31 +1,44 @@ import React from 'react'; -import waitForExpect from 'wait-for-expect'; -import TestRenderer from 'react-test-renderer'; -import { MemoryRouter } from 'react-router-dom'; -import { MockedProvider } from 'react-apollo/test-utils'; +import { createTestInstance } from '@magento/peregrine'; import LoadingIndicator from '../../LoadingIndicator'; import CategoryTile from '../categoryTile'; import CategoryList from '../categoryList'; -import getCategoryList from '../../../queries/getCategoryList.graphql'; +import { useCategoryList } from '@magento/peregrine/lib/talons/CategoryList/useCategoryList'; +import { useCategoryTile } from '@magento/peregrine/lib/talons/CategoryList/useCategoryTile'; +jest.mock('@magento/venia-drivers'); jest.mock('../../../classify'); +jest.mock('@magento/peregrine/lib/talons/CategoryList/useCategoryTile', () => { + return { + useCategoryTile: jest.fn() + }; +}); + +jest.mock('@magento/peregrine/lib/talons/CategoryList/useCategoryList', () => { + return { + useCategoryList: jest.fn() + }; +}); -const withRouterAndApolloClient = (mocks, renderFn) => ( - - - {renderFn()} - - -); +useCategoryTile.mockReturnValue({ + image: {}, + item: {} +}); + +useCategoryList.mockReturnValue({ + data: { + category: { + children: [] + } + }, + loading: false, + error: false +}); test('renders a header', () => { const title = 'foo'; - const { root } = TestRenderer.create( - withRouterAndApolloClient([], () => ( - - )) - ); + const { root } = createTestInstance(); const list = root.findByProps({ className: 'root' }); const header = list.findByProps({ className: 'header' }); @@ -35,91 +48,83 @@ test('renders a header', () => { }); test('omits the header if there is no title', () => { - const { root } = TestRenderer.create( - withRouterAndApolloClient([], () => ) - ); + const { root } = createTestInstance(); expect(root.findAllByProps({ className: 'header' })).toHaveLength(0); }); -test('renders category tiles', async () => { - const mocks = [ - { - request: { - query: getCategoryList, - variables: { - id: 2 - } - }, - result: { - data: { - category: { - id: 2, - children: [ - { - id: 15, - name: 'foo', - url_key: 'foo-url.html', - url_path: '/foo-url.html', - children_count: 0, - path: '1/2/15', - image: 'media/foo.png', - productImagePreview: { - items: [ - { - small_image: { - url: 'media/foo-product.jpg' - } - } - ] - } - }, +test('renders a loading indicator', () => { + useCategoryList.mockReturnValueOnce({ + loading: true + }); + + const { root } = createTestInstance(); + + expect(root.findAllByType(LoadingIndicator)).toBeTruthy(); +}); + +test('renders category tiles', () => { + const data = { + category: { + id: 2, + children: [ + { + id: 15, + name: 'foo', + url_key: 'foo-url.html', + url_path: '/foo-url.html', + children_count: 0, + path: '1/2/15', + image: 'media/foo.png', + productImagePreview: { + items: [ { - id: 16, - name: 'bar', - url_key: 'bar-url.html', - url_path: '/bar-url.html', - children_count: 0, - path: '1/2/16', - image: null, - productImagePreview: { - items: [ - { - small_image: { - url: 'media/bar-product.jpg' - } - } - ] + small_image: { + url: 'media/foo-product.jpg' } - }, + } + ] + } + }, + { + id: 16, + name: 'bar', + url_key: 'bar-url.html', + url_path: '/bar-url.html', + children_count: 0, + path: '1/2/16', + image: null, + productImagePreview: { + items: [ { - id: 17, - name: 'baz', - url_key: 'baz-url.html', - url_path: '/baz-url.html', - children_count: 0, - path: '1/2/17', - image: null, - productImagePreview: { - items: [] + small_image: { + url: 'media/bar-product.jpg' } } ] } + }, + { + id: 17, + name: 'baz', + url_key: 'baz-url.html', + url_path: '/baz-url.html', + children_count: 0, + path: '1/2/17', + image: null, + productImagePreview: { + items: [] + } } - } + ] } - ]; + }; - const { root } = TestRenderer.create( - withRouterAndApolloClient(mocks, () => ( - - )) - ); + useCategoryList.mockReturnValueOnce({ + childCategories: data.category.children + }); - expect(root.findByType(LoadingIndicator)).toBeTruthy(); + const { root } = createTestInstance(); - await waitForExpect(() => { - expect(root.findAllByType(CategoryTile)).toHaveLength(3); - }); + expect(root.findAllByType(CategoryTile)).toHaveLength(3); }); diff --git a/packages/venia-ui/lib/components/CategoryList/categoryList.js b/packages/venia-ui/lib/components/CategoryList/categoryList.js index a8722530a3..6fa2e9d6df 100644 --- a/packages/venia-ui/lib/components/CategoryList/categoryList.js +++ b/packages/venia-ui/lib/components/CategoryList/categoryList.js @@ -1,96 +1,90 @@ -import React, { Component } from 'react'; +import React from 'react'; import { string, number, shape } from 'prop-types'; -import { Query } from '@magento/venia-drivers'; -import classify from '../../classify'; +import { mergeClasses } from '../../classify'; import { fullPageLoadingIndicator } from '../LoadingIndicator'; import defaultClasses from './categoryList.css'; import CategoryTile from './categoryTile'; import categoryListQuery from '../../queries/getCategoryList.graphql'; +import { useCategoryList } from '@magento/peregrine/lib/talons/CategoryList/useCategoryList'; -class CategoryList extends Component { - static propTypes = { - id: number, - title: string, - classes: shape({ - root: string, - header: string, - content: string - }) +// map Magento 2.3.1 schema changes to Venia 2.0.0 proptype shape to maintain backwards compatibility +const mapCategory = categoryItem => { + const { items } = categoryItem.productImagePreview; + return { + ...categoryItem, + productImagePreview: { + items: items.map(item => { + const { small_image } = item; + return { + ...item, + small_image: + typeof small_image === 'object' + ? small_image.url + : small_image + }; + }) + } }; +}; - get header() { - const { title, classes } = this.props; +const CategoryList = props => { + const { id, title } = props; + const talonProps = useCategoryList({ + query: categoryListQuery, + id + }); - return title ? ( -
-

- {title} -

-
- ) : null; - } + const { childCategories, error, loading } = talonProps; - // map Magento 2.3.1 schema changes to Venia 2.0.0 proptype shape to maintain backwards compatibility - mapCategory(categoryItem) { - const { items } = categoryItem.productImagePreview; - return { - ...categoryItem, - productImagePreview: { - items: items.map(item => { - const { small_image } = item; - return { - ...item, - small_image: - typeof small_image === 'object' - ? small_image.url - : small_image - }; - }) - } - }; - } + const classes = mergeClasses(defaultClasses, props.classes); - render() { - const { id, classes } = this.props; + const header = title ? ( +
+

+ {title} +

+
+ ) : null; - return ( -
- {this.header} - - {({ loading, error, data }) => { - if (error) { - return ( -
- Data Fetch Error:
{error.message}
-
- ); - } - if (loading) { - return fullPageLoadingIndicator; - } - if (data.category.children.length === 0) { - return ( -
- No child categories found. -
- ); - } - - return ( -
- {data.category.children.map(item => ( - - ))} -
- ); - }} -
+ let child; + if (error) { + child = ( +
+ Data Fetch Error:
{error.message}
); } -} + if (loading || !childCategories) { + child = fullPageLoadingIndicator; + } else if (childCategories.length === 0) { + child = ( +
No child categories found.
+ ); + } else { + child = ( +
+ {childCategories.map(item => ( + + ))} +
+ ); + } + return ( +
+ {header} + {child} +
+ ); +}; + +CategoryList.propTypes = { + id: number, + title: string, + classes: shape({ + root: string, + header: string, + content: string + }) +}; -export default classify(defaultClasses)(CategoryList); +export default CategoryList; diff --git a/packages/venia-ui/lib/components/CategoryList/categoryTile.js b/packages/venia-ui/lib/components/CategoryList/categoryTile.js index 6f439e30a8..354b53c2c3 100644 --- a/packages/venia-ui/lib/components/CategoryList/categoryTile.js +++ b/packages/venia-ui/lib/components/CategoryList/categoryTile.js @@ -1,80 +1,62 @@ -import React, { Component } from 'react'; +import React from 'react'; import { arrayOf, string, shape } from 'prop-types'; -import classify from '../../classify'; +import { mergeClasses } from '../../classify'; import { Link, resourceUrl } from '@magento/venia-drivers'; import defaultClasses from './categoryTile.css'; - -// TODO: get categoryUrlSuffix from graphql storeOptions when it is ready -const categoryUrlSuffix = '.html'; - -const previewImageSize = 480; - -class CategoryTile extends Component { - static propTypes = { - item: shape({ - image: string, - name: string.isRequired, - productImagePreview: shape({ - items: arrayOf( - shape({ - small_image: string - }) - ) - }), - url_key: string.isRequired - }).isRequired, - classes: shape({ - item: string, - image: string, - imageWrapper: string, - name: string - }).isRequired - }; - - get imagePath() { - const { image, productImagePreview } = this.props.item; - const previewProduct = productImagePreview.items[0]; - if (image) { - return resourceUrl(image, { - type: 'image-category', - width: previewImageSize - }); - } else if (previewProduct) { - return resourceUrl(previewProduct.small_image, { - type: 'image-product', - width: previewImageSize - }); - } else { - return null; - } - } - - render() { - const { imagePath, props } = this; - const { classes, item } = props; - - // interpolation doesn't work inside `url()` for legacy reasons - // so a custom property should wrap its value in `url()` - const imageUrl = imagePath ? `url(${imagePath})` : 'none'; - const style = { '--venia-image': imageUrl }; - - // render an actual image element for accessibility - const imagePreview = imagePath ? ( - {item.name} - ) : null; - - return ( - - - {imagePreview} - - {item.name} - - ); - } -} - -export default classify(defaultClasses)(CategoryTile); +import { useCategoryTile } from '@magento/peregrine/lib/talons/CategoryList/useCategoryTile'; + +const CategoryTile = props => { + const mixinProps = useCategoryTile({ + item: props.item + }); + + const { image, item } = mixinProps; + + const imagePath = resourceUrl(image.url, { + type: image.type, + width: image.width + }); + + // interpolation doesn't work inside `url()` for legacy reasons + // so a custom property should wrap its value in `url()` + const imageUrl = imagePath ? `url(${imagePath})` : 'none'; + const imageWrapperStyle = { '--venia-image': imageUrl }; + + const classes = mergeClasses(defaultClasses, props.classes); + + // render an actual image element for accessibility + const imagePreview = imagePath ? ( + {item.name} + ) : null; + + return ( + + + {imagePreview} + + {item.name} + + ); +}; + +CategoryTile.propTypes = { + item: shape({ + image: string, + name: string.isRequired, + productImagePreview: shape({ + items: arrayOf( + shape({ + small_image: string + }) + ) + }), + url_key: string.isRequired + }).isRequired, + classes: shape({ + item: string, + image: string, + imageWrapper: string, + name: string + }) +}; +export default CategoryTile;