diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..92a26b83 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,14 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: phlask-ecosystem +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 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/public/testData.json b/public/testData.json index 882a2fbe..5451e34f 100644 --- a/public/testData.json +++ b/public/testData.json @@ -17,7 +17,11 @@ }, "state": "PA", "status": "OPERATIONAL", - "verified": false, + "verification": { + "verified": false, + "last_modified": "2024-03-31T00:37:46.004545+00:00", + "verifier": "phlask" + }, "version": 1, "water": { "dispenser_type": [ @@ -44,7 +48,11 @@ }, "state": "PA", "status": "OPERATIONAL", - "verified": false, + "verification": { + "verified": false, + "last_modified": "2024-03-31T00:37:46.004545+00:00", + "verifier": "phlask" + }, "version": 1, "water": { "dispenser_type": [ @@ -71,7 +79,11 @@ }, "state": "PA", "status": "OPERATIONAL", - "verified": false, + "verification": { + "verified": false, + "last_modified": "2024-03-31T00:37:46.004545+00:00", + "verifier": "phlask" + }, "version": 1, "water": { "dispenser_type": [ @@ -98,7 +110,11 @@ }, "state": "PA", "status": "OPERATIONAL", - "verified": false, + "verification": { + "verified": false, + "last_modified": "2024-03-31T00:37:46.004545+00:00", + "verifier": "phlask" + }, "version": 1, "water": { "dispenser_type": [ @@ -125,7 +141,11 @@ }, "state": "PA", "status": "OPERATIONAL", - "verified": false, + "verification": { + "verified": false, + "last_modified": "2024-03-31T00:37:46.004545+00:00", + "verifier": "phlask" + }, "version": 1, "water": { "dispenser_type": [ @@ -152,7 +172,11 @@ }, "state": "PA", "status": "OPERATIONAL", - "verified": false, + "verification": { + "verified": false, + "last_modified": "2024-03-31T00:37:46.004545+00:00", + "verifier": "phlask" + }, "version": 1, "water": { "dispenser_type": [ @@ -179,7 +203,11 @@ }, "state": "PA", "status": "OPERATIONAL", - "verified": false, + "verification": { + "verified": false, + "last_modified": "2024-03-31T00:37:46.004545+00:00", + "verifier": "phlask" + }, "version": 1, "water": { "dispenser_type": [ @@ -206,7 +234,11 @@ }, "state": "PA", "status": "OPERATIONAL", - "verified": false, + "verification": { + "verified": false, + "last_modified": "2024-03-31T00:37:46.004545+00:00", + "verifier": "phlask" + }, "version": 1, "water": { "dispenser_type": [ @@ -237,7 +269,11 @@ }, "state": "PA", "status": "OPERATIONAL", - "verified": false, + "verification": { + "verified": false, + "last_modified": "2024-03-31T00:37:46.004545+00:00", + "verifier": "phlask" + }, "version": 1, "zip_code": "19149" }, @@ -263,7 +299,11 @@ }, "state": "PA", "status": "OPERATIONAL", - "verified": false, + "verification": { + "verified": false, + "last_modified": "2024-03-31T00:37:46.004545+00:00", + "verifier": "phlask" + }, "version": 1, "zip_code": "19149" }, @@ -289,7 +329,11 @@ }, "state": "PA", "status": "OPERATIONAL", - "verified": false, + "verification": { + "verified": false, + "last_modified": "2024-03-31T00:37:46.004545+00:00", + "verifier": "phlask" + }, "version": 1, "zip_code": "19149" }, @@ -315,7 +359,11 @@ }, "state": "PA", "status": "OPERATIONAL", - "verified": false, + "verification": { + "verified": false, + "last_modified": "2024-03-31T00:37:46.004545+00:00", + "verifier": "phlask" + }, "version": 1, "zip_code": "19149" }, @@ -341,7 +389,11 @@ }, "state": "PA", "status": "OPERATIONAL", - "verified": false, + "verification": { + "verified": false, + "last_modified": "2024-03-31T00:37:46.004545+00:00", + "verifier": "phlask" + }, "version": 1, "zip_code": "19149" }, @@ -367,7 +419,11 @@ }, "state": "PA", "status": "OPERATIONAL", - "verified": false, + "verification": { + "verified": false, + "last_modified": "2024-03-31T00:37:46.004545+00:00", + "verifier": "phlask" + }, "version": 1, "zip_code": "19149" }, @@ -393,7 +449,11 @@ }, "state": "PA", "status": "OPERATIONAL", - "verified": false, + "verification": { + "verified": false, + "last_modified": "2024-03-31T00:37:46.004545+00:00", + "verifier": "phlask" + }, "version": 1, "zip_code": "19149" }, @@ -419,7 +479,11 @@ }, "state": "PA", "status": "OPERATIONAL", - "verified": false, + "verification": { + "verified": false, + "last_modified": "2024-03-31T00:37:46.004545+00:00", + "verifier": "phlask" + }, "version": 1, "zip_code": "19149" } diff --git a/src/actions/actions.js b/src/actions/actions.js index 71d1ee09..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', @@ -37,7 +38,12 @@ export const getResources = createAsyncThunk( if (process.env.REACT_APP_CYPRESS_TEST) return testData; const snapshot = await get(ref(database, '/')); const results = snapshot.val(); - return Object.values(results) || []; + return Object.entries(results).map( + ([id, resource]) => ({ + ...resource, + id + }) + ); } ); @@ -48,6 +54,9 @@ export const pushNewResource = newResource => ({ newResource }); +// Handles the case where an existing resource is updated from the submission form +export const updateExistingResource = createAction('UPDATE_EXISTING_RESOURCE'); + export const SET_USER_LOCATION = 'SET_USER_LOCATION'; export const setUserLocation = coords => ({ type: SET_USER_LOCATION, diff --git a/src/components/AddResourceModal/AddBathroom/PageOne.jsx b/src/components/AddResourceModal/AddBathroom/PageOne.jsx index 2cd83c77..da5fa722 100644 --- a/src/components/AddResourceModal/AddBathroom/PageOne.jsx +++ b/src/components/AddResourceModal/AddBathroom/PageOne.jsx @@ -17,6 +17,8 @@ import MyLocationIcon from '@mui/icons-material/MyLocation'; import useIsMobile from 'hooks/useIsMobile'; import noop from 'utils/noop'; +import { WEBSITE_REGEX } from '../utils'; + const ENTRY_TYPE = [ { entryType: 'Open access', explanation: 'Public site, open to all' }, { entryType: 'Restricted', explanation: 'May not be open to all' }, @@ -195,7 +197,7 @@ const PageOne = ({ { + const id = result._path.pieces[0]; + newResource.id = id; + dispatch(pushNewResource(newResource)); + }); }); }; diff --git a/src/components/AddResourceModal/AddWaterTap/PageOne.jsx b/src/components/AddResourceModal/AddWaterTap/PageOne.jsx index ff3d44b6..c82c9078 100644 --- a/src/components/AddResourceModal/AddWaterTap/PageOne.jsx +++ b/src/components/AddResourceModal/AddWaterTap/PageOne.jsx @@ -24,6 +24,8 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import useIsMobile from 'hooks/useIsMobile'; import noop from 'utils/noop'; +import { WEBSITE_REGEX } from '../utils'; + const ENTRY_TYPE = [ { entryType: 'Open access', explanation: 'Public site, open to all' }, { entryType: 'Restricted', explanation: 'May not be open to all' }, @@ -256,7 +258,7 @@ const PageOne = ({ { 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/components/SelectedTapMobile/SelectedTapDetails.js b/src/components/SelectedTapMobile/SelectedTapDetails.js index 79ab5886..e0a17b89 100644 --- a/src/components/SelectedTapMobile/SelectedTapDetails.js +++ b/src/components/SelectedTapMobile/SelectedTapDetails.js @@ -18,6 +18,7 @@ import { FOOD_RESOURCE_TYPE, FORAGE_RESOURCE_TYPE } from '../../types/ResourceEntry'; +import VerificationButton from 'components/Verification/VerificationButton'; function SelectedTapDetails(props) { const [pointerPositionY, setPointerPositionY] = useState(0); @@ -36,6 +37,10 @@ function SelectedTapDetails(props) { */ const resource = props.selectedPlace; + if (resource == null || Object.keys(resource).length === 0) { + return
; + } + let icon; switch (resource.resource_type) { case WATER_RESOURCE_TYPE: @@ -153,7 +158,17 @@ function SelectedTapDetails(props) { {/* Currently the three dot button does nothing */} )} - +
+ + +
state.filterMarkers.allResources); + const filteredResources = useSelector(state => selectFilteredResource(state)); const userLocation = useSelector(state => state.filterMarkers.userLocation); const toolbarModal = useSelector(state => state.filterMarkers.toolbarModal); const isResourceMenuShown = useSelector( @@ -109,32 +111,21 @@ function Toolbar({ map }) { // NOTE: This was left as an acceptable scenario for now, // as it is difficult for a user to do this reliably due to the popup of the location panel. // This may be reproducible on Desktop. - let data; - - switch (resourceType) { - case WATER_RESOURCE_TYPE: - data = allResources; - break; - // TODO(vontell): Filter based on requested type - default: - data = allResources; - } - - const closest = getClosest(data, { + const closest = getClosest(filteredResources, { lat: userLocation.lat, lon: userLocation.lng }); if (!closest) return; - dispatch(setSelectedPlace(closest)); + dispatch(toggleInfoWindow({ + isShown: true, + infoWindowClass: isMobile ? 'info-window-in' : 'info-window-in-desktop' + })); + dispatch(setSelectedPlace(closest)); map.panTo({ lat: closest.latitude, lng: closest.longitude }); - toggleInfoWindow({ - isShown: true, - infoWindowClass: isMobile ? 'info-window-in' : 'info-window-in-desktop' - }); } function closestButtonClicked() { diff --git a/src/components/Verification/VerificationButton.jsx b/src/components/Verification/VerificationButton.jsx new file mode 100644 index 00000000..2d2997c7 --- /dev/null +++ b/src/components/Verification/VerificationButton.jsx @@ -0,0 +1,304 @@ +import ModalWrapper from 'components/AddResourceModal/ModalWrapper'; +import React, { useState, useCallback } from 'react'; +import Button from '@mui/material/Button'; +import { getDatabase, set, ref } from 'firebase/database'; +import { resourcesConfig } from '../../firebase/firebaseConfig'; +import { initializeApp } from 'firebase/app'; +import Input from '@mui/material/Input'; +import { useDispatch } from 'react-redux'; +import { + updateExistingResource, + setSelectedPlace +} from '../../actions/actions'; +import Dialog from '@mui/material/Dialog'; + +const PASSWORD = 'ZnJlZXdhdGVy'; // Ask in Slack if you want the real password + +/** + * This button provides all functionality for locally verifying a resource. + * @param {object} props + * @param {ResourceEntry} props.resource The resource being verified + * @returns + */ +const VerificationButton = props => { + const { resource } = props; + const dispatch = useDispatch(); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [password, setPassword] = useState(''); + const [isAdmin, setIsAdmin] = useState(false); + const [loginError, setLoginError] = useState(''); + const [name, setName] = useState(''); + const [hasBeenUpdated, setHasBeenUpdated] = useState(false); + + const closeModal = useCallback(() => { + setIsModalOpen(false); + setPassword(''); + setIsAdmin(false); + setHasBeenUpdated(false); + setLoginError(''); + }, []); + + const updateFirebaseEntry = resource => { + // TODO(vontell): We probably should not init this here every time, although it is likely fine. + const app = initializeApp(resourcesConfig); + const database = getDatabase(app); + // Removed ID since we don't want that as part of the saved data structure + const { id, ...filteredResource } = resource; + set(ref(database, `/${resource.id}`), filteredResource); + setHasBeenUpdated(true); + dispatch(updateExistingResource({resource})); + dispatch(setSelectedPlace(resource)); + }; + + const markAsVerified = useCallback(() => { + const newVerification = { + verified: true, + last_modifier: name || 'phlask_app', + last_modified: new Date().toISOString() + }; + const newResource = { + ...resource, + verification: newVerification + }; + updateFirebaseEntry(newResource); + }, [name, resource.verification]); + + const markAsUnverified = useCallback(() => { + const newVerification = { + verified: false, + last_modifier: name || 'phlask_app', + last_modified: new Date().toISOString() + }; + const newResource = { + ...resource, + verification: newVerification + }; + updateFirebaseEntry(newResource); + }, []); + + const markAsInactive = useCallback(() => { + const newVerification = { + verified: false, + last_modifier: name || 'phlask_app', + last_modified: new Date().toISOString() + }; + const newResource = { + ...resource, + status: 'HIDDEN', + verification: newVerification + }; + updateFirebaseEntry(newResource); + }, []); + + if (!resource) { + return; + } + + return ( +
+
setIsModalOpen(true)} style={{ cursor: 'pointer' }}> + {resource.verification.verified ? 'VERIFIED' : 'UNVERIFIED'} +
+ +
+ {!isAdmin && ( +
+

+ Enter the admin password to update the verified status of this + resource. Also enter your name. +

+ { + setName(e.target.value); + }} + /> + { + setPassword(e.target.value); + setLoginError(''); + }} + /> + {loginError &&

{loginError}

} +
+ + +
+
+ )} + {isAdmin && ( +
+ {!hasBeenUpdated && ( +
+ {/* As an admin, you can either mark this as verified, unverified, or inactive */} +

+ Please select the change you would like to make. This was + previously marked as{' '} + {resource.verification.verified ? 'VERIFIED' : 'UNVERIFIED'}{' '} + by {resource.verification.last_modifier}. +

+ + + + + +
+ )} + {hasBeenUpdated && ( +
+

Your change has been recorded. Thanks!

+ +
+ )} +
+ )} +
+
+
+ ); +}; + +export default VerificationButton; diff --git a/src/reducers/filterMarkers.js b/src/reducers/filterMarkers.js index b86196f1..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,8 +86,39 @@ export default (state = initialState, act) => { allResources: [...state.allResources, act.newResource] }; - case actions.SET_FILTER_FUNCTION: - return { filterFunction: !state.filterFunction, ...state }; + 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, + allResources: state.allResources.map(resource => + resource.id === act.payload.resource.id + ? act.payload.resource + : resource + ) + }; case actions.SET_SELECTED_PLACE: // if passed Selected Place as an object, set selected place as the object @@ -106,10 +126,10 @@ export default (state = initialState, act) => { 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 { @@ -126,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; diff --git a/src/types/ResourceEntry.js b/src/types/ResourceEntry.js index 7c625e2b..96025ff8 100644 --- a/src/types/ResourceEntry.js +++ b/src/types/ResourceEntry.js @@ -19,7 +19,7 @@ * @property {string} last_modified The date this resource was last modified, in ISO UTC format. * @property {string} last_modifier Who last modified this resource. * @property {DataSource} source Where this resource data came from. - * @property {boolean} verified Whether or not this resource is currently verified. + * @property {Verification} verification The verification details of this resource. * @property {"WATER" | "FOOD" | "FORAGE" | "BATHROOM"} resource_type The type of resource. * @property {string | undefined} address The street address of the resource (not including city, state, or zip). May include the secondary address. * @property {string} city The city of the resource. @@ -32,7 +32,7 @@ * @property {string | undefined} guidelines Any additional community guidelines or rules for this resource. * @property {string | undefined} description A description of the resource. * @property {string} name A non-address name for this location, such as the business name or park name. - * @property {"OPERATIONAL" | "TEMPORARILY_CLOSED" | "PERMANENTLY_CLOSED"} status The current status of this resource. + * @property {"OPERATIONAL" | "TEMPORARILY_CLOSED" | "PERMANENTLY_CLOSED" | "HIDDEN"} status The current status of this resource. * @property {"OPEN" | "RESTRICTED" | "UNSURE" | undefined} entry_type What entry permissions are required for this resource. * @property {GooglePlacesPeriod[] | undefined} hours The hours of operation for this resource, if available. * @property {WaterInfo | undefined} water If the resource_type is WATER, the information about the water resource. @@ -94,7 +94,15 @@ * @property {("WHEELCHAIR_ACCESSIBLE" | "GENDER_NEUTRAL" | "CHANGING_TABLE" | "SINGLE_OCCUPANCY" | "FAMILY")[]} tags A list of additional tags regarding this bathroom resource. Can be empty. */ -export const WATER_RESOURCE_TYPE = "WATER"; -export const FOOD_RESOURCE_TYPE = "FOOD"; -export const FORAGE_RESOURCE_TYPE = "FORAGE"; -export const BATHROOM_RESOURCE_TYPE = "BATHROOM"; \ No newline at end of file +/** + * Details for verification status. + * @typedef {Object} Verification + * @property {boolean} verified Whether or not this resource is currently verified. + * @property {Date} last_modified The latest date this resource had a verification change. + * @property {string} verifier Who most recently changes the verification state of this resource. + */ + +export const WATER_RESOURCE_TYPE = 'WATER'; +export const FOOD_RESOURCE_TYPE = 'FOOD'; +export const FORAGE_RESOURCE_TYPE = 'FORAGE'; +export const BATHROOM_RESOURCE_TYPE = 'BATHROOM';