diff --git a/.changeset/three-trains-tie.md b/.changeset/three-trains-tie.md new file mode 100644 index 00000000000..1e8e52412c5 --- /dev/null +++ b/.changeset/three-trains-tie.md @@ -0,0 +1,5 @@ +--- +'@shopify/polaris': minor +--- + +Fixed a bug in `Filters` where changes to the `appliedFilters` prop were not being handled diff --git a/polaris-react/src/components/Filters/Filters.tsx b/polaris-react/src/components/Filters/Filters.tsx index 9ed2e22e1f4..eaa8aef53e2 100644 --- a/polaris-react/src/components/Filters/Filters.tsx +++ b/polaris-react/src/components/Filters/Filters.tsx @@ -141,6 +141,7 @@ export function Filters({ const {mdDown} = useBreakpoints(); const {polarisSummerEditions2023: se23} = useFeatures(); const [popoverActive, setPopoverActive] = useState(false); + const [localPinnedFilters, setLocalPinnedFilters] = useState([]); const hasMounted = useRef(false); useEffect(() => { @@ -157,23 +158,44 @@ export function Filters({ 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), + ({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), ); - const pinnedFilters = localPinnedFilters + useEffect(() => { + const allAppliedFilterKeysInLocalPinnedFilters = 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) => () => { diff --git a/polaris-react/src/components/Filters/tests/Filters.test.tsx b/polaris-react/src/components/Filters/tests/Filters.test.tsx index 1abc7321176..761d5d00254 100644 --- a/polaris-react/src/components/Filters/tests/Filters.test.tsx +++ b/polaris-react/src/components/Filters/tests/Filters.test.tsx @@ -205,6 +205,33 @@ describe('', () => { }); }); + 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; diff --git a/polaris-react/src/components/IndexFilters/IndexFilters.stories.tsx b/polaris-react/src/components/IndexFilters/IndexFilters.stories.tsx index 94af382c70d..cd49d87b73f 100644 --- a/polaris-react/src/components/IndexFilters/IndexFilters.stories.tsx +++ b/polaris-react/src/components/IndexFilters/IndexFilters.stories.tsx @@ -10,6 +10,7 @@ import { RangeSlider, TextField, Card, + IndexFiltersMode, } from '@shopify/polaris'; import {useSetIndexFiltersMode} from './hooks'; @@ -674,6 +675,292 @@ export function WithPinnedFilters() { } } +export function WithNoPinnedAndPrefilledFilters() { + 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(IndexFiltersMode.Filtering); + 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: ( + + ), + pinned: false, + }, + { + key: 'taggedWith', + label: 'Tagged with', + filter: ( + + ), + pinned: false, + }, + { + 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 Disabled() { const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));