From a159589bfd7cf4f08cca5d5943ba39aed0e9a8e6 Mon Sep 17 00:00:00 2001
From: Eike Cochu <eike@cochu.com>
Date: Thu, 18 Feb 2016 01:26:29 +0100
Subject: [PATCH] using word id, simplified ui routes

ui: simplified routes, removed .index routes
ui: added ui-view to route bases
ui: moved network graph link to actions bar
ui: added alert directive to display alerts
ui: added alert to topic renaming
backend: removed word attribute from word model, using id instead
backend: moved topics query from single word query to subpath /topics
backend: removed uuid from apierror, unused
backend: removed wrapper from resource put requests, serializing to model directly
backend: fixed deserializing topicword/word when receiving from frontend
---
 .../java/de/vipra/rest/model/APIError.java    |  7 --
 .../vipra/rest/resource/ArticleResource.java  |  4 +-
 .../de/vipra/rest/resource/TopicResource.java | 12 +--
 .../de/vipra/rest/resource/WordResource.java  | 28 +++++--
 vipra-ui/app/html/articles/index.html         | 20 ++---
 vipra-ui/app/html/articles/show.html          |  6 +-
 vipra-ui/app/html/directives/alert.html       |  6 ++
 vipra-ui/app/html/topics/index.html           | 20 ++---
 vipra-ui/app/html/topics/show.html            | 29 ++++---
 vipra-ui/app/html/words/index.html            | 54 +++++++------
 vipra-ui/app/html/words/show.html             |  4 +-
 vipra-ui/app/index.html                       |  6 +-
 vipra-ui/app/js/app.js                        | 24 +-----
 vipra-ui/app/js/controllers.js                | 80 +++++++++++--------
 vipra-ui/app/js/directives.js                 | 25 ++++++
 vipra-ui/app/js/factories.js                  | 14 ++--
 vipra-ui/app/js/helpers.js                    | 17 +++-
 vipra-ui/app/less/app.less                    |  8 +-
 .../src/main/java/de/vipra/util/WordMap.java  |  2 +-
 .../main/java/de/vipra/util/an/ConfigKey.java |  5 ++
 .../java/de/vipra/util/an/ElasticIndex.java   |  4 +
 .../java/de/vipra/util/an/QueryIgnore.java    |  5 ++
 .../java/de/vipra/util/model/TopicFull.java   |  2 +-
 .../java/de/vipra/util/model/TopicWord.java   | 20 +++--
 .../main/java/de/vipra/util/model/Word.java   | 40 +---------
 .../de/vipra/util/service/MongoService.java   |  2 +-
 .../java/de/vipra/util/service/Service.java   |  4 +-
 27 files changed, 247 insertions(+), 201 deletions(-)
 create mode 100644 vipra-ui/app/html/directives/alert.html

diff --git a/vipra-backend/src/main/java/de/vipra/rest/model/APIError.java b/vipra-backend/src/main/java/de/vipra/rest/model/APIError.java
index 241a0a32..2a3714b4 100644
--- a/vipra-backend/src/main/java/de/vipra/rest/model/APIError.java
+++ b/vipra-backend/src/main/java/de/vipra/rest/model/APIError.java
@@ -1,12 +1,9 @@
 package de.vipra.rest.model;
 
-import java.util.UUID;
-
 import javax.ws.rs.core.Response.Status;
 
 public class APIError {
 
-	private final String id = UUID.randomUUID().toString();
 	private String status;
 	private String code;
 	private String title;
@@ -25,10 +22,6 @@ public class APIError {
 		this(Integer.toString(status.getStatusCode()), status.getReasonPhrase(), title, detail);
 	}
 
-	public String getId() {
-		return id;
-	}
-
 	public String getStatus() {
 		return status;
 	}
diff --git a/vipra-backend/src/main/java/de/vipra/rest/resource/ArticleResource.java b/vipra-backend/src/main/java/de/vipra/rest/resource/ArticleResource.java
index a5733b72..a203c319 100644
--- a/vipra-backend/src/main/java/de/vipra/rest/resource/ArticleResource.java
+++ b/vipra-backend/src/main/java/de/vipra/rest/resource/ArticleResource.java
@@ -156,9 +156,9 @@ public class ArticleResource {
 	@Consumes(MediaType.APPLICATION_JSON)
 	@Produces(MediaType.APPLICATION_JSON)
 	@Path("{id}")
-	public Response replaceArticle(@PathParam("id") String id, Wrapper<ArticleFull> wrapper) {
-		ArticleFull article = wrapper.getData();
+	public Response replaceArticle(@PathParam("id") String id, ArticleFull article) {
 		Wrapper<ArticleFull> res = new Wrapper<>();
+
 		try {
 			dbArticles.updateSingle(article);
 			return res.ok(article);
diff --git a/vipra-backend/src/main/java/de/vipra/rest/resource/TopicResource.java b/vipra-backend/src/main/java/de/vipra/rest/resource/TopicResource.java
index 4d2512d0..a1b3e03a 100644
--- a/vipra-backend/src/main/java/de/vipra/rest/resource/TopicResource.java
+++ b/vipra-backend/src/main/java/de/vipra/rest/resource/TopicResource.java
@@ -119,8 +119,10 @@ public class TopicResource {
 		Wrapper<List<ArticleFull>> res = new Wrapper<>();
 		try {
 			Topic topic = new Topic(MongoUtils.objectId(id));
-			List<ArticleFull> articles = dbArticles.getMultiple(
-					QueryBuilder.builder().criteria("topics.topic", topic).fields(true, StringUtils.getFields(fields)));
+			QueryBuilder query = QueryBuilder.builder().criteria("topics.topic", topic);
+			if (fields != null && !fields.isEmpty())
+				query.fields(true, StringUtils.getFields(fields));
+			List<ArticleFull> articles = dbArticles.getMultiple(query);
 			return res.ok(articles);
 		} catch (Exception e) {
 			e.printStackTrace();
@@ -133,15 +135,15 @@ public class TopicResource {
 	@Consumes(MediaType.APPLICATION_JSON)
 	@Produces(MediaType.APPLICATION_JSON)
 	@Path("{id}")
-	public Response replaceTopic(@PathParam("id") String id, Wrapper<TopicFull> wrapper) {
-		TopicFull topic = wrapper.getData();
+	public Response replaceTopic(@PathParam("id") String id, TopicFull topic) {
 		Wrapper<TopicFull> res = new Wrapper<>();
+
 		try {
 			dbTopics.updateSingle(topic);
 			return res.ok(topic);
 		} catch (DatabaseException e) {
 			e.printStackTrace();
-			res = new Wrapper<>(new APIError(Response.Status.INTERNAL_SERVER_ERROR, "item could not be updated",
+			res.addError(new APIError(Response.Status.INTERNAL_SERVER_ERROR, "item could not be updated",
 					"item could not be updated due to an internal server error"));
 			return res.serverError();
 		}
diff --git a/vipra-backend/src/main/java/de/vipra/rest/resource/WordResource.java b/vipra-backend/src/main/java/de/vipra/rest/resource/WordResource.java
index ad2abe96..6e158879 100644
--- a/vipra-backend/src/main/java/de/vipra/rest/resource/WordResource.java
+++ b/vipra-backend/src/main/java/de/vipra/rest/resource/WordResource.java
@@ -47,7 +47,7 @@ public class WordResource {
 	@GET
 	@Produces(MediaType.APPLICATION_JSON)
 	public Response getWords(@QueryParam("skip") Integer skip, @QueryParam("limit") Integer limit,
-			@QueryParam("sort") @DefaultValue("word") String sortBy, @QueryParam("fields") String fields) {
+			@QueryParam("sort") @DefaultValue("id") String sortBy, @QueryParam("fields") String fields) {
 		Wrapper<List<Word>> res = new Wrapper<>();
 
 		if (skip != null && limit != null)
@@ -94,16 +94,32 @@ public class WordResource {
 		}
 
 		if (word != null) {
-			List<TopicFull> topics = dbTopics.getMultiple(
-					QueryBuilder.builder().fields(false, "index", "created", "modified").criteria("words.word", word));
-			word.setTopics(topics);
-
 			return res.ok(word);
 		} else {
-			String msg = String.format(Messages.NOT_FOUND, "word", id);
+			String msg = String.format(Messages.NOT_FOUND, "id", id);
 			res.addError(new APIError(Response.Status.NOT_FOUND, "Resource not found", msg));
 			return res.notFound();
 		}
 	}
 
+	@GET
+	@Produces(MediaType.APPLICATION_JSON)
+	@Consumes(MediaType.APPLICATION_JSON)
+	@Path("{id}/topics")
+	public Response getWordTopics(@PathParam("id") String id, @QueryParam("fields") String fields) {
+		Wrapper<List<TopicFull>> res = new Wrapper<>();
+		try {
+			Word word = new Word(id);
+			QueryBuilder query = QueryBuilder.builder().fields(true, "id", "name").criteria("words.word", word);
+			if (fields != null && !fields.isEmpty())
+				query.fields(true, StringUtils.getFields(fields));
+			List<TopicFull> topics = dbTopics.getMultiple(query);
+			return res.ok(topics);
+		} catch (Exception e) {
+			e.printStackTrace();
+			res.addError(new APIError(Response.Status.BAD_REQUEST, "Error", e.getMessage()));
+			return res.badRequest();
+		}
+	}
+
 }
diff --git a/vipra-ui/app/html/articles/index.html b/vipra-ui/app/html/articles/index.html
index 7c5a3a60..45868a68 100644
--- a/vipra-ui/app/html/articles/index.html
+++ b/vipra-ui/app/html/articles/index.html
@@ -1,11 +1,13 @@
-<div class="well">
-  Found <span ng-bind="articlesMeta.total"></span> articles in the database <query-time/>.<br>
-</div>
+<div ui-view>
+  <div class="well">
+    Found <span ng-bind="articlesMeta.total"></span> articles in the database <query-time/>.<br>
+  </div>
 
-<ul class="dashed">
-  <li ng-repeat="article in articles">
-    <a ui-sref="articles.show({id: article.id})" ng-bind="::article.title"></a>
-  </li>
-</ul>
+  <ul class="dashed">
+    <li ng-repeat="article in articles">
+      <a ui-sref="articles.show({id: article.id})" ng-bind="::article.title"></a>
+    </li>
+  </ul>
 
-<pagination total="articlesMeta.total" page="page" limit="limit" change="changePage"/>
\ No newline at end of file
+  <pagination total="articlesMeta.total" page="page" limit="limit" change="changePage"/>
+</div>
\ No newline at end of file
diff --git a/vipra-ui/app/html/articles/show.html b/vipra-ui/app/html/articles/show.html
index e5f4708a..7a7e9ad8 100644
--- a/vipra-ui/app/html/articles/show.html
+++ b/vipra-ui/app/html/articles/show.html
@@ -14,7 +14,7 @@
         </tr>
         <tr>
           <th>Date</th>
-          <td ng-bind="::article.date"></td>
+          <td ng-bind="::articleDate"></td>
         </tr>
         <tr>
           <th>URL</th>
@@ -22,11 +22,11 @@
         </tr>
         <tr>
           <th>Created</th>
-          <td ng-bind="::article.created"></td>
+          <td ng-bind="::articleCreated"></td>
         </tr>
         <tr>
           <th>Last modified</th>
-          <td ng-bind="::article.modified"></td>
+          <td ng-bind="::articleModified"></td>
         </tr>
         <tr>
           <th>Word count</th>
diff --git a/vipra-ui/app/html/directives/alert.html b/vipra-ui/app/html/directives/alert.html
new file mode 100644
index 00000000..441726d4
--- /dev/null
+++ b/vipra-ui/app/html/directives/alert.html
@@ -0,0 +1,6 @@
+<div ng-attr-class="{{classes}}" role="alert">
+  <button type="button" class="close" data-dismiss="alert" aria-label="Close" ng-show="dismissible">
+    <span aria-hidden="true">&times;</span>
+  </button>
+  <ng-transclude/>
+</div>
\ No newline at end of file
diff --git a/vipra-ui/app/html/topics/index.html b/vipra-ui/app/html/topics/index.html
index c73784d5..3eac37a5 100644
--- a/vipra-ui/app/html/topics/index.html
+++ b/vipra-ui/app/html/topics/index.html
@@ -1,11 +1,13 @@
-<div class="well">
-    Found <span ng-bind="topicsMeta.total"></span> topics in the database <query-time/>.
-</div>
+<div ui-view>
+  <div class="well">
+      Found <span ng-bind="topicsMeta.total"></span> topics in the database <query-time/>.
+  </div>
 
-<ul class="dashed">
-  <li ng-repeat="topic in topics">
-    <a ui-sref="topics.show({id: topic.id})">{{topic.name}}</a>
-  </li>
-</ul>
+  <ul class="dashed">
+    <li ng-repeat="topic in topics">
+      <a ui-sref="topics.show({id: topic.id})">{{topic.name}}</a>
+    </li>
+  </ul>
 
-<pagination total="topicsMeta.total" page="page" limit="limit" change="changePage"/>
\ No newline at end of file
+  <pagination total="topicsMeta.total" page="page" limit="limit" change="changePage"/>
+</div>
\ No newline at end of file
diff --git a/vipra-ui/app/html/topics/show.html b/vipra-ui/app/html/topics/show.html
index 753ba0cb..b82d508a 100644
--- a/vipra-ui/app/html/topics/show.html
+++ b/vipra-ui/app/html/topics/show.html
@@ -13,10 +13,23 @@
       </div>
     </div>
   </h1>
+
+  <bs-alert type="danger" ng-if="renameErrors">
+    <span ng-bind-html="renameErrors"></span>
+  </bs-alert>
   
-  <bs-dropdown label="Actions">
-    <li><a ng-click="startRename()">Rename</a></li>
-  </bs-dropdown>
+  <table class="item-actions">
+    <tr>
+      <td>
+        <bs-dropdown label="Actions">
+          <li><a ng-click="startRename()">Rename</a></li>
+        </bs-dropdown>
+      </td>
+      <td>
+        <a class="btn btn-default" ui-sref="network({type:'topics', id:topic.id})">Network graph</a>
+      </td>
+    </tr>
+  </table>
 </div>
 
 <h3>Info <hide-link target="#info"/></h3>
@@ -35,17 +48,11 @@
         </tr>
         <tr>
           <th>Created</th>
-          <td ng-bind="::topic.created"></td>
+          <td ng-bind="::topicCreated"></td>
         </tr>
         <tr>
           <th>Last modified</th>
-          <td ng-bind="::topic.modified"></td>
-        </tr>
-        <tr>
-          <th>Links</th>
-          <td>
-            <a ui-sref="network({type:'topics', id:topic.id})">Network graph</a>
-          </td>
+          <td ng-bind="::topicModified"></td>
         </tr>
       </tbody>
     </table>
diff --git a/vipra-ui/app/html/words/index.html b/vipra-ui/app/html/words/index.html
index d0850631..10297771 100644
--- a/vipra-ui/app/html/words/index.html
+++ b/vipra-ui/app/html/words/index.html
@@ -1,29 +1,31 @@
-<div class="well">
-  Found <span ng-bind="wordsMeta.total"></span> words in the database <query-time/>.
-</div>
-
-<div class="row">
-  <div class="col-md-4">
-    <ul class="list-unstyled">
-      <li ng-repeat="word in words.slice(0,100)">
-        <a ui-sref="words.show({id: word.id})">{{word.id}}</a>
-      </li>
-    </ul>
-  </div>
-  <div class="col-md-4">
-    <ul class="list-unstyled">
-      <li ng-repeat="word in words.slice(100,200)">
-        <a ui-sref="words.show({id: word.id})">{{word.id}}</a>
-      </li>
-    </ul>
+<div ui-view>
+  <div class="well">
+    Found <span ng-bind="wordsMeta.total"></span> words in the database <query-time/>.
   </div>
-  <div class="col-md-4">
-    <ul class="list-unstyled">
-      <li ng-repeat="word in words.slice(200,300)">
-        <a ui-sref="words.show({id: word.id})">{{word.id}}</a>
-      </li>
-    </ul>
+
+  <div class="row">
+    <div class="col-md-4">
+      <ul class="list-unstyled">
+        <li ng-repeat="word in words.slice(0,100)">
+          <a ui-sref="words.show({id: word.id})">{{word.id}}</a>
+        </li>
+      </ul>
+    </div>
+    <div class="col-md-4">
+      <ul class="list-unstyled">
+        <li ng-repeat="word in words.slice(100,200)">
+          <a ui-sref="words.show({id: word.id})">{{word.id}}</a>
+        </li>
+      </ul>
+    </div>
+    <div class="col-md-4">
+      <ul class="list-unstyled">
+        <li ng-repeat="word in words.slice(200,300)">
+          <a ui-sref="words.show({id: word.id})">{{word.id}}</a>
+        </li>
+      </ul>
+    </div>
   </div>
-</div>
 
-<pagination total="wordsMeta.total" page="page" limit="limit" change="changePage"/>
\ No newline at end of file
+  <pagination total="wordsMeta.total" page="page" limit="limit" change="changePage"/>
+</div>
\ No newline at end of file
diff --git a/vipra-ui/app/html/words/show.html b/vipra-ui/app/html/words/show.html
index 749c267f..c5e0cf03 100644
--- a/vipra-ui/app/html/words/show.html
+++ b/vipra-ui/app/html/words/show.html
@@ -10,7 +10,7 @@
       <tbody>
         <tr>
           <th>Created</th>
-          <td ng-bind="::word.created"></td>
+          <td ng-bind="::wordCreated"></td>
         </tr>
       </tbody>
     </table>
@@ -22,7 +22,7 @@
 <div class="row" id="topics">
   <div class="col-md-12">
     <ul class="list-unstyled">
-      <li ng-repeat="topic in ::word.topics">
+      <li ng-repeat="topic in ::topics">
         <topic-link topic="topic"/>
       </li>
     </ul>
diff --git a/vipra-ui/app/index.html b/vipra-ui/app/index.html
index 8c4471f6..319bbda4 100644
--- a/vipra-ui/app/index.html
+++ b/vipra-ui/app/index.html
@@ -51,9 +51,9 @@
         <!-- Collect the nav links, forms, and other content for toggling -->
         <div class="collapse navbar-collapse" id="vipra-navbar-collapse-1">
           <ul class="nav navbar-nav">
-            <li ng-class="{active:$state.includes('articles')}"><a ui-sref="articles.index">Articles</a></li>
-            <li ng-class="{active:$state.includes('topics')}"><a ui-sref="topics.index">Topics</a></li>
-            <li ng-class="{active:$state.includes('words')}"><a ui-sref="words.index">Words</a></li>
+            <li ng-class="{active:$state.includes('articles')}"><a ui-sref="articles">Articles</a></li>
+            <li ng-class="{active:$state.includes('topics')}"><a ui-sref="topics">Topics</a></li>
+            <li ng-class="{active:$state.includes('words')}"><a ui-sref="words">Words</a></li>
           </ul>
         </div><!-- /.navbar-collapse -->
       </div><!-- /.container-fluid -->
diff --git a/vipra-ui/app/js/app.js b/vipra-ui/app/js/app.js
index be65d8cc..e0a708a3 100644
--- a/vipra-ui/app/js/app.js
+++ b/vipra-ui/app/js/app.js
@@ -38,13 +38,7 @@
     // states: articles
 
     $stateProvider.state('articles', {
-      url: '/articles',
-      abstract: true,
-      template: '<ui-view/>'
-    });
-
-    $stateProvider.state('articles.index', {
-      url: '?page',
+      url: '/articles?page',
       templateUrl: tplBase + '/articles/index.html',
       controller: 'ArticlesIndexController',
       reloadOnSearch: false
@@ -59,13 +53,7 @@
     // states: topics
 
     $stateProvider.state('topics', {
-      url: '/topics',
-      abstract: true,
-      template: '<ui-view/>'
-    });
-
-    $stateProvider.state('topics.index', {
-      url: '?page',
+      url: '/topics?page',
       templateUrl: tplBase + '/topics/index.html',
       controller: 'TopicsIndexController',
       reloadOnSearch: false
@@ -80,13 +68,7 @@
     // states: words
 
     $stateProvider.state('words', {
-      url: '/words',
-      abstract: true,
-      template: '<ui-view/>'
-    });
-
-    $stateProvider.state('words.index', {
-      url: '?page',
+      url: '/words?page',
       templateUrl: tplBase + '/words/index.html',
       controller: 'WordsIndexController',
       reloadOnSearch: false
diff --git a/vipra-ui/app/js/controllers.js b/vipra-ui/app/js/controllers.js
index cdbd83dd..ae8935fd 100644
--- a/vipra-ui/app/js/controllers.js
+++ b/vipra-ui/app/js/controllers.js
@@ -286,16 +286,17 @@
   app.controller('ArticlesShowController', ['$scope', '$stateParams', 'ArticleFactory',
     function($scope, $stateParams, ArticleFactory, testService) {
 
+    $scope.topicSort = $scope.topicSort || 'topic.share';
+    $scope.topicSortRev = typeof $scope.topicSortRev === 'undefined' ? false : $scope.topicSortRev;
+
     ArticleFactory.get({id: $stateParams.id}, function(response) {
       $scope.article = response.data;
       $scope.article.text = createInitial($scope.article.text);
-      $scope.article.date = formatDate($scope.article.date);
-      $scope.article.created = formatDateTime($scope.article.created);
-      $scope.article.modified = formatDateTime($scope.article.modified);
+      $scope.articleDate = formatDate($scope.article.date);
+      $scope.articleCreated = formatDateTime($scope.article.created);
+      $scope.articleModified = formatDateTime($scope.article.modified);
       $scope.articleMeta = response.meta;
       $scope.queryTime = response.$queryTime;
-      $scope.topicSort = $scope.topicSort || 'topic.share';
-      $scope.topicSortRev = typeof $scope.topicSortRev === 'undefined' ? false : $scope.topicSortRev;
 
       // calculate percentage share
       var topicShareSeries = [],
@@ -323,7 +324,6 @@
       };
 
       $scope.topicShare = topicShare;
-
     });
 
   }]);
@@ -370,39 +370,47 @@
   app.controller('TopicsShowController', ['$scope', '$stateParams', '$timeout', 'TopicFactory',
     function($scope, $stateParams, $timeout, TopicFactory) {
 
+    $scope.wordSort = $scope.wordSort || 'likeliness';
+    $scope.wordSortRev = typeof $scope.wordSortRev === 'undefined' ? true : $scope.wordSortRev;
+
     TopicFactory.get({id: $stateParams.id}, function(response) {
       $scope.topic = response.data;
-      $scope.topic.created = formatDateTime($scope.topic.created);
-      $scope.topic.modified = formatDateTime($scope.topic.modified);
+      $scope.topicCreated = formatDateTime($scope.topic.created);
+      $scope.topicModified = formatDateTime($scope.topic.modified);
       $scope.topicMeta = response.meta;
       $scope.queryTime = response.$queryTime;
-      $scope.wordSort = $scope.wordSort || 'likeliness';
-      $scope.wordSortRev = typeof $scope.wordSortRev === 'undefined' ? true : $scope.wordSortRev;
-
-      $scope.startRename = function() {
-        $scope.origName = $scope.topic.name;
-        $scope.isRename = true;
-        $timeout(function() {
-          $('#topicName').select();
-        }, 0);
-      };
+    });
 
-      $scope.endRename = function(save) {
+    $scope.startRename = function() {
+      $scope.origName = $scope.topic.name;
+      $scope.isRename = true;
+      $timeout(function() {
+        $('#topicName').select();
+      }, 0);
+    };
+
+    $scope.endRename = function(save) {
+      delete $scope.renameErrors;
+      if(save) {
+        TopicFactory.update({id:$scope.topic.id}, $scope.topic, function(response) {
+          $scope.topic = response.data;
+          $scope.isRename = false;
+        }, function(response) {
+          if(response.data)
+          $scope.renameErrors = getErrors(response.data.errors);
+        });
+      } else {
         $scope.isRename = false;
-        if(save) {
-          // TODO implement
-        } else {
-          $scope.topic.name = $scope.origName;
-        }
-      };
+        $scope.topic.name = $scope.origName;
+      }
+    };
 
-      $scope.keyup = function($event) {
-        if($event.which === 13 || $event.which === 27) {
-          $scope.endRename($event.which === 13);
-          $event.preventDefault();
-        }
-      };
-    });
+    $scope.keyup = function($event) {
+      if($event.which === 13 || $event.which === 27) {
+        $scope.endRename($event.which === 13);
+        $event.preventDefault();
+      }
+    };
 
   }]);
 
@@ -418,7 +426,7 @@
 
     $scope.page = Math.max($stateParams.page || 1, 1);
     $scope.limit = 300;
-    $scope.sort = Store('sortwords') || 'word';
+    $scope.sort = Store('sortwords') || 'id';
     $scope.order = Store('orderwords') || '';
 
     $scope.reload = function() {
@@ -451,11 +459,15 @@
 
     WordFactory.get({id: $stateParams.id}, function(response) {
       $scope.word = response.data;
-      $scope.word.created = formatDateTime($scope.word.created);
+      $scope.wordCreated = formatDateTime($scope.word.created);
       $scope.wordMeta = response.meta;
       $scope.queryTime = response.$queryTime;
     });
 
+    WordFactory.topics({id: $stateParams.id}, function(response) {
+      $scope.topics = response.data;
+    });
+
   }]);
 
   /****************************************************************************
diff --git a/vipra-ui/app/js/directives.js b/vipra-ui/app/js/directives.js
index 93059f3c..2d05ad9c 100644
--- a/vipra-ui/app/js/directives.js
+++ b/vipra-ui/app/js/directives.js
@@ -155,6 +155,31 @@
     };
   });
 
+  app.directive('bsAlert', function() {
+    return {
+      scope: {
+        type: '@',
+        dismissible: '@'
+      },
+      transclude: true,
+      replace: true,
+      templateUrl: 'html/directives/alert.html',
+      link: function($scope) {
+        if(!$scope.type) {
+          console.log('no alert type given');
+          return;
+        }
+
+        $scope.dismissible = $scope.dismissible !== 'false';
+
+        var classes = 'alert alert-' + $scope.type;
+        if($scope.dismissible)
+          classes += ' alert-dismissible';
+        $scope.classes = classes;
+      }
+    };
+  });
+
   app.directive('sortBy', ['Store', function(Store) {
     return {
       scope: {
diff --git a/vipra-ui/app/js/factories.js b/vipra-ui/app/js/factories.js
index 80efd1c6..37a1632f 100644
--- a/vipra-ui/app/js/factories.js
+++ b/vipra-ui/app/js/factories.js
@@ -6,30 +6,32 @@
 
   var app = angular.module('vipra.factories', []);
 
-  var endpoint = '//' + location.hostname + ':8000/vipra/rest';
+  var endpoint = '//' + location.hostname + ':8080/vipra/rest';
 
   app.factory('ArticleFactory', ['$resource', function($resource) {
     return $resource(endpoint + '/articles/:id', {}, {
-      query: { isArray: false, cache: true }
+      query: { isArray: false }
     });
   }]);
 
   app.factory('TopicFactory', ['$resource', function($resource) {
     return $resource(endpoint + '/topics/:id', {}, {
-      query: { isArray: false, cache: true },
-      articles: { method: 'GET', isArray: false, url: endpoint + '/topics/:id/articles', cache: true }
+      query: { isArray: false },
+      update: { method: 'PUT' },
+      articles: { url: endpoint + '/topics/:id/articles' }
     });
   }]);
 
   app.factory('WordFactory', ['$resource', function($resource) {
     return $resource(endpoint + '/words/:id', {}, {
-      query: { isArray: false, cache: true }
+      query: { isArray: false },
+      topics: { url: endpoint + '/words/:id/topics' }
     });
   }]);
 
   app.factory('SearchFactory', ['$resource', function($resource) {
     return $resource(endpoint + '/search', {}, {
-      query: { isArray: false, cache: true }
+      query: { isArray: false }
     });
   }]);
 
diff --git a/vipra-ui/app/js/helpers.js b/vipra-ui/app/js/helpers.js
index 31c3ef07..64f87779 100644
--- a/vipra-ui/app/js/helpers.js
+++ b/vipra-ui/app/js/helpers.js
@@ -27,6 +27,19 @@
     return 'id' + Math.random().toString(36).substring(7);
   };
 
+  window.console = window.console || {
+    log: function () {}
+  };
+
+  window.getErrors = function(errors) {
+    var html = [];
+    if(errors && errors.length) {
+      for(var i = 0; i < errors.length; i++)
+        html.push('<strong>' + errors[i].title + '</strong>: ' + errors[i].detail);
+    }
+    return html.join('<br>');
+  }
+
   String.prototype.ellipsize = function(max) {
     max = max || 20;
     if(this.length > max) {
@@ -44,8 +57,4 @@
       return this.lastIndexOf(start, 0) === 0;
     };
 
-  window.console = window.console || {
-    log: function () {}
-  };
-
 })();
\ No newline at end of file
diff --git a/vipra-ui/app/less/app.less b/vipra-ui/app/less/app.less
index a1be9704..de6153ed 100644
--- a/vipra-ui/app/less/app.less
+++ b/vipra-ui/app/less/app.less
@@ -185,7 +185,13 @@ ul.dashed {
 }
 
 .item-actions {
-  padding: 5px 0 0 10px;
+  td {
+    vertical-align: middle;
+  }
+
+  td + td {
+    padding-left: 10px;
+  }
 }
 
 .row {
diff --git a/vipra-util/src/main/java/de/vipra/util/WordMap.java b/vipra-util/src/main/java/de/vipra/util/WordMap.java
index 1613e819..ba1a02b2 100644
--- a/vipra-util/src/main/java/de/vipra/util/WordMap.java
+++ b/vipra-util/src/main/java/de/vipra/util/WordMap.java
@@ -30,7 +30,7 @@ public class WordMap {
 		this.newWords = new HashSet<>();
 		List<Word> words = dbWords.getAll();
 		for (Word word : words)
-			wordMap.put(word.getWord().toLowerCase(), word);
+			wordMap.put(word.getId().toLowerCase(), word);
 	}
 
 	public Word get(Object w) {
diff --git a/vipra-util/src/main/java/de/vipra/util/an/ConfigKey.java b/vipra-util/src/main/java/de/vipra/util/an/ConfigKey.java
index 12577016..354ce783 100644
--- a/vipra-util/src/main/java/de/vipra/util/an/ConfigKey.java
+++ b/vipra-util/src/main/java/de/vipra/util/an/ConfigKey.java
@@ -5,6 +5,11 @@ import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.lang.annotation.Target;
 
+/**
+ * The ConfigKey field annotates configuration values. The key itself is the
+ * properties file key under which the value will be stored in the configuration
+ * file.
+ */
 @Retention(RetentionPolicy.RUNTIME)
 @Target(ElementType.FIELD)
 public @interface ConfigKey {
diff --git a/vipra-util/src/main/java/de/vipra/util/an/ElasticIndex.java b/vipra-util/src/main/java/de/vipra/util/an/ElasticIndex.java
index b1d88bc6..055132c8 100644
--- a/vipra-util/src/main/java/de/vipra/util/an/ElasticIndex.java
+++ b/vipra-util/src/main/java/de/vipra/util/an/ElasticIndex.java
@@ -5,6 +5,10 @@ import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.lang.annotation.Target;
 
+/**
+ * The ElasticIndex annotation marks fields for use when creating and updating
+ * ElasticSearch indexes.
+ */
 @Retention(RetentionPolicy.RUNTIME)
 @Target({ ElementType.FIELD, ElementType.METHOD })
 public @interface ElasticIndex {
diff --git a/vipra-util/src/main/java/de/vipra/util/an/QueryIgnore.java b/vipra-util/src/main/java/de/vipra/util/an/QueryIgnore.java
index 8e2c7df1..21398b27 100644
--- a/vipra-util/src/main/java/de/vipra/util/an/QueryIgnore.java
+++ b/vipra-util/src/main/java/de/vipra/util/an/QueryIgnore.java
@@ -5,6 +5,11 @@ import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.lang.annotation.Target;
 
+/**
+ * The QueryIgnore annotation allows ignoring annotated fields on single, multi,
+ * and all resource requests. Example: ignore large fields on multi query to
+ * reduce payload size.
+ */
 @Retention(RetentionPolicy.RUNTIME)
 @Target(ElementType.FIELD)
 public @interface QueryIgnore {
diff --git a/vipra-util/src/main/java/de/vipra/util/model/TopicFull.java b/vipra-util/src/main/java/de/vipra/util/model/TopicFull.java
index ba27f175..126abc28 100644
--- a/vipra-util/src/main/java/de/vipra/util/model/TopicFull.java
+++ b/vipra-util/src/main/java/de/vipra/util/model/TopicFull.java
@@ -117,7 +117,7 @@ public class TopicFull implements Model<ObjectId>, Serializable {
 			int size = Math.min(Constants.AUTO_TOPIC_WORDS, words.size());
 			List<String> topWords = new ArrayList<>(size);
 			for (int i = 0; i < size; i++) {
-				topWords.add(words.get(i).getWord().getWord());
+				topWords.add(words.get(i).getWord().getId());
 			}
 			name = StringUtils.join(topWords);
 		}
diff --git a/vipra-util/src/main/java/de/vipra/util/model/TopicWord.java b/vipra-util/src/main/java/de/vipra/util/model/TopicWord.java
index ad75e6f5..dcff0797 100644
--- a/vipra-util/src/main/java/de/vipra/util/model/TopicWord.java
+++ b/vipra-util/src/main/java/de/vipra/util/model/TopicWord.java
@@ -3,11 +3,11 @@ package de.vipra.util.model;
 import java.io.Serializable;
 
 import org.mongodb.morphia.annotations.Embedded;
-import org.mongodb.morphia.annotations.PostLoad;
 import org.mongodb.morphia.annotations.Reference;
 
+import com.fasterxml.jackson.annotation.JsonGetter;
 import com.fasterxml.jackson.annotation.JsonIgnore;
-import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonSetter;
 
 @SuppressWarnings("serial")
 @Embedded
@@ -17,9 +17,6 @@ public class TopicWord implements Comparable<TopicWord>, Serializable {
 	@JsonIgnore
 	private Word word;
 
-	@JsonProperty("id")
-	private String wordString;
-
 	private Double likeliness;
 
 	public TopicWord() {}
@@ -37,8 +34,14 @@ public class TopicWord implements Comparable<TopicWord>, Serializable {
 		this.word = word;
 	}
 
+	@JsonGetter("id")
 	public String getWordString() {
-		return wordString;
+		return word.getId();
+	}
+
+	@JsonSetter("id")
+	public void setWordString(String word) {
+		this.word = new Word(word);
 	}
 
 	public Double getLikeliness() {
@@ -64,9 +67,4 @@ public class TopicWord implements Comparable<TopicWord>, Serializable {
 		return TopicWord.class.getSimpleName() + "[word:" + word + ", likeliness:" + likeliness + "]";
 	}
 
-	@PostLoad
-	private void postLoad() {
-		this.wordString = word.getWord();
-	}
-
 }
diff --git a/vipra-util/src/main/java/de/vipra/util/model/Word.java b/vipra-util/src/main/java/de/vipra/util/model/Word.java
index 268a8dc2..0bcd5395 100644
--- a/vipra-util/src/main/java/de/vipra/util/model/Word.java
+++ b/vipra-util/src/main/java/de/vipra/util/model/Word.java
@@ -2,7 +2,6 @@ package de.vipra.util.model;
 
 import java.io.Serializable;
 import java.util.Date;
-import java.util.List;
 
 import org.mongodb.morphia.annotations.Entity;
 import org.mongodb.morphia.annotations.Id;
@@ -22,23 +21,8 @@ import de.vipra.util.an.QueryIgnore;
 @Indexes(@Index("-created"))
 public class Word implements Model<String>, Serializable {
 
-	/**
-	 * This is the id. It is used by the frontend, which expects an 'id' field.
-	 * This field is populated on load from the database and it is not stored.
-	 */
-	@Transient
-	private String id;
-
-	/**
-	 * This is the actual word. It is used as the database id and is not
-	 * returned to the frontend.
-	 */
 	@Id
-	@JsonIgnore
-	private String word;
-
-	@Transient
-	private List<TopicFull> topics;
+	private String id;
 
 	@QueryIgnore(multi = true)
 	private Date created;
@@ -54,8 +38,8 @@ public class Word implements Model<String>, Serializable {
 
 	public Word() {}
 
-	public Word(String word) {
-		this.word = word;
+	public Word(String id) {
+		this.id = id;
 	}
 
 	@Override
@@ -68,23 +52,6 @@ public class Word implements Model<String>, Serializable {
 		this.id = id;
 	}
 
-	public String getWord() {
-		return word;
-	}
-
-	public void setWord(String word) {
-		this.word = word;
-		this.id = word;
-	}
-
-	public List<TopicFull> getTopics() {
-		return topics;
-	}
-
-	public void setTopics(List<TopicFull> topics) {
-		this.topics = topics;
-	}
-
 	public boolean isCreated() {
 		return isCreated;
 	}
@@ -104,7 +71,6 @@ public class Word implements Model<String>, Serializable {
 	@PostLoad
 	@PostPersist
 	private void postLoadPersist() {
-		this.id = word;
 		this.isCreated = true;
 	}
 
diff --git a/vipra-util/src/main/java/de/vipra/util/service/MongoService.java b/vipra-util/src/main/java/de/vipra/util/service/MongoService.java
index d5dcc1f7..9ae45b21 100644
--- a/vipra-util/src/main/java/de/vipra/util/service/MongoService.java
+++ b/vipra-util/src/main/java/de/vipra/util/service/MongoService.java
@@ -117,7 +117,7 @@ public class MongoService<Type extends Model<IdType>, IdType> implements Service
 	}
 
 	@Override
-	public void updateSingle(Type t) throws DatabaseException {
+	public void updateSingle(Type t, String... fields) throws DatabaseException {
 		datastore.save(t);
 	}
 
diff --git a/vipra-util/src/main/java/de/vipra/util/service/Service.java b/vipra-util/src/main/java/de/vipra/util/service/Service.java
index b193737c..99808fdf 100644
--- a/vipra-util/src/main/java/de/vipra/util/service/Service.java
+++ b/vipra-util/src/main/java/de/vipra/util/service/Service.java
@@ -98,9 +98,11 @@ public interface Service<Type extends Model<IdType>, IdType, E extends Exception
 	 * 
 	 * @param t
 	 *            Entity to be updated
+	 * @param fields
+	 *            Fields to be updated
 	 * @throws E
 	 */
-	void updateSingle(Type t) throws E;
+	void updateSingle(Type t, String... fields) throws E;
 
 	/**
 	 * Drop all entities from the database
-- 
GitLab