AngularJS frontend
Tip | This guide is not a proper introduction to AngularJS (see the official tutorial instead), we assume some familiarity with the framework from the reader. |
Application view
The interface fits in a single HTML file located at src/main/resources/webroot/index.html
. The head
section is:
<html lang="en" ng-app="wikiApp"> (1)
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Wiki Angular App</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css"
integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.css">
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.7.5/angular.min.js"></script>
<script src="https://cdn.jsdelivr.net/lodash/4.17.4/lodash.min.js"></script>
<script src="/app/wiki.js"></script> (2)
<style>
body {
padding-top: 2rem;
padding-bottom: 2rem;
}
</style>
</head>
<body>
The AngularJS module is named
wikiApp
.wiki.js
holds the code for our AngularJS module and controller.
As you can see beyond AngularJS we are using the following dependencies from external CDNs:
Boostrap to style our interface,
Font Awesome to provide icons,
Lodash to help with some functional idioms in our JavaScript code.
Bootstrap requires some further scripts that can be loaded at the end of the document for performance reasons:
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"
integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo"
crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js"
integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49"
crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js"
integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy"
crossorigin="anonymous"></script>
</body>
</html>
Our AngularJS controller is called WikiController
and it is bound to a div
which is also a Bootstrap container:
<div class="container" ng-controller="WikiController">
<!-- (...) -->
The buttons on top of the interface consist of the following elements:
<div class="row">
<div class="col-md-12">
<span class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" id="pageDropdownButton" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<i class="fa fa-file-text" aria-hidden="true"></i> Pages
</button>
<div class="dropdown-menu" aria-labelledby="pageDropdownButton">
<a ng-repeat="page in pages track by page.id" class="dropdown-item" ng-click="load(page.id)" href="#">{{page.name}}</a>
(1)
</div>
</span>
<span>
<button type="button" class="btn btn-secondary" ng-click="reload()"><i class="fa fa-refresh"
aria-hidden="true"></i> Reload</button>
(2)
</span>
<span>
<button type="button" class="btn btn-secondary" ng-click="newPage()"><i class="fa fa-plus-square"
aria-hidden="true"></i> New page</button>
</span>
<span class="float-right">
<button type="button" class="btn btn-secondary" ng-click="delete()" ng-show="pageExists()"><i
class="fa fa-trash"
aria-hidden="true"></i> Delete page</button> (3)
</span>
</div>
<div class="col-md-12"> (4)
<div class="invisible alert" role="alert" id="alertMessage">
{{alertMessage}}
</div>
</div>
</div>
For each wiki page name we generate an element using
ng-repeat
andng-click
to define the controller action (load
) when it is being clicked.The refresh button is bound to the
reload
controller action. All other buttons work the same way.The
ng-show
directive allows us to show or hide the element depending on the controllerpageExists
method value.This
div
is used to display notifications of success or failures.
The Markdown preview and editor elements are the following:
<div class="row">
<div class="col-md-6" id="rendering"></div>
<div class="col-md-6">
<form>
<div class="form-group">
<label for="markdown">Markdown</label>
<textarea id="markdown" class="form-control" rows="25" ng-model="pageMarkdown"></textarea> (1)
</div>
<div class="form-group">
<label for="pageName">Name</label>
<input class="form-control" type="text" value="" id="pageName" ng-model="pageName" ng-disabled="pageExists()">
</div>
<button type="button" class="btn btn-secondary" ng-click="save()"><i class="fa fa-pencil"
aria-hidden="true"></i> Save
</button>
</form>
</div>
</div>
ng-model
binds thetextarea
content to thepageMarkdown
property of the controller.
Application controller
The wiki.js
JavaScript starts with an AngularJS module declaration:
'use strict';
angular.module("wikiApp", [])
.controller("WikiController", ["$scope", "$http", "$timeout", function ($scope, $http, $timeout) {
var DEFAULT_PAGENAME = "Example page";
var DEFAULT_MARKDOWN = "# Example page\n\nSome text _here_.\n";
// (...)
The wikiApp
module has no plugin dependency, and declares a single WikiController
controller. The controller requires dependency injection of the following objects:
$scope
to provide DOM scoping to the controller, and$http
to perform asynchronous HTTP requests to the backend, and$timeout
to trigger actions after a given delay while staying tied to the AngularJS life-cycle (e.g., to ensure that any state modification triggers view changes, which is not the case when using the classic setTimeout function).
Controller methods are being tied to the $scope
object. Let us start with 3 simple methods:
$scope.newPage = function() {
$scope.pageId = undefined;
$scope.pageName = DEFAULT_PAGENAME;
$scope.pageMarkdown = DEFAULT_MARKDOWN;
};
$scope.reload = function () {
$http.get("/api/pages").then(function (response) {
$scope.pages = response.data.pages;
});
};
$scope.pageExists = function() {
return $scope.pageId !== undefined;
};
Creating a new page consists in initializing controller properties that are attached to the $scope
object. Reloading the pages objects from the backend is a matter of performing a HTTP GET request (note that the $http
request methods return promises). The pageExists
method is being used to show / hide elements in the interface.
Loading the content of the page is also a matter of performing a HTTP GET request, and updating the preview a DOM manipulation:
$scope.load = function (id) {
$http.get("/api/pages/" + id).then(function(response) {
var page = response.data.page;
$scope.pageId = page.id;
$scope.pageName = page.name;
$scope.pageMarkdown = page.markdown;
$scope.updateRendering(page.html);
});
};
$scope.updateRendering = function(html) {
document.getElementById("rendering").innerHTML = html;
};
The next methods support saving / updating and deleting pages. For these operations we used the full then
promise method with the first argument being called on success, and the second one being called on error. We also introduce the success
and error
helper methods to display notifications (3 seconds on success, 5 seconds on error):
$scope.save = function() {
var payload;
if ($scope.pageId === undefined) {
payload = {
"name": $scope.pageName,
"markdown": $scope.pageMarkdown
};
$http.post("/api/pages", payload).then(function(ok) {
$scope.reload();
$scope.success("Page created");
var guessMaxId = _.maxBy($scope.pages, function(page) { return page.id; });
$scope.load(guessMaxId.id || 0);
}, function(err) {
$scope.error(err.data.error);
});
} else {
var payload = {
"markdown": $scope.pageMarkdown
};
$http.put("/api/pages/" + $scope.pageId, payload).then(function(ok) {
$scope.success("Page saved");
}, function(err) {
$scope.error(err.data.error);
});
}
};
$scope.delete = function() {
$http.delete("/api/pages/" + $scope.pageId).then(function(ok) {
$scope.reload();
$scope.newPage();
$scope.success("Page deleted");
}, function(err) {
$scope.error(err.data.error);
});
};
$scope.success = function(message) {
$scope.alertMessage = message;
var alert = document.getElementById("alertMessage");
alert.classList.add("alert-success");
alert.classList.remove("invisible");
$timeout(function() {
alert.classList.add("invisible");
alert.classList.remove("alert-success");
}, 3000);
};
$scope.error = function(message) {
$scope.alertMessage = message;
var alert = document.getElementById("alertMessage");
alert.classList.add("alert-danger");
alert.classList.remove("invisible");
$timeout(function() {
alert.classList.add("invisible");
alert.classList.remove("alert-danger");
}, 5000);
};
initializing the application state and views is done by fetching the pages list, and starting with a blank new page editor:
$scope.reload();
$scope.newPage();
Finally here is how we perform live rendering of Markdown text:
var markdownRenderingPromise = null;
$scope.$watch("pageMarkdown", function(text) { (1)
if (markdownRenderingPromise !== null) {
$timeout.cancel(markdownRenderingPromise); (3)
}
markdownRenderingPromise = $timeout(function() {
markdownRenderingPromise = null;
$http.post("/app/markdown", text).then(function(response) { (4)
$scope.updateRendering(response.data);
});
}, 300); (2)
});
$scope.$watch
allows being notified of state changes. Here we monitor changes on thepageMarkdown
property that is bound to the editortextarea
.300 milliseconds is a fine delay to trigger rendering if nothing has changed in the editor.
Timeouts are promise, so if the state has changed we cancel the previous one and create a new one. This is how we delay rendering instead of doing it on every keystroke.
We ask the backend to render the editor text into some HTML, then refresh the preview.