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:

Dashboard Mockup


FÜr die Erstellung der Mockups nutze ich im übrigen derzeit am liebsten [Draw.io](http://draw.io) Das Tool lässt sich nämlich einerseits mit dem eigenen Google Drive Konto verknüpfen, ist entsprechend von überall zugänglich, und es exisiert auch eine Offline-Desktop Version, für den Fall das man mal doch kein Internet hat.

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:

Mockup Aufteilung

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:

  1. /profile
  2. /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:

  1. ChannelController

    • HauptController, der Controller übergreifende Funktionen anbietet und den Context von Master und Details zusammenfasst
  2. ChannelMasterController

    • Kümmert sich um die Liste aller Kanäle
  3. 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:

DiskursArchi

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!