UNPKG

@shopgate/engage

Version:
283 lines (262 loc) • 9.84 kB
import React, { useMemo, useState, useEffect, useCallback } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import debounce from 'lodash/debounce'; import isEqual from 'lodash/isEqual'; import { router } from '@shopgate/engage/core'; import { getFiltersByHash } from '@shopgate/engage/filter'; // eslint-disable-next-line no-unused-vars, import/named import Context, { APIFilter, RouteFilters } from "./FilterPageProvider.context"; import { buildInitialFilters, buildUpdatedFilters } from "../helpers"; /** * @param {Object} state The application state. * @param {Object} props The component props. * @returns {Object} */ import { jsx as _jsx } from "react/jsx-runtime"; const mapStateToProps = (state, props) => ({ filters: getFiltersByHash(state, props) }); /** * @param {Object} next The next component props. * @param {Object} prev The previous component props. * @returns {boolean} */ const areStatePropsEqual = (next, prev) => { if (!prev.filters && next.filters || !isEqual(prev.filters, next.filters)) { return false; } return true; }; /** * The FilterPageProvider component provides all relevant data and callbacks to represent and modify * the current state of the "filter" page. * @param {Object} props Provider props * @param {APIFilter[]} props.filters Array of available filters * @param {RouteFilters} props.activeFilters Object with the active filters for a filtered product * list * @param {string} props.parentRouteId Id of the route with the product list that's supposed to be * filtered * @param {Function} [props.onApply] Callback invoked when users pressed the apply button * @param {string} [props.categoryId] A category to be used for filter selection from Redux * @param {string} [props.searchPhrase] A search phrase to be used for filter selection from Redux * @param {NodeList} children Provider children * @returns {JSX.Element} */ const FilterPageProvider = ({ filters: filtersProp, activeFilters: activeFiltersProp, parentRouteId, onApply, children }) => { const [currentFilters, setCurrentFilters] = useState(activeFiltersProp || {}); /** * Storage that hosts an object that represents the initial state of the filters page. * It's created from the "filters" array that contains all available filters, and the * "activeFilters" object that represents filters with an active value selection. */ const [initialFilters, setInitialFilters] = useState(buildInitialFilters(filtersProp, activeFiltersProp)); /** * Storage that hosts an object that represents the a partial state of the filters page with * all filters that where modified since the filters page was opened. */ const [changedFilters, setChangedFilters] = useState({}); // Object that represents the current state of all filters const mergedFilters = useMemo(() => ({ ...initialFilters, ...changedFilters }), [changedFilters, initialFilters]); /** * Effect that updates the "initialFilters" state when it doesn't have content yet */ useEffect(() => { setInitialFilters(currentState => { if (Object.keys(currentState).length > 0) { return currentState; } return buildInitialFilters(filtersProp, activeFiltersProp); }); }, [activeFiltersProp, filtersProp]); /** * Whether a reset of the active filters is possible. * * Reset is possible whenever filters where selected by the user before, or when filters where * modified since the filters page was opened. * @type {boolean} */ const resetPossible = useMemo(() => !!(Object.keys(currentFilters).length || Object.keys(changedFilters).length), [changedFilters, currentFilters]); /** * Whether the filter selection has changed since the filters page was opened * @type {boolean} */ const hasChanged = useMemo(() => Object.keys(changedFilters).length > 0 || !!(Object.keys(currentFilters).length === 0 && activeFiltersProp), [activeFiltersProp, changedFilters, currentFilters]); /** * Retrieves a list of currently selected values for a filter * @callback getSelectedFilterValues * @param {string} filterId The id of the filter * @returns {string[]} */ const getSelectedFilterValues = useCallback( /** * @param {string} filterId The id of the filter * @returns {string[]} */ filterId => { const value = changedFilters[filterId] ? changedFilters[filterId].value : initialFilters[filterId]?.value || []; return value.map(entry => entry.id || entry); }, [changedFilters, initialFilters]); /** * Resets all filters which have been changed by the user */ const resetAllFilters = useCallback(() => { setInitialFilters(buildInitialFilters(filtersProp, {})); setCurrentFilters({}); setChangedFilters({}); }, [filtersProp]); /** * Resets all filters which have been changed by the user since the filters page was opened */ const resetChangedFilters = useCallback(() => { setChangedFilters({}); }, []); /** * Adds or updates the selection for a changed filter * @callback updateChangedFilterInternal * @param {string} filterId The id of the filter to be updated * @param {string[]} selectedValues The updated selected values */ const updateChangedFilterInternal = useCallback( /** * @param {string} filterId The id of the filter to be updated * @param {string[]} selectedValues The updated selected values */ (filterId, selectedValues) => { setChangedFilters(currentState => ({ ...currentState, [filterId]: selectedValues })); }, []); /** * Removes a changed filter * @callback removeChangedFilterInternal * @param {string} filterId The id of the filter to be updated * @param {string[]} selectedValues The updated selected values */ const removeChangedFilterInternal = useCallback( /** * @param {string} filterId The id of the filter to be removed */ filterId => { setChangedFilters(currentState => { // Separate the given id from the other set filters. const { [filterId]: removed, ...remainingFilters } = currentState; return remainingFilters; }); }, []); /** * Updates the selection for a filter * * @param {string} filterId The id of the filter to be updated * @param {string[]} selectedValues The updated selected values */ const updateSelectedFilterValues = useCallback(debounce( /** * @param {string} filterId The id of the filter to be updated * @param {string[]} selectedValues The updated selected values */ (filterId, selectedValues) => { // Retrieve data of filter to be updated from the filters array. const filter = filtersProp.find(entry => entry.id === filterId); // Retrieve the values for the updated filter that where set when the filter page was opened const { value: initialValues } = initialFilters[filterId]; // Prepare the update payload let stateValue = [].concat(selectedValues); /** * No initial values where set for this filter, and the update contains no values. So we * can remove the filter from the changedFilters storage. */ if (initialValues.length === 0 && selectedValues.length === 0) { removeChangedFilterInternal(filterId); return; } /** * When the filter update would recreate the state that the filter initially had, we * remove the filter from the changedFilters storage. * * That enables proper behavior for the "reset" and "update" button states. */ if (initialValues.length !== 0 && selectedValues.length !== 0) { if (initialValues.every((initial, i) => initial === selectedValues[i])) { removeChangedFilterInternal(filterId); return; } } if (Array.isArray(filter.values)) { /** * The selectedValues array only contains a list of ids. * For the getProducts request that's dispatched after the current filter selection was * applied, id and label is required at the filter values level. */ stateValue = selectedValues.map(valueId => { const match = filter.values.find(entry => entry.id === valueId); return { id: match.id, label: match.label }; }); } updateChangedFilterInternal(filterId, { id: filterId, type: filter.type, label: filter.label, value: stateValue, ...(filter.source && { source: filter.source }) }); }, 50), [filtersProp, initialFilters, removeChangedFilterInternal, updateChangedFilterInternal]); /** * Applies the current filter selection to the parent route with a product list to be filtered */ const applyFilters = useCallback(() => { const filters = buildUpdatedFilters(currentFilters, changedFilters); router.update(parentRouteId, { filters }); onApply(filters); }, [changedFilters, currentFilters, onApply, parentRouteId]); const value = useMemo(() => ({ resetPossible, hasChanged, apiFilters: filtersProp || [], filters: mergedFilters, resetAllFilters, resetChangedFilters, getSelectedFilterValues, updateSelectedFilterValues, applyFilters }), [hasChanged, resetPossible, filtersProp, mergedFilters, resetAllFilters, resetChangedFilters, getSelectedFilterValues, updateSelectedFilterValues, applyFilters]); return /*#__PURE__*/_jsx(Context.Provider, { value: value, children: children }); }; FilterPageProvider.defaultProps = { children: null, activeFilters: null, parentRouteId: null, filters: null, onApply: () => setTimeout(router.pop, 250) }; /** * @type FilterPageProvider */ export default connect(mapStateToProps, null, null, { areStatePropsEqual })(FilterPageProvider);