diff --git a/app/Actions/TagRepo.php b/app/Actions/TagRepo.php index b892efe57..06a1b893d 100644 --- a/app/Actions/TagRepo.php +++ b/app/Actions/TagRepo.php @@ -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,22 +13,51 @@ class TagRepo protected $tag; protected $permissionService; - /** - * TagRepo constructor. - */ - public function __construct(Tag $tag, PermissionService $ps) + public function __construct(PermissionService $ps) { - $this->tag = $tag; $this->permissionService = $ps; } + /** + * Start a query against all tags in the system. + */ + public function queryWithTotals(string $searchTerm, string $nameFilter): Builder + { + $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'); + } + /** * Get tag name suggestions from scanning existing tag names. * If no search term is given the 50 most popular tag names are provided. */ 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'] ?? ''), + ]); } } diff --git a/app/Http/Controllers/TagController.php b/app/Http/Controllers/TagController.php index b0065af70..c8292a16b 100644 --- a/app/Http/Controllers/TagController.php +++ b/app/Http/Controllers/TagController.php @@ -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. */ diff --git a/resources/icons/info-filled.svg b/resources/icons/info-filled.svg index 4c91c86b7..0597dbdf2 100644 --- a/resources/icons/info-filled.svg +++ b/resources/icons/info-filled.svg @@ -1,4 +1,3 @@ - \ No newline at end of file diff --git a/resources/icons/leaderboard.svg b/resources/icons/leaderboard.svg new file mode 100644 index 000000000..9083330dc --- /dev/null +++ b/resources/icons/leaderboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/lang/en/common.php b/resources/lang/en/common.php index 161891bf4..722bf00db 100644 --- a/resources/lang/en/common.php +++ b/resources/lang/en/common.php @@ -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', diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index 4871b6225..71d062a02 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -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.', diff --git a/resources/sass/_blocks.scss b/resources/sass/_blocks.scss index f9c206154..ef03699f1 100644 --- a/resources/sass/_blocks.scss +++ b/resources/sass/_blocks.scss @@ -245,7 +245,7 @@ @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; } @@ -266,6 +266,35 @@ 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 */ diff --git a/resources/sass/_tables.scss b/resources/sass/_tables.scss index c78e13446..dd585733c 100644 --- a/resources/sass/_tables.scss +++ b/resources/sass/_tables.scss @@ -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 { diff --git a/resources/views/entities/tag-list.blade.php b/resources/views/entities/tag-list.blade.php index ffbd5c330..a49eef31b 100644 --- a/resources/views/entities/tag-list.blade.php +++ b/resources/views/entities/tag-list.blade.php @@ -1,11 +1,3 @@ @foreach($entity->tags as $tag) -
- @if($linked ?? true) -
@icon('tag'){{ $tag->name }}
- @if($tag->value)
{{$tag->value}}
@endif - @else -
@icon('tag'){{ $tag->name }}
- @if($tag->value)
{{$tag->value}}
@endif - @endif -
+ @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 index 000000000..057c70921 --- /dev/null +++ b/resources/views/entities/tag.blade.php @@ -0,0 +1,9 @@ +
+ @if($linked ?? true) +
@icon('tag'){{ $tag->name }}
+ @if($tag->value)
{{$tag->value}}
@endif + @else +
@icon('tag'){{ $tag->name }}
+ @if($tag->value)
{{$tag->value}}
@endif + @endif +
\ 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 index 000000000..4f2fa061e --- /dev/null +++ b/resources/views/form/request-query-inputs.blade.php @@ -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) + + @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 index 000000000..de231493e --- /dev/null +++ b/resources/views/tags/index.blade.php @@ -0,0 +1,85 @@ +@extends('layouts.simple') + +@section('body') +
+ +
+ +
+

{{ trans('entities.tags') }}

+ +
+
+
+ @include('form.request-query-inputs', ['params' => ['page', 'name']]) + +
+
+
+
+ + @if($nameFilter) +
+ {{ trans('common.filter_active') }} + @include('entities.tag', ['tag' => new \BookStack\Actions\Tag(['name' => $nameFilter])]) +
+ @include('form.request-query-inputs', ['params' => ['search']]) + +
+
+ @endif + + + + @foreach($tags as $tag) + + + + + + + + + + @endforeach +
+ @include('entities.tag', ['tag' => $tag]) + + @icon('leaderboard'){{ $tag->usages }} + + @icon('page'){{ $tag->page_count }} + + @icon('chapter'){{ $tag->chapter_count }} + + @icon('book'){{ $tag->book_count }} + + @icon('bookshelf'){{ $tag->shelf_count }} + + @if($tag->values ?? false) + {{ trans('entities.tags_x_unique_values', ['count' => $tag->values]) }} + @elseif(empty($nameFilter)) + {{ trans('entities.tags_all_values') }} + @endif +
+ +
+ {{ $tags->links() }} +
+
+ +
+ +@stop diff --git a/routes/web.php b/routes/web.php index 419a1e7f5..646201d55 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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']);