Weather Report Guide (Simple)
This guide walks you through building a simple weather report widget. It takes about 25 minutes to complete. It was written with CanJS 4.1.
The final widget looks like:
To use the widget:
- Enter a location (example: Chicago)
- If the location name isn’t unique, click on the intended location.
- See the 10-day forecast for your selected city.
Start this tutorial by cloning the following JS Bin:
This JS Bin has initial prototype HTML and CSS which is useful for getting the application to look right.
The following sections are broken down into:
- Problem — A description of what the section is trying to accomplish.
- Things to know — Information about CanJS that is useful for solving the problem.
- Solution — The solution to the problem.
Setup
The problem
Get the basic setup for a CanJS app (in a JS Bin) setup by:
- Creating a template that outputs the pre-constructed HTML.
- Defining a
WeatherViewModelconstructor function. - Rendering the template with an instance of
WeatherViewModel. - Inserting the result of the rendered template into the page.
Things to know
A can-stache template can be loaded from a
<script>tag with can.stache.from and used to render data into a document fragment:const template = can.stache.from(SCRIPT_ID); const fragment = template({message: "Hello World"}); // fragment -> <h1>Hello World</h1>can.DefineMap can be used to define the behavior of observable objects like:
const Type = can.DefineMap.extend({ message: "string" });Instances of these can.DefineMap types are often used as a ViewModel that controls the behavior of a can-stache template (or can-component).
const MessageViewModel = can.DefineMap.extend({ message: "string" }); const messageVM = new MessageViewModel(); const fragment = template(messageVM)
The solution
Update the HTML tab to wrap the template in a script tag:
<script id="app-template" type="text/stache">
<div class="weather-widget">
<div class="location-entry">
<label for="location">Enter Your location:</label>
<input id="location" type="text"/>
</div>
<p class="loading-message">
Loading places…
</p>
<div class="location-options">
<label>Pick your place:</label>
<ul>
<li>Some Place</li>
<li>Another Place</li>
</ul>
</div>
<div class="forecast">
<h1>10-day Chicago Weather Forecast</h1>
<ul>
<li>
<span class="date">Today</span>
<span class='description scattered-showers'>Scattered Showers</span>
<span class="high-temp">100<sup>°</sup></span>
<span class="low-temp">-10<sup>°</sup></span>
</li>
</ul>
</div>
</div>
</script>
Update the JavaScript tab to:
- Define a ViewModel.
- Create an instance of the ViewModel .
- Load the
app-templatetemplate. - Render the template with the ViewModel instance.
- Insert the rendered result into the page.
const WeatherViewModel = can.DefineMap.extend({
});
const vm = new WeatherViewModel();
const template = can.stache.from("app-template");
const fragment = template( vm );
document.body.appendChild(fragment);
Allow a user to enter a location
The problem
We want an input element to:
- Allow a person to type a location to search for weather.
- Show the user the location they typed.
Things to know
There are many ways to define a property on a
DefineMap. The simplest way ispropName: "<TYPE>"like:DefineMap.extend({ property: "string" })The toParent:to can set an input’s
valueto a ViewModel property like:<input value:to="property" />A can-stache template uses {{key}} magic tags to insert data into the HTML output like:
{{property}}
The solution
Update the JavaScript tab to define a location property as a string.
const WeatherViewModel = can.DefineMap.extend({
location: "string"
});
const vm = new WeatherViewModel();
const template = can.stache.from("app-template");
const fragment = template( vm );
document.body.appendChild(fragment);
Update the template in the HTML tab to:
- Update
locationon the ViewModel when the input changes. - Show value of the ViewModel’s
locationproperty.
<script id="app-template" type="text/stache">
<div class="weather-widget">
<div class="location-entry">
<label for="location">Enter Your location:</label>
<input id="location" value:to="location" type="text" />
</div>
<p class="loading-message">
Loading places…
</p>
<div class="location-options">
<label>Pick your place:</label>
<ul>
<li>Some Place</li>
<li>Another Place</li>
</ul>
</div>
<div class="forecast">
<h1>10-day {{location}} Weather Forecast</h1>
<ul>
<li>
<span class="date">Today</span>
<span class='description scattered-showers'>Scattered Showers</span>
<span class="high-temp">100<sup>°</sup></span>
<span class="low-temp">-10<sup>°</sup></span>
</li>
</ul>
</div>
</div>
</script>
Get and display the places for the user’s location name
The problem
Once the user has entered a location name, we need to get which “place” it is. For example, a user might enter Paris, but we don’t know if they mean the Paris in France or the one in Illinois. We need to get a list of matching places for the location name and display the matching places on the page.
Things to know
ES5 Getter Syntax can be used to define a
DefineMapproperty that changes when another property changes. For example, the following defines anexcitedMessageproperty that always has a!after themessageproperty:DefineMap.extend({ message: "string", get excitedMessage() { return this.message+"!"; } });YQL provides a service endpoint for retrieving a list of places that match some text. For example, the following requests all places that match
Paris:https://query.yahooapis.com/v1/public/yql? format=json& q=select * from geo.places where text="Paris"The list of matched places will be in the response data’s
data.query.results.placeproperty. If there is only a single match,placewill be an object instead of an array.The fetch API is an easy way to make requests to a URL and get back JSON. Use it like:
fetch(url).then(function(response) { return response.json(); }).then(function(data) { });can.param is able to convert an object into a query string format like:
can.param({format: "json", q: "select"}) //-> "format=json&q=select"Use {{#if(value)}} to do
if/elsebranching incan-stache.Use {{#each(value)}} to do looping in
can-stache.Promises are observable in can-stache. Given a promisesomePromise, you can:- Check if the promise is loading like:
{{#if(somePromise.isPending)}}. - Loop through the resolved value of the promise like:
{{#each(somePromise.value)}}.
- Check if the promise is loading like:
The solution
- Show a “Loading places…” message while we wait on data.
- Once the places are resolved, list each place’s name, state, country and type.
Update the template in the HTML tab to:
<script id="app-template" type="text/stache">
<div class="weather-widget">
<div class="location-entry">
<label for="location">Enter Your location:</label>
<input id="location" value:to="location" type="text" />
</div>
{{#if(placesPromise.isPending)}}
<p class="loading-message">
Loading places…
</p>
{{/if}}
{{#if(placesPromise.isResolved)}}
<div class="location-options">
<label>Pick your place:</label>
<ul>
{{#each(placesPromise.value)}}
<li>
{{name}}, {{admin1.content}}, {{country.code}} ({{placeTypeName.content}})
</li>
{{/each}}
</ul>
</div>
{{/if}}
<div class="forecast">
<h1>10-day {{location}} Weather Forecast</h1>
<ul>
<li>
<span class="date">Today</span>
<span class='description scattered-showers'>Scattered Showers</span>
<span class="high-temp">100<sup>°</sup></span>
<span class="low-temp">-10<sup>°</sup></span>
</li>
</ul>
</div>
</div>
</script>
Update the JavaScript tab to:
- Define a
placesPromiseproperty that will represent the loading places. - If the user has typed in at least two characters, we fetch the matching places.
- If only a single place is returned, we still convert it into an array so the data stays consistent.
const yqlURL = "//query.yahooapis.com/v1/public/yql?";
const WeatherViewModel = can.DefineMap.extend({
location: "string",
get placesPromise() {
if (this.location && this.location.length > 2) {
return fetch(
yqlURL +
can.param({
q: 'select * from geo.places where text="'+this.location+'"',
format: "json"
})
).then(function(response) {
return response.json();
}).then(function(data) {
console.log(data);
if (Array.isArray(data.query.results.place)) {
return data.query.results.place;
} else {
return [data.query.results.place];
}
});
}
}
});
const vm = new WeatherViewModel();
const template = can.stache.from("app-template");
const fragment = template( vm );
document.body.appendChild(fragment);
Allow a user to select a place
The problem
When a user clicks on a place, we need to indicate their selection.
Things to know
Use (on:EVENT) to listen to an event on an element and call a method in
can-stache. For example, the following callssayHi()when the<div>is clicked.<div on:click="sayHi()"> … </div>thisin a stache template refers to the current context of a template or section.For example, the
thisinthis.namerefers to thecontextobject:const template = stache("{{this.name}}"); const context = {name: "Justin"}; template(context);Or, when looping through a list of items,
thisrefers to each item:{{#each(items)}} <li>{{this.name}}</li> <!-- this is each item in items --> {{/each}}
The “any” type can be used to define a property as accepting any data type like:
const MessageViewModel = can.DefineMap.extend({ message: "string", metaData: "any" })can.DefineMapcan also have methods:const MessageViewModel = can.DefineMap.extend({ message: "string", metaData: "any", sayHi: function() { this.message = "Hello"; } });
The solution
Update the template in the HTML tab to:
- When a
<li>is clicked on, callpickPlacewith the correspondingplace. - When a
placehas been set, write out the forecast header.
<script id="app-template" type="text/stache">
<div class="weather-widget">
<div class="location-entry">
<label for="location">Enter Your location:</label>
<input id="location" value:to="location" type="text" />
</div>
{{#if(placesPromise.isPending)}}
<p class="loading-message">
Loading places…
</p>
{{/if}}
{{#if(placesPromise.isResolved)}}
<div class="location-options">
<label>Pick your place:</label>
<ul>
{{#each(placesPromise.value)}}
<li on:click="../pickPlace(this)">
{{name}}, {{admin1.content}}, {{country.code}} ({{placeTypeName.content}})
</li>
{{/each}}
</ul>
</div>
{{/if}}
{{#if(place)}}
<div class="forecast">
<h1>10-day {{place.name}} Weather Forecast</h1>
<ul>
<li>
<span class="date">Today</span>
<span class='description scattered-showers'>Scattered Showers</span>
<span class="high-temp">100<sup>°</sup></span>
<span class="low-temp">-10<sup>°</sup></span>
</li>
</ul>
</div>
{{/if}}
</div>
</script>
Update the JavaScript tab to:
- Define a
placeproperty as taking any data type. - Define a
pickPlacemethod that sets the place property.
const yqlURL = "//query.yahooapis.com/v1/public/yql?";
const WeatherViewModel = can.DefineMap.extend({
location: "string",
get placesPromise() {
if (this.location && this.location.length > 2) {
return fetch(
yqlURL+
can.param({
q: 'select * from geo.places where text="'+this.location+'"',
format: "json"
})
).then(function(response) {
return response.json();
}).then(function(data) {
console.log(data);
if (Array.isArray(data.query.results.place)) {
return data.query.results.place;
} else {
return [data.query.results.place];
}
});
}
},
place: "any",
pickPlace: function(place) {
this.place = place;
}
});
const vm = new WeatherViewModel();
const template = can.stache.from("app-template");
const fragment = template( vm );
document.body.appendChild(fragment);
Get and display the forecast
The problem
Once we’ve selected a place, we need to get and display the forecast data for the selected place.
Things to know
ViewModel methods can be called within a can-stache template like:
{{myMethod(someValue)}}YQL provides a service endpoint for retrieving a forecast that matches a
place’swoeid. For example, the following requests the forecast for Paris, France’swoeid:https://query.yahooapis.com/v1/public/yql? format=json& q=select * from weather.forecast where woeid=615702The stylesheet includes icons for classNames that match:
sunny,mostly-cloudy,scattered-thunderstorms, etc.
The solution
Update the template in the HTML tab to:
- Display each forecast day’s details (date, text, high, and low).
- Use the
toClassNamemethod to convert the forecast’stextinto aclassNamevalue that will be matched by the stylesheet.
<script id="app-template" type="text/stache">
<div class="weather-widget">
<div class="location-entry">
<label for="location">Enter Your location:</label>
<input id="location" value:to="location" type="text" />
</div>
{{#if(placesPromise.isPending)}}
<p class="loading-message">
Loading places…
</p>
{{/if}}
{{#if(placesPromise.isResolved)}}
<div class="location-options">
<label>Pick your place:</label>
<ul>
{{#each(placesPromise.value)}}
<li on:click="../pickPlace(this)">
{{name}}, {{admin1.content}}, {{country.code}} ({{placeTypeName.content}})
</li>
{{/each}}
</ul>
</div>
{{/if}}
{{#if(place)}}
<div class="forecast">
<h1>10-day {{place.name}} Weather Forecast</h1>
<ul>
{{#each(forecastPromise.value)}}
<li>
<span class="date">{{date}}</span>
<span class="description {{toClassName(text)}}">{{text}}</span>
<span class="high-temp">{{high}}<sup>°</sup></span>
<span class="low-temp">{{low}}<sup>°</sup></span>
</li>
{{/each}}
</ul>
</div>
{{/if}}
</div>
</script>
Update the JavaScript tab to:
- Define a
forecastPromiseproperty that gets a list of promises. - Define a
toClassNamemethod that lowercases and hyphenates any text passed in.
const yqlURL = "//query.yahooapis.com/v1/public/yql?";
const WeatherViewModel = can.DefineMap.extend({
location: "string",
get placesPromise() {
if (this.location && this.location.length > 2) {
return fetch(
yqlURL+
can.param({
q: 'select * from geo.places where text="'+this.location+'"',
format: "json"
})
).then(function(response) {
return response.json();
}).then(function(data) {
console.log(data);
if (Array.isArray(data.query.results.place)) {
return data.query.results.place;
} else {
return [data.query.results.place];
}
});
}
},
place: "any",
pickPlace: function(place) {
this.place = place;
},
get forecastPromise() {
if ( this.place ) {
return fetch(
yqlURL+
can.param({
q: 'select * from weather.forecast where woeid='+this.place.woeid,
format: "json"
})
).then(function(response) {
return response.json();
}).then(function(data) {
console.log("forecast data", data);
const forecast = data.query.results.channel.item.forecast;
return forecast;
});
}
},
toClassName: function(text) {
return text.toLowerCase().replace(/ /g, "-");
}
});
const vm = new WeatherViewModel();
const template = can.stache.from("app-template");
const fragment = template( vm );
document.body.appendChild(fragment);
Hide the forecast if the user changes the entered location
The problem
Currently, if the user changes the entered location, the weather forecast for the other city is still visible. Let’s hide it!
Things to know
DefineMapsetter's can be used to add behavior when a property is set like:const MessageViewModel = can.DefineMap.extend({ message: { type: "string", set: function() { this.metaData = null; } }, metaData: "any", });
The solution
Update the JavaScript tab to set the place property to null when the location changes.
const yqlURL = "//query.yahooapis.com/v1/public/yql?";
const WeatherViewModel = can.DefineMap.extend({
location: {
type: "string",
set: function() {
this.place = null;
}
},
get placesPromise() {
if (this.location && this.location.length > 2) {
return fetch(
yqlURL+
can.param({
q: 'select * from geo.places where text="'+this.location+'"',
format: "json"
})
).then(function(response) {
return response.json();
}).then(function(data) {
console.log(data);
if (Array.isArray(data.query.results.place)) {
return data.query.results.place;
} else {
return [data.query.results.place];
}
});
}
},
place: "any",
pickPlace: function(place) {
this.place = place;
},
get forecastPromise() {
if ( this.place ) {
return fetch(
yqlURL+
can.param({
q: 'select * from weather.forecast where woeid='+this.place.woeid,
format: "json"
})
).then(function(response) {
return response.json();
}).then(function(data) {
console.log("forecast data", data);
const forecast = data.query.results.channel.item.forecast;
return forecast;
});
}
},
toClassName: function(text) {
return text.toLowerCase().replace(/ /g, "-");
}
});
const vm = new WeatherViewModel();
const template = can.stache.from("app-template");
const fragment = template( vm );
document.body.appendChild(fragment);
Skip selecting a place if only one place matches the entered location
The problem
If a single place is returned for the entered location, we can skip asking the user to select their place; instead, we should show the forecast immediately.
Things to know
can.DefineMapgetters are passed their last set value. This way, the property can be derived from either the set value or other properties.const MessageVM = can.DefineMap.extend({ username: "string", message: { get: function(lastSet) { if (lastSet) { return lastSet; } else { return "Hello "+this.username; } } } }); const messageVM = new MessageVM({username: "Hank"}); messageVM.message //-> "Hello Hank"; messageVM.message = "Welcome to Earth"; messageVM.message //-> "Welcome to Earth"Use asynchronous getters to derive data from asynchronous sources. For example:
const MessageVM = can.DefineMap.extend({ messageId: "string", message: { get: function(lastSet, resolve) { fetch("/message/"+this.messageId) .then(function(response) { return response.json(); }).then(resolve); } } });
The solution
Update the template in the HTML tab to use a showPlacePicker property to determine if we should show the place picker list.
<script id="app-template" type="text/stache">
<div class="weather-widget">
<div class="location-entry">
<label for="location">Enter Your location:</label>
<input id="location" value:to="location" type="text" />
</div>
{{#if(placesPromise.isPending)}}
<p class="loading-message">
Loading places…
</p>
{{/if}}
{{#if(placesPromise.isResolved)}}
{{#if(showPlacePicker)}}
<div class="location-options">
<label>Pick your place:</label>
<ul>
{{#each(placesPromise.value)}}
<li on:click="../pickPlace(this)">
{{name}}, {{admin1.content}}, {{country.code}} ({{placeTypeName.content}})
</li>
{{/each}}
</ul>
</div>
{{/if}}
{{/if}}
{{#if(place)}}
<div class="forecast">
<h1>10-day {{place.name}} Weather Forecast</h1>
<ul>
{{#each(forecastPromise.value)}}
<li>
<span class="date">{{date}}</span>
<span class="description {{toClassName(text)}}">{{text}}</span>
<span class="high-temp">{{high}}<sup>°</sup></span>
<span class="low-temp">{{low}}<sup>°</sup></span>
</li>
{{/each}}
</ul>
</div>
{{/if}}
</div>
</script>
Update the JavaScript tab to:
- Define a
placesproperty that will have the places list returned by theYQLservice. - Define a
showPlacePickerproperty that is true if there’s more than one place inplacesand theplaceproperty hasn’t been set yet. - Update the
placeproperty to default to the first item inplacesif there is only one item.
const yqlURL = "//query.yahooapis.com/v1/public/yql?";
const WeatherViewModel = can.DefineMap.extend({
location: {
type: "string",
set: function() {
this.place = null;
}
},
get placesPromise() {
if (this.location && this.location.length > 2) {
return fetch(
yqlURL+
can.param({
q: 'select * from geo.places where text="'+this.location+'"',
format: "json"
})
).then(function(response) {
return response.json();
}).then(function(data) {
console.log(data);
if (Array.isArray(data.query.results.place)) {
return data.query.results.place;
} else {
return [data.query.results.place];
}
});
}
},
places: {
get: function(lastSet, resolve) {
if (this.placesPromise) {
this.placesPromise.then(resolve)
}
}
},
get showPlacePicker() {
return !this.place && this.places && this.places.length > 1;
},
place: {
type: "any",
get: function(lastSet) {
if (lastSet) {
return lastSet;
} else {
if (this.places && this.places.length === 1) {
return this.places[0];
}
}
}
},
pickPlace: function(place) {
this.place = place;
},
get forecastPromise() {
if ( this.place ) {
return fetch(
yqlURL+
can.param({
q: 'select * from weather.forecast where woeid='+this.place.woeid,
format: "json"
})
).then(function(response) {
return response.json();
}).then(function(data) {
console.log("forecast data", data);
const forecast = data.query.results.channel.item.forecast;
return forecast;
});
}
},
toClassName: function(text) {
return text.toLowerCase().replace(/ /g, "-");
}
});
const vm = new WeatherViewModel();
const template = can.stache.from("app-template");
const fragment = template( vm );
document.body.appendChild(fragment);
Result
When finished, you should see something like the following JS Bin: