]> BookStack Code Mirror - bookstack/commitdiff
Started build of tag view
authorDan Brown <redacted>
Sat, 6 Nov 2021 16:30:20 +0000 (16:30 +0000)
committerDan Brown <redacted>
Sat, 6 Nov 2021 16:30:20 +0000 (16:30 +0000)
- Created listing
- Allows drilldown to tag name
- Shows totals

Not yet covered via testing

13 files changed:
app/Actions/TagRepo.php
app/Http/Controllers/TagController.php
resources/icons/info-filled.svg
resources/icons/leaderboard.svg [new file with mode: 0644]
resources/lang/en/common.php
resources/lang/en/entities.php
resources/sass/_blocks.scss
resources/sass/_tables.scss
resources/views/entities/tag-list.blade.php
resources/views/entities/tag.blade.php [new file with mode: 0644]
resources/views/form/request-query-inputs.blade.php [new file with mode: 0644]
resources/views/tags/index.blade.php [new file with mode: 0644]
routes/web.php

index b892efe577901191c4a7fea292e134eefbff86ea..06a1b893d888acc7415636ce7a2739a60764c2f1 100644 (file)
@@ -4,6 +4,7 @@ namespace BookStack\Actions;
 
 use BookStack\Auth\Permissions\PermissionService;
 use BookStack\Entities\Models\Entity;
+use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\DB;
 
@@ -12,13 +13,42 @@ class TagRepo
     protected $tag;
     protected $permissionService;
 
+    public function __construct(PermissionService $ps)
+    {
+        $this->permissionService = $ps;
+    }
+
     /**
-     * TagRepo constructor.
+     * Start a query against all tags in the system.
      */
-    public function __construct(Tag $tag, PermissionService $ps)
+    public function queryWithTotals(string $searchTerm, string $nameFilter): Builder
     {
-        $this->tag = $tag;
-        $this->permissionService = $ps;
+        $groupingAttribute = $nameFilter ? 'value' : 'name';
+        $query = Tag::query()
+            ->select([
+                'name',
+                ($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'),
+                DB::raw('COUNT(id) as usages'),
+                DB::raw('SUM(IF(entity_type = \'BookStack\\\\Page\', 1, 0)) as page_count'),
+                DB::raw('SUM(IF(entity_type = \'BookStack\\\\Chapter\', 1, 0)) as chapter_count'),
+                DB::raw('SUM(IF(entity_type = \'BookStack\\\\Book\', 1, 0)) as book_count'),
+                DB::raw('SUM(IF(entity_type = \'BookStack\\\\BookShelf\', 1, 0)) as shelf_count'),
+            ])
+            ->groupBy($groupingAttribute)
+            ->orderBy($groupingAttribute);
+
+        if ($nameFilter) {
+            $query->where('name', '=', $nameFilter);
+        }
+
+        if ($searchTerm) {
+            $query->where(function(Builder $query) use ($searchTerm) {
+                $query->where('name', 'like', '%' . $searchTerm . '%')
+                    ->orWhere('value', 'like', '%' . $searchTerm . '%');
+            });
+        }
+
+        return $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
     }
 
     /**
@@ -27,7 +57,7 @@ class TagRepo
      */
     public function getNameSuggestions(?string $searchTerm): Collection
     {
-        $query = $this->tag->newQuery()
+        $query = Tag::query()
             ->select('*', DB::raw('count(*) as count'))
             ->groupBy('name');
 
@@ -49,7 +79,7 @@ class TagRepo
      */
     public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection
     {
-        $query = $this->tag->newQuery()
+        $query = Tag::query()
             ->select('*', DB::raw('count(*) as count'))
             ->groupBy('value');
 
@@ -90,9 +120,9 @@ class TagRepo
      */
     protected function newInstanceFromInput(array $input): Tag
     {
-        $name = trim($input['name']);
-        $value = isset($input['value']) ? trim($input['value']) : '';
-
-        return $this->tag->newInstance(['name' => $name, 'value' => $value]);
+        return new Tag([
+            'name'  => trim($input['name']),
+            'value' => trim($input['value'] ?? ''),
+        ]);
     }
 }
index b0065af70f928e2f61cf8c8ec7d9050352503eb6..c8292a16b810771a26b51d6ede90fe1ed1ea2141 100644 (file)
@@ -17,6 +17,24 @@ class TagController extends Controller
         $this->tagRepo = $tagRepo;
     }
 
+    /**
+     * Show a listing of existing tags in the system.
+     */
+    public function index(Request $request)
+    {
+        $search = $request->get('search', '');
+        $nameFilter = $request->get('name', '');
+        $tags = $this->tagRepo
+            ->queryWithTotals($search, $nameFilter)
+            ->paginate(20);
+
+        return view('tags.index', [
+            'tags'   => $tags,
+            'search' => $search,
+            'nameFilter' => $nameFilter,
+        ]);
+    }
+
     /**
      * Get tag name suggestions from a given search term.
      */
index 4c91c86b77e460e8fd9bd1199f36b233fe671a8b..0597dbdf2a03852986c4523edd71ec40fbc0df55 100644 (file)
@@ -1,4 +1,3 @@
 <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
-    <path d="M0 0h24v24H0z" fill="none"/>
     <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/>
 </svg>
\ No newline at end of file
diff --git a/resources/icons/leaderboard.svg b/resources/icons/leaderboard.svg
new file mode 100644 (file)
index 0000000..9083330
--- /dev/null
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" viewBox="0 0 24 24" fill="#000000"><g><path d="M7.5,21H2V9h5.5V21z M14.75,3h-5.5v18h5.5V3z M22,11h-5.5v10H22V11z"/></g></svg>
\ No newline at end of file
index 161891bf489814d9d3faa3f2f1d9413c8cb12384..722bf00db9e827ad5d2c0db84a55adb4db9ad265 100644 (file)
@@ -45,6 +45,8 @@ return [
     'unfavourite' => 'Unfavourite',
     'next' => 'Next',
     'previous' => 'Previous',
+    'filter_active' => 'Active Filter:',
+    'filter_clear' => 'Clear Filter',
 
     // Sort Options
     'sort_options' => 'Sort Options',
index 4871b62253d8ff6f36a3b8736d9eb26ad898abd5..71d062a029c57594422902c29ec78f22f228d035 100644 (file)
@@ -258,6 +258,13 @@ return [
     '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',
     'tags_remove' => 'Remove this tag',
+    'tags_usages' => 'Total tag usages',
+    'tags_assigned_pages' => 'Assigned to Pages',
+    'tags_assigned_chapters' => 'Assigned to Chapters',
+    'tags_assigned_books' => 'Assigned to Books',
+    'tags_assigned_shelves' => 'Assigned to Shelves',
+    'tags_x_unique_values' => ':count unique values',
+    'tags_all_values' => 'All values',
     'attachments' => 'Attachments',
     'attachments_explain' => 'Upload some files or attach some links to display on your page. These are visible in the page sidebar.',
     'attachments_explain_instant_save' => 'Changes here are saved instantly.',
index f9c2061547fbdf5daf598d6ca75b7973d72fabfc..ef03699f1c5ee93c00ff583445d900400f7c928a 100644 (file)
   @include lightDark(border-color, #CCC, #666);
   a, span, a:hover, a:active {
     padding: 4px 8px;
-    @include lightDark(color, rgba(0, 0, 0, 0.6), rgba(255, 255, 255, 0.8));
+    @include lightDark(color, rgba(0, 0, 0, 0.7), rgba(255, 255, 255, 0.8));
     transition: background-color ease-in-out 80ms;
     text-decoration: none;
   }
   margin-bottom: 0;
 }
 
+td .tag-item {
+  margin-bottom: 0;
+}
+
+/**
+ * Pill boxes
+ */
+
+.pill {
+  display: inline-block;
+  border: 1px solid currentColor;
+  padding: .2em .8em;
+  font-size: 0.8em;
+  border-radius: 1rem;
+  position: relative;
+  overflow: hidden;
+  line-height: 1.4;
+  &:before {
+    content: '';
+    background-color: currentColor;
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    opacity: 0.1;
+  }
+}
+
 /**
  * API Docs
  */
index c78e13446129644f1ea9501c3b177435f281983e..dd585733ce4b20b79a7748a9afc442c68c2dbbd7 100644 (file)
@@ -35,7 +35,7 @@ table.table {
     font-weight: bold;
   }
   tr:hover {
-    @include lightDark(background-color, #eee, #333);
+    @include lightDark(background-color, #F2F2F2, #333);
   }
   .text-right {
     text-align: end;
@@ -49,6 +49,12 @@ table.table {
   a {
     display: inline-block;
   }
+  &.expand-to-padding {
+    margin-left: -$-s;
+    margin-right: -$-s;
+    width: calc(100% + (2*#{$-s}));
+    max-width: calc(100% + (2*#{$-s}));
+  }
 }
 
 table.no-style {
index ffbd5c3303f46cebb93795f6b58f210886b51293..a49eef31b23cfcab646f9c690b4fac978f7ba711 100644 (file)
@@ -1,11 +1,3 @@
 @foreach($entity->tags as $tag)
-    <div class="tag-item primary-background-light">
-        @if($linked ?? true)
-            <div class="tag-name"><a href="{{ $tag->nameUrl() }}">@icon('tag'){{ $tag->name }}</a></div>
-            @if($tag->value) <div class="tag-value"><a href="{{ $tag->valueUrl() }}">{{$tag->value}}</a></div> @endif
-        @else
-            <div class="tag-name"><span>@icon('tag'){{ $tag->name }}</span></div>
-            @if($tag->value) <div class="tag-value"><span>{{$tag->value}}</span></div> @endif
-        @endif
-    </div>
+    @include('entities.tag', ['tag' => $tag])
 @endforeach
\ No newline at end of file
diff --git a/resources/views/entities/tag.blade.php b/resources/views/entities/tag.blade.php
new file mode 100644 (file)
index 0000000..057c709
--- /dev/null
@@ -0,0 +1,9 @@
+<div class="tag-item primary-background-light" data-name="{{ $tag->name }}" data-value="{{ $tag->value }}">
+    @if($linked ?? true)
+        <div class="tag-name"><a href="{{ $tag->nameUrl() }}">@icon('tag'){{ $tag->name }}</a></div>
+        @if($tag->value) <div class="tag-value"><a href="{{ $tag->valueUrl() }}">{{$tag->value}}</a></div> @endif
+    @else
+        <div class="tag-name"><span>@icon('tag'){{ $tag->name }}</span></div>
+        @if($tag->value) <div class="tag-value"><span>{{$tag->value}}</span></div> @endif
+    @endif
+</div>
\ No newline at end of file
diff --git a/resources/views/form/request-query-inputs.blade.php b/resources/views/form/request-query-inputs.blade.php
new file mode 100644 (file)
index 0000000..4f2fa06
--- /dev/null
@@ -0,0 +1,8 @@
+{{--
+$params - The query paramters to convert to inputs.
+--}}
+@foreach(array_intersect_key(request()->query(), array_flip($params)) as $name => $value)
+    @if ($value)
+    <input type="hidden" name="{{ $name }}" value="{{ $value }}">
+    @endif
+@endforeach
\ No newline at end of file
diff --git a/resources/views/tags/index.blade.php b/resources/views/tags/index.blade.php
new file mode 100644 (file)
index 0000000..de23149
--- /dev/null
@@ -0,0 +1,85 @@
+@extends('layouts.simple')
+
+@section('body')
+    <div class="container small">
+
+        <main class="card content-wrap mt-xxl">
+
+            <div class="flex-container-row wrap justify-space-between items-center mb-s">
+                <h1 class="list-heading">{{ trans('entities.tags') }}</h1>
+
+                <div>
+                    <div class="block inline mr-xs">
+                        <form method="get" action="{{ url("/tags") }}">
+                            @include('form.request-query-inputs', ['params' => ['page', 'name']])
+                            <input type="text"
+                                   name="search"
+                                   placeholder="{{ trans('common.search') }}"
+                                   value="{{ $search }}">
+                        </form>
+                    </div>
+                </div>
+            </div>
+
+            @if($nameFilter)
+                <div class="mb-m">
+                    <span class="mr-xs">{{ trans('common.filter_active') }}</span>
+                    @include('entities.tag', ['tag' => new \BookStack\Actions\Tag(['name' => $nameFilter])])
+                    <form method="get" action="{{ url("/tags") }}" class="inline block">
+                        @include('form.request-query-inputs', ['params' => ['search']])
+                        <button class="text-button text-warn">@icon('close'){{ trans('common.filter_clear') }}</button>
+                    </form>
+                </div>
+            @endif
+
+
+            <table class="table expand-to-padding mt-m">
+                @foreach($tags as $tag)
+                    <tr>
+                        <td>
+                            <span class="text-bigger mr-xl">@include('entities.tag', ['tag' => $tag])</span>
+                        </td>
+                        <td width="60" class="px-xs">
+                            <a href="{{ isset($tag->value) ? $tag->valueUrl() : $tag->nameUrl() }}"
+                               title="{{ trans('entities.tags_usages') }}"
+                               class="pill text-muted">@icon('leaderboard'){{ $tag->usages }}</a>
+                        </td>
+                        <td width="60" class="px-xs">
+                            <a href="{{ isset($tag->value) ? $tag->valueUrl() : $tag->nameUrl() . '+{type:page}' }}"
+                               title="{{ trans('entities.tags_assigned_pages') }}"
+                               class="pill text-page">@icon('page'){{ $tag->page_count }}</a>
+                        </td>
+                        <td width="60" class="px-xs">
+                            <a href="{{ isset($tag->value) ? $tag->valueUrl() : $tag->nameUrl() . '+{type:chapter}' }}"
+                               title="{{ trans('entities.tags_assigned_chapters') }}"
+                               class="pill text-chapter">@icon('chapter'){{ $tag->chapter_count }}</a>
+                        </td>
+                        <td width="60" class="px-xs">
+                            <a href="{{ isset($tag->value) ? $tag->valueUrl() : $tag->nameUrl() . '+{type:book}' }}"
+                               title="{{ trans('entities.tags_assigned_books') }}"
+                               class="pill text-book">@icon('book'){{ $tag->book_count }}</a>
+                        </td>
+                        <td width="60" class="px-xs">
+                            <a href="{{ isset($tag->value) ? $tag->valueUrl() : $tag->nameUrl() . '+{type:bookshelf}' }}"
+                               title="{{ trans('entities.tags_assigned_shelves') }}"
+                               class="pill text-bookshelf">@icon('bookshelf'){{ $tag->shelf_count }}</a>
+                        </td>
+                        <td class="text-right text-muted">
+                            @if($tag->values ?? false)
+                                <a href="{{ url('/tags?name=' . urlencode($tag->name)) }}">{{ trans('entities.tags_x_unique_values', ['count' => $tag->values]) }}</a>
+                            @elseif(empty($nameFilter))
+                                <a href="{{ url('/tags?name=' . urlencode($tag->name)) }}">{{ trans('entities.tags_all_values') }}</a>
+                            @endif
+                        </td>
+                    </tr>
+                @endforeach
+            </table>
+
+            <div>
+                {{ $tags->links() }}
+            </div>
+        </main>
+
+    </div>
+
+@stop
index 419a1e7f5b18634ded8c9592a9bff6e7dbfea610..646201d55231f54a6da573f062707ba896ad3a6d 100644 (file)
@@ -165,11 +165,10 @@ Route::middleware('auth')->group(function () {
     Route::get('/ajax/page/{id}', [PageController::class, 'getPageAjax']);
     Route::delete('/ajax/page/{id}', [PageController::class, 'ajaxDestroy']);
 
-    // Tag routes (AJAX)
-    Route::prefix('ajax/tags')->group(function () {
-        Route::get('/suggest/names', [TagController::class, 'getNameSuggestions']);
-        Route::get('/suggest/values', [TagController::class, 'getValueSuggestions']);
-    });
+    // Tag routes
+    Route::get('/tags', [TagController::class, 'index']);
+    Route::get('/ajax/tags/suggest/names', [TagController::class, 'getNameSuggestions']);
+    Route::get('/ajax/tags/suggest/values', [TagController::class, 'getValueSuggestions']);
 
     Route::get('/ajax/search/entities', [SearchController::class, 'searchEntitiesAjax']);