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..aaf0cb9b2 100644 --- a/app/Http/Controllers/TagController.php +++ b/app/Http/Controllers/TagController.php @@ -17,6 +17,28 @@ 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(50) + ->appends(array_filter([ + 'search' => $search, + 'name' => $nameFilter + ])); + + 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..5cf47629a 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -258,6 +258,16 @@ 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', + 'tags_view_tags' => 'View Tags', + 'tags_view_existing_tags' => 'View existing tags', + 'tags_list_empty_hint' => 'Tags can be assigned via the page editor sidebar or while editing the details of a book, chapter or shelf.', '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/books/index.blade.php b/resources/views/books/index.blade.php index 5f1bd3743..6573bbe6a 100644 --- a/resources/views/books/index.blade.php +++ b/resources/views/books/index.blade.php @@ -44,6 +44,11 @@ @endif @include('entities.view-toggle', ['view' => $view, 'type' => 'books']) + + + @icon('tag') + {{ trans('entities.tags_view_tags') }} + 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-manager.blade.php b/resources/views/entities/tag-manager.blade.php index 5975c4bd9..803def037 100644 --- a/resources/views/entities/tag-manager.blade.php +++ b/resources/views/entities/tag-manager.blade.php @@ -5,12 +5,15 @@ refs="tag-manager@add-remove" class="tags"> -

{!! nl2br(e(trans('entities.tags_explain'))) !!}

+

+ {!! nl2br(e(trans('entities.tags_explain'))) !!}
+ {{ trans('entities.tags_view_existing_tags') }}. +

-
- @include('entities.tag-manager-list', ['tags' => $entity ? $entity->tags->all() : []]) -
+
+ @include('entities.tag-manager-list', ['tags' => $entity ? $entity->tags->all() : []]) +
- + \ 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/shelves/index.blade.php b/resources/views/shelves/index.blade.php index 5c25356b0..ee52769aa 100644 --- a/resources/views/shelves/index.blade.php +++ b/resources/views/shelves/index.blade.php @@ -15,7 +15,13 @@ {{ trans('entities.shelves_new_action') }} @endif + @include('entities.view-toggle', ['view' => $view, 'type' => 'shelves']) + + + @icon('tag') + {{ trans('entities.tags_view_tags') }} + diff --git a/resources/views/tags/index.blade.php b/resources/views/tags/index.blade.php new file mode 100644 index 000000000..c88449ce7 --- /dev/null +++ b/resources/views/tags/index.blade.php @@ -0,0 +1,56 @@ +@extends('layouts.simple') + +@section('body') +
+ +
+ +
+

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

+ +
+
+
+ @include('form.request-query-inputs', ['params' => ['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 + + @if(count($tags) > 0) + + @foreach($tags as $tag) + @include('tags.parts.table-row', ['tag' => $tag, 'nameFilter' => $nameFilter]) + @endforeach +
+ +
+ {{ $tags->links() }} +
+ @else +

+ {{ trans('common.no_items') }}. +
+ {{ trans('entities.tags_list_empty_hint') }} +

+ @endif +
+ +
+ +@stop diff --git a/resources/views/tags/parts/table-row.blade.php b/resources/views/tags/parts/table-row.blade.php new file mode 100644 index 000000000..aa04959a9 --- /dev/null +++ b/resources/views/tags/parts/table-row.blade.php @@ -0,0 +1,37 @@ + + + @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 + + \ No newline at end of file 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']); diff --git a/tests/Entity/TagTest.php b/tests/Entity/TagTest.php index 9b3fb1532..db76cae5f 100644 --- a/tests/Entity/TagTest.php +++ b/tests/Entity/TagTest.php @@ -3,6 +3,7 @@ namespace Tests\Entity; use BookStack\Actions\Tag; +use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use Tests\TestCase; @@ -98,4 +99,95 @@ class TagTest extends TestCase $resp->assertElementContains('[href="' . $page->getUrl() . '"]', 'color'); $resp->assertElementContains('[href="' . $page->getUrl() . '"]', 'red'); } + + public function test_tags_index_shows_tag_name_as_expected_with_right_counts() + { + /** @var Page $page */ + $page = Page::query()->first(); + $page->tags()->create(['name' => 'Category', 'value' => 'GreatTestContent']); + $page->tags()->create(['name' => 'Category', 'value' => 'OtherTestContent']); + + $resp = $this->asEditor()->get('/tags'); + $resp->assertSee('Category'); + $resp->assertElementCount('.tag-item', 1); + $resp->assertDontSee('GreatTestContent'); + $resp->assertDontSee('OtherTestContent'); + $resp->assertElementContains('a[title="Total tag usages"]', '2'); + $resp->assertElementContains('a[title="Assigned to Pages"]', '2'); + $resp->assertElementContains('a[title="Assigned to Books"]', '0'); + $resp->assertElementContains('a[title="Assigned to Chapters"]', '0'); + $resp->assertElementContains('a[title="Assigned to Shelves"]', '0'); + $resp->assertElementContains('a[href$="/tags?name=Category"]', '2 unique values'); + + /** @var Book $book */ + $book = Book::query()->first(); + $book->tags()->create(['name' => 'Category', 'value' => 'GreatTestContent']); + $resp = $this->asEditor()->get('/tags'); + $resp->assertElementContains('a[title="Total tag usages"]', '3'); + $resp->assertElementContains('a[title="Assigned to Books"]', '1'); + $resp->assertElementContains('a[href$="/tags?name=Category"]', '2 unique values'); + } + + public function test_tag_index_can_be_searched() + { + /** @var Page $page */ + $page = Page::query()->first(); + $page->tags()->create(['name' => 'Category', 'value' => 'GreatTestContent']); + + $resp = $this->asEditor()->get('/tags?search=cat'); + $resp->assertElementContains('.tag-item .tag-name', 'Category'); + + $resp = $this->asEditor()->get('/tags?search=content'); + $resp->assertElementContains('.tag-item .tag-name', 'Category'); + $resp->assertElementContains('.tag-item .tag-value', 'GreatTestContent'); + + $resp = $this->asEditor()->get('/tags?search=other'); + $resp->assertElementNotExists('.tag-item .tag-name'); + } + + public function test_tag_index_can_be_scoped_to_specific_tag_name() + { + /** @var Page $page */ + $page = Page::query()->first(); + $page->tags()->create(['name' => 'Category', 'value' => 'GreatTestContent']); + $page->tags()->create(['name' => 'Category', 'value' => 'OtherTestContent']); + $page->tags()->create(['name' => 'OtherTagName', 'value' => 'OtherValue']); + + $resp = $this->asEditor()->get('/tags?name=Category'); + $resp->assertSee('Category'); + $resp->assertSee('GreatTestContent'); + $resp->assertSee('OtherTestContent'); + $resp->assertDontSee('OtherTagName'); + $resp->assertElementCount('table .tag-item', 2); + $resp->assertSee('Active Filter:'); + $resp->assertElementContains('form[action$="/tags"]', 'Clear Filter'); + } + + public function test_tags_index_adheres_to_page_permissions() + { + /** @var Page $page */ + $page = Page::query()->first(); + $page->tags()->create(['name' => 'SuperCategory', 'value' => 'GreatTestContent']); + + $resp = $this->asEditor()->get('/tags'); + $resp->assertSee('SuperCategory'); + $resp = $this->get('/tags?name=SuperCategory'); + $resp->assertSee('GreatTestContent'); + + $page->restricted = true; + $this->regenEntityPermissions($page); + + $resp = $this->asEditor()->get('/tags'); + $resp->assertDontSee('SuperCategory'); + $resp = $this->get('/tags?name=SuperCategory'); + $resp->assertDontSee('GreatTestContent'); + } + + public function test_tag_index_shows_message_on_no_results() + { + /** @var Page $page */ + $resp = $this->asEditor()->get('/tags?search=testingval'); + $resp->assertSee('No items available'); + $resp->assertSee('Tags can be assigned via the page editor sidebar'); + } } diff --git a/tests/TestResponse.php b/tests/TestResponse.php index 5e2be3ac3..4e53aa020 100644 --- a/tests/TestResponse.php +++ b/tests/TestResponse.php @@ -53,6 +53,26 @@ class TestResponse extends BaseTestResponse return $this; } + /** + * Assert the response contains the given count of elements + * that match the given css selector. + * + * @return $this + */ + public function assertElementCount(string $selector, int $count) + { + $elements = $this->crawler()->filter($selector); + PHPUnit::assertTrue( + $elements->count() === $count, + 'Unable to ' . $count . ' element(s) matching the selector: ' . PHP_EOL . PHP_EOL . + "[{$selector}]" . PHP_EOL . PHP_EOL . + 'found ' . $elements->count() . ' within' . PHP_EOL . PHP_EOL . + "[{$this->getContent()}]." + ); + + return $this; + } + /** * Assert the response does not contain the specified element. *