diff --git a/packages/venia-concept/src/RootComponents/Category/__tests__/__snapshots__/categoryContent.spec.js.snap b/packages/venia-concept/src/RootComponents/Category/__tests__/__snapshots__/categoryContent.spec.js.snap index f751c3e0dc..c70e39170a 100644 --- a/packages/venia-concept/src/RootComponents/Category/__tests__/__snapshots__/categoryContent.spec.js.snap +++ b/packages/venia-concept/src/RootComponents/Category/__tests__/__snapshots__/categoryContent.spec.js.snap @@ -7,13 +7,6 @@ exports[`renders the correct tree 1`] = `

-

{ - const { id, pageSize } = props; + const { filterClear, id, openDrawer, pageSize } = props; const [paginationValues, paginationApi] = usePagination(); const { currentPage, totalPages } = paginationValues; @@ -27,14 +32,23 @@ const Category = props => { const { runQuery, setLoading } = queryApi; const classes = mergeClasses(defaultClasses, props.classes); + // clear any stale filters + useEffect(() => { + if (isObjectEmpty(getFilterParams())) { + filterClear(); + } + }, []); + + // run the category query useEffect(() => { setLoading(true); runQuery({ variables: { + currentPage: Number(currentPage), id: Number(id), + idString: String(id), onServer: false, - pageSize: Number(pageSize), - currentPage: Number(currentPage) + pageSize: Number(pageSize) } }); @@ -43,36 +57,40 @@ const Category = props => { top: 0, behavior: 'smooth' }); - }, [id, pageSize, currentPage]); + }, [currentPage, id, pageSize]); const totalPagesFromData = data - ? data.category.products.page_info.total_pages + ? data.products.page_info.total_pages : null; + useEffect(() => { setTotalPages(totalPagesFromData); }, [totalPagesFromData]); if (error) return
Data Fetch Error
; - // show loading indicator until our data has been fetched and pagination state has been updated + + // show loading indicator until data has been fetched + // and pagination state has been updated if (!totalPages) return loadingIndicator; - // if our data is still loading, we want to reset our data state to null return ( ); }; Category.propTypes = { - id: number, classes: shape({ gallery: string, root: string, title: string }), + id: number, pageSize: number }; @@ -81,4 +99,12 @@ Category.defaultProps = { pageSize: 6 }; -export default Category; +const mapDispatchToProps = dispatch => ({ + filterClear: () => dispatch(catalogActions.filterOption.clear()), + openDrawer: () => dispatch(toggleDrawer('filter')) +}); + +export default connect( + null, + mapDispatchToProps +)(Category); diff --git a/packages/venia-concept/src/RootComponents/Category/categoryContent.js b/packages/venia-concept/src/RootComponents/Category/categoryContent.js index 148d2544a0..3b14b7e92a 100644 --- a/packages/venia-concept/src/RootComponents/Category/categoryContent.js +++ b/packages/venia-concept/src/RootComponents/Category/categoryContent.js @@ -1,35 +1,59 @@ import React from 'react'; +import { shape, string } from 'prop-types'; + import { mergeClasses } from 'src/classify'; +import FilterModal from 'src/components/FilterModal'; import Gallery from 'src/components/Gallery'; import Pagination from 'src/components/Pagination'; import defaultClasses from './category.css'; const CategoryContent = props => { - const { pageControl, data, pageSize } = props; + const { data, openDrawer, pageControl, pageSize } = props; const classes = mergeClasses(defaultClasses, props.classes); - const items = data ? data.category.products.items : null; - const title = data ? data.category.description : null; - const categoryTitle = data ? data.category.name : null; + const filters = data ? data.products.filters : null; + const items = data ? data.products.items : null; + const title = data ? data.category.name : null; + + const header = filters ? ( +
+ +
+ ) : null; + + const modal = filters ? : null; return (

- {/* TODO: Switch to RichContent component from Peregrine when merged */} -
-
{categoryTitle}
+
{title}

+ {header}
- +
+ {modal}
); }; export default CategoryContent; + +CategoryContent.propTypes = { + classes: shape({ + filterContainer: string, + gallery: string, + headerButtons: string, + pagination: string, + root: string, + title: string + }) +}; diff --git a/packages/venia-concept/src/RootComponents/Category/categoryContentContainer.js b/packages/venia-concept/src/RootComponents/Category/categoryContentContainer.js new file mode 100644 index 0000000000..4f47790988 --- /dev/null +++ b/packages/venia-concept/src/RootComponents/Category/categoryContentContainer.js @@ -0,0 +1,12 @@ +import { connect } from 'src/drivers'; +import { toggleDrawer } from 'src/actions/app'; +import CategoryContent from './categoryContent'; + +const mapDispatchToProps = dispatch => ({ + openDrawer: () => dispatch(toggleDrawer('filter')) +}); + +export default connect( + null, + mapDispatchToProps +)(CategoryContent); diff --git a/packages/venia-concept/src/RootComponents/Search/container.js b/packages/venia-concept/src/RootComponents/Search/container.js index 15f3545d95..312863239c 100644 --- a/packages/venia-concept/src/RootComponents/Search/container.js +++ b/packages/venia-concept/src/RootComponents/Search/container.js @@ -1,6 +1,7 @@ import { connect } from 'src/drivers'; - +import { toggleDrawer } from 'src/actions/app'; import Search from './search'; +import catalogActions from 'src/actions/catalog'; import { executeSearch, toggleSearch } from 'src/actions/app'; const mapStateToProps = ({ app }) => { @@ -9,7 +10,13 @@ const mapStateToProps = ({ app }) => { return { searchOpen }; }; -const mapDispatchToProps = { executeSearch, toggleSearch }; +const mapDispatchToProps = dispatch => ({ + openDrawer: () => dispatch(toggleDrawer('filter')), + filterClear: () => dispatch(catalogActions.filterOption.clear()), + executeSearch: (query, history, categoryId) => + dispatch(executeSearch(query, history, categoryId)), + toggleSearch: () => dispatch(toggleSearch()) +}); export default connect( mapStateToProps, diff --git a/packages/venia-concept/src/RootComponents/Search/search.css b/packages/venia-concept/src/RootComponents/Search/search.css index 86e0643f53..18544b281d 100644 --- a/packages/venia-concept/src/RootComponents/Search/search.css +++ b/packages/venia-concept/src/RootComponents/Search/search.css @@ -4,6 +4,7 @@ .categoryTop { display: flex; + flex-wrap: wrap; padding: 0 0 1rem 0; color: rgb(var(--venia-text-alt)); justify-content: center; @@ -30,3 +31,21 @@ .noResult { display: flex; } + +.headerButtons { + display: flex; + justify-content: center; + flex-basis: 100%; + padding-top: 0.5rem; +} + +.filterButton { + padding: 0.5rem; + margin-left: 0.5rem; + margin-right: 0.5rem; + width: 9rem; + border: 1px solid black; + border-radius: 100px; + color: black; + outline: none; +} diff --git a/packages/venia-concept/src/RootComponents/Search/search.js b/packages/venia-concept/src/RootComponents/Search/search.js index c0b42e5305..76f1c032d6 100644 --- a/packages/venia-concept/src/RootComponents/Search/search.js +++ b/packages/venia-concept/src/RootComponents/Search/search.js @@ -6,8 +6,11 @@ import gql from 'graphql-tag'; import Gallery from 'src/components/Gallery'; import classify from 'src/classify'; import Icon from 'src/components/Icon'; +import { getFilterParams } from 'src/util/getFilterParamsFromUrl'; import getQueryParameterValue from 'src/util/getQueryParameterValue'; +import isObjectEmpty from 'src/util/isObjectEmpty'; import CloseIcon from 'react-feather/dist/icons/x'; +import FilterModal from 'src/components/FilterModal'; import { loadingIndicator } from 'src/components/LoadingIndicator'; import defaultClasses from './search.css'; import PRODUCT_SEARCH from '../../queries/productSearch.graphql'; @@ -27,6 +30,7 @@ export class Search extends Component { root: string, totalPages: string }), + openDrawer: func.isRequired, executeSearch: func.isRequired, history: object, location: object.isRequired, @@ -37,18 +41,35 @@ export class Search extends Component { componentDidMount() { // Ensure that search is open when the user lands on the search page. - const { location, searchOpen, toggleSearch } = this.props; + const { location, searchOpen, toggleSearch, filterClear } = this.props; const inputText = getQueryParameterValue({ location, queryParameter: 'query' }); + isObjectEmpty(getFilterParams()) && filterClear(); + if (toggleSearch && !searchOpen && inputText) { toggleSearch(); } } + componentDidUpdate(prevProps) { + const queryPrev = getQueryParameterValue({ + location: prevProps.location, + queryParameter: 'query' + }); + + const queryCurrent = getQueryParameterValue({ + location: this.props.location, + queryParameter: 'query' + }); + if (queryPrev !== queryCurrent) { + this.props.filterClear(); + } + } + getCategoryName = (categoryId, classes) => (
+
+ )}
+ + {filters && }
- +
); diff --git a/packages/venia-concept/src/actions/app/asyncActions.js b/packages/venia-concept/src/actions/app/asyncActions.js index 7cb7c009f6..208e313cef 100644 --- a/packages/venia-concept/src/actions/app/asyncActions.js +++ b/packages/venia-concept/src/actions/app/asyncActions.js @@ -14,5 +14,6 @@ export const executeSearch = (query, history, categoryId) => let searchQuery = `query=${query}`; if (categoryId) searchQuery += `&category=${categoryId}`; history.push(`/search.html?${searchQuery}`); + dispatch(catalogActions.filterOption.clear()); dispatch(actions.executeSearch(query)); }; diff --git a/packages/venia-concept/src/actions/catalog/actions.js b/packages/venia-concept/src/actions/catalog/actions.js index 347653ef14..c86de75e34 100644 --- a/packages/venia-concept/src/actions/catalog/actions.js +++ b/packages/venia-concept/src/actions/catalog/actions.js @@ -14,6 +14,11 @@ const actionMap = { SET_PREV_PAGE_TOTAL: { REQUEST: null, RECEIVE: null + }, + FILTER_OPTION: { + SET_TO_APPLIED: null, + UPDATE: null, + CLEAR: null } }; diff --git a/packages/venia-concept/src/actions/catalog/asyncActions.js b/packages/venia-concept/src/actions/catalog/asyncActions.js index 1ec345d88e..8e2de4bdd4 100644 --- a/packages/venia-concept/src/actions/catalog/asyncActions.js +++ b/packages/venia-concept/src/actions/catalog/asyncActions.js @@ -1,4 +1,80 @@ import actions from './actions'; +import mockData from './mockData'; +import { preserveQueryParams } from 'src/util/preserveQueryParams'; +import { persistentQueries } from 'src/shared/persistentQueries'; + +export const serialize = (params, keys = [], isArray = false) => { + const serialized = Object.keys(params) + .map(key => { + const val = params[key]; + const isObject = + Object.prototype.toString.call(val) === '[object Object]'; + if (isObject || Array.isArray(val)) { + if (val.length === 0) return null; + keys.push(Array.isArray(params) ? '' : key); + return serialize(val, keys, Array.isArray(val)); + } else { + let tKey = key; + + if (keys.length > 0) { + const tKeys = isArray + ? keys.filter(v => v != '') + : [...keys, key].filter(v => v != ''); + tKey = tKeys.reduce((str, k) => { + return '' === str ? k : `${str}[${k}]`; + }, ''); + } + + return isArray ? `${tKey}[]=${val}` : `${tKey}=${val}`; + } + }) + .filter(Boolean) + .join('&'); + + keys.pop(); + return serialized; +}; + +const updateCatalogUrl = (filters, history, queryParams) => { + history.push('?' + queryParams.toString() + '&' + serialize(filters)); +}; + +export const addFilter = ({ group, title, value }, history) => + async function thunk(dispatch, getState) { + const { + catalog: { chosenFilterOptions } + } = getState(); + + const oldState = chosenFilterOptions[group] || []; + const newState = oldState.concat({ title, value }); + + dispatch(actions.filterOption.update({ newState, group })); + + if (history) { + const filters = { ...chosenFilterOptions, [group]: newState }; + updateCatalogUrl(filters, history); + } + }; + +export const removeFilter = ({ group, title, value }, history, location) => + async function thunk(dispatch, getState) { + const { + catalog: { chosenFilterOptions } + } = getState(); + const newQueryParam = preserveQueryParams(location, persistentQueries); + + const oldState = chosenFilterOptions[group] || []; + const newState = oldState.filter(item => { + return item.title !== title || item.value !== value; + }); + + dispatch(actions.filterOption.update({ newState, group })); + + if (history) { + const filters = { ...chosenFilterOptions, [group]: newState }; + updateCatalogUrl(filters, history, newQueryParam); + } + }; export const getAllCategories = () => async function thunk(dispatch) { @@ -7,9 +83,10 @@ export const getAllCategories = () => try { // TODO: implement rest or graphql call for categories // `/rest/V1/categories` requires auth for some reason - const { default: payload } = await import('./mockData'); + // TODO: we need to configure Jest to support dynamic imports + // const { default: payload } = await import('./mockData'); - dispatch(actions.getAllCategories.receive(payload)); + dispatch(actions.getAllCategories.receive(mockData)); } catch (error) { dispatch(actions.getAllCategories.receive(error)); } diff --git a/packages/venia-concept/src/components/FilterModal/FilterFooter/filterFooter.css b/packages/venia-concept/src/components/FilterModal/FilterFooter/filterFooter.css new file mode 100644 index 0000000000..0d706a1e83 --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/FilterFooter/filterFooter.css @@ -0,0 +1,49 @@ +.footer { + position: fixed; + background-color: white; + display: flex; + justify-content: center; + left: 0; + right: 0; + bottom: 0; + padding: 1.5rem; + border-top: 2px solid rgb(var(--venia-border)); +} + +.footerButton { + padding: 0.5rem; + margin-left: 0.5rem; + margin-right: 0.5rem; + width: 9rem; + border: 1px solid black; + border-radius: 100px; + color: black; + outline: none; +} + +.footerButtonDisabled { + composes: footerButton; + border: 1px solid #d1d1d1; + color: #d1d1d1; +} + +.resetButton { + composes: footerButton; +} + +.resetButtonDisabled { + composes: footerButtonDisabled; + background-color: white; +} + +.applyButton { + composes: footerButton; + color: white; + background-color: black; +} + +.applyButtonDisabled { + composes: footerButtonDisabled; + color: white; + background-color: #d1d1d1; +} diff --git a/packages/venia-concept/src/components/FilterModal/FilterFooter/filterFooter.js b/packages/venia-concept/src/components/FilterModal/FilterFooter/filterFooter.js new file mode 100644 index 0000000000..3cb0b41863 --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/FilterFooter/filterFooter.js @@ -0,0 +1,112 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import catalogActions, { serialize } from 'src/actions/catalog'; +import { withRouter } from 'react-router-dom'; +import { closeDrawer } from 'src/actions/app'; +import classify from 'src/classify'; +import defaultClasses from './filterFooter.css'; +import { compose } from 'redux'; +import { connect } from 'react-redux'; +import isObjectEmpty from 'src/util/isObjectEmpty'; +import { preserveQueryParams } from 'src/util/preserveQueryParams'; +import { persistentQueries } from 'src/shared/persistentQueries'; +class FilterFooter extends Component { + static propTypes = { + classes: PropTypes.shape({ + resetButton: PropTypes.string, + resetButtonDisabled: PropTypes.string, + applyButton: PropTypes.string, + applyButtonDisabled: PropTypes.string, + footer: PropTypes.string + }), + history: PropTypes.object, + filterClear: PropTypes.func, + chosenFilterOptions: PropTypes.object, + closeDrawer: PropTypes.func + }; + + resetFilterOptions = () => { + const { history, filterClear, location } = this.props; + const queryParams = preserveQueryParams(location, persistentQueries); + queryParams + ? history.push('?' + queryParams.toString()) + : history.push(); + filterClear(); + }; + + handleApplyFilters = () => { + const { + history, + chosenFilterOptions, + closeDrawer, + location + } = this.props; + const queryParams = preserveQueryParams(location, persistentQueries); + history.push( + '?' + queryParams.toString() + '&' + serialize(chosenFilterOptions) + ); + closeDrawer(); + }; + + getFooterButtons = areOptionsPristine => { + const { classes } = this.props; + + const resetButtonClass = areOptionsPristine + ? classes.resetButtonDisabled + : classes.resetButton; + + const applyButtonClass = areOptionsPristine + ? classes.applyButtonDisabled + : classes.applyButton; + + return ( + + + + + ); + }; + + render() { + const { classes, chosenFilterOptions } = this.props; + const footerButtons = this.getFooterButtons( + isObjectEmpty(chosenFilterOptions) + ); + + return
{footerButtons}
; + } +} + +const mapStateToProps = ({ catalog }) => { + const { chosenFilterOptions } = catalog; + + return { + chosenFilterOptions: chosenFilterOptions + }; +}; + +const mapDispatchToProps = { + filterClear: catalogActions.filterOption.clear, + closeDrawer: closeDrawer +}; + +export default compose( + withRouter, + classify(defaultClasses), + connect( + mapStateToProps, + mapDispatchToProps + ) +)(FilterFooter); diff --git a/packages/venia-concept/src/components/FilterModal/FilterFooter/index.js b/packages/venia-concept/src/components/FilterModal/FilterFooter/index.js new file mode 100644 index 0000000000..49c63577ad --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/FilterFooter/index.js @@ -0,0 +1 @@ +export { default } from './filterFooter'; diff --git a/packages/venia-concept/src/components/FilterModal/FilterList/filterDefault.css b/packages/venia-concept/src/components/FilterModal/FilterList/filterDefault.css new file mode 100644 index 0000000000..e561fd774e --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/FilterList/filterDefault.css @@ -0,0 +1,21 @@ +.root { + display: inline-flex; + align-items: center; + outline: 0; +} + +.icon { + background-color: white; + border: 1px solid rgb(var(--venia-text)); + width: 1rem; + height: 1rem; + display: inline-flex; + border-radius: 3px; + margin-right: 1rem; +} + +.iconActive { + composes: icon; + color: white; + background-color: rgb(var(--venia-text)); +} diff --git a/packages/venia-concept/src/components/FilterModal/FilterList/filterDefault.js b/packages/venia-concept/src/components/FilterModal/FilterList/filterDefault.js new file mode 100644 index 0000000000..469bbd55ba --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/FilterList/filterDefault.js @@ -0,0 +1,44 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import Icon from 'src/components/Icon'; +import Checkmark from 'react-feather/dist/icons/check'; +import classify from 'src/classify'; +import defaultClasses from './filterDefault.css'; + +class FilterDefault extends Component { + static propTypes = { + classes: PropTypes.shape({ + root: PropTypes.string, + icon: PropTypes.string, + iconActive: PropTypes.string + }), + item: PropTypes.shape({ + label: PropTypes.string + }), + isSelected: PropTypes.bool, + label: PropTypes.string, + group: PropTypes.string + }; + + render() { + const { + classes, + isSelected, + item: { label }, + ...rest + } = this.props; + + const iconClassName = isSelected ? classes.iconActive : classes.icon; + + return ( + + ); + } +} + +export default classify(defaultClasses)(FilterDefault); diff --git a/packages/venia-concept/src/components/FilterModal/FilterList/filterList.css b/packages/venia-concept/src/components/FilterModal/FilterList/filterList.css new file mode 100644 index 0000000000..0f836d49de --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/FilterList/filterList.css @@ -0,0 +1,3 @@ +.filterItem { + margin-bottom: 1rem; +} diff --git a/packages/venia-concept/src/components/FilterModal/FilterList/filterList.js b/packages/venia-concept/src/components/FilterModal/FilterList/filterList.js new file mode 100644 index 0000000000..d7f76bd6be --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/FilterList/filterList.js @@ -0,0 +1,112 @@ +import React, { Component } from 'react'; +import { compose } from 'redux'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import classify from 'src/classify'; +import { withRouter } from 'react-router-dom'; +import defaultClasses from './filterList.css'; +import { List } from '@magento/peregrine'; +import FilterDefault from './filterDefault'; +import Swatch from 'src/components/ProductOptions/swatch'; +import { WithFilterSearch } from 'src/components/FilterModal/FilterSearch'; + +class FilterList extends Component { + static propTypes = { + classes: PropTypes.shape({ + filterItem: PropTypes.string + }), + chosenOptions: PropTypes.arrayOf( + PropTypes.shape({ + title: PropTypes.string, + value: PropTypes.string + }) + ), + layoutClass: PropTypes.string, + isSwatch: PropTypes.bool, + addFilter: PropTypes.func, + removeFilter: PropTypes.func, + items: PropTypes.array + }; + + stripHtml = html => html.replace(/(<([^>]+)>)/gi, ''); + + toggleOption = event => { + const { removeFilter, addFilter, history } = this.props; + const { value, title, dataset } = + event.currentTarget || event.srcElement; + const { group } = dataset; + const item = { title, value, group }; + this.isOptionActive(item) + ? removeFilter(item, history, window.location) + : addFilter(item); + }; + + isOptionActive = option => + this.props.chosenOptions.findIndex( + item => item.value === option.value && item.name === option.name + ) > -1; + + isFilterSelected = item => { + const label = this.stripHtml(item.label); + return !!this.props.chosenOptions.find( + ({ title, value }) => label === title && item.value_string === value + ); + }; + + render() { + const { toggleOption, isFilterSelected, stripHtml } = this; + const { classes, items, id, layoutClass, isSwatch } = this.props; + + return ( + `item-${id}-${value_string}`} + render={props => ( + + )} + renderItem={({ item }) => { + const isActive = isFilterSelected(item); + + const filterProps = { + item: { + label: stripHtml(item.label), + value_index: item.value_string + }, + value: item.value_string, + title: stripHtml(item.label), + 'data-group': id, + onClick: toggleOption, + isSelected: isActive + }; + + const filterClass = !isSwatch ? classes.filterItem : null; + + return ( +
  • + {isSwatch ? ( + + ) : ( + + )} +
  • + ); + }} + /> + ); + } +} + +const mapStateToProps = ({ catalog }, { id }) => { + const { chosenFilterOptions } = catalog; + + return { + chosenOptions: chosenFilterOptions[id] || [] + }; +}; + +export default compose( + withRouter, + classify(defaultClasses), + connect(mapStateToProps), + WithFilterSearch +)(FilterList); diff --git a/packages/venia-concept/src/components/FilterModal/FilterList/index.js b/packages/venia-concept/src/components/FilterModal/FilterList/index.js new file mode 100644 index 0000000000..1136331c17 --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/FilterList/index.js @@ -0,0 +1 @@ +export { default } from './filterList'; diff --git a/packages/venia-concept/src/components/FilterModal/FilterSearch/filterSearch.css b/packages/venia-concept/src/components/FilterModal/FilterSearch/filterSearch.css new file mode 100644 index 0000000000..e290c84f89 --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/FilterSearch/filterSearch.css @@ -0,0 +1,10 @@ +.filterSearch { + align-items: center; + display: grid; + justify-items: stretch; + width: 100%; +} + +.noFilters { + padding: 1rem 1rem 1.5rem; +} diff --git a/packages/venia-concept/src/components/FilterModal/FilterSearch/filterSearch.js b/packages/venia-concept/src/components/FilterModal/FilterSearch/filterSearch.js new file mode 100644 index 0000000000..ddb25f8c78 --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/FilterSearch/filterSearch.js @@ -0,0 +1,87 @@ +import React, { Fragment } from 'react'; +import TextInput from 'src/components/TextInput'; +import Trigger from 'src/components/Trigger'; +import { Form } from 'informed'; +import Icon from 'src/components/Icon'; +import ClearIcon from 'react-feather/dist/icons/x'; +import SearchIcon from 'react-feather/dist/icons/search'; +import classify from 'src/classify'; +import defaultClasses from './filterSearch.css'; + +const clearIcon = ; +const searchIcon = ; + +const withFilterSearch = WrappedComponent => { + class withFilterSearch extends React.Component { + state = { + filterQuery: '' + }; + + handleFilterSearch = value => this.setState({ filterQuery: value }); + + getFilteredItems = (items, filterQuery) => + items.filter(item => + item.label.toUpperCase().includes(filterQuery.toUpperCase()) + ); + + getSearchInput = ({ formApi }) => { + const { handleFilterSearch } = this; + const handleResetSearch = () => formApi.reset(); + const { name } = this.props; + const { filterQuery } = this.state; + + const resetButton = filterQuery && ( + {clearIcon} + ); + + return ( + + ); + }; + + render() { + const { getFilteredItems, getSearchInput } = this; + const { filterQuery } = this.state; + const { items, classes, options, ...rest } = this.props; + + const isSearchable = options && options.searchable; + + const filteredItems = + isSearchable && filterQuery + ? getFilteredItems(items, filterQuery) + : items; + + return ( + + {isSearchable && ( +
    + {getSearchInput} +
    + )} + {filteredItems.length > 0 ? ( + + ) : ( +
    + No filter matches the search +
    + )} +
    + ); + } + } + + return classify(defaultClasses)(withFilterSearch); +}; + +export default withFilterSearch; diff --git a/packages/venia-concept/src/components/FilterModal/FilterSearch/index.js b/packages/venia-concept/src/components/FilterModal/FilterSearch/index.js new file mode 100644 index 0000000000..8e372d132b --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/FilterSearch/index.js @@ -0,0 +1 @@ +export { default as WithFilterSearch } from './filterSearch'; diff --git a/packages/venia-concept/src/components/FilterModal/FiltersCurrent/filtersCurrent.css b/packages/venia-concept/src/components/FilterModal/FiltersCurrent/filtersCurrent.css new file mode 100644 index 0000000000..64f0f16016 --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/FiltersCurrent/filtersCurrent.css @@ -0,0 +1,27 @@ +.root { + display: flex; + overflow: auto; + padding-bottom: 0.5rem; + margin: 0 1.5rem 1rem; +} + +.root:empty { + display: none; +} + +.icon { + line-height: 1; + padding-right: 0.25rem; +} + +.button { + border: 1px solid rgb(var(--venia-border)); + border-radius: 3px; + outline: 0; + padding: 0.25rem 0.75rem; + margin-left: 0.5rem; + margin-right: 0.5rem; + white-space: nowrap; + display: flex; + align-items: flex-end; +} diff --git a/packages/venia-concept/src/components/FilterModal/FiltersCurrent/filtersCurrent.js b/packages/venia-concept/src/components/FilterModal/FiltersCurrent/filtersCurrent.js new file mode 100644 index 0000000000..260deb9643 --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/FiltersCurrent/filtersCurrent.js @@ -0,0 +1,74 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { compose } from 'redux'; +import Icon from 'src/components/Icon'; +import Remove from 'react-feather/dist/icons/x'; +import classify from 'src/classify'; +import { withRouter } from 'react-router-dom'; +import defaultClasses from './filtersCurrent.css'; + +class FiltersCurrent extends Component { + static propTypes = { + classes: PropTypes.shape({ + root: PropTypes.string, + item: PropTypes.string, + button: PropTypes.string, + icon: PropTypes.string + }), + keyPrefix: PropTypes.string, + removeFilter: PropTypes.func, + chosenFilterOptions: PropTypes.shape({ + title: PropTypes.string, + value: PropTypes.string + }) + }; + + removeOption = event => { + const { title, value, dataset } = + event.currentTarget || event.srcElement; + const { group } = dataset; + const { removeFilter, history, location } = this.props; + removeFilter({ title, value, group }, history, location); + }; + + getCurrentFilter = (item, key) => { + const { removeOption } = this; + const { classes, keyPrefix } = this.props; + const { title, value } = item; + + return ( +
  • + +
  • + ); + }; + + render() { + const { chosenFilterOptions, classes } = this.props; + const { getCurrentFilter } = this; + + return ( +
      + {Object.keys(chosenFilterOptions).map(key => + chosenFilterOptions[key].map(item => + getCurrentFilter(item, key) + ) + )} +
    + ); + } +} + +export default compose( + withRouter, + classify(defaultClasses) +)(FiltersCurrent); diff --git a/packages/venia-concept/src/components/FilterModal/FiltersCurrent/filtersCurrentContainer.js b/packages/venia-concept/src/components/FilterModal/FiltersCurrent/filtersCurrentContainer.js new file mode 100644 index 0000000000..10c6910712 --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/FiltersCurrent/filtersCurrentContainer.js @@ -0,0 +1,19 @@ +import { connect } from 'react-redux'; +import FiltersCurrent from './filtersCurrent'; +import { removeFilter } from 'src/actions/catalog'; + +const mapStateToProps = ({ catalog }) => { + const { chosenFilterOptions } = catalog; + return { + chosenFilterOptions + }; +}; + +const mapDispatchToProps = { + removeFilter +}; + +export default connect( + mapStateToProps, + mapDispatchToProps +)(FiltersCurrent); diff --git a/packages/venia-concept/src/components/FilterModal/FiltersCurrent/index.js b/packages/venia-concept/src/components/FilterModal/FiltersCurrent/index.js new file mode 100644 index 0000000000..2ec78daf07 --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/FiltersCurrent/index.js @@ -0,0 +1 @@ +export { default as FiltersCurrent } from './filtersCurrentContainer'; diff --git a/packages/venia-concept/src/components/FilterModal/constants.js b/packages/venia-concept/src/components/FilterModal/constants.js new file mode 100644 index 0000000000..ff2d96e6d7 --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/constants.js @@ -0,0 +1,23 @@ +export const filterModes = { + default: 'default', + swatch: 'swatch' +}; + +export const filterLayouts = { + grid: 'grid', + list: 'list' +}; + +export const filterRenderOptions = { + fashion_color: { + mode: filterModes.swatch, + options: { + layout: filterLayouts.grid, + searchable: true + } + }, + default: { + mode: filterModes.default, + options: {} + } +}; diff --git a/packages/venia-concept/src/components/FilterModal/filterBlock.css b/packages/venia-concept/src/components/FilterModal/filterBlock.css new file mode 100644 index 0000000000..120a08d7e6 --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/filterBlock.css @@ -0,0 +1,61 @@ +.root:first-child { + border-top: 2px solid rgb(var(--venia-border)); + border-bottom: 2px solid rgb(var(--venia-border)); +} + +.root { + border-bottom: 2px solid rgb(var(--venia-border)); +} + +.layout { + justify-content: flex-start; + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.layoutGrid { + composes: layout; + padding-bottom: 1rem; + padding-top: 0; + display: flex; + justify-content: flex-start; + flex-wrap: wrap; +} + +.clearIcon { + display: flex; +} + +.optionHeader { + display: flex; + justify-content: space-between; + align-items: center; +} + +.optionNameExpanded { + font-weight: 600; +} + +.optionToggleButton { + outline: none; + width: 100%; + display: flex; + justify-content: space-between; + padding-top: 0.75rem; + padding-bottom: 0.75rem; +} + +.filterList { + display: none; + padding-right: 2.75rem; +} + +.filterListExpanded { + composes: filterList; + display: block; +} + +.closeWrapper { + display: inline-flex; + align-items: center; +} diff --git a/packages/venia-concept/src/components/FilterModal/filterBlock.js b/packages/venia-concept/src/components/FilterModal/filterBlock.js new file mode 100644 index 0000000000..38996e4305 --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/filterBlock.js @@ -0,0 +1,125 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classify from 'src/classify'; +import FilterList from './FilterList'; +import Icon from 'src/components/Icon'; +import { filterModes, filterRenderOptions, filterLayouts } from './constants'; +import ArrowDown from 'react-feather/dist/icons/chevron-down'; +import ArrowUp from 'react-feather/dist/icons/chevron-up'; +import defaultClasses from './filterBlock.css'; + +class FilterBlock extends Component { + static propTypes = { + classes: PropTypes.shape({ + root: PropTypes.string, + layout: PropTypes.string, + layoutGrid: PropTypes.string, + optionHeader: PropTypes.string, + optionToggleButton: PropTypes.string, + optionName: PropTypes.string, + optionNameExpanded: PropTypes.string, + closeWrapper: PropTypes.string, + filterList: PropTypes.string, + filterListExpanded: PropTypes.string + }), + item: PropTypes.shape({ + name: PropTypes.string, + filter_items: PropTypes.array, + request_var: PropTypes.string + }), + addFilter: PropTypes.func, + removeFilter: PropTypes.func + }; + + state = { + isExpanded: false + }; + + optionToggle = () => { + const { isExpanded } = this.state; + this.setState({ isExpanded: !isExpanded }); + }; + + getControlBlock = isExpanded => { + const { classes, item } = this.props; + const iconSrc = isExpanded ? ArrowUp : ArrowDown; + const nameClass = isExpanded + ? classes.optionNameExpanded + : classes.optionName; + + return ( +
    + +
    + ); + }; + + getLayout = options => { + const { layout } = options ? options : {}; + const { classes } = this.props; + switch (layout) { + case filterLayouts.grid: + return classes.layoutGrid; + default: + return classes.layout; + } + }; + + getRenderOptions = value => + filterRenderOptions[`${value}`] || + filterRenderOptions[filterModes.default]; + + render() { + const { + classes, + item: { filter_items, request_var, name }, + removeFilter, + addFilter + } = this.props; + + const { isExpanded } = this.state; + + const { mode, options } = this.getRenderOptions(request_var); + + const listClassName = isExpanded + ? classes.filterListExpanded + : classes.filterList; + + const controlBlock = this.getControlBlock(isExpanded); + + const filterLayoutClass = this.getLayout(options); + + const isSwatch = filterModes[mode] === filterModes.swatch; + + const filterProps = { + isSwatch, + options, + name, + addFilter, + removeFilter, + mode, + id: request_var, + items: filter_items, + layoutClass: filterLayoutClass + }; + + return ( +
  • + {controlBlock} +
    + +
    +
  • + ); + } +} + +export default classify(defaultClasses)(FilterBlock); diff --git a/packages/venia-concept/src/components/FilterModal/filterModal.css b/packages/venia-concept/src/components/FilterModal/filterModal.css new file mode 100644 index 0000000000..2f77cd8b99 --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/filterModal.css @@ -0,0 +1,56 @@ +.root { + position: fixed; + left: 0; + bottom: 0; + right: 0; + background-color: white; + transform: translate3d(0, 100%, 0); + transition-duration: 192ms; + transition-timing-function: var(--venia-anim-out); + transition-property: opacity, transform, visibility; + visibility: hidden; + opacity: 0; + overflow: hidden; + width: 100%; + z-index: 3; + display: grid; + grid-template-rows: min-content 1fr; + height: 100%; +} + +.rootOpen { + composes: root; + box-shadow: 1px 0 rgb(var(--venia-border)); + opacity: 1; + transform: translate3d(0, 0, 0); + transition-duration: 224ms; + transition-timing-function: cubic-bezier(0, 0, 0.2, 1); + visibility: visible; +} + +.modalWrapper { + overflow: auto; + max-height: 100vh; +} + +.header { + display: flex; + justify-content: space-between; + padding: 1.5rem 1.5rem 0.5rem; +} + +.headerTitle { + display: flex; + align-items: center; + font-size: 14px; + font-weight: 600; + line-height: 14px; +} + +.filterOptionsContainer { + padding: 0.5rem 1.5rem 7.5rem; +} + +.searchFilterContainer { + margin-bottom: 1.125rem; +} diff --git a/packages/venia-concept/src/components/FilterModal/filterModal.js b/packages/venia-concept/src/components/FilterModal/filterModal.js new file mode 100644 index 0000000000..af3afd42c6 --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/filterModal.js @@ -0,0 +1,85 @@ +import React, { Component } from 'react'; +import FilterFooter from './FilterFooter'; +import PropTypes from 'prop-types'; +import { List } from '@magento/peregrine'; +import { FiltersCurrent } from './FiltersCurrent'; +import classify from 'src/classify'; +import CloseIcon from 'react-feather/dist/icons/x'; +import Icon from 'src/components/Icon'; +import FilterBlock from './filterBlock'; +import defaultClasses from './filterModal.css'; +import { Modal } from 'src/components/Modal'; + +class FilterModal extends Component { + static propTypes = { + classes: PropTypes.shape({ + root: PropTypes.string, + modalWrapper: PropTypes.string, + header: PropTypes.string, + headerTitle: PropTypes.string, + filterOptionsContainer: PropTypes.string + }), + filters: PropTypes.arrayOf( + PropTypes.shape({ + request_var: PropTypes.string, + items: PropTypes.array + }) + ), + addFilter: PropTypes.func, + removeFilter: PropTypes.func, + closeDrawer: PropTypes.func + }; + + componentDidUpdate() { + const { drawer } = this.props; + + if (drawer !== 'filter') { + this.props.setToApplied(); + } + } + + render() { + const { classes, drawer, closeDrawer } = this.props; + const modalClass = + drawer === 'filter' ? classes.rootOpen : classes.root; + + return ( + + + + ); + } +} + +export default classify(defaultClasses)(FilterModal); diff --git a/packages/venia-concept/src/components/FilterModal/filterModalContainer.js b/packages/venia-concept/src/components/FilterModal/filterModalContainer.js new file mode 100644 index 0000000000..1ec3a76797 --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/filterModalContainer.js @@ -0,0 +1,27 @@ +import { connect } from 'react-redux'; +import { compose } from 'redux'; +import FilterModal from './filterModal'; +import { closeDrawer } from 'src/actions/app'; +import catalogActions, { addFilter, removeFilter } from 'src/actions/catalog'; + +const mapStateToProps = ({ app }) => { + const { drawer } = app; + return { + drawer + }; +}; + +const mapDispatchToProps = dispatch => ({ + closeDrawer: () => dispatch(closeDrawer()), + addFilter: (item, history) => dispatch(addFilter(item, history)), + removeFilter: (item, history, location) => + dispatch(removeFilter(item, history, location)), + setToApplied: () => dispatch(catalogActions.filterOption.setToApplied()) +}); + +export default compose( + connect( + mapStateToProps, + mapDispatchToProps + ) +)(FilterModal); diff --git a/packages/venia-concept/src/components/FilterModal/index.js b/packages/venia-concept/src/components/FilterModal/index.js new file mode 100644 index 0000000000..398412b5b3 --- /dev/null +++ b/packages/venia-concept/src/components/FilterModal/index.js @@ -0,0 +1 @@ +export { default } from './filterModalContainer'; diff --git a/packages/venia-concept/src/components/Modal/index.js b/packages/venia-concept/src/components/Modal/index.js new file mode 100644 index 0000000000..b04ab42f99 --- /dev/null +++ b/packages/venia-concept/src/components/Modal/index.js @@ -0,0 +1 @@ +export { default as Modal } from './modal'; diff --git a/packages/venia-concept/src/components/Modal/modal.js b/packages/venia-concept/src/components/Modal/modal.js new file mode 100644 index 0000000000..cd15484233 --- /dev/null +++ b/packages/venia-concept/src/components/Modal/modal.js @@ -0,0 +1,22 @@ +import { useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { node, object } from 'prop-types'; + +const Modal = ({ children, container }) => { + const target = useMemo( + () => + container instanceof HTMLElement + ? container + : document.getElementById('root'), + [container] + ); + + return createPortal(children, target); +}; + +export default Modal; + +Modal.propTypes = { + children: node, + container: object +}; diff --git a/packages/venia-concept/src/components/ProductOptions/tile.css b/packages/venia-concept/src/components/ProductOptions/tile.css index 23b1873912..31d9cfad33 100644 --- a/packages/venia-concept/src/components/ProductOptions/tile.css +++ b/packages/venia-concept/src/components/ProductOptions/tile.css @@ -6,14 +6,14 @@ margin-left: 1rem; margin-top: 1rem; min-width: 3rem; - padding: 0 0.75rem; + padding: 0 0.5rem; } @media (min-width: 1024px) { .root { height: 2rem; min-width: 2rem; - padding: 0 0.5rem; + padding: 0; } } diff --git a/packages/venia-concept/src/components/PurchaseHistoryPage/PurchaseHistory/purchaseHistory.js b/packages/venia-concept/src/components/PurchaseHistoryPage/PurchaseHistory/purchaseHistory.js index e7b9162d55..b3c118d4d1 100644 --- a/packages/venia-concept/src/components/PurchaseHistoryPage/PurchaseHistory/purchaseHistory.js +++ b/packages/venia-concept/src/components/PurchaseHistoryPage/PurchaseHistory/purchaseHistory.js @@ -1,11 +1,10 @@ import React, { Component } from 'react'; import { arrayOf, bool, func, number, shape, string } from 'prop-types'; import { List } from '@magento/peregrine'; - -import PurchaseHistoryItem from '../PurchaseHistoryItem'; +import Filter from 'src/components/PurchaseHistoryPage/Filter'; import classify from 'src/classify'; +import PurchaseHistoryItem from '../PurchaseHistoryItem'; import defaultClasses from './purchaseHistory.css'; -import Filter from '../Filter'; class PurchaseHistory extends Component { static propTypes = { diff --git a/packages/venia-concept/src/queries/getCategory.graphql b/packages/venia-concept/src/queries/getCategory.graphql index 22b56ce9d9..c81afbee3f 100644 --- a/packages/venia-concept/src/queries/getCategory.graphql +++ b/packages/venia-concept/src/queries/getCategory.graphql @@ -1,33 +1,52 @@ -query category($id: Int!, $pageSize: Int!, $currentPage: Int!, $onServer: Boolean!) { - category(id: $id) { +query category( + $id: Int! + $pageSize: Int! + $currentPage: Int! + $onServer: Boolean! + $idString: String +) { + category(id: $id) { + id + description + name + product_count + meta_title @include(if: $onServer) + meta_keywords @include(if: $onServer) + meta_description @include(if: $onServer) + } + products( + pageSize: $pageSize + currentPage: $currentPage + filter: { category_id: { eq: $idString } } + ) { + filters { + name + filter_items_count + request_var + filter_items { + label + value_string + } + } + items { id - description name - product_count - products(pageSize: $pageSize, currentPage: $currentPage) { - items { - id - name - small_image { - url - } - url_key - price { - regularPrice { - amount { - value - currency - } - } + small_image { + url + } + url_key + price { + regularPrice { + amount { + value + currency } } - page_info { - total_pages - } - total_count } - meta_title @include(if: $onServer) - meta_keywords @include(if: $onServer) - meta_description @include(if: $onServer) } + page_info { + total_pages + } + total_count } +} diff --git a/packages/venia-concept/src/reducers/catalog.js b/packages/venia-concept/src/reducers/catalog.js index 2c1534129b..f519f94083 100644 --- a/packages/venia-concept/src/reducers/catalog.js +++ b/packages/venia-concept/src/reducers/catalog.js @@ -1,4 +1,5 @@ import { handleActions } from 'redux-actions'; +import { getFilterParams } from 'src/util/getFilterParamsFromUrl'; import actions from 'src/actions/catalog'; @@ -9,7 +10,8 @@ export const initialState = { rootCategoryId: null, currentPage: 1, pageSize: 6, - prevPageTotal: null + prevPageTotal: null, + chosenFilterOptions: getFilterParams() }; const reducerMap = { @@ -43,6 +45,41 @@ const reducerMap = { ...state, prevPageTotal: payload }; + }, + [actions.filterOption.setToApplied]: state => { + return { + ...state, + chosenFilterOptions: getFilterParams() + }; + }, + [actions.filterOption.update]: ( + state, + { payload: { newState, group } } + ) => { + if (newState.length === 0 && group) { + const { chosenFilterOptions } = state; + delete chosenFilterOptions[group]; + + return { + ...state, + chosenFilterOptions: { + ...chosenFilterOptions + } + }; + } + return { + ...state, + chosenFilterOptions: { + ...state.chosenFilterOptions, + [group]: newState + } + }; + }, + [actions.filterOption.clear]: state => { + return { + ...state, + chosenFilterOptions: {} + }; } }; diff --git a/packages/venia-concept/src/shared/persistentQueries.js b/packages/venia-concept/src/shared/persistentQueries.js new file mode 100644 index 0000000000..6bf7e8eab9 --- /dev/null +++ b/packages/venia-concept/src/shared/persistentQueries.js @@ -0,0 +1,6 @@ +/** + * Groups of URL query strings which should persist in scenarios + * when new query string is being pushed. The case below covers persistence + * of query strings on category page and search results page. + */ +export const persistentQueries = ['page', 'query', 'category']; diff --git a/packages/venia-concept/src/util/getFilterParamsFromUrl.js b/packages/venia-concept/src/util/getFilterParamsFromUrl.js new file mode 100644 index 0000000000..0ff2844c5d --- /dev/null +++ b/packages/venia-concept/src/util/getFilterParamsFromUrl.js @@ -0,0 +1,32 @@ +import { persistentQueries } from 'src/shared/persistentQueries'; + +export const getFilterParams = () => { + const params = new URLSearchParams(window.location.search); + let titles, + values = []; + + const urlFilterParams = {}; + + for (const key of params.keys()) { + const cleanKey = key.replace(/\[.*\]/gm, ''); + if (urlFilterParams[cleanKey]) continue; + + /** + * Filter out persistent queries + */ + const isPersistent = persistentQueries.filter( + query => query === cleanKey + ); + if (isPersistent.length > 0) continue; + + titles = params.getAll(`${cleanKey}[title]`); + values = params.getAll(`${cleanKey}[value]`); + + urlFilterParams[cleanKey] = titles.map((title, index) => ({ + title: title, + value: values[index] + })); + } + + return urlFilterParams; +}; diff --git a/packages/venia-concept/src/util/preserveQueryParams.js b/packages/venia-concept/src/util/preserveQueryParams.js new file mode 100644 index 0000000000..88a1b0b8fc --- /dev/null +++ b/packages/venia-concept/src/util/preserveQueryParams.js @@ -0,0 +1,18 @@ +/** + * Function takes location object, filters through it and creates + * new query params with preserved values from the array. + * @param {object} location + * @param {array} queries + */ +export const preserveQueryParams = (location, queries) => { + if (!location) return null; + const newQueryParam = new URLSearchParams(); + const { search } = location; + const queryParams = new URLSearchParams(search); + queries.map(name => { + const value = queryParams.get(name); + if (!value) return; + newQueryParam.set(name, value); + }); + return newQueryParam; +};