Moin moin und hallo,
in diesem Teil will ich mich ein bisschen mehr um das Frontend für den Moderator kümmern. Dazu entwerfe ich ein Mockup und zeige wie mann eine Single Site App mit Angular implementiert.
Ziel
Wir können jetzt uns als Moderatoren registrieren und anmelden, nur viel machen können wir bis jetzt dann nicht mehr. Das möchte ich für den Moderator jetzt mal ändern. Der Moderator ist die Schlüsselfigur in der Applikation, die die Kommunikation erst für alle los tritt.
Mockup
Wie im letzten Artikel angedeutet möchte ich die Verwaltung von Kanälen mit Hilfe einer Master-Details-Ansicht auf lösen.
Wie sieht das Ganze denn aus?
z.B. so:
Struktur der Angular App
Theorie
Wie ich oben schon schrieb möchte ich eine Single-Site-App implementieren, d.h. es wird in jedem Fall ein Router benötigt, der alle Unterbereiche ansteuert.
Bevor es in die Implementierung geht muss das Mockup noch in einzelne Zuständigkeiten und dazugehörige Controller (vergleichbar mit “Components” in Angular 2) auf teilen.
Die Aufteilung sehe ich dabei wie folgt:
Zur Erklärung:
- Der Router leitet die Anfragen an die Kanäle und Profil Controller
- Der ChannelController ist der Root-Context für den Bereich Kanäle
- Der MasterController steuert die Liste sowie Suche und Auswahl von den vorliegenden Kanälen
- Der DetailsController kümmert sich schließlich darum dass Änderungen an den Knaälen gemacht und persistent abgelegt werden können
- Der Reiter Kommentare wird ebenfalls von einem eigenständigen Controller abgebildet werden, dieser ist aber erst über die Auswahl eines konkreten Kanals erreichbar
- Der Reiter Blockaden wird stukturell genauso aufgebaut wie Reiter Kommentare
Implementierungsdetails
Nachdem klar ist, wie die Angular App aussieht und daraus die interne Architektur grob abgeleitet wurde, kann ich mich an die Implementierung setzen.
Dazu hier ein Auszug aus der Hauptansicht zu der App:
/views/moderator/app.pug
extends ../layout.pug
block body
.container(ng-app="moderator-app", ng-controller="MainController")
h1 Diskurs App
include ./partials/_navbar.pug
div(ng-view)
script(type="text/ng-template", id="channels.html")
include ./partials/_channels.pug
script(type="text/ng-template", id="blocks.html")
include ./partials/_blocks.pug
script(type="text/ng-template", id="comments.html")
include ./partials/_comments.pug
script(type="text/ng-template", id="profile.html")
include ../auth/partials/_profile_edit.pug
block scripts
script(src="/bower_components/bluebird/js/browser/bluebird.core.min.js")
script(src="/bower_components/angular-route/angular-route.min.js")
script(src="/bower_components/bootbox.js/bootbox.js")
script(src="/js/ui-bootstrap-tpls-2.1.3.min.js")
script(src="/util/messageHelper.js")
script(src="/moderator-app/router.js")
//controller
script(src="/moderator-app/controller/mainController.js")
script(src="/moderator-app/controller/channelController.js")
script(src="/moderator-app/controller/channelMasterController.js")
script(src="/moderator-app/controller/channelDetailsController.js")
script(src="/moderator-app/controller/commentController.js")
script(src="/moderator-app/controller/blocksController.js")
script(src="/moderator-app/controller/profileController.js")
//service
script(src="/moderator-app/service/authService.js")
script(src="/moderator-app/service/blockService.js")
script(src="/moderator-app/service/channelService.js")
script(src="/moderator-app/service/commentService.js")
//model
script(src="/moderator-app/model/blocksDao.js")
script(src="/moderator-app/model/channelDao.js")
script(src="/moderator-app/model/commentDao.js")
script(src="/moderator-app/model/userDao.js")
Wie man sehen kann werden hier das Layout und die Abhängigkeiten der Single-Site-App auf gesetzt.
Die Templates, für die einzelnen Tabs werden, werden jeweils in Partials aufgeteilt und sind später über ID direkt erreichbar.
Mit “ng-app” initialisiert man die App.
Nachdem alle Abhängigkeiten aufgesetzt sind kann es mit dem Router weitergehen.
/assets/moderator-app/router.js
angular.module('moderator-app', ['ngResource', 'ngRoute', 'util', 'ui.bootstrap'], function () {})
.constant('version', 'v0.1.0')
.config(function ($locationProvider, $routeProvider) {
$locationProvider.html5Mode(false);
$routeProvider
.when('/channels', {
templateUrl: 'channels.html',
controller: 'ChannelController'
})
.when('/profile', {
templateUrl: 'profile.html',
controller: 'ProfileController'
})
.otherwise({
redirectTo: '/channels'
});
});
Für das Frontend reichen mir zwei Routen:
- /profile
- /channels
Das deckt sich auch exakt mit den Überlegungen bzgl. dem Navigationmenü.
Warum nur eine “/channel” Route?
Wenn man sich die Tabs im Entwurf anschaut und kurz drüber nachdenkt, so kann weder der Kommentare noch Blockaden Tab ohne eine vorherige Auswahl eines Kanals funktionieren, da der jeweilige Datensatz immer davon abhängt.
Sicherlich könnte man die Route und einen zusätzlichen Routenparameter “ChannelId” erweitern und damit auch dann einzelne Kommentare über eine Route ansteuern, dies erhöht aber einerseits die Komplexität und bietet andererseits derzeit keinen wirklichen Mehrwert.
Das direkte Navigieren zu Modellen bzw. Datensätzen macht, meiner Meinung nach, eh nur dann Sinn, wenn man die URL mit anderen teilen möchte, was hier in jedem Fall nciht zurifft.
Partials mit Angular und Jade
Wie sehen nun die Templates aus?
Ich möchte das Ganze mit dem Kanal-Bereich beispielhaft durchspielen:
/views/moderator/partials/_channels.pug
uib-tabset.channel-tabset(active="active")
uib-tab(index="0", heading="Kanal", classes="")
include _channel_master_details.pug
uib-tab(index="1")
uib-tab-heading
| Kommentare
span.label.label-default {{commentList.length}}
include _comments.pug
uib-tab(index="2")
uib-tab-heading
| Blocks
span.label.label-default {{blockList.length}}
include _blocks.pug
Für die Gestaltung der Tabs nutze ich das Angular-Bootstrap Framework, dass so ziemlich alle Widgets von Bootstrap in Angular konformen Direktiven einpackt.
Jedes der Tabs kapselt seine Funktionalität in eigene, dafür zuständige Controller.
/views/moderator/partials/_channel_master_details.pug
.row()
.col-md-4
include ./_channel_list.pug
.col-md-8
include ./_channel_details.pug
/views/moderator/partials/_channel_list.pug
div(ng-controller="ChannelMasterController")
.input-group
input.form-control(ng-model="searchTerm", placeholder="Suchbegriff", ng-keydown="checkForSearch($event)")
span(class="input-group-btn")
button.btn.btn-default(ng-click="search()")
i.fa.fa-search
| Suchen
hr
.list-group
a.list-group-item(ng-repeat="channel in channelList", ng-class="{'active': selectedChannel.id == channel.id }", ng-click="selectChannel(channel)") {{channel.title}}
/views/moderator/partials/_channel_details.pug
.channel-form-container.animated.fadeIn(ng-controller="ChannelDetailsController")
button.btn.btn-primary#newChannelBtn(ng-click="newChannel()")
i.fa.fa-plus
ng-form(name="channelForm", ng-show="selectedChannel")
.well
fieldset
legend Kanaldaten
.form-group(ng-class="{ 'has-error' : channelForm.title.$invalid && !channelForm.title.$pristine }")
label Titel*
input.form-control(type="text", name="title", ng-model="selectedChannel.title", required="true")
p(ng-show="channelForm.title.$invalid && !channelForm.title.$pristine", class="help-block") Bitte geben Sie einen Titel ein.
.form-group(ng-class="{ 'has-error' : channelForm.name.$invalid && !channelForm.name.$pristine }")
label Beschreibung
textarea.form-control(type="text", name="description", ng-model="selectedChannel.description")
.form-group
label Aktiviert*
input.form-control(ng-model="selectedChannel.enabled", type="checkbox", name="enabled", ng-true-value="true" ng-false-value="false")
.alert.alert-info(ng-show="!selectedChannel")
i.fa.fa-info
| Bitte wählen Sie einen Kanal aus oder legen Sie einen neuen an.
nav.navbar.navbar-default.navbar-fixed-bottom(ng-show="selectedChannel")
.container
.row
.col-md-6.col-xs-6
button.btn.btn-success.btn-block(ng-click="saveChannel()", type="button", ng-disabled="!isFormValid()")
i.fa.fa-check
| Speichern
.col-md-6.col-xs-6(ng-show="selectedChannel.id")
button.btn.btn-block.btn-danger(type="button", ng-click="deleteChannel()")
i.fa.fa-times
| Löschen
Für die Verwaltung der Kanäle gibt es also folgende drei Controller:
ChannelController
- HauptController, der Controller übergreifende Funktionen anbietet und den Context von Master und Details zusammenfasst
ChannelMasterController
- Kümmert sich um die Liste aller Kanäle
ChannelDetailsController
- Sorgt dafür das ausgewählte Kanäle gespeichert oder gelöscht werden
/assets/moderator-app/controller/channelController.js
angular.module("moderator-app")
.controller("ChannelController", function ($log, $scope, $rootScope, ChannelService) {
$rootScope.showDeleteConfirmDialog = function () {
return new Promise(function (resolve, reject) {
bootbox.dialog({
message: "Sind Sie sicher dass Sie den Kanal löschen möchten?",
title: "Löschen",
buttons: {
danger: {
label: "Ja, bitte löschen",
className: "btn-danger",
callback: function () {
resolve(true);
}
},
close: {
label: "Abbrechen",
className: "btn-default",
callback: function () {
resolve(false);
}
}
}
});
})
}
$rootScope.showBlockConfirmDialog = function () {
return new Promise(function (resolve, reject) {
bootbox.dialog({
message: "Sind Sie sicher dass Sie den Benutzer dauerhaft für den Kanal blockieren möchten?",
title: "Benutzer blockieren",
buttons: {
danger: {
label: "Ja, bitte blockieren",
className: "btn-danger",
callback: function () {
resolve(true);
}
},
close: {
label: "Abbrechen",
className: "btn-default",
callback: function () {
resolve(false);
}
}
}
});
})
}
$rootScope.channelList = [];
$rootScope.commentList = [];
$rootScope.blockList = [];
})
Derzeit kümmert sich also der ChannelController nur darum die Listen-Variablen im Root-Scope initialisiert werden und das die Dialoge von überall geöffnet werden können. Insbesorndere der Lösch Dialog wird mehrfach verwendet.
Bei den Dialogen lässt sich streiten, ob diese nicht in einem Service besser untergebracht werden. Ich habe sie hier stehen lassen um die Abreit mit dem RootScope etwas zu verdeutlichen. Langfristig werden diese in einen eigenen DialogService heraus gezogen werden.
Das wird deshalb möglich, weil alle Services in Angular als Singletons implementiert sind.
/assets/moderator-app/controller/channelMasterController.js
angular.module('moderator-app')
.controller('ChannelMasterController', function ($scope, $rootScope, $log, $routeParams, ChannelService, MessageHelper) {
$scope.selectChannel = function (channel) {
$rootScope.selectedChannel = channel;
}
$scope.search = function () {
$rootScope.channelList = ChannelService.findAllByText($scope.searchTerm);
$log.debug($rootScope.channelList)
}
$scope.checkForSearch = function ($event) {
if ($event.keyCode == 13) {
$scope.search();
}
}
function setup() {
$scope.searchTerm = "";
ChannelService.list().then(function (channelList) {
$rootScope.channelList = channelList;
$rootScope.$apply()
})
}
setup();
});
})
Der ChannelMasterController fällt reicht klein aus, da er auch in seiner Zuständigkeit semantisch eingeschränkt wurde. Er stellt sicher dass Kanäle geladen, durchsucht und asugewählt werden können. Ein ausgewählter Kanal wnadert dabei in den $rootScope damit dieser im ChannelsDetailsController weiter verwendet werden kann.
/assets/moderator-app/controller/channelDetailsController.js
angular.module('moderator-app')
.controller('ChannelDetailsController', function ($scope, $rootScope, $log, ChannelService, MessageHelper) {
$scope.newChannel = function () {
$rootScope.selectedChannel = {
title: "",
description: "",
enabled: true
};
}
$scope.saveChannel = function () {
if (ChannelService.isValid($rootScope.selectedChannel)) {
if ($rootScope.selectedChannel.id) {
ChannelService.update($rootScope.selectedChannel)
.then(function (channelRecord) {
toastr.success("Erfolgreich gespeichert.")
})
} else {
ChannelService.create($rootScope.selectedChannel)
.then(function (channel) {
$rootScope.selectedChannel = channel;
});
}
} else {
toastr.warning("Bitte überprüfen Sie Ihre Eingaben!");
}
}
$scope.isFormValid = function () {
return $scope.channelForm && $scope.channelForm.$valid;
}
$scope.deleteChannel = function () {
var deleteChannel = function (deleteConfirmed) {
if (deleteConfirmed) {
ChannelService.delete($rootScope.selectedChannel)
.then(function (channelList) {
delete $rootScope.selectedChannel;
$rootScope.channelList = channelList
})
}
}
$rootScope.showDeleteConfirmDialog()
.then(deleteChannel)
.catch(MessageHelper.handleResponseFail)
}
});
ChannelDetailsController hat entsprechend wenig Überraschungen zu bieten. Wichtig zu beachten ist, dass alle relevante Geschäftslogik grundsätzlich in Services, in diesem Fall ChannelService, verpackt wird. Die Controller dienen eigentlich als Presenter, d.h. kümmern sich nur darum das die Kommunikation zwischen Geshcäftslogik und View stattfinden kann.
/assets/moderator-app/service/channelService.js
angular.module('moderator-app')
.service('ChannelService', function (ChannelDao, MessageHelper) {
var self = this;
self.channelList = [];
self.clear = function () {
self.channelList = [];
};
self.add = function (channel) {
self.channelList.push(channel);
};
self.remove = function (channel) {
self.channelList = _.without(self.channelList, channel);
};
self.findById = function (id) {
return _.find(self.channelList, function (channel) {
return channel.id == id;
});
};
self.list = function () {
return new Promise(function (resolve, reject) {
if (self.channelList && self.channelList.length > 0) {
return resolve(self.channelList)
}
ChannelDao.list().$promise
.then(function (channels) {
self.channelList = channels;
return resolve(self.channelList)
})
.catch(MessageHelper.handleResponseFail)
})
};
self.addAll = function (list) {
self.channelList.list(list);
};
self.isValid = function (channel) {
return channel.title && channel.title.length > 0;
};
self.create = function (channel) {
return ChannelDao.create(channel).$promise
.then(function (channelRecord) {
self.add(channelRecord)
return channelRecord;
})
.catch(MessageHelper.handleResponseFail)
}
self.update = function (channel) {
ChannelDao.update({
action: channel.id
}, channel).$promise
.then(function (channelRecord) {
return channelRecord;
})
.catch(MessageHelper.handleResponseFail)
}
self.delete = function (channel) {
return ChannelDao.delete({
action: channel.id
}, channel).$promise
.then(function (channelRecord) {
self.remove(channel)
return self.channelList
})
.catch(MessageHelper.handleResponseFail)
}
self.findAllByText = function (text) {
return _.filter(self.channelList, function (channel) {
return channel.title.toLowerCase().indexOf(text.toLowerCase()) >= 0 || channel.description.toLowerCase().indexOf(text.toLowerCase()) >= 0;
});
};
return self;
})
Gut derzeit macht auch der Service kaum mehr als die Anfrage an die Dao durch zu leiten. Aber Abseits von CRUD können hier recht komplexe Prozesse mit abgebildet werden. Wichtig zu beachten ist aueßrdem, dass Services ihrerseits andere Services (per Denpendency Injection) nutzen können. Man veranschauliche sich wie viel redundanten Code ich mit MessageHelper.handleResponseFail hier eingesparrt habe.
Zur Erinnerung:
/assets/util/messageHelper.js
angular.module('util', [], function () {})
.service("MessageHelper", function ($log) {
this.handleResponseFail = function (err) {
if (err && err.data && err.data.message) {
toastr.error(err.data.message);
} else if (err && err.summary) {
toastr.error(err.summary);
} else {
toastr.error("Bei der Verbindung zum Server ist eine Fehler aufgetreten. Bitte überprüfen Sie die Daten und versuchen Sie es erneut.");
}
}
this.alert = function (text) {
toastr.error(text);
}
this.success = function (text) {
toastr.success(text);
}
return this;
});
Zu guter Letzt die ChannelDao
/assets/moderator-app/model/channelDao.js
angular.module('moderator-app')
.factory("ChannelDao", function ($resource) {
return $resource('/channel/:action', {}, {
update: {
method: 'PUT',
params: {
}
},
list: {
method: 'GET',
params: {
action: ''
},
isArray: true
},
create: {
method: 'POST',
params: {
action: ''
}
},
delete: {
method: 'DELETE',
params: {
}
},
getTotal: {
method: 'GET',
params: {
action: 'getTotal'
}
}
});
})
Das war jetzt aber ganz viel Code. Schauen wir uns nochmal die Architektur von oben an:
Nach genau dem gleichen Muster erfolgt die Implementierung der beiden anderen Tabs.
Fazit
Ich bin schon recht weit in der App fortgeschritten, bzw. das Dashboard des Moderator ist in seinen Mininum an Funktionalität fertig gestellt.
Im Blog stelle ich nur alle Konzepte aber nicht alle Klassen und Helfer vor, da es den Rahmen sprengen und nicht wirklich einen Mehrwert bieten würde.
Den aktuellen Stand habe ich mal in ein kommentarloses Video verpackt(auf das Bild klicken):
#### In diesem Sinne, bis demnächst!