diff --git a/.changeset/slimy-pans-worry.md b/.changeset/slimy-pans-worry.md new file mode 100644 index 00000000000..1698bd06680 --- /dev/null +++ b/.changeset/slimy-pans-worry.md @@ -0,0 +1,5 @@ +--- +'@shopify/polaris': minor +--- + +Updated Filters to not render or perform filters logic if the filters array is empty diff --git a/polaris-react/src/components/Filters/Filters.tsx b/polaris-react/src/components/Filters/Filters.tsx index 0b98b65c6d3..173d83f398c 100644 --- a/polaris-react/src/components/Filters/Filters.tsx +++ b/polaris-react/src/components/Filters/Filters.tsx @@ -1,27 +1,15 @@ -import React, {useState, useRef, useEffect, useMemo} from 'react'; +import React, {useMemo} from 'react'; import type {ReactNode} from 'react'; -import {PlusMinor} from '@shopify/polaris-icons'; import type {TransitionStatus} from 'react-transition-group'; -import {useI18n} from '../../utilities/i18n'; -import {Popover} from '../Popover'; -import {ActionList} from '../ActionList'; -import {Text} from '../Text'; -import {UnstyledButton} from '../UnstyledButton'; import {classNames} from '../../utilities/css'; -import {useBreakpoints} from '../../utilities/breakpoints'; -import type { - ActionListItemDescriptor, - AppliedFilterInterface, - FilterInterface, -} from '../../types'; +import type {AppliedFilterInterface, FilterInterface} from '../../types'; import {InlineStack} from '../InlineStack'; import type {BoxProps} from '../Box'; import {Box} from '../Box'; import {Spinner} from '../Spinner'; -import {Button} from '../Button'; -import {FilterPill, SearchField} from './components'; +import {FiltersBar, SearchField} from './components'; import styles from './Filters.scss'; const TRANSITION_DURATION = 'var(--p-motion-duration-150)'; @@ -136,150 +124,6 @@ export function Filters({ onAddFilterClick, closeOnChildOverlayClick, }: FiltersProps) { - const i18n = useI18n(); - const {mdDown} = useBreakpoints(); - const [popoverActive, setPopoverActive] = useState(false); - const [localPinnedFilters, setLocalPinnedFilters] = useState([]); - const hasMounted = useRef(false); - - useEffect(() => { - hasMounted.current = true; - }); - - const togglePopoverActive = () => - setPopoverActive((popoverActive) => !popoverActive); - - const handleAddFilterClick = () => { - onAddFilterClick?.(); - togglePopoverActive(); - }; - const appliedFilterKeys = appliedFilters?.map(({key}) => key); - - const pinnedFiltersFromPropsAndAppliedFilters = filters.filter( - ({pinned, key}) => - (Boolean(pinned) || appliedFilterKeys?.includes(key)) && - // Filters that are pinned in local state display at the end of our list - !localPinnedFilters.find((filterKey) => filterKey === key), - ); - - useEffect(() => { - const allAppliedFilterKeysInLocalPinnedFilters = - !appliedFilterKeys || - appliedFilterKeys.every((value) => localPinnedFilters.includes(value)); - - if (!allAppliedFilterKeysInLocalPinnedFilters) { - setLocalPinnedFilters((currentLocalPinnedFilters: string[]): string[] => { - const newPinnedFilters = - appliedFilterKeys?.filter( - (filterKey) => - !currentLocalPinnedFilters.find( - (currentFilterKey) => currentFilterKey === filterKey, - ), - ) || []; - - return [...currentLocalPinnedFilters, ...newPinnedFilters]; - }); - } - }, [appliedFilterKeys, localPinnedFilters]); - - const pinnedFiltersFromLocalState = localPinnedFilters - .map((key) => filters.find((filter) => filter.key === key)) - .reduce( - (acc, filter) => (filter ? [...acc, filter] : acc), - [], - ); - - const pinnedFilters = [ - ...pinnedFiltersFromPropsAndAppliedFilters, - ...pinnedFiltersFromLocalState, - ]; - - const onFilterClick = - ({key, onAction}: FilterInterface) => - () => { - // PopoverOverlay will cause a rerender of the component and nuke the - // popoverActive state, so we set this as a microtask - setTimeout(() => { - setLocalPinnedFilters((currentLocalPinnedFilters) => [ - ...new Set([...currentLocalPinnedFilters, key]), - ]); - onAction?.(); - togglePopoverActive(); - }, 0); - }; - - const filterToActionItem = (filter: FilterInterface) => ({ - ...filter, - content: filter.label, - onAction: onFilterClick(filter), - }); - - const unpinnedFilters = filters.filter( - (filter) => !pinnedFilters.some(({key}) => key === filter.key), - ); - - const unsectionedFilters = unpinnedFilters - .filter((filter) => !filter.section) - .map(filterToActionItem); - - const sectionedFilters = unpinnedFilters - .filter((filter) => filter.section) - .reduce( - (acc, filter) => { - const filterActionItem = filterToActionItem(filter); - const sectionIndex = acc.findIndex( - (section) => section.title === filter.section, - ); - - if (sectionIndex === -1) { - acc.push({ - title: filter.section!, - items: [filterActionItem], - }); - } else { - acc[sectionIndex].items.push(filterActionItem); - } - - return acc; - }, - [] as { - title: string; - items: ActionListItemDescriptor[]; - }[], - ); - - const hasOneOrMorePinnedFilters = pinnedFilters.length >= 1; - - const labelVariant = mdDown ? 'bodyLg' : 'bodySm'; - - const addFilterActivator = ( -
- - - {i18n.translate('Polaris.Filters.addFilter')}{' '} - - - -
- ); - - const handleClearAllFilters = () => { - setLocalPinnedFilters([]); - onClearAll?.(); - }; - - const shouldShowAddButton = filters.some((filter) => !filter.pinned); - const additionalContent = useMemo(() => { return ( <> @@ -351,114 +195,20 @@ export function Filters({ } : undefined; - const pinnedFiltersMarkup = pinnedFilters.map( - ({key: filterKey, ...pinnedFilter}) => { - const appliedFilter = appliedFilters?.find(({key}) => key === filterKey); - const handleFilterPillRemove = () => { - setLocalPinnedFilters((currentLocalPinnedFilters) => - currentLocalPinnedFilters.filter((key) => key !== filterKey), - ); - appliedFilter?.onRemove(filterKey); - }; - - return ( - - ); - }, - ); - - const addButton = shouldShowAddButton ? ( -
- - - -
- ) : null; - - const clearAllMarkup = - appliedFilters?.length || localPinnedFilters.length ? ( -
- -
- ) : null; - const filtersMarkup = hideFilters || filters.length === 0 ? null : ( -
-
-
- {pinnedFiltersMarkup} - {addButton} - {clearAllMarkup} -
-
- {hideQueryField ? ( - - - {additionalContent} - - - ) : null} -
+ ); return ( diff --git a/polaris-react/src/components/Filters/components/FiltersBar/FiltersBar.tsx b/polaris-react/src/components/Filters/components/FiltersBar/FiltersBar.tsx new file mode 100644 index 00000000000..3fb5ff671cb --- /dev/null +++ b/polaris-react/src/components/Filters/components/FiltersBar/FiltersBar.tsx @@ -0,0 +1,299 @@ +import type {ReactNode} from 'react'; +import React, {useState, useRef, useEffect} from 'react'; +import {PlusMinor} from '@shopify/polaris-icons'; +import type {TransitionStatus} from 'react-transition-group'; + +import {useI18n} from '../../../../utilities/i18n'; +import {useOnValueChange} from '../../../../utilities/use-on-value-change'; +import {Popover} from '../../../Popover'; +import {ActionList} from '../../../ActionList'; +import {Text} from '../../../Text'; +import {UnstyledButton} from '../../../UnstyledButton'; +import {classNames} from '../../../../utilities/css'; +import {useBreakpoints} from '../../../../utilities/breakpoints'; +import type { + ActionListItemDescriptor, + AppliedFilterInterface, + FilterInterface, +} from '../../../../types'; +import {InlineStack} from '../../../InlineStack'; +import {Box} from '../../../Box'; +import {Button} from '../../../Button'; +import {FilterPill} from '../FilterPill'; +import styles from '../../Filters.scss'; + +export interface FiltersBarProps { + /** Currently entered text in the query field */ + queryValue?: string; + /** Placeholder text for the query field. */ + queryPlaceholder?: string; + /** Whether the query field is focused. */ + focused?: boolean; + /** Available filters added to the filter bar. Shortcut filters are pinned to the front of the bar. */ + filters: FilterInterface[]; + /** Applied filters which are rendered as filter pills. The remove callback is called with the respective key. */ + appliedFilters?: AppliedFilterInterface[]; + /** Callback when the reset all button is pressed. */ + onClearAll: () => void; + /** Disable all filters. */ + disabled?: boolean; + /** Hide the query field. */ + hideQueryField?: boolean; + /** Disable the filters */ + disableFilters?: boolean; + mountedState?: TransitionStatus; + /** Callback when the add filter button is clicked. */ + onAddFilterClick?: () => void; + /** Whether the filter should close when clicking inside another Popover. */ + closeOnChildOverlayClick?: boolean; + additionalContent?: ReactNode; + mountedStateStyles?: any; +} + +export function FiltersBar({ + filters, + appliedFilters, + onClearAll, + disabled, + hideQueryField, + disableFilters, + mountedStateStyles, + additionalContent, + onAddFilterClick, + closeOnChildOverlayClick, +}: FiltersBarProps) { + const i18n = useI18n(); + const {mdDown} = useBreakpoints(); + const [popoverActive, setPopoverActive] = useState(false); + const hasMounted = useRef(false); + useEffect(() => { + hasMounted.current = true; + }); + + const togglePopoverActive = () => + setPopoverActive((popoverActive) => !popoverActive); + + const handleAddFilterClick = () => { + onAddFilterClick?.(); + togglePopoverActive(); + }; + const appliedFilterKeys = appliedFilters?.map(({key}) => key); + + const pinnedFiltersFromPropsAndAppliedFilters = filters.filter( + ({pinned, key}) => { + const isPinnedOrApplied = + Boolean(pinned) || appliedFilterKeys?.includes(key); + return isPinnedOrApplied; + }, + ); + const [localPinnedFilters, setLocalPinnedFilters] = useState( + pinnedFiltersFromPropsAndAppliedFilters.map(({key}) => key), + ); + + useOnValueChange(filters.length, () => { + setLocalPinnedFilters( + pinnedFiltersFromPropsAndAppliedFilters.map(({key}) => key), + ); + }); + + const pinnedFilters = localPinnedFilters + .map((key) => filters.find((filter) => filter.key === key)) + .reduce( + (acc, filter) => (filter ? [...acc, filter] : acc), + [], + ); + + const onFilterClick = + ({key, onAction}: FilterInterface) => + () => { + // PopoverOverlay will cause a rerender of the component and nuke the + // popoverActive state, so we set this as a microtask + setTimeout(() => { + setLocalPinnedFilters((currentLocalPinnedFilters) => [ + ...new Set([...currentLocalPinnedFilters, key]), + ]); + onAction?.(); + togglePopoverActive(); + }, 0); + }; + + const filterToActionItem = (filter: FilterInterface) => ({ + ...filter, + content: filter.label, + onAction: onFilterClick(filter), + }); + + const unpinnedFilters = filters.filter( + (filter) => !pinnedFilters.some(({key}) => key === filter.key), + ); + + const unsectionedFilters = unpinnedFilters + .filter((filter) => !filter.section) + .map(filterToActionItem); + + const sectionedFilters = unpinnedFilters + .filter((filter) => filter.section) + .reduce( + (acc, filter) => { + const filterActionItem = filterToActionItem(filter); + const sectionIndex = acc.findIndex( + (section) => section.title === filter.section, + ); + + if (sectionIndex === -1) { + acc.push({ + title: filter.section!, + items: [filterActionItem], + }); + } else { + acc[sectionIndex].items.push(filterActionItem); + } + + return acc; + }, + [] as { + title: string; + items: ActionListItemDescriptor[]; + }[], + ); + + const hasOneOrMorePinnedFilters = pinnedFilters.length >= 1; + + const labelVariant = mdDown ? 'bodyMd' : 'bodySm'; + + const addFilterActivator = ( +
+ + + {i18n.translate('Polaris.Filters.addFilter')}{' '} + + + +
+ ); + + const handleClearAllFilters = () => { + setLocalPinnedFilters([]); + onClearAll?.(); + }; + const shouldShowAddButton = filters.some((filter) => !filter.pinned); + + const pinnedFiltersMarkup = pinnedFilters.map( + ({key: filterKey, ...pinnedFilter}) => { + const appliedFilter = appliedFilters?.find(({key}) => key === filterKey); + const handleFilterPillRemove = () => { + setLocalPinnedFilters((currentLocalPinnedFilters) => + currentLocalPinnedFilters.filter((key) => key !== filterKey), + ); + appliedFilter?.onRemove(filterKey); + }; + + return ( + + ); + }, + ); + + const addButton = shouldShowAddButton ? ( +
+ + + +
+ ) : null; + + const clearAllMarkup = + appliedFilters?.length || localPinnedFilters.length ? ( +
+ +
+ ) : null; + + return ( +
+
+
+ {pinnedFiltersMarkup} + {addButton} + {clearAllMarkup} +
+
+ {hideQueryField ? ( + + + {additionalContent} + + + ) : null} +
+ ); +} diff --git a/polaris-react/src/components/Filters/components/FiltersBar/index.ts b/polaris-react/src/components/Filters/components/FiltersBar/index.ts new file mode 100644 index 00000000000..0bb12d6cb90 --- /dev/null +++ b/polaris-react/src/components/Filters/components/FiltersBar/index.ts @@ -0,0 +1 @@ +export * from './FiltersBar'; diff --git a/polaris-react/src/components/Filters/components/FiltersBar/tests/FiltersBar.test.tsx b/polaris-react/src/components/Filters/components/FiltersBar/tests/FiltersBar.test.tsx new file mode 100644 index 00000000000..669034455b9 --- /dev/null +++ b/polaris-react/src/components/Filters/components/FiltersBar/tests/FiltersBar.test.tsx @@ -0,0 +1,347 @@ +import React from 'react'; +import {matchMedia} from '@shopify/jest-dom-mocks'; +import {mountWithApp} from 'tests/utilities'; + +import {ActionList} from '../../../../ActionList'; +import {FiltersBar} from '../FiltersBar'; +import type {FiltersBarProps} from '../FiltersBar'; +import {FilterPill} from '../../FilterPill'; + +describe('', () => { + let originalScroll: any; + + beforeEach(() => { + originalScroll = HTMLElement.prototype.scroll; + matchMedia.mock(); + }); + + afterEach(() => { + HTMLElement.prototype.scroll = originalScroll; + matchMedia.restore(); + }); + + const defaultProps: FiltersBarProps = { + filters: [ + { + key: 'foo', + label: 'Foo', + pinned: false, + filter:
Filter
, + }, + { + key: 'bar', + label: 'Bar', + pinned: true, + filter:
Filter
, + }, + { + key: 'baz', + label: 'Baz', + pinned: false, + filter:
Filter
, + }, + ], + appliedFilters: [], + onClearAll: jest.fn(), + }; + + it('renders a list of pinned filters', () => { + const scrollSpy = jest.fn(); + HTMLElement.prototype.scroll = scrollSpy; + const wrapper = mountWithApp(); + + expect(wrapper).toContainReactComponentTimes(FilterPill, 1); + expect(wrapper).toContainReactComponent(FilterPill, { + label: defaultProps.filters[1].label, + }); + }); + + it('renders the unpinned filters inside a Popover', () => { + const scrollSpy = jest.fn(); + HTMLElement.prototype.scroll = scrollSpy; + const wrapper = mountWithApp(); + + wrapper.act(() => { + wrapper + .find('button', { + 'aria-label': 'Add filter', + })! + .trigger('onClick'); + }); + + expect(wrapper).toContainReactComponent(ActionList, { + items: [ + expect.objectContaining({content: defaultProps.filters[0].label}), + expect.objectContaining({content: defaultProps.filters[2].label}), + ], + }); + }); + + it('renders the unpinned disabled filters inside a Popover', () => { + const scrollSpy = jest.fn(); + HTMLElement.prototype.scroll = scrollSpy; + const filters = [ + ...defaultProps.filters, + { + key: 'disabled', + label: 'Disabled', + pinned: false, + disabled: true, + filter:
Filter
, + }, + ]; + + const wrapper = mountWithApp( + , + ); + + wrapper.act(() => { + wrapper + .find('button', { + 'aria-label': 'Add filter', + })! + .trigger('onClick'); + }); + + expect(wrapper).toContainReactComponent(ActionList, { + items: [ + expect.objectContaining({ + content: filters[0].label, + }), + expect.objectContaining({ + content: filters[2].label, + }), + expect.objectContaining({ + content: filters[3].label, + disabled: true, + }), + ], + }); + }); + + it('renders an applied filter', () => { + const scrollSpy = jest.fn(); + HTMLElement.prototype.scroll = scrollSpy; + const appliedFilters = [ + { + ...defaultProps.filters[2], + label: 'Bux', + value: ['Bux'], + onRemove: jest.fn(), + }, + ]; + const wrapper = mountWithApp( + , + ); + + expect(wrapper).toContainReactComponentTimes(FilterPill, 2); + expect(wrapper.findAll(FilterPill)[1]).toHaveReactProps({ + label: 'Bux', + selected: true, + }); + }); + + it('will not open the popover for an applied filter by default', () => { + const appliedFilters = [ + { + ...defaultProps.filters[2], + label: 'Bux', + value: ['Bux'], + onRemove: jest.fn(), + }, + ]; + const wrapper = mountWithApp( + , + ); + + expect(wrapper).toContainReactComponentTimes(FilterPill, 2); + expect(wrapper.findAll(FilterPill)[1]).toHaveReactProps({ + label: 'Bux', + initialActive: false, + }); + }); + + it('triggers the onAddFilterClick callback when the add filter button is clicked', () => { + const callbackFunction = jest.fn(); + const wrapper = mountWithApp( + , + ); + wrapper.act(() => { + wrapper + .find('button', { + 'aria-label': 'Add filter', + })! + .trigger('onClick'); + }); + + expect(callbackFunction).toHaveBeenCalled(); + expect(wrapper).toContainReactComponent(ActionList); + }); + + it('will not remove a pinned filter when it is removed from the applied filters array', () => { + const appliedFilters = [ + { + ...defaultProps.filters[2], + label: 'Value is Baz', + value: ['Baz'], + onRemove: jest.fn(), + }, + ]; + const wrapper = mountWithApp( + , + ); + + expect(wrapper.findAll(FilterPill)[1]).toHaveReactProps({ + label: 'Value is Baz', + selected: true, + }); + wrapper.setProps({appliedFilters: []}); + + expect(wrapper.findAll(FilterPill)[1]).toHaveReactProps({ + label: 'Baz', + selected: false, + }); + }); + + it('correctly invokes the onRemove callback when clicking on an applied filter', () => { + const scrollSpy = jest.fn(); + HTMLElement.prototype.scroll = scrollSpy; + const appliedFilters = [ + { + ...defaultProps.filters[2], + label: 'Bux', + value: ['Bux'], + onRemove: jest.fn(), + }, + ]; + const wrapper = mountWithApp( + , + ); + + wrapper.act(() => { + wrapper.findAll(FilterPill)[1].findAll('button')[1].trigger('onClick'); + }); + + expect(appliedFilters[0].onRemove).toHaveBeenCalled(); + }); + + it('will not render the add badge if all filters are pinned by default', () => { + const scrollSpy = jest.fn(); + HTMLElement.prototype.scroll = scrollSpy; + const filters = defaultProps.filters.map((filter) => ({ + ...filter, + pinned: true, + })); + + const wrapper = mountWithApp( + , + ); + + expect(wrapper).not.toContainReactComponent('div', { + className: 'AddFilterActivator', + }); + }); + + it('will not render a disabled filter if pinned', () => { + const scrollSpy = jest.fn(); + HTMLElement.prototype.scroll = scrollSpy; + const filters = [ + ...defaultProps.filters, + { + key: 'disabled', + label: 'Disabled', + pinned: true, + disabled: true, + filter:
Filter
, + }, + ]; + + const wrapper = mountWithApp( + , + ); + + expect(wrapper).toContainReactComponentTimes(FilterPill, 2); + + wrapper.act(() => { + wrapper + .find('button', { + 'aria-label': 'Add filter', + })! + .trigger('onClick'); + }); + + expect(wrapper).toContainReactComponent(ActionList, { + items: [ + expect.objectContaining({content: defaultProps.filters[0].label}), + expect.objectContaining({content: defaultProps.filters[2].label}), + ], + }); + + expect(wrapper.findAll(FilterPill)[1].domNode).toBeNull(); + }); + + it('renders filters with sections', () => { + const filtersWithSections = [ + { + key: 'sectionfilter1', + label: 'SF1', + pinned: false, + filter:
SF1
, + section: 'Section One', + }, + { + key: 'sectionfilter2', + label: 'SF2', + pinned: false, + filter:
SF1
, + section: 'Section Two', + }, + { + key: 'sectionfilter3', + label: 'SF3', + pinned: false, + filter:
SF3
, + section: 'Section One', + }, + ]; + + const wrapper = mountWithApp( + , + ); + + wrapper.act(() => { + wrapper + .find('button', { + 'aria-label': 'Add filter', + })! + .trigger('onClick'); + }); + + expect(wrapper).toContainReactComponent(ActionList, { + sections: [ + expect.objectContaining({ + title: 'Section One', + items: [ + expect.objectContaining({ + content: filtersWithSections[0].label, + }), + expect.objectContaining({ + content: filtersWithSections[2].label, + }), + ], + }), + expect.objectContaining({ + title: 'Section Two', + items: [ + expect.objectContaining({ + content: filtersWithSections[1].label, + }), + ], + }), + ], + }); + }); +}); diff --git a/polaris-react/src/components/Filters/components/index.ts b/polaris-react/src/components/Filters/components/index.ts index 2bb64cad0c9..02044c400ad 100644 --- a/polaris-react/src/components/Filters/components/index.ts +++ b/polaris-react/src/components/Filters/components/index.ts @@ -1,2 +1,3 @@ export * from './FilterPill'; export * from './SearchField'; +export * from './FiltersBar'; diff --git a/polaris-react/src/components/Filters/tests/Filters.test.tsx b/polaris-react/src/components/Filters/tests/Filters.test.tsx index 761d5d00254..ead52bed33c 100644 --- a/polaris-react/src/components/Filters/tests/Filters.test.tsx +++ b/polaris-react/src/components/Filters/tests/Filters.test.tsx @@ -2,10 +2,9 @@ import React from 'react'; import {matchMedia} from '@shopify/jest-dom-mocks'; import {mountWithApp} from 'tests/utilities'; -import {ActionList} from '../../ActionList'; import {Filters} from '../Filters'; import type {FiltersProps} from '../Filters'; -import {FilterPill} from '../components'; +import {FiltersBar, SearchField} from '../components'; describe('', () => { let originalScroll: any; @@ -47,330 +46,38 @@ describe('', () => { onClearAll: jest.fn(), }; - it('renders a list of pinned filters', () => { + it('renders the SearchField by default', () => { const scrollSpy = jest.fn(); HTMLElement.prototype.scroll = scrollSpy; const wrapper = mountWithApp(); - - expect(wrapper).toContainReactComponentTimes(FilterPill, 1); - expect(wrapper).toContainReactComponent(FilterPill, { - label: defaultProps.filters[1].label, - }); + expect(wrapper).toContainReactComponent(SearchField); }); - it('renders the unpinned filters inside a Popover', () => { + it('does not render the SearchField if hideQueryField is true', () => { const scrollSpy = jest.fn(); HTMLElement.prototype.scroll = scrollSpy; - const wrapper = mountWithApp(); - - wrapper.act(() => { - wrapper - .find('button', { - 'aria-label': 'Add filter', - })! - .trigger('onClick'); - }); - - expect(wrapper).toContainReactComponent(ActionList, { - items: [ - expect.objectContaining({content: defaultProps.filters[0].label}), - expect.objectContaining({content: defaultProps.filters[2].label}), - ], - }); + const wrapper = mountWithApp(); + expect(wrapper).not.toContainReactComponent(SearchField); }); - it('renders the unpinned disabled filters inside a Popover', () => { + it('renders the FiltersBar by default', () => { const scrollSpy = jest.fn(); HTMLElement.prototype.scroll = scrollSpy; - const filters = [ - ...defaultProps.filters, - { - key: 'disabled', - label: 'Disabled', - pinned: false, - disabled: true, - filter:
Filter
, - }, - ]; - - const wrapper = mountWithApp( - , - ); - - wrapper.act(() => { - wrapper - .find('button', { - 'aria-label': 'Add filter', - })! - .trigger('onClick'); - }); - - expect(wrapper).toContainReactComponent(ActionList, { - items: [ - expect.objectContaining({ - content: filters[0].label, - }), - expect.objectContaining({ - content: filters[2].label, - }), - expect.objectContaining({ - content: filters[3].label, - disabled: true, - }), - ], - }); - }); - - it('renders an applied filter', () => { - const scrollSpy = jest.fn(); - HTMLElement.prototype.scroll = scrollSpy; - const appliedFilters = [ - { - ...defaultProps.filters[2], - label: 'Bux', - value: ['Bux'], - onRemove: jest.fn(), - }, - ]; - const wrapper = mountWithApp( - , - ); - - expect(wrapper).toContainReactComponentTimes(FilterPill, 2); - expect(wrapper.findAll(FilterPill)[1]).toHaveReactProps({ - label: 'Bux', - selected: true, - }); - }); - - it('will not open the popover for an applied filter by default', () => { - const appliedFilters = [ - { - ...defaultProps.filters[2], - label: 'Bux', - value: ['Bux'], - onRemove: jest.fn(), - }, - ]; - const wrapper = mountWithApp( - , - ); - - expect(wrapper).toContainReactComponentTimes(FilterPill, 2); - expect(wrapper.findAll(FilterPill)[1]).toHaveReactProps({ - label: 'Bux', - initialActive: false, - }); - }); - - it('triggers the onAddFilterClick callback when the add filter button is clicked', () => { - const callbackFunction = jest.fn(); - const wrapper = mountWithApp( - , - ); - wrapper.act(() => { - wrapper - .find('button', { - 'aria-label': 'Add filter', - })! - .trigger('onClick'); - }); - - expect(callbackFunction).toHaveBeenCalled(); - expect(wrapper).toContainReactComponent(ActionList); - }); - - it('will not remove a pinned filter when it is removed from the applied filters array', () => { - const appliedFilters = [ - { - ...defaultProps.filters[2], - label: 'Value is Baz', - value: ['Baz'], - onRemove: jest.fn(), - }, - ]; - const wrapper = mountWithApp( - , - ); - - expect(wrapper.findAll(FilterPill)[1]).toHaveReactProps({ - label: 'Value is Baz', - selected: true, - }); - wrapper.setProps({appliedFilters: []}); - - expect(wrapper.findAll(FilterPill)[1]).toHaveReactProps({ - label: 'Baz', - selected: false, - }); - }); - - it('correctly adds the applied filters when updated via props', () => { - const appliedFilters = [ - { - ...defaultProps.filters[2], - label: 'Value is Baz', - value: ['Baz'], - onRemove: jest.fn(), - }, - ]; - const wrapper = mountWithApp( - , - ); - - expect(wrapper).not.toContainReactComponent(FilterPill, { - selected: true, - }); - - wrapper.setProps({ - appliedFilters, - }); - - expect(wrapper.findAll(FilterPill)[1]).toHaveReactProps({ - label: 'Value is Baz', - selected: true, - }); - }); - - it('correctly invokes the onRemove callback when clicking on an applied filter', () => { - const scrollSpy = jest.fn(); - HTMLElement.prototype.scroll = scrollSpy; - const appliedFilters = [ - { - ...defaultProps.filters[2], - label: 'Bux', - value: ['Bux'], - onRemove: jest.fn(), - }, - ]; - const wrapper = mountWithApp( - , - ); - - wrapper.act(() => { - wrapper.findAll(FilterPill)[1].findAll('button')[1].trigger('onClick'); - }); - - expect(appliedFilters[0].onRemove).toHaveBeenCalled(); + const wrapper = mountWithApp(); + expect(wrapper).toContainReactComponent(FiltersBar); }); - it('will not render the add badge if all filters are pinned by default', () => { + it('does not render the FiltersBar if hideFilters is true', () => { const scrollSpy = jest.fn(); HTMLElement.prototype.scroll = scrollSpy; - const filters = defaultProps.filters.map((filter) => ({ - ...filter, - pinned: true, - })); - - const wrapper = mountWithApp( - , - ); - - expect(wrapper).not.toContainReactComponent('div', { - className: 'AddFilterActivator', - }); + const wrapper = mountWithApp(); + expect(wrapper).not.toContainReactComponent(FiltersBar); }); - it('will not render a disabled filter if pinned', () => { + it('does not render the FiltersBar if hideFilters is falsy but there are no filters', () => { const scrollSpy = jest.fn(); HTMLElement.prototype.scroll = scrollSpy; - const filters = [ - ...defaultProps.filters, - { - key: 'disabled', - label: 'Disabled', - pinned: true, - disabled: true, - filter:
Filter
, - }, - ]; - - const wrapper = mountWithApp( - , - ); - - expect(wrapper).toContainReactComponentTimes(FilterPill, 2); - - wrapper.act(() => { - wrapper - .find('button', { - 'aria-label': 'Add filter', - })! - .trigger('onClick'); - }); - - expect(wrapper).toContainReactComponent(ActionList, { - items: [ - expect.objectContaining({content: defaultProps.filters[0].label}), - expect.objectContaining({content: defaultProps.filters[2].label}), - ], - }); - - expect(wrapper.findAll(FilterPill)[1].domNode).toBeNull(); - }); - - it('renders filters with sections', () => { - const filtersWithSections = [ - { - key: 'sectionfilter1', - label: 'SF1', - pinned: false, - filter:
SF1
, - section: 'Section One', - }, - { - key: 'sectionfilter2', - label: 'SF2', - pinned: false, - filter:
SF1
, - section: 'Section Two', - }, - { - key: 'sectionfilter3', - label: 'SF3', - pinned: false, - filter:
SF3
, - section: 'Section One', - }, - ]; - - const wrapper = mountWithApp( - , - ); - - wrapper.act(() => { - wrapper - .find('button', { - 'aria-label': 'Add filter', - })! - .trigger('onClick'); - }); - - expect(wrapper).toContainReactComponent(ActionList, { - sections: [ - expect.objectContaining({ - title: 'Section One', - items: [ - expect.objectContaining({ - content: filtersWithSections[0].label, - }), - expect.objectContaining({ - content: filtersWithSections[2].label, - }), - ], - }), - expect.objectContaining({ - title: 'Section Two', - items: [ - expect.objectContaining({ - content: filtersWithSections[1].label, - }), - ], - }), - ], - }); + const wrapper = mountWithApp(); + expect(wrapper).not.toContainReactComponent(FiltersBar); }); }); diff --git a/polaris-react/src/components/IndexFilters/IndexFilters.stories.tsx b/polaris-react/src/components/IndexFilters/IndexFilters.stories.tsx index ba3d0b4b9cb..58a96585968 100644 --- a/polaris-react/src/components/IndexFilters/IndexFilters.stories.tsx +++ b/polaris-react/src/components/IndexFilters/IndexFilters.stories.tsx @@ -508,6 +508,578 @@ export function WithPinnedFilters() { return true; }; + const primaryAction: IndexFiltersProps['primaryAction'] = + selected === 0 + ? { + type: 'save-as', + onAction: onCreateNewView, + disabled: false, + loading: false, + } + : { + type: 'save', + onAction: onHandleSave, + disabled: false, + loading: false, + }; + const [accountStatus, setAccountStatus] = useState(null); + const [moneySpent, setMoneySpent] = useState(null); + const [taggedWith, setTaggedWith] = useState(''); + const [queryValue, setQueryValue] = useState(''); + + const handleAccountStatusChange = useCallback( + (value) => setAccountStatus(value), + [], + ); + const handleMoneySpentChange = useCallback( + (value) => setMoneySpent(value), + [], + ); + const handleTaggedWithChange = useCallback( + (value) => setTaggedWith(value), + [], + ); + const handleFiltersQueryChange = useCallback( + (value) => setQueryValue(value), + [], + ); + const handleAccountStatusRemove = useCallback( + () => setAccountStatus(null), + [], + ); + const handleMoneySpentRemove = useCallback(() => setMoneySpent(null), []); + const handleTaggedWithRemove = useCallback(() => setTaggedWith(''), []); + const handleQueryValueRemove = useCallback(() => setQueryValue(''), []); + const handleFiltersClearAll = useCallback(() => { + handleAccountStatusRemove(); + handleMoneySpentRemove(); + handleTaggedWithRemove(); + handleQueryValueRemove(); + }, [ + handleAccountStatusRemove, + handleMoneySpentRemove, + handleQueryValueRemove, + handleTaggedWithRemove, + ]); + + const filters = [ + { + key: 'accountStatus', + label: 'Account status', + filter: ( + + ), + pinned: true, + }, + { + key: 'taggedWith', + label: 'Tagged with', + filter: ( + + ), + pinned: true, + }, + { + key: 'moneySpent', + label: 'Money spent', + filter: ( + + ), + }, + ]; + + const appliedFilters: IndexFiltersProps['appliedFilters'] = []; + if (!isEmpty(accountStatus)) { + const key = 'accountStatus'; + appliedFilters.push({ + key, + label: disambiguateLabel(key, accountStatus), + onRemove: handleAccountStatusRemove, + }); + } + if (!isEmpty(moneySpent)) { + const key = 'moneySpent'; + appliedFilters.push({ + key, + label: disambiguateLabel(key, moneySpent), + onRemove: handleMoneySpentRemove, + }); + } + if (!isEmpty(taggedWith)) { + const key = 'taggedWith'; + appliedFilters.push({ + key, + label: disambiguateLabel(key, taggedWith), + onRemove: handleTaggedWithRemove, + }); + } + + return ( + + {}} + onSort={setSortSelected} + primaryAction={primaryAction} + cancelAction={{ + onAction: onHandleCancel, + disabled: false, + loading: false, + }} + tabs={tabs} + selected={selected} + onSelect={setSelected} + canCreateNewView + onCreateNewView={onCreateNewView} + filters={filters} + appliedFilters={appliedFilters} + onClearAll={handleFiltersClearAll} + mode={mode} + setMode={setMode} + /> + + + ); + + function disambiguateLabel(key, value) { + switch (key) { + case 'moneySpent': + return `Money spent is between $${value[0]} and $${value[1]}`; + case 'taggedWith': + return `Tagged with ${value}`; + case 'accountStatus': + return value.map((val) => `Customer ${val}`).join(', '); + default: + return value; + } + } + + function isEmpty(value) { + if (Array.isArray(value)) { + return value.length === 0; + } else { + return value === '' || value == null; + } + } +} + +export function WithPrefilledFilters() { + const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + const [itemStrings, setItemStrings] = useState([ + 'All', + 'Unpaid', + 'Open', + 'Closed', + 'Local delivery', + 'Local pickup', + ]); + const deleteView = (index: number) => { + const newItemStrings = [...itemStrings]; + newItemStrings.splice(index, 1); + setItemStrings(newItemStrings); + setSelected(0); + }; + + const duplicateView = async (name: string) => { + setItemStrings([...itemStrings, name]); + setSelected(itemStrings.length); + await sleep(1); + return true; + }; + + const tabs: TabProps[] = itemStrings.map((item, index) => ({ + content: item, + index, + onAction: () => {}, + id: `${item}-${index}`, + isLocked: index === 0, + actions: + index === 0 + ? [] + : [ + { + type: 'rename', + onAction: () => {}, + onPrimaryAction: async (value: string) => { + const newItemsStrings = tabs.map((item, idx) => { + if (idx === index) { + return value; + } + return item.content; + }); + await sleep(1); + setItemStrings(newItemsStrings); + return true; + }, + }, + { + type: 'duplicate', + onPrimaryAction: async (name) => { + await sleep(1); + duplicateView(name); + return true; + }, + }, + { + type: 'edit', + }, + { + type: 'delete', + onPrimaryAction: async (id: string) => { + await sleep(1); + deleteView(index); + return true; + }, + }, + ], + })); + const [selected, setSelected] = useState(0); + const onCreateNewView = async (value: string) => { + await sleep(500); + setItemStrings([...itemStrings, value]); + setSelected(itemStrings.length); + return true; + }; + const sortOptions: IndexFiltersProps['sortOptions'] = [ + {label: 'Order', value: 'order asc', directionLabel: 'Ascending'}, + {label: 'Order', value: 'order desc', directionLabel: 'Descending'}, + {label: 'Customer', value: 'customer asc', directionLabel: 'A-Z'}, + {label: 'Customer', value: 'customer desc', directionLabel: 'Z-A'}, + {label: 'Date', value: 'date asc', directionLabel: 'A-Z'}, + {label: 'Date', value: 'date desc', directionLabel: 'Z-A'}, + {label: 'Total', value: 'total asc', directionLabel: 'Ascending'}, + {label: 'Total', value: 'total desc', directionLabel: 'Descending'}, + ]; + const [sortSelected, setSortSelected] = useState(['order asc']); + const {mode, setMode} = useSetIndexFiltersMode(); + const onHandleCancel = () => {}; + + const onHandleSave = async () => { + await sleep(1); + return true; + }; + + const primaryAction: IndexFiltersProps['primaryAction'] = + selected === 0 + ? { + type: 'save-as', + onAction: onCreateNewView, + disabled: false, + loading: false, + } + : { + type: 'save', + onAction: onHandleSave, + disabled: false, + loading: false, + }; + const [accountStatus, setAccountStatus] = useState([ + 'enabled', + ]); + const [moneySpent, setMoneySpent] = useState(null); + const [taggedWith, setTaggedWith] = useState('Returning customer'); + const [queryValue, setQueryValue] = useState(''); + + const handleAccountStatusChange = useCallback( + (value) => setAccountStatus(value), + [], + ); + const handleMoneySpentChange = useCallback( + (value) => setMoneySpent(value), + [], + ); + const handleTaggedWithChange = useCallback( + (value) => setTaggedWith(value), + [], + ); + const handleFiltersQueryChange = useCallback( + (value) => setQueryValue(value), + [], + ); + const handleAccountStatusRemove = useCallback( + () => setAccountStatus(null), + [], + ); + const handleMoneySpentRemove = useCallback(() => setMoneySpent(null), []); + const handleTaggedWithRemove = useCallback(() => setTaggedWith(''), []); + const handleQueryValueRemove = useCallback(() => setQueryValue(''), []); + const handleFiltersClearAll = useCallback(() => { + handleAccountStatusRemove(); + handleMoneySpentRemove(); + handleTaggedWithRemove(); + handleQueryValueRemove(); + }, [ + handleAccountStatusRemove, + handleMoneySpentRemove, + handleQueryValueRemove, + handleTaggedWithRemove, + ]); + + const filters = [ + { + key: 'accountStatus', + label: 'Account status', + filter: ( + + ), + shortcut: true, + }, + { + key: 'taggedWith', + label: 'Tagged with', + filter: ( + + ), + shortcut: true, + }, + { + key: 'moneySpent', + label: 'Money spent', + filter: ( + + ), + }, + ]; + + const appliedFilters: IndexFiltersProps['appliedFilters'] = []; + if (!isEmpty(accountStatus)) { + const key = 'accountStatus'; + appliedFilters.push({ + key, + label: disambiguateLabel(key, accountStatus), + onRemove: handleAccountStatusRemove, + }); + } + if (!isEmpty(moneySpent)) { + const key = 'moneySpent'; + appliedFilters.push({ + key, + label: disambiguateLabel(key, moneySpent), + onRemove: handleMoneySpentRemove, + }); + } + if (!isEmpty(taggedWith)) { + const key = 'taggedWith'; + appliedFilters.push({ + key, + label: disambiguateLabel(key, taggedWith), + onRemove: handleTaggedWithRemove, + }); + } + + return ( + + {}} + onSort={setSortSelected} + primaryAction={primaryAction} + cancelAction={{ + onAction: onHandleCancel, + disabled: false, + loading: false, + }} + tabs={tabs} + selected={selected} + onSelect={setSelected} + canCreateNewView + onCreateNewView={onCreateNewView} + filters={filters} + appliedFilters={appliedFilters} + onClearAll={handleFiltersClearAll} + mode={mode} + setMode={setMode} + /> +
+ + ); + + function disambiguateLabel(key, value) { + switch (key) { + case 'moneySpent': + return `Money spent is between $${value[0]} and $${value[1]}`; + case 'taggedWith': + return `Tagged with ${value}`; + case 'accountStatus': + return value.map((val) => `Customer ${val}`).join(', '); + default: + return value; + } + } + + function isEmpty(value) { + if (Array.isArray(value)) { + return value.length === 0; + } else { + return value === '' || value == null; + } + } +} + +export function WithAsyncData() { + const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + const [loadData, setLoadData] = useState(false); + const [addAsyncFilter, setAddAsyncFilter] = useState(false); + const [itemStrings, setItemStrings] = useState([ + 'All', + 'Unpaid', + 'Open', + 'Closed', + 'Local delivery', + 'Local pickup', + ]); + const deleteView = (index: number) => { + const newItemStrings = [...itemStrings]; + newItemStrings.splice(index, 1); + setItemStrings(newItemStrings); + setSelected(0); + }; + + const duplicateView = async (name: string) => { + setItemStrings([...itemStrings, name]); + setSelected(itemStrings.length); + await sleep(1); + return true; + }; + + const tabs: TabProps[] = itemStrings.map((item, index) => ({ + content: item, + index, + onAction: () => {}, + id: `${item}-${index}`, + isLocked: index === 0, + actions: + index === 0 + ? [] + : [ + { + type: 'rename', + onAction: () => {}, + onPrimaryAction: async (value: string) => { + const newItemsStrings = tabs.map((item, idx) => { + if (idx === index) { + return value; + } + return item.content; + }); + await sleep(1); + setItemStrings(newItemsStrings); + return true; + }, + }, + { + type: 'duplicate', + onPrimaryAction: async (name) => { + await sleep(1); + duplicateView(name); + return true; + }, + }, + { + type: 'edit', + }, + { + type: 'delete', + onPrimaryAction: async (id: string) => { + await sleep(1); + deleteView(index); + return true; + }, + }, + ], + })); + const [selected, setSelected] = useState(0); + const onCreateNewView = async (value: string) => { + await sleep(500); + setItemStrings([...itemStrings, value]); + setSelected(itemStrings.length); + return true; + }; + const sortOptions: IndexFiltersProps['sortOptions'] = [ + {label: 'Order', value: 'order asc', directionLabel: 'Ascending'}, + {label: 'Order', value: 'order desc', directionLabel: 'Descending'}, + {label: 'Customer', value: 'customer asc', directionLabel: 'A-Z'}, + {label: 'Customer', value: 'customer desc', directionLabel: 'Z-A'}, + {label: 'Date', value: 'date asc', directionLabel: 'A-Z'}, + {label: 'Date', value: 'date desc', directionLabel: 'Z-A'}, + {label: 'Total', value: 'total asc', directionLabel: 'Ascending'}, + {label: 'Total', value: 'total desc', directionLabel: 'Descending'}, + ]; + const [sortSelected, setSortSelected] = useState(['order asc']); + const {mode, setMode} = useSetIndexFiltersMode(IndexFiltersMode.Filtering); + const onHandleCancel = () => {}; + + const onHandleSave = async () => { + await sleep(1); + return true; + }; + const primaryAction: IndexFiltersProps['primaryAction'] = selected === 0 ? { @@ -527,6 +1099,10 @@ export function WithPinnedFilters() { ]); const [moneySpent, setMoneySpent] = useState(null); const [taggedWith, setTaggedWith] = useState('Returning customer'); + const [deliveryMethod, setDeliveryMethod] = useState([ + 'local_pick_up', + 'local_delivery', + ]); const [queryValue, setQueryValue] = useState(''); const handleAccountStatusChange = useCallback( @@ -541,6 +1117,10 @@ export function WithPinnedFilters() { (value) => setTaggedWith(value), [], ); + const handleDeliveryMethodChange = useCallback( + (value) => setDeliveryMethod(value), + [], + ); const handleFiltersQueryChange = useCallback( (value) => setQueryValue(value), [], @@ -551,17 +1131,23 @@ export function WithPinnedFilters() { ); const handleMoneySpentRemove = useCallback(() => setMoneySpent(null), []); const handleTaggedWithRemove = useCallback(() => setTaggedWith(''), []); + const handleDeliveryMethodRemove = useCallback( + () => setDeliveryMethod(null), + [], + ); const handleQueryValueRemove = useCallback(() => setQueryValue(''), []); const handleFiltersClearAll = useCallback(() => { handleAccountStatusRemove(); handleMoneySpentRemove(); handleTaggedWithRemove(); + handleDeliveryMethodRemove(); handleQueryValueRemove(); }, [ handleAccountStatusRemove, handleMoneySpentRemove, handleQueryValueRemove, handleTaggedWithRemove, + handleDeliveryMethodRemove, ]); const filters = [ @@ -618,6 +1204,28 @@ export function WithPinnedFilters() { }, ]; + if (addAsyncFilter) { + filters.push({ + key: 'delivery_method', + label: 'Delivery method', + filter: ( + + ), + }); + } + const appliedFilters: IndexFiltersProps['appliedFilters'] = []; if (!isEmpty(accountStatus)) { const key = 'accountStatus'; @@ -643,6 +1251,14 @@ export function WithPinnedFilters() { onRemove: handleTaggedWithRemove, }); } + if (!isEmpty(deliveryMethod)) { + const key = 'delivery_method'; + appliedFilters.push({ + key, + label: disambiguateLabel(key, deliveryMethod), + onRemove: handleDeliveryMethodRemove, + }); + } return ( @@ -665,13 +1281,29 @@ export function WithPinnedFilters() { onSelect={setSelected} canCreateNewView onCreateNewView={onCreateNewView} - filters={filters} - appliedFilters={appliedFilters} + filters={loadData ? filters : []} + appliedFilters={loadData ? appliedFilters : []} onClearAll={handleFiltersClearAll} mode={mode} setMode={setMode} />
+
+ + +
); @@ -683,6 +1315,8 @@ export function WithPinnedFilters() { return `Tagged with ${value}`; case 'accountStatus': return value.map((val) => `Customer ${val}`).join(', '); + case 'delivery_method': + return `Delivery method: ${value.join(', ')}`; default: return value; }