commit
						921e25e7e1
					
				| 
						 | 
				
			
			@ -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'] ?? ''),
 | 
			
		||||
        ]);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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.
 | 
			
		||||
     */
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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  | 
| 
						 | 
				
			
			@ -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  | 
| 
						 | 
				
			
			@ -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',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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.',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
 */
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -44,6 +44,11 @@
 | 
			
		|||
            @endif
 | 
			
		||||
 | 
			
		||||
            @include('entities.view-toggle', ['view' => $view, 'type' => 'books'])
 | 
			
		||||
 | 
			
		||||
            <a href="{{ url('/tags') }}" class="icon-list-item">
 | 
			
		||||
                <span>@icon('tag')</span>
 | 
			
		||||
                <span>{{ trans('entities.tags_view_tags') }}</span>
 | 
			
		||||
            </a>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			@ -5,12 +5,15 @@
 | 
			
		|||
     refs="tag-manager@add-remove"
 | 
			
		||||
     class="tags">
 | 
			
		||||
 | 
			
		||||
        <p class="text-muted small">{!! nl2br(e(trans('entities.tags_explain'))) !!}</p>
 | 
			
		||||
    <p class="text-muted small">
 | 
			
		||||
        {!! nl2br(e(trans('entities.tags_explain'))) !!} <br>
 | 
			
		||||
        <a href="{{ url('/tags') }}" target="_blank">{{ trans('entities.tags_view_existing_tags') }}</a>.
 | 
			
		||||
    </p>
 | 
			
		||||
 | 
			
		||||
        <div component="sortable-list"
 | 
			
		||||
             option:sortable-list:handle-selector=".handle">
 | 
			
		||||
            @include('entities.tag-manager-list', ['tags' => $entity ? $entity->tags->all() : []])
 | 
			
		||||
        </div>
 | 
			
		||||
    <div component="sortable-list"
 | 
			
		||||
         option:sortable-list:handle-selector=".handle">
 | 
			
		||||
        @include('entities.tag-manager-list', ['tags' => $entity ? $entity->tags->all() : []])
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
        <button refs="add-remove-rows@add" type="button" class="text-button">{{ trans('entities.tags_add') }}</button>
 | 
			
		||||
    <button refs="add-remove-rows@add" type="button" class="text-button">{{ trans('entities.tags_add') }}</button>
 | 
			
		||||
</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			@ -15,7 +15,13 @@
 | 
			
		|||
                    <span>{{ trans('entities.shelves_new_action') }}</span>
 | 
			
		||||
                </a>
 | 
			
		||||
            @endif
 | 
			
		||||
 | 
			
		||||
            @include('entities.view-toggle', ['view' => $view, 'type' => 'shelves'])
 | 
			
		||||
 | 
			
		||||
            <a href="{{ url('/tags') }}" class="icon-list-item">
 | 
			
		||||
                <span>@icon('tag')</span>
 | 
			
		||||
                <span>{{ trans('entities.tags_view_tags') }}</span>
 | 
			
		||||
            </a>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,56 @@
 | 
			
		|||
@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' => ['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
 | 
			
		||||
 | 
			
		||||
            @if(count($tags) > 0)
 | 
			
		||||
                <table class="table expand-to-padding mt-m">
 | 
			
		||||
                    @foreach($tags as $tag)
 | 
			
		||||
                        @include('tags.parts.table-row', ['tag' => $tag, 'nameFilter' => $nameFilter])
 | 
			
		||||
                    @endforeach
 | 
			
		||||
                </table>
 | 
			
		||||
 | 
			
		||||
                <div>
 | 
			
		||||
                    {{ $tags->links() }}
 | 
			
		||||
                </div>
 | 
			
		||||
            @else
 | 
			
		||||
                <p class="text-muted italic my-xl">
 | 
			
		||||
                    {{ trans('common.no_items') }}.
 | 
			
		||||
                    <br>
 | 
			
		||||
                    {{ trans('entities.tags_list_empty_hint') }}
 | 
			
		||||
                </p>
 | 
			
		||||
            @endif
 | 
			
		||||
        </main>
 | 
			
		||||
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
@stop
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,37 @@
 | 
			
		|||
<tr>
 | 
			
		||||
    <td>
 | 
			
		||||
        <span class="text-bigger mr-xl">@include('entities.tag', ['tag' => $tag])</span>
 | 
			
		||||
    </td>
 | 
			
		||||
    <td width="70" 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="70" 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="70" 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="70" 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="70" 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>
 | 
			
		||||
| 
						 | 
				
			
			@ -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']);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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.
 | 
			
		||||
     *
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue