From 0bf817afc18e1b19b4037ce1e74717ed71204aa6 Mon Sep 17 00:00:00 2001 From: Chloe Rice Date: Fri, 5 Apr 2024 11:56:15 -0700 Subject: [PATCH] [Filters] Add support for indicating an applied filter has unsaved changes (#11783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### WHY are these changes introduced? Part of https://github.com/Shopify/web/issues/76490 ### WHAT is this pull request doing? Adds support for indicating unsaved changes have been made to the applied filters. ![Screenshot 2024-04-04 at 4 26 23 PM](https://github.com/Shopify/polaris/assets/18447883/7133939e-c313-4cd2-bac3-8aac8e603a0d) ### How to 🎩 📚 [Storybook](https://5d559397bae39100201eedc1-fbscsztkor.chromatic.com/?path=/story/all-components-filters--with-children-content-and-unsaved-changes) 🌀 [Spinstance](https://admin.web.unsaved-indexfilter-changes.chloe-rice.us.spin.dev/store/shop1/products?selectedView=all&status=ACTIVE) 🖥 [Local development instructions](https://github.com/Shopify/polaris/blob/main/README.md#install-dependencies-and-build-workspaces) 🗒 [General tophatting guidelines](https://github.com/Shopify/polaris/blob/main/documentation/Tophatting.md) 📄 [Changelog guidelines](https://github.com/Shopify/polaris/blob/main/.github/CONTRIBUTING.md#changelog) ### 🎩 checklist - [x] Tested a [snapshot](https://github.com/Shopify/polaris/blob/main/documentation/Releasing.md#-snapshot-releases) - [x] Tested on [mobile](https://github.com/Shopify/polaris/blob/main/documentation/Tophatting.md#cross-browser-testing) - [x] Tested on [multiple browsers](https://help.shopify.com/en/manual/shopify-admin/supported-browsers) - [x] Tested for [accessibility](https://github.com/Shopify/polaris/blob/main/documentation/Accessibility%20testing.md) - [x] Updated the component's `README.md` with documentation changes - [x] [Tophatted documentation](https://github.com/Shopify/polaris/blob/main/documentation/Tophatting%20documentation.md) changes in the style guide --------- Co-authored-by: translation-platform[bot] <34770790+translation-platform[bot]@users.noreply.github.com> --- .changeset/cold-windows-occur.md | 5 + polaris-react/locales/cs.json | 3 +- polaris-react/locales/de.json | 3 +- polaris-react/locales/en.json | 3 +- polaris-react/locales/fi.json | 3 +- polaris-react/locales/fr.json | 3 +- polaris-react/locales/it.json | 3 +- polaris-react/locales/ja.json | 3 +- polaris-react/locales/ko.json | 3 +- polaris-react/locales/pl.json | 3 +- polaris-react/locales/pt-PT.json | 3 +- polaris-react/locales/sv.json | 3 +- polaris-react/locales/th.json | 3 +- polaris-react/locales/tr.json | 3 +- polaris-react/locales/vi.json | 3 +- polaris-react/locales/zh-CN.json | 3 +- polaris-react/locales/zh-TW.json | 3 +- .../components/Filters/Filters.stories.tsx | 328 ++++++++++++++++- .../FilterPill/FilterPill.module.css | 5 - .../components/FilterPill/FilterPill.tsx | 77 ++-- .../FilterPill/tests/FilterPill.test.tsx | 19 + .../components/FiltersBar/FiltersBar.tsx | 3 + .../FiltersBar/tests/FiltersBar.test.tsx | 21 +- .../components/SearchField/SearchField.tsx | 6 +- .../components/IndexFilters/IndexFilters.tsx | 5 +- polaris-react/src/types.ts | 8 +- .../selection-and-input/filters.mdx | 2 + ...h-children-content-and-unsaved-changes.tsx | 343 ++++++++++++++++++ 28 files changed, 799 insertions(+), 71 deletions(-) create mode 100644 .changeset/cold-windows-occur.md create mode 100644 polaris.shopify.com/pages/examples/filters-with-children-content-and-unsaved-changes.tsx diff --git a/.changeset/cold-windows-occur.md b/.changeset/cold-windows-occur.md new file mode 100644 index 00000000000..a1f9aa310a6 --- /dev/null +++ b/.changeset/cold-windows-occur.md @@ -0,0 +1,5 @@ +--- +'@shopify/polaris': minor +--- + +Added support to `Filters` for indicating `appliedFilters` have unsaved changes diff --git a/polaris-react/locales/cs.json b/polaris-react/locales/cs.json index eae60596af3..3013e60c8bc 100644 --- a/polaris-react/locales/cs.json +++ b/polaris-react/locales/cs.json @@ -360,7 +360,8 @@ "accessibilityLabel": "Ukončit režim celé obrazovky" }, "FilterPill": { - "clear": "Vymazat" + "clear": "Vymazat", + "unsavedChanges": "Neuložené změny: {label}" }, "IndexFilters": { "searchFilterTooltip": "Hledat a filtrovat", diff --git a/polaris-react/locales/de.json b/polaris-react/locales/de.json index cd7130dd7ad..cc221cb93b7 100644 --- a/polaris-react/locales/de.json +++ b/polaris-react/locales/de.json @@ -358,7 +358,8 @@ "accessibilityLabel": "Vollbildmodus beenden" }, "FilterPill": { - "clear": "Löschen" + "clear": "Löschen", + "unsavedChanges": "Nicht gespeicherte Änderungen – {label}" }, "IndexFilters": { "searchFilterTooltip": "Suchen und filtern", diff --git a/polaris-react/locales/en.json b/polaris-react/locales/en.json index 5a6e5038060..24ca91713f6 100644 --- a/polaris-react/locales/en.json +++ b/polaris-react/locales/en.json @@ -173,7 +173,8 @@ "searchInView": "in:{viewName}" }, "FilterPill": { - "clear": "Clear" + "clear": "Clear", + "unsavedChanges": "Unsaved changes - {label}" }, "IndexFilters": { "searchFilterTooltip": "Search and filter", diff --git a/polaris-react/locales/fi.json b/polaris-react/locales/fi.json index 739aa0bea09..2d7568963c9 100644 --- a/polaris-react/locales/fi.json +++ b/polaris-react/locales/fi.json @@ -358,7 +358,8 @@ "accessibilityLabel": "Poistu koko näytön tilasta" }, "FilterPill": { - "clear": "Tyhjennä" + "clear": "Tyhjennä", + "unsavedChanges": "Tallentamattomat muutokset – {label}" }, "IndexFilters": { "searchFilterTooltip": "Haku ja suodatus", diff --git a/polaris-react/locales/fr.json b/polaris-react/locales/fr.json index c4c5c6c30fa..baadbb22d2f 100644 --- a/polaris-react/locales/fr.json +++ b/polaris-react/locales/fr.json @@ -359,7 +359,8 @@ "accessibilityLabel": "Quitter le mode plein écran" }, "FilterPill": { - "clear": "Effacer" + "clear": "Effacer", + "unsavedChanges": "Modifications non enregistrées - {label}" }, "IndexFilters": { "searchFilterTooltip": "Rechercher et filtrer", diff --git a/polaris-react/locales/it.json b/polaris-react/locales/it.json index 5a720a0cfa0..aafde284e00 100644 --- a/polaris-react/locales/it.json +++ b/polaris-react/locales/it.json @@ -359,7 +359,8 @@ "accessibilityLabel": "Esci dalla modalità schermo intero" }, "FilterPill": { - "clear": "Cancella" + "clear": "Cancella", + "unsavedChanges": "Modifiche non salvate - {label}" }, "IndexFilters": { "searchFilterTooltip": "Cerca e filtra", diff --git a/polaris-react/locales/ja.json b/polaris-react/locales/ja.json index 15620b6040d..cd07b430836 100644 --- a/polaris-react/locales/ja.json +++ b/polaris-react/locales/ja.json @@ -358,7 +358,8 @@ "accessibilityLabel": "フルスクリーンモードを閉じる" }, "FilterPill": { - "clear": "クリア" + "clear": "クリア", + "unsavedChanges": "未保存の変更 - {label}" }, "IndexFilters": { "searchFilterTooltip": "検索と絞り込み", diff --git a/polaris-react/locales/ko.json b/polaris-react/locales/ko.json index bdc9146ff8c..c59ada1b41a 100644 --- a/polaris-react/locales/ko.json +++ b/polaris-react/locales/ko.json @@ -358,7 +358,8 @@ "accessibilityLabel": "전체 화면 모드 종료" }, "FilterPill": { - "clear": "지우기" + "clear": "지우기", + "unsavedChanges": "저장되지 않은 변경 사항 - {label}" }, "IndexFilters": { "searchFilterTooltip": "검색 및 필터링", diff --git a/polaris-react/locales/pl.json b/polaris-react/locales/pl.json index fb62089d572..8f4c04e8a7e 100644 --- a/polaris-react/locales/pl.json +++ b/polaris-react/locales/pl.json @@ -360,7 +360,8 @@ "accessibilityLabel": "Zakończ tryb pełnoekranowy" }, "FilterPill": { - "clear": "Wyczyść" + "clear": "Wyczyść", + "unsavedChanges": "Niezapisane zmiany – {label}" }, "IndexFilters": { "searchFilterTooltip": "Wyszukaj i przefiltruj", diff --git a/polaris-react/locales/pt-PT.json b/polaris-react/locales/pt-PT.json index 0f2eb1fe7e2..b1a6943f0d5 100644 --- a/polaris-react/locales/pt-PT.json +++ b/polaris-react/locales/pt-PT.json @@ -359,7 +359,8 @@ "accessibilityLabel": "Sair do modo ecrã inteiro" }, "FilterPill": { - "clear": "Limpar" + "clear": "Limpar", + "unsavedChanges": "Alterações não guardadas - {label}" }, "IndexFilters": { "searchFilterTooltip": "Pesquisar e filtrar", diff --git a/polaris-react/locales/sv.json b/polaris-react/locales/sv.json index 6eecaa07f34..7c8b2016424 100644 --- a/polaris-react/locales/sv.json +++ b/polaris-react/locales/sv.json @@ -358,7 +358,8 @@ "accessibilityLabel": "Lämna helskärmsläget" }, "FilterPill": { - "clear": "Rensa" + "clear": "Rensa", + "unsavedChanges": "Ej sparade ändringar – {label}" }, "IndexFilters": { "searchFilterTooltip": "Sök och filtrera", diff --git a/polaris-react/locales/th.json b/polaris-react/locales/th.json index 098c618a7f8..a5ea4ffd02c 100644 --- a/polaris-react/locales/th.json +++ b/polaris-react/locales/th.json @@ -358,7 +358,8 @@ "accessibilityLabel": "ออกจากโหมดเต็มหน้าจอ" }, "FilterPill": { - "clear": "ล้าง" + "clear": "ล้าง", + "unsavedChanges": "การเปลี่ยนแปลงที่ไม่ได้บันทึก - {label}" }, "IndexFilters": { "searchFilterTooltip": "ค้นหาและกรอง", diff --git a/polaris-react/locales/tr.json b/polaris-react/locales/tr.json index 44ef7dca3e4..87ad550e2ab 100644 --- a/polaris-react/locales/tr.json +++ b/polaris-react/locales/tr.json @@ -358,7 +358,8 @@ "accessibilityLabel": "Tam ekran modundan çık" }, "FilterPill": { - "clear": "Temizle" + "clear": "Temizle", + "unsavedChanges": "Kaydedilmemiş değişiklikler - {label}" }, "IndexFilters": { "searchFilterTooltip": "Arama ve filtreleme", diff --git a/polaris-react/locales/vi.json b/polaris-react/locales/vi.json index 9bf93965d23..1d3d31acb52 100644 --- a/polaris-react/locales/vi.json +++ b/polaris-react/locales/vi.json @@ -358,7 +358,8 @@ "accessibilityLabel": "Thoát chế độ toàn màn hình" }, "FilterPill": { - "clear": "Xóa" + "clear": "Xóa", + "unsavedChanges": "Thay đổi chưa lưu - {label}" }, "IndexFilters": { "searchFilterTooltip": "Tìm kiếm và lọc", diff --git a/polaris-react/locales/zh-CN.json b/polaris-react/locales/zh-CN.json index ee056905486..aa4a40246fd 100644 --- a/polaris-react/locales/zh-CN.json +++ b/polaris-react/locales/zh-CN.json @@ -358,7 +358,8 @@ "accessibilityLabel": "退出全屏模式" }, "FilterPill": { - "clear": "清除" + "clear": "清除", + "unsavedChanges": "未保存的更改 - {label}" }, "IndexFilters": { "searchFilterTooltip": "搜索和筛选", diff --git a/polaris-react/locales/zh-TW.json b/polaris-react/locales/zh-TW.json index 48d2ee8160a..704a0e36426 100644 --- a/polaris-react/locales/zh-TW.json +++ b/polaris-react/locales/zh-TW.json @@ -358,7 +358,8 @@ "accessibilityLabel": "退出全螢幕模式" }, "FilterPill": { - "clear": "清除" + "clear": "清除", + "unsavedChanges": "尚未儲存的變更 - {label}" }, "IndexFilters": { "searchFilterTooltip": "搜尋及篩選", diff --git a/polaris-react/src/components/Filters/Filters.stories.tsx b/polaris-react/src/components/Filters/Filters.stories.tsx index 338eb97c34a..b84ee9d36b4 100644 --- a/polaris-react/src/components/Filters/Filters.stories.tsx +++ b/polaris-react/src/components/Filters/Filters.stories.tsx @@ -12,7 +12,8 @@ import { ResourceList, TextField, Text, - BlockStack, + Box, + Card, } from '@shopify/polaris'; export default { @@ -539,6 +540,331 @@ export function WithChildrenContent() { } } +export function WithChildrenContentAndUnsavedChanges() { + const emptyFilterState: { + query: { + label: string; + value: ''; + }; + accountStatus: { + label: string; + value: string[]; + }; + moneySpent: { + label: string; + value: [number, number]; + }; + taggedWith: { + label: string; + value: ''; + }; + } = { + query: { + label: 'Search', + value: '', + }, + accountStatus: { + label: 'Account status', + value: [], + }, + moneySpent: { + label: 'Money spent', + value: [0, 0], + }, + taggedWith: { + label: 'Tagged with', + value: '', + }, + }; + + const [queryValue, setQueryValue] = useState(''); + const [taggedWith, setTaggedWith] = useState(''); + const [moneySpent, setMoneySpent] = useState<[number, number]>([0, 0]); + const [accountStatus, setAccountStatus] = useState(['enabled']); + const [savedFilterState, setSavedFilterState] = useState< + Map< + string, + { + label: string; + value: string | string[] | number | [number, number]; + } + > + >(new Map(Object.entries(emptyFilterState))); + + const handleFilterChange = + (key: string) => (value: string | string[] | number | [number, number]) => { + if (key === 'taggedWith') setTaggedWith(value as string); + if (key === 'moneySpent') setMoneySpent(value as [number, number]); + if (key === 'accountStatus') setAccountStatus(value as string[]); + }; + + const handleFilterRemove = (key: string) => { + if (key === 'taggedWith') { + setTaggedWith(emptyFilterState.taggedWith.value); + } else if (key === 'moneySpent') { + setMoneySpent(emptyFilterState.moneySpent.value); + } else if (key === 'accountStatus') { + setAccountStatus(emptyFilterState.accountStatus.value); + } + }; + + const handleFiltersQueryChange = (value: string) => setQueryValue(value); + + const handleQueryValueRemove = useCallback(() => setQueryValue(''), []); + + const handleFiltersClearAll = () => { + Object.entries(emptyFilterState).forEach(([key]) => + handleFilterRemove(key), + ); + + handleQueryValueRemove(); + }; + + const filters = [ + { + key: 'accountStatus', + label: 'Account status', + value: accountStatus, + filter: ( + + ), + shortcut: true, + pinned: true, + }, + { + key: 'taggedWith', + label: 'Tagged with', + value: taggedWith, + filter: ( + + ), + shortcut: true, + pinned: true, + }, + { + key: 'moneySpent', + label: 'Money spent', + value: moneySpent, + filter: ( + + ), + }, + ]; + + const appliedFilters: FiltersProps['appliedFilters'] = []; + + filters.forEach(({key, label, value}) => { + if (!isEmpty(value)) { + appliedFilters.push({ + key, + label: `${label}: ${humanReadableValue(key, value)}`, + unsavedChanges: !isUnchanged(key, value), + onRemove: () => handleFilterRemove(key), + }); + } + }); + + const handleSaveFilters = () => { + const nextSavedFilterState = new Map(savedFilterState); + appliedFilters.forEach(({key, unsavedChanges}) => { + const savedFilter = nextSavedFilterState.get(key); + const value = filters.find((filter) => filter.key === key)?.value; + console.log(`Saving filter: ${key}, ${value}`, savedFilter); + + if (value && unsavedChanges && savedFilter) { + savedFilter.value = value; + } + }); + + setSavedFilterState(nextSavedFilterState); + }; + + const disableAction = appliedFilters.every( + ({unsavedChanges}) => !unsavedChanges, + ); + + return ( +
+ + + + + + + } + flushFilters + items={[ + { + id: '341', + url: '#', + name: 'Mae Jemison', + location: 'Decatur, USA', + }, + { + id: '256', + url: '#', + name: 'Ellen Ochoa', + location: 'Los Angeles, USA', + }, + ]} + renderItem={(item) => { + const {id, url, name, location} = item; + const media = ( + nm.substring(0, 1)) + .join('')} + size="md" + name={name} + /> + ); + + return ( + + + {name} + +
{location}
+
+ ); + }} + /> +
+
+ ); + + function humanReadableValue( + key: string, + value: string | string[] | number | [number, number], + ): string { + if (isEmpty(value)) return ''; + + switch (key) { + case 'moneySpent': { + const [min, max] = value as [number, number]; + if (min === 0 && max === 0) return ''; + if (min === 0) return `up to $${max}`; + if (max === 0) return `more than $${min}`; + return `between $${min} and $${max}`; + } + case 'taggedWith': { + const tags = (value as string).trim().split(','); + if (tags.length === 1) return ` ${tags[0]}`; + else if (tags.length === 2) return `${tags[0]} and ${tags[1]}`; + return tags + .map((tag, index) => { + return index !== tags.length - 1 ? tag : `and ${tag}`; + }) + .join(', '); + } + case 'accountStatus': { + const statuses = value as string[]; + if (statuses.length === 1) { + return statuses[0]; + } else if (statuses.length === 2) { + return `${statuses[0]} or ${statuses[1]}`; + } else { + return statuses + .map((status, index) => { + return index !== statuses.length - 1 ? status : `or ${status}`; + }) + .join(', '); + } + } + default: + return ''; + } + } + + function isEmpty(value: string | string[] | number | [number, number]) { + if (Array.isArray(value)) { + return value.length === 0 || value[1] === 0; + } else { + return value === '' || value === 0 || value == null; + } + } + + function isUnchanged( + key: string, + value: string | string[] | number | [number, number], + ) { + if (key === 'taggedWith') { + return value === savedFilterState.get(key)?.value; + } else if (key === 'moneySpent') { + const [min, max] = value as [number, number]; + const savedMoneySpent = savedFilterState.get(key)?.value as [ + number, + number, + ]; + + return min === savedMoneySpent?.[0] && max === savedMoneySpent?.[1]; + } else if (key === 'accountStatus') { + const savedAccountStatus = + (savedFilterState.get(key)?.value as string[]) || []; + return ( + Array.isArray(value) && + value.every( + (val) => + typeof val === 'string' && + savedAccountStatus?.includes(val as string), + ) + ); + } + } +} + export function Disabled() { const [taggedWith, setTaggedWith] = useState(''); const [queryValue, setQueryValue] = useState(''); diff --git a/polaris-react/src/components/Filters/components/FilterPill/FilterPill.module.css b/polaris-react/src/components/Filters/components/FilterPill/FilterPill.module.css index 6dca1d72a3f..745010abd2f 100644 --- a/polaris-react/src/components/Filters/components/FilterPill/FilterPill.module.css +++ b/polaris-react/src/components/Filters/components/FilterPill/FilterPill.module.css @@ -80,11 +80,6 @@ padding: 0 var(--p-space-100) 0 var(--p-space-200); height: 22px; } - - .Label { - display: flex; - padding-left: var(--p-space-050); - } } .ActiveFilterButton .ToggleButton { diff --git a/polaris-react/src/components/Filters/components/FilterPill/FilterPill.tsx b/polaris-react/src/components/Filters/components/FilterPill/FilterPill.tsx index a32ec3a482a..f6802389e3b 100644 --- a/polaris-react/src/components/Filters/components/FilterPill/FilterPill.tsx +++ b/polaris-react/src/components/Filters/components/FilterPill/FilterPill.tsx @@ -3,6 +3,7 @@ import {XSmallIcon, ChevronDownIcon} from '@shopify/polaris-icons'; import {useI18n} from '../../../../utilities/i18n'; import {useToggle} from '../../../../utilities/use-toggle'; +import {Box} from '../../../Box'; import {Popover} from '../../../Popover'; import {Button} from '../../../Button'; import {BlockStack} from '../../../BlockStack'; @@ -16,6 +17,8 @@ import type {FilterInterface} from '../../../../types'; import styles from './FilterPill.module.css'; export interface FilterPillProps extends FilterInterface { + /** Whether the filter is newly applied or updated and hasn't been saved */ + unsavedChanges?: boolean; /** A unique identifier for the filter */ filterKey: string; /** Whether the filter is selected or not */ @@ -33,6 +36,7 @@ export interface FilterPillProps extends FilterInterface { } export function FilterPill({ + unsavedChanges = false, filterKey, label, filter, @@ -111,14 +115,46 @@ export function FilterPill({ styles.ToggleButton, ); - const wrappedLabel = ( -
- - {label} - + const disclosureMarkup = !selected ? ( +
+
+ ) : null; + + const labelMarkup = ( + + + + {label} + + + ); + const unsavedPip = unsavedChanges ? ( + + + + ) : null; + + const removeFilterButtonMarkup = selected ? ( + +
+ +
+
+ ) : null; + const activator = (
@@ -128,33 +164,20 @@ export function FilterPill({ onClick={togglePopoverActive} className={toggleButtonClassNames} type="button" + accessibilityLabel={ + unsavedChanges + ? i18n.translate('Polaris.FilterPill.unsavedChanges', {label}) + : label + } > - {selected ? ( - <>{wrappedLabel} - ) : ( - <> - {wrappedLabel} -
- -
- - )} + {unsavedPip} + {labelMarkup} + {disclosureMarkup}
- {selected ? ( - -
- -
-
- ) : null} + {removeFilterButtonMarkup}
); diff --git a/polaris-react/src/components/Filters/components/FilterPill/tests/FilterPill.test.tsx b/polaris-react/src/components/Filters/components/FilterPill/tests/FilterPill.test.tsx index e5884a15251..49a891a1c15 100644 --- a/polaris-react/src/components/Filters/components/FilterPill/tests/FilterPill.test.tsx +++ b/polaris-react/src/components/Filters/components/FilterPill/tests/FilterPill.test.tsx @@ -7,6 +7,7 @@ import type {FilterPillProps} from '../FilterPill'; import {Popover} from '../../../../Popover'; import {Icon} from '../../../../Icon'; import {Button} from '../../../../Button'; +import {Box} from '../../../../Box'; import {UnstyledButton} from '../../../../UnstyledButton'; jest.mock('../../../../../utilities/breakpoints', () => ({ @@ -103,6 +104,24 @@ describe('', () => { wrapper.find(UnstyledButton, {'aria-label': 'Clear'})!.trigger('onClick'); expect(spy).toHaveBeenCalledWith(defaultProps.filterKey); }); + + describe('unsaved changes', () => { + it('indicates unsaved changes with an emphasized pip when selected and unsavedChanges is true', () => { + const wrapper = mountWithApp( + , + ); + expect(wrapper).toContainReactComponent(Box, { + background: 'bg-fill-emphasis', + }); + }); + + it('does not render an emphasized pip when selected and has unsaved changes by default', () => { + const wrapper = mountWithApp(); + expect(wrapper).not.toContainReactComponent(Box, { + background: 'bg-fill-emphasis', + }); + }); + }); }); describe('popover', () => { diff --git a/polaris-react/src/components/Filters/components/FiltersBar/FiltersBar.tsx b/polaris-react/src/components/Filters/components/FiltersBar/FiltersBar.tsx index bd3c1a8f048..3a5ca335e02 100644 --- a/polaris-react/src/components/Filters/components/FiltersBar/FiltersBar.tsx +++ b/polaris-react/src/components/Filters/components/FiltersBar/FiltersBar.tsx @@ -63,6 +63,7 @@ export function FiltersBar({ const i18n = useI18n(); const [popoverActive, setPopoverActive] = useState(false); const hasMounted = useRef(false); + useEffect(() => { hasMounted.current = true; }); @@ -74,6 +75,7 @@ export function FiltersBar({ onAddFilterClick?.(); togglePopoverActive(); }; + const appliedFilterKeys = appliedFilters?.map(({key}) => key); const pinnedFromPropsKeys = filters @@ -210,6 +212,7 @@ export function FiltersBar({ initialActive={ hasMounted.current && !pinnedFilter.pinned && !appliedFilter } + unsavedChanges={appliedFilter?.unsavedChanges} label={appliedFilter?.label || pinnedFilter.label} filterKey={filterKey} selected={appliedFilterKeys?.includes(filterKey)} 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 index aafa17e13e8..75698a9c47f 100644 --- a/polaris-react/src/components/Filters/components/FiltersBar/tests/FiltersBar.test.tsx +++ b/polaris-react/src/components/Filters/components/FiltersBar/tests/FiltersBar.test.tsx @@ -166,9 +166,8 @@ describe('', () => { HTMLElement.prototype.scroll = scrollSpy; const appliedFilters = [ { - ...defaultProps.filters[2], + key: defaultProps.filters[2].key, label: 'Bux', - value: ['Bux'], onRemove: jest.fn(), }, ]; @@ -186,9 +185,8 @@ describe('', () => { it('will not open the popover for an applied filter by default', () => { const appliedFilters = [ { - ...defaultProps.filters[2], + key: defaultProps.filters[2].key, label: 'Bux', - value: ['Bux'], onRemove: jest.fn(), }, ]; @@ -223,9 +221,8 @@ describe('', () => { it('will not remove a pinned filter when it is removed from the applied filters array', () => { const appliedFilters = [ { - ...defaultProps.filters[2], + key: defaultProps.filters[2].key, label: 'Value is Baz', - value: ['Baz'], onRemove: jest.fn(), }, ]; @@ -250,9 +247,8 @@ describe('', () => { HTMLElement.prototype.scroll = scrollSpy; const appliedFilters = [ { - ...defaultProps.filters[2], + key: defaultProps.filters[2].key, label: 'Bux', - value: ['Bux'], onRemove: jest.fn(), }, ]; @@ -390,9 +386,8 @@ describe('', () => { it('will keep a pinned filter from props pinned when clearing', () => { const appliedFilters = [ { - ...defaultProps.filters[1], + key: defaultProps.filters[1].key, label: 'Bar 2', - value: ['Bar 2'], onRemove: jest.fn(), }, ]; @@ -414,15 +409,13 @@ describe('', () => { it('will keep a pinned filter from props pinned when clearing all', () => { const appliedFilters = [ { - ...defaultProps.filters[0], + key: defaultProps.filters[0].key, label: 'Bar 2', - value: ['Bar 2'], onRemove: jest.fn(), }, { - ...defaultProps.filters[2], + key: defaultProps.filters[2].key, label: 'Bar 2', - value: ['Bar 2'], onRemove: jest.fn(), }, ]; diff --git a/polaris-react/src/components/Filters/components/SearchField/SearchField.tsx b/polaris-react/src/components/Filters/components/SearchField/SearchField.tsx index f54e2fd99dc..3608811eaea 100644 --- a/polaris-react/src/components/Filters/components/SearchField/SearchField.tsx +++ b/polaris-react/src/components/Filters/components/SearchField/SearchField.tsx @@ -49,8 +49,8 @@ export function SearchField({ ) : null; - function handleChange(value: string) { - onChange(value); + function handleChange(eventValue: string) { + onChange(eventValue ?? value); } function handleClear() { @@ -65,7 +65,7 @@ export function SearchField({ handleChange(eventValue ?? value)} + onChange={handleChange} onFocus={onFocus} onBlur={onBlur} onClearButtonClick={handleClear} diff --git a/polaris-react/src/components/IndexFilters/IndexFilters.tsx b/polaris-react/src/components/IndexFilters/IndexFilters.tsx index 885e26b7394..a4ee15ff478 100644 --- a/polaris-react/src/components/IndexFilters/IndexFilters.tsx +++ b/polaris-react/src/components/IndexFilters/IndexFilters.tsx @@ -152,6 +152,7 @@ export function IndexFilters({ const {mdDown} = useBreakpoints(); const defaultRef = useRef(null); const filteringRef = useRef(null); + const { value: filtersFocused, setFalse: setFiltersUnFocused, @@ -290,13 +291,13 @@ export function IndexFilters({ disabled, ]); - function handleClickEditColumnsButon() { + function handleClickEditColumnsButton() { beginEdit(IndexFiltersMode.EditingColumns); } const editColumnsMarkup = showEditColumnsButton ? ( ) : null; diff --git a/polaris-react/src/types.ts b/polaris-react/src/types.ts index 657dbc7c189..10d7826e455 100644 --- a/polaris-react/src/types.ts +++ b/polaris-react/src/types.ts @@ -369,10 +369,12 @@ export type NonEmptyArray = [T, ...T[]]; export type ArrayElement = T extends (infer U)[] ? U : never; export interface AppliedFilterInterface { - /** A unique key used to identify the applied filter */ + /** A unique key used to identify the filter */ key: string; - /** A label for the applied filter */ + /** The name of the filter */ label: string; + /** Whether the filter is newly applied or updated and hasn't been saved */ + unsavedChanges?: boolean; /** Callback when the remove button is pressed */ onRemove(key: string): void; } @@ -380,7 +382,7 @@ export interface AppliedFilterInterface { export interface FilterInterface { /** A unique key used to identify the filter */ key: string; - /** The label for the filter */ + /** The name of the filter */ label: string; /** The markup for the given filter */ filter: React.ReactNode; diff --git a/polaris.shopify.com/content/components/selection-and-input/filters.mdx b/polaris.shopify.com/content/components/selection-and-input/filters.mdx index c6d10ee1755..b99b1541fa2 100644 --- a/polaris.shopify.com/content/components/selection-and-input/filters.mdx +++ b/polaris.shopify.com/content/components/selection-and-input/filters.mdx @@ -17,6 +17,8 @@ examples: title: With a data table - fileName: filters-with-children-content.tsx title: With children content + - fileName: filters-with-children-content-and-unsaved-changes.tsx + title: With children content and unsaved changes - fileName: filters-disabled.tsx title: Disabled - fileName: filters-with-some-disabled.tsx diff --git a/polaris.shopify.com/pages/examples/filters-with-children-content-and-unsaved-changes.tsx b/polaris.shopify.com/pages/examples/filters-with-children-content-and-unsaved-changes.tsx new file mode 100644 index 00000000000..323fe8e77f4 --- /dev/null +++ b/polaris.shopify.com/pages/examples/filters-with-children-content-and-unsaved-changes.tsx @@ -0,0 +1,343 @@ +import { + TextField, + Card, + ResourceList, + Filters, + Button, + Avatar, + Text, + Box, + ChoiceList, + RangeSlider, +} from '@shopify/polaris'; +import type {FiltersProps} from '@shopify/polaris'; +import {useState, useCallback} from 'react'; +import {withPolarisExample} from '../../src/components/PolarisExampleWrapper'; + +export function FiltersWithChildrenContentAndUnsavedChangesExample() { + const emptyFilterState: { + query: { + label: string; + value: ''; + }; + accountStatus: { + label: string; + value: string[]; + }; + moneySpent: { + label: string; + value: [number, number]; + }; + taggedWith: { + label: string; + value: ''; + }; + } = { + query: { + label: 'Search', + value: '', + }, + accountStatus: { + label: 'Account status', + value: [], + }, + moneySpent: { + label: 'Money spent', + value: [0, 0], + }, + taggedWith: { + label: 'Tagged with', + value: '', + }, + }; + + const [queryValue, setQueryValue] = useState(''); + const [taggedWith, setTaggedWith] = useState(''); + const [moneySpent, setMoneySpent] = useState<[number, number]>([0, 0]); + const [accountStatus, setAccountStatus] = useState(['enabled']); + const [savedFilterState, setSavedFilterState] = useState< + Map< + string, + { + label: string; + value: string | string[] | number | [number, number]; + } + > + >(new Map(Object.entries(emptyFilterState))); + + const handleFilterChange = + (key: string) => (value: string | string[] | number | [number, number]) => { + if (key === 'taggedWith') setTaggedWith(value as string); + if (key === 'moneySpent') setMoneySpent(value as [number, number]); + if (key === 'accountStatus') setAccountStatus(value as string[]); + }; + + const handleFilterRemove = (key: string) => { + if (key === 'taggedWith') { + setTaggedWith(emptyFilterState.taggedWith.value); + } else if (key === 'moneySpent') { + setMoneySpent(emptyFilterState.moneySpent.value); + } else if (key === 'accountStatus') { + setAccountStatus(emptyFilterState.accountStatus.value); + } + }; + + const handleFiltersQueryChange = (value: string) => setQueryValue(value); + + const handleQueryValueRemove = useCallback(() => setQueryValue(''), []); + + const handleFiltersClearAll = () => { + Object.entries(emptyFilterState).forEach(([key]) => + handleFilterRemove(key), + ); + + handleQueryValueRemove(); + }; + + const filters = [ + { + key: 'accountStatus', + label: 'Account status', + value: accountStatus, + filter: ( + + ), + shortcut: true, + pinned: true, + }, + { + key: 'taggedWith', + label: 'Tagged with', + value: taggedWith, + filter: ( + + ), + shortcut: true, + pinned: true, + }, + { + key: 'moneySpent', + label: 'Money spent', + value: moneySpent, + filter: ( + + ), + }, + ]; + + const appliedFilters: FiltersProps['appliedFilters'] = []; + + filters.forEach(({key, label, value}) => { + if (!isEmpty(value)) { + appliedFilters.push({ + key, + label: `${label}: ${humanReadableValue(key, value)}`, + unsavedChanges: !isUnchanged(key, value), + onRemove: () => handleFilterRemove(key), + }); + } + }); + + const handleSaveFilters = () => { + const nextSavedFilterState = new Map(savedFilterState); + appliedFilters.forEach(({key, unsavedChanges}) => { + const savedFilter = nextSavedFilterState.get(key); + const value = filters.find((filter) => filter.key === key)?.value; + console.log(`Saving filter: ${key}, ${value}`, savedFilter); + + if (value && unsavedChanges && savedFilter) { + savedFilter.value = value; + } + }); + + setSavedFilterState(nextSavedFilterState); + }; + + const disableAction = appliedFilters.every( + ({unsavedChanges}) => !unsavedChanges, + ); + + return ( +
+ + + + + + + } + flushFilters + items={[ + { + id: '341', + url: '#', + name: 'Mae Jemison', + location: 'Decatur, USA', + }, + { + id: '256', + url: '#', + name: 'Ellen Ochoa', + location: 'Los Angeles, USA', + }, + ]} + renderItem={(item) => { + const {id, url, name, location} = item; + const media = ( + nm.substring(0, 1)) + .join('')} + size="md" + name={name} + /> + ); + + return ( + + + {name} + +
{location}
+
+ ); + }} + /> +
+
+ ); + + function humanReadableValue( + key: string, + value: string | string[] | number | [number, number], + ): string { + if (isEmpty(value)) return ''; + + switch (key) { + case 'moneySpent': { + const [min, max] = value as [number, number]; + if (min === 0 && max === 0) return ''; + if (min === 0) return `up to $${max}`; + if (max === 0) return `more than $${min}`; + return `between $${min} and $${max}`; + } + + case 'taggedWith': { + const tags = (value as string).trim().split(','); + if (tags.length === 1) return ` ${tags[0]}`; + else if (tags.length === 2) return `${tags[0]} and ${tags[1]}`; + return tags + .map((tag, index) => { + return index !== tags.length - 1 ? tag : `and ${tag}`; + }) + .join(', '); + } + case 'accountStatus': { + const statuses = value as string[]; + if (statuses.length === 1) { + return statuses[0]; + } else if (statuses.length === 2) { + return `${statuses[0]} or ${statuses[1]}`; + } else { + return statuses + .map((status, index) => { + return index !== statuses.length - 1 ? status : `or ${status}`; + }) + .join(', '); + } + } + default: + return ''; + } + } + + function isEmpty(value: string | string[] | number | [number, number]) { + if (Array.isArray(value)) { + return value.length === 0 || value[1] === 0; + } else { + return value === '' || value === 0 || value == null; + } + } + + function isUnchanged( + key: string, + value: string | string[] | number | [number, number], + ) { + if (key === 'taggedWith') { + return value === savedFilterState.get(key)?.value; + } else if (key === 'moneySpent') { + const [min, max] = value as [number, number]; + const savedMoneySpent = savedFilterState.get(key)?.value as [ + number, + number, + ]; + + return min === savedMoneySpent?.[0] && max === savedMoneySpent?.[1]; + } else if (key === 'accountStatus') { + const savedAccountStatus = + (savedFilterState.get(key)?.value as string[]) || []; + return ( + Array.isArray(value) && + (value as string[]).every((val) => + savedAccountStatus?.includes(val as string), + ) + ); + } + } +} + +export default withPolarisExample( + FiltersWithChildrenContentAndUnsavedChangesExample, +);