diff --git a/packages/extensions/venia-sample-language-packs/i18n/fr_FR.json b/packages/extensions/venia-sample-language-packs/i18n/fr_FR.json index 19a48c6896..bf8258523e 100644 --- a/packages/extensions/venia-sample-language-packs/i18n/fr_FR.json +++ b/packages/extensions/venia-sample-language-packs/i18n/fr_FR.json @@ -45,6 +45,7 @@ "cartTrigger.ariaLabel": "Basculer le mini-panier. Vous avez {count} articles dans votre panier.", "categoryContent.filter": "Filtre", "categoryContent.itemsSortedBy": "Articles triés par ", + "categoryContent.resultCount": "{count} Résultats", "categoryLeaf.allLabel": "Tout(e) {name}", "categoryList.noResults": "Aucune catégorie enfant trouvée.", "checkoutPage.accountSuccessfullyCreated": "Compte créé avec succès.", @@ -130,6 +131,8 @@ "Email Signup": "Inscription par courriel", "field.optional": "Optionnelle", "filterFooter.results": "Voir les résultats", + "filterList.showMore": "Voir plus", + "filterList.showLess": "Voir moins", "filterModal.action": "Tout effacer", "filterModal.headerTitle": "Filtres", "filterSearch.name": "Entrez un {name}", @@ -283,6 +286,7 @@ "productListing.loading": "Récupération du panier ...", "productOptions.selectedLabel": "Choisie {label}:", "productQuantity.label": "quantité de produit", + "productSort.sortByButton": "Trier par", "productSort.sortButton": "Trier", "quantity.buttonDecrement": "Diminuer la quantité", "quantity.buttonIncrement": "Augmenter la quantité", diff --git a/packages/peregrine/lib/talons/FilterModal/__tests__/useFilterBlock.spec.js b/packages/peregrine/lib/talons/FilterModal/__tests__/useFilterBlock.spec.js new file mode 100644 index 0000000000..83b08a5de7 --- /dev/null +++ b/packages/peregrine/lib/talons/FilterModal/__tests__/useFilterBlock.spec.js @@ -0,0 +1,101 @@ +import React, { useEffect } from 'react'; +import { act } from 'react-test-renderer'; + +import { createTestInstance } from '@magento/peregrine'; +import { useFilterBlock } from '../useFilterBlock'; + +const log = jest.fn(); + +let handleClickProp = null; +let inputValues = {}; + +const Component = () => { + const talonProps = useFilterBlock(inputValues); + + useEffect(() => { + log(talonProps); + handleClickProp = talonProps.handleClick; + }, [talonProps]); + + return null; +}; + +const givenDefaultValues = () => { + inputValues = { + filterState: new Set(), + items: [], + initialOpen: false + }; +}; + +const givenInitiallyOpen = () => { + inputValues = { + filterState: new Set(), + items: [], + initialOpen: true + }; +}; + +const givenSelectedItems = () => { + const item = { + attribute_code: 'foo' + }; + + inputValues = { + filterState: new Set().add(item), + items: [item], + initialOpen: false + }; +}; + +describe('#useFilterBlock', () => { + beforeEach(() => { + log.mockClear(); + handleClickProp = null; + givenDefaultValues(); + }); + + it('is closed by default', () => { + createTestInstance(); + + expect(log).toHaveBeenCalledWith({ + handleClick: expect.any(Function), + isExpanded: false + }); + }); + + it('is open if passed initially open', () => { + givenInitiallyOpen(); + createTestInstance(); + + expect(log).toHaveBeenCalledWith({ + handleClick: expect.any(Function), + isExpanded: true + }); + }); + + it('is open if items are selected', () => { + givenSelectedItems(); + createTestInstance(); + + expect(log).toHaveBeenCalledWith({ + handleClick: expect.any(Function), + isExpanded: true + }); + }); + + it('can toggle visibility', () => { + createTestInstance(); + + expect(typeof handleClickProp).toBe('function'); + + act(() => { + handleClickProp(); + }); + + expect(log).toHaveBeenLastCalledWith({ + handleClick: expect.any(Function), + isExpanded: true + }); + }); +}); diff --git a/packages/peregrine/lib/talons/FilterModal/__tests__/useFilterList.spec.js b/packages/peregrine/lib/talons/FilterModal/__tests__/useFilterList.spec.js new file mode 100644 index 0000000000..7c0e042025 --- /dev/null +++ b/packages/peregrine/lib/talons/FilterModal/__tests__/useFilterList.spec.js @@ -0,0 +1,51 @@ +import React, { useEffect } from 'react'; +import { act } from 'react-test-renderer'; + +import { createTestInstance } from '@magento/peregrine'; +import { useFilterList } from '../useFilterList'; + +const log = jest.fn(); + +let handleClickProp = null; + +const Component = () => { + const talonProps = useFilterList(); + + useEffect(() => { + log(talonProps); + handleClickProp = talonProps.handleListToggle; + }, [talonProps]); + + return null; +}; + +describe('#useFilterList', () => { + beforeEach(() => { + log.mockClear(); + handleClickProp = null; + }); + + it('is initially closed', () => { + createTestInstance(); + + expect(log).toHaveBeenCalledWith({ + handleListToggle: expect.any(Function), + isListExpanded: false + }); + }); + + it('can toggle visibility', () => { + createTestInstance(); + + expect(typeof handleClickProp).toBe('function'); + + act(() => { + handleClickProp(); + }); + + expect(log).toHaveBeenLastCalledWith({ + handleListToggle: expect.any(Function), + isListExpanded: true + }); + }); +}); diff --git a/packages/peregrine/lib/talons/FilterModal/index.js b/packages/peregrine/lib/talons/FilterModal/index.js index e478e2ffc6..f6d7c94e95 100644 --- a/packages/peregrine/lib/talons/FilterModal/index.js +++ b/packages/peregrine/lib/talons/FilterModal/index.js @@ -2,3 +2,4 @@ export { useFilterBlock } from './useFilterBlock'; export { useFilterFooter } from './useFilterFooter'; export { useFilterModal } from './useFilterModal'; export { useFilterState } from './useFilterState'; +export { useFilterList } from './useFilterList'; diff --git a/packages/peregrine/lib/talons/FilterModal/useFilterBlock.js b/packages/peregrine/lib/talons/FilterModal/useFilterBlock.js index fcca17b8ea..8691516d95 100644 --- a/packages/peregrine/lib/talons/FilterModal/useFilterBlock.js +++ b/packages/peregrine/lib/talons/FilterModal/useFilterBlock.js @@ -1,7 +1,19 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useState, useEffect, useMemo } from 'react'; -export const useFilterBlock = () => { - const [isExpanded, setExpanded] = useState(false); +export const useFilterBlock = props => { + const { filterState, items, initialOpen } = props; + + const hasSelected = useMemo(() => { + return items.some(item => { + return filterState && filterState.has(item); + }); + }, [filterState, items]); + + const [isExpanded, setExpanded] = useState(hasSelected || initialOpen); + + useEffect(() => { + setExpanded(hasSelected || initialOpen); + }, [hasSelected, initialOpen]); const handleClick = useCallback(() => { setExpanded(value => !value); diff --git a/packages/peregrine/lib/talons/FilterModal/useFilterList.js b/packages/peregrine/lib/talons/FilterModal/useFilterList.js new file mode 100644 index 0000000000..53ce21612d --- /dev/null +++ b/packages/peregrine/lib/talons/FilterModal/useFilterList.js @@ -0,0 +1,14 @@ +import { useCallback, useState } from 'react'; + +export const useFilterList = () => { + const [isListExpanded, setExpanded] = useState(false); + + const handleListToggle = useCallback(() => { + setExpanded(value => !value); + }, [setExpanded]); + + return { + handleListToggle, + isListExpanded + }; +}; diff --git a/packages/peregrine/lib/talons/FilterSidebar/__tests__/useFilterSidebar.spec.js b/packages/peregrine/lib/talons/FilterSidebar/__tests__/useFilterSidebar.spec.js new file mode 100644 index 0000000000..a34d4e1061 --- /dev/null +++ b/packages/peregrine/lib/talons/FilterSidebar/__tests__/useFilterSidebar.spec.js @@ -0,0 +1,166 @@ +import React, { useEffect } from 'react'; +import { act } from 'react-test-renderer'; +import { useHistory, useLocation } from 'react-router-dom'; + +import { createTestInstance } from '@magento/peregrine'; +import { useFilterSidebar } from '../useFilterSidebar'; + +jest.mock('../../FilterModal/helpers', () => ({ + getStateFromSearch: jest.fn(() => ({})), + getSearchFromState: jest.fn(() => 'searchFromState'), + stripHtml: jest.fn(() => 'strippedHtml') +})); + +jest.mock('@magento/peregrine/lib/context/app', () => { + const api = { + closeDrawer: jest.fn() + }; + const state = { + drawer: 'filter' + }; + return { + useAppContext: jest.fn(() => [state, api]) + }; +}); + +jest.mock('../../FilterModal/useFilterState', () => { + const api = { + setItems: jest.fn() + }; + const state = {}; + return { + useFilterState: jest.fn(() => [state, api]) + }; +}); + +// Mock introspection to return all the filters from the test data +jest.mock('@apollo/client', () => { + const apolloClient = jest.requireActual('@apollo/client'); + const introspectionData = { + __type: { + inputFields: [ + { + name: 'price' + }, + { + name: 'category_id' + }, + { + name: 'foo' + } + ] + } + }; + return { + ...apolloClient, + useQuery: jest.fn(() => ({ data: introspectionData, error: null })) + }; +}); + +jest.mock('react-router-dom', () => ({ + useHistory: jest.fn(() => ({ push: jest.fn() })), + useLocation: jest.fn(() => ({ pathname: '', search: '' })) +})); +const mockPush = jest.fn(); +useHistory.mockImplementation(() => ({ push: mockPush })); + +const defaultProps = { + filters: [ + { + attribute_code: 'price', + label: 'Price', + options: [ + { + label: '*-100', + value: '*_100' + } + ] + }, + { + attribute_code: 'category_id', + label: 'Category', + options: [ + { + label: 'Bottoms', + value: '28' + }, + { + label: 'Tops', + value: '19' + } + ] + }, + { + attribute_code: 'foo', + label: 'Foo', + options: [ + { + label: 'Bar', + value: 'bar' + } + ] + } + ], + operations: {} +}; +const log = jest.fn(); + +const Component = () => { + const talonProps = useFilterSidebar(defaultProps); + + useEffect(() => { + log(talonProps); + }, [talonProps]); + + return null; +}; +describe('#useFilterSidebar', () => { + beforeEach(() => { + log.mockClear(); + mockPush.mockClear(); + }); + + it('returns expected shape', () => { + createTestInstance(); + + expect(log).toHaveBeenCalledWith({ + filterApi: expect.any(Object), + filterItems: expect.any(Object), + filterKeys: expect.any(Object), + filterNames: expect.any(Object), + filterState: expect.any(Object), + handleApply: expect.any(Function), + handleClose: expect.any(Function), + handleKeyDownActions: expect.any(Function), + handleOpen: expect.any(Function), + handleReset: expect.any(Function), + isApplying: expect.any(Boolean), + isOpen: expect.any(Boolean) + }); + }); + + it('enables category_id filter on search page', () => { + useLocation.mockReturnValueOnce({ + pathname: '/search.html' + }); + createTestInstance(); + const { filterNames } = log.mock.calls[0][0]; + expect(filterNames.get('category_id')).toBeTruthy(); + }); + + it('only renders filters that are valid and enabled', () => { + createTestInstance(); + const { filterNames } = log.mock.calls[0][0]; + expect(filterNames.get('foo')).toBeTruthy(); + }); + + it('writes filter state to history when "isApplying"', () => { + createTestInstance(); + const { handleApply } = log.mock.calls[0][0]; + + act(() => { + handleApply(); + }); + expect(mockPush).toHaveBeenCalled(); + }); +}); diff --git a/packages/peregrine/lib/talons/FilterSidebar/index.js b/packages/peregrine/lib/talons/FilterSidebar/index.js new file mode 100644 index 0000000000..573929fab8 --- /dev/null +++ b/packages/peregrine/lib/talons/FilterSidebar/index.js @@ -0,0 +1 @@ +export { useFilterSidebar } from './useFilterSidebar'; diff --git a/packages/peregrine/lib/talons/FilterSidebar/useFilterSidebar.js b/packages/peregrine/lib/talons/FilterSidebar/useFilterSidebar.js new file mode 100644 index 0000000000..6606e18aab --- /dev/null +++ b/packages/peregrine/lib/talons/FilterSidebar/useFilterSidebar.js @@ -0,0 +1,202 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useQuery } from '@apollo/client'; +import { useHistory, useLocation } from 'react-router-dom'; + +import { useAppContext } from '@magento/peregrine/lib/context/app'; + +import mergeOperations from '../../util/shallowMerge'; +import { useFilterState } from '../FilterModal'; +import { + getSearchFromState, + getStateFromSearch, + stripHtml +} from '../FilterModal/helpers'; + +import DEFAULT_OPERATIONS from '../FilterModal/filterModal.gql'; + +const DRAWER_NAME = 'filter'; + +export const useFilterSidebar = props => { + const { filters } = props; + + const operations = mergeOperations(DEFAULT_OPERATIONS, props.operations); + const { getFilterInputsQuery } = operations; + + const [isApplying, setIsApplying] = useState(false); + const [{ drawer }, { toggleDrawer, closeDrawer }] = useAppContext(); + const [filterState, filterApi] = useFilterState(); + const prevDrawer = useRef(null); + const isOpen = drawer === DRAWER_NAME; + + const history = useHistory(); + const { pathname, search } = useLocation(); + + const { data: introspectionData } = useQuery(getFilterInputsQuery); + + const inputFields = introspectionData + ? introspectionData.__type.inputFields + : []; + + const attributeCodes = useMemo( + () => filters.map(({ attribute_code }) => attribute_code), + [filters] + ); + + // Create a set of disabled filters. + const DISABLED_FILTERS = useMemo(() => { + const disabled = new Set(); + // Disable category filtering when not on a search page. + if (pathname !== '/search.html') { + disabled.add('category_id'); + } + + return disabled; + }, [pathname]); + + // Get "allowed" filters by intersection of filter attribute codes and + // schema input field types. This restricts the displayed filters to those + // that the api will understand. + const possibleFilters = useMemo(() => { + const nextFilters = new Set(); + + // perform mapping and filtering in the same cycle + for (const { name } of inputFields) { + const isValid = attributeCodes.includes(name); + const isEnabled = !DISABLED_FILTERS.has(name); + + if (isValid && isEnabled) { + nextFilters.add(name); + } + } + + return nextFilters; + }, [DISABLED_FILTERS, attributeCodes, inputFields]); + + // iterate over filters once to set up all the collections we need + const [filterNames, filterKeys, filterItems] = useMemo(() => { + const names = new Map(); + const keys = new Set(); + const itemsByGroup = new Map(); + + for (const filter of filters) { + const { options, label: name, attribute_code: group } = filter; + + // If this aggregation is not a possible filter, just back out. + if (possibleFilters.has(group)) { + const items = []; + + // add filter name + names.set(group, name); + + // add filter key permutations + keys.add(`${group}[filter]`); + + // add items + for (const { label, value } of options) { + items.push({ title: stripHtml(label), value }); + } + itemsByGroup.set(group, items); + } + } + + return [names, keys, itemsByGroup]; + }, [filters, possibleFilters]); + + // on apply, write filter state to location + useEffect(() => { + if (isApplying) { + const nextSearch = getSearchFromState( + search, + filterKeys, + filterState + ); + + // write filter state to history + history.push({ pathname, search: nextSearch }); + + // mark the operation as complete + setIsApplying(false); + } + }, [filterKeys, filterState, history, isApplying, pathname, search]); + + const handleOpen = useCallback(() => { + toggleDrawer(DRAWER_NAME); + }, [toggleDrawer]); + + const handleClose = useCallback(() => { + closeDrawer(); + }, [closeDrawer]); + + const handleApply = useCallback(() => { + setIsApplying(true); + handleClose(); + }, [handleClose]); + + const handleReset = useCallback(() => { + filterApi.clear(); + setIsApplying(true); + }, [filterApi, setIsApplying]); + + const handleKeyDownActions = useCallback( + event => { + // do not handle keyboard actions when the modal is closed + if (!isOpen) { + return; + } + + switch (event.keyCode) { + // when "Esc" key fired -> close the modal + case 27: + handleClose(); + break; + } + }, + [isOpen, handleClose] + ); + + useEffect(() => { + const justOpened = + prevDrawer.current === null && drawer === DRAWER_NAME; + const justClosed = + prevDrawer.current === DRAWER_NAME && drawer === null; + + // on drawer toggle, read filter state from location + if (justOpened || justClosed) { + const nextState = getStateFromSearch( + search, + filterKeys, + filterItems + ); + + filterApi.setItems(nextState); + } + + // on drawer close, update the modal visibility state + if (justClosed) { + handleClose(); + } + + prevDrawer.current = drawer; + }, [drawer, filterApi, filterItems, filterKeys, search, handleClose]); + + useEffect(() => { + const nextState = getStateFromSearch(search, filterKeys, filterItems); + + filterApi.setItems(nextState); + }, [filterApi, filterItems, filterKeys, search]); + + return { + filterApi, + filterItems, + filterKeys, + filterNames, + filterState, + handleApply, + handleClose, + handleKeyDownActions, + handleOpen, + handleReset, + isApplying, + isOpen + }; +}; diff --git a/packages/peregrine/lib/talons/RootComponents/Category/__tests__/__snapshots__/useCategoryContent.spec.js.snap b/packages/peregrine/lib/talons/RootComponents/Category/__tests__/__snapshots__/useCategoryContent.spec.js.snap index a1f4d20f7c..944a6a48fe 100644 --- a/packages/peregrine/lib/talons/RootComponents/Category/__tests__/__snapshots__/useCategoryContent.spec.js.snap +++ b/packages/peregrine/lib/talons/RootComponents/Category/__tests__/__snapshots__/useCategoryContent.spec.js.snap @@ -16,6 +16,7 @@ Object { null, null, ], + "totalCount": null, "totalPagesFromData": null, } `; @@ -39,6 +40,7 @@ Object { "name": "Necklace", }, ], + "totalCount": 2, "totalPagesFromData": 1, } `; diff --git a/packages/peregrine/lib/talons/RootComponents/Category/__tests__/useCategoryContent.spec.js b/packages/peregrine/lib/talons/RootComponents/Category/__tests__/useCategoryContent.spec.js index 1f58650902..2ad7fcc31e 100644 --- a/packages/peregrine/lib/talons/RootComponents/Category/__tests__/useCategoryContent.spec.js +++ b/packages/peregrine/lib/talons/RootComponents/Category/__tests__/useCategoryContent.spec.js @@ -46,7 +46,8 @@ const mockProps = { id: 2, name: 'Necklace' } - ] + ], + total_count: 2 } } }; diff --git a/packages/peregrine/lib/talons/RootComponents/Category/useCategoryContent.js b/packages/peregrine/lib/talons/RootComponents/Category/useCategoryContent.js index 98a514c8e5..dde2fc42e2 100644 --- a/packages/peregrine/lib/talons/RootComponents/Category/useCategoryContent.js +++ b/packages/peregrine/lib/talons/RootComponents/Category/useCategoryContent.js @@ -63,6 +63,7 @@ export const useCategoryContent = props => { const totalPagesFromData = data ? data.products.page_info.total_pages : null; + const totalCount = data ? data.products.total_count : null; const categoryName = categoryData ? categoryData.category.name : null; const categoryDescription = categoryData ? categoryData.category.description @@ -73,6 +74,7 @@ export const useCategoryContent = props => { categoryDescription, filters, items, + totalCount, totalPagesFromData }; }; diff --git a/packages/peregrine/lib/targets/__tests__/peregrine-targets.spec.js b/packages/peregrine/lib/targets/__tests__/peregrine-targets.spec.js index e8a9996d80..b2f020ba66 100644 --- a/packages/peregrine/lib/targets/__tests__/peregrine-targets.spec.js +++ b/packages/peregrine/lib/targets/__tests__/peregrine-targets.spec.js @@ -145,8 +145,10 @@ test('exposes all hooks and targets', async () => { talons.CreateAccountPage.useCreateAccountPage.wrapWith() wraps export "useCreateAccountPage" from "CreateAccountPage/useCreateAccountPage.js" talons.FilterModal.useFilterBlock.wrapWith() wraps export "useFilterBlock" from "FilterModal/useFilterBlock.js" talons.FilterModal.useFilterFooter.wrapWith() wraps export "useFilterFooter" from "FilterModal/useFilterFooter.js" + talons.FilterModal.useFilterList.wrapWith() wraps export "useFilterList" from "FilterModal/useFilterList.js" talons.FilterModal.useFilterModal.wrapWith() wraps export "useFilterModal" from "FilterModal/useFilterModal.js" talons.FilterModal.useFilterState.wrapWith() wraps export "useFilterState" from "FilterModal/useFilterState.js" + talons.FilterSidebar.useFilterSidebar.wrapWith() wraps export "useFilterSidebar" from "FilterSidebar/useFilterSidebar.js" talons.Footer.useFooter.wrapWith() wraps export "useFooter" from "Footer/useFooter.js" talons.ForgotPassword.useForgotPassword.wrapWith() wraps export "useForgotPassword" from "ForgotPassword/useForgotPassword.js" talons.FormError.useFormError.wrapWith() wraps export "useFormError" from "FormError/useFormError.js" diff --git a/packages/venia-ui/i18n/en_US.json b/packages/venia-ui/i18n/en_US.json index 16fa91bd68..bc31f456d2 100644 --- a/packages/venia-ui/i18n/en_US.json +++ b/packages/venia-ui/i18n/en_US.json @@ -49,6 +49,7 @@ "cartTrigger.ariaLabel": "Toggle mini cart. You have {count} items in your cart.", "categoryContent.filter": "Filter", "categoryContent.itemsSortedBy": "Items sorted by ", + "categoryContent.resultCount": "{count} Results", "categoryLeaf.allLabel": "All {name}", "categoryList.noResults": "No child categories found.", "checkoutPage.accountSuccessfullyCreated": "Account successfully created.", @@ -147,6 +148,8 @@ "errorView.message": "Looks like something went wrong. Sorry about that.", "field.optional": "Optional", "filterFooter.results": "See Results", + "filterList.showMore": "Show More", + "filterList.showLess": "Show Less", "filterModal.action": "Clear all", "filterModal.action.clearAll.ariaLabel": "Clear all applied filters", "filterModal.action.clearFilterItem.ariaLabel": "Clear filter \"{name}\"", @@ -319,6 +322,7 @@ "productListing.loading": "Fetching Cart...", "productOptions.selectedLabel": "Selected {label}:", "productQuantity.label": "product's quantity", + "productSort.sortByButton": "Sort by", "productSort.sortButton": "Sort", "quantity.buttonDecrement": "Decrease Quantity", "quantity.buttonIncrement": "Increase Quantity", diff --git a/packages/venia-ui/lib/RootComponents/Category/__tests__/__snapshots__/categoryContent.spec.js.snap b/packages/venia-ui/lib/RootComponents/Category/__tests__/__snapshots__/categoryContent.spec.js.snap index f19045737d..817598a682 100644 --- a/packages/venia-ui/lib/RootComponents/Category/__tests__/__snapshots__/categoryContent.spec.js.snap +++ b/packages/venia-ui/lib/RootComponents/Category/__tests__/__snapshots__/categoryContent.spec.js.snap @@ -9,64 +9,120 @@ Array [
-

-
- Name -
-

+
+ Name +
+ +
+
- + +
+
+
- - -
- + -
-
- + /> + +
+ +
, ] @@ -81,79 +137,143 @@ Array [
-

-
- Name -
-

+
+ Name +
+ +
- +
+
- + +
+
+ - - -
- + +
+
+ +
+ - -
-
-
, ] `; @@ -167,21 +287,39 @@ Array [
-

-
- Empty Name -
-

+
+ Empty Name +
+ +
- +
+
+
+
+
+ +
, ] `; @@ -195,94 +333,112 @@ Array [
-

-
- Name -
-

+
+ Name +
+ +
- +
+
+
+
- - - - - - - - - + + + + + + + + + + + + - - - - - + +
, ] @@ -297,64 +453,120 @@ Array [
-

-
- Name -
-

+
+ Name +
+ + +
- + +
+
+
- - -
- + -
-
- + /> + +
+ +
, ] @@ -369,21 +581,39 @@ Array [
-

-
- Name -
-

+
+ Name +
+ +
- +
+
+
+
+
+ +
, ] `; @@ -397,64 +627,120 @@ Array [
-

-
- Name -
-

+
+ Name +
+ +
+
- + +
+
+
- - -
- + -
-
- + /> + +
+ +
, ] diff --git a/packages/venia-ui/lib/RootComponents/Category/category.css b/packages/venia-ui/lib/RootComponents/Category/category.css index ee8b2050dd..01fdfd9fef 100644 --- a/packages/venia-ui/lib/RootComponents/Category/category.css +++ b/packages/venia-ui/lib/RootComponents/Category/category.css @@ -1,3 +1,7 @@ +:root { + --category-sidebar-width: 325px; +} + .root { padding: 1rem; } @@ -17,12 +21,6 @@ height: 100vh; } -.headerButtons { - display: flex; - justify-content: center; - padding-bottom: 1.5rem; -} - .categoryTitle { color: rgb(var(--venia-global-color-text)); padding-bottom: 1rem; @@ -31,3 +29,69 @@ line-height: 1.375rem; text-align: center; } + +.heading { + display: flex; + flex-wrap: wrap; + justify-content: center; + padding-bottom: 0.5rem; +} + +.categoryInfo { + line-height: var(--venia-global-typography-heading-lineHeight); + margin: 1rem 0; + max-width: 75vw; +} + +.headerButtons { + display: flex; + flex-basis: 100%; + justify-content: center; + padding-bottom: 1.5rem; +} + +.sidebar { + display: none; +} + +@media (min-width: 1024px) { + .root { + display: flex; + flex-wrap: wrap; + } + + .categoryHeader { + width: 100%; + } + + .headerButtons { + justify-content: flex-end; + } + + .heading { + justify-content: space-between; + flex-wrap: nowrap; + align-items: center; + padding-bottom: 1.5rem; + } + + .headerButtons { + padding-bottom: 0; + } + + .categoryInfo { + margin: 0; + flex-basis: 100%; + } + + .sidebar { + display: flex; + align-self: flex-start; + width: var(--category-sidebar-width); + padding-top: 4rem; + } + + .categoryContent { + flex-grow: 1; + } +} diff --git a/packages/venia-ui/lib/RootComponents/Category/categoryContent.js b/packages/venia-ui/lib/RootComponents/Category/categoryContent.js index ce2bac96d1..424645d424 100644 --- a/packages/venia-ui/lib/RootComponents/Category/categoryContent.js +++ b/packages/venia-ui/lib/RootComponents/Category/categoryContent.js @@ -1,4 +1,5 @@ import React, { Fragment, Suspense, useMemo } from 'react'; +import { FormattedMessage } from 'react-intl'; import { array, number, shape, string } from 'prop-types'; import { useCategoryContent } from '@magento/peregrine/lib/talons/RootComponents/Category'; @@ -16,6 +17,9 @@ import SortedByContainer from '../../components/SortedByContainer'; import FilterModalOpenButton from '../../components/FilterModalOpenButton'; const FilterModal = React.lazy(() => import('../../components/FilterModal')); +const FilterSidebar = React.lazy(() => + import('../../components/FilterSidebar') +); const CategoryContent = props => { const { @@ -39,6 +43,7 @@ const CategoryContent = props => { categoryDescription, filters, items, + totalCount, totalPagesFromData } = talonProps; @@ -57,6 +62,10 @@ const CategoryContent = props => { ) : null; + const sidebar = shouldShowFilterButtons ? ( + + ) : null; + const maybeSortButton = shouldShowSortButtons ? ( ) : null; @@ -65,6 +74,17 @@ const CategoryContent = props => { ) : null; + const categoryResultsHeading = + totalCount > 0 ? ( + + ) : null; + const categoryDescriptionElement = categoryDescription ? ( ) : null; @@ -103,19 +123,31 @@ const CategoryContent = props => { {categoryName}
-

-
- {categoryName || '...'} +
+

+
+ {categoryName || '...'} +
+

+ {categoryDescriptionElement} +
+
+ {sidebar} +
+
+
+
+ {categoryResultsHeading} +
+
+ {maybeFilterButtons} + {maybeSortButton} +
+ {maybeSortContainer}
-

- {categoryDescriptionElement} -
- {maybeFilterButtons} - {maybeSortButton} + {content} + {filtersModal}
- {maybeSortContainer} - {content} - {filtersModal}
); diff --git a/packages/venia-ui/lib/components/FilterModal/CurrentFilters/currentFilter.js b/packages/venia-ui/lib/components/FilterModal/CurrentFilters/currentFilter.js index ec4f72b9d9..7dc3349c21 100644 --- a/packages/venia-ui/lib/components/FilterModal/CurrentFilters/currentFilter.js +++ b/packages/venia-ui/lib/components/FilterModal/CurrentFilters/currentFilter.js @@ -1,6 +1,6 @@ import React, { useCallback } from 'react'; import { useIntl } from 'react-intl'; -import { shape, string } from 'prop-types'; +import { shape, string, func } from 'prop-types'; import { X as Remove } from 'react-feather'; import { mergeClasses } from '../../../classify'; @@ -9,13 +9,16 @@ import Trigger from '../../Trigger'; import defaultClasses from './currentFilter.css'; const CurrentFilter = props => { - const { group, item, removeItem } = props; + const { group, item, removeItem, onRemove } = props; const { formatMessage } = useIntl(); const classes = mergeClasses(defaultClasses, props.classes); const handleClick = useCallback(() => { removeItem({ group, item }); - }, [group, item, removeItem]); + if (typeof onRemove === 'function') { + onRemove(group, item); + } + }, [group, item, removeItem, onRemove]); const ariaLabel = formatMessage( { @@ -39,8 +42,13 @@ const CurrentFilter = props => { export default CurrentFilter; +CurrentFilter.defaultProps = { + onRemove: null +}; + CurrentFilter.propTypes = { classes: shape({ root: string - }) + }), + onRemove: func }; diff --git a/packages/venia-ui/lib/components/FilterModal/CurrentFilters/currentFilters.js b/packages/venia-ui/lib/components/FilterModal/CurrentFilters/currentFilters.js index 86f0732ec8..64e7f82c0f 100644 --- a/packages/venia-ui/lib/components/FilterModal/CurrentFilters/currentFilters.js +++ b/packages/venia-ui/lib/components/FilterModal/CurrentFilters/currentFilters.js @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { shape, string } from 'prop-types'; +import { shape, string, func } from 'prop-types'; import { mergeClasses } from '../../../classify'; import CurrentFilter from './currentFilter'; @@ -7,7 +7,7 @@ import defaultClasses from './currentFilters.css'; import { useIntl } from 'react-intl'; const CurrentFilters = props => { - const { filterApi, filterState } = props; + const { filterApi, filterState, onRemove } = props; const { removeItem } = filterApi; const classes = mergeClasses(defaultClasses, props.classes); const { formatMessage } = useIntl(); @@ -26,6 +26,7 @@ const CurrentFilters = props => { group={group} item={item} removeItem={removeItem} + onRemove={onRemove} /> ); @@ -33,7 +34,7 @@ const CurrentFilters = props => { } return elements; - }, [classes.item, filterState, removeItem]); + }, [classes.item, filterState, removeItem, onRemove]); const currentFiltersAriaLabel = formatMessage({ id: 'filterModal.currentFilters.ariaLabel', @@ -47,13 +48,18 @@ const CurrentFilters = props => { ); }; +CurrentFilters.defaultProps = { + onRemove: null +}; + CurrentFilters.propTypes = { classes: shape({ root: string, item: string, button: string, icon: string - }) + }), + onRemove: func }; export default CurrentFilters; diff --git a/packages/venia-ui/lib/components/FilterModal/FilterList/__tests__/filterItem.spec.js b/packages/venia-ui/lib/components/FilterModal/FilterList/__tests__/filterItem.spec.js new file mode 100644 index 0000000000..dff5959d4c --- /dev/null +++ b/packages/venia-ui/lib/components/FilterModal/FilterList/__tests__/filterItem.spec.js @@ -0,0 +1,131 @@ +import React from 'react'; +import { createTestInstance } from '@magento/peregrine'; +import { mockFilterDefault } from '../filterDefault'; +import FilterItem from '../filterItem'; + +const mockOnApply = jest.fn(); + +jest.mock('../filterDefault', () => { + const mockedFilterDefault = jest.fn(({ onClick }) => { + onClick(); + + return null; + }); + + return { + __esModule: true, + default: mockedFilterDefault, + mockFilterDefault: mockedFilterDefault + }; +}); + +let inputProps = {}; + +const Component = () => { + return ; +}; + +const givenDefaultValues = () => { + inputProps = { + filterApi: { + toggleItem: jest.fn() + }, + filterState: new Set(), + group: 'Foo', + item: { + title: 'Bar', + value: 'bar' + }, + isExpanded: true, + onApply: null + }; +}; + +const givenOnApply = () => { + inputProps = { + filterApi: { + toggleItem: jest.fn() + }, + filterState: new Set(), + group: 'Foo', + item: { + title: 'Bar', + value: 'bar' + }, + isExpanded: true, + onApply: mockOnApply + }; +}; + +const givenSelectedItem = () => { + const item = { + title: 'Bar', + value: 'bar' + }; + + inputProps = { + filterApi: { + toggleItem: jest.fn() + }, + filterState: new Set().add(item), + group: 'Foo', + item: item, + isExpanded: true + }; +}; + +describe('#FilterItem', () => { + beforeEach(() => { + mockFilterDefault.mockClear(); + mockOnApply.mockClear(); + + givenDefaultValues(); + }); + + it('passes props to FilterDefault', () => { + createTestInstance(); + + expect(mockFilterDefault).toHaveBeenCalledWith( + expect.objectContaining({ + isExpanded: true, + isSelected: false, + onClick: expect.any(Function) + }), + {} + ); + }); + + it('handles no onApply function being passed', () => { + createTestInstance(); + + expect(inputProps.filterApi.toggleItem).toHaveBeenCalledWith({ + group: inputProps.group, + item: inputProps.item + }); + }); + + it('calls onApply when passed', () => { + givenOnApply(); + createTestInstance(); + + expect(inputProps.filterApi.toggleItem).toHaveBeenCalledWith({ + group: inputProps.group, + item: inputProps.item + }); + expect(mockOnApply).toHaveBeenCalled(); + }); + + it('determines the item is selected', () => { + givenSelectedItem(); + createTestInstance(); + + expect(mockFilterDefault).toHaveBeenCalledWith( + expect.objectContaining({ + isExpanded: true, + onClick: expect.any(Function), + isSelected: true + }), + {} + ); + }); +}); diff --git a/packages/venia-ui/lib/components/FilterModal/FilterList/filterItem.js b/packages/venia-ui/lib/components/FilterModal/FilterList/filterItem.js index 13090ca354..1d48103caf 100644 --- a/packages/venia-ui/lib/components/FilterModal/FilterList/filterItem.js +++ b/packages/venia-ui/lib/components/FilterModal/FilterList/filterItem.js @@ -5,7 +5,7 @@ import setValidator from '@magento/peregrine/lib/validators/set'; import FilterDefault from './filterDefault'; const FilterItem = props => { - const { filterApi, filterState, group, item, isExpanded } = props; + const { filterApi, filterState, group, item, isExpanded, onApply } = props; const { toggleItem } = filterApi; const { title, value } = item; const isSelected = filterState && filterState.has(item); @@ -21,7 +21,11 @@ const FilterItem = props => { const handleClick = useCallback(() => { toggleItem({ group, item }); - }, [group, item, toggleItem]); + + if (typeof onApply === 'function') { + onApply(group, item); + } + }, [group, item, toggleItem, onApply]); return ( { ); }; -export default FilterItem; +FilterItem.defaultProps = { + onChange: null +}; FilterItem.propTypes = { filterApi: shape({ @@ -46,5 +52,8 @@ FilterItem.propTypes = { item: shape({ title: string.isRequired, value: oneOfType([number, string]).isRequired - }).isRequired + }).isRequired, + onChange: func }; + +export default FilterItem; diff --git a/packages/venia-ui/lib/components/FilterModal/FilterList/filterList.css b/packages/venia-ui/lib/components/FilterModal/FilterList/filterList.css index a1fcc4fb52..7b08861384 100644 --- a/packages/venia-ui/lib/components/FilterModal/FilterList/filterList.css +++ b/packages/venia-ui/lib/components/FilterModal/FilterList/filterList.css @@ -5,3 +5,20 @@ margin-left: -0.375rem; padding-bottom: 2rem; } + +.itemHidden { + display: none; +} + +.showMoreLessItem { + padding-left: 3px; +} + +.showMoreLessButton { + font-size: 14px; + text-decoration: underline; +} + +.showMoreLessButton:hover { + text-decoration: none; +} diff --git a/packages/venia-ui/lib/components/FilterModal/FilterList/filterList.js b/packages/venia-ui/lib/components/FilterModal/FilterList/filterList.js index 728aa4308b..a2ba778c22 100644 --- a/packages/venia-ui/lib/components/FilterModal/FilterList/filterList.js +++ b/packages/venia-ui/lib/components/FilterModal/FilterList/filterList.js @@ -1,6 +1,8 @@ import React, { Fragment, useMemo } from 'react'; -import { array, shape, string } from 'prop-types'; +import { array, shape, string, func, number, bool } from 'prop-types'; +import { useIntl } from 'react-intl'; import setValidator from '@magento/peregrine/lib/validators/set'; +import { useFilterList } from '@magento/peregrine/lib/talons/FilterModal'; import { mergeClasses } from '../../../classify'; import FilterItem from './filterItem'; @@ -9,25 +11,41 @@ import defaultClasses from './filterList.css'; const labels = new WeakMap(); const FilterList = props => { - const { filterApi, filterState, group, items, isExpanded } = props; + const { + filterApi, + filterState, + group, + items, + isExpanded, + onApply, + itemCountToShow + } = props; const classes = mergeClasses(defaultClasses, props.classes); + const talonProps = useFilterList(); + const { isListExpanded, handleListToggle } = talonProps; + const { formatMessage } = useIntl(); // memoize item creation // search value is not referenced, so this array is stable const itemElements = useMemo( () => - items.map(item => { + items.map((item, index) => { const { title, value } = item; const key = `item-${group}-${value}`; + const itemClass = + isListExpanded || index < itemCountToShow + ? classes.item + : classes.itemHidden; // create an element for each item const element = ( -
  • +
  • @@ -39,16 +57,69 @@ const FilterList = props => { return element; }), - [classes, filterApi, filterState, group, items, isExpanded] + [ + classes, + filterApi, + filterState, + group, + items, + isExpanded, + isListExpanded, + itemCountToShow, + onApply + ] ); + const showMoreLessItem = useMemo(() => { + if (items.length <= itemCountToShow) { + return null; + } + + const label = isListExpanded + ? formatMessage({ + id: 'filterList.showLess', + defaultMessage: 'Show Less' + }) + : formatMessage({ + id: 'filterList.showMore', + defaultMessage: 'Show More' + }); + + return ( +
  • + +
  • + ); + }, [ + isListExpanded, + handleListToggle, + items, + itemCountToShow, + formatMessage, + classes + ]); + return ( -
      {itemElements}
    +
      + {itemElements} + {showMoreLessItem} +
    ); }; +FilterList.defaultProps = { + onApply: null, + itemCountToShow: 5, + isExpanded: false +}; + FilterList.propTypes = { classes: shape({ item: string, @@ -57,7 +128,10 @@ FilterList.propTypes = { filterApi: shape({}), filterState: setValidator, group: string, - items: array + items: array, + onApply: func, + itemCountToShow: number, + isExpanded: bool }; export default FilterList; diff --git a/packages/venia-ui/lib/components/FilterModal/__tests__/filterModal.spec.js b/packages/venia-ui/lib/components/FilterModal/__tests__/filterModal.spec.js new file mode 100644 index 0000000000..21c77c663a --- /dev/null +++ b/packages/venia-ui/lib/components/FilterModal/__tests__/filterModal.spec.js @@ -0,0 +1,159 @@ +import React from 'react'; +import { createTestInstance } from '@magento/peregrine'; +import { mockFilterBlock } from '../filterBlock'; +import { mockCurrentFilters } from '../CurrentFilters'; +import FilterModal from '../filterModal'; + +const mockFilters = [ + { + attribute_code: 'foo', + label: 'Foo', + options: [ + { + label: 'value 1', + value: 1 + } + ] + }, + { + attribute_code: 'bar', + label: 'Bar', + options: [ + { + label: 'value 1', + value: 1 + }, + { + label: 'value 2', + value: 2 + } + ] + }, + { + attribute_code: 'baz', + label: 'Baz', + options: [ + { + label: 'value 1', + value: 1 + } + ] + } +]; + +jest.mock('../../../classify'); + +jest.mock('../../Portal', () => ({ + Portal: jest.fn(({ children }) => { + return children; + }) +})); + +jest.mock('react-aria', () => ({ + FocusScope: jest.fn(({ children }) => { + return children; + }) +})); + +jest.mock('@magento/peregrine/lib/talons/FilterModal', () => ({ + useFilterModal: jest.fn(({ filters }) => { + const names = new Map(); + const itemsByGroup = new Map(); + + for (const filter of filters) { + const { options, label: name, attribute_code: group } = filter; + const items = []; + // add filter name + names.set(group, name); + + // add items + for (const { label, value } of options) { + items.push({ title: label, value }); + } + itemsByGroup.set(group, items); + } + + return { + filterApi: null, + filterItems: itemsByGroup, + filterNames: names, + filterState: new Map(), + handleApply: jest.fn(), + handleClose: jest.fn(), + handleReset: jest.fn(), + handleKeyDownActions: jest.fn(), + isOpen: true + }; + }) +})); + +jest.mock('../filterBlock', () => { + const mockedFilterBlock = jest.fn(() => { + return null; + }); + + return { + __esModule: true, + default: mockedFilterBlock, + mockFilterBlock: mockedFilterBlock + }; +}); + +jest.mock('../CurrentFilters', () => { + const mockedCurrentFilters = jest.fn(() => { + return null; + }); + + return { + __esModule: true, + default: mockedCurrentFilters, + mockCurrentFilters: mockedCurrentFilters + }; +}); + +jest.mock('../filterFooter', () => { + return jest.fn(() => { + return null; + }); +}); + +let inputProps = {}; + +const Component = () => { + return ; +}; + +const givenDefaultValues = () => { + inputProps = { + filters: [] + }; +}; + +const givenFilters = () => { + inputProps = { + filters: mockFilters + }; +}; + +describe('#FilterModal', () => { + beforeEach(() => { + mockFilterBlock.mockClear(); + mockCurrentFilters.mockClear(); + + givenDefaultValues(); + }); + + it('renders without filters', () => { + createTestInstance(); + + expect(mockFilterBlock).not.toHaveBeenCalled(); + expect(mockCurrentFilters).toHaveBeenCalled(); + }); + + it('renders with filters', () => { + givenFilters(); + createTestInstance(); + + expect(mockFilterBlock).toHaveBeenCalledTimes(mockFilters.length); + }); +}); diff --git a/packages/venia-ui/lib/components/FilterModal/filterBlock.js b/packages/venia-ui/lib/components/FilterModal/filterBlock.js index 377320bab3..8490c4107c 100644 --- a/packages/venia-ui/lib/components/FilterModal/filterBlock.js +++ b/packages/venia-ui/lib/components/FilterModal/filterBlock.js @@ -1,6 +1,6 @@ import React from 'react'; +import { arrayOf, shape, string, func, bool } from 'prop-types'; import { useIntl } from 'react-intl'; -import { arrayOf, shape, string } from 'prop-types'; import { ChevronDown as ArrowDown, ChevronUp as ArrowUp } from 'react-feather'; import { Form } from 'informed'; import { useFilterBlock } from '@magento/peregrine/lib/talons/FilterModal'; @@ -12,9 +12,22 @@ import FilterList from './FilterList'; import defaultClasses from './filterBlock.css'; const FilterBlock = props => { - const { filterApi, filterState, group, items, name } = props; + const { + filterApi, + filterState, + group, + items, + name, + onApply, + initialOpen + } = props; + const { formatMessage } = useIntl(); - const talonProps = useFilterBlock(); + const talonProps = useFilterBlock({ + filterState, + items, + initialOpen + }); const { handleClick, isExpanded } = talonProps; const iconSrc = isExpanded ? ArrowUp : ArrowDown; const classes = mergeClasses(defaultClasses, props.classes); @@ -72,6 +85,7 @@ const FilterBlock = props => { filterState={filterState} group={group} items={items} + onApply={onApply} isExpanded={isExpanded} /> @@ -79,7 +93,10 @@ const FilterBlock = props => { ); }; -export default FilterBlock; +FilterBlock.defaultProps = { + onApply: null, + initialOpen: false +}; FilterBlock.propTypes = { classes: shape({ @@ -94,5 +111,9 @@ FilterBlock.propTypes = { filterState: setValidator, group: string.isRequired, items: arrayOf(shape({})), - name: string.isRequired + name: string.isRequired, + onApply: func, + initialOpen: bool }; + +export default FilterBlock; diff --git a/packages/venia-ui/lib/components/FilterModalOpenButton/filterModalOpenButton.css b/packages/venia-ui/lib/components/FilterModalOpenButton/filterModalOpenButton.css index 588ca7de5c..3ff3940e4b 100644 --- a/packages/venia-ui/lib/components/FilterModalOpenButton/filterModalOpenButton.css +++ b/packages/venia-ui/lib/components/FilterModalOpenButton/filterModalOpenButton.css @@ -2,3 +2,9 @@ composes: root_lowPriority from '../../components/Button/button.css'; min-width: 6.25rem; } + +@media (min-width: 1024px) { + .filterButton { + display: none; + } +} diff --git a/packages/venia-ui/lib/components/FilterSidebar/__tests__/filterSidebar.spec.js b/packages/venia-ui/lib/components/FilterSidebar/__tests__/filterSidebar.spec.js new file mode 100644 index 0000000000..4d7f686570 --- /dev/null +++ b/packages/venia-ui/lib/components/FilterSidebar/__tests__/filterSidebar.spec.js @@ -0,0 +1,162 @@ +import React from 'react'; +import { createTestInstance } from '@magento/peregrine'; +import { mockFilterBlock } from '../../FilterModal/filterBlock'; +import { mockCurrentFilters } from '../../FilterModal/CurrentFilters'; +import FilterSidebar from '../filterSidebar'; + +const mockFilters = [ + { + attribute_code: 'foo', + label: 'Foo', + options: [ + { + label: 'value 1', + value: 1 + } + ] + }, + { + attribute_code: 'bar', + label: 'Bar', + options: [ + { + label: 'value 1', + value: 1 + }, + { + label: 'value 2', + value: 2 + } + ] + }, + { + attribute_code: 'baz', + label: 'Baz', + options: [ + { + label: 'value 1', + value: 1 + } + ] + } +]; + +const mockFiltersOpenCount = 2; + +jest.mock('@magento/peregrine/lib/talons/FilterSidebar', () => ({ + useFilterSidebar: jest.fn(({ filters }) => { + const names = new Map(); + const itemsByGroup = new Map(); + + for (const filter of filters) { + const { options, label: name, attribute_code: group } = filter; + const items = []; + // add filter name + names.set(group, name); + + // add items + for (const { label, value } of options) { + items.push({ title: label, value }); + } + itemsByGroup.set(group, items); + } + + return { + filterApi: null, + filterItems: itemsByGroup, + filterNames: names, + filterState: new Map(), + handleApply: jest.fn(), + handleReset: jest.fn() + }; + }) +})); + +jest.mock('../../FilterModal/filterBlock', () => { + const mockedFilterBlock = jest.fn(() => { + return null; + }); + + return { + __esModule: true, + default: mockedFilterBlock, + mockFilterBlock: mockedFilterBlock + }; +}); + +jest.mock('../../FilterModal/CurrentFilters', () => { + const mockedCurrentFilters = jest.fn(() => { + return null; + }); + + return { + __esModule: true, + default: mockedCurrentFilters, + mockCurrentFilters: mockedCurrentFilters + }; +}); + +let inputProps = {}; + +const Component = () => { + return ; +}; + +const givenDefaultValues = () => { + inputProps = { + filters: [] + }; +}; + +const givenFilters = () => { + inputProps = { + filters: mockFilters + }; +}; + +const givenFiltersAndAmountToShow = () => { + inputProps = { + filters: mockFilters, + filterCountToOpen: mockFiltersOpenCount + }; +}; + +describe('#FilterSidebar', () => { + beforeEach(() => { + mockFilterBlock.mockClear(); + mockCurrentFilters.mockClear(); + + givenDefaultValues(); + }); + + it('renders without filters', () => { + createTestInstance(); + + expect(mockFilterBlock).not.toHaveBeenCalled(); + expect(mockCurrentFilters).toHaveBeenCalled(); + }); + + it('renders with filters', () => { + givenFilters(); + createTestInstance(); + + expect(mockFilterBlock).toHaveBeenCalledTimes(mockFilters.length); + }); + + it('accepts configurable amount of open filters', () => { + givenFiltersAndAmountToShow(); + createTestInstance(); + + expect(mockFilterBlock).toHaveBeenCalledTimes(mockFilters.length); + + for (let i = 1; i <= mockFilters.length; i++) { + expect(mockFilterBlock).toHaveBeenNthCalledWith( + i, + expect.objectContaining({ + initialOpen: i <= mockFiltersOpenCount + }), + expect.any(Object) + ); + } + }); +}); diff --git a/packages/venia-ui/lib/components/FilterSidebar/filterSidebar.css b/packages/venia-ui/lib/components/FilterSidebar/filterSidebar.css new file mode 100644 index 0000000000..9a76aea438 --- /dev/null +++ b/packages/venia-ui/lib/components/FilterSidebar/filterSidebar.css @@ -0,0 +1,50 @@ +.root { + --borderColor: var(--venia-global-color-border); + background-color: white; + bottom: 0; + display: none; + grid-template-rows: 1fr 7rem; + max-width: 360px; + width: 100%; + z-index: 3; +} + +.body { + overflow: auto; +} + +.header { + display: flex; + justify-content: space-between; + padding: 1.25rem 1.25rem 0; +} + +.headerTitle { + display: flex; + align-items: center; + font-size: var(--venia-global-typography-heading-L-fontSize); + line-height: 0.875rem; +} + +.action { + padding: 1rem 1.25rem 0; +} + +.action button { + font-size: var(--venia-typography-body-S-fontSize); + text-decoration: none; +} + +.blocks { + padding: 1rem 1.25rem 0; +} + +.blocks > li:last-child { + border-bottom: 2px solid rgb(var(--borderColor)); +} + +@media (min-width: 1024px) { + .root { + display: grid; + } +} diff --git a/packages/venia-ui/lib/components/FilterSidebar/filterSidebar.js b/packages/venia-ui/lib/components/FilterSidebar/filterSidebar.js new file mode 100644 index 0000000000..7d1a760546 --- /dev/null +++ b/packages/venia-ui/lib/components/FilterSidebar/filterSidebar.js @@ -0,0 +1,141 @@ +import React, { useMemo, useCallback, useRef, Fragment } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { array, arrayOf, shape, string, number } from 'prop-types'; +import { useFilterSidebar } from '@magento/peregrine/lib/talons/FilterSidebar'; + +import { mergeClasses } from '../../classify'; +import LinkButton from '../LinkButton'; +import CurrentFilters from '../FilterModal/CurrentFilters'; +import FilterBlock from '../FilterModal/filterBlock'; +import defaultClasses from './filterSidebar.css'; + +const SCROLL_OFFSET = 150; + +/** + * A view that displays applicable and applied filters. + * + * @param {Object} props.filters - filters to display + */ +const FilterSidebar = props => { + const { filters, filterCountToOpen } = props; + const talonProps = useFilterSidebar({ filters }); + const { + filterApi, + filterItems, + filterNames, + filterState, + handleApply, + handleReset + } = talonProps; + + const filterRef = useRef(); + const classes = mergeClasses(defaultClasses, props.classes); + + const handleApplyFilter = useCallback( + (...args) => { + const filterElement = filterRef.current; + if ( + filterElement && + typeof filterElement.getBoundingClientRect === 'function' + ) { + const filterTop = filterElement.getBoundingClientRect().top; + const windowScrollY = + window.scrollY + filterTop - SCROLL_OFFSET; + window.scrollTo(0, windowScrollY); + } + + handleApply(...args); + }, + [handleApply, filterRef] + ); + + const filtersList = useMemo( + () => + Array.from(filterItems, ([group, items], iteration) => { + const blockState = filterState.get(group); + const groupName = filterNames.get(group); + + return ( + + ); + }), + [ + filterApi, + filterItems, + filterNames, + filterState, + filterCountToOpen, + handleApplyFilter + ] + ); + + const clearAll = filterState.size ? ( +
    + + + +
    + ) : null; + + return ( + + + + ); +}; + +FilterSidebar.defaultProps = { + filterCountToOpen: 3 +}; + +FilterSidebar.propTypes = { + classes: shape({ + action: string, + blocks: string, + body: string, + header: string, + headerTitle: string, + root: string, + root_open: string + }), + filters: arrayOf( + shape({ + attribute_code: string, + items: array + }) + ), + filterCountToOpen: number +}; + +export default FilterSidebar; diff --git a/packages/venia-ui/lib/components/FilterSidebar/index.js b/packages/venia-ui/lib/components/FilterSidebar/index.js new file mode 100644 index 0000000000..4d35f5678e --- /dev/null +++ b/packages/venia-ui/lib/components/FilterSidebar/index.js @@ -0,0 +1 @@ +export { default } from './filterSidebar'; diff --git a/packages/venia-ui/lib/components/ProductSort/__tests__/__snapshots__/productSort.spec.js.snap b/packages/venia-ui/lib/components/ProductSort/__tests__/__snapshots__/productSort.spec.js.snap index 0a4e3b37c5..e2055e6d49 100644 --- a/packages/venia-ui/lib/components/ProductSort/__tests__/__snapshots__/productSort.spec.js.snap +++ b/packages/venia-ui/lib/components/ProductSort/__tests__/__snapshots__/productSort.spec.js.snap @@ -8,10 +8,39 @@ exports[`renders correctly 1`] = ` type="button" > - + + + + + + +   + + + + + + + + diff --git a/packages/venia-ui/lib/components/ProductSort/productSort.css b/packages/venia-ui/lib/components/ProductSort/productSort.css index 51c2af657a..d4c315763b 100644 --- a/packages/venia-ui/lib/components/ProductSort/productSort.css +++ b/packages/venia-ui/lib/components/ProductSort/productSort.css @@ -37,3 +37,40 @@ composes: root_lowPriority from '../../components/Button/button.css'; min-width: 6.25rem; } + +.desktopText { + display: none; +} + +.sortText { + line-height: 24px; + font-size: var(--venia-global-fontSize-200); +} + +.desktopIconWrapper { + composes: root from '../Icon/icon.css'; + transform: translateX(10px); +} + +.desktopIcon { + composes: icon from '../Icon/icon.css'; + stroke: rgb(var(--venia-global-color-gray-500)); +} + +@media (min-width: 1024px) { + .sortButton { + border-width: 2px; + border-color: rgb(var(--venia-global-color-gray-500)); + border-radius: 6px; + font-weight: var(--venia-global-fontWeight-normal); + text-transform: none; + } + + .mobileText { + display: none; + } + + .desktopText { + display: inline-flex; + } +} diff --git a/packages/venia-ui/lib/components/ProductSort/productSort.js b/packages/venia-ui/lib/components/ProductSort/productSort.js index 97ff121fca..75425ec963 100644 --- a/packages/venia-ui/lib/components/ProductSort/productSort.js +++ b/packages/venia-ui/lib/components/ProductSort/productSort.js @@ -1,4 +1,5 @@ import React, { useMemo, useCallback } from 'react'; +import { ChevronDown as ArrowDown } from 'react-feather'; import { FormattedMessage } from 'react-intl'; import { array, arrayOf, shape, string, oneOf } from 'prop-types'; import { useDropdown } from '@magento/peregrine/lib/hooks/useDropdown'; @@ -7,9 +8,10 @@ import { mergeClasses } from '../../classify'; import SortItem from './sortItem'; import defaultClasses from './productSort.css'; import Button from '../Button'; +import Icon from '../Icon'; const ProductSort = props => { - const classes = mergeClasses(defaultClasses); + const classes = mergeClasses(defaultClasses, props.classes); const { availableSortMethods, sortProps } = props; const [currentSort, setSort] = sortProps; const { elementRef, expanded, setExpanded } = useDropdown(); @@ -81,10 +83,28 @@ const ProductSort = props => { }} onClick={handleSortClick} > - + + + + + + +  {currentSort.sortText} + + + {sortElements} diff --git a/packages/venia-ui/lib/components/SearchPage/__tests__/__snapshots__/searchPage.spec.js.snap b/packages/venia-ui/lib/components/SearchPage/__tests__/__snapshots__/searchPage.spec.js.snap index becaf11954..dc15144a06 100644 --- a/packages/venia-ui/lib/components/SearchPage/__tests__/__snapshots__/searchPage.spec.js.snap +++ b/packages/venia-ui/lib/components/SearchPage/__tests__/__snapshots__/searchPage.spec.js.snap @@ -5,49 +5,56 @@ exports[`Search Page Component error view does not render when data is present 1 className="root" >
    - - 0 items - -
    -
    + className="sidebar" + />
    - -
    -
    - -
    -
    - +
    + + + 0 items + +
    +
    +
    +
    + -
    + /> +
    +
    + +
    +
    `; @@ -56,27 +63,29 @@ exports[`Search Page Component error view renders when data is not present and n className="root" >
    - - 0 items - -
    -
    -
    - +
    +
    +
    +
    +
    + +
    `; @@ -86,49 +95,56 @@ exports[`Search Page Component filter button/modal does not render if there are className="root" >
    - - 0 items - -
    -
    + className="sidebar" + />
    - -
    -
    - -
    -
    - +
    + + + 0 items + +
    +
    +
    +
    + -
    + /> +
    +
    + +
    +
    `; @@ -137,64 +153,79 @@ exports[`Search Page Component filter button/modal renders when there are filter className="root" >
    + +
    +
    - - 0 items -
    +
    + + + 0 items + +
    +
    + +
    +
    +
    - -
    -
    -
    - -
    -
    - +
    + +
    + -
    -
    - -
    - +
    `; @@ -203,49 +234,51 @@ exports[`Search Page Component loading indicator does not render when data is pr className="root" >
    - - 0 items - -
    -
    + className="sidebar" + />
    - -
    -
    - -
    -
    - +
    + +
    +
    +
    +
    + -
    + /> +
    +
    + +
    +
    `; @@ -254,96 +287,98 @@ exports[`Search Page Component loading indicator renders when data is not presen className="root" >
    - - 0 items - -
    -
    -
    - - - - - - - - - - - - - - +
    - +
    +
    + + + + + + + + + + + + + + + +
    `; @@ -353,32 +388,34 @@ exports[`Search Page Component search results does not render if data returned h className="root" >
    +
    - - 0 items - +
    + +
    +
    +
    -
    -
    - -
    -
    - + className="noResult" + > + +
    `; @@ -388,71 +425,78 @@ exports[`Search Page Component search results heading renders a generic message className="root" >
    +
    - - 1 items -
    +
    + + + 1 items + +
    +
    + +
    + +
    +
    - -
    - +
    + -
    -
    - + /> +
    -
    - -
    -
    - -
    `; @@ -461,78 +505,85 @@ exports[`Search Page Component search results heading renders a specific message className="root" >
    +
    - - 1 items -
    - + + + 1 items + +
    +
    + +
    +
    - + -
    -
    - + +
    + + /> +
    -
    - -
    -
    - -
    `; @@ -541,49 +592,51 @@ exports[`Search Page Component search results renders if data has items 1`] = ` className="root" >
    - - 0 items - -
    -
    + className="sidebar" + />
    - -
    -
    - -
    -
    - +
    + +
    +
    +
    +
    + -
    + /> +
    +
    + +
    +
    `; @@ -592,32 +645,39 @@ exports[`Search Page Component sort button/container does not render if total co className="root" >
    +
    - - 0 items - +
    + + + 0 items + +
    +
    +
    -
    -
    - -
    -
    - + className="noResult" + > + +
    `; @@ -627,71 +687,78 @@ exports[`Search Page Component sort button/container renders when total count > className="root" >
    +
    - - 1 items -
    +
    + + + 1 items + +
    +
    + +
    + +
    +
    - -
    - +
    + -
    -
    - + /> +
    -
    - -
    -
    - -
    `; @@ -700,32 +767,39 @@ exports[`Search Page Component total count renders 0 items if data.products.tota className="root" >
    +
    - - 0 items - +
    + + + 0 items + +
    +
    +
    -
    -
    - -
    -
    - + className="noResult" + > + +
    `; @@ -735,70 +809,77 @@ exports[`Search Page Component total count renders results from data 1`] = ` className="root" >
    +
    - - 1 items -
    +
    + + + 1 items + +
    +
    + +
    + +
    +
    - -
    - +
    + -
    -
    - + /> +
    -
    - -
    -
    - -
    `; diff --git a/packages/venia-ui/lib/components/SearchPage/searchPage.css b/packages/venia-ui/lib/components/SearchPage/searchPage.css index d70205bd90..0d58338eef 100644 --- a/packages/venia-ui/lib/components/SearchPage/searchPage.css +++ b/packages/venia-ui/lib/components/SearchPage/searchPage.css @@ -1,13 +1,9 @@ -.root { - padding: 1rem; +:root { + --search-sidebar-width: 325px; } -.categoryTop { - align-items: center; - color: rgb(var(--venia-global-color-text-alt)); - display: flex; - flex-wrap: wrap; - justify-content: center; +.root { + padding: 1rem; } .noResult { @@ -22,26 +18,58 @@ } .heading { + display: flex; + flex-wrap: wrap; + justify-content: center; +} + +.searchInfo { line-height: var(--venia-global-typography-heading-lineHeight); margin: 2.5rem 0 1rem; max-width: 75vw; - overflow: hidden; - text-overflow: ellipsis; +} + +.totalPages { + margin-left: 0.5rem; } .headingHighlight { font-weight: var(--venia-global-fontWeight-bold); } -.filterButton { - composes: root_lowPriority from '../../components/Button/button.css'; - min-width: 6.25rem; +.sidebar { + display: none; } -.sortContainer { - font-size: 0.875rem; -} +@media (min-width: 1024px) { + .root { + display: flex; + flex-wrap: nowrap; + } + + .heading { + justify-content: space-between; + flex-wrap: nowrap; + align-items: center; + } + + .searchInfo { + margin: 0; + flex-basis: 100%; + } + + .headerButtons { + justify-content: flex-end; + } + + .sidebar { + display: flex; + align-self: flex-start; + width: var(--search-sidebar-width); + padding-top: 4rem; + } -.sortText { - font-weight: 600; + .searchContent { + flex-grow: 1; + } } diff --git a/packages/venia-ui/lib/components/SearchPage/searchPage.js b/packages/venia-ui/lib/components/SearchPage/searchPage.js index 7ac40f2d94..fc787ebd36 100644 --- a/packages/venia-ui/lib/components/SearchPage/searchPage.js +++ b/packages/venia-ui/lib/components/SearchPage/searchPage.js @@ -14,6 +14,7 @@ import SortedByContainer from '../SortedByContainer'; import FilterModalOpenButton from '../FilterModalOpenButton'; const FilterModal = React.lazy(() => import('../FilterModal')); +const FilterSidebar = React.lazy(() => import('../FilterSidebar')); const SearchPage = props => { const classes = mergeClasses(defaultClasses, props.classes); @@ -100,6 +101,10 @@ const SearchPage = props => { ) : null; + const maybeSidebar = shouldShowFilterButtons ? ( + + ) : null; + const maybeSortButton = shouldShowSortButtons ? ( ) : null; @@ -127,27 +132,39 @@ const SearchPage = props => { /> ); + const itemCountHeading = + data && !loading ? ( + + {formatMessage( + { + id: 'searchPage.totalPages', + defaultMessage: `items` + }, + { totalCount: productsCount } + )} + + ) : null; + return (
    -
    - - {formatMessage( - { - id: 'searchPage.totalPages', - defaultMessage: `items` - }, - { totalCount: productsCount } - )} - -
    - {maybeFilterButtons} - {maybeSortButton} +
    + {maybeSidebar} +
    +
    +
    +
    + {searchResultsHeading} + {itemCountHeading} +
    +
    + {maybeFilterButtons} + {maybeSortButton} +
    + {maybeSortContainer}
    - {maybeSortContainer} + {content} + {maybeFilterModal}
    -
    {searchResultsHeading}
    - {content} - {maybeFilterModal}
    ); }; diff --git a/packages/venia-ui/lib/components/SortedByContainer/sortedByContainer.css b/packages/venia-ui/lib/components/SortedByContainer/sortedByContainer.css index 4c718cbe69..959b9ad565 100644 --- a/packages/venia-ui/lib/components/SortedByContainer/sortedByContainer.css +++ b/packages/venia-ui/lib/components/SortedByContainer/sortedByContainer.css @@ -8,3 +8,9 @@ .sortText { font-weight: 600; } + +@media (min-width: 1024px) { + .root { + display: none; + } +}