From 582158f70e6c63980cce17d408a0cc435a0d985f Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Fri, 30 Mar 2018 14:09:51 +0100
Subject: [PATCH] Added tags to chapters and books

Closes #121
---
 app/Http/Controllers/ChapterController.php    |  9 ++--
 app/Repos/EntityRepo.php                      | 26 ++++++----
 resources/assets/js/vues/tag-manager.js       |  8 ++--
 resources/assets/sass/_blocks.scss            |  3 ++
 resources/assets/sass/_components.scss        |  4 ++
 resources/assets/sass/_forms.scss             |  3 ++
 resources/lang/en/entities.php                |  4 +-
 resources/views/books/form.blade.php          | 41 +++++++++-------
 resources/views/books/show.blade.php          | 23 ++++++---
 resources/views/chapters/form.blade.php       |  9 ++++
 resources/views/chapters/show.blade.php       |  9 ++++
 resources/views/components/tag-list.blade.php | 10 ++++
 .../views/components/tag-manager.blade.php    | 23 +++++++++
 resources/views/pages/form-toolbox.blade.php  | 25 ++--------
 resources/views/pages/show.blade.php          | 11 +----
 tests/Entity/TagTest.php                      | 47 +++++++++++++++----
 16 files changed, 173 insertions(+), 82 deletions(-)
 create mode 100644 resources/views/components/tag-list.blade.php
 create mode 100644 resources/views/components/tag-manager.blade.php

diff --git a/app/Http/Controllers/ChapterController.php b/app/Http/Controllers/ChapterController.php
index a4e0b6409..b737afc6d 100644
--- a/app/Http/Controllers/ChapterController.php
+++ b/app/Http/Controllers/ChapterController.php
@@ -107,17 +107,14 @@ class ChapterController extends Controller
      * @param          $bookSlug
      * @param          $chapterSlug
      * @return Response
+     * @throws \BookStack\Exceptions\NotFoundException
      */
     public function update(Request $request, $bookSlug, $chapterSlug)
     {
         $chapter = $this->entityRepo->getBySlug('chapter', $chapterSlug, $bookSlug);
         $this->checkOwnablePermission('chapter-update', $chapter);
-        if ($chapter->name !== $request->get('name')) {
-            $chapter->slug = $this->entityRepo->findSuitableSlug('chapter', $request->get('name'), $chapter->id, $chapter->book->id);
-        }
-        $chapter->fill($request->all());
-        $chapter->updated_by = user()->id;
-        $chapter->save();
+
+        $this->entityRepo->updateFromInput('chapter', $chapter, $request->all());
         Activity::add($chapter, 'chapter_update', $chapter->book->id);
         return redirect($chapter->getUrl());
     }
diff --git a/app/Repos/EntityRepo.php b/app/Repos/EntityRepo.php
index ece9aa305..e94d34369 100644
--- a/app/Repos/EntityRepo.php
+++ b/app/Repos/EntityRepo.php
@@ -492,14 +492,19 @@ class EntityRepo
     public function createFromInput($type, $input = [], $book = false)
     {
         $isChapter = strtolower($type) === 'chapter';
-        $entity = $this->getEntity($type)->newInstance($input);
-        $entity->slug = $this->findSuitableSlug($type, $entity->name, false, $isChapter ? $book->id : false);
-        $entity->created_by = user()->id;
-        $entity->updated_by = user()->id;
-        $isChapter ? $book->chapters()->save($entity) : $entity->save();
-        $this->permissionService->buildJointPermissionsForEntity($entity);
-        $this->searchService->indexEntity($entity);
-        return $entity;
+        $entityModel = $this->getEntity($type)->newInstance($input);
+        $entityModel->slug = $this->findSuitableSlug($type, $entityModel->name, false, $isChapter ? $book->id : false);
+        $entityModel->created_by = user()->id;
+        $entityModel->updated_by = user()->id;
+        $isChapter ? $book->chapters()->save($entityModel) : $entityModel->save();
+
+        if (isset($input['tags'])) {
+            $this->tagRepo->saveTagsToEntity($entityModel, $input['tags']);
+        }
+
+        $this->permissionService->buildJointPermissionsForEntity($entityModel);
+        $this->searchService->indexEntity($entityModel);
+        return $entityModel;
     }
 
     /**
@@ -518,6 +523,11 @@ class EntityRepo
         $entityModel->fill($input);
         $entityModel->updated_by = user()->id;
         $entityModel->save();
+
+        if (isset($input['tags'])) {
+            $this->tagRepo->saveTagsToEntity($entityModel, $input['tags']);
+        }
+
         $this->permissionService->buildJointPermissionsForEntity($entityModel);
         $this->searchService->indexEntity($entityModel);
         return $entityModel;
diff --git a/resources/assets/js/vues/tag-manager.js b/resources/assets/js/vues/tag-manager.js
index 97c00487e..177af681f 100644
--- a/resources/assets/js/vues/tag-manager.js
+++ b/resources/assets/js/vues/tag-manager.js
@@ -2,7 +2,8 @@ const draggable = require('vuedraggable');
 const autosuggest = require('./components/autosuggest');
 
 let data = {
-    pageId: false,
+    entityId: false,
+    entityType: null,
     tags: [],
 };
 
@@ -48,9 +49,10 @@ let methods = {
 };
 
 function mounted() {
-    this.pageId = Number(this.$el.getAttribute('page-id'));
+    this.entityId = Number(this.$el.getAttribute('entity-id'));
+    this.entityType = this.$el.getAttribute('entity-type');
 
-    let url = window.baseUrl(`/ajax/tags/get/page/${this.pageId}`);
+    let url = window.baseUrl(`/ajax/tags/get/${this.entityType}/${this.entityId}`);
     this.$http.get(url).then(response => {
         let tags = response.data;
         for (let i = 0, len = tags.length; i < len; i++) {
diff --git a/resources/assets/sass/_blocks.scss b/resources/assets/sass/_blocks.scss
index 4cf2397bc..f876ff281 100644
--- a/resources/assets/sass/_blocks.scss
+++ b/resources/assets/sass/_blocks.scss
@@ -226,6 +226,7 @@
     text-align: center;
     justify-content: center;
     width: 28px;
+    flex-grow: 0;
     padding-left: $-xs;
     padding-right: $-xs;
     &:hover {
@@ -237,6 +238,7 @@
   }
   > div .outline input {
     margin: $-s 0;
+    width: 100%;
   }
   > div.padded {
     padding: $-s 0 !important;
@@ -251,6 +253,7 @@
   > div {
     padding: 0 $-s;
     max-width: 80%;
+    flex: 1;
   }
 }
 
diff --git a/resources/assets/sass/_components.scss b/resources/assets/sass/_components.scss
index 84eebc89b..31e006e27 100644
--- a/resources/assets/sass/_components.scss
+++ b/resources/assets/sass/_components.scss
@@ -604,3 +604,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
     color: #999;
   }
 }
+
+#tag-manager .drag-card {
+  max-width: 500px;
+}
\ No newline at end of file
diff --git a/resources/assets/sass/_forms.scss b/resources/assets/sass/_forms.scss
index 11adc7951..3ab2de522 100644
--- a/resources/assets/sass/_forms.scss
+++ b/resources/assets/sass/_forms.scss
@@ -237,6 +237,9 @@ input:checked + .toggle-switch {
   &.open .collapse-title label:before {
     transform: rotate(90deg);
   }
+  &+.form-group[collapsible] {
+    margin-top: -($-s + 1);
+  }
 }
 
 .inline-input-style {
diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php
index 8a47ae011..c25dbb623 100644
--- a/resources/lang/en/entities.php
+++ b/resources/lang/en/entities.php
@@ -200,8 +200,10 @@ return [
      * Editor sidebar
      */
     'page_tags' => 'Page Tags',
+    'chapter_tags' => 'Chapter Tags',
+    'book_tags' => 'Book Tags',
     'tag' => 'Tag',
-    'tags' =>  '',
+    'tags' =>  'Tags',
     'tag_value' => 'Tag Value (Optional)',
     'tags_explain' => "Add some tags to better categorise your content. \n You can assign a value to a tag for more in-depth organisation.",
     'tags_add' => 'Add another tag',
diff --git a/resources/views/books/form.blade.php b/resources/views/books/form.blade.php
index 0620ae976..880149777 100644
--- a/resources/views/books/form.blade.php
+++ b/resources/views/books/form.blade.php
@@ -11,23 +11,32 @@
 </div>
 
 <div class="form-group" collapsible id="logo-control">
-        <div class="collapse-title text-primary" collapsible-trigger>
-            <label for="user-avatar">{{ trans('common.cover_image') }}</label>
-        </div>
-        <div class="collapse-content" collapsible-content>
-            <p class="small">{{ trans('common.cover_image_description') }}</p>
+    <div class="collapse-title text-primary" collapsible-trigger>
+        <label for="user-avatar">{{ trans('common.cover_image') }}</label>
+    </div>
+    <div class="collapse-content" collapsible-content>
+        <p class="small">{{ trans('common.cover_image_description') }}</p>
 
-            @include('components.image-picker', [
-                'resizeHeight' => '512',
-                'resizeWidth' => '512',
-                'showRemove' => false,
-                'defaultImage' => baseUrl('/book_default_cover.png'),
-                'currentImage' => @isset($model) ? $model->getBookCover() : baseUrl('/book_default_cover.png') ,
-                'currentId' => @isset($model) ? $model->image_id : 0,
-                'name' => 'image_id',
-                'imageClass' => 'cover'
-            ])
-        </div>
+        @include('components.image-picker', [
+            'resizeHeight' => '512',
+            'resizeWidth' => '512',
+            'showRemove' => false,
+            'defaultImage' => baseUrl('/book_default_cover.png'),
+            'currentImage' => @isset($model) ? $model->getBookCover() : baseUrl('/book_default_cover.png') ,
+            'currentId' => @isset($model) ? $model->image_id : 0,
+            'name' => 'image_id',
+            'imageClass' => 'cover'
+        ])
+    </div>
+</div>
+
+<div class="form-group" collapsible id="logo-control">
+    <div class="collapse-title text-primary" collapsible-trigger>
+        <label for="user-avatar">{{ trans('entities.book_tags') }}</label>
+    </div>
+    <div class="collapse-content" collapsible-content>
+        @include('components.tag-manager', ['entity' => isset($book)?$book:null, 'entityType' => 'chapter'])
+    </div>
 </div>
 
 <div class="form-group text-right">
diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php
index d3a51cb3a..9f021b2b0 100644
--- a/resources/views/books/show.blade.php
+++ b/resources/views/books/show.blade.php
@@ -68,19 +68,28 @@
         </div>
     @endif
 
-    @if(count($activity) > 0)
-        <div class="activity card">
-            <h3>@icon('time') {{ trans('entities.recent_activity') }}</h3>
-            @include('partials/activity-list', ['activity' => $activity])
-        </div>
-    @endif
-
     <div class="card">
         <h3>@icon('info') {{ trans('common.details') }}</h3>
         <div class="body">
             @include('partials.entity-meta', ['entity' => $book])
         </div>
     </div>
+
+    @if($book->tags->count() > 0)
+        <div class="card tag-display">
+            <h3>@icon('tag') {{ trans('entities.book_tags') }}</h3>
+            <div class="body">
+                @include('components.tag-list', ['entity' => $book])
+            </div>
+        </div>
+    @endif
+
+    @if(count($activity) > 0)
+        <div class="activity card">
+            <h3>@icon('time') {{ trans('entities.recent_activity') }}</h3>
+            @include('partials/activity-list', ['activity' => $activity])
+        </div>
+    @endif
 @stop
 
 @section('container-attrs')
diff --git a/resources/views/chapters/form.blade.php b/resources/views/chapters/form.blade.php
index 19cf65a61..fde460844 100644
--- a/resources/views/chapters/form.blade.php
+++ b/resources/views/chapters/form.blade.php
@@ -11,6 +11,15 @@
     @include('form/textarea', ['name' => 'description'])
 </div>
 
+<div class="form-group" collapsible id="logo-control">
+    <div class="collapse-title text-primary" collapsible-trigger>
+        <label for="user-avatar">{{ trans('entities.chapter_tags') }}</label>
+    </div>
+    <div class="collapse-content" collapsible-content>
+        @include('components.tag-manager', ['entity' => isset($chapter)?$chapter:null, 'entityType' => 'chapter'])
+    </div>
+</div>
+
 <div class="form-group text-right">
     <a href="{{ isset($chapter) ? $chapter->getUrl() : $book->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
     <button type="submit" class="button pos">{{ trans('entities.chapters_save') }}</button>
diff --git a/resources/views/chapters/show.blade.php b/resources/views/chapters/show.blade.php
index 62bff243b..ea9820022 100644
--- a/resources/views/chapters/show.blade.php
+++ b/resources/views/chapters/show.blade.php
@@ -91,6 +91,15 @@
         </div>
     </div>
 
+    @if($chapter->tags->count() > 0)
+        <div class="card tag-display">
+            <h3>@icon('tag') {{ trans('entities.chapter_tags') }}</h3>
+            <div class="body">
+                @include('components.tag-list', ['entity' => $chapter])
+            </div>
+        </div>
+    @endif
+
     @include('partials/book-tree', ['book' => $book, 'sidebarTree' => $sidebarTree])
 @stop
 
diff --git a/resources/views/components/tag-list.blade.php b/resources/views/components/tag-list.blade.php
new file mode 100644
index 000000000..9f4273c5a
--- /dev/null
+++ b/resources/views/components/tag-list.blade.php
@@ -0,0 +1,10 @@
+<table>
+    <tbody>
+    @foreach($entity->tags as $tag)
+        <tr class="tag">
+            <td @if(!$tag->value) colspan="2" @endif><a href="{{ baseUrl('/search?term=%5B' . urlencode($tag->name) .'%5D') }}">{{ $tag->name }}</a></td>
+            @if($tag->value) <td class="tag-value"><a href="{{ baseUrl('/search?term=%5B' . urlencode($tag->name) .'%3D' . urlencode($tag->value) . '%5D') }}">{{$tag->value}}</a></td> @endif
+        </tr>
+    @endforeach
+    </tbody>
+</table>
\ No newline at end of file
diff --git a/resources/views/components/tag-manager.blade.php b/resources/views/components/tag-manager.blade.php
new file mode 100644
index 000000000..801919a14
--- /dev/null
+++ b/resources/views/components/tag-manager.blade.php
@@ -0,0 +1,23 @@
+<div id="tag-manager" entity-id="{{ isset($entity) ? $entity->id : 0 }}" entity-type="{{ $entity ? $entity->getType() : $entityType }}">
+    <div class="tags">
+        <p class="muted small">{!! nl2br(e(trans('entities.tags_explain'))) !!}</p>
+
+
+        <draggable :options="{handle: '.handle'}" :list="tags" element="div">
+            <div v-for="(tag, i) in tags" :key="tag.key" class="card drag-card">
+                <div class="handle" >@icon('grip')</div>
+                <div>
+                    <autosuggest url="{{ baseUrl('/ajax/tags/suggest/names') }}" type="name" class="outline" :name="getTagFieldName(i, 'name')"
+                                 v-model="tag.name" @input="tagChange(tag)" @blur="tagBlur(tag)" placeholder="{{ trans('entities.tag') }}"/>
+                </div>
+                <div>
+                    <autosuggest url="{{ baseUrl('/ajax/tags/suggest/values') }}" type="value" class="outline" :name="getTagFieldName(i, 'value')"
+                                 v-model="tag.value" @change="tagChange(tag)" @blur="tagBlur(tag)" placeholder="{{ trans('entities.tag_value') }}"/>
+                </div>
+                <div v-show="tags.length !== 1" class="text-center drag-card-action text-neg" @click="removeTag(tag)">@icon('close')</div>
+            </div>
+        </draggable>
+
+        <button @click="addEmptyTag" type="button" class="text-button">{{ trans('entities.tags_add') }}</button>
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/pages/form-toolbox.blade.php b/resources/views/pages/form-toolbox.blade.php
index dd847f297..f6ee2510d 100644
--- a/resources/views/pages/form-toolbox.blade.php
+++ b/resources/views/pages/form-toolbox.blade.php
@@ -9,29 +9,10 @@
         @endif
     </div>
 
-    <div toolbox-tab-content="tags" id="tag-manager" page-id="{{ $page->id or 0 }}">
+    <div toolbox-tab-content="tags">
         <h4>{{ trans('entities.page_tags') }}</h4>
-        <div class="padded tags">
-            <p class="muted small">{!! nl2br(e(trans('entities.tags_explain'))) !!}</p>
-
-
-            <draggable :options="{handle: '.handle'}" :list="tags" element="div">
-                <div v-for="(tag, i) in tags" :key="tag.key" class="card drag-card">
-                    <div class="handle" >@icon('grip')</div>
-                    <div>
-                        <autosuggest url="{{ baseUrl('/ajax/tags/suggest/names') }}" type="name" class="outline" :name="getTagFieldName(i, 'name')"
-                                     v-model="tag.name" @input="tagChange(tag)" @blur="tagBlur(tag)" placeholder="{{ trans('entities.tag') }}"/>
-                    </div>
-                    <div>
-                        <autosuggest url="{{ baseUrl('/ajax/tags/suggest/values') }}" type="value" class="outline" :name="getTagFieldName(i, 'value')"
-                                     v-model="tag.value" @change="tagChange(tag)" @blur="tagBlur(tag)" placeholder="{{ trans('entities.tag_value') }}"/>
-                    </div>
-                    <div v-show="tags.length !== 1" class="text-center drag-card-action text-neg" @click="removeTag(tag)">@icon('close')</div>
-                </div>
-            </draggable>
-
-            <button @click="addEmptyTag" type="button" class="text-button">{{ trans('entities.tags_add') }}</button>
-
+        <div class="padded">
+            @include('components.tag-manager', ['entity' => $page, 'entityType' => 'page'])
         </div>
     </div>
 
diff --git a/resources/views/pages/show.blade.php b/resources/views/pages/show.blade.php
index f11da0f4f..a6c4f329d 100644
--- a/resources/views/pages/show.blade.php
+++ b/resources/views/pages/show.blade.php
@@ -81,16 +81,7 @@
         <div class="card tag-display">
             <h3>@icon('tag') {{ trans('entities.page_tags') }}</h3>
             <div class="body">
-                <table>
-                    <tbody>
-                    @foreach($page->tags as $tag)
-                        <tr class="tag">
-                            <td @if(!$tag->value) colspan="2" @endif><a href="{{ baseUrl('/search?term=%5B' . urlencode($tag->name) .'%5D') }}">{{ $tag->name }}</a></td>
-                            @if($tag->value) <td class="tag-value"><a href="{{ baseUrl('/search?term=%5B' . urlencode($tag->name) .'%3D' . urlencode($tag->value) . '%5D') }}">{{$tag->value}}</a></td> @endif
-                        </tr>
-                    @endforeach
-                    </tbody>
-                </table>
+                @include('components.tag-list', ['entity' => $page])
             </div>
         </div>
     @endif
diff --git a/tests/Entity/TagTest.php b/tests/Entity/TagTest.php
index 1ef7b7bde..7e1166388 100644
--- a/tests/Entity/TagTest.php
+++ b/tests/Entity/TagTest.php
@@ -1,6 +1,7 @@
 <?php namespace Tests;
 
-use BookStack\Role;
+use BookStack\Book;
+use BookStack\Chapter;
 use BookStack\Tag;
 use BookStack\Page;
 use BookStack\Services\PermissionService;
@@ -15,21 +16,21 @@ class TagTest extends BrowserKitTest
      * @param Tag[]|bool $tags
      * @return mixed
      */
-    protected function getPageWithTags($tags = false)
+    protected function getEntityWithTags($class, $tags = false)
     {
-        $page = Page::first();
+        $entity = $class::first();
 
         if (!$tags) {
             $tags = factory(Tag::class, $this->defaultTagCount)->make();
         }
 
-        $page->tags()->saveMany($tags);
-        return $page;
+        $entity->tags()->saveMany($tags);
+        return $entity;
     }
 
     public function test_get_page_tags()
     {
-        $page = $this->getPageWithTags();
+        $page = $this->getEntityWithTags(Page::class);
 
         // Add some other tags to check they don't interfere
         factory(Tag::class, $this->defaultTagCount)->create();
@@ -41,6 +42,34 @@ class TagTest extends BrowserKitTest
         $this->assertTrue(count($json) === $this->defaultTagCount, "Returned JSON item count is not as expected");
     }
 
+    public function test_get_chapter_tags()
+    {
+        $chapter = $this->getEntityWithTags(Chapter::class);
+
+        // Add some other tags to check they don't interfere
+        factory(Tag::class, $this->defaultTagCount)->create();
+
+        $this->asAdmin()->get("/ajax/tags/get/chapter/" . $chapter->id)
+            ->shouldReturnJson();
+
+        $json = json_decode($this->response->getContent());
+        $this->assertTrue(count($json) === $this->defaultTagCount, "Returned JSON item count is not as expected");
+    }
+
+    public function test_get_book_tags()
+    {
+        $book = $this->getEntityWithTags(Book::class);
+
+        // Add some other tags to check they don't interfere
+        factory(Tag::class, $this->defaultTagCount)->create();
+
+        $this->asAdmin()->get("/ajax/tags/get/book/" . $book->id)
+            ->shouldReturnJson();
+
+        $json = json_decode($this->response->getContent());
+        $this->assertTrue(count($json) === $this->defaultTagCount, "Returned JSON item count is not as expected");
+    }
+
     public function test_tag_name_suggestions()
     {
         // Create some tags with similar names to test with
@@ -51,7 +80,7 @@ class TagTest extends BrowserKitTest
         $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'county']));
         $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'planet']));
         $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'plans']));
-        $page = $this->getPageWithTags($attrs);
+        $page = $this->getEntityWithTags(Page::class, $attrs);
 
         $this->asAdmin()->get('/ajax/tags/suggest/names?search=dog')->seeJsonEquals([]);
         $this->get('/ajax/tags/suggest/names?search=co')->seeJsonEquals(['color', 'country', 'county']);
@@ -69,7 +98,7 @@ class TagTest extends BrowserKitTest
         $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'county', 'value' => 'dog']));
         $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'planet', 'value' => 'catapult']));
         $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'plans', 'value' => 'dodgy']));
-        $page = $this->getPageWithTags($attrs);
+        $page = $this->getEntityWithTags(Page::class, $attrs);
 
         $this->asAdmin()->get('/ajax/tags/suggest/values?search=ora')->seeJsonEquals([]);
         $this->get('/ajax/tags/suggest/values?search=cat')->seeJsonEquals(['cats', 'cattery', 'catapult']);
@@ -85,7 +114,7 @@ class TagTest extends BrowserKitTest
         $attrs = collect();
         $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'country']));
         $attrs = $attrs->merge(factory(Tag::class, 5)->make(['name' => 'color']));
-        $page = $this->getPageWithTags($attrs);
+        $page = $this->getEntityWithTags(Page::class, $attrs);
 
         $this->asAdmin()->get('/ajax/tags/suggest/names?search=co')->seeJsonEquals(['color', 'country']);
         $this->asEditor()->get('/ajax/tags/suggest/names?search=co')->seeJsonEquals(['color', 'country']);