Started build of tag view

- Created listing
- Allows drilldown to tag name
- Shows totals

Not yet covered via testing
This commit is contained in:
Dan Brown 2021-11-06 16:30:20 +00:00
parent 8d7c8ac8bf
commit 929c8312bd
No known key found for this signature in database
GPG key ID: 46D9F943C24A2EF9
13 changed files with 213 additions and 28 deletions

View 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,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'] ?? ''),
]);
}
}

View 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.
*/

View 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>

Before

Width:  |  Height:  |  Size: 222 B

After

Width:  |  Height:  |  Size: 180 B

View file

@ -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>

After

Width:  |  Height:  |  Size: 199 B

View 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',

View 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.',

View file

@ -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
*/

View 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 {

View 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

View file

@ -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>

View file

@ -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

View file

@ -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

View 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']);