My Current Project
As I reach the end of my journey as a bootcamp student, for my capstone project, I'm building an app that will allow users to search for national parks with particular accessibility-related amenities available. For today's blog, I wanted to walk through how I implemented a central piece of functionality for the app: the search. More specifically, the ability to search using multiple criteria.
The Problem I'm Trying to Solve
The search was at the heart of why I had the idea to create this app in the first place. In the official National Park Service mobile app, a user can search for parks to explore based on:
- their proximity to the park
- the name of the park
- the state the park is located in
- activities available at the park
- topics discussed at the park
- the type of park (e.g., park, monument, recreation area)
That's a lot of search options! However, despite there being a wide range of accessible offerings at the parks, there currently isn't an easy way for users to find parks based on the accessibility offerings available. A user has to click into each park, navigate to the amenities section, and from there, find the relevant section on accessibility.
The National Park Service website provides a map of accessibility in the national parks, showing the location of all parks in the United States with accessible features and services. However, clicking on each location only provides a link to the park's accessibility page. There's no way to see at a glance which amenities are available.
There's no way to know whether "accessibility" at a given park means having wheelchair-accessible trails, having a sign-language interpreter for guided tours, or having tactile exhibits for those with visual impairments. The user has to manually search each page to whittle down which ones have the amenities they need to make the most of their trip, which is needlessly time-consuming.
My Vision for the Search Function
When I first sketched out what I wanted the user interface to look like, I knew I wanted the homepage to be a list of the various accessibility and accessibility-adjacent (e.g., cellular signal, access to groceries, access to drinking water) amenities available at the parks. I wanted users to be able to select (by clicking the corresponding checkbox) any of the criteria that would help them decide where to go, click 'Search,' and have a list of parks returned to them matching all the criteria they selected.
Let's code along together to make that vision a reality!
Create 'Search' and 'Results' Components
We'll start by creating two components to house the search and results, respectively, and importing those into our main app to add them to the component tree. At the same time, let's add some basic routing by importing Switch
and Route
from React Router. (Note: I'm using version 5 of React Router here.)
// App.js
// React Imports
import { React } from 'react';
import { Switch, Route } from 'react-router-dom';
// Component Imports
// Note: This import reference will vary based on your file structure. In this case, the main App is stored outside the 'Components' folder.
import { Search } from './Components/Search';
import { Results } from './Components/Results';
function App() {
return (
<Switch>
<Route path='/search'>
<Search />
</Route>
<Route path='/results'>
<Results />
</Route>
</Switch>
)
};
export default App;
Create the Initial Form
Next, in the search component, let's import React's useState
and useEffect
hooks. We'll put those to work right away, fetching the amenity data from the server from within useEffect
with an empty dependency array so that the GET
request only gets sent when the component initially renders, not continuously. Once that data is successfully retrieved, we'll save this array of amenity objects as a state variable.
With the amenities array saved in state, we can then iterate through using the map()
method. Cycling through each amenity object in the array, we'll map its properties onto the attributes of an <input>
element. The end result of the map will be a new array of checkbox elements that can then be added within the overall <form>
being returned by the Search
component.
Here's what the user interface should look like so far (without adding styling, for simplicity):
Here's what the code should look like so far:
// Search.js
// Importing React hooks
import { useState, useEffect } from 'react';
function Search() {
// Setting state for amenities
const [amenities, setAmenities] = useState([])
// Fetching amenities from within useEffect
useEffect( () => {
fetch('/amenities')
.then(response => response.json())
.then(amenityData => setAmenities(amenityData))
}, [])
// Iterating through amenities to create an array 'checkbox' input elements.
const checkboxes = amenities.map( (amenity) => {
return (
<div key={amenity.id}>
<label>
<input
type="checkbox"
name="checked"
value={amenity.id}
/>
{amenity.name}
</label>
<br />
</div>
)
})
return (
<div>
<h1>Search</h1>
<form>
// Inserting that array of checkboxes into the form.
{checkboxes}
<input type="submit" value="Search" />
</form>
</div>
)
};
export default Search;
One thing that you'll notice looking at the code above is that each of the checkbox elements has the same name, "checked". This is by design, as we'll cover in the next section.
Configure Formik
Formik is an incredibly useful React library for managing state within our forms. Setting up the useFormik
hook and using its built-in state and change handlers takes out a lot of the work that comes with setting up form fields as controlled components.
// Search.js
// Other React imports
// Importing Formik
import { useFormik } from 'formik';
function Search() {
// State-setting, useEffect fetching, etc.
// Initializing the useFormik hook
const formik = useFormik({
initialValues: {
checked: []
},
onSubmit: (values) => {
console.log(values)
}
})
// checkboxes, return, etc.
};
In the snippet of code above, we're initializing the useFormik
hook. We're only specifiying one field here, 'checked' — corresponding with the name
attribute that we assigned to each of the checkbox elements earlier — and setting it equal to an empty array for its initial value. Per Formik's documentation on forms with checkbox groups, "given that the fields all share the same name
, Formik will automagically bind them to a single array". Anytime we click on a checkbox, that checkbox's value (which we've set to the amenity's unique ID) gets added to that array. If we click on the same box again to uncheck it, that value gets removed from the array.
Of course, to accomplish that, let's not forget that Formik's handlers need to get added on to the form elements. The formik.handleChange
prop should get passed in to each input's onChange
attribute:
// Search.js
// imports
function Search() {
// State-setting, useEffect fetching, Formik set-up, etc.
const checkboxes = amenities.map( (amenity) => {
return (
<div key={amenity.id}>
<label>
<input
type="checkbox"
name="checked"
value={amenity.id}
// Adding Formik's built-in onChange handler.
onChange={formik.handleChange}
/>
{amenity.name}
</label>
<br />
</div>
)
});
// Return, etc.
};
The formik.handleSubmit
prop should get added to the overall form's onSubmit
attribute:
// Search.js
// imports
function Search() {
// State-setting, useEffect fetching, Formik set-up, etc.
// Checkboxes
return (
<div>
<h1>Search</h1>
<form onSubmit={formik.handleSubmit}>
{checkboxes}
<input type="submit" value="Search" />
</form>
</div>
)
};
Now, let's hop back to our useFormik
hook and take a closer look at onSubmit
. Formik initially receives an array of strings from the form. We're going to want to take those individual strings and join them together into one string, which we can accomplish using the join()
method.
Here's what that updated code should look like now:
// Search.js
// imports
function Search() {
// State-setting, useEffect fetching
const formik = useFormik({
initialValues: {
checked: []
},
onSubmit: (values) => {
const value_array = values['checked'];
console.log('Value Array:', value_array)
const value_string = value_array.join();
console.log('Value String:', value_string)
}
})
// Checkboxes, return, etc.
};
And here's what output should look like when printed in the browser console:
Joining these ids together into one string will make communicating our request to the server much easier, as we'll discuss in the next part.
Before I realized that I could pass this string of multiple ids as a parameter in my fetch request to my server, I tried setting up a series of fetch requests with each individual amenity ID to return arrays of matching park objects and then filtering through those arrays to find the intersecting records. I won't go into the nitty-gritty details here, but suffice it to say that, not getting the expected results 30 lines of code later, there is no straightforward way of accomplishing this kind of filtering with JavaScript. However, utilizing the powerful data manipulation capabilities of Python and SQL, this task is much simpler to accomplish on the backend!
Backend
The backend of this project uses Python, Flask, and SQLAlchemy. (Note: I won't get into the details of the initial configuration of those libraries but encourage you to check out the Flask and Flask-SQLAlchemy docs for more background.)
Create API Resource and Route
To build out the route that we'll use to return search results, we'll start by creating a new API resource, 'ParksByAmenityIds'
, with a GET
method that takes a string of ids as a parameter (hey, that sounds like what we just made on the frontend!). Before building out the logic within that resource, let's immediately go ahead and add this resource and the corresponding endpoint below (it is very easy to forget otherwise!).
# app.py
from flask import make_response
from flask_restful import Resource
from config import app, api
from models import Amenity
class ParksByAmenityIds(Resource):
def get(self, id_string):
pass
api.add_resource(ParksByAmenityIds, '/park_amenities/<string:id_string>')
With that out of the way, let's build out the underlying logic.
Build Out Query Logic
First, we'll split the id_string
into an array of separate ids.
id_array = id_string.split(',')
Next, we'll use a list comprehension to convert those id elements from strings into integers.
int_ids = [int(id) for id in id_array]
Now, we can get a list of the parks that have any (but not all) of the specified amenities by querying the ParkAmenity
join table. We can use the .in_
column operator (which operates similarly to Python's in
keyword) to return all the parks with a matching amenity id. (For the example I used with amenity ids 1, 2, and 3, there were 276 items returned with duplicates.)
all_matching_amenities = [amenity.to_dict() for amenity in ParkAmenity.query.filter(ParkAmenity.amenity_id.in_(int_ids)).all()]
Next, we'll use another list comprehension to create a new list of all the park IDs from the result of the all_matching_amenities
query.
all_park_ids = [element['park']['id'] for element in all_matching_amenities]
Using yet another list comprehension, let's return all of the ids which appear in the park ID list as many times as there are amenities we're searching for (in this case, we've got a list of three amenities). We're using the .count()
method as well as finding the length of our amenities list with len()
to make this comparison. The result will be a list of all the park ids connected to all the specified amenities.
multiple_matches = [id for id in all_park_ids if all_park_ids.count(id) == len(int_ids)]
We can quickly remove all of the duplicates by turning that list into a set with Python's set()
constructor.
unique_matches = set(multiple_matches)
In this example, we've whittled down 276 items that match at least one criterion to a set of 18 items that match all three criteria — fantastic!
Using those unique matches, we can now query the Parks
table (again using the .in_
column operator) to return all the parks with an id appearing in our new set of unique matches. This list of park objects (with all their details, not just the ids) is what our server will send back to the frontend in response to our search request.
parks = [park.to_dict() for park in Park.query.filter(Park.id.in_(unique_matches)).all()]
Add Error Handling if No Matches Found
Before we tie this back together with our frontend, we should add some error handling. It will not be uncommon for users to select a combination of criteria that won't yield any matches. We'll want to let the frontend know if this is the case so that it can render an error message to the user accordingly. In this case, we'll add a clause that specifies that if the length of the list of parks returned is 0, the server should send back an error.
if len(parks) == 0:
response = make_response(
{"error": "No matching parks."},
404
)
return response
else:
response = make_response(
parks,
200
)
return response
Here's what the entire resource looks like, all put together:
# app.py
# imports, additional resources/routes
class ParksByAmenityIds(Resource):
def get(self, id_string):
id_array = id_string.split(',')
int_ids = [int(id) for id in id_array]
all_matching_amenities = [amenity.to_dict() for amenity in ParkAmenity.query.filter(ParkAmenity.amenity_id.in_(int_ids)).all()]
all_park_ids = [element['park']['id'] for element in all_matching_amenities]
multiple_matches = [id for id in all_park_ids if all_park_ids.count(id) == len(int_ids)]
unique_matches = set(multiple_matches)
parks = [park.to_dict() for park in Park.query.filter(Park.id.in_(unique_matches)).all()]
if len(parks) == 0:
response = make_response(
{"error": "No matching parks."},
404
)
return response
else:
response = make_response(
parks,
200
)
return response
api.add_resource(ParksByAmenityIds, '/park_amenities/<string:id_string>')
Tying the Frontend and Backend Together
Now that we've got this route built out, let's update the Formik onSubmit
handler with that route to fetch the matching parks from the server. We'll add a clause for rendering an error on the search page if no matches are found. We'll set up searchError
as a boolean state variable and have the error message render conditionally based on whether the error state is true.
// Search.js
// imports
function Search() {
const [amenities, setAmenities] = useState([])
const [searchError, setSearchError] = useState(false)
// useEffect fetching
const formik = useFormik({
initialValues: {
checked: []
},
onSubmit: (values) => {
const value_array = values['checked'];
const value_string = value_array.join();
fetch(`/park_amenities/${value_string}`)
.then(response => {
if (response.ok) {
response.json()
.then(parkData => console.log(parkData))
} else {
response.json()
.then(error => setSearchError(true))
}
})
}
})
// Checkboxes
return (
<div>
<h1>Search</h1>
{searchError
? <p>No matches found.</p>
: null
}
<form onSubmit={formik.handleSubmit}>
{checkboxes}
<input type="submit" value="Search" />
</form>
</div>
)
};
To be able to render those matches in our 'Results' component, we'll need to establish parks
as a state variable in the parent component (App
) and pass those down to Search
and Results
as props.
// App.js
// React Imports
import { React, useState } from 'react';
// Additional Imports
function App() {
const [parks, setParks] = useState([])
return (
<Switch>
<Route path='/search'>
<Search setParks={setParks}/>
</Route>
<Route path='/results'>
<Results parks={parks}/>
</Route>
</Switch>
)
};
export default App;
Now, upon a successful search, we can use the setParks
prop to save those matches in state. We'll also use the useHistory
hook from React Router to send us to the Results page upon a successful search using the .push()
method.
// Search.js
import { useHistory } from 'react-router-dom';
// Additional Imports
function Search({ setParks }) {
// state-setting
const history = useHistory()
// useEffect fetching
const formik = useFormik({
initialValues: {
checked: []
},
onSubmit: (values) => {
const value_array = values['checked'];
const value_string = value_array.join();
fetch(`/park_amenities/${value_string}`)
.then(response => {
if (response.ok) {
response.json()
.then(parkData => setParks(parkData))
.then(() => history.push('/results'))
} else {
response.json()
.then(error => setSearchError(true))
}
})
}
})
// checkboxes, return, etc.
};
Last, let's quickly get a basic Results
component built out:
// Results.js
function Results({ parks }) {
const li_parks = parks.map(park => {
return <li key={park.id}>{park.name}</li>
})
return (
<div>
<h1>Results</h1>
<ul>
{li_parks}
</ul>
</div>
)
};
export default Results;
And there we have it, a successful search — hopefully the first of many for our users!
Top comments (0)