Contents
I quite enjoyed Fred Song's articles here on Code Project:
Angular 7 with .NET Core 2.2 - Global Weather (Part 1)
Angular 7 with .NET Core 2.2 - Global Weather (Part 2)
and they are well worth the read for the step-by-step instructions on how to use Angular 7 with .NET Core. It probably wasn't the author's intention though to inspire an article asking the question, do we really need these frameworks? Having used Angular a bit myself now, as well as Sencha Ext JS and a smattering of other frameworks--but not yet Vue! If you watch the Vue introductory video, they bring up several points about why we have these web frameworks:
- Web pages have become more dynamic and powerful thanks to Javascript.
- We've moved a lot of server-side code into the browser.
- That has left us with thousands of lines of Javascript code connecting to CSS and HTML with no formal organization.
And this is why we have Javascript frameworks:
- Better organization.
- Better maintainability.
- Easier to test.
- Reuse of components -- Javascript, HTML, CSS.
And the selling points of all of these frameworks is that they are "reactive" -- when the data changes, all the places on the page where the data is used is automatically updated.
Fire up notepad (or your favorite HTML editor) and let's start with Part I of the Global Weather article!
Here's what we don't need to do:
- Install Node JS so we get npm
- Install Angular
- Create an ASP.NET Core Web Project in Visual Studio 2017
- Create the weather client with Angular CLI
- Tweak the startup.cs class
- Change the
Configure
method - Touch launchSettings.json
- Work around the Angular / .ASP.NET Core issue by creating a
CurrentDirectoryHelpers
class - Tweak startup.cs again
- Create a weather component
- Add an
import
statement in the router for the component created in the previous step - Register
ReactiveFormsModule
in the app.modules.ts file. - Build reactive form in
ngOnInit()
of weather.component.ts
Having eliminated all the above steps, I'm more than 1/3 of the way through the article where we finally get to creating the HTML for the form.
I actually don't really like forms. So my HTML looks like this:
<div class='frame'>
<div>
<label class='label'>Country:</label>
<select id='countries'></select>
<label class='error' id='countryRequired'>Please select a country.</label>
</div>
<div class='vspacer10'></div>
<div>
<label class='label'>Admin Area:</label>
<select id='adminAreas'></select>
<label class='error' id='areaRequired'>Please select an area.</label>
</div>
<div class='vspacer10'>
<label class='label'>City / Town:</label>
<input id='city'>
<label class='error' id='cityRequired'>Please enter a city.</label>
<label class='error' id='cityNotFound'>City not found!</label>
<label class='error' id='cityMoreThanOne'>More than one city found!</label>
</div>
</div>
This looks a bit different from Fred Song's (let me reiterate, excellent) article, for reasons we'll see later.
- Bootstrap. Especially installing it with npm.
OK, you really want it? Get it from the CDN, the compiled version which apparently needs jQuery and Popper as well. WTF?
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"...></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js"...></script>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" ...>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js" ...></script>
Actually jQuery and Popper are not needed for this simple styling.
Anyways, don't want it, don't need it. It just makes me want to zoom out the browser window because suddenly everything is so BIG.
More things we don't need:
- Angular service
- Angular HttpClient
We're now half way through Song's article and we're ready to get a list of countries.
First, let's set up a poor man's "let's get things rolling" function. I realize this isn't necessarily fully browser compatible (reading about what jQuery does is entertaining on how messed up browsers and their versions are) but this works for me:
<script>
(function initialize() {
initializeElements();
getCountries();
})();
</script>
Now we need some behaviors and helper functions:
function el(id) {
return document.getElementById(id);
}
function initializeElements() {
let elOptions = el(Constants.elAdminAreas).options;
elOptions[0] = new Option(Constants.selectArea, String.empty);
}
async function getCountries() {
let url = appendAppKey(Constants.countriesUrl);
let countryList = await get(url);
showCountries(countryList.json);
}
function appendAppKey(url) {
return `${url}?${Constants.apiKeyName}=${Constants.apiKey}`;
}
function get(api) {
return new Promise(resolve => {
let xhr = new XMLHttpRequest();
xhr.onload = () => {
resolve({json: JSON.parse(xhr.response), status: xhr.status});
};
xhr.onerror = () => {
resolve({json: JSON.parse(String.emptyJSON), status: xhr.status, statusText: xhr.statusText});
if (xhr.statusText != String.empty) {
standardErrorHandler({status: xhr.status, statusText: xhr.statusText});
}
};
xhr.open(Constants.restGET, api, true);
xhr.send();
});
}
function clearDropdown(el) {
el.innerHTML = String.empty;
}
function showCountries(json) {
clearDropdown(el(Constants.elCountries));
let countries = json.map(item => {return {name: item.EnglishName, id: item.ID};})
.sort((a, b) => {return a.name < b.name ? -1 : 1});
let elOptions = el(Constants.elCountries).options;
elOptions[0] = new Option(Constants.selectCountry, String.empty);
countries.map(item => { elOptions[elOptions.length] = new Option(item.name, item.id);});
}
The get
function is really the heart of the whole process. Because the AccuWeather service always returns JSON, we can serialize the string response into a JSON object. The response JSON and status are embedded in their own object for consistency between the "ok" and "error" returns.
We now have a country list:
The default behavior of the dropdown already implements auto-complete: you can start typing and the selection list will start navigating to the first match. There's an implementation at W3Schools.com here which uses a textbox to show you what you're typing along with a separate dropdown list. I decided I'm not actually keen on implementing it because as mentioned, the dropdown already implements auto-complete.
One of the things I discovered with the AccuWeather API is that it has the concept of an "admin area." For the United States, the admin areas are the states. For Australia, the admin areas are the territories and states. For Canada they are provinces. And so forth. A city name is not necessarily unique in a country or even an admin area -- for example, the town "Red Hook" in New York occurs twice. So I was somewhat surprised that Song's article didn't cover handling multiple cities found within a country and admin area. But what it does do is let you query cities even without selecting a country or admin area. We'll look at these options later. Regardless, I wanted to expose the admin area selection, which is why my UI is a bit different.
Once a country is selected, we can get the admin areas. We'll add this function now:
function wireUpEvents() {
el(Constants.elCountries).onchange = onCountrySelected;
el(Constants.elAdminAreas).onchange = onAdminAreaSelected;
el(Constants.elCity).onkeyup = (event) => onKey(event.keyCode, 13, cityEntered);
}
I don't like "Go" buttons, it's extra mouse movement or tab-select with spacebar. The ENTER key will do just fine to actually handle the city. More implementation:
async function onCountrySelected() {
showError(Constants.countryRequired, false);
let id = el(Constants.elCountries).value;
await getAdminAreas(id);
}
async function getAdminAreas(locId) {
let url = appendAppKey(`${Constants.adminAreasUrl}/${locId}`);
let areaList = await get(url);
showAdminAreas(areaList.json);
}
function onAdminAreaSelected() {
showError(Constants.areaRequired, false);
let id = el(Constants.elAdminAreas).value;
}
function showAdminAreas(json) {
clearDropdown(el(Constants.elAdminAreas));
let areas = json.map(item => {return {name: item.EnglishName, id: item.ID};})
.sort((a, b) => {return a.name < b.name ? -1 : 1});
let elOptions = el(Constants.elAdminAreas).options;
elOptions[0] = new Option(Constants.selectArea, String.empty);
areas.map(item => { elOptions[elOptions.length] = new Option(item.name, item.id);});
}
Now, after selecting the country, we get the admin areas:
The initial behavior that I implemented simply displays and error message if more than one city in the country and admin area is found:
The implementation is straight forward but in my opinion violates my "keep the functions small" style guideline:
async function cityEntered() {
let countryId = el(Constants.elCountries).value;
let areaId = el(Constants.elAdminAreas).value;
let city = el(Constants.elCity).value;
let ok = true;
showError(Constants.cityNotFound, false);
showError(Constants.cityMoreThanOne, false);
ok &= showError(Constants.countryRequired, countryId == String.empty);
ok &= showError(Constants.areaRequired, areaId == String.empty);
ok &= showError(Constants.cityRequired, city == String.empty);
if (ok) {
showError(Constants.cityRequired, false);
let cities = await getCities(countryId, areaId, city);
let key = verifyCities(cities.json);
if (key === undefined) {
clearForecast();
} else {
getWeather(key);
}
}
}
function verifyCities(json) {
let key = undefined;
if (json.length == 0) {
showError(Constants.cityNotFound);
} else if (json.length > 1) {
showError(Constants.cityMoreThanOne);
} else {
key = json[0].Key;
}
return key;
}
function getCities(countryId, areaId, city) {
let url = appendAppKey(Constants.citySearchUrl);
url = addParam(url, Constants.queryKey, city);
url = url.replace(Constants.countryId, countryId).replace(Constants.areaId, areaId);
return get(url);
}
function addParam(url, paramName, paramValue) {
let url = `${url}&${paramName}=${paramValue}`;
return url;
}
function onKey(keyCode, matchValue, callback) {
if (keyCode == matchValue) {
callback();
}
}
Notice I use a callback for onKey
-- here a callback is appropriate. It's not an async event, it's a condition that if met results in the callback.
Models! We don't need models of the returned countries, admin areas, or weather data because guess what, the JSON is the model! Why are we mapping the resulting JSON to the model when it's already in JSON? That's called CWDN - Computation We Don't Need. So let's skip down to about 3/4 of the way to the bottom of Song's article and drop the weather data into a container using a template to describe how it looks.
First, let's create a container for the weather data:
<div id='forecastContainer'>
</div>
Next, let's define a template for how the weather data will be formatted. Since we're getting back the forecast for more than one day, each day will be rendered as a table:
<div class='hidden' id='forecastTemplate'>
<table class='vspacer20 fixed-layout'>
<tbody>
<tr>
<td class='colDayOfWeek'>
<label id='dow{n}'></label>
</td>
<td class='colIcon'>
<img id='icon{n}'/>
</td>
<td>
<label id='descr{n}'></label>
</td>
</tr>
<tr>
<td>
<label class='small-font'>High:</label>
<label class='small-font' id='maxTemp{n}'></label>
<br/>
<label class='small-font'>Low:</label>
<label class='small-font' id='minTemp{n}'></label>
</td>
<td>
<label class='small-font'>Wind:</label>
<label class='small-font' id='windSpeed{n}'></label>
<label class='small-font' id='windDir{n}'></label>
<br/>
<label class='small-font'>Gusts:</label>
<label class='small-font' id='gustSpeed{n}'></label>
<label class='small-font' id='gustDir{n}'></label>
</td>
<td>
<label class='small-font'>Rain:</label>
<label class='small-font' id='rain{n}'></label>
<br/>
<label class='small-font'>Snow:</label>
<label class='small-font' id='snow{n}'></label>
<br/>
<label class='small-font'>Ice:</label>
<label class='small-font' id='ice{n}'></label>
</td>
</tr>
</tbody>
</table>
</div>
Now we can manipulate the document model and create elements unique by ID using the template for each forecast day:
async function getWeather(key) {
let url = appendAppKey(`${Constants.weatherFiveDayForecastUrl}/${key}`);
url = addParam(url, Constants.details, String.true);
let forecast = await get(url);
let dailyForecasts = forecast.json.DailyForecasts;
clearForecast();
dailyForecasts.forEach((forecast, idx) => { showForecast(forecast, idx); });
}
function showForecast(forecast, idx) {
let date = forecast.Date;
let dayOfWeek = new Date(date).toLocaleString('en-us', { weekday: 'long' });
let minTemp = forecast.Temperature.Minimum.Value + ' ' + forecast.Temperature.Minimum.Unit;
let maxTemp = forecast.Temperature.Maximum.Value + ' ' + forecast.Temperature.Maximum.Unit;
let icon = forecast.Day.Icon;
let descr = forecast.Day.LongPhrase;
let windSpeed = forecast.Day.Wind.Speed.Value + ' ' + forecast.Day.Wind.Speed.Unit;
let windDirection = forecast.Day.Wind.Direction.Localized;
let windGust = forecast.Day.WindGust.Speed.Value + ' ' + forecast.Day.WindGust.Speed.Unit;
let windGustDirection = forecast.Day.WindGust.Direction.Localized;
let rain = forecast.Day.Rain.Value + ' ' + forecast.Day.Rain.Unit;
let snow = forecast.Day.Snow.Value + ' ' + forecast.Day.Snow.Unit;
let ice = forecast.Day.Ice.Value + ' ' + forecast.Day.Ice.Unit;
let formattedIcon = parseInt(icon).toLocaleString(Constants.enUS, {minimumIntegerDigits: 2, useGrouping:false});
let iconUrl = `${Constants.iconUrl}${formattedIcon}-s.png`;
let elForecastTemplate = el(Constants.elForecastTemplate);
let elForecastContainer = el(Constants.elForecastContainer);
let elForecastHtml = String.replaceAll(elForecastTemplate.innerHTML, Constants.indexer, idx);
elForecastContainer.innerHTML += elForecastHtml;
el(`${Constants.dow}${idx}`).innerHTML = dayOfWeek;
el(`${Constants.descr}${idx}`).innerHTML = descr;
el(`${Constants.icon}${idx}`).src = iconUrl;
el(`${Constants.minTemp}${idx}`).innerHTML = minTemp;
el(`${Constants.maxTemp}${idx}`).innerHTML = maxTemp;
el(`${Constants.windSpeed}${idx}`).innerHTML = windSpeed;
el(`${Constants.windDir}${idx}`).innerHTML = windDirection;
el(`${Constants.gustSpeed}${idx}`).innerHTML = windGust;
el(`${Constants.gustDir}${idx}`).innerHTML = windGustDirection;
el(`${Constants.rain}${idx}`).innerHTML = rain;
el(`${Constants.snow}${idx}`).innerHTML = snow;
el(`${Constants.ice}${idx}`).innerHTML = ice;
}
And the result is:
That concludes Part I. We have:
- Eliminated Angular
- Eliminated ASP.NET Core
- Improved functionality
Also, we've created an application that doesn't require a server -- it can be loaded as file in Chrome!
Song's second article is entirely devoted to saving and restoring the user's selection in the back end. Again, this is an excellent introduction in how ASP.NET Core works with Angular. I have no complaints! But for our purposes, we can use HTML5's LocalStorage
feature. I would have used cookies, but Chrome doesn't use cookies for pages rendered by the file system. A thin wrapper suffices (I like to wrap things):
class LocalStore {
static put(name, value) {
window.localStorage.setItem(name, value);
}
static get (name) {
return window.localStorage.getItem(name);
}
}
The async usage in the functions above becomes clearer when we look at how the user's settings are restored. The initialization of the page becomes:
(function initialize() {
initializeElements();
wireUpEvents();
getCountries().then(usePreviousSettings);
})();
function usePreviousSettings() {
let countryId = LocalStore.get(Constants.cookieCountry);
let areaId = LocalStore.get(Constants.cookieArea);
let city = LocalStore.get(Constants.cookieCity);
if (!String.isNullOrEmpty(city)) {
el(Constants.elCity).value = city;
}
if (!String.isNullOrEmpty(countryId)) {
el(Constants.elCountries).value = countryId;
onCountrySelected().then(() =>
{
if (!String.isNullOrEmpty(areaId)) {
el(Constants.elAdminAreas).value = areaId;
if (!String.isNullOrEmpty(city)) {
cityEntered();
}
}
});
}
}
Then we just need to save the values as they are selected and the city is entered:
async function onCountrySelected() {
showError(Constants.countryRequired, false);
let id = el(Constants.elCountries).value;
LocalStore.put(Constants.cookieCountry, id); <--- This is new
await getAdminAreas(id);
}
function onAdminAreaSelected() {
showError(Constants.areaRequired, false);
let id = el(Constants.elAdminAreas).value;
LocalStore.put(Constants.cookieArea, id); <--- This is new
}
and in the cityEntered
function:
...
let key = verifyCities(cities.json);
if (key === undefined) {
LocalStore.put(Constants.cookieCityKey, String.empty);
clearForecast();
} else {
LocalStore.put(Constants.cookieCityKey, key);
getWeather(key);
}
In the case when there is more than one matching city in the country or the country and admin area, we present a list of cities from which the user must choose:
In the example in Song's article, there is only one Melbourne in all of Australia, so the user can omit selecting the admin area:
Conversely, if there is more than one city of that name in the selected country, the "select the city" dropdown appears so the user can narrow down the search:
Whenever a new country or admin area is selected, we want to clear the relevant sections and error notices. The city key is also reset in the store because no city has been selected, and refreshing the browser should not display any new forecast. This is handled by the reset
function:
function reset(state) {
switch (state) {
case Constants.stateCountrySelected:
LocalStore.put(Constants.cookieArea, String.empty);
LocalStore.put(Constants.cookieCity, String.empty);
el(Constants.elCity).value = String.empty;
break;
case Constants.stateAdminAreaSelected:
LocalStore.put(Constants.cookieCity, String.empty);
el(Constants.elCity).value = String.empty;
break;
case Constants.stateCityEntered:
break;
}
showError(Constants.countryRequired, false);
showError(Constants.areaRequired, false);
showError(Constants.cityNotFound, false);
showError(Constants.cityMoreThanOne, false);
showError(Constants.cityRequired, false);
el(Constants.elSelectCity).style.visibility = Constants.hidden;
el(Constants.elForecastContainer).innerHTML = String.empty;
LocalStore.put(Constants.cookieCityKey, String.empty);
}
OK, I can't help myself -- it should be obvious that the above code is not reusable. Furthermore, there's no separation of the view and controller, the very thing that the Vue tutorial video points out as being an issue when what used to be server-side code is moved to the client. So how do we make all this reusable in a simple way? As an aside, one thing I loathe in many of these web frameworks is the use of strings to reference "objects" like views and controllers. ExtJS is definitely a sinner in this regard, for example:
var someController = this.getController('someController');
or from the Angular documentation:
myApp.controller('GreetingController' ...
In my no-framework code, the only two pieces that need to be modularized for re-use is the view and the controller. The HTML part of the view must be treated like a template and we need two classes: one for the view behavior and one for the controller logic. To avoid string names, we create actual instances in which we pass the controller to the view, and the view to the controller. While this creates a 1:1 relationship between views and controllers, I don't have a problem with that as typically I find that a view talks to one controller, and the controller might have additional controllers that it references. And yes, this approach (not using strings to reference views, models, controllers, stores, etc) is probably easily accomplished with existing web frameworks, but it requires discipline. The separation we want to achieve is:
- The view handles its state -- the HTML document object model
- The controller responds to view events, manipulates the data, and gives the view the data to render.
Of course, creating classes as containers for the various functions now means we have to this
here, this
there, this
everywhere. Blech.
One immediate difference is that the el
helper function (to avoid lots of typing) belongs in the view. In fact, it belongs in the base class of any view:
class ViewBase {
constructor() {
}
el(id) {
return document.getElementById(id);
}
}
To demonstrate reuse, we'll refactor the code so that the forecast of two cities can be viewed side by side at the same time for comparison. A proof of concept is achieved by:
- Refactoring the country/area/city selection into a template (uh, that really means hiding it and changing the ID).
- Defining where the templates go.
Refactoring the city selection into a template:
<div class='hidden' id='citySelectionTemplate'>
<div>
<label class='label'>Country:</label>
<select id='countries'></select>
<label class='error' id='countryRequired'>Please select a country.</label>
</div>
... etc ...
Creating a "view" of the side-by-side forecast:
<div class='frame' id='app'>
<div class="inline">
<div id="city1"></div>
<div id="forecast1"></div>
</div>
<div class="inline">
<div id="city2"></div>
<div id="forecast2"></div>
</div>
</div>
Initializing an app controller on page load:
(function initialize() {
let appView = new AppView();
let appController = new AppController(appView);
appController.initialize();
})();
and assigning the templates their designated areas on the page:
class AppController extends ControllerBase {
constructor(appView) {
super(appView);
}
initialize() {
this.view.addTemplate(Constants.elCitySelection1, Constants.elCitySelectionTemplate);
this.view.addTemplate(Constants.elCitySelection2, Constants.elCitySelectionTemplate);
}
}
And the magic addTemplate
function, which is in the base class of view:
addTemplate(elName, templateElementName) {
let templateHtml = this.el(templateElementName).innerHTML;
this.el(elName).innerHTML = templateHtml;
}
Resulting in:
Great, we have "proved" the concept.
Now, the annoying thing is of course that the ID's are no longer unique. So we need a disciplined approach to how we want to handle this. The should-be-obvious solution is to prepend the ID with a template name, and let's be cute about it and use <T>
a la C# templates, haha, like this:
<div class='hidden' ; id='citySelectionTemplate'>
<div>
<label class='label'>Country:</label>
<select id='<T>countries'></select>
<label class='error' id='<T>countryRequired'>Please select a country.</label>
</div>
... etc ...
We do this everywhere, so for example, the weather forecast, which is itself a template within the template, includes both the template <T>
token and the {n}
indexer for each day. Example:
<label class='small-font' id='<T>maxTemp{n}'></label>
Now we can create our views, passing in the template name and adding the desired template to the desired area of our app view:
static get t1() { return 't1'; }
static get t2() { return 't2'; }
initialize() {
this.citySelectionView1 = new CitySelectionView(AppController.t1);
this.citySelectionView2 = new CitySelectionView(AppController.t2);
this.citySelectionView1.addTemplate(Constants.elCitySelection1, Constants.elCitySelectionTemplate);
this.citySelectionView2.addTemplate(Constants.elCitySelection2, Constants.elCitySelectionTemplate);
}
Refactor the addTemplate
code so it knows to also handle the <T>
token:
addTemplate(elName, templateElementName) {
let templateHtml = this.el(templateElementName).innerHTML;
templateHtml = String.replaceAll(templateHtml, '<T>', this.templateName);
this.el(elName).innerHTML = templateHtml;
}
and we get unique ID's:
Note that this results in predictable ID naming which is critical for automated testing.
We also create a templated tel
helper to prepend the template name:
tel(id) {
return document.getElementById(this.templateName + id);
}
This is base class for all views, providing useful view-generic functions. Most importantly, it maintains the template name:
class ViewBase {
constructor(tname = '') {
this.templateName = tname;
}
... etc ...
This is a base class for all controllers, and it's sole purpose in life is to maintain the optional primary view associated with the controller.
class ControllerBase {
constructor(view = null) {
this.view = view;
}
}
An application controller is useful though it really does nothing more than initialization. It doesn't have an associated view, though it could if we had, say, common areas to all pages, like a menu. The application controller provides an important role here in setting up how the weather page will look -- in this case, setting up two controllers with their city selection and forecast views:
class AppController extends ControllerBase {
constructor() {
super();
}
static get t1() { return 't1'; }
static get t2() { return 't2'; }
initialize() {
this.initializeViews();
this.initializeControllers();
this.citySelectionView1.initializeElements();
this.citySelectionView2.initializeElements();
this.weatherController1.finishInitialization();
this.weatherController2.finishInitialization();
}
initializeViews() {
this.citySelectionView1 = new CitySelectionView(AppController.t1);
this.citySelectionView2 = new CitySelectionView(AppController.t2);
this.citySelectionView1.addTemplate(Constants.elCitySelection1, Constants.elCitySelectionTemplate);
this.citySelectionView2.addTemplate(Constants.elCitySelection2, Constants.elCitySelectionTemplate);
this.forecastView1 = new ForecastView(AppController.t1);
this.forecastView2 = new ForecastView(AppController.t2);
}
initializeControllers() {
this.weatherController1 = new WeatherController(AppController.t1, this.citySelectionView1, this.forecastView1);
this.weatherController2 = new WeatherController(AppController.t2, this.citySelectionView2, this.forecastView2);
}
}
Because the weather controller is working with two views -- the city selection view and the forecast view -- we don't really have a primary view, so the view in the ControllerBase
is not used:
class WeatherController extends ControllerBase {
constructor(tname, citySelectionView, forecastView) {
super();
this.templateName = tname;
this.citySelectionView = citySelectionView;
this.forecastView = forecastView;
this.wireUpEvents();
}
... etc ...
Notice that the controller also gets the template name as a parameter in the constructor. We do this so that each of the two sections can have their own unique store names, which, for example, is used when getting and setting values in the local store:
LocalStore.put(this.templateName + Constants.cookieCountry, id);
The controller knows what it wants to be notified about and what data it needs to get from the view and set to the view. But we want to be disciplined about this. The view should be responsible for how this happens. The controller should not know about the view implementation--in other words, the controller should not know about the view's DOM. Instead, the view implements methods for wiring up events and getting and setting the data as determined by the actual controls being rendered by the HTML which, amusingly, is completely disconnected from anything about the view.
Wiring up events is handled in the view base class as they are generic to any view:
onKeyUp(id, keyVal, fnc) {
let el = this.tel(id);
el.onkeyup = (event) => {
this.onKey(event.keyCode, keyVal, () => fnc(this, el.value));
}
}
onChange(id, fnc) {
let el = this.tel(id);
el.onchange = () => fnc(this, el.value);
}
onKey(keyCode, matchValue, callback) {
if (keyCode == matchValue) {
callback();
}
}
Getting and setting data is handled in the CitySelectionView
:
clearCitySelection() {
this.tel(Constants.elSelectCity).style.visibility = Constants.hidden;
}
getSelectedCountry() {
return this.tel(Constants.elCountries).value;
}
getSelectedAdminArea() {
return this.tel(Constants.elAdminAreas).value;
}
setCity(cityName) {
this.tel(Constants.elCity).value = cityName;
}
selectCountry(countryId) {
this.tel(Constants.elCountries).value = countryId;
}
selectAdminArea(adminAreaId) {
this.tel(Constants.elAdminAreas).value = adminAreaId;
}
Javascript doesn't (yet) have a mechanism for declaring a field or a function to be private, so it requires discipline to not access the very public functions of a view. If the controller were to directly manipulate the DOM, then we lose maintainability -- let's say the view wants to use an edit box instead of a dropdown for the country name -- The controller shouldn't know that the view is doing this. This is why even the "show" methods are part of the view (note how the tel
function is always used to decode the template name):
showCountries(json) {
this.clearDropdown(Constants.elCountries);
let countries = json.map(item => { return { name: item.EnglishName, id: item.ID }; })
.sort((a, b) => { return a.name < b.name ? -1 : 1 });
let elOptions = this.tel(Constants.elCountries).options;
elOptions[0] = new Option(Constants.selectCountry, String.empty);
countries.map(item => { elOptions[elOptions.length] = new Option(item.name, item.id); });
}
We also have a separate forecast view. Notice again how it uses tel to decode the template name for it's DOM:
[snipped]
this.tel(`${Constants.dow}${idx}`).innerHTML = dayOfWeek;
this.tel(`${Constants.descr}${idx}`).innerHTML = descr;
this.tel(`${Constants.icon}${idx}`).src = iconUrl;
this.tel(`${Constants.minTemp}${idx}`).innerHTML = minTemp;
this.tel(`${Constants.maxTemp}${idx}`).innerHTML = maxTemp;
this.tel(`${Constants.windSpeed}${idx}`).innerHTML = windSpeed;
this.tel(`${Constants.windDir}${idx}`).innerHTML = windDirection;
this.tel(`${Constants.gustSpeed}${idx}`).innerHTML = windGust;
this.tel(`${Constants.gustDir}${idx}`).innerHTML = windGustDirection;
this.tel(`${Constants.rain}${idx}`).innerHTML = rain;
this.tel(`${Constants.snow}${idx}`).innerHTML = snow;
this.tel(`${Constants.ice}${idx}`).innerHTML = ice;
At the end of this refactoring, we have:
- A view for the city selection.
- A view for the forecast.
- An application controller for initialization.
- A weather controller for handling city selection and getting the weather.
What's completely missing here is a model:
- We don't need a model of the AccuWeather JSON structure. At best, such a model might implement methods to format the data, such as how the controller is combining wind speed and direction, or temperature and temperature unit.
- We don't need a view-model. OK, this could be useful in creating getter/setter methods for the data that the controller needs to know about, complete with "change" triggers to update the controller when the view element changes and update the view element when the controller wants to change view data.
In both cases, these add unnecessary complexity for such a simple page.
What stood out for me the most in writing this code was that, since I wasn't dealing with the complexities of the Angular framework, nor in Part 2 creating a backing database, endpoints, etc., I was instead able to pay more attention to the edge cases of the AccuWeather API, particularly the issues around multiple matching cities. I also spent time adding some friendly behaviors to the UI, such as clearing the selections and the weather when the user changes a selection. In other words, I spent more time on the actual user experience (UX) and considerably less time on the project setup, framework, and coding issues. I think there's a lesson to be learned here -- these frameworks have a geeky kind of allure to them, but how much of your time is spent learning, coding, and debugging the nuances of the framework vs. achieving meaningful user experience behaviors? How often do we overlook a simple solution and instead implement something more complicated that ends up having its own edge cases and often enough complicates the UX? Personally, I like to choose the UX every time over the "ooh, look, I used framework X."
It takes discipline, not frameworks, to achieve the above 4 goals, and in my experience, that discipline is almost always lacking. Instead, I see:
- Organization foisted on the developer by what the framework thinks is the best way to organize the web page files. This is a really only a minor quibble, I don't have a problem with a framework by default organizing the files into the typical MVC - model, view, controller -- (or similar) architecture. What I do argue with is that an MVC architecture and its ilk are often unnecessarily burdensome.
- Maintainability is quite dubious:
- A web app becomes locked in to a particular version and its bugs, and upgrading a large application is prohibitively costly. The result is that the developers end up maintaining an application that uses a framework that becomes years outdated, documentation becomes harder to find, and improvements in Javascript can't be used because the "compiler" of the obsolete framework doesn't handle things like HTML5 syntax or Javascript fat arrow
=>
functions. - New third party components that you might want to take advantage probably require the latest (or near latest) version of whatever framework is being used by the web app. Your only recourse is to use the component without the support of the framework and wire things up "the old way."
- Testing -- let's be real here.
- Testing is done by exercising the web page to make sure it's doing the right thing.
- Automating those tests is and will continue to be painful, particularly when dealing with asynchronous server-side calls that have unpredictable response times and data sets.
- And the real nail in the coffin to automated testing (particularly tools that use element ID's to get/set values) is that these frameworks can mangle the HTML, creating internal ID values and HTML structures that make it all but impossible to find the element to which the test script needs access.
- Reuse -- I've only ever seen limited reuse. Headers and footers are the most common, sometimes there are elements in certain pages that are reused in other pages.
This is the mangled ID that ExtJS created for the input box in a "dialog" form:
<input id="combo-2365-inputEl"
Close the dialog and re-open it, and I see:
<input id="combo-2365-inputEl"
Notice the number changed! How can I write an automated test script when the element's ID is 1) arbitrary and 2) has no relationship to the "id" that I gave it in the definition of the form?
You can't just start with discipline. I'm a firm believer that it takes experience (sometimes years of experience) in doing things the "wrong" way to discover the "better" way and incorporate those lessons into a discipline. With web frameworks and third party components popping up like weeds, not to mention the so-called benefits of rapid delivery, when does a developer have time to become a master in these technologies? When does the developer have time to gain enough experience with something to apply some discipline? We are, in my opinion, doing a disservice to ourselves by this rapid change, and again in my opinion, open source is largely to blame for this.
I see this all the time:
- I have some HTML where 90% of it can be reused but I have to make small tweaks for the specific page. Solution? Cut & paste, add a new view, and make the 10% changes.
- I have a Javascript function where 90% of it can be reused. Solution? Cut & paste, make the changes, add the function with a different name in the same controller or create a new controller.
Instead of refactoring the HTML to be more dynamic (that was one of the points of these frameworks, right?) or refactor the calls to the function to be more general purpose, it's easier, faster, and gets the change delivered quicker simply by copy & pasting. There's no discipline in that. Do that enough times, and your single page app, which loads everything up front, becomes slower and slower, even with minimization and content compressing.
In my opinion, the overhead of sheer typing is outrageous. We:
- Start with the database schema
- We recreate an entity model in the back-end
- We recreate the model in the front-end.
- We create a view that maps fields to the model in the front-end.
While all of these steps can be avoided by a tool that generates each and every one of those classes, files, JSON objects, and templates:
- It usually creates unnecessary script that needs to be sent down to the browser, slowing down the website.
- It often creates unnecessary serialization and deserialization as the data is sent down to the browser, again slowing down the website.
- At some point (usually rather quickly), the tool becomes useless (unless it is very very good) because one or more of the models is customized with computations and/or formatters. So now when the real model (the DB schema) changes, you are required to manual update all the intermediary models.
To reiterate, good organization, maintainability, ability to automate testing, and reuse are not features that I've ever seen a framework actually help me with. If I don't have the discipline to write Javascript with the above 4 goals in mind, the framework will not help and in fact often provides a hinderance. Personally, when I develop websites, my solution is to:
- Not use any framework at all.
- Use third party components sparingly as third party components impact maintainability and ease of testing as well.
- Avoid unnecessary models particularly when all I need is to serialize some records into JSON, deliver that to the browser and render it in the view.
- Going the other direction holds as well -- either the entire record is sent up as JSON, deserialized into parameter-value pairs and inserted into the table, or specific fields are updated either as they change or when the user clicks on "Update." Different endpoints for different purposes.
I also try to adhere to my own style guidelines. These are my own guidelines and I'm just telling you what they are, not trying to foist them on you.
- I don't want to see any "red" in my Javascript. The color red is typically used by editors to indicate string constants.
Wrong:
<img border="0" height="24" src="1278694/ex1.png" width="322" />
Right:
Or similar, the idea is to use a class with static getters for constants. There are exceptions to this rule, of course, but they are conscious exceptions.
- Callbacks are archaic -- use async/await
The typical approach is to use a callback for a completion, either an asynchronous operation or a function's "I've done what I was supposed to do, now what?"
Wrong:
function someFunction() {
getMyData(url, processMyData);
}
function processMyData(data) {
}
Right:
async function getMyData() {
}
function someFunction() {
let data = await getMyData();
processMyData(data);
}
or:
function someFunction() {
getMyData().then((data) => processMyData(data));
}
- Use fat arrows instead of
function
Wrong:
function someFunction() {
getMyData().then(function (data) { processMyData(data);} );
}
Right:
function someFunction() {
getMyData().then((data) => processMyData(data));
}
This is a really minor one and even in my mind, questionable still, haha.
Wrong:
return url + Constants.apiKeyName + '=' + Constants.apiKey;
Right:
return `${url}?${Constants.apiKeyName}=${Constants.apiKey}`;
- Don't Repeat Yourself (DRY)
Don't really need to repeat why this is important, right?
- High level functions should minimize flow control statements and look like a linear workflow as much as possible.
If there's flow control statements (like if-else and loops) then refactor the function into separate smaller functions.
- Functions should be small!
Shouldn't need to explain that one. Even if it's a one line function:
Wrong:
let newStr = str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
Seriously? WTF does this do?
Right:
static escapeRegExp(str) {
return str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1");
}
Oh, this escapes characters that would normally be interpreted in a regex.
- Use
map
, reduce
, and filter
(MRF) instead of for loops
Personally, I think for loops are archaic, particularly since MRF avoids writing lots of "if" statements, creating temporary holding arrays and pushing matching items into those arrays.
Nothing makes debugging more annoying than inlining computations.
Wrong:
function addParam(url, paramName, paramValue) {
return `${url}&${paramName}=${paramValue}`;
}
Right:
function addParam(url, paramName, paramValue) {
newUrl = `${url}&${paramName}=${paramValue}`;
return newUrl;
}
And that was a simple example.
Granted, not being able to put frameworks X, Y, and Z on your resume results in two things:
- Your ability to land a job is greatly reduced.
- If you do land a job, you'll be pulling your hair out wondering why things are so complicated though that might happen anyways.
So, at the end of the day, I suggest that unless you are entirely 100% in control of your financial destiny, you ignore this article, suppress your WTF reactions to the code base you'll be working on, take a deep breath and plunge into the world of web frameworks. Who knows, you might find a team where discipline actually is important and the framework becomes a useful tool in a well designed web app vs. the constant "how do I do this?" googling that I, for one, experience working in a "modern" framework. (Vue excluded since I have no experience with them.)
In other words, be part of the crowd, not separate from the crowd. Bury your head in the sand. Or if you prefer, join the dark side.