From e34dd270c003baaf957499420ec6b70619498d95 Mon Sep 17 00:00:00 2001 From: Gustav Larsson Date: Wed, 11 Jan 2023 14:48:49 +0100 Subject: [PATCH] Location filter modal (#27) * Add Location filter modal, update TimeFilter to also use the same FilterButton * Rename "United Kingdom of Great Britain and Northern Island" to "United Kingdom" * Remove count from Global/Online filter options --- components/Dropdown.tsx | 76 -------- components/FilterArea.tsx | 42 ++--- components/FilterButton.tsx | 39 ++++ components/LocationFilter.tsx | 257 +++++++++++++++++++++++++++ components/TimeFilter.tsx | 104 +++++++++++ utils/location/data/countries.json | 2 +- utils/location/get-filter-options.ts | 52 ++---- 7 files changed, 429 insertions(+), 143 deletions(-) delete mode 100644 components/Dropdown.tsx create mode 100644 components/FilterButton.tsx create mode 100644 components/LocationFilter.tsx create mode 100644 components/TimeFilter.tsx diff --git a/components/Dropdown.tsx b/components/Dropdown.tsx deleted file mode 100644 index 827bcd1..0000000 --- a/components/Dropdown.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react'; -import { ChevronDown } from '@styled-icons/fa-solid/ChevronDown'; - -const findOptionFromValue = (options, value) => { - return options.find(option => { - if (!value) { - return option.value === ''; - } - - return option.value === value; - }); -}; - -export default function DropdownSelector({ - options, - fieldLabel, - value, - onChange, - currentCategoryColor, - ariaLabel, -}: { - options: any; - fieldLabel: any; - value: any; - currentCategoryColor: string; - ariaLabel: string; - onChange: (any) => void; - onOpen?: () => void; -}) { - const selectedOption = findOptionFromValue(options, value); - const breakIndex = options.findIndex(option => option.break); - const lastVisibleIndex = breakIndex > 0 ? breakIndex : options.length; - const filterApplied = selectedOption.value !== options[0].value; - - return ( -
- - -
- ); -} diff --git a/components/FilterArea.tsx b/components/FilterArea.tsx index cd9e87e..a9db768 100644 --- a/components/FilterArea.tsx +++ b/components/FilterArea.tsx @@ -2,8 +2,9 @@ import React, { Fragment } from 'react'; import AnimateHeight from 'react-animate-height'; import CategoryFilter from './CategorySelect'; -import Dropdown from './Dropdown'; -import { CloseIcon, DateIcon, FilterIcon, LocationPin } from './Icons'; +import { CloseIcon, FilterIcon } from './Icons'; +import { LocationFilter } from './LocationFilter'; +import { TimeFilter } from './TimeFilter'; export const Filters = ({ filter, @@ -16,7 +17,6 @@ export const Filters = ({ collectivesInView, }) => { const [expanded, setExpanded] = React.useState(!mobile); - return (
{mobile && ( @@ -49,43 +49,23 @@ export const Filters = ({
- - - Location -
- } - options={locationOptions.map(option => ({ - ...option, - value: JSON.stringify({ type: option.type, value: option.value }), - }))} - value={JSON.stringify(filter.location)} - onOpen={() => { - mobile && setExpanded(false); - }} - onChange={option => { - setFilter({ location: JSON.parse(option.value) }); + locationOptions={locationOptions} + setLocationFilter={locationFilter => { + setFilter({ location: locationFilter }); }} /> - - - Date range -
- } options={[ { value: 'ALL', label: 'All time' }, { value: 'PAST_YEAR', label: 'Past 12 months' }, { value: 'PAST_QUARTER', label: 'Past 3 months' }, ]} - value={filter.timePeriod} - onChange={({ value }) => { + setTimeFilter={value => { setFilter({ timePeriod: value }); }} /> diff --git a/components/FilterButton.tsx b/components/FilterButton.tsx new file mode 100644 index 0000000..60ee66d --- /dev/null +++ b/components/FilterButton.tsx @@ -0,0 +1,39 @@ +import React, { forwardRef } from 'react'; + +import { ChevronUpDown } from './Icons'; + +interface Props { + currentCategoryColor: string; + selectedOption?: any; + icon: any; + label: string; + onClick?: any; +} + +const FilterButton: React.ForwardRefRenderFunction = ( + { currentCategoryColor, selectedOption, icon, label, onClick }, + ref, +) => { + return ( + + ); +}; + +export default forwardRef(FilterButton); diff --git a/components/LocationFilter.tsx b/components/LocationFilter.tsx new file mode 100644 index 0000000..ea7da06 --- /dev/null +++ b/components/LocationFilter.tsx @@ -0,0 +1,257 @@ +import React, { Fragment, useState } from 'react'; +import { Dialog, Transition } from '@headlessui/react'; +import { ChevronDown } from '@styled-icons/fa-solid/ChevronDown'; +import { ChevronUp } from '@styled-icons/fa-solid/ChevronUp'; + +import FilterButton from './FilterButton'; +import { CloseIcon, LocationPin } from './Icons'; + +export const LocationFilter = ({ locationOptions, currentCategoryColor, filter, setLocationFilter }) => { + const [open, setOpen] = useState(false); + const selectedOption = locationOptions.find( + option => option.value === filter.location.value && option.type === filter.location.type, + ); + return ( + + } + onClick={() => setOpen(true)} + currentCategoryColor={currentCategoryColor} + selectedOption={selectedOption} + /> + {open && ( + setOpen(false)} + options={locationOptions} + setLocationFilter={setLocationFilter} + filter={filter} + /> + )} + + ); +}; + +const LocationFilterModal = ({ open, handleClose, options, filter, setLocationFilter }) => { + const [activeFilter, setActiveFilter] = useState<{ region?: string; country?: string; city?: string }>({ + region: filter?.location?.type === 'region' ? filter?.location?.value : undefined, + country: filter?.location?.type === 'country' ? filter?.location?.value : undefined, + city: filter?.location?.type === 'city' ? filter?.location?.value : undefined, + }); + + const [query, setQuery] = useState(''); + const filteredOptions = { + regions: options + .filter(option => option.type === 'region') + .filter(option => + option.label.toLowerCase().replace(/\s+/g, '').includes(query.toLowerCase().replace(/\s+/g, '')), + ), + countries: options + .filter(option => option.type === 'country') + .filter(option => !activeFilter.region || activeFilter.region === option.region) + .filter(option => + option.label.toLowerCase().replace(/\s+/g, '').includes(query.toLowerCase().replace(/\s+/g, '')), + ), + cities: options + .filter(option => option.type === 'city') + .filter(option => !activeFilter.region || activeFilter.region === option.region) + .filter(option => !activeFilter.country || activeFilter.country === option.country) + .filter(option => + option.label.toLowerCase().replace(/\s+/g, '').includes(query.toLowerCase().replace(/\s+/g, '')), + ), + }; + + const applyFilter = () => { + const locationFilter = { + type: activeFilter.city ? 'city' : activeFilter.country ? 'country' : activeFilter.region ? 'region' : null, + value: activeFilter.city || activeFilter.country || activeFilter.region || null, + }; + + setLocationFilter(locationFilter); + handleClose(); + }; + + return ( + + + +
+ + +
+
+ + +
+
+ { + setQuery(event.target.value); + setActiveFilter({}); + }} + value={query} + // eslint-disable-next-line jsx-a11y/no-autofocus + autoFocus + /> + +
+
+
+ {!!filteredOptions.regions.length && ( + + )} + {!!filteredOptions.countries.length && ( + + )} + {!!filteredOptions.cities.length && ( + + )} +
+
+
+ +
+
+
+
+
+
+
+
+ ); +}; + +const LocationSection = ({ label, options, onSelect, activeFilter, grow, filter, field }) => { + const [expanded, setExpanded] = useState( + !filter.location.value || filter.location.type === field || filter.location.type === 'region', + ); + + return ( + + + {expanded && ( +
+
+ {options.map(option => { + return ( + + ); + })} +
+
+ )} +
+ ); +}; + +const LocationOption = ({ option, onSelect, activeFilter }) => { + const ref = React.useRef(null); + + // scroll active option into view when opening the modal + React.useEffect(() => { + if (active) { + setTimeout(() => { + ref.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 50); + } + }, []); + + const active = activeFilter && activeFilter[option.type] === option.value; + + let filtersToKeep = {}; + if (option.type === 'region') { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { city, country, region, ...rest } = activeFilter; + filtersToKeep = rest; + } else if (option.type === 'country') { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { city, country, ...rest } = activeFilter; + filtersToKeep = rest; + } else if (option.type === 'city') { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { city, ...rest } = activeFilter; + filtersToKeep = rest; + } + + return ( + + ); +}; diff --git a/components/TimeFilter.tsx b/components/TimeFilter.tsx new file mode 100644 index 0000000..059f32a --- /dev/null +++ b/components/TimeFilter.tsx @@ -0,0 +1,104 @@ +import React, { Fragment, useEffect, useRef, useState } from 'react'; +import { Menu, Transition } from '@headlessui/react'; + +import FilterButton from './FilterButton'; +import { DateIcon } from './Icons'; + +const findOptionFromValue = (options, value) => { + return options.find(option => { + return option.value === value; + }); +}; + +export function TimeFilter({ + filter, + options, + currentCategoryColor, + setTimeFilter, +}: { + options: any; + filter: any; + currentCategoryColor: string; + setTimeFilter: (any) => void; +}) { + const selectedOption = findOptionFromValue(options, filter.timePeriod); + const buttonRef = useRef(null); + return ( + + + {({ open }) => ( + +
+ + } + currentCategoryColor={currentCategoryColor} + selectedOption={selectedOption} + ref={buttonRef} + /> + +
+ +
+ )} +
+
+ ); +} + +const MenuContainer = ({ options, setTimeFilter, open, buttonRef }) => { + const [dropdownPlacement, setDropdownPlacement] = useState('bottom'); + + // put dropdown above button if it's too close to the bottom of the screen + useEffect(() => { + if (!buttonRef.current) { + return; + } + const { top } = buttonRef.current.getBoundingClientRect(); + const windowHeight = window.innerHeight; + const distance = windowHeight - top; + if (distance < 175) { + setDropdownPlacement('top'); + } else { + setDropdownPlacement('bottom'); + } + }, [open]); + + return ( + + +
+ {options.map(option => { + return ( + + {({ active }) => ( + + )} + + ); + })} +
+
+
+ ); +}; diff --git a/utils/location/data/countries.json b/utils/location/data/countries.json index c68aa00..291da1e 100644 --- a/utils/location/data/countries.json +++ b/utils/location/data/countries.json @@ -678,7 +678,7 @@ { "name": "Ukraine", "code": "UA", "code3chars": "UKR", "region": "Europe", "subRegion": "Eastern Europe" }, { "name": "United Arab Emirates", "code": "AE", "code3chars": "ARE", "region": "Asia", "subRegion": "Western Asia" }, { - "name": "United Kingdom of Great Britain and Northern Ireland", + "name": "United Kingdom", "code": "GB", "code3chars": "GBR", "region": "Europe", diff --git a/utils/location/get-filter-options.ts b/utils/location/get-filter-options.ts index ea4ddf0..552e8a9 100644 --- a/utils/location/get-filter-options.ts +++ b/utils/location/get-filter-options.ts @@ -5,6 +5,8 @@ type LocationOption = { value: string; label?: string; count: number; + country?: string; + region?: string; }; export default function getFilterOptions(collectives) { @@ -33,6 +35,7 @@ export default function getFilterOptions(collectives) { foundLocations.countries[c.location.countryCode] = { type: 'country', value: c.location.countryCode, + region: c.location.region, count: (foundLocations.countries[c.location.countryCode]?.count || 0) + 1, }; } @@ -49,50 +52,29 @@ export default function getFilterOptions(collectives) { value: c.location.city, label: c.location.city, count: (foundLocations.cities[c.location.city]?.count || 0) + 1, + country: c.location.countryCode, + region: c.location.region, }; } } }); - const regions = Object.values(foundLocations.regions).sort((a, b) => a.label.localeCompare(b.label)); + const regions = Object.values(foundLocations.regions).sort((a, b) => b.count - a.count); - const countries = Object.values(foundLocations.countries).map(c => { - const country = countriesData.find(c2 => c2.code === c.value); - return { ...c, label: country.name, region: country.region }; - }); + const countries = Object.values(foundLocations.countries) + .map(c => { + const country = countriesData.find(c2 => c2.code === c.value); + return { ...c, label: country.name, region: country.region }; + }) + .sort((a, b) => b.count - a.count); - const states = Object.values(foundLocations.states); const cities = Object.values(foundLocations.cities).sort((a, b) => b.count - a.count); - const topCities = cities.slice(0, 5); - const restCities = cities.slice(5); - - // add top cities with hr below - const regionsAndCountriesNested = []; - // for each region - regions.forEach(region => { - // add the region to the options - regionsAndCountriesNested.push(region); - // add the countries in that region to the options - countries - .filter(country => { - return country.region === region.value; - }) - .sort((a, b) => a.label.localeCompare(b.label)) - .forEach(country => { - regionsAndCountriesNested.push(country); - }); - }); - return [ - { type: null, value: null, label: 'All locations', count: collectives.length }, - ...topCities, - { hr: true }, - ...regionsAndCountriesNested, - { break: true }, - ...restCities, - ...states, - { type: 'other', value: 'online', label: 'Online', count: collectives.filter(c => c.location?.isOnline).length }, - { type: 'other', value: 'global', label: 'Global', count: collectives.filter(c => c.location?.isGlobal).length }, + ...regions, + ...countries, + ...cities, + { type: 'other', value: 'online', label: 'Online' }, + { type: 'other', value: 'global', label: 'Global' }, ]; }