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:

  1. <html lang="en" ng-app="wikiApp"> (1)
  2. <head>
  3. <meta charset="UTF-8">
  4. <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  5. <title>Wiki Angular App</title>
  6. <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css"
  7. integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous">
  8. <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.css">
  9. <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.7.5/angular.min.js"></script>
  10. <script src="https://cdn.jsdelivr.net/lodash/4.17.4/lodash.min.js"></script>
  11. <script src="/app/wiki.js"></script> (2)
  12. <style>
  13. body {
  14. padding-top: 2rem;
  15. padding-bottom: 2rem;
  16. }
  17. </style>
  18. </head>
  19. <body>
  1. The AngularJS module is named wikiApp.

  2. 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:

  1. <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"
  2. integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo"
  3. crossorigin="anonymous"></script>
  4. <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js"
  5. integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49"
  6. crossorigin="anonymous"></script>
  7. <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js"
  8. integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy"
  9. crossorigin="anonymous"></script>
  10. </body>
  11. </html>

Our AngularJS controller is called WikiController and it is bound to a div which is also a Bootstrap container:

  1. <div class="container" ng-controller="WikiController">
  2. <!-- (...) -->

The buttons on top of the interface consist of the following elements:

  1. <div class="row">
  2. <div class="col-md-12">
  3. <span class="dropdown">
  4. <button class="btn btn-secondary dropdown-toggle" type="button" id="pageDropdownButton" data-toggle="dropdown"
  5. aria-haspopup="true" aria-expanded="false">
  6. <i class="fa fa-file-text" aria-hidden="true"></i> Pages
  7. </button>
  8. <div class="dropdown-menu" aria-labelledby="pageDropdownButton">
  9. <a ng-repeat="page in pages track by page.id" class="dropdown-item" ng-click="load(page.id)" href="#">{{page.name}}</a>
  10. (1)
  11. </div>
  12. </span>
  13. <span>
  14. <button type="button" class="btn btn-secondary" ng-click="reload()"><i class="fa fa-refresh"
  15. aria-hidden="true"></i> Reload</button>
  16. (2)
  17. </span>
  18. <span>
  19. <button type="button" class="btn btn-secondary" ng-click="newPage()"><i class="fa fa-plus-square"
  20. aria-hidden="true"></i> New page</button>
  21. </span>
  22. <span class="float-right">
  23. <button type="button" class="btn btn-secondary" ng-click="delete()" ng-show="pageExists()"><i
  24. class="fa fa-trash"
  25. aria-hidden="true"></i> Delete page</button> (3)
  26. </span>
  27. </div>
  28. <div class="col-md-12"> (4)
  29. <div class="invisible alert" role="alert" id="alertMessage">
  30. {{alertMessage}}
  31. </div>
  32. </div>
  33. </div>
  1. For each wiki page name we generate an element using ng-repeat and ng-click to define the controller action (load) when it is being clicked.

  2. The refresh button is bound to the reload controller action. All other buttons work the same way.

  3. The ng-show directive allows us to show or hide the element depending on the controller pageExists method value.

  4. This div is used to display notifications of success or failures.

The Markdown preview and editor elements are the following:

  1. <div class="row">
  2. <div class="col-md-6" id="rendering"></div>
  3. <div class="col-md-6">
  4. <form>
  5. <div class="form-group">
  6. <label for="markdown">Markdown</label>
  7. <textarea id="markdown" class="form-control" rows="25" ng-model="pageMarkdown"></textarea> (1)
  8. </div>
  9. <div class="form-group">
  10. <label for="pageName">Name</label>
  11. <input class="form-control" type="text" value="" id="pageName" ng-model="pageName" ng-disabled="pageExists()">
  12. </div>
  13. <button type="button" class="btn btn-secondary" ng-click="save()"><i class="fa fa-pencil"
  14. aria-hidden="true"></i> Save
  15. </button>
  16. </form>
  17. </div>
  18. </div>
  1. ng-model binds the textarea content to the pageMarkdown property of the controller.

Application controller

The wiki.js JavaScript starts with an AngularJS module declaration:

  1. 'use strict';
  2. angular.module("wikiApp", [])
  3. .controller("WikiController", ["$scope", "$http", "$timeout", function ($scope, $http, $timeout) {
  4. var DEFAULT_PAGENAME = "Example page";
  5. var DEFAULT_MARKDOWN = "# Example page\n\nSome text _here_.\n";
  6. // (...)

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:

  1. $scope.newPage = function() {
  2. $scope.pageId = undefined;
  3. $scope.pageName = DEFAULT_PAGENAME;
  4. $scope.pageMarkdown = DEFAULT_MARKDOWN;
  5. };
  6. $scope.reload = function () {
  7. $http.get("/api/pages").then(function (response) {
  8. $scope.pages = response.data.pages;
  9. });
  10. };
  11. $scope.pageExists = function() {
  12. return $scope.pageId !== undefined;
  13. };

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:

  1. $scope.load = function (id) {
  2. $http.get("/api/pages/" + id).then(function(response) {
  3. var page = response.data.page;
  4. $scope.pageId = page.id;
  5. $scope.pageName = page.name;
  6. $scope.pageMarkdown = page.markdown;
  7. $scope.updateRendering(page.html);
  8. });
  9. };
  10. $scope.updateRendering = function(html) {
  11. document.getElementById("rendering").innerHTML = html;
  12. };

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):

  1. $scope.save = function() {
  2. var payload;
  3. if ($scope.pageId === undefined) {
  4. payload = {
  5. "name": $scope.pageName,
  6. "markdown": $scope.pageMarkdown
  7. };
  8. $http.post("/api/pages", payload).then(function(ok) {
  9. $scope.reload();
  10. $scope.success("Page created");
  11. var guessMaxId = _.maxBy($scope.pages, function(page) { return page.id; });
  12. $scope.load(guessMaxId.id || 0);
  13. }, function(err) {
  14. $scope.error(err.data.error);
  15. });
  16. } else {
  17. var payload = {
  18. "markdown": $scope.pageMarkdown
  19. };
  20. $http.put("/api/pages/" + $scope.pageId, payload).then(function(ok) {
  21. $scope.success("Page saved");
  22. }, function(err) {
  23. $scope.error(err.data.error);
  24. });
  25. }
  26. };
  27. $scope.delete = function() {
  28. $http.delete("/api/pages/" + $scope.pageId).then(function(ok) {
  29. $scope.reload();
  30. $scope.newPage();
  31. $scope.success("Page deleted");
  32. }, function(err) {
  33. $scope.error(err.data.error);
  34. });
  35. };
  36. $scope.success = function(message) {
  37. $scope.alertMessage = message;
  38. var alert = document.getElementById("alertMessage");
  39. alert.classList.add("alert-success");
  40. alert.classList.remove("invisible");
  41. $timeout(function() {
  42. alert.classList.add("invisible");
  43. alert.classList.remove("alert-success");
  44. }, 3000);
  45. };
  46. $scope.error = function(message) {
  47. $scope.alertMessage = message;
  48. var alert = document.getElementById("alertMessage");
  49. alert.classList.add("alert-danger");
  50. alert.classList.remove("invisible");
  51. $timeout(function() {
  52. alert.classList.add("invisible");
  53. alert.classList.remove("alert-danger");
  54. }, 5000);
  55. };

initializing the application state and views is done by fetching the pages list, and starting with a blank new page editor:

  1. $scope.reload();
  2. $scope.newPage();

Finally here is how we perform live rendering of Markdown text:

  1. var markdownRenderingPromise = null;
  2. $scope.$watch("pageMarkdown", function(text) { (1)
  3. if (markdownRenderingPromise !== null) {
  4. $timeout.cancel(markdownRenderingPromise); (3)
  5. }
  6. markdownRenderingPromise = $timeout(function() {
  7. markdownRenderingPromise = null;
  8. $http.post("/app/markdown", text).then(function(response) { (4)
  9. $scope.updateRendering(response.data);
  10. });
  11. }, 300); (2)
  12. });
  1. $scope.$watch allows being notified of state changes. Here we monitor changes on the pageMarkdown property that is bound to the editor textarea.

  2. 300 milliseconds is a fine delay to trigger rendering if nothing has changed in the editor.

  3. 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.

  4. We ask the backend to render the editor text into some HTML, then refresh the preview.