Data molding, HTTP requests, basic event handling
In the previous part, I went over adding a SVG spritesheet to the project. As promised in that post, this time around I'm going to work on the behavior of the location selection page.
When I say "behavior", I typically mean JavaScript. Before we get on with it, I
should note that JavaScript is not always necessary to add behavior to a page.
For example, links add behavior to a page at no additional cost, the <details>
element will expand and collapse, and so on. So when I say "behavior", please
treat it as an equivalent of "behavior that requires JavaScript".
Data molding
I don't say "data modeling" here, as that's not what I'm going to do. My process is fairly ad-hoc and informal. (I know, horrible. 😂) This is a matter of temperament and may not work for you. For some people, carefully thinking everything through works better. To differentiate from the complex work some of these guys are doing, I'm going to call my napkin doodles "data molding". YMMV. Whichever approach you pick, know that perfecting the approach is what makes it work well.
I'm going to cheat a bit, too. I've already worked on weather apps before, and this one specifically uses the same API as in the the one I built last time. Therefore, I already know that the geolocation API will return all place details including its coordinates. I also know that the weather forecast API will only need the coordinates to give me the weather data, but will not return the place name (obviously because the coordinates don't have to be within an inhabited place). When storing places, I need to, therefore, store the name of the place as well as the coordinates, as otherwise I won't be able to show the place name in the UI.
To keep things simple, I'll assume the most basic format for storage will work just fine:
{
"name": "Foo",
"lat": 0,
"long": 0
}
The name bit is clear. It's a string. For the latitude ("lat") and longitude
("long"), I'll need to first look at the API docs and see what format it
requires.
The API requires me to send the longitude and latitude as signed floats. Negative values are South latitudes and West longitudes, while positive values are North latitudes and East longitudes. This is pretty standard stuff. Open Meteo isn't doing anything weird with it that would require some kind of abstraction to iron over the rough edges. It's enough to just use the values returned by the geolocation API as is.
I'll also check the response to search queries so that I can plan on how to map them to search results. Each place in the response looks like this:
{
"id": 5809844,
"name": "Seattle",
"latitude": 47.60621,
"longitude": -122.33207,
"elevation": 56.0,
"feature_code": "PPLA2",
"country_code": "US",
"admin1_id": 5815135,
"admin2_id": 5799783,
"admin3_id": 7174408,
"timezone": "America/Los_Angeles",
"population": 684451,
"country_id": 6252001,
"country": "United States",
"admin1": "Washington",
"admin2": "King",
"admin3": "City of Seattle"
}
As mentioned in one of the previous parts, some countries customarily include
the administrative region (the "admin1" field) in the name of the location
(e.g., "Seattle, WA"), while others don't (e.g., Port Louis in Mauritius is just
"Port Louis"). After a bit of research, it's difficult to find conclusive
information about the rules regarding mentioning regions within place names. For
instance, "Reading" (UK) is listed as "Reading, Berkshire" on Wikipedia because
there's also a "Reading, Pennsylvania", but the geolocation API doesn't return
"Berkshire" as an administrative region name in the response.
The conclusion is, therefore, that I'll simply use the name, the contents of the
first administrative region, and the country. These are "name", "admin1" and
"country" fields. I'll show this information for every location, and that's
basically covering most cases at the expense of a bit more verbosity for some
locations.
Getting the data
Ok, I've got enough detail – for now – so I'm going to start writing the form-handling code.
First, I'll do the simplest thing that would (probably) work.
import {CustomElement} from './common.js'
customElements.define('x-search', class extends CustomElement {
setup() {
let input = this.querySelector('[name=search]'),
form = this.querySelector('form')
input.oninput = ev => {
clearTimeout(input.debounceTimer)
input.debounceTimer = setTimeout(() => {
let name = input.value.trim()
if (!name) {
console.log([])
return
}
let url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(name)}&count=10&language=en&format=json`
fetch(url)
.then(res => res.json())
.then(res => res.results || [])
.catch(() => [])
.then(console.log)
}, 300)
}
form.onsubmit = ev => ev.preventDefault()
}
})
The first thing I know I already have on the page is the custom element,
<x-search> which contains the elements that comprise the search form. Since
the first thing I'm going to be doing is instrumenting the form, it makes sense
to start by creating this custom element. Within the element's setup() method,
I'm grabbing references to the form-related elements and adding event listeners
to each. The input event listener is a single contiguous block of code that
debounces the actual code and then performs the HTTP request, logging the
results.
I'm not going to go too deep into how debouncing works or why it's done. You can already find plenty of material online regarding that. I'll just note that the first two lines of the input event handler are for debouncing, if the naming didn't give it away.
If you're wondering if I'm going to now refactor that, the answer is no. For me, the primary reason I factor stuff out into separate functions is to reuse it. I currently have no reason to reuse any of it, nor do I foresee any, so it's fine the way it is. Secondary reason may be to make it easier to reason about the code by separating concerns.
Separating out the debounce logic might look like a good candidate for the latter, but to me personally it's not as strong of a case. The code that does debouncing is actually fairly cleanly separated from the rest of the logic even as it is, and it's a two-line pattern that is easy to remember. So, for now at least, I'm leaving it alone.
Separating the HTTP-related code might look like a more compelling case. However, I think it's more of a judgement call. Yes, it might be possible to separate various concerns (cleaning the value, doing HTTP, handling the event), but damnit, it's not 5000 lines of code. You should be able to follow this without issues. Right?
I'm letting these sit and proof a bit more (yes, it's culinary terminology – ask someone who bakes).
The empty search keyword is handled by returning an empty result set. I
currently don't see a need to differentiate between no match and an actual
empty set, but I might need to do that later. In fact, I probably will.
However, I don't feel the urgency to handle that now, as it will be a
trivial fix (e.g., return undefined to mean "no search keyword"), so I'm
not going to err on the side of keeping the code simpler and underengineered.
The errors are all basically swallowed by the .catch() callback, and coerced
into an empty result set. This is because the API is not under my control, and
there's likely nothing the user will be able to do to recover. In other words,
any and all errors are my fault (programming error) or network/server outage.
Outages might be useful to report, but I'll probably take care of that when I
start working on the offline support. Right now, that sounds like a more logical
place to deal with this.
As a side-note, I don't specifically mention this but I do poke at the code all the time while I work on it. This is why I prefer the coding style and build setup (well, no build) because it gives me fast feedback. The code you see has gone three iterations in about 5 minutes before being copied into the blog post, and most definitely undergoing at least a few more as it's unfinished.
Naming convention
A naming convention emerged during the initial coding so I want to mention that here real quick. It's not a lot, but hearing my thought process might give you some hints... or something.
- I have 'result' to mean a single location record returned by the API. This is because the API calls them 'result'. I'm simply going along with it to minimize the friction.
- 'Location' is the same thing as 'result' outside the context of fetching the data. (And vice versa, 'result' is a 'location' in the HTTP response.)
I chose 'location' as a name (in part 1). It was supposed to be a generic name that suggests you can pick locations other than a 'city' (e.g., 'village', 'town', 'municipality'), That's what the UI label says, that's in the filename, etc. In hindsight that was a bad idea. I haven't considered that 'location' is also what JavaScripts calls the location API. I'm not really horrified of shadowing global names in limited scopes, but I'm not going to insist if I can use a different name.
Since I don't have a lot of code, I'm going to go ahead and change everything to 'place'.
- I change the
location.*files toplace.*. - I change the "Location search" heading to "Place search"
- I change the "Search for a location" label to "Search for a place"
- I change
<x-location-list>to<x-places> - I change the "Location list" heading to "Place list"
- ...
As you can see, I change all references both in the code and the user-facing labels. I think it's important to keep label text and code in sync as much as possible. I'm not going to insist, but I'm going to do my best to keep them in sync. And it's not (just) OCD. There's a good reason for it. When you're troubleshooting an issue, you will usually get information from non-technical people who refer to things they see on the screen (or perceive using some other means, like a screen reader). Therefore, these labels are your staring point. If the naming in the labels coincides with the naming in the code, it will be a lot easier to locate the code you need to check. If the names differ, you'll need to maintain a look-up table either in your head, or in documentation. That's just one more thing to worry about, and I like to keep such things to a minimum.
Connecting the form to search result list
The searching is done within the <x-search> element. The results are
displayed within the <x-search-results> element. If I were to now create a
custom element for the <x-search-results> tag, I'd basically have two
separate units. This means I'd need some way to integrate them.
There are several options for this. Each has its drawbacks and advantages.
I could simply avoid having a physical barrier between the units by promoting
the <x-search> element to include both the form and the result, which gives me
a natural anchor for the shared data and access to both the form elements and
the list. This probably creates the least amount of moving parts, as it provides
a natural anchor for the two units. It also creates the least-scoped
communication channel between them. I would expect the code to be easier to
reason about. The downside of this approach is that I'm forcing a change in the
content structure (shifting tags) which stinks of coupling. (In general, if
you're working the way I do, if you're having to edit files with two different
extensions to get one thing done, you should suspect unnecessary coupling. It's
not a guarantee it is, but it's very likely.)
I could explicitly select the <x-search-results> element from within
<x-search>. This is not too bad either. It gives me the same benefits as the
first solution, and keeps the coupling confined within JavaScript. It's not
entirely without coupling, but it's a coupling that's far less likely to be a
problem. (Not that the first one would be too horrible in this particular case
either.)
I could use an event bus to establish communication between the two elements. Using an event bus keeps the two units independent which is great. It means I could finish implementing one of the units and then switch to the other one. The downside is it makes code hard to reason about. When you start firing arbitrary events, it can get tricky to track down where the listener is (and vice versa). It also adds moving parts - the event bus, and a naming problem.
I could allow the <x-search-results> to publish a module-scoped function that
<x-search> would use to talk to it. The last solution is similar to an event
bus. It's a poor man's event bus in fact. It adds a bit more coupling than the
event bus, but it also makes the code wee bit easier to reason about.
Since no solution has the kind of drawbacks that I'll be able to dodge entirely and enjoy only the benefits, I'm going to opt for the first solution. The main allure of the first solution is that it is the most natural solution (least contorted), and is therefore the most likely to be understandable.
The last question I need to answer is whether I keep the search form behavior in
its own custom element or let the newly-promoted element handle that. For
instance, I could have the <x-search-form> element emit a change event every
time the form is edited, and then the <x-search> element would treat it as a
black box.
I think for now, I'm going with the solution of having the newly promoted element handle it. For two reasons: separating that logic into a unit of its own will create more moving parts, and I will have to introduce boilerplate for no added benefit.
I'm going to rename the current <x-search> element to <x-search-form>
and then wrap <x-search-form> and <x-places> in the <x-search> tag:
<x-search>
<x-search-form></x-search-form>
<x-places></x-places>
</x-search>
I don't need to modify the JavaScript code (yet), and it should still work. However, I will need to adjust the styling.
Firstly, I need to rename all references to x-search to x-search-form. I
also need to make it so that the newly added <x-search> does not affect
the layout. The new display: contents property should do the trick.
x-search {
display: contents;
}
The display: contents declaration does exactly what I said before: it causes
the <x-search> element to not affect the layout. When calculating the layout,
the browser will pretend as if its children are the direct children of
<x-search>'s parent – as if they're not wrapped at all.
After I made this change, the this in the x-search custom element I defined
earlier becomes the common ancestor of both <x-search-form> and
<x-places> elements, so I get access to everything I need.
Updating the search result list
The search results contain up to 10 items. From the DOM manipulation standpoint, this is not a lot, so we can use the simplest possible strategy for updating the list, which is to just re-render all list items from scratch on each update.
First I add a few more references to elements I'm going to need.
customElements.define('x-search', class extends CustomElement {
setup() {
let input = this.querySelector('[name=search]'),
form = this.querySelector('form'),
searchResultList = this.querySelector('x-search-results ul'),
placeTemplate = this.querySelector('x-search-results template')
// ....
}
// ....
})
I like to use words like "list" and "template" in the variable names to suggest what these things might be used for.
The function that updates the result list looks like this:
customElements.define('x-search', class extends CustomElement {
setup() {
// ....
let updateResultList = results => {
let newResultList = document.createDocumentFragment()
for (let result of results) {
let resultItem = placeTemplate.content.cloneNode(true),
button = resultItem.querySelector('button'),
label = button.querySelector('span')
button.place = result
label.textContent = `${result.name}, ${result.admin1}, ${result.country}`
newResultList.append(resultItem)
}
searchResultList.replaceChildren(newResultList)
}
// ....
}
// ....
})
The document.createDocumentFragment() function creates and returns a
DocumentFragment object. This is a magic element that can have one or
more child nodes, but when the fragment itself is inserted into the DOM tree, it
melts away leaving only its contents. If you're used to React, it's the same as
the <> tag. I use a fragment to hold the new search result list items before
inserting them into the list all at once.
For each result in the list returned by the API, I create a list item from the
template. (Do you see how well this sentence matches the actual code?) When I
select a <template> element, it has a contents property that represents the
DocumentFramgment containing whatever the template contains. I want a fresh
copy of this, so I .cloneNode(true) it. (I can also clone a live
node this way and use it for the exact same purpose. The difference is that
a template doesn't get parsed by the browser when the page is being parsed,
so it saves me a bit of overhead.)
One thing you'll notice is I assigned to a .place property on the button
element. References to DOM nodes are stable. What this means is, irrespective of
how you obtain a reference to a DOM node, you are guaranteed to get the exact
same object. This is even true for references you obtain through an
Event.target property. Thanks to this, we can share information using the DOM
tree by adding custom (non-standard) properties to the nodes. When the button is
clicked, I can, for instance, access the .place property and obtain the place
information. (Contrary to what some people believe, this has no special impact
on anything: it's the same as assigning properties on any other object.)
After I insert the information I need, I append the item to the list by calling
.append() on the DocumentFragment. Finally, I replace the contents
of the list using .replaceChildren() on the <ul> element.
Finally, I edit the input event handler and replace console.log with
updateResultList (quite literally).
customElements.define('x-search', class extends CustomElement {
setup() {
// ....
input.oninput = ev => {
clearTimeout(input.debounceTimer)
input.debounceTimer = setTimeout(() => {
let name = input.value.trim()
if (!name) {
updateResultList([])
return
}
let url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(name)}&count=10&language=en&format=json`
fetch(url)
.then(res => res.json())
.then(res => res.results || [])
.catch(() => [])
.then(updateResultList)
}, 300)
}
// ....
}
})
After testing this a bit, it appears it works very well.
Selecting a place
By "selecting a place" I mean storing the user's choice in localStorage and
then navigating to the forecast page. I've already done all the groundwork for
this, so I only need to do one thing: add the click event listener for the
buttons.
For this I'll use a delegated event handler.
customElements.define('x-search', class extends CustomElement {
setup() {
// ....
searchResultList.onclick = ev => {
let button = ev.target.closest('button')
if (!button) return
localStorage.selectedPlace = JSON.stringify({
name: button.place.name,
lat: button.place.latitude,
long: button.place.longitude,
})
location.assign('./')
}
}
})
Rather than attaching event listeners to each button separately, I attach it
to the common ancestor, the <ul> element. While bubbling, the click events
will inevitably pass through the <ul>, where I can catch and handle them.
The reason I grab a reference to the button using ev.target.closest('button')
is that the button elements are not the only clickable elements in the <ul>'s
subtree. The button's parent, <li>, is also clickable. So are the <x-icon>
and <span> within the <button>. By calling .closest('button') on the event
target, I ensure the user actually clicked on a button. It may sound a bit
convoluted when explained like this, but it's actually just a pattern you learn
and that's it – you don't actually have to think too deeply about this once you
learn how to implement it.
The data is stored under the selectedPlace key in localStorage. The data is
stored in a simplified object as per what I outlined in the "Data molding"
section. This little abstraction is mostly unnecessary, but here's something I'd
like to point out. The shape of the data in the localStorage.selectedPlace key
is a contract between this page and the forecast page. While I can easily
disregard it in this particular project, it would be something I'd honor as best
I can if this was a team project, and someone else was working on the index
page.
After storing the place object as JSON, I instruct the browser to load the ./
URL by calling location.assign(). Technically, it could've also been
/, but this just adds support for relative URLs so that I can host this in a
subdirectory of the site, and not just the root.
Lastly, I will modify the index page so that it has some content which let's me know the app actually navigated away. It doesn't matter what the content is as it's just a placeholder, so I won't bore you with it.
I give the whole thing a good kick in the tires and it appears to hold up well.
After picking a location, I'm taken to the index page, and the localStorage
contains the correct data.
Conclusion
In this part, I've done half of the work on the behavior side of the location... I mean place selection page. In the next part, I'm going to be taking on the storage of last used place and keeping a list of 5 such places.