diff --git a/.vscode/settings.json b/.vscode/settings.json index 07b55e81..0d3e7a73 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,8 @@ { "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, - "editor.acceptSuggestionOnEnter": "on" + "editor.acceptSuggestionOnEnter": "on", + "[javascript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + } } diff --git a/cypress/e2e/desktop/filters.cy.js b/cypress/e2e/desktop/filters.cy.js index c7469faf..03f1084b 100644 --- a/cypress/e2e/desktop/filters.cy.js +++ b/cypress/e2e/desktop/filters.cy.js @@ -3,47 +3,37 @@ // For each resource type, test each filter permutation and confirm only the expected number of taps appear. describe("filters", () => { - beforeEach(() => { - cy.visit("/"); - // Load the form - - // NOTE: This line currently uses components that are due to be updated. - // Close the tutorial modal - cy.get('[aria-label=Close]').click() - // Load the filter menu - cy.get('[data-cy=button-filter-menu]').click() - }); - - it("should successfully show a result for each water site filter permutation", () => { - // TODO Add an approach to test all possible filter permutations - // Currently limiting this test to a single filtering permutation as a starting point - - cy.get('[data-cy="filter-option-Drinking fountain"]').click() - cy.get('[data-cy="filter-option-ADA accessible"]').click() - cy.get('[data-cy="filter-option-Open Access"]').click() - - cy.get('[data-cy=filter-apply-button]').click() - - // Close the filter menu - cy.get('[data-cy=button-filter-menu]').click() - - // Check that a location that matches filter is present - cy.get('[title=data-cy-2]').should('exist'); - - // Check that a location that does not match filter is not present - // NOTE: This is commented as this test will not current pass as filtering is not working properly. - // cy.get('[title=data-cy-1]').should('not.exist'); - }); - - it("should successfully show a result for each food site filter permutation", () => { - // TODO - }); - - it("should successfully show a result for each foraging site filter permutation", () => { - // TODO - }); - - it("should successfully show a result for each bathroom site filter permutation", () => { - // TODO - }); + beforeEach(() => { + cy.visit("/"); + // Load the form + + // NOTE: This line currently uses components that are due to be updated. + // Close the tutorial modal + cy.get('[aria-label=Close]').click() + // Load the filter menu + cy.get('[data-cy=button-filter-menu]').click() + }); + + it("should successfully show a result for each water site filter permutation", () => { + // TODO Add an approach to test all possible filter permutations + // Currently limiting this test to a single filtering permutation as a starting point + + cy.get('[data-cy="filter-option-Drinking fountain"]').click() + cy.get('[data-cy="filter-option-ADA accessible"]').click() + cy.get('[data-cy="filter-option-Open Access"]').click() + // Close the filter menu + cy.get('[data-cy=button-filter-menu]').click() + }); + + it("should successfully show a result for each food site filter permutation", () => { + // TODO + }); + + it("should successfully show a result for each foraging site filter permutation", () => { + // TODO + }); + + it("should successfully show a result for each bathroom site filter permutation", () => { + // TODO + }); }); diff --git a/src/actions/actions.js b/src/actions/actions.js index 653caff2..e2d1dfc7 100644 --- a/src/actions/actions.js +++ b/src/actions/actions.js @@ -18,15 +18,16 @@ export const setToggleStateFood = (toggle, toggleState) => ({ toggleState }); -export const SET_FILTER_FUNCTION = 'SET_FILTER_FUNCTION'; -export const setFilterFunction = () => ({ - type: SET_FILTER_FUNCTION -}); +export const setFilterFunction = createAction('SET_FILTER_FUNCTION') + +export const setEntryFilterFunction = createAction('SET_ENTRY_FILTER_FUNCTION') + +export const removeFilterFunction = createAction('REMOVE_FILTER_FUNCTION') + +export const removeEntryFilterFunction = createAction('REMOVE_ENTRY_FILTER_FUNCTION') + +export const resetFilterFunction = createAction('RESET_FILTER_FUNCTION') -export const RESET_FILTER_FUNCTION = 'RESET_FILTER_FUNCTION'; -export const resetFilterFunction = () => ({ - type: RESET_FILTER_FUNCTION -}); export const getResources = createAsyncThunk( 'fetch-resources', diff --git a/src/components/ChooseResource/ChooseResource.js b/src/components/ChooseResource/ChooseResource.js index 600b42d6..7d992bca 100644 --- a/src/components/ChooseResource/ChooseResource.js +++ b/src/components/ChooseResource/ChooseResource.js @@ -21,6 +21,7 @@ import { ReactComponent as ForagingIcon } from '../icons/ForagingIconChooseResou import { ReactComponent as WaterIcon } from '../icons/WaterIconChooseResource.svg'; import useOnClickOutside from '../../components/AddResourceModal/useOnClickOutside.js'; import useIsMobile from 'hooks/useIsMobile'; +import { resetFilterFunction } from '../../actions/actions'; const ResourceButton = props => { const Icon = props.icon; @@ -95,6 +96,7 @@ export default function ChooseResource(props) { color="#5286E9" text="Water" onClick={() => { + dispatch(resetFilterFunction()) dispatch({ type: CHANGE_RESOURCE_TYPE, resourceType: WATER_RESOURCE_TYPE @@ -107,6 +109,7 @@ export default function ChooseResource(props) { color="#5DA694" text="Foraging" onClick={() => { + dispatch(resetFilterFunction()) dispatch({ type: CHANGE_RESOURCE_TYPE, resourceType: FORAGE_RESOURCE_TYPE @@ -119,6 +122,7 @@ export default function ChooseResource(props) { color="#FF9A55" text="Food" onClick={() => { + dispatch(resetFilterFunction()) dispatch({ type: CHANGE_RESOURCE_TYPE, resourceType: FOOD_RESOURCE_TYPE @@ -131,6 +135,7 @@ export default function ChooseResource(props) { color="#9E9E9E" text="Bathroom" onClick={() => { + dispatch(resetFilterFunction()) dispatch({ type: CHANGE_RESOURCE_TYPE, resourceType: BATHROOM_RESOURCE_TYPE diff --git a/src/components/Filter/Filter.js b/src/components/Filter/Filter.js index 758ed994..9f15212b 100644 --- a/src/components/Filter/Filter.js +++ b/src/components/Filter/Filter.js @@ -11,6 +11,7 @@ import styles from './Filter.module.scss'; import CloseIcon from '@mui/icons-material/Close'; import IconButton from '@mui/material/IconButton'; import useIsMobile from 'hooks/useIsMobile'; +import selectFilteredResource from 'selectors/resourceSelectors'; const FilterTags = ({ tags, activeTags, resourceType, index, handleTag }) => ( @@ -24,7 +25,7 @@ const FilterTags = ({ tags, activeTags, resourceType, index, handleTag }) => ( : '') } onClick={() => { - handleTag(0, resourceType, index, key); + handleTag(0, resourceType, tag, index, key); }} data-cy={`filter-option-${tag}`} > @@ -52,7 +53,7 @@ const FilterTagsExclusive = ({ : '') } onClick={() => { - handleTag(1, resourceType, index, key); + handleTag(1, resourceType, tag, index, key); }} data-cy={`filter-option-${tag}`} > @@ -73,6 +74,7 @@ export default function Filter({ const isMobile = useIsMobile(); const dispatch = useDispatch(); const toolbarModal = useSelector(state => state.filterMarkers.toolbarModal); + const filteredResources = useSelector(state => selectFilteredResource(state)); return ( <> {!isMobile && ( @@ -183,11 +185,14 @@ export default function Filter({ Clear All

- - + Resources: {filteredResources.length} +

diff --git a/src/components/ReactGoogleMaps/ReactGoogleMaps.js b/src/components/ReactGoogleMaps/ReactGoogleMaps.js index 7a68f684..e594b584 100644 --- a/src/components/ReactGoogleMaps/ReactGoogleMaps.js +++ b/src/components/ReactGoogleMaps/ReactGoogleMaps.js @@ -9,7 +9,12 @@ import { setUserLocation, toggleInfoWindow, getResources, - setSelectedPlace + setSelectedPlace, + setFilterFunction, + resetFilterFunction, + removeFilterFunction, + removeEntryFilterFunction, + setEntryFilterFunction, } from '../../actions/actions'; import SearchBar from '../SearchBar/SearchBar'; import SelectedTap from '../SelectedTap/SelectedTap'; @@ -21,7 +26,7 @@ import TutorialModal from '../TutorialModal/TutorialModal'; import Filter from '../Filter/Filter'; import Toolbar from '../Toolbar/Toolbar'; import phlaskMarkerIconV2 from '../icons/PhlaskMarkerIconV2'; -import selectFilteredResource from '../../selectors/waterSelectors'; +import selectFilteredResource from '../../selectors/resourceSelectors'; import useIsMobile from 'hooks/useIsMobile'; import { CITY_HALL_COORDINATES } from 'constants/defaults'; @@ -143,7 +148,7 @@ for (const [key, value] of Object.entries(filters)) { if (category.type == 0) { data.push(new Array(category.tags.length).fill(false)); } else { - data.push(null); + data.push(category.tags.length); } }); noActiveFilterTags[key] = data; @@ -154,7 +159,6 @@ export const ReactGoogleMaps = ({ google }) => { const isMobile = useIsMobile(); const allResources = useSelector(state => state.filterMarkers.allResources); const filteredResources = useSelector(state => selectFilteredResource(state)); - const mapCenter = useSelector(state => state.filterMarkers.mapCenter); const resourceType = useSelector(state => state.filterMarkers.resourceType); const showingInfoWindow = useSelector( @@ -250,18 +254,29 @@ export const ReactGoogleMaps = ({ google }) => { } }; - const handleTag = (type, filterType, index, key) => { + const handleTag = (type, filterType, filterTag, index, key) => { + //handles multi select filters if (type == 0) { let activeFilterTags_ = { ...activeFilterTags }; + if (activeFilterTags_[filterType][index][key]) { + dispatch(removeFilterFunction({ tag: filterTag })) + } + else { + dispatch(setFilterFunction({ tag: filterTag })) + } activeFilterTags_[filterType][index][key] = !activeFilterTags_[filterType][index][key]; setActiveFilterTags(activeFilterTags_); - } else if (type == 1) { + } + //handles single select entry/organization filters + else if (type == 1) { let activeFilterTags_ = { ...activeFilterTags }; if (activeFilterTags_[filterType][index] == key) { activeFilterTags_[filterType][index] = null; + dispatch(removeEntryFilterFunction()) } else { - activeFilterTags_[filterType][index] = { ...key }; + activeFilterTags_[filterType][index] = key; + dispatch(setEntryFilterFunction({ tag: filterTag })) } setActiveFilterTags(activeFilterTags_); } @@ -269,6 +284,7 @@ export const ReactGoogleMaps = ({ google }) => { const clearAllTags = () => { setActiveFilterTags(JSON.parse(JSON.stringify(noActiveFilterTags))); + dispatch(resetFilterFunction()) }; const applyTags = () => { diff --git a/src/reducers/filterMarkers.js b/src/reducers/filterMarkers.js index d0f9d531..e5ba791a 100644 --- a/src/reducers/filterMarkers.js +++ b/src/reducers/filterMarkers.js @@ -15,19 +15,8 @@ const initialState = { showingInfoWindow: false, infoIsExpanded: false, infoWindowClass: 'info-window-out-desktop', - tapFilters: { - filtered: false, - handicap: false, - sparkling: false, - openNow: false, - accessTypesHidden: [] - }, - foodFilters: { - idRequired: false, - kidOnly: false, - openNow: false, - accessTypesHidden: [] - }, + filterTags: [], + filterEntry: "", /** @type {ResourceEntry[]} */ allResources: [], selectedPlace: {}, @@ -97,6 +86,30 @@ export default (state = initialState, act) => { allResources: [...state.allResources, act.newResource] }; + case actions.setFilterFunction.type: + return { ...state, filterTags: [...state.filterTags, act.payload.tag] } + + case actions.setEntryFilterFunction.type: + return { ...state, filterEntry: act.payload.tag } + + case actions.removeFilterFunction.type: + return { + ...state, + filterTags: state.filterTags.filter(x => x !== act.payload.tag) + } + + case actions.removeEntryFilterFunction.type: + return { + ...state, + filterEntry: '' + } + + case actions.resetFilterFunction.type: + return { + ...state, + filterTags: [], + filterEntry: '' + }; case actions.updateExistingResource.type: return { ...state, @@ -107,19 +120,16 @@ export default (state = initialState, act) => { ) }; - case actions.SET_FILTER_FUNCTION: - return { filterFunction: !state.filterFunction, ...state }; - case actions.SET_SELECTED_PLACE: // if passed Selected Place as an object, set selected place as the object // if passed an ID, locate the item using ID, then set selected place return typeof act.selectedPlace === 'object' ? { ...state, selectedPlace: act.selectedPlace } : { - ...state, - selectedPlace: state.allResources[act.selectedPlace], - showingInfoWindow: true - }; + ...state, + selectedPlace: state.allResources[act.selectedPlace], + showingInfoWindow: true + }; case actions.toggleInfoWindow.type: return { @@ -136,28 +146,6 @@ export default (state = initialState, act) => { case actions.TOGGLE_INFO_EXPANDED: return { ...state, infoIsExpanded: act.isExpanded }; - case actions.RESET_FILTER_FUNCTION: - return { - ...state, - tapFilters: { - accessTypesHidden: [], - filtered: false, - handicap: false, - sparkling: false, - openNow: false - }, - foodFilters: { - foodSite: false, - school: false, - charter: false, - pha: false, - idRequired: false, - kidOnly: false, - openNow: false, - accessTypesHidden: [] - } - }; - case actions.SET_FILTERED_TAP_TYPES: { let currentAccessTypesHidden = [...state.tapFilters.accessTypesHidden]; if (currentAccessTypesHidden.includes(act.tapType)) { diff --git a/src/selectors/resourceSelectors.js b/src/selectors/resourceSelectors.js new file mode 100644 index 00000000..9bc629cd --- /dev/null +++ b/src/selectors/resourceSelectors.js @@ -0,0 +1,102 @@ +import { createSelector } from 'reselect'; + +const getAllResources = state => state.filterMarkers.allResources; +const getResourceType = state => state.filterMarkers.resourceType; +const filterTags = state => state.filterMarkers.filterTags; +const filterEntry = state => state.filterMarkers.filterEntry; + +//this controls the mapping between values shown in the frontend and values used in the database, keys correspond to frontend and values to the database +const tagMapping = { + //ENTRY TYPES + 'Open Access': 'OPEN', + 'Restricted': 'RESTRICTED', + 'Unsure': 'UNSURE', + //WATER + //dispenser types + 'Drinking fountain': 'DRINKING_FOUNTAIN', + 'Bottle filler': 'BOTTLE_FILLER', + 'Sink': 'SINK', + 'Water cooler': 'WATER_COOLER', + 'Soda machine': 'SODA_MACHINE', + 'Vessel': 'VESSEL', + //features + 'ADA accessible': 'WHEELCHAIR_ACCESSIBLE', + 'Filtered water': 'FILTERED', + 'Vessel needed': 'BYOB', + 'ID required': 'ID_REQUIRED', + //FOOD + //food types + 'Perishable': 'PERISHABLE', + 'Non-perishable': 'NON_PERISHABLE', + 'Prepared foods and meals': 'PREPARED', + //distribution types + 'Eat on site': 'EAT_ON_SITE', + 'Delivery': 'DELIVERY', + 'Pick up': 'PICKUP', + //organization types + 'Government': 'GOVERNMENT', 'Business': 'BUSINESS', 'Non-profit': 'NON_PROFIT', + //FORAGING + //forage type + 'Nut': 'NUT', 'Fruit': 'FRUIT', 'Leaves': 'LEAVES', 'Bark': 'BARK', 'Flowers': 'FLOWERS', + //features + 'Medicinal': 'MEDICINAL', 'In season': 'IN_SEASON', 'Community garden': 'COMMUNITY_GARDEN', + //BATHROOM + //features + 'Gender neutral': 'GENDER_NEUTRAL', + 'Changing table': 'CHANGING_TABLE', + 'Single occupancy': 'SINGLE_OCCUPANCY', + 'Family bathroom': 'FAMILY', + 'Has water fountain': 'HAS_FOUNTAIN', +} + +const propertyMapping = { + 'WATER': ['dispenser_type', 'tags'], + 'FOOD': ['food_type', 'distribution_type'], + 'FORAGE': ['forage_type', 'tags'], + 'BATHROOM': ['tags'] +} + +/** + * This creates a selector for all resources filtered by the requested filters. + */ +const selectFilteredResource = createSelector( + [getAllResources, getResourceType, filterTags, filterEntry], + (allResources, resourceType, tags, entryType) => { + // First, filter based on resource type + let filteredResources = [] + allResources.filter(resource => { + let isValid = false + let isEntryValid = true + if (resource.resource_type === resourceType) isValid = true + //run through filters + if (isValid) { + //logic for entry types + if (entryType) { + if (resource.entry_type != tagMapping[entryType]) isEntryValid = false + } + const resourceTypeFormatted = resource.resource_type.toLowerCase() + //logic for other filters + tags.forEach(tag => { + const tagFormatted = tagMapping[tag] + //check to make sure resource has property with filter info + if (resourceTypeFormatted in resource) { + let tagFound = false + //loop through filter info types to find filter value + propertyMapping[resourceType].forEach(prop => { + if (prop in resource[resourceTypeFormatted]) { + if (resource[resourceTypeFormatted][prop].includes(tagFormatted)) tagFound = true + } + }) + if (!tagFound) isValid = false + } + else if (!(resourceTypeFormatted in resource)) isValid = false + }) + } + //return resource if it matches resource type and entry type filters + if (isValid && isEntryValid) filteredResources.push(resource) + }); + return filteredResources + } +); + +export default selectFilteredResource; diff --git a/src/selectors/waterSelectors.js b/src/selectors/waterSelectors.js deleted file mode 100644 index 92cd8ca7..00000000 --- a/src/selectors/waterSelectors.js +++ /dev/null @@ -1,102 +0,0 @@ -import { createSelector } from 'reselect'; - -import { hours } from '../helpers/hours'; - -const getTapFilters = state => state.filterMarkers.tapFilters; -const getAllResources = state => state.filterMarkers.allResources; -const getResourceType = state => state.filterMarkers.resourceType; - -/** - * This creates a selector for all resources filtered by the requested filters. - */ -const selectFilteredResource = createSelector( - [getAllResources, getResourceType], - (allResources, resourceType) => { - // First, filter based on resource - return allResources.filter(resource => { - return resource.resource_type === resourceType; - }); - - // // Default filters - // filteredTaps = Object.keys(filteredTaps) - // .filter(key => { - // return ( - // allTaps[key].permanently_closed !== undefined && - // allTaps[key].permanently_closed !== true - // ); - // }) - // .reduce((obj, key) => { - // obj[key] = allTaps[key]; - // return obj; - // }, []); - // - // // If we want to filter for filtered taps (water filter) - // if (tapFilters.filtered) { - // filteredTaps = Object.keys(filteredTaps) - // .filter(key => allTaps[key].filtration === 'Yes') - // .reduce((obj, key) => { - // obj[key] = allTaps[key]; - // return obj; - // }, []); - // } - // - // // If we want to filter for handicap-accessible taps - // if (tapFilters.handicap) { - // filteredTaps = Object.keys(filteredTaps) - // .filter(key => filteredTaps[key].handicap === 'Yes') - // .reduce((obj, key) => { - // obj[key] = filteredTaps[key]; - // return obj; - // }, []); - // } - // - // // If we want to filter for taps that offer sparkling water - // if (tapFilters.sparkling) { - // filteredTaps = Object.keys(filteredTaps) - // .filter(key => filteredTaps[key].sparkling === 'yes') - // .reduce((obj, key) => { - // obj[key] = filteredTaps[key]; - // return obj; - // }, []); - // } - // - // if (tapFilters.openNow) { - // filteredTaps = Object.keys(filteredTaps) - // .filter(key => { - // const today = new Date(); - // const currentDay = today.getDay(); - // - // let selectedPlace = filteredTaps[key]; - // - // return selectedPlace.hours !== undefined - // ? selectedPlace.hours.length >= currentDay + 1 - // ? selectedPlace.hours[currentDay].close !== undefined && - // selectedPlace.hours[currentDay].open !== undefined - // ? hours.checkOpen( - // selectedPlace.hours[currentDay].open.time, - // selectedPlace.hours[currentDay].close.time - // ) - // : false - // : false - // : false; - // }) - // .reduce((obj, key) => { - // obj[key] = filteredTaps[key]; - // return obj; - // }, []); - // } - // - // filteredTaps = Object.keys(filteredTaps) - // .filter( - // key => !tapFilters.accessTypesHidden.includes(filteredTaps[key].access) - // ) - // .reduce((obj, key) => { - // obj[key] = filteredTaps[key]; - // return obj; - // }, []); - // return filteredTaps; - // }); - } -); - -export default selectFilteredResource;