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 (
+
+
+ {label}
+
+
+ );
+ }, [
+ isListExpanded,
+ handleListToggle,
+ items,
+ itemCountToShow,
+ formatMessage,
+ classes
+ ]);
+
return (
-
+
+ {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 (
+
+
+
+
+
+
+
+
+
+ {clearAll}
+
+
+
+
+ );
+};
+
+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"
>
+ className="sidebar"
+ />
-
-
-
-
-
+
+
+
+ 0 items
+
+
+
+
+
+ />
+
+
+
`;
@@ -56,27 +63,29 @@ exports[`Search Page Component error view renders when data is not present and n
className="root"
>
-
`;
@@ -86,49 +95,56 @@ exports[`Search Page Component filter button/modal does not render if there are
className="root"
>
+ 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"
>
+ className="sidebar"
+ />
-
-
-
-
+
+
`;
@@ -254,96 +287,98 @@ exports[`Search Page Component loading indicator renders when data is not presen
className="root"
>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
@@ -353,32 +388,34 @@ exports[`Search Page Component search results does not render if data returned h
className="root"
>
+
-
-
-
-
-
-
+ 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"
>
+
-
+
-
-
-
-
`;
@@ -541,49 +592,51 @@ exports[`Search Page Component search results renders if data has items 1`] = `
className="root"
>
+ 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;
+ }
+}