From 986346a0e9de839ac876cf74c2b9e7d15da43d9b Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sat, 29 Oct 2022 15:23:21 +0100
Subject: [PATCH 01/20] Redesigned users list to be responsive and aligned

---
 .../Queries/AllUsersPaginatedAndSorted.php    |  3 +
 app/Http/Controllers/UserController.php       | 10 +--
 resources/js/components/entity-permissions.js |  2 +-
 resources/lang/en/settings.php                |  1 +
 resources/sass/_components.scss               | 62 +++++++-------
 resources/sass/_layout.scss                   | 33 ++++++++
 resources/views/books/parts/list.blade.php    |  6 +-
 .../views/{entities => common}/sort.blade.php |  0
 .../form/entity-permissions-row.blade.php     |  4 +-
 .../views/form/entity-permissions.blade.php   |  4 +-
 resources/views/shelves/parts/list.blade.php  |  6 +-
 resources/views/shelves/show.blade.php        |  2 +-
 resources/views/users/index.blade.php         | 80 ++++++++++---------
 13 files changed, 127 insertions(+), 86 deletions(-)
 rename resources/views/{entities => common}/sort.blade.php (100%)

diff --git a/app/Auth/Queries/AllUsersPaginatedAndSorted.php b/app/Auth/Queries/AllUsersPaginatedAndSorted.php
index 7b849eaf4..29e58fe09 100644
--- a/app/Auth/Queries/AllUsersPaginatedAndSorted.php
+++ b/app/Auth/Queries/AllUsersPaginatedAndSorted.php
@@ -19,6 +19,9 @@ class AllUsersPaginatedAndSorted
     public function run(int $count, array $sortData): LengthAwarePaginator
     {
         $sort = $sortData['sort'];
+        if ($sort === 'created_at') {
+            $sort = 'users.created_at';
+        }
 
         $query = User::query()->select(['*'])
             ->scopes(['withLastActivityAt'])
diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php
index 895481d02..9b089c29a 100644
--- a/app/Http/Controllers/UserController.php
+++ b/app/Http/Controllers/UserController.php
@@ -37,15 +37,15 @@ class UserController extends Controller
     {
         $this->checkPermission('users-manage');
         $listDetails = [
-            'order'  => $request->get('order', 'asc'),
             'search' => $request->get('search', ''),
-            'sort'   => $request->get('sort', 'name'),
+            'sort'   => setting()->getForCurrentUser('users_sort', 'name'),
+            'order'  => setting()->getForCurrentUser('users_sort_order', 'asc'),
         ];
 
         $users = (new AllUsersPaginatedAndSorted())->run(20, $listDetails);
 
         $this->setPageTitle(trans('settings.users'));
-        $users->appends($listDetails);
+        $users->appends(['search' => $listDetails['search']]);
 
         return view('users.index', [
             'users'       => $users,
@@ -251,7 +251,7 @@ class UserController extends Controller
      */
     public function changeSort(Request $request, string $id, string $type)
     {
-        $validSortTypes = ['books', 'bookshelves', 'shelf_books'];
+        $validSortTypes = ['books', 'bookshelves', 'shelf_books', 'users'];
         if (!in_array($type, $validSortTypes)) {
             return redirect()->back(500);
         }
@@ -318,7 +318,7 @@ class UserController extends Controller
         $this->checkPermissionOrCurrentUser('users-manage', $userId);
 
         $sort = $request->get('sort');
-        if (!in_array($sort, ['name', 'created_at', 'updated_at', 'default'])) {
+        if (!in_array($sort, ['name', 'created_at', 'updated_at', 'default', 'email', 'last_activity_at'])) {
             $sort = 'name';
         }
 
diff --git a/resources/js/components/entity-permissions.js b/resources/js/components/entity-permissions.js
index c67c85f19..0dec5ca09 100644
--- a/resources/js/components/entity-permissions.js
+++ b/resources/js/components/entity-permissions.js
@@ -62,7 +62,7 @@ class EntityPermissions {
     }
 
     removeRowOnButtonClick(button) {
-        const row = button.closest('.content-permissions-row');
+        const row = button.closest('.item-list-row');
         const roleId = button.dataset.roleId;
         const roleName = button.dataset.roleName;
 
diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php
index 1ad271e7c..d4d6d3bae 100755
--- a/resources/lang/en/settings.php
+++ b/resources/lang/en/settings.php
@@ -172,6 +172,7 @@ return [
 
     // Users
     'users' => 'Users',
+    'users_index_desc' => 'Create & manage individual user accounts within the system. User accounts are used for login and attribution of content & activity. Access permissions are primarily role-based but user content ownership, among other factors, may also affect permissions & access.',
     'user_profile' => 'User Profile',
     'users_add_new' => 'Add New User',
     'users_search' => 'Search Users',
diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss
index 9fdd5a611..667c26388 100644
--- a/resources/sass/_components.scss
+++ b/resources/sass/_components.scss
@@ -798,37 +798,6 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   max-width: 500px;
 }
 
-.content-permissions {
-  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
-}
-.content-permissions-row {
-  border: 1.5px solid;
-  @include lightDark(border-color, #E2E2E2, #444);
-  border-bottom-width: 0;
-  label {
-    padding-bottom: 0;
-  }
-  &:hover {
-    @include lightDark(background-color, #F2F2F2, #333);
-  }
-}
-.content-permissions-row:first-child {
-  border-radius: 4px 4px 0 0;
-}
-.content-permissions-row:last-child {
-  border-radius: 0 0 4px 4px;
-  border-bottom-width: 1.5px;
-}
-.content-permissions-row:first-child:last-child {
-  border-radius: 4px;
-}
-.content-permissions-row-toggle-all {
-  visibility: hidden;
-}
-.content-permissions-row:hover .content-permissions-row-toggle-all {
-  visibility: visible;
-}
-
 .template-item {
   cursor: pointer;
   position: relative;
@@ -969,4 +938,35 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   .dropdown-search-dropdown .dropdown-search-list {
     max-height: 240px;
   }
+}
+
+.item-list {
+  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
+}
+.item-list-row {
+  border: 1.5px solid;
+  @include lightDark(border-color, #E2E2E2, #444);
+  border-bottom-width: 0;
+  label {
+    padding-bottom: 0;
+  }
+  &:hover {
+    @include lightDark(background-color, #F6F6F6, #333);
+  }
+}
+.item-list-row:first-child {
+  border-radius: 4px 4px 0 0;
+}
+.item-list-row:last-child {
+  border-radius: 0 0 4px 4px;
+  border-bottom-width: 1.5px;
+}
+.item-list-row:first-child:last-child {
+  border-radius: 4px;
+}
+.item-list-row-toggle-all {
+  visibility: hidden;
+}
+.item-list-row:hover .item-list-row-toggle-all {
+  visibility: visible;
 }
\ No newline at end of file
diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss
index cfb8397c9..d4413d32c 100644
--- a/resources/sass/_layout.scss
+++ b/resources/sass/_layout.scss
@@ -158,6 +158,18 @@ body.flexbox {
   }
 }
 
+.flex-2 {
+  min-height: 0;
+  flex: 2;
+  max-width: 100%;
+}
+
+.flex-3 {
+  min-height: 0;
+  flex: 3;
+  max-width: 100%;
+}
+
 .flex-none {
   flex: none;
 }
@@ -178,6 +190,27 @@ body.flexbox {
   align-items: center;
 }
 
+/**
+ * Min width utilities
+ */
+.min-width-xs {
+  min-width: 120px;
+}
+.min-width-s {
+  min-width: 160px;
+}
+.min-width-m {
+  min-width: 200px;
+}
+.min-width-l {
+  min-width: 240px;
+}
+.min-width-xl {
+  min-width: 280px;
+}
+.min-width-xxl {
+  min-width: 320px;
+}
 
 /**
  * Display and float utilities
diff --git a/resources/views/books/parts/list.blade.php b/resources/views/books/parts/list.blade.php
index 30b076613..79d0554c5 100644
--- a/resources/views/books/parts/list.blade.php
+++ b/resources/views/books/parts/list.blade.php
@@ -3,7 +3,7 @@
         <h1 class="list-heading">{{ trans('entities.books') }}</h1>
         <div class="text-m-right my-m">
 
-            @include('entities.sort', ['options' => [
+            @include('common.sort', ['options' => [
                 'name' => trans('common.sort_name'),
                 'created_at' => trans('common.sort_created_at'),
                 'updated_at' => trans('common.sort_updated_at'),
@@ -19,11 +19,11 @@
                 @endforeach
             </div>
         @else
-             <div class="grid third">
+            <div class="grid third">
                 @foreach($books as $key => $book)
                     @include('entities.grid-item', ['entity' => $book])
                 @endforeach
-             </div>
+            </div>
         @endif
         <div>
             {!! $books->render() !!}
diff --git a/resources/views/entities/sort.blade.php b/resources/views/common/sort.blade.php
similarity index 100%
rename from resources/views/entities/sort.blade.php
rename to resources/views/common/sort.blade.php
diff --git a/resources/views/form/entity-permissions-row.blade.php b/resources/views/form/entity-permissions-row.blade.php
index d2e6a4756..d4c6c4ac1 100644
--- a/resources/views/form/entity-permissions-row.blade.php
+++ b/resources/views/form/entity-permissions-row.blade.php
@@ -5,7 +5,7 @@ $permission - The entity permission containing the permissions.
 $inheriting - Boolean if the current row should be marked as inheriting default permissions. Used for "Everyone Else" role.
 --}}
 
-<div component="permissions-table" class="content-permissions-row flex-container-row justify-space-between wrap">
+<div component="permissions-table" class="item-list-row flex-container-row justify-space-between wrap">
     <div class="gap-x-m flex-container-row items-center px-l py-m flex">
         <div class="text-large" title="{{ $role->id === 0 ? trans('entities.permissions_role_everyone_else') : trans('common.role') }}">
             @icon($role->id === 0 ? 'groups' : 'role')
@@ -16,7 +16,7 @@ $inheriting - Boolean if the current row should be marked as inheriting default
         </span>
         @if($role->id !== 0)
             <button type="button"
-                class="ml-auto flex-none text-small text-primary text-button hover-underline content-permissions-row-toggle-all hide-under-s"
+                class="ml-auto flex-none text-small text-primary text-button hover-underline item-list-row-toggle-all hide-under-s"
                 refs="permissions-table@toggle-all"
                 ><strong>{{ trans('common.toggle_all') }}</strong></button>
         @endif
diff --git a/resources/views/form/entity-permissions.blade.php b/resources/views/form/entity-permissions.blade.php
index 724d0fb39..9bf309fb8 100644
--- a/resources/views/form/entity-permissions.blade.php
+++ b/resources/views/form/entity-permissions.blade.php
@@ -35,7 +35,7 @@
 
     <hr>
 
-    <div refs="entity-permissions@role-container" class="content-permissions mt-m mb-m">
+    <div refs="entity-permissions@role-container" class="item-list mt-m mb-m">
         @foreach($data->permissionsWithRoles() as $permission)
             @include('form.entity-permissions-row', [
                 'permission' => $permission,
@@ -58,7 +58,7 @@
         </div>
     </div>
 
-    <div class="content-permissions mt-m mb-xl">
+    <div class="item-list mt-m mb-xl">
         @include('form.entity-permissions-row', [
                 'role' => $data->everyoneElseRole(),
                 'permission' => $data->everyoneElseEntityPermission(),
diff --git a/resources/views/shelves/parts/list.blade.php b/resources/views/shelves/parts/list.blade.php
index d78606ac7..4c841db64 100644
--- a/resources/views/shelves/parts/list.blade.php
+++ b/resources/views/shelves/parts/list.blade.php
@@ -1,10 +1,9 @@
-
 <main class="content-wrap mt-m card">
 
     <div class="grid half v-center">
         <h1 class="list-heading">{{ trans('entities.shelves') }}</h1>
         <div class="text-right">
-            @include('entities.sort', ['options' => $sortOptions, 'order' => $order, 'sort' => $sort, 'type' => 'bookshelves'])
+            @include('common.sort', ['options' => $sortOptions, 'order' => $order, 'sort' => $sort, 'type' => 'bookshelves'])
         </div>
     </div>
 
@@ -31,7 +30,8 @@
     @else
         <p class="text-muted">{{ trans('entities.shelves_empty') }}</p>
         @if(userCan('bookshelf-create-all'))
-            <a href="{{ url("/create-shelf") }}" class="button outline">@icon('edit'){{ trans('entities.create_now') }}</a>
+            <a href="{{ url("/create-shelf") }}"
+               class="button outline">@icon('edit'){{ trans('entities.create_now') }}</a>
         @endif
     @endif
 
diff --git a/resources/views/shelves/show.blade.php b/resources/views/shelves/show.blade.php
index 37d288956..fe11ccfce 100644
--- a/resources/views/shelves/show.blade.php
+++ b/resources/views/shelves/show.blade.php
@@ -23,7 +23,7 @@
             <h1 class="flex fit-content break-text">{{ $shelf->name }}</h1>
             <div class="flex"></div>
             <div class="flex fit-content text-m-right my-m ml-m">
-                @include('entities.sort', ['options' => [
+                @include('common.sort', ['options' => [
                     'default' => trans('common.sort_default'),
                     'name' => trans('common.sort_name'),
                     'created_at' => trans('common.sort_created_at'),
diff --git a/resources/views/users/index.blade.php b/resources/views/users/index.blade.php
index 03eae2c00..daa41d7d7 100644
--- a/resources/views/users/index.blade.php
+++ b/resources/views/users/index.blade.php
@@ -9,37 +9,37 @@
 
             <div class="flex-container-row wrap justify-space-between items-center">
                 <h1 class="list-heading">{{ trans('settings.users') }}</h1>
-
                 <div>
-                    <div class="block inline mr-xs">
-                        <form method="get" action="{{ url("/settings/users") }}">
-                            @foreach(collect($listDetails)->except('search') as $name => $val)
-                                <input type="hidden" name="{{ $name }}" value="{{ $val }}">
-                            @endforeach
-                            <input type="text" name="search" placeholder="{{ trans('settings.users_search') }}" @if($listDetails['search']) value="{{$listDetails['search']}}" @endif>
-                        </form>
-                    </div>
                     <a href="{{ url("/settings/users/create") }}" class="outline button mt-none">{{ trans('settings.users_add_new') }}</a>
                 </div>
             </div>
 
-            <table class="table">
-                <tr>
-                    <th width="9%"></th>
-                    <th width="36%">
-                        <a href="{{ sortUrl('/settings/users', $listDetails, ['sort' => 'name']) }}">{{ trans('auth.name') }}</a>
-                        /
-                        <a href="{{ sortUrl('/settings/users', $listDetails, ['sort' => 'email']) }}">{{ trans('auth.email') }}</a>
-                    </th>
-                    <th width="35%">{{ trans('settings.role_user_roles') }}</th>
-                    <th class="text-right" width="20%">
-                        <a href="{{ sortUrl('/settings/users', $listDetails, ['sort' => 'last_activity_at']) }}">{{ trans('settings.users_latest_activity') }}</a>
-                    </th>
-                </tr>
+            <p class="text-muted">{{ trans('settings.users_index_desc') }}</p>
+
+            <div class="flex-container-row items-center justify-space-between gap-m mt-m mb-l wrap">
+                <div>
+                    <div class="block inline mr-xs">
+                        <form method="get" action="{{ url("/settings/users") }}">
+                            <input type="text" name="search" placeholder="{{ trans('settings.users_search') }}" @if($listDetails['search']) value="{{$listDetails['search']}}" @endif>
+                        </form>
+                    </div>
+                </div>
+                <div class="justify-flex-end">
+                    @include('common.sort', ['options' => [
+                        'name' => trans('common.sort_name'),
+                        'email' => trans('auth.email'),
+                        'created_at' => trans('common.sort_created_at'),
+                        'updated_at' => trans('common.sort_updated_at'),
+                        'last_activity_at' => trans('settings.users_latest_activity'),
+                    ], 'order' => $listDetails['order'], 'sort' => $listDetails['sort'], 'type' => 'users'])
+                </div>
+            </div>
+
+            <div class="item-list">
                 @foreach($users as $user)
-                    <tr>
-                        <td class="text-center" style="line-height: 0;"><img class="avatar med" src="{{ $user->getAvatar(40)}}" alt="{{ $user->name }}"></td>
-                        <td>
+                    <div class="flex-container-row item-list-row items-center wrap py-s">
+                        <div class="px-m py-xs flex-container-row items-center flex-2 gap-l min-width-m">
+                            <img class="avatar med" width="40" height="40" src="{{ $user->getAvatar(40)}}" alt="{{ $user->name }}">
                             <a href="{{ url("/settings/users/{$user->id}") }}">
                                 {{ $user->name }}
                                 <br>
@@ -48,20 +48,24 @@
                                     <span title="MFA Configured" class="text-pos">@icon('lock')</span>
                                 @endif
                             </a>
-                        </td>
-                        <td>
-                            @foreach($user->roles as $index => $role)
-                                <small><a href="{{ url("/settings/roles/{$role->id}") }}">{{$role->display_name}}</a>@if($index !== count($user->roles) -1),@endif</small>
-                            @endforeach
-                        </td>
-                        <td class="text-right text-muted">
-                            @if($user->last_activity_at)
-                                <small title="{{ $user->last_activity_at->format('Y-m-d H:i:s') }}">{{ $user->last_activity_at->diffForHumans() }}</small>
-                            @endif
-                        </td>
-                    </tr>
+                        </div>
+                        <div class="flex-container-row items-center flex-3 min-width-m">
+                            <div class="px-m py-xs flex">
+                                @foreach($user->roles as $index => $role)
+                                    <small><a href="{{ url("/settings/roles/{$role->id}") }}">{{$role->display_name}}</a>@if($index !== count($user->roles) -1),@endif</small>
+                                @endforeach
+                            </div>
+                            <div class="px-m py-xs flex text-right text-muted">
+                                @if($user->last_activity_at)
+                                    <small>{{ trans('settings.users_latest_activity') }}</small>
+                                    <br>
+                                    <small title="{{ $user->last_activity_at->format('Y-m-d H:i:s') }}">{{ $user->last_activity_at->diffForHumans() }}</small>
+                                @endif
+                            </div>
+                        </div>
+                    </div>
                 @endforeach
-            </table>
+            </div>
 
             <div>
                 {{ $users->links() }}

From 0ef06fd29845de2b567459442c63c52dd65f24b5 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sat, 29 Oct 2022 15:25:28 +0100
Subject: [PATCH 02/20] Extracted user list item to its own template

---
 resources/views/users/index.blade.php         | 28 +------------------
 .../users/parts/users-list-item.blade.php     | 27 ++++++++++++++++++
 2 files changed, 28 insertions(+), 27 deletions(-)
 create mode 100644 resources/views/users/parts/users-list-item.blade.php

diff --git a/resources/views/users/index.blade.php b/resources/views/users/index.blade.php
index daa41d7d7..5fda0f6c0 100644
--- a/resources/views/users/index.blade.php
+++ b/resources/views/users/index.blade.php
@@ -37,33 +37,7 @@
 
             <div class="item-list">
                 @foreach($users as $user)
-                    <div class="flex-container-row item-list-row items-center wrap py-s">
-                        <div class="px-m py-xs flex-container-row items-center flex-2 gap-l min-width-m">
-                            <img class="avatar med" width="40" height="40" src="{{ $user->getAvatar(40)}}" alt="{{ $user->name }}">
-                            <a href="{{ url("/settings/users/{$user->id}") }}">
-                                {{ $user->name }}
-                                <br>
-                                <span class="text-muted">{{ $user->email }}</span>
-                                @if($user->mfa_values_count > 0)
-                                    <span title="MFA Configured" class="text-pos">@icon('lock')</span>
-                                @endif
-                            </a>
-                        </div>
-                        <div class="flex-container-row items-center flex-3 min-width-m">
-                            <div class="px-m py-xs flex">
-                                @foreach($user->roles as $index => $role)
-                                    <small><a href="{{ url("/settings/roles/{$role->id}") }}">{{$role->display_name}}</a>@if($index !== count($user->roles) -1),@endif</small>
-                                @endforeach
-                            </div>
-                            <div class="px-m py-xs flex text-right text-muted">
-                                @if($user->last_activity_at)
-                                    <small>{{ trans('settings.users_latest_activity') }}</small>
-                                    <br>
-                                    <small title="{{ $user->last_activity_at->format('Y-m-d H:i:s') }}">{{ $user->last_activity_at->diffForHumans() }}</small>
-                                @endif
-                            </div>
-                        </div>
-                    </div>
+                    @include('users.parts.users-list-item', ['user' => $user])
                 @endforeach
             </div>
 
diff --git a/resources/views/users/parts/users-list-item.blade.php b/resources/views/users/parts/users-list-item.blade.php
new file mode 100644
index 000000000..ffc74d708
--- /dev/null
+++ b/resources/views/users/parts/users-list-item.blade.php
@@ -0,0 +1,27 @@
+<div class="flex-container-row item-list-row items-center wrap py-s">
+    <div class="px-m py-xs flex-container-row items-center flex-2 gap-l min-width-m">
+        <img class="avatar med" width="40" height="40" src="{{ $user->getAvatar(40)}}" alt="{{ $user->name }}">
+        <a href="{{ url("/settings/users/{$user->id}") }}">
+            {{ $user->name }}
+            <br>
+            <span class="text-muted">{{ $user->email }}</span>
+            @if($user->mfa_values_count > 0)
+                <span title="MFA Configured" class="text-pos">@icon('lock')</span>
+            @endif
+        </a>
+    </div>
+    <div class="flex-container-row items-center flex-3 min-width-m">
+        <div class="px-m py-xs flex">
+            @foreach($user->roles as $index => $role)
+                <small><a href="{{ url("/settings/roles/{$role->id}") }}">{{$role->display_name}}</a>@if($index !== count($user->roles) -1),@endif</small>
+            @endforeach
+        </div>
+        <div class="px-m py-xs flex text-right text-muted">
+            @if($user->last_activity_at)
+                <small>{{ trans('settings.users_latest_activity') }}</small>
+                <br>
+                <small title="{{ $user->last_activity_at->format('Y-m-d H:i:s') }}">{{ $user->last_activity_at->diffForHumans() }}</small>
+            @endif
+        </div>
+    </div>
+</div>
\ No newline at end of file

From 98b59a10249afeccbf25bdd5eadf35f43a19cf67 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sat, 29 Oct 2022 20:52:17 +0100
Subject: [PATCH 03/20] Revised role index list to align with user list

---
 .../Queries/AllRolesPaginatedAndSorted.php    | 37 ++++++++++++++
 app/Auth/Role.php                             |  8 ----
 app/Http/Controllers/RoleController.php       | 28 ++++++-----
 app/Http/Controllers/UserController.php       | 10 +++-
 resources/lang/en/settings.php                |  5 ++
 .../views/settings/roles/index.blade.php      | 48 +++++++++++--------
 .../roles/parts/roles-list-item.blade.php     | 14 ++++++
 resources/views/users/index.blade.php         |  2 +-
 8 files changed, 109 insertions(+), 43 deletions(-)
 create mode 100644 app/Auth/Queries/AllRolesPaginatedAndSorted.php
 create mode 100644 resources/views/settings/roles/parts/roles-list-item.blade.php

diff --git a/app/Auth/Queries/AllRolesPaginatedAndSorted.php b/app/Auth/Queries/AllRolesPaginatedAndSorted.php
new file mode 100644
index 000000000..add1e9e54
--- /dev/null
+++ b/app/Auth/Queries/AllRolesPaginatedAndSorted.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace BookStack\Auth\Queries;
+
+use BookStack\Auth\Role;
+use Illuminate\Pagination\LengthAwarePaginator;
+
+/**
+ * Get all the roles in the system in a paginated format.
+ */
+class AllRolesPaginatedAndSorted
+{
+    /**
+     * @param array{sort: string, order: string, search: string} $sortData
+     */
+    public function run(int $count, array $sortData): LengthAwarePaginator
+    {
+        $sort = $sortData['sort'];
+        if ($sort === 'created_at') {
+            $sort = 'users.created_at';
+        }
+
+        $query = Role::query()->select(['*'])
+            ->withCount(['users', 'permissions'])
+            ->orderBy($sort, $sortData['order']);
+
+        if ($sortData['search']) {
+            $term = '%' . $sortData['search'] . '%';
+            $query->where(function ($query) use ($term) {
+                $query->where('display_name', 'like', $term)
+                    ->orWhere('description', 'like', $term);
+            });
+        }
+
+        return $query->paginate($count);
+    }
+}
diff --git a/app/Auth/Role.php b/app/Auth/Role.php
index 17a4edcc0..b293d1af2 100644
--- a/app/Auth/Role.php
+++ b/app/Auth/Role.php
@@ -110,14 +110,6 @@ class Role extends Model implements Loggable
         return static::query()->where('system_name', '=', $systemName)->first();
     }
 
-    /**
-     * Get all visible roles.
-     */
-    public static function visible(): Collection
-    {
-        return static::query()->where('hidden', '=', false)->orderBy('name')->get();
-    }
-
     /**
      * {@inheritdoc}
      */
diff --git a/app/Http/Controllers/RoleController.php b/app/Http/Controllers/RoleController.php
index fee31ffbf..d022bf35d 100644
--- a/app/Http/Controllers/RoleController.php
+++ b/app/Http/Controllers/RoleController.php
@@ -3,6 +3,7 @@
 namespace BookStack\Http\Controllers;
 
 use BookStack\Auth\Permissions\PermissionsRepo;
+use BookStack\Auth\Queries\AllRolesPaginatedAndSorted;
 use BookStack\Auth\Role;
 use BookStack\Exceptions\PermissionsException;
 use Exception;
@@ -11,11 +12,8 @@ use Illuminate\Validation\ValidationException;
 
 class RoleController extends Controller
 {
-    protected $permissionsRepo;
+    protected PermissionsRepo $permissionsRepo;
 
-    /**
-     * PermissionController constructor.
-     */
     public function __construct(PermissionsRepo $permissionsRepo)
     {
         $this->permissionsRepo = $permissionsRepo;
@@ -24,14 +22,25 @@ class RoleController extends Controller
     /**
      * Show a listing of the roles in the system.
      */
-    public function index()
+    public function index(Request $request)
     {
         $this->checkPermission('user-roles-manage');
-        $roles = $this->permissionsRepo->getAllRoles();
+
+        $listDetails = [
+            'search' => $request->get('search', ''),
+            'sort'   => setting()->getForCurrentUser('roles_sort', 'display_name'),
+            'order'  => setting()->getForCurrentUser('roles_sort_order', 'asc'),
+        ];
+
+        $roles = (new AllRolesPaginatedAndSorted())->run(20, $listDetails);
+        $roles->appends(['search' => $listDetails['search']]);
 
         $this->setPageTitle(trans('settings.roles'));
 
-        return view('settings.roles.index', ['roles' => $roles]);
+        return view('settings.roles.index', [
+            'roles'       => $roles,
+            'listDetails' => $listDetails,
+        ]);
     }
 
     /**
@@ -75,16 +84,11 @@ class RoleController extends Controller
 
     /**
      * Show the form for editing a user role.
-     *
-     * @throws PermissionsException
      */
     public function edit(string $id)
     {
         $this->checkPermission('user-roles-manage');
         $role = $this->permissionsRepo->getRoleById($id);
-        if ($role->hidden) {
-            throw new PermissionsException(trans('errors.role_cannot_be_edited'));
-        }
 
         $this->setPageTitle(trans('settings.role_edit'));
 
diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php
index 9b089c29a..bd69aa8f5 100644
--- a/app/Http/Controllers/UserController.php
+++ b/app/Http/Controllers/UserController.php
@@ -251,7 +251,7 @@ class UserController extends Controller
      */
     public function changeSort(Request $request, string $id, string $type)
     {
-        $validSortTypes = ['books', 'bookshelves', 'shelf_books', 'users'];
+        $validSortTypes = ['books', 'bookshelves', 'shelf_books', 'users', 'roles'];
         if (!in_array($type, $validSortTypes)) {
             return redirect()->back(500);
         }
@@ -318,7 +318,13 @@ class UserController extends Controller
         $this->checkPermissionOrCurrentUser('users-manage', $userId);
 
         $sort = $request->get('sort');
-        if (!in_array($sort, ['name', 'created_at', 'updated_at', 'default', 'email', 'last_activity_at'])) {
+        // TODO - Need to find a better way to validate sort options
+        //   Probably better to do a simple validation here then validate at usage.
+        $validSorts = [
+            'name', 'created_at', 'updated_at', 'default', 'email', 'last_activity_at', 'display_name',
+            'users_count', 'permissions_count',
+        ];
+        if (!in_array($sort, $validSorts)) {
             $sort = 'name';
         }
 
diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php
index d4d6d3bae..e8978d41e 100755
--- a/resources/lang/en/settings.php
+++ b/resources/lang/en/settings.php
@@ -133,6 +133,11 @@ return [
     // Role Settings
     'roles' => 'Roles',
     'role_user_roles' => 'User Roles',
+    'roles_index_desc' => 'Roles are used to group users & provide system permission to their members. When a user is a member of multiple roles the privileges granted will stack and the user will inherit all abilities.',
+    'roles_x_users_assigned' => '1 user assigned|:count users assigned',
+    'roles_x_permissions_provided' => '1 permission|:count permissions',
+    'roles_assigned_users' => 'Assigned Users',
+    'roles_permissions_provided' => 'Provided Permissions',
     'role_create' => 'Create New Role',
     'role_create_success' => 'Role successfully created',
     'role_delete' => 'Delete Role',
diff --git a/resources/views/settings/roles/index.blade.php b/resources/views/settings/roles/index.blade.php
index 4c3b5625a..6aeb16f92 100644
--- a/resources/views/settings/roles/index.blade.php
+++ b/resources/views/settings/roles/index.blade.php
@@ -12,30 +12,38 @@
                 <h1 class="list-heading">{{ trans('settings.role_user_roles') }}</h1>
 
                 <div class="text-right">
-                    <a href="{{ url("/settings/roles/new") }}" class="button outline">{{ trans('settings.role_create') }}</a>
+                    <a href="{{ url("/settings/roles/new") }}" class="button outline my-none">{{ trans('settings.role_create') }}</a>
                 </div>
             </div>
 
-            <table class="table">
-                <tr>
-                    <th>{{ trans('settings.role_name') }}</th>
-                    <th></th>
-                    <th class="text-center">{{ trans('settings.users') }}</th>
-                </tr>
-                @foreach($roles as $role)
-                    <tr>
-                        <td><a href="{{ url("/settings/roles/{$role->id}") }}">{{ $role->display_name }}</a></td>
-                        <td>
-                            @if($role->mfa_enforced)
-                                <span title="{{ trans('settings.role_mfa_enforced') }}">@icon('lock') </span>
-                            @endif
-                            {{ $role->description }}
-                        </td>
-                        <td class="text-center">{{ $role->users->count() }}</td>
-                    </tr>
-                @endforeach
-            </table>
+            <p class="text-muted">{{ trans('settings.roles_index_desc') }}</p>
 
+            <div class="flex-container-row items-center justify-space-between gap-m mt-m mb-l wrap">
+                <div>
+                    <div class="block inline mr-xs">
+                        <form method="get" action="{{ url("/settings/roles") }}">
+                            <input type="text" name="search" placeholder="{{ trans('common.search') }}" @if($listDetails['search']) value="{{$listDetails['search']}}" @endif>
+                        </form>
+                    </div>
+                </div>
+                <div class="justify-flex-end">
+                    @include('common.sort', ['options' => [
+                        'display_name' => trans('common.sort_name'),
+                        'users_count' => trans('settings.roles_assigned_users'),
+                        'permissions_count' => trans('settings.roles_permissions_provided'),
+                    ], 'order' => $listDetails['order'], 'sort' => $listDetails['sort'], 'type' => 'roles'])
+                </div>
+            </div>
+
+            <div class="item-list">
+                @foreach($roles as $role)
+                    @include('settings.roles.parts.roles-list-item', ['role' => $role])
+                @endforeach
+            </div>
+
+            <div class="mb-m">
+                {{ $roles->links() }}
+            </div>
 
         </div>
     </div>
diff --git a/resources/views/settings/roles/parts/roles-list-item.blade.php b/resources/views/settings/roles/parts/roles-list-item.blade.php
new file mode 100644
index 000000000..43e8dc81a
--- /dev/null
+++ b/resources/views/settings/roles/parts/roles-list-item.blade.php
@@ -0,0 +1,14 @@
+<div class="item-list-row flex-container-row py-xs items-center">
+    <div class="py-xs px-m flex-2">
+        <a href="{{ url("/settings/roles/{$role->id}") }}">{{ $role->display_name }}</a><br>
+        @if($role->mfa_enforced)
+            <small title="{{ trans('settings.role_mfa_enforced') }}">@icon('lock') </small>
+        @endif
+        <small>{{ $role->description }}</small>
+    </div>
+    <div class="text-right flex py-xs px-m text-muted">
+        {{ trans_choice('settings.roles_x_users_assigned', $role->users_count, ['count' => $role->users_count]) }}
+        <br>
+        {{ trans_choice('settings.roles_x_permissions_provided', $role->permissions_count, ['count' => $role->permissions_count]) }}
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/users/index.blade.php b/resources/views/users/index.blade.php
index 5fda0f6c0..139ac4579 100644
--- a/resources/views/users/index.blade.php
+++ b/resources/views/users/index.blade.php
@@ -10,7 +10,7 @@
             <div class="flex-container-row wrap justify-space-between items-center">
                 <h1 class="list-heading">{{ trans('settings.users') }}</h1>
                 <div>
-                    <a href="{{ url("/settings/users/create") }}" class="outline button mt-none">{{ trans('settings.users_add_new') }}</a>
+                    <a href="{{ url("/settings/users/create") }}" class="outline button my-none">{{ trans('settings.users_add_new') }}</a>
                 </div>
             </div>
 

From f75091a1c5d505f256ad253c5038a5bde068bc4e Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sun, 30 Oct 2022 12:02:06 +0000
Subject: [PATCH 04/20] Revised webhooks list to new format

Also aligned query naming to start with model in use.
Also added created/updated sort options to roles.
---
 .../Queries/WebhooksAllPaginatedAndSorted.php | 34 ++++++++++++
 ...ted.php => RolesAllPaginatedAndSorted.php} |  2 +-
 ...ted.php => UsersAllPaginatedAndSorted.php} |  2 +-
 app/Http/Controllers/RoleController.php       |  4 +-
 app/Http/Controllers/UserController.php       |  8 +--
 app/Http/Controllers/WebhookController.php    | 20 +++++--
 resources/lang/en/settings.php                |  2 +
 resources/sass/_components.scss               | 13 +++++
 .../views/common/status-indicator.blade.php   |  3 +
 .../views/settings/roles/index.blade.php      |  2 +
 .../views/settings/webhooks/index.blade.php   | 55 ++++++++++---------
 .../parts/webhooks-list-item.blade.php        | 18 ++++++
 12 files changed, 123 insertions(+), 40 deletions(-)
 create mode 100644 app/Actions/Queries/WebhooksAllPaginatedAndSorted.php
 rename app/Auth/Queries/{AllRolesPaginatedAndSorted.php => RolesAllPaginatedAndSorted.php} (96%)
 rename app/Auth/Queries/{AllUsersPaginatedAndSorted.php => UsersAllPaginatedAndSorted.php} (97%)
 create mode 100644 resources/views/common/status-indicator.blade.php
 create mode 100644 resources/views/settings/webhooks/parts/webhooks-list-item.blade.php

diff --git a/app/Actions/Queries/WebhooksAllPaginatedAndSorted.php b/app/Actions/Queries/WebhooksAllPaginatedAndSorted.php
new file mode 100644
index 000000000..86e4e2040
--- /dev/null
+++ b/app/Actions/Queries/WebhooksAllPaginatedAndSorted.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace BookStack\Actions\Queries;
+
+use BookStack\Actions\Webhook;
+use Illuminate\Pagination\LengthAwarePaginator;
+
+/**
+ * Get all the webhooks in the system in a paginated format.
+ */
+class WebhooksAllPaginatedAndSorted
+{
+    /**
+     * @param array{sort: string, order: string, search: string} $sortData
+     */
+    public function run(int $count, array $sortData): LengthAwarePaginator
+    {
+        $sort = $sortData['sort'];
+
+        $query = Webhook::query()->select(['*'])
+            ->withCount(['trackedEvents'])
+            ->orderBy($sort, $sortData['order']);
+
+        if ($sortData['search']) {
+            $term = '%' . $sortData['search'] . '%';
+            $query->where(function ($query) use ($term) {
+                $query->where('name', 'like', $term)
+                    ->orWhere('endpoint', 'like', $term);
+            });
+        }
+
+        return $query->paginate($count);
+    }
+}
diff --git a/app/Auth/Queries/AllRolesPaginatedAndSorted.php b/app/Auth/Queries/RolesAllPaginatedAndSorted.php
similarity index 96%
rename from app/Auth/Queries/AllRolesPaginatedAndSorted.php
rename to app/Auth/Queries/RolesAllPaginatedAndSorted.php
index add1e9e54..6abbfd1ad 100644
--- a/app/Auth/Queries/AllRolesPaginatedAndSorted.php
+++ b/app/Auth/Queries/RolesAllPaginatedAndSorted.php
@@ -8,7 +8,7 @@ use Illuminate\Pagination\LengthAwarePaginator;
 /**
  * Get all the roles in the system in a paginated format.
  */
-class AllRolesPaginatedAndSorted
+class RolesAllPaginatedAndSorted
 {
     /**
      * @param array{sort: string, order: string, search: string} $sortData
diff --git a/app/Auth/Queries/AllUsersPaginatedAndSorted.php b/app/Auth/Queries/UsersAllPaginatedAndSorted.php
similarity index 97%
rename from app/Auth/Queries/AllUsersPaginatedAndSorted.php
rename to app/Auth/Queries/UsersAllPaginatedAndSorted.php
index 29e58fe09..3a64cc800 100644
--- a/app/Auth/Queries/AllUsersPaginatedAndSorted.php
+++ b/app/Auth/Queries/UsersAllPaginatedAndSorted.php
@@ -11,7 +11,7 @@ use Illuminate\Pagination\LengthAwarePaginator;
  * user is assumed to be trusted. (Admin users).
  * Email search can be abused to extract email addresses.
  */
-class AllUsersPaginatedAndSorted
+class UsersAllPaginatedAndSorted
 {
     /**
      * @param array{sort: string, order: string, search: string} $sortData
diff --git a/app/Http/Controllers/RoleController.php b/app/Http/Controllers/RoleController.php
index d022bf35d..0e13a9fb3 100644
--- a/app/Http/Controllers/RoleController.php
+++ b/app/Http/Controllers/RoleController.php
@@ -3,7 +3,7 @@
 namespace BookStack\Http\Controllers;
 
 use BookStack\Auth\Permissions\PermissionsRepo;
-use BookStack\Auth\Queries\AllRolesPaginatedAndSorted;
+use BookStack\Auth\Queries\RolesAllPaginatedAndSorted;
 use BookStack\Auth\Role;
 use BookStack\Exceptions\PermissionsException;
 use Exception;
@@ -32,7 +32,7 @@ class RoleController extends Controller
             'order'  => setting()->getForCurrentUser('roles_sort_order', 'asc'),
         ];
 
-        $roles = (new AllRolesPaginatedAndSorted())->run(20, $listDetails);
+        $roles = (new RolesAllPaginatedAndSorted())->run(20, $listDetails);
         $roles->appends(['search' => $listDetails['search']]);
 
         $this->setPageTitle(trans('settings.roles'));
diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php
index bd69aa8f5..77be07533 100644
--- a/app/Http/Controllers/UserController.php
+++ b/app/Http/Controllers/UserController.php
@@ -3,7 +3,7 @@
 namespace BookStack\Http\Controllers;
 
 use BookStack\Auth\Access\SocialAuthService;
-use BookStack\Auth\Queries\AllUsersPaginatedAndSorted;
+use BookStack\Auth\Queries\UsersAllPaginatedAndSorted;
 use BookStack\Auth\Role;
 use BookStack\Auth\User;
 use BookStack\Auth\UserRepo;
@@ -42,7 +42,7 @@ class UserController extends Controller
             'order'  => setting()->getForCurrentUser('users_sort_order', 'asc'),
         ];
 
-        $users = (new AllUsersPaginatedAndSorted())->run(20, $listDetails);
+        $users = (new UsersAllPaginatedAndSorted())->run(20, $listDetails);
 
         $this->setPageTitle(trans('settings.users'));
         $users->appends(['search' => $listDetails['search']]);
@@ -251,7 +251,7 @@ class UserController extends Controller
      */
     public function changeSort(Request $request, string $id, string $type)
     {
-        $validSortTypes = ['books', 'bookshelves', 'shelf_books', 'users', 'roles'];
+        $validSortTypes = ['books', 'bookshelves', 'shelf_books', 'users', 'roles', 'webhooks'];
         if (!in_array($type, $validSortTypes)) {
             return redirect()->back(500);
         }
@@ -322,7 +322,7 @@ class UserController extends Controller
         //   Probably better to do a simple validation here then validate at usage.
         $validSorts = [
             'name', 'created_at', 'updated_at', 'default', 'email', 'last_activity_at', 'display_name',
-            'users_count', 'permissions_count',
+            'users_count', 'permissions_count', 'endpoint', 'active',
         ];
         if (!in_array($sort, $validSorts)) {
             $sort = 'name';
diff --git a/app/Http/Controllers/WebhookController.php b/app/Http/Controllers/WebhookController.php
index 264921dfc..23120c7e4 100644
--- a/app/Http/Controllers/WebhookController.php
+++ b/app/Http/Controllers/WebhookController.php
@@ -3,6 +3,7 @@
 namespace BookStack\Http\Controllers;
 
 use BookStack\Actions\ActivityType;
+use BookStack\Actions\Queries\WebhooksAllPaginatedAndSorted;
 use BookStack\Actions\Webhook;
 use Illuminate\Http\Request;
 
@@ -18,16 +19,23 @@ class WebhookController extends Controller
     /**
      * Show all webhooks configured in the system.
      */
-    public function index()
+    public function index(Request $request)
     {
-        $webhooks = Webhook::query()
-            ->orderBy('name', 'desc')
-            ->with('trackedEvents')
-            ->get();
+        $listDetails = [
+            'search' => $request->get('search', ''),
+            'sort'   => setting()->getForCurrentUser('webhooks_sort', 'name'),
+            'order'  => setting()->getForCurrentUser('webhooks_sort_order', 'asc'),
+        ];
+
+        $webhooks = (new WebhooksAllPaginatedAndSorted())->run(20, $listDetails);
+        $webhooks->appends(['search' => $listDetails['search']]);
 
         $this->setPageTitle(trans('settings.webhooks'));
 
-        return view('settings.webhooks.index', ['webhooks' => $webhooks]);
+        return view('settings.webhooks.index', [
+            'webhooks'    => $webhooks,
+            'listDetails' => $listDetails,
+        ]);
     }
 
     /**
diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php
index e8978d41e..f4204dd68 100755
--- a/resources/lang/en/settings.php
+++ b/resources/lang/en/settings.php
@@ -247,6 +247,8 @@ return [
 
     // Webhooks
     'webhooks' => 'Webhooks',
+    'webhooks_index_desc' => 'Webhooks are a way to send data to external URLs when certain actions and events occur within the system which allows event-based integration with external platforms such as messaging or notification systems.',
+    'webhooks_x_trigger_events' => '1 trigger event|:count trigger events',
     'webhooks_create' => 'Create New Webhook',
     'webhooks_none_created' => 'No webhooks have yet been created.',
     'webhooks_edit' => 'Edit Webhook',
diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss
index 667c26388..acb45100f 100644
--- a/resources/sass/_components.scss
+++ b/resources/sass/_components.scss
@@ -969,4 +969,17 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
 }
 .item-list-row:hover .item-list-row-toggle-all {
   visibility: visible;
+}
+
+.status-indicator-active, .status-indicator-inactive {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  display: inline-block;
+}
+.status-indicator-active {
+  background-color: $positive;
+}
+.status-indicator-inactive {
+  background-color: $negative;
 }
\ No newline at end of file
diff --git a/resources/views/common/status-indicator.blade.php b/resources/views/common/status-indicator.blade.php
new file mode 100644
index 000000000..ba9b1b463
--- /dev/null
+++ b/resources/views/common/status-indicator.blade.php
@@ -0,0 +1,3 @@
+<span title="{{ trans('common.status_' . ($status ? 'active' : 'inactive')) }}"
+      class="status-indicator-{{ $status ? 'active' : 'inactive' }}"
+></span>
\ No newline at end of file
diff --git a/resources/views/settings/roles/index.blade.php b/resources/views/settings/roles/index.blade.php
index 6aeb16f92..7e3d5b852 100644
--- a/resources/views/settings/roles/index.blade.php
+++ b/resources/views/settings/roles/index.blade.php
@@ -31,6 +31,8 @@
                         'display_name' => trans('common.sort_name'),
                         'users_count' => trans('settings.roles_assigned_users'),
                         'permissions_count' => trans('settings.roles_permissions_provided'),
+                        'created_at' => trans('common.sort_created_at'),
+                        'updated_at' => trans('common.sort_updated_at'),
                     ], 'order' => $listDetails['order'], 'sort' => $listDetails['sort'], 'type' => 'roles'])
                 </div>
             </div>
diff --git a/resources/views/settings/webhooks/index.blade.php b/resources/views/settings/webhooks/index.blade.php
index bbe58453f..09b2ee770 100644
--- a/resources/views/settings/webhooks/index.blade.php
+++ b/resources/views/settings/webhooks/index.blade.php
@@ -8,48 +8,51 @@
 
         <div class="card content-wrap auto-height">
 
-            <div class="grid half v-center">
+            <div class="flex-container-row items-center justify-space-between wrap">
                 <h1 class="list-heading">{{ trans('settings.webhooks') }}</h1>
 
-                <div class="text-right">
+                <div>
                     <a href="{{ url("/settings/webhooks/create") }}"
                        class="button outline">{{ trans('settings.webhooks_create') }}</a>
                 </div>
             </div>
 
-            @if(count($webhooks) > 0)
+            <p class="text-muted">{{ trans('settings.webhooks_index_desc') }}</p>
 
-                <table class="table">
-                    <tr>
-                        <th>{{ trans('common.name') }}</th>
-                        <th width="100">{{ trans('settings.webhook_events_table_header') }}</th>
-                        <th width="100">{{ trans('common.status') }}</th>
-                    </tr>
+            <div class="flex-container-row items-center justify-space-between gap-m mt-m mb-l wrap">
+                <div>
+                    <div class="block inline mr-xs">
+                        <form method="get" action="{{ url("/settings/webhooks") }}">
+                            <input type="text" name="search" placeholder="{{ trans('common.search') }}" @if($listDetails['search']) value="{{$listDetails['search']}}" @endif>
+                        </form>
+                    </div>
+                </div>
+                <div class="justify-flex-end">
+                    @include('common.sort', ['options' => [
+                        'name' => trans('common.sort_name'),
+                        'endpoint'  => trans('settings.webhooks_endpoint'),
+                        'created_at' => trans('common.sort_created_at'),
+                        'updated_at' => trans('common.sort_updated_at'),
+                        'active'     => trans('common.status'),
+                    ], 'order' => $listDetails['order'], 'sort' => $listDetails['sort'], 'type' => 'webhooks'])
+                </div>
+            </div>
+
+            @if(count($webhooks) > 0)
+                <div class="item-list">
                     @foreach($webhooks as $webhook)
-                        <tr>
-                            <td>
-                                <a href="{{ $webhook->getUrl() }}">{{ $webhook->name }}</a> <br>
-                                <span class="small text-muted italic">{{ $webhook->endpoint }}</span>
-                            </td>
-                            <td>
-                                @if($webhook->tracksEvent('all'))
-                                    {{ trans('settings.webhooks_events_all') }}
-                                @else
-                                    {{ $webhook->trackedEvents->count() }}
-                                @endif
-                            </td>
-                            <td>
-                                {{ trans('common.status_' . ($webhook->active ? 'active' : 'inactive')) }}
-                            </td>
-                        </tr>
+                        @include('settings.webhooks.parts.webhooks-list-item', ['webhook' => $webhook])
                     @endforeach
-                </table>
+                </div>
             @else
                 <p class="text-muted empty-text px-none">
                     {{ trans('settings.webhooks_none_created') }}
                 </p>
             @endif
 
+            <div class="my-m">
+                {{ $webhooks->links() }}
+            </div>
 
         </div>
     </div>
diff --git a/resources/views/settings/webhooks/parts/webhooks-list-item.blade.php b/resources/views/settings/webhooks/parts/webhooks-list-item.blade.php
new file mode 100644
index 000000000..5b7d135eb
--- /dev/null
+++ b/resources/views/settings/webhooks/parts/webhooks-list-item.blade.php
@@ -0,0 +1,18 @@
+<div class="item-list-row py-s">
+    <div class="flex-container-row">
+        <div class="flex-2 py-xxs px-m flex-container-row items-center gap-s">
+            @include('common.status-indicator', ['status' => $webhook->active])
+            <a href="{{ $webhook->getUrl() }}">{{ $webhook->name }}</a>
+        </div>
+        <div class="flex py-xxs px-m text-right text-muted">
+            @if($webhook->tracksEvent('all'))
+                {{ trans('settings.webhooks_events_all') }}
+            @else
+                {{ trans_choice('settings.webhooks_x_trigger_events', $webhook->tracked_events_count, ['count' =>  $webhook->tracked_events_count]) }}
+            @endif
+        </div>
+    </div>
+    <div class="px-m py-xxs text-muted italic text-limit-lines-1">
+        <small>{{ $webhook->endpoint }}</small>
+    </div>
+</div>
\ No newline at end of file

From ec4cbbd0041122f30d13afc016f8a5fac72e0754 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sun, 30 Oct 2022 15:16:06 +0000
Subject: [PATCH 05/20] Refactored common list handling operations to new class

---
 .../Queries/WebhooksAllPaginatedAndSorted.php |  14 +--
 .../Queries/RolesAllPaginatedAndSorted.php    |  14 +--
 .../Queries/UsersAllPaginatedAndSorted.php    |  14 +--
 app/Http/Controllers/BookController.php       |  15 ++-
 app/Http/Controllers/BookshelfController.php  |  31 +++---
 app/Http/Controllers/RoleController.php       |  19 ++--
 app/Http/Controllers/UserController.php       |  65 ++++-------
 app/Http/Controllers/WebhookController.php    |  19 ++--
 app/Util/SimpleListOptions.php                | 104 ++++++++++++++++++
 resources/views/books/index.blade.php         |   2 +-
 resources/views/books/parts/list.blade.php    |   8 +-
 .../views/settings/roles/index.blade.php      |  13 +--
 .../views/settings/webhooks/index.blade.php   |  13 +--
 resources/views/shelves/index.blade.php       |   2 +-
 resources/views/shelves/parts/list.blade.php  |   2 +-
 resources/views/shelves/show.blade.php        |   7 +-
 resources/views/users/index.blade.php         |  13 +--
 17 files changed, 212 insertions(+), 143 deletions(-)
 create mode 100644 app/Util/SimpleListOptions.php

diff --git a/app/Actions/Queries/WebhooksAllPaginatedAndSorted.php b/app/Actions/Queries/WebhooksAllPaginatedAndSorted.php
index 86e4e2040..4958b6070 100644
--- a/app/Actions/Queries/WebhooksAllPaginatedAndSorted.php
+++ b/app/Actions/Queries/WebhooksAllPaginatedAndSorted.php
@@ -3,6 +3,7 @@
 namespace BookStack\Actions\Queries;
 
 use BookStack\Actions\Webhook;
+use BookStack\Util\SimpleListOptions;
 use Illuminate\Pagination\LengthAwarePaginator;
 
 /**
@@ -10,19 +11,14 @@ use Illuminate\Pagination\LengthAwarePaginator;
  */
 class WebhooksAllPaginatedAndSorted
 {
-    /**
-     * @param array{sort: string, order: string, search: string} $sortData
-     */
-    public function run(int $count, array $sortData): LengthAwarePaginator
+    public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
     {
-        $sort = $sortData['sort'];
-
         $query = Webhook::query()->select(['*'])
             ->withCount(['trackedEvents'])
-            ->orderBy($sort, $sortData['order']);
+            ->orderBy($listOptions->getSort(), $listOptions->getOrder());
 
-        if ($sortData['search']) {
-            $term = '%' . $sortData['search'] . '%';
+        if ($listOptions->getSearch()) {
+            $term = '%' . $listOptions->getSearch() . '%';
             $query->where(function ($query) use ($term) {
                 $query->where('name', 'like', $term)
                     ->orWhere('endpoint', 'like', $term);
diff --git a/app/Auth/Queries/RolesAllPaginatedAndSorted.php b/app/Auth/Queries/RolesAllPaginatedAndSorted.php
index 6abbfd1ad..9ee4f6c24 100644
--- a/app/Auth/Queries/RolesAllPaginatedAndSorted.php
+++ b/app/Auth/Queries/RolesAllPaginatedAndSorted.php
@@ -3,6 +3,7 @@
 namespace BookStack\Auth\Queries;
 
 use BookStack\Auth\Role;
+use BookStack\Util\SimpleListOptions;
 use Illuminate\Pagination\LengthAwarePaginator;
 
 /**
@@ -10,22 +11,19 @@ use Illuminate\Pagination\LengthAwarePaginator;
  */
 class RolesAllPaginatedAndSorted
 {
-    /**
-     * @param array{sort: string, order: string, search: string} $sortData
-     */
-    public function run(int $count, array $sortData): LengthAwarePaginator
+    public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
     {
-        $sort = $sortData['sort'];
+        $sort = $listOptions->getSort();
         if ($sort === 'created_at') {
             $sort = 'users.created_at';
         }
 
         $query = Role::query()->select(['*'])
             ->withCount(['users', 'permissions'])
-            ->orderBy($sort, $sortData['order']);
+            ->orderBy($sort, $listOptions->getOrder());
 
-        if ($sortData['search']) {
-            $term = '%' . $sortData['search'] . '%';
+        if ($listOptions->getSearch()) {
+            $term = '%' . $listOptions->getSearch() . '%';
             $query->where(function ($query) use ($term) {
                 $query->where('display_name', 'like', $term)
                     ->orWhere('description', 'like', $term);
diff --git a/app/Auth/Queries/UsersAllPaginatedAndSorted.php b/app/Auth/Queries/UsersAllPaginatedAndSorted.php
index 3a64cc800..29b6a8969 100644
--- a/app/Auth/Queries/UsersAllPaginatedAndSorted.php
+++ b/app/Auth/Queries/UsersAllPaginatedAndSorted.php
@@ -3,6 +3,7 @@
 namespace BookStack\Auth\Queries;
 
 use BookStack\Auth\User;
+use BookStack\Util\SimpleListOptions;
 use Illuminate\Pagination\LengthAwarePaginator;
 
 /**
@@ -13,12 +14,9 @@ use Illuminate\Pagination\LengthAwarePaginator;
  */
 class UsersAllPaginatedAndSorted
 {
-    /**
-     * @param array{sort: string, order: string, search: string} $sortData
-     */
-    public function run(int $count, array $sortData): LengthAwarePaginator
+    public function run(int $count, SimpleListOptions $listOptions): LengthAwarePaginator
     {
-        $sort = $sortData['sort'];
+        $sort = $listOptions->getSort();
         if ($sort === 'created_at') {
             $sort = 'users.created_at';
         }
@@ -27,10 +25,10 @@ class UsersAllPaginatedAndSorted
             ->scopes(['withLastActivityAt'])
             ->with(['roles', 'avatar'])
             ->withCount('mfaValues')
-            ->orderBy($sort, $sortData['order']);
+            ->orderBy($sort, $listOptions->getOrder());
 
-        if ($sortData['search']) {
-            $term = '%' . $sortData['search'] . '%';
+        if ($listOptions->getSearch()) {
+            $term = '%' . $listOptions->getSearch() . '%';
             $query->where(function ($query) use ($term) {
                 $query->where('name', 'like', $term)
                     ->orWhere('email', 'like', $term);
diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php
index b323ae496..14c3af1cc 100644
--- a/app/Http/Controllers/BookController.php
+++ b/app/Http/Controllers/BookController.php
@@ -15,6 +15,7 @@ use BookStack\Exceptions\ImageUploadException;
 use BookStack\Exceptions\NotFoundException;
 use BookStack\Facades\Activity;
 use BookStack\References\ReferenceFetcher;
+use BookStack\Util\SimpleListOptions;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
 use Throwable;
@@ -35,13 +36,16 @@ class BookController extends Controller
     /**
      * Display a listing of the book.
      */
-    public function index()
+    public function index(Request $request)
     {
         $view = setting()->getForCurrentUser('books_view_type');
-        $sort = setting()->getForCurrentUser('books_sort', 'name');
-        $order = setting()->getForCurrentUser('books_sort_order', 'asc');
+        $listOptions = SimpleListOptions::fromRequest($request, 'books')->withSortOptions([
+            'name' => trans('common.sort_name'),
+            'created_at' => trans('common.sort_created_at'),
+            'updated_at' => trans('common.sort_updated_at'),
+        ]);
 
-        $books = $this->bookRepo->getAllPaginated(18, $sort, $order);
+        $books = $this->bookRepo->getAllPaginated(18, $listOptions->getSort(), $listOptions->getOrder());
         $recents = $this->isSignedIn() ? $this->bookRepo->getRecentlyViewed(4) : false;
         $popular = $this->bookRepo->getPopular(4);
         $new = $this->bookRepo->getRecentlyCreated(4);
@@ -56,8 +60,7 @@ class BookController extends Controller
             'popular' => $popular,
             'new'     => $new,
             'view'    => $view,
-            'sort'    => $sort,
-            'order'   => $order,
+            'listOptions' => $listOptions,
         ]);
     }
 
diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php
index 3c63be631..537ea915b 100644
--- a/app/Http/Controllers/BookshelfController.php
+++ b/app/Http/Controllers/BookshelfController.php
@@ -10,6 +10,7 @@ use BookStack\Entities\Tools\ShelfContext;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Exceptions\NotFoundException;
 use BookStack\References\ReferenceFetcher;
+use BookStack\Util\SimpleListOptions;
 use Exception;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
@@ -30,18 +31,16 @@ class BookshelfController extends Controller
     /**
      * Display a listing of the book.
      */
-    public function index()
+    public function index(Request $request)
     {
         $view = setting()->getForCurrentUser('bookshelves_view_type');
-        $sort = setting()->getForCurrentUser('bookshelves_sort', 'name');
-        $order = setting()->getForCurrentUser('bookshelves_sort_order', 'asc');
-        $sortOptions = [
+        $listOptions = SimpleListOptions::fromRequest($request, 'bookshelves')->withSortOptions([
             'name'       => trans('common.sort_name'),
             'created_at' => trans('common.sort_created_at'),
             'updated_at' => trans('common.sort_updated_at'),
-        ];
+        ]);
 
-        $shelves = $this->shelfRepo->getAllPaginated(18, $sort, $order);
+        $shelves = $this->shelfRepo->getAllPaginated(18, $listOptions->getSort(), $listOptions->getOrder());
         $recents = $this->isSignedIn() ? $this->shelfRepo->getRecentlyViewed(4) : false;
         $popular = $this->shelfRepo->getPopular(4);
         $new = $this->shelfRepo->getRecentlyCreated(4);
@@ -55,9 +54,7 @@ class BookshelfController extends Controller
             'popular'     => $popular,
             'new'         => $new,
             'view'        => $view,
-            'sort'        => $sort,
-            'order'       => $order,
-            'sortOptions' => $sortOptions,
+            'listOptions' => $listOptions,
         ]);
     }
 
@@ -100,16 +97,21 @@ class BookshelfController extends Controller
      *
      * @throws NotFoundException
      */
-    public function show(ActivityQueries $activities, string $slug)
+    public function show(Request $request, ActivityQueries $activities, string $slug)
     {
         $shelf = $this->shelfRepo->getBySlug($slug);
         $this->checkOwnablePermission('bookshelf-view', $shelf);
 
-        $sort = setting()->getForCurrentUser('shelf_books_sort', 'default');
-        $order = setting()->getForCurrentUser('shelf_books_sort_order', 'asc');
+        $listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
+            'default' => trans('common.sort_default'),
+            'name' => trans('common.sort_name'),
+            'created_at' => trans('common.sort_created_at'),
+            'updated_at' => trans('common.sort_updated_at'),
+        ]);
 
+        $sort = $listOptions->getSort();
         $sortedVisibleShelfBooks = $shelf->visibleBooks()->get()
-            ->sortBy($sort === 'default' ? 'pivot.order' : $sort, SORT_REGULAR, $order === 'desc')
+            ->sortBy($sort === 'default' ? 'pivot.order' : $sort, SORT_REGULAR, $listOptions->getOrder() === 'desc')
             ->values()
             ->all();
 
@@ -124,8 +126,7 @@ class BookshelfController extends Controller
             'sortedVisibleShelfBooks' => $sortedVisibleShelfBooks,
             'view'                    => $view,
             'activity'                => $activities->entityActivity($shelf, 20, 1),
-            'order'                   => $order,
-            'sort'                    => $sort,
+            'listOptions'             => $listOptions,
             'referenceCount'          => $this->referenceFetcher->getPageReferenceCountToEntity($shelf),
         ]);
     }
diff --git a/app/Http/Controllers/RoleController.php b/app/Http/Controllers/RoleController.php
index 0e13a9fb3..a9be19e0c 100644
--- a/app/Http/Controllers/RoleController.php
+++ b/app/Http/Controllers/RoleController.php
@@ -6,6 +6,7 @@ use BookStack\Auth\Permissions\PermissionsRepo;
 use BookStack\Auth\Queries\RolesAllPaginatedAndSorted;
 use BookStack\Auth\Role;
 use BookStack\Exceptions\PermissionsException;
+use BookStack\Util\SimpleListOptions;
 use Exception;
 use Illuminate\Http\Request;
 use Illuminate\Validation\ValidationException;
@@ -26,20 +27,22 @@ class RoleController extends Controller
     {
         $this->checkPermission('user-roles-manage');
 
-        $listDetails = [
-            'search' => $request->get('search', ''),
-            'sort'   => setting()->getForCurrentUser('roles_sort', 'display_name'),
-            'order'  => setting()->getForCurrentUser('roles_sort_order', 'asc'),
-        ];
+        $listOptions = SimpleListOptions::fromRequest($request, 'roles')->withSortOptions([
+            'display_name' => trans('common.sort_name'),
+            'users_count' => trans('settings.roles_assigned_users'),
+            'permissions_count' => trans('settings.roles_permissions_provided'),
+            'created_at' => trans('common.sort_created_at'),
+            'updated_at' => trans('common.sort_updated_at'),
+        ]);
 
-        $roles = (new RolesAllPaginatedAndSorted())->run(20, $listDetails);
-        $roles->appends(['search' => $listDetails['search']]);
+        $roles = (new RolesAllPaginatedAndSorted())->run(20, $listOptions);
+        $roles->appends($listOptions->getPaginationAppends());
 
         $this->setPageTitle(trans('settings.roles'));
 
         return view('settings.roles.index', [
             'roles'       => $roles,
-            'listDetails' => $listDetails,
+            'listOptions' => $listOptions,
         ]);
     }
 
diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php
index 77be07533..9b3cc3977 100644
--- a/app/Http/Controllers/UserController.php
+++ b/app/Http/Controllers/UserController.php
@@ -10,6 +10,7 @@ use BookStack\Auth\UserRepo;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Exceptions\UserUpdateException;
 use BookStack\Uploads\ImageRepo;
+use BookStack\Util\SimpleListOptions;
 use Exception;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\DB;
@@ -36,20 +37,23 @@ class UserController extends Controller
     public function index(Request $request)
     {
         $this->checkPermission('users-manage');
-        $listDetails = [
-            'search' => $request->get('search', ''),
-            'sort'   => setting()->getForCurrentUser('users_sort', 'name'),
-            'order'  => setting()->getForCurrentUser('users_sort_order', 'asc'),
-        ];
 
-        $users = (new UsersAllPaginatedAndSorted())->run(20, $listDetails);
+        $listOptions = SimpleListOptions::fromRequest($request, 'users')->withSortOptions([
+            'name' => trans('common.sort_name'),
+            'email' => trans('auth.email'),
+            'created_at' => trans('common.sort_created_at'),
+            'updated_at' => trans('common.sort_updated_at'),
+            'last_activity_at' => trans('settings.users_latest_activity'),
+        ]);
+
+        $users = (new UsersAllPaginatedAndSorted())->run(20, $listOptions);
 
         $this->setPageTitle(trans('settings.users'));
-        $users->appends(['search' => $listDetails['search']]);
+        $users->appends($listOptions->getPaginationAppends());
 
         return view('users.index', [
             'users'       => $users,
-            'listDetails' => $listDetails,
+            'listOptions' => $listOptions,
         ]);
     }
 
@@ -256,7 +260,18 @@ class UserController extends Controller
             return redirect()->back(500);
         }
 
-        return $this->changeListSort($id, $request, $type);
+        $this->checkPermissionOrCurrentUser('users-manage', $id);
+
+        $sort = substr($request->get('sort') ?: 'name', 0, 50);
+        $order = $request->get('order') === 'desc' ? 'desc' : 'asc';
+
+        $user = $this->userRepo->getById($id);
+        $sortKey = $type . '_sort';
+        $orderKey = $type . '_sort_order';
+        setting()->putUser($user, $sortKey, $sort);
+        setting()->putUser($user, $orderKey, $order);
+
+        return redirect()->back(302, [], "/settings/users/{$id}");
     }
 
     /**
@@ -309,36 +324,4 @@ class UserController extends Controller
 
         setting()->putUser(user(), 'code-language-favourites', implode(',', $currentFavorites));
     }
-
-    /**
-     * Changed the stored preference for a list sort order.
-     */
-    protected function changeListSort(int $userId, Request $request, string $listName)
-    {
-        $this->checkPermissionOrCurrentUser('users-manage', $userId);
-
-        $sort = $request->get('sort');
-        // TODO - Need to find a better way to validate sort options
-        //   Probably better to do a simple validation here then validate at usage.
-        $validSorts = [
-            'name', 'created_at', 'updated_at', 'default', 'email', 'last_activity_at', 'display_name',
-            'users_count', 'permissions_count', 'endpoint', 'active',
-        ];
-        if (!in_array($sort, $validSorts)) {
-            $sort = 'name';
-        }
-
-        $order = $request->get('order');
-        if (!in_array($order, ['asc', 'desc'])) {
-            $order = 'asc';
-        }
-
-        $user = $this->userRepo->getById($userId);
-        $sortKey = $listName . '_sort';
-        $orderKey = $listName . '_sort_order';
-        setting()->putUser($user, $sortKey, $sort);
-        setting()->putUser($user, $orderKey, $order);
-
-        return redirect()->back(302, [], "/settings/users/$userId");
-    }
 }
diff --git a/app/Http/Controllers/WebhookController.php b/app/Http/Controllers/WebhookController.php
index 23120c7e4..c72dcc510 100644
--- a/app/Http/Controllers/WebhookController.php
+++ b/app/Http/Controllers/WebhookController.php
@@ -5,6 +5,7 @@ namespace BookStack\Http\Controllers;
 use BookStack\Actions\ActivityType;
 use BookStack\Actions\Queries\WebhooksAllPaginatedAndSorted;
 use BookStack\Actions\Webhook;
+use BookStack\Util\SimpleListOptions;
 use Illuminate\Http\Request;
 
 class WebhookController extends Controller
@@ -21,20 +22,22 @@ class WebhookController extends Controller
      */
     public function index(Request $request)
     {
-        $listDetails = [
-            'search' => $request->get('search', ''),
-            'sort'   => setting()->getForCurrentUser('webhooks_sort', 'name'),
-            'order'  => setting()->getForCurrentUser('webhooks_sort_order', 'asc'),
-        ];
+        $listOptions = SimpleListOptions::fromRequest($request, 'webhooks')->withSortOptions([
+            'name' => trans('common.sort_name'),
+            'endpoint'  => trans('settings.webhooks_endpoint'),
+            'created_at' => trans('common.sort_created_at'),
+            'updated_at' => trans('common.sort_updated_at'),
+            'active'     => trans('common.status'),
+        ]);
 
-        $webhooks = (new WebhooksAllPaginatedAndSorted())->run(20, $listDetails);
-        $webhooks->appends(['search' => $listDetails['search']]);
+        $webhooks = (new WebhooksAllPaginatedAndSorted())->run(20, $listOptions);
+        $webhooks->appends($listOptions->getPaginationAppends());
 
         $this->setPageTitle(trans('settings.webhooks'));
 
         return view('settings.webhooks.index', [
             'webhooks'    => $webhooks,
-            'listDetails' => $listDetails,
+            'listOptions' => $listOptions,
         ]);
     }
 
diff --git a/app/Util/SimpleListOptions.php b/app/Util/SimpleListOptions.php
new file mode 100644
index 000000000..f6daa6954
--- /dev/null
+++ b/app/Util/SimpleListOptions.php
@@ -0,0 +1,104 @@
+<?php
+
+namespace BookStack\Util;
+
+use Illuminate\Http\Request;
+
+/**
+ * Handled options commonly used for item lists within the system, providing a standard
+ * model for handling and validating sort, order and search options.
+ */
+class SimpleListOptions
+{
+    protected string $typeKey;
+    protected string $sort;
+    protected string $order;
+    protected string $search;
+    protected array $sortOptions = [];
+
+    public function __construct(string $typeKey, string $sort, string $order, string $search = '')
+    {
+        $this->typeKey = $typeKey;
+        $this->sort = $sort;
+        $this->order = $order;
+        $this->search = $search;
+    }
+
+    /**
+     * Create a new instance from the given request.
+     * Takes the item type (plural) that's used as a key for storing sort preferences.
+     */
+    public static function fromRequest(Request $request, string $typeKey): self
+    {
+        $search = $request->get('search', '');
+        $sort = setting()->getForCurrentUser($typeKey . '_sort', '');
+        $order = setting()->getForCurrentUser($typeKey . '_sort_order', 'asc');
+
+        return new static($typeKey, $sort, $order, $search);
+    }
+
+    /**
+     * Configure the valid sort options for this set of list options.
+     * Provided sort options must be an array, keyed by search properties
+     * with values being user-visible option labels.
+     * Returns current options for easy fluent usage during creation.
+     */
+    public function withSortOptions(array $sortOptions): self
+    {
+        $this->sortOptions = array_merge($this->sortOptions, $sortOptions);
+
+        return $this;
+    }
+
+    /**
+     * Get the current order option.
+     */
+    public function getOrder(): string
+    {
+        return strtolower($this->order) === 'desc' ? 'desc' : 'asc';
+    }
+
+    /**
+     * Get the current sort option.
+     */
+    public function getSort(): string
+    {
+        $default = array_key_first($this->sortOptions) ?? 'name';
+        $sort = $this->sort ?: $default;
+
+        if (empty($this->sortOptions) || array_key_exists($sort, $this->sortOptions)) {
+            return $sort;
+        }
+
+        return $default;
+    }
+
+    /**
+     * Get the set search term.
+     */
+    public function getSearch(): string
+    {
+        return $this->search;
+    }
+
+    /**
+     * Get the data to append for pagination.
+     */
+    public function getPaginationAppends(): array
+    {
+        return ['search' => $this->search];
+    }
+
+    /**
+     * Get the data required by the sort control view.
+     */
+    public function getSortControlData(): array
+    {
+        return [
+            'options' => $this->sortOptions,
+            'order' => $this->getOrder(),
+            'sort' => $this->getSort(),
+            'type' => $this->typeKey,
+        ];
+    }
+}
diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php
index 6573bbe6a..447d6fd44 100644
--- a/resources/views/books/index.blade.php
+++ b/resources/views/books/index.blade.php
@@ -1,7 +1,7 @@
 @extends('layouts.tri')
 
 @section('body')
-    @include('books.parts.list', ['books' => $books, 'view' => $view])
+    @include('books.parts.list', ['books' => $books, 'view' => $view, 'listOptions' => $listOptions])
 @stop
 
 @section('left')
diff --git a/resources/views/books/parts/list.blade.php b/resources/views/books/parts/list.blade.php
index 79d0554c5..2cf83dfa9 100644
--- a/resources/views/books/parts/list.blade.php
+++ b/resources/views/books/parts/list.blade.php
@@ -2,13 +2,7 @@
     <div class="grid half v-center no-row-gap">
         <h1 class="list-heading">{{ trans('entities.books') }}</h1>
         <div class="text-m-right my-m">
-
-            @include('common.sort', ['options' => [
-                'name' => trans('common.sort_name'),
-                'created_at' => trans('common.sort_created_at'),
-                'updated_at' => trans('common.sort_updated_at'),
-            ], 'order' => $order, 'sort' => $sort, 'type' => 'books'])
-
+            @include('common.sort', $listOptions->getSortControlData())
         </div>
     </div>
     @if(count($books) > 0)
diff --git a/resources/views/settings/roles/index.blade.php b/resources/views/settings/roles/index.blade.php
index 7e3d5b852..27ee9ce3f 100644
--- a/resources/views/settings/roles/index.blade.php
+++ b/resources/views/settings/roles/index.blade.php
@@ -22,18 +22,15 @@
                 <div>
                     <div class="block inline mr-xs">
                         <form method="get" action="{{ url("/settings/roles") }}">
-                            <input type="text" name="search" placeholder="{{ trans('common.search') }}" @if($listDetails['search']) value="{{$listDetails['search']}}" @endif>
+                            <input type="text"
+                                   name="search"
+                                   placeholder="{{ trans('common.search') }}"
+                                   value="{{ $listOptions->getSearch() }}">
                         </form>
                     </div>
                 </div>
                 <div class="justify-flex-end">
-                    @include('common.sort', ['options' => [
-                        'display_name' => trans('common.sort_name'),
-                        'users_count' => trans('settings.roles_assigned_users'),
-                        'permissions_count' => trans('settings.roles_permissions_provided'),
-                        'created_at' => trans('common.sort_created_at'),
-                        'updated_at' => trans('common.sort_updated_at'),
-                    ], 'order' => $listDetails['order'], 'sort' => $listDetails['sort'], 'type' => 'roles'])
+                    @include('common.sort', $listOptions->getSortControlData())
                 </div>
             </div>
 
diff --git a/resources/views/settings/webhooks/index.blade.php b/resources/views/settings/webhooks/index.blade.php
index 09b2ee770..a564effe2 100644
--- a/resources/views/settings/webhooks/index.blade.php
+++ b/resources/views/settings/webhooks/index.blade.php
@@ -23,18 +23,15 @@
                 <div>
                     <div class="block inline mr-xs">
                         <form method="get" action="{{ url("/settings/webhooks") }}">
-                            <input type="text" name="search" placeholder="{{ trans('common.search') }}" @if($listDetails['search']) value="{{$listDetails['search']}}" @endif>
+                            <input type="text"
+                                   name="search"
+                                   placeholder="{{ trans('common.search') }}"
+                                   value="{{ $listOptions->getSearch() }}">
                         </form>
                     </div>
                 </div>
                 <div class="justify-flex-end">
-                    @include('common.sort', ['options' => [
-                        'name' => trans('common.sort_name'),
-                        'endpoint'  => trans('settings.webhooks_endpoint'),
-                        'created_at' => trans('common.sort_created_at'),
-                        'updated_at' => trans('common.sort_updated_at'),
-                        'active'     => trans('common.status'),
-                    ], 'order' => $listDetails['order'], 'sort' => $listDetails['sort'], 'type' => 'webhooks'])
+                    @include('common.sort', $listOptions->getSortControlData())
                 </div>
             </div>
 
diff --git a/resources/views/shelves/index.blade.php b/resources/views/shelves/index.blade.php
index ee52769aa..75d46318f 100644
--- a/resources/views/shelves/index.blade.php
+++ b/resources/views/shelves/index.blade.php
@@ -1,7 +1,7 @@
 @extends('layouts.tri')
 
 @section('body')
-    @include('shelves.parts.list', ['shelves' => $shelves, 'view' => $view])
+    @include('shelves.parts.list', ['shelves' => $shelves, 'view' => $view, 'listOptions' => $listOptions])
 @stop
 
 @section('right')
diff --git a/resources/views/shelves/parts/list.blade.php b/resources/views/shelves/parts/list.blade.php
index 4c841db64..da9c06d92 100644
--- a/resources/views/shelves/parts/list.blade.php
+++ b/resources/views/shelves/parts/list.blade.php
@@ -3,7 +3,7 @@
     <div class="grid half v-center">
         <h1 class="list-heading">{{ trans('entities.shelves') }}</h1>
         <div class="text-right">
-            @include('common.sort', ['options' => $sortOptions, 'order' => $order, 'sort' => $sort, 'type' => 'bookshelves'])
+            @include('common.sort', $listOptions->getSortControlData())
         </div>
     </div>
 
diff --git a/resources/views/shelves/show.blade.php b/resources/views/shelves/show.blade.php
index fe11ccfce..0195759d8 100644
--- a/resources/views/shelves/show.blade.php
+++ b/resources/views/shelves/show.blade.php
@@ -23,12 +23,7 @@
             <h1 class="flex fit-content break-text">{{ $shelf->name }}</h1>
             <div class="flex"></div>
             <div class="flex fit-content text-m-right my-m ml-m">
-                @include('common.sort', ['options' => [
-                    'default' => trans('common.sort_default'),
-                    'name' => trans('common.sort_name'),
-                    'created_at' => trans('common.sort_created_at'),
-                    'updated_at' => trans('common.sort_updated_at'),
-                ], 'order' => $order, 'sort' => $sort, 'type' => 'shelf_books'])
+                @include('common.sort', $listOptions->getSortControlData())
             </div>
         </div>
 
diff --git a/resources/views/users/index.blade.php b/resources/views/users/index.blade.php
index 139ac4579..0dd607f8c 100644
--- a/resources/views/users/index.blade.php
+++ b/resources/views/users/index.blade.php
@@ -20,18 +20,15 @@
                 <div>
                     <div class="block inline mr-xs">
                         <form method="get" action="{{ url("/settings/users") }}">
-                            <input type="text" name="search" placeholder="{{ trans('settings.users_search') }}" @if($listDetails['search']) value="{{$listDetails['search']}}" @endif>
+                            <input type="text"
+                                   name="search"
+                                   placeholder="{{ trans('settings.users_search') }}"
+                                   value="{{ $listOptions->getSearch() }}">
                         </form>
                     </div>
                 </div>
                 <div class="justify-flex-end">
-                    @include('common.sort', ['options' => [
-                        'name' => trans('common.sort_name'),
-                        'email' => trans('auth.email'),
-                        'created_at' => trans('common.sort_created_at'),
-                        'updated_at' => trans('common.sort_updated_at'),
-                        'last_activity_at' => trans('settings.users_latest_activity'),
-                    ], 'order' => $listDetails['order'], 'sort' => $listDetails['sort'], 'type' => 'users'])
+                    @include('common.sort', $listOptions->getSortControlData())
                 </div>
             </div>
 

From 2c114e1a4aaad9fdea2b13b5dddb583b1ebc64cb Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sun, 30 Oct 2022 15:25:02 +0000
Subject: [PATCH 06/20] Split out user controller preference methods to new
 controller

---
 app/Http/Controllers/UserController.php       | 127 +----------------
 .../Controllers/UserPreferencesController.php | 134 ++++++++++++++++++
 routes/web.php                                |  17 ++-
 3 files changed, 146 insertions(+), 132 deletions(-)
 create mode 100644 app/Http/Controllers/UserPreferencesController.php

diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php
index 9b3cc3977..f69f00cf7 100644
--- a/app/Http/Controllers/UserController.php
+++ b/app/Http/Controllers/UserController.php
@@ -5,7 +5,6 @@ namespace BookStack\Http\Controllers;
 use BookStack\Auth\Access\SocialAuthService;
 use BookStack\Auth\Queries\UsersAllPaginatedAndSorted;
 use BookStack\Auth\Role;
-use BookStack\Auth\User;
 use BookStack\Auth\UserRepo;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Exceptions\UserUpdateException;
@@ -22,9 +21,6 @@ class UserController extends Controller
     protected UserRepo $userRepo;
     protected ImageRepo $imageRepo;
 
-    /**
-     * UserController constructor.
-     */
     public function __construct(UserRepo $userRepo, ImageRepo $imageRepo)
     {
         $this->userRepo = $userRepo;
@@ -111,9 +107,8 @@ class UserController extends Controller
     {
         $this->checkPermissionOrCurrentUser('users-manage', $id);
 
-        /** @var User $user */
-        $user = User::query()->with(['apiTokens', 'mfaValues'])->findOrFail($id);
-
+        $user = $this->userRepo->getById($id);
+        $user->load(['apiTokens', 'mfaValues']);
         $authMethod = ($user->system_name) ? 'system' : config('auth.method');
 
         $activeSocialDrivers = $socialAuthService->getActiveDrivers();
@@ -206,122 +201,4 @@ class UserController extends Controller
 
         return redirect('/settings/users');
     }
-
-    /**
-     * Update the user's preferred book-list display setting.
-     */
-    public function switchBooksView(Request $request, int $id)
-    {
-        return $this->switchViewType($id, $request, 'books');
-    }
-
-    /**
-     * Update the user's preferred shelf-list display setting.
-     */
-    public function switchShelvesView(Request $request, int $id)
-    {
-        return $this->switchViewType($id, $request, 'bookshelves');
-    }
-
-    /**
-     * Update the user's preferred shelf-view book list display setting.
-     */
-    public function switchShelfView(Request $request, int $id)
-    {
-        return $this->switchViewType($id, $request, 'bookshelf');
-    }
-
-    /**
-     * For a type of list, switch with stored view type for a user.
-     */
-    protected function switchViewType(int $userId, Request $request, string $listName)
-    {
-        $this->checkPermissionOrCurrentUser('users-manage', $userId);
-
-        $viewType = $request->get('view_type');
-        if (!in_array($viewType, ['grid', 'list'])) {
-            $viewType = 'list';
-        }
-
-        $user = $this->userRepo->getById($userId);
-        $key = $listName . '_view_type';
-        setting()->putUser($user, $key, $viewType);
-
-        return redirect()->back(302, [], "/settings/users/$userId");
-    }
-
-    /**
-     * Change the stored sort type for a particular view.
-     */
-    public function changeSort(Request $request, string $id, string $type)
-    {
-        $validSortTypes = ['books', 'bookshelves', 'shelf_books', 'users', 'roles', 'webhooks'];
-        if (!in_array($type, $validSortTypes)) {
-            return redirect()->back(500);
-        }
-
-        $this->checkPermissionOrCurrentUser('users-manage', $id);
-
-        $sort = substr($request->get('sort') ?: 'name', 0, 50);
-        $order = $request->get('order') === 'desc' ? 'desc' : 'asc';
-
-        $user = $this->userRepo->getById($id);
-        $sortKey = $type . '_sort';
-        $orderKey = $type . '_sort_order';
-        setting()->putUser($user, $sortKey, $sort);
-        setting()->putUser($user, $orderKey, $order);
-
-        return redirect()->back(302, [], "/settings/users/{$id}");
-    }
-
-    /**
-     * Toggle dark mode for the current user.
-     */
-    public function toggleDarkMode()
-    {
-        $enabled = setting()->getForCurrentUser('dark-mode-enabled', false);
-        setting()->putUser(user(), 'dark-mode-enabled', $enabled ? 'false' : 'true');
-
-        return redirect()->back();
-    }
-
-    /**
-     * Update the stored section expansion preference for the given user.
-     */
-    public function updateExpansionPreference(Request $request, string $id, string $key)
-    {
-        $this->checkPermissionOrCurrentUser('users-manage', $id);
-        $keyWhitelist = ['home-details'];
-        if (!in_array($key, $keyWhitelist)) {
-            return response('Invalid key', 500);
-        }
-
-        $newState = $request->get('expand', 'false');
-
-        $user = $this->userRepo->getById($id);
-        setting()->putUser($user, 'section_expansion#' . $key, $newState);
-
-        return response('', 204);
-    }
-
-    public function updateCodeLanguageFavourite(Request $request)
-    {
-        $validated = $this->validate($request, [
-            'language' => ['required', 'string', 'max:20'],
-            'active'   => ['required', 'bool'],
-        ]);
-
-        $currentFavoritesStr = setting()->getForCurrentUser('code-language-favourites', '');
-        $currentFavorites = array_filter(explode(',', $currentFavoritesStr));
-
-        $isFav = in_array($validated['language'], $currentFavorites);
-        if (!$isFav && $validated['active']) {
-            $currentFavorites[] = $validated['language'];
-        } elseif ($isFav && !$validated['active']) {
-            $index = array_search($validated['language'], $currentFavorites);
-            array_splice($currentFavorites, $index, 1);
-        }
-
-        setting()->putUser(user(), 'code-language-favourites', implode(',', $currentFavorites));
-    }
 }
diff --git a/app/Http/Controllers/UserPreferencesController.php b/app/Http/Controllers/UserPreferencesController.php
new file mode 100644
index 000000000..8e9160810
--- /dev/null
+++ b/app/Http/Controllers/UserPreferencesController.php
@@ -0,0 +1,134 @@
+<?php
+
+namespace BookStack\Http\Controllers;
+
+use BookStack\Auth\UserRepo;
+use Illuminate\Http\Request;
+
+class UserPreferencesController extends Controller
+{
+    protected UserRepo $userRepo;
+
+    public function __construct(UserRepo $userRepo)
+    {
+        $this->userRepo = $userRepo;
+    }
+
+    /**
+     * Update the user's preferred book-list display setting.
+     */
+    public function switchBooksView(Request $request, int $id)
+    {
+        return $this->switchViewType($id, $request, 'books');
+    }
+
+    /**
+     * Update the user's preferred shelf-list display setting.
+     */
+    public function switchShelvesView(Request $request, int $id)
+    {
+        return $this->switchViewType($id, $request, 'bookshelves');
+    }
+
+    /**
+     * Update the user's preferred shelf-view book list display setting.
+     */
+    public function switchShelfView(Request $request, int $id)
+    {
+        return $this->switchViewType($id, $request, 'bookshelf');
+    }
+
+    /**
+     * For a type of list, switch with stored view type for a user.
+     */
+    protected function switchViewType(int $userId, Request $request, string $listName)
+    {
+        $this->checkPermissionOrCurrentUser('users-manage', $userId);
+
+        $viewType = $request->get('view_type');
+        if (!in_array($viewType, ['grid', 'list'])) {
+            $viewType = 'list';
+        }
+
+        $user = $this->userRepo->getById($userId);
+        $key = $listName . '_view_type';
+        setting()->putUser($user, $key, $viewType);
+
+        return redirect()->back(302, [], "/settings/users/$userId");
+    }
+
+    /**
+     * Change the stored sort type for a particular view.
+     */
+    public function changeSort(Request $request, string $id, string $type)
+    {
+        $validSortTypes = ['books', 'bookshelves', 'shelf_books', 'users', 'roles', 'webhooks'];
+        if (!in_array($type, $validSortTypes)) {
+            return redirect()->back(500);
+        }
+
+        $this->checkPermissionOrCurrentUser('users-manage', $id);
+
+        $sort = substr($request->get('sort') ?: 'name', 0, 50);
+        $order = $request->get('order') === 'desc' ? 'desc' : 'asc';
+
+        $user = $this->userRepo->getById($id);
+        $sortKey = $type . '_sort';
+        $orderKey = $type . '_sort_order';
+        setting()->putUser($user, $sortKey, $sort);
+        setting()->putUser($user, $orderKey, $order);
+
+        return redirect()->back(302, [], "/settings/users/{$id}");
+    }
+
+    /**
+     * Toggle dark mode for the current user.
+     */
+    public function toggleDarkMode()
+    {
+        $enabled = setting()->getForCurrentUser('dark-mode-enabled', false);
+        setting()->putUser(user(), 'dark-mode-enabled', $enabled ? 'false' : 'true');
+
+        return redirect()->back();
+    }
+
+    /**
+     * Update the stored section expansion preference for the given user.
+     */
+    public function updateExpansionPreference(Request $request, string $id, string $key)
+    {
+        $this->checkPermissionOrCurrentUser('users-manage', $id);
+        $keyWhitelist = ['home-details'];
+        if (!in_array($key, $keyWhitelist)) {
+            return response('Invalid key', 500);
+        }
+
+        $newState = $request->get('expand', 'false');
+
+        $user = $this->userRepo->getById($id);
+        setting()->putUser($user, 'section_expansion#' . $key, $newState);
+
+        return response('', 204);
+    }
+
+    public function updateCodeLanguageFavourite(Request $request)
+    {
+        $validated = $this->validate($request, [
+            'language' => ['required', 'string', 'max:20'],
+            'active'   => ['required', 'bool'],
+        ]);
+
+        $currentFavoritesStr = setting()->getForCurrentUser('code-language-favourites', '');
+        $currentFavorites = array_filter(explode(',', $currentFavoritesStr));
+
+        $isFav = in_array($validated['language'], $currentFavorites);
+        if (!$isFav && $validated['active']) {
+            $currentFavorites[] = $validated['language'];
+        } elseif ($isFav && !$validated['active']) {
+            $index = array_search($validated['language'], $currentFavorites);
+            array_splice($currentFavorites, $index, 1);
+        }
+
+        setting()->putUser(user(), 'code-language-favourites', implode(',', $currentFavorites));
+    }
+}
diff --git a/routes/web.php b/routes/web.php
index 1cffbfd7d..b3f11f53a 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -29,6 +29,7 @@ use BookStack\Http\Controllers\StatusController;
 use BookStack\Http\Controllers\TagController;
 use BookStack\Http\Controllers\UserApiTokenController;
 use BookStack\Http\Controllers\UserController;
+use BookStack\Http\Controllers\UserPreferencesController;
 use BookStack\Http\Controllers\UserProfileController;
 use BookStack\Http\Controllers\UserSearchController;
 use BookStack\Http\Controllers\WebhookController;
@@ -239,18 +240,20 @@ Route::middleware('auth')->group(function () {
     Route::get('/settings/users', [UserController::class, 'index']);
     Route::get('/settings/users/create', [UserController::class, 'create']);
     Route::get('/settings/users/{id}/delete', [UserController::class, 'delete']);
-    Route::patch('/settings/users/{id}/switch-books-view', [UserController::class, 'switchBooksView']);
-    Route::patch('/settings/users/{id}/switch-shelves-view', [UserController::class, 'switchShelvesView']);
-    Route::patch('/settings/users/{id}/switch-shelf-view', [UserController::class, 'switchShelfView']);
-    Route::patch('/settings/users/{id}/change-sort/{type}', [UserController::class, 'changeSort']);
-    Route::patch('/settings/users/{id}/update-expansion-preference/{key}', [UserController::class, 'updateExpansionPreference']);
-    Route::patch('/settings/users/toggle-dark-mode', [UserController::class, 'toggleDarkMode']);
-    Route::patch('/settings/users/update-code-language-favourite', [UserController::class, 'updateCodeLanguageFavourite']);
     Route::post('/settings/users/create', [UserController::class, 'store']);
     Route::get('/settings/users/{id}', [UserController::class, 'edit']);
     Route::put('/settings/users/{id}', [UserController::class, 'update']);
     Route::delete('/settings/users/{id}', [UserController::class, 'destroy']);
 
+    // User Preferences
+    Route::patch('/settings/users/{id}/switch-books-view', [UserPreferencesController::class, 'switchBooksView']);
+    Route::patch('/settings/users/{id}/switch-shelves-view', [UserPreferencesController::class, 'switchShelvesView']);
+    Route::patch('/settings/users/{id}/switch-shelf-view', [UserPreferencesController::class, 'switchShelfView']);
+    Route::patch('/settings/users/{id}/change-sort/{type}', [UserPreferencesController::class, 'changeSort']);
+    Route::patch('/settings/users/{id}/update-expansion-preference/{key}', [UserPreferencesController::class, 'updateExpansionPreference']);
+    Route::patch('/settings/users/toggle-dark-mode', [UserPreferencesController::class, 'toggleDarkMode']);
+    Route::patch('/settings/users/update-code-language-favourite', [UserPreferencesController::class, 'updateCodeLanguageFavourite']);
+
     // User API Tokens
     Route::get('/settings/users/{userId}/create-api-token', [UserApiTokenController::class, 'create']);
     Route::post('/settings/users/{userId}/create-api-token', [UserApiTokenController::class, 'store']);

From ab184c01d8648b86ac33d57ea5df57100aeef6f8 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sun, 30 Oct 2022 15:37:52 +0000
Subject: [PATCH 07/20] Updated API tokens list to new responsive format

---
 .../users/api-tokens/parts/list.blade.php     | 36 +++++++++----------
 1 file changed, 18 insertions(+), 18 deletions(-)

diff --git a/resources/views/users/api-tokens/parts/list.blade.php b/resources/views/users/api-tokens/parts/list.blade.php
index ea1893372..58617fb85 100644
--- a/resources/views/users/api-tokens/parts/list.blade.php
+++ b/resources/views/users/api-tokens/parts/list.blade.php
@@ -1,6 +1,6 @@
 <section class="card content-wrap auto-height" id="api_tokens">
-    <div class="grid half mb-s">
-        <div><h2 class="list-heading">{{ trans('settings.users_api_tokens') }}</h2></div>
+    <div class="flex-container-row wrap justify-space-between items-center mb-s">
+        <h2 class="list-heading">{{ trans('settings.users_api_tokens') }}</h2>
         <div class="text-right pt-xs">
             @if(userCan('access-api'))
                 <a href="{{ url('/api/docs') }}" class="button outline">{{ trans('settings.users_api_tokens_docs') }}</a>
@@ -9,25 +9,25 @@
         </div>
     </div>
     @if (count($user->apiTokens) > 0)
-        <table class="table">
-            <tr>
-                <th>{{ trans('common.name') }}</th>
-                <th>{{ trans('settings.users_api_tokens_expires') }}</th>
-                <th></th>
-            </tr>
+        <div class="item-list my-m">
             @foreach($user->apiTokens as $token)
-                <tr>
-                    <td>
-                        {{ $token->name }} <br>
+                <div class="item-list-row flex-container-row items-center wrap py-xs gap-x-m">
+                    <div class="flex px-m py-xs min-width-m">
+                        <a href="{{ $user->getEditUrl('/api-tokens/' . $token->id) }}">{{ $token->name }}</a> <br>
                         <span class="small text-muted italic">{{ $token->token_id }}</span>
-                    </td>
-                    <td>{{ $token->expires_at->format('Y-m-d') ?? '' }}</td>
-                    <td class="text-right">
-                        <a class="button outline small" href="{{ $user->getEditUrl('/api-tokens/' . $token->id) }}">{{ trans('common.edit') }}</a>
-                    </td>
-                </tr>
+                    </div>
+                    <div class="flex flex-container-row items-center min-width-m">
+                        <div class="flex px-m py-xs text-muted">
+                            <strong class="text-small">{{ trans('settings.users_api_tokens_expires') }}</strong> <br>
+                            {{ $token->expires_at->format('Y-m-d') ?? '' }}
+                        </div>
+                        <div class="flex px-m py-xs text-right">
+                            <a class="button outline small" href="{{ $user->getEditUrl('/api-tokens/' . $token->id) }}">{{ trans('common.edit') }}</a>
+                        </div>
+                    </div>
+                </div>
             @endforeach
-        </table>
+        </div>
     @else
         <p class="text-muted italic py-m">{{ trans('settings.users_api_tokens_none') }}</p>
     @endif

From 2bbf7b2194237a82cee722bb9b936ceeaae78241 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sun, 30 Oct 2022 20:24:08 +0000
Subject: [PATCH 08/20] Revised audit log list to new responsive format

---
 app/Http/Controllers/AuditLogController.php   | 46 +++++-----
 resources/js/components/list-sort-control.js  | 20 +++--
 resources/sass/_layout.scss                   |  4 +
 resources/sass/styles.scss                    | 11 ---
 resources/views/common/sort.blade.php         | 31 +++++--
 resources/views/settings/audit.blade.php      | 86 +++++++++++--------
 .../views/settings/parts/table-user.blade.php |  6 +-
 7 files changed, 116 insertions(+), 88 deletions(-)

diff --git a/app/Http/Controllers/AuditLogController.php b/app/Http/Controllers/AuditLogController.php
index ec3f36975..da8009d78 100644
--- a/app/Http/Controllers/AuditLogController.php
+++ b/app/Http/Controllers/AuditLogController.php
@@ -3,6 +3,8 @@
 namespace BookStack\Http\Controllers;
 
 use BookStack\Actions\Activity;
+use BookStack\Actions\ActivityType;
+use BookStack\Util\SimpleListOptions;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\DB;
 
@@ -13,10 +15,15 @@ class AuditLogController extends Controller
         $this->checkPermission('settings-manage');
         $this->checkPermission('users-manage');
 
-        $listDetails = [
-            'order'     => $request->get('order', 'desc'),
+        $sort = $request->get('sort', 'activity_date');
+        $order = $request->get('order', 'desc');
+        $listOptions = (new SimpleListOptions('', $sort, $order))->withSortOptions([
+            'created_at' => trans('settings.audit_table_date'),
+            'type' => trans('settings.audit_table_event'),
+        ]);
+
+        $filters = [
             'event'     => $request->get('event', ''),
-            'sort'      => $request->get('sort', 'created_at'),
             'date_from' => $request->get('date_from', ''),
             'date_to'   => $request->get('date_to', ''),
             'user'      => $request->get('user', ''),
@@ -25,39 +32,38 @@ class AuditLogController extends Controller
 
         $query = Activity::query()
             ->with([
-                'entity' => function ($query) {
-                    $query->withTrashed();
-                },
+                'entity' => fn ($query) => $query->withTrashed(),
                 'user',
             ])
-            ->orderBy($listDetails['sort'], $listDetails['order']);
+            ->orderBy($listOptions->getSort(), $listOptions->getOrder());
 
-        if ($listDetails['event']) {
-            $query->where('type', '=', $listDetails['event']);
+        if ($filters['event']) {
+            $query->where('type', '=', $filters['event']);
         }
-        if ($listDetails['user']) {
-            $query->where('user_id', '=', $listDetails['user']);
+        if ($filters['user']) {
+            $query->where('user_id', '=', $filters['user']);
         }
 
-        if ($listDetails['date_from']) {
-            $query->where('created_at', '>=', $listDetails['date_from']);
+        if ($filters['date_from']) {
+            $query->where('created_at', '>=', $filters['date_from']);
         }
-        if ($listDetails['date_to']) {
-            $query->where('created_at', '<=', $listDetails['date_to']);
+        if ($filters['date_to']) {
+            $query->where('created_at', '<=', $filters['date_to']);
         }
-        if ($listDetails['ip']) {
-            $query->where('ip', 'like', $listDetails['ip'] . '%');
+        if ($filters['ip']) {
+            $query->where('ip', 'like', $filters['ip'] . '%');
         }
 
         $activities = $query->paginate(100);
-        $activities->appends($listDetails);
+        $activities->appends($request->all());
 
-        $types = DB::table('activities')->select('type')->distinct()->pluck('type');
+        $types = ActivityType::all();
         $this->setPageTitle(trans('settings.audit'));
 
         return view('settings.audit', [
             'activities'    => $activities,
-            'listDetails'   => $listDetails,
+            'filters'       => $filters,
+            'listOptions'   => $listOptions,
             'activityTypes' => $types,
         ]);
     }
diff --git a/resources/js/components/list-sort-control.js b/resources/js/components/list-sort-control.js
index 23fc64ae6..3b642dbde 100644
--- a/resources/js/components/list-sort-control.js
+++ b/resources/js/components/list-sort-control.js
@@ -1,17 +1,22 @@
 /**
  * ListSortControl
  * Manages the logic for the control which provides list sorting options.
+ * @extends {Component}
  */
 class ListSortControl {
 
-    constructor(elem) {
-        this.elem = elem;
-        this.menu = elem.querySelector('ul');
+    setup() {
+        this.elem = this.$el;
+        this.menu = this.$refs.menu;
 
-        this.sortInput = elem.querySelector('[name="sort"]');
-        this.orderInput = elem.querySelector('[name="order"]');
-        this.form = elem.querySelector('form');
+        this.sortInput = this.$refs.sort;
+        this.orderInput = this.$refs.order;
+        this.form = this.$refs.form;
 
+        this.setupListeners();
+    }
+
+    setupListeners() {
         this.menu.addEventListener('click', event => {
             if (event.target.closest('[data-sort-value]') !== null) {
                 this.sortOptionClick(event);
@@ -34,8 +39,7 @@ class ListSortControl {
 
     sortDirectionClick(event) {
         const currentDir = this.orderInput.value;
-        const newDir = (currentDir === 'asc') ? 'desc' : 'asc';
-        this.orderInput.value = newDir;
+        this.orderInput.value = (currentDir === 'asc') ? 'desc' : 'asc';
         event.preventDefault();
         this.form.submit();
     }
diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss
index d4413d32c..51389dc69 100644
--- a/resources/sass/_layout.scss
+++ b/resources/sass/_layout.scss
@@ -144,6 +144,10 @@ body.flexbox {
   flex-direction: column;
 }
 
+.flex-container-row.inline, .flex-container-column.inline {
+  display: inline-flex !important;
+}
+
 .flex-container-column.wrap, .flex-container-row.wrap {
   flex-wrap: wrap;
 }
diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss
index ab97466a5..44d0055b5 100644
--- a/resources/sass/styles.scss
+++ b/resources/sass/styles.scss
@@ -352,15 +352,4 @@ input.scroll-box-search, .scroll-box-header-item {
       transform: rotate(180deg);
     }
   }
-}
-
-table.table .table-user-item {
-  display: grid;
-  grid-template-columns: 42px 1fr;
-  align-items: center;
-}
-table.table .table-entity-item {
-  display: grid;
-  grid-template-columns: 36px 1fr;
-  align-items: center;
 }
\ No newline at end of file
diff --git a/resources/views/common/sort.blade.php b/resources/views/common/sort.blade.php
index f81ed797f..996f7a837 100644
--- a/resources/views/common/sort.blade.php
+++ b/resources/views/common/sort.blade.php
@@ -2,25 +2,40 @@
     $selectedSort = (isset($sort) && array_key_exists($sort, $options)) ? $sort : array_keys($options)[0];
     $order = (isset($order) && in_array($order, ['asc', 'desc'])) ? $order : 'asc';
 ?>
-<div class="list-sort-container" list-sort-control>
+<div component="list-sort-control" class="list-sort-container">
     <div class="list-sort-label">{{ trans('common.sort') }}</div>
-    <form action="{{ url("/settings/users/". user()->id ."/change-sort/{$type}") }}" method="post">
+    <form refs="list-sort-control@form"
+          @if($useQuery ?? false)
+              action="{{ url()->current() }}"
+              method="get"
+          @else
+              action="{{ url("/settings/users/". user()->id ."/change-sort/{$type}") }}"
+              method="post"
+          @endif
+    >
 
-        {!! csrf_field() !!}
-        {!! method_field('PATCH') !!}
-        <input type="hidden" value="{{ $selectedSort }}" name="sort">
-        <input type="hidden" value="{{ $order }}" name="order">
+        @if($useQuery ?? false)
+            @foreach(array_filter(request()->except(['sort', 'order'])) as $key => $value)
+                <input type="hidden" name="{{ $key }}" value="{{ $value }}">
+            @endforeach
+        @else
+            {!! method_field('PATCH') !!}
+            {!! csrf_field() !!}
+        @endif
+
+        <input refs="list-sort-control@sort" type="hidden" value="{{ $selectedSort }}" name="sort">
+        <input refs="list-sort-control@order" type="hidden" value="{{ $order }}" name="order">
 
         <div class="list-sort">
             <div component="dropdown" class="list-sort-type dropdown-container">
                 <div refs="dropdown@toggle" aria-haspopup="true" aria-expanded="false" aria-label="{{ trans('common.sort_options') }}" tabindex="0">{{ $options[$selectedSort] }}</div>
-                <ul refs="dropdown@menu" class="dropdown-menu">
+                <ul refs="dropdown@menu list-sort-control@menu" class="dropdown-menu">
                     @foreach($options as $key => $label)
                         <li @if($key === $selectedSort) class="active" @endif><a href="#" data-sort-value="{{$key}}" class="text-item">{{ $label }}</a></li>
                     @endforeach
                 </ul>
             </div>
-            <button href="#" class="list-sort-dir" type="button" data-sort-dir
+            <button class="list-sort-dir" type="button" data-sort-dir
                     aria-label="{{ trans('common.sort_direction_toggle') }} - {{ $order === 'asc' ? trans('common.sort_ascending') : trans('common.sort_descending') }}" tabindex="0">
                 @icon($order === 'desc' ? 'sort-up' : 'sort-down')
             </button>
diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php
index 2daeb8a82..2df2499ab 100644
--- a/resources/views/settings/audit.blade.php
+++ b/resources/views/settings/audit.blade.php
@@ -9,7 +9,11 @@
         <h1 class="list-heading">{{ trans('settings.audit') }}</h1>
         <p class="text-muted">{{ trans('settings.audit_desc') }}</p>
 
-        <form action="{{ url('/settings/audit') }}" method="get" class="flex-container-row wrap justify-flex-start gap-m">
+        <form action="{{ url('/settings/audit') }}" method="get" class="flex-container-row wrap justify-flex-start gap-x-m gap-y-xs">
+
+            @foreach(request()->only(['order', 'sort']) as $key => $val)
+                <input type="hidden" name="{{ $key }}" value="{{ $val }}">
+            @endforeach
 
             <div component="dropdown" class="list-sort-type dropdown-container">
                 <label for="">{{ trans('settings.audit_event_filter') }}</label>
@@ -18,17 +22,17 @@
                         aria-haspopup="true"
                         aria-expanded="false"
                         aria-label="{{ trans('common.sort_options') }}"
-                        class="input-base text-left">{{ $listDetails['event'] ?: trans('settings.audit_event_filter_no_filter') }}</button>
+                        class="input-base text-left">{{ $filters['event'] ?: trans('settings.audit_event_filter_no_filter') }}</button>
                 <ul refs="dropdown@menu" class="dropdown-menu">
-                    <li @if($listDetails['event'] === '') class="active" @endif><a href="{{ sortUrl('/settings/audit', $listDetails, ['event' => '']) }}" class="text-item">{{ trans('settings.audit_event_filter_no_filter') }}</a></li>
+                    <li @if($filters['event'] === '') class="active" @endif><a href="{{ sortUrl('/settings/audit', array_filter(request()->except('page')), ['event' => '']) }}" class="text-item">{{ trans('settings.audit_event_filter_no_filter') }}</a></li>
                     @foreach($activityTypes as $type)
-                        <li @if($type === $listDetails['event']) class="active" @endif><a href="{{ sortUrl('/settings/audit', $listDetails, ['event' => $type]) }}" class="text-item">{{ $type }}</a></li>
+                        <li @if($type === $filters['event']) class="active" @endif><a href="{{ sortUrl('/settings/audit', array_filter(request()->except('page')), ['event' => $type]) }}" class="text-item">{{ $type }}</a></li>
                     @endforeach
                 </ul>
             </div>
 
-            @if(!empty($listDetails['event']))
-                <input type="hidden" name="event" value="{{ $listDetails['event'] }}">
+            @if(!empty($filters['event']))
+                <input type="hidden" name="event" value="{{ $filters['event'] }}">
             @endif
 
             @foreach(['date_from', 'date_to'] as $filterKey)
@@ -38,7 +42,7 @@
                            component="submit-on-change"
                            type="date"
                            name="{{ $filterKey }}"
-                           value="{{ $listDetails[$filterKey] ?? '' }}">
+                           value="{{ $filters[$filterKey] ?? '' }}">
                 </div>
             @endforeach
 
@@ -46,44 +50,47 @@
                  component="submit-on-change"
                  option:submit-on-change:filter='[name="user"]'>
                 <label for="owner">{{ trans('settings.audit_table_user') }}</label>
-                @include('form.user-select', ['user' => $listDetails['user'] ? \BookStack\Auth\User::query()->find($listDetails['user']) : null, 'name' => 'user'])
+                @include('form.user-select', ['user' => $filters['user'] ? \BookStack\Auth\User::query()->find($filters['user']) : null, 'name' => 'user'])
             </div>
 
 
             <div class="form-group">
                 <label for="ip">{{ trans('settings.audit_table_ip') }}</label>
-                @include('form.text', ['name' => 'ip', 'model' => (object) $listDetails])
+                @include('form.text', ['name' => 'ip', 'model' => (object) $filters])
                 <input type="submit" style="display: none">
             </div>
         </form>
 
-        <hr class="mt-l mb-s">
+        <hr class="mt-m mb-s">
 
-        {{ $activities->links() }}
+        <div class="flex-container-row justify-space-between items-center wrap">
+            <div class="flex-2 min-width-xl">{{ $activities->links() }}</div>
+            <div class="flex-none min-width-m py-m">
+                @include('common.sort', [...$listOptions->getSortControlData(), 'useQuery' => true])
+            </div>
+        </div>
 
-        <table class="table">
-            <tbody>
-            <tr>
-                <th>{{ trans('settings.audit_table_user') }}</th>
-                <th>
-                    <a href="{{ sortUrl('/settings/audit', $listDetails, ['sort' => 'key']) }}">{{ trans('settings.audit_table_event') }}</a>
-                </th>
-                <th>{{ trans('settings.audit_table_related') }}</th>
-                <th>{{ trans('settings.audit_table_ip') }}</th>
-                <th>
-                    <a href="{{ sortUrl('/settings/audit', $listDetails, ['sort' => 'created_at']) }}">{{ trans('settings.audit_table_date') }}</a></th>
-            </tr>
+        <div class="item-list">
+            <div class="item-list-row flex-container-row items-center bold hide-under-m">
+                <div class="flex-2 px-m py-xs flex-container-row items-center">{{ trans('settings.audit_table_user') }}</div>
+                <div class="flex-2 px-m py-xs">{{ trans('settings.audit_table_event') }}</div>
+                <div class="flex-3 px-m py-xs">{{ trans('settings.audit_table_related') }}</div>
+                <div class="flex-container-row flex-3">
+                    <div class="flex px-m py-xs">{{ trans('settings.audit_table_ip') }}</div>
+                    <div class="flex-2 px-m py-xs text-right">{{ trans('settings.audit_table_date') }}</div>
+                </div>
+            </div>
             @foreach($activities as $activity)
-                <tr>
-                    <td>
+                <div class="item-list-row flex-container-row items-center wrap">
+                    <div class="flex-2 px-m py-xs flex-container-row items-center min-width-m">
                         @include('settings.parts.table-user', ['user' => $activity->user, 'user_id' => $activity->user_id])
-                    </td>
-                    <td>{{ $activity->type }}</td>
-                    <td width="40%">
+                    </div>
+                    <div class="flex-2 px-m py-xs min-width-m"><strong class="mr-xs hide-over-m">{{ trans('settings.audit_table_event') }}:</strong> {{ $activity->type }}</div>
+                    <div class="flex-3 px-m py-xs min-width-l">
                         @if($activity->entity)
-                            <a href="{{ $activity->entity->getUrl() }}" class="table-entity-item">
-                                <span role="presentation" class="icon text-{{$activity->entity->getType()}}">@icon($activity->entity->getType())</span>
-                                <div class="text-{{ $activity->entity->getType() }}">
+                            <a href="{{ $activity->entity->getUrl() }}" class="flex-container-row items-center">
+                                <span role="presentation" class="icon flex-none text-{{$activity->entity->getType()}}">@icon($activity->entity->getType())</span>
+                                <div class="flex text-{{ $activity->entity->getType() }}">
                                     {{ $activity->entity->name }}
                                 </div>
                             </a>
@@ -95,15 +102,18 @@
                         @elseif($activity->detail)
                             <div class="px-m">{{ $activity->detail }}</div>
                         @endif
-                    </td>
-                    <td>{{ $activity->ip }}</td>
-                    <td>{{ $activity->created_at }}</td>
-                </tr>
+                    </div>
+                    <div class="flex-container-row flex-3 wrap">
+                        <div class="flex px-m py-xs min-width-xs"><strong class="mr-xs hide-over-m">{{ trans('settings.audit_table_ip') }}:<br></strong> {{ $activity->ip }}</div>
+                        <div class="flex-2 px-m py-xs text-m-right min-width-xs"><strong class="mr-xs hide-over-m">{{ trans('settings.audit_table_date') }}:<br></strong> {{ $activity->created_at }}</div>
+                    </div>
+                </div>
             @endforeach
-            </tbody>
-        </table>
+        </div>
 
-        {{ $activities->links() }}
+        <div class="py-m">
+            {{ $activities->links() }}
+        </div>
     </div>
 
 </div>
diff --git a/resources/views/settings/parts/table-user.blade.php b/resources/views/settings/parts/table-user.blade.php
index a8f2777f0..d29ad1979 100644
--- a/resources/views/settings/parts/table-user.blade.php
+++ b/resources/views/settings/parts/table-user.blade.php
@@ -3,9 +3,9 @@ $user - User mode to display, Can be null.
 $user_id - Id of user to show. Must be provided.
 --}}
 @if($user)
-    <a href="{{ $user->getEditUrl() }}" class="table-user-item">
-        <div><img class="avatar block" src="{{ $user->getAvatar(40)}}" alt="{{ $user->name }}"></div>
-        <div>{{ $user->name }}</div>
+    <a href="{{ $user->getEditUrl() }}" class="flex-container-row inline gap-s items-center">
+        <div class="flex-none"><img width="40" height="40" class="avatar block" src="{{ $user->getAvatar(40)}}" alt="{{ $user->name }}"></div>
+        <div class="flex">{{ $user->name }}</div>
     </a>
 @else
     [ID: {{ $user_id }}] {{ trans('common.deleted_user') }}

From be320c55012b105415bf5202acbfef2ab102c792 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sun, 30 Oct 2022 20:27:41 +0000
Subject: [PATCH 09/20] Adjusted audit log row spacing a tad

---
 resources/views/settings/audit.blade.php | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)

diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php
index 2df2499ab..5106ec312 100644
--- a/resources/views/settings/audit.blade.php
+++ b/resources/views/settings/audit.blade.php
@@ -81,12 +81,12 @@
                 </div>
             </div>
             @foreach($activities as $activity)
-                <div class="item-list-row flex-container-row items-center wrap">
-                    <div class="flex-2 px-m py-xs flex-container-row items-center min-width-m">
+                <div class="item-list-row flex-container-row items-center wrap py-xxs">
+                    <div class="flex-2 px-m py-xxs flex-container-row items-center min-width-m">
                         @include('settings.parts.table-user', ['user' => $activity->user, 'user_id' => $activity->user_id])
                     </div>
-                    <div class="flex-2 px-m py-xs min-width-m"><strong class="mr-xs hide-over-m">{{ trans('settings.audit_table_event') }}:</strong> {{ $activity->type }}</div>
-                    <div class="flex-3 px-m py-xs min-width-l">
+                    <div class="flex-2 px-m py-xxs min-width-m"><strong class="mr-xs hide-over-m">{{ trans('settings.audit_table_event') }}:</strong> {{ $activity->type }}</div>
+                    <div class="flex-3 px-m py-xxs min-width-l">
                         @if($activity->entity)
                             <a href="{{ $activity->entity->getUrl() }}" class="flex-container-row items-center">
                                 <span role="presentation" class="icon flex-none text-{{$activity->entity->getType()}}">@icon($activity->entity->getType())</span>
@@ -103,9 +103,9 @@
                             <div class="px-m">{{ $activity->detail }}</div>
                         @endif
                     </div>
-                    <div class="flex-container-row flex-3 wrap">
-                        <div class="flex px-m py-xs min-width-xs"><strong class="mr-xs hide-over-m">{{ trans('settings.audit_table_ip') }}:<br></strong> {{ $activity->ip }}</div>
-                        <div class="flex-2 px-m py-xs text-m-right min-width-xs"><strong class="mr-xs hide-over-m">{{ trans('settings.audit_table_date') }}:<br></strong> {{ $activity->created_at }}</div>
+                    <div class="flex-container-row flex-3">
+                        <div class="flex px-m py-xxs min-width-xs"><strong class="mr-xs hide-over-m">{{ trans('settings.audit_table_ip') }}:<br></strong> {{ $activity->ip }}</div>
+                        <div class="flex-2 px-m py-xxs text-m-right min-width-xs"><strong class="mr-xs hide-over-m">{{ trans('settings.audit_table_date') }}:<br></strong> {{ $activity->created_at }}</div>
                     </div>
                 </div>
             @endforeach

From 09f2bc28d2360670e34c01cb1906f0ba92bebf4a Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sun, 30 Oct 2022 20:29:21 +0000
Subject: [PATCH 10/20] Removed addition detail spacing in audit list

---
 resources/views/settings/audit.blade.php | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php
index 5106ec312..51ae8aba1 100644
--- a/resources/views/settings/audit.blade.php
+++ b/resources/views/settings/audit.blade.php
@@ -95,12 +95,12 @@
                                 </div>
                             </a>
                         @elseif($activity->detail && $activity->isForEntity())
-                            <div class="px-m">
+                            <div>
                                 {{ trans('settings.audit_deleted_item') }} <br>
                                 {{ trans('settings.audit_deleted_item_name', ['name' => $activity->detail]) }}
                             </div>
                         @elseif($activity->detail)
-                            <div class="px-m">{{ $activity->detail }}</div>
+                            <div>{{ $activity->detail }}</div>
                         @endif
                     </div>
                     <div class="flex-container-row flex-3">

From 9e8516c2df558067922fbc1b5fc8bfcd75e3c519 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sun, 30 Oct 2022 21:06:42 +0000
Subject: [PATCH 11/20] Tweaked list spacings a little to align paddings

---
 .../settings/webhooks/parts/webhooks-list-item.blade.php  | 8 ++++----
 resources/views/users/parts/users-list-item.blade.php     | 2 +-
 2 files changed, 5 insertions(+), 5 deletions(-)

diff --git a/resources/views/settings/webhooks/parts/webhooks-list-item.blade.php b/resources/views/settings/webhooks/parts/webhooks-list-item.blade.php
index 5b7d135eb..0ba613196 100644
--- a/resources/views/settings/webhooks/parts/webhooks-list-item.blade.php
+++ b/resources/views/settings/webhooks/parts/webhooks-list-item.blade.php
@@ -1,10 +1,10 @@
 <div class="item-list-row py-s">
     <div class="flex-container-row">
-        <div class="flex-2 py-xxs px-m flex-container-row items-center gap-s">
+        <div class="flex-2 px-m flex-container-row items-center gap-xs">
             @include('common.status-indicator', ['status' => $webhook->active])
-            <a href="{{ $webhook->getUrl() }}">{{ $webhook->name }}</a>
+            <div>&nbsp;<a href="{{ $webhook->getUrl() }}">{{ $webhook->name }}</a></div>
         </div>
-        <div class="flex py-xxs px-m text-right text-muted">
+        <div class="flex px-m text-right text-muted">
             @if($webhook->tracksEvent('all'))
                 {{ trans('settings.webhooks_events_all') }}
             @else
@@ -12,7 +12,7 @@
             @endif
         </div>
     </div>
-    <div class="px-m py-xxs text-muted italic text-limit-lines-1">
+    <div class="px-m text-muted italic text-limit-lines-1">
         <small>{{ $webhook->endpoint }}</small>
     </div>
 </div>
\ No newline at end of file
diff --git a/resources/views/users/parts/users-list-item.blade.php b/resources/views/users/parts/users-list-item.blade.php
index ffc74d708..dc7c9f272 100644
--- a/resources/views/users/parts/users-list-item.blade.php
+++ b/resources/views/users/parts/users-list-item.blade.php
@@ -1,4 +1,4 @@
-<div class="flex-container-row item-list-row items-center wrap py-s">
+<div class="flex-container-row item-list-row items-center wrap py-xs">
     <div class="px-m py-xs flex-container-row items-center flex-2 gap-l min-width-m">
         <img class="avatar med" width="40" height="40" src="{{ $user->getAvatar(40)}}" alt="{{ $user->name }}">
         <a href="{{ url("/settings/users/{$user->id}") }}">

From 80d2889217d78ad2c6ae682980757c2bf12d4c94 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Mon, 31 Oct 2022 11:40:28 +0000
Subject: [PATCH 12/20] Updated tags list to new responsive format

---
 app/Actions/TagRepo.php                       | 11 +++++-
 app/Http/Controllers/TagController.php        | 17 +++++---
 .../Controllers/UserPreferencesController.php |  2 +-
 resources/lang/en/entities.php                |  1 +
 resources/sass/_blocks.scss                   | 27 +------------
 resources/sass/_layout.scss                   |  5 +++
 resources/sass/_opacity.scss                  | 28 +++++++++++++
 resources/sass/styles.scss                    |  1 +
 resources/views/tags/index.blade.php          | 39 ++++++++++---------
 .../views/tags/parts/table-row.blade.php      | 37 ------------------
 .../views/tags/parts/tags-list-item.blade.php | 31 +++++++++++++++
 11 files changed, 109 insertions(+), 90 deletions(-)
 create mode 100644 resources/sass/_opacity.scss
 delete mode 100644 resources/views/tags/parts/table-row.blade.php
 create mode 100644 resources/views/tags/parts/tags-list-item.blade.php

diff --git a/app/Actions/TagRepo.php b/app/Actions/TagRepo.php
index 2618ed2e9..cece30de0 100644
--- a/app/Actions/TagRepo.php
+++ b/app/Actions/TagRepo.php
@@ -4,6 +4,7 @@ namespace BookStack\Actions;
 
 use BookStack\Auth\Permissions\PermissionApplicator;
 use BookStack\Entities\Models\Entity;
+use BookStack\Util\SimpleListOptions;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\DB;
@@ -20,8 +21,14 @@ class TagRepo
     /**
      * Start a query against all tags in the system.
      */
-    public function queryWithTotals(string $searchTerm, string $nameFilter): Builder
+    public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilter): Builder
     {
+        $searchTerm = $listOptions->getSearch();
+        $sort = $listOptions->getSort();
+        if ($sort === 'name' && $nameFilter) {
+            $sort = 'value';
+        }
+
         $query = Tag::query()
             ->select([
                 'name',
@@ -32,7 +39,7 @@ class TagRepo
                 DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
                 DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
             ])
-            ->orderBy($nameFilter ? 'value' : 'name');
+            ->orderBy($sort, $listOptions->getOrder());
 
         if ($nameFilter) {
             $query->where('name', '=', $nameFilter);
diff --git a/app/Http/Controllers/TagController.php b/app/Http/Controllers/TagController.php
index 056cc9902..a221437dd 100644
--- a/app/Http/Controllers/TagController.php
+++ b/app/Http/Controllers/TagController.php
@@ -3,6 +3,7 @@
 namespace BookStack\Http\Controllers;
 
 use BookStack\Actions\TagRepo;
+use BookStack\Util\SimpleListOptions;
 use Illuminate\Http\Request;
 
 class TagController extends Controller
@@ -19,22 +20,26 @@ class TagController extends Controller
      */
     public function index(Request $request)
     {
-        $search = $request->get('search', '');
+        $listOptions = SimpleListOptions::fromRequest($request, 'tags')->withSortOptions([
+            'name' => trans('common.sort_name'),
+            'usages' => trans('entities.tags_usages'),
+        ]);
+
         $nameFilter = $request->get('name', '');
         $tags = $this->tagRepo
-            ->queryWithTotals($search, $nameFilter)
+            ->queryWithTotals($listOptions, $nameFilter)
             ->paginate(50)
             ->appends(array_filter([
-                'search' => $search,
+                ...$listOptions->getPaginationAppends(),
                 'name'   => $nameFilter,
             ]));
 
         $this->setPageTitle(trans('entities.tags'));
 
         return view('tags.index', [
-            'tags'       => $tags,
-            'search'     => $search,
-            'nameFilter' => $nameFilter,
+            'tags'        => $tags,
+            'nameFilter'  => $nameFilter,
+            'listOptions' => $listOptions,
         ]);
     }
 
diff --git a/app/Http/Controllers/UserPreferencesController.php b/app/Http/Controllers/UserPreferencesController.php
index 8e9160810..ca77dcd0b 100644
--- a/app/Http/Controllers/UserPreferencesController.php
+++ b/app/Http/Controllers/UserPreferencesController.php
@@ -62,7 +62,7 @@ class UserPreferencesController extends Controller
      */
     public function changeSort(Request $request, string $id, string $type)
     {
-        $validSortTypes = ['books', 'bookshelves', 'shelf_books', 'users', 'roles', 'webhooks'];
+        $validSortTypes = ['books', 'bookshelves', 'shelf_books', 'users', 'roles', 'webhooks', 'tags'];
         if (!in_array($type, $validSortTypes)) {
             return redirect()->back(500);
         }
diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php
index bf6201900..b3dfb0bf7 100644
--- a/resources/lang/en/entities.php
+++ b/resources/lang/en/entities.php
@@ -275,6 +275,7 @@ return [
     'shelf_tags' => 'Shelf Tags',
     'tag' => 'Tag',
     'tags' =>  'Tags',
+    'tags_index_desc' => 'Tags can be applied to content within the system to apply a flexible form of categorization. Tags can have both a key and value, with the value being optional. Once applied, content can then be queried using the tag name and value.',
     'tag_name' =>  'Tag Name',
     'tag_value' => 'Tag Value (Optional)',
     'tags_explain' => "Add some tags to better categorise your content. \n You can assign a value to a tag for more in-depth organisation.",
diff --git a/resources/sass/_blocks.scss b/resources/sass/_blocks.scss
index 0398224ca..6058add82 100644
--- a/resources/sass/_blocks.scss
+++ b/resources/sass/_blocks.scss
@@ -286,35 +286,10 @@
   margin-bottom: 0;
 }
 
-td .tag-item {
+.item-list-row .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/_layout.scss b/resources/sass/_layout.scss
index 51389dc69..105b6a16f 100644
--- a/resources/sass/_layout.scss
+++ b/resources/sass/_layout.scss
@@ -160,6 +160,11 @@ body.flexbox {
     flex-basis: auto;
     flex-grow: 0;
   }
+  &.fill-area {
+    flex-grow: 1;
+    flex-shrink: 0;
+    min-width: fit-content;
+  }
 }
 
 .flex-2 {
diff --git a/resources/sass/_opacity.scss b/resources/sass/_opacity.scss
new file mode 100644
index 000000000..235aed48e
--- /dev/null
+++ b/resources/sass/_opacity.scss
@@ -0,0 +1,28 @@
+
+.opacity-10 {
+  opacity: 0.1;
+}
+.opacity-20 {
+  opacity: 0.2;
+}
+.opacity-30 {
+  opacity: 0.3;
+}
+.opacity-40 {
+  opacity: 0.4;
+}
+.opacity-50 {
+  opacity: 0.5;
+}
+.opacity-60 {
+  opacity: 0.6;
+}
+.opacity-70 {
+  opacity: 0.7;
+}
+.opacity-80 {
+  opacity: 0.8;
+}
+.opacity-90 {
+  opacity: 0.9;
+}
\ No newline at end of file
diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss
index 44d0055b5..5e31dbdfb 100644
--- a/resources/sass/styles.scss
+++ b/resources/sass/styles.scss
@@ -4,6 +4,7 @@
 @import "variables";
 @import "mixins";
 @import "spacing";
+@import "opacity";
 @import "html";
 @import "text";
 @import "colors";
diff --git a/resources/views/tags/index.blade.php b/resources/views/tags/index.blade.php
index c88449ce7..b6b3325e0 100644
--- a/resources/views/tags/index.blade.php
+++ b/resources/views/tags/index.blade.php
@@ -5,25 +5,28 @@
 
         <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>
+            <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>
+            <p class="text-muted">{{ trans('entities.tags_index_desc') }}</p>
+
+            <div class="flex-container-row wrap justify-space-between items-center mb-s gap-m">
+                <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="{{ $listOptions->getSearch() }}">
+                    </form>
+                </div>
+                <div class="block inline">
+                    @include('common.sort', $listOptions->getSortControlData())
                 </div>
             </div>
 
             @if($nameFilter)
-                <div class="mb-m">
-                    <span class="mr-xs">{{ trans('common.filter_active') }}</span>
+                <div class="my-m">
+                    <strong class="mr-xs">{{ trans('common.filter_active') }}</strong>
                     @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']])
@@ -33,13 +36,13 @@
             @endif
 
             @if(count($tags) > 0)
-                <table class="table expand-to-padding mt-m">
+                <div class="item-list mt-m">
                     @foreach($tags as $tag)
-                        @include('tags.parts.table-row', ['tag' => $tag, 'nameFilter' => $nameFilter])
+                        @include('tags.parts.tags-list-item', ['tag' => $tag, 'nameFilter' => $nameFilter])
                     @endforeach
-                </table>
+                </div>
 
-                <div>
+                <div class="my-m">
                     {{ $tags->links() }}
                 </div>
             @else
diff --git a/resources/views/tags/parts/table-row.blade.php b/resources/views/tags/parts/table-row.blade.php
deleted file mode 100644
index aa04959a9..000000000
--- a/resources/views/tags/parts/table-row.blade.php
+++ /dev/null
@@ -1,37 +0,0 @@
-<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>
\ No newline at end of file
diff --git a/resources/views/tags/parts/tags-list-item.blade.php b/resources/views/tags/parts/tags-list-item.blade.php
new file mode 100644
index 000000000..3962db760
--- /dev/null
+++ b/resources/views/tags/parts/tags-list-item.blade.php
@@ -0,0 +1,31 @@
+<div class="item-list-row flex-container-row items-center wrap">
+    <div class="{{ isset($nameFilter) && $tag->value ? 'flex-2' : 'flex' }} py-s px-m min-width-m">
+        <span class="text-bigger mr-xl">@include('entities.tag', ['tag' => $tag])</span>
+    </div>
+    <div class="flex-2 flex-container-row justify-center items-center gap-m py-s px-m min-width-l wrap">
+        <a href="{{ isset($tag->value) ? $tag->valueUrl() : $tag->nameUrl() }}"
+           title="{{ trans('entities.tags_usages') }}"
+           class="flex fill-area min-width-xxs bold text-right text-muted"><span class="opacity-60">@icon('leaderboard')</span>{{ $tag->usages }}</a>
+        <a href="{{ isset($tag->value) ? $tag->valueUrl() : $tag->nameUrl() . '+{type:page}' }}"
+           title="{{ trans('entities.tags_assigned_pages') }}"
+           class="flex fill-area min-width-xxs bold text-right text-page"><span class="opacity-60">@icon('page')</span>{{ $tag->page_count }}</a>
+        <a href="{{ isset($tag->value) ? $tag->valueUrl() : $tag->nameUrl() . '+{type:chapter}' }}"
+           title="{{ trans('entities.tags_assigned_chapters') }}"
+           class="flex fill-area min-width-xxs bold text-right text-chapter"><span class="opacity-60">@icon('chapter')</span>{{ $tag->chapter_count }}</a>
+        <a href="{{ isset($tag->value) ? $tag->valueUrl() : $tag->nameUrl() . '+{type:book}' }}"
+           title="{{ trans('entities.tags_assigned_books') }}"
+           class="flex fill-area min-width-xxs bold text-right text-book"><span class="opacity-60">@icon('book')</span>{{ $tag->book_count }}</a>
+        <a href="{{ isset($tag->value) ? $tag->valueUrl() : $tag->nameUrl() . '+{type:bookshelf}' }}"
+           title="{{ trans('entities.tags_assigned_shelves') }}"
+           class="flex fill-area min-width-xxs bold text-right text-bookshelf"><span class="opacity-60">@icon('bookshelf')</span>{{ $tag->shelf_count }}</a>
+    </div>
+    @if($tag->values ?? false)
+        <div class="flex text-s-right text-muted py-s px-m min-width-s">
+            <a href="{{ url('/tags?name=' . urlencode($tag->name)) }}">{{ trans('entities.tags_x_unique_values', ['count' => $tag->values]) }}</a>
+        </div>
+    @elseif(empty($nameFilter))
+        <div class="flex text-s-right text-muted py-s px-m min-width-s">
+            <a href="{{ url('/tags?name=' . urlencode($tag->name)) }}">{{ trans('entities.tags_all_values') }}</a>
+        </div>
+    @endif
+</div>
\ No newline at end of file

From de807f853872455b5ab3eded4c2b7e17f745f4fc Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Mon, 31 Oct 2022 16:45:32 +0000
Subject: [PATCH 13/20] Updated recycle bin list to new responsive layout

---
 .../settings/recycle-bin/index.blade.php      | 92 +++++--------------
 .../parts/recycle-bin-list-item.blade.php     | 48 ++++++++++
 2 files changed, 71 insertions(+), 69 deletions(-)
 create mode 100644 resources/views/settings/recycle-bin/parts/recycle-bin-list-item.blade.php

diff --git a/resources/views/settings/recycle-bin/index.blade.php b/resources/views/settings/recycle-bin/index.blade.php
index 56e2437fe..9e82ba467 100644
--- a/resources/views/settings/recycle-bin/index.blade.php
+++ b/resources/views/settings/recycle-bin/index.blade.php
@@ -8,11 +8,11 @@
         <div class="card content-wrap auto-height">
             <h2 class="list-heading">{{ trans('settings.recycle_bin') }}</h2>
 
-            <div class="grid half left-focus">
-                <div>
-                    <p class="text-muted">{{ trans('settings.recycle_bin_desc') }}</p>
+            <div class="flex-container-row items-center gap-x-l gap-y-m wrap">
+                <div class="flex-2 min-width-l">
+                    <p class="text-muted mb-none">{{ trans('settings.recycle_bin_desc') }}</p>
                 </div>
-                <div class="text-right">
+                <div class="flex text-m-right min-width-m">
                     <div component="dropdown" class="dropdown-container">
                         <button refs="dropdown@toggle"
                                 type="button"
@@ -30,79 +30,33 @@
                 </div>
             </div>
 
-
             <hr class="mt-l mb-s">
 
-            {!! $deletions->links() !!}
+            <div class="py-m">
+                {!! $deletions->links() !!}
+            </div>
 
-            <table class="table">
-                <tr>
-                    <th width="30%">{{ trans('settings.recycle_bin_deleted_item') }}</th>
-                    <th width="20%">{{ trans('settings.recycle_bin_deleted_parent') }}</th>
-                    <th width="20%">{{ trans('settings.recycle_bin_deleted_by') }}</th>
-                    <th width="15%">{{ trans('settings.recycle_bin_deleted_at') }}</th>
-                    <th width="15%"></th>
-                </tr>
+            <div class="item-list">
+                <div class="item-list-row flex-container-row items-center px-s bold hide-under-l">
+                    <div class="flex-2 px-m py-xs">{{ trans('settings.audit_deleted_item') }}</div>
+                    <div class="flex-2 px-m py-xs">{{ trans('settings.recycle_bin_deleted_parent') }}</div>
+                    <div class="flex-2 px-m py-xs">{{ trans('settings.recycle_bin_deleted_by') }}</div>
+                    <div class="flex px-m py-xs">{{ trans('settings.recycle_bin_deleted_at') }}</div>
+                    <div class="flex px-m py-xs text-right"></div>
+                </div>
                 @if(count($deletions) === 0)
-                    <tr>
-                        <td colspan="5">
-                            <p class="text-muted"><em>{{ trans('settings.recycle_bin_contents_empty') }}</em></p>
-                        </td>
-                    </tr>
+                    <div class="item-list-row px-l py-m">
+                        <p class="text-muted mb-none"><em>{{ trans('settings.recycle_bin_contents_empty') }}</em></p>
+                    </div>
                 @endif
                 @foreach($deletions as $deletion)
-                <tr>
-                    <td>
-                        <div class="table-entity-item">
-                            <span role="presentation" class="icon text-{{$deletion->deletable->getType()}}">@icon($deletion->deletable->getType())</span>
-                            <div class="text-{{ $deletion->deletable->getType() }}">
-                                {{ $deletion->deletable->name }}
-                            </div>
-                        </div>
-                        @if($deletion->deletable instanceof \BookStack\Entities\Models\Book || $deletion->deletable instanceof \BookStack\Entities\Models\Chapter)
-                            <div class="mb-m"></div>
-                        @endif
-                        @if($deletion->deletable instanceof \BookStack\Entities\Models\Book)
-                            <div class="pl-xl block inline">
-                                <div class="text-chapter">
-                                    @icon('chapter') {{ trans_choice('entities.x_chapters', $deletion->deletable->chapters()->withTrashed()->count()) }}
-                                </div>
-                            </div>
-                        @endif
-                        @if($deletion->deletable instanceof \BookStack\Entities\Models\Book || $deletion->deletable instanceof \BookStack\Entities\Models\Chapter)
-                        <div class="pl-xl block inline">
-                            <div class="text-page">
-                                @icon('page') {{ trans_choice('entities.x_pages', $deletion->deletable->pages()->withTrashed()->count()) }}
-                            </div>
-                        </div>
-                        @endif
-                    </td>
-                    <td>
-                        @if($deletion->deletable->getParent())
-                        <div class="table-entity-item">
-                            <span role="presentation" class="icon text-{{$deletion->deletable->getParent()->getType()}}">@icon($deletion->deletable->getParent()->getType())</span>
-                            <div class="text-{{ $deletion->deletable->getParent()->getType() }}">
-                                {{ $deletion->deletable->getParent()->name }}
-                            </div>
-                        </div>
-                        @endif
-                    </td>
-                    <td>@include('settings.parts.table-user', ['user' => $deletion->deleter, 'user_id' => $deletion->deleted_by])</td>
-                    <td width="200">{{ $deletion->created_at }}</td>
-                    <td width="150" class="text-right">
-                        <div component="dropdown" class="dropdown-container">
-                            <button type="button" refs="dropdown@toggle" class="button outline">{{ trans('common.actions') }}</button>
-                            <ul refs="dropdown@menu" class="dropdown-menu">
-                                <li><a class="text-item" href="{{ $deletion->getUrl('/restore') }}">{{ trans('settings.recycle_bin_restore') }}</a></li>
-                                <li><a class="text-item" href="{{ $deletion->getUrl('/destroy') }}">{{ trans('settings.recycle_bin_permanently_delete') }}</a></li>
-                            </ul>
-                        </div>
-                    </td>
-                </tr>
+                    @include('settings.recycle-bin.parts.recycle-bin-list-item', ['deletion' => $deletion])
                 @endforeach
-            </table>
+            </div>
 
-            {!! $deletions->links() !!}
+            <div class="py-m">
+                {!! $deletions->links() !!}
+            </div>
 
         </div>
 
diff --git a/resources/views/settings/recycle-bin/parts/recycle-bin-list-item.blade.php b/resources/views/settings/recycle-bin/parts/recycle-bin-list-item.blade.php
new file mode 100644
index 000000000..8af598b1e
--- /dev/null
+++ b/resources/views/settings/recycle-bin/parts/recycle-bin-list-item.blade.php
@@ -0,0 +1,48 @@
+<div class="item-list-row flex-container-row items-center px-s wrap">
+    <div class="flex-2 px-m py-xs min-width-xl">
+        <div class="flex-container-row items-center py-xs">
+            <span role="presentation" class="flex-none icon text-{{$deletion->deletable->getType()}}">@icon($deletion->deletable->getType())</span>
+            <div class="text-{{ $deletion->deletable->getType() }}">
+                {{ $deletion->deletable->name }}
+            </div>
+        </div>
+        @if($deletion->deletable instanceof \BookStack\Entities\Models\Book)
+            <div class="pl-l block inline">
+                <div class="text-chapter">
+                    @icon('chapter') {{ trans_choice('entities.x_chapters', $deletion->deletable->chapters()->withTrashed()->count()) }}
+                </div>
+            </div>
+        @endif
+        @if($deletion->deletable instanceof \BookStack\Entities\Models\Book || $deletion->deletable instanceof \BookStack\Entities\Models\Chapter)
+            <div class="pl-l block inline">
+                <div class="text-page">
+                    @icon('page') {{ trans_choice('entities.x_pages', $deletion->deletable->pages()->withTrashed()->count()) }}
+                </div>
+            </div>
+        @endif
+    </div>
+    <div class="flex-2 px-m py-xs min-width-m">
+        @if($deletion->deletable->getParent())
+            <strong class="hide-over-l">{{ trans('settings.recycle_bin_deleted_parent') }}:<br></strong>
+            <div class="flex-container-row items-center">
+                <span role="presentation" class="flex-none icon text-{{$deletion->deletable->getParent()->getType()}}">@icon($deletion->deletable->getParent()->getType())</span>
+                <div class="text-{{ $deletion->deletable->getParent()->getType() }}">
+                    {{ $deletion->deletable->getParent()->name }}
+                </div>
+            </div>
+        @endif
+    </div>
+    <div class="flex-2 px-m py-xs flex-container-row items-center min-width-m">
+        <div><strong class="hide-over-l">{{ trans('settings.recycle_bin_deleted_by') }}:<br></strong>@include('settings.parts.table-user', ['user' => $deletion->deleter, 'user_id' => $deletion->deleted_by])</div>
+    </div>
+    <div class="flex px-m py-xs min-width-s"><strong class="hide-over-l">{{ trans('settings.recycle_bin_deleted_at') }}:<br></strong>{{ $deletion->created_at }}</div>
+    <div class="flex px-m py-xs text-m-right min-width-s">
+        <div component="dropdown" class="dropdown-container">
+            <button type="button" refs="dropdown@toggle" class="button outline">{{ trans('common.actions') }}</button>
+            <ul refs="dropdown@menu" class="dropdown-menu">
+                <li><a class="text-item" href="{{ $deletion->getUrl('/restore') }}">{{ trans('settings.recycle_bin_restore') }}</a></li>
+                <li><a class="text-item" href="{{ $deletion->getUrl('/destroy') }}">{{ trans('settings.recycle_bin_permanently_delete') }}</a></li>
+            </ul>
+        </div>
+    </div>
+</div>
\ No newline at end of file

From d4e71e431bc6fdb0af04d5d75fb7e3e9d4e216c2 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Mon, 31 Oct 2022 21:26:31 +0000
Subject: [PATCH 14/20] Revised revision list to responsive layout

---
 .../Controllers/PageRevisionController.php    | 23 +++++---
 .../Controllers/UserPreferencesController.php |  2 +-
 app/Util/SimpleListOptions.php                |  4 +-
 resources/lang/en/entities.php                |  2 +
 resources/sass/_layout.scss                   |  9 +++
 ...lade.php => revisions-index-row.blade.php} | 57 ++++++++++---------
 resources/views/pages/revisions.blade.php     | 38 ++++++++-----
 7 files changed, 85 insertions(+), 50 deletions(-)
 rename resources/views/pages/parts/{revision-table-row.blade.php => revisions-index-row.blade.php} (62%)

diff --git a/app/Http/Controllers/PageRevisionController.php b/app/Http/Controllers/PageRevisionController.php
index 85ee6c2bc..3da5e7c2d 100644
--- a/app/Http/Controllers/PageRevisionController.php
+++ b/app/Http/Controllers/PageRevisionController.php
@@ -8,6 +8,8 @@ use BookStack\Entities\Repos\PageRepo;
 use BookStack\Entities\Tools\PageContent;
 use BookStack\Exceptions\NotFoundException;
 use BookStack\Facades\Activity;
+use BookStack\Util\SimpleListOptions;
+use Illuminate\Http\Request;
 use Ssddanbrown\HtmlDiff\Diff;
 
 class PageRevisionController extends Controller
@@ -24,22 +26,29 @@ class PageRevisionController extends Controller
      *
      * @throws NotFoundException
      */
-    public function index(string $bookSlug, string $pageSlug)
+    public function index(Request $request, string $bookSlug, string $pageSlug)
     {
         $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug);
+        $listOptions = SimpleListOptions::fromRequest($request, 'page_revisions', true)->withSortOptions([
+            'id' => trans('entities.pages_revisions_sort_number')
+        ]);
+
         $revisions = $page->revisions()->select([
-            'id', 'page_id', 'name', 'created_at', 'created_by', 'updated_at',
-            'type', 'revision_number', 'summary',
-        ])
+                'id', 'page_id', 'name', 'created_at', 'created_by', 'updated_at',
+                'type', 'revision_number', 'summary',
+            ])
             ->selectRaw("IF(markdown = '', false, true) as is_markdown")
             ->with(['page.book', 'createdBy'])
-            ->get();
+            ->reorder('id', $listOptions->getOrder())
+            ->reorder('created_at', $listOptions->getOrder())
+            ->paginate(50);
 
         $this->setPageTitle(trans('entities.pages_revisions_named', ['pageName' => $page->getShortName()]));
 
         return view('pages.revisions', [
-            'revisions' => $revisions,
-            'page'      => $page,
+            'revisions'   => $revisions,
+            'page'        => $page,
+            'listOptions' => $listOptions,
         ]);
     }
 
diff --git a/app/Http/Controllers/UserPreferencesController.php b/app/Http/Controllers/UserPreferencesController.php
index ca77dcd0b..972742e03 100644
--- a/app/Http/Controllers/UserPreferencesController.php
+++ b/app/Http/Controllers/UserPreferencesController.php
@@ -62,7 +62,7 @@ class UserPreferencesController extends Controller
      */
     public function changeSort(Request $request, string $id, string $type)
     {
-        $validSortTypes = ['books', 'bookshelves', 'shelf_books', 'users', 'roles', 'webhooks', 'tags'];
+        $validSortTypes = ['books', 'bookshelves', 'shelf_books', 'users', 'roles', 'webhooks', 'tags', 'page_revisions'];
         if (!in_array($type, $validSortTypes)) {
             return redirect()->back(500);
         }
diff --git a/app/Util/SimpleListOptions.php b/app/Util/SimpleListOptions.php
index f6daa6954..cb7e75a2d 100644
--- a/app/Util/SimpleListOptions.php
+++ b/app/Util/SimpleListOptions.php
@@ -28,11 +28,11 @@ class SimpleListOptions
      * Create a new instance from the given request.
      * Takes the item type (plural) that's used as a key for storing sort preferences.
      */
-    public static function fromRequest(Request $request, string $typeKey): self
+    public static function fromRequest(Request $request, string $typeKey, bool $sortDescDefault = false): self
     {
         $search = $request->get('search', '');
         $sort = setting()->getForCurrentUser($typeKey . '_sort', '');
-        $order = setting()->getForCurrentUser($typeKey . '_sort_order', 'asc');
+        $order = setting()->getForCurrentUser($typeKey . '_sort_order', $sortDescDefault ? 'desc' : 'asc');
 
         return new static($typeKey, $sort, $order, $search);
     }
diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php
index b3dfb0bf7..e7fbe37d9 100644
--- a/resources/lang/en/entities.php
+++ b/resources/lang/en/entities.php
@@ -233,12 +233,14 @@ return [
     'pages_permissions_success' => 'Page permissions updated',
     'pages_revision' => 'Revision',
     'pages_revisions' => 'Page Revisions',
+    'pages_revisions_desc' => 'Listed below are all the past revisions of this page. You can look back upon, compare, and restore old page versions if permissions allow. The full history of the page may not be fully reflected here since, depending on system configuration, old revisions could be auto-deleted.',
     'pages_revisions_named' => 'Page Revisions for :pageName',
     'pages_revision_named' => 'Page Revision for :pageName',
     'pages_revision_restored_from' => 'Restored from #:id; :summary',
     'pages_revisions_created_by' => 'Created By',
     'pages_revisions_date' => 'Revision Date',
     'pages_revisions_number' => '#',
+    'pages_revisions_sort_number' => 'Revision Number',
     'pages_revisions_numbered' => 'Revision #:id',
     'pages_revisions_numbered_changes' => 'Revision #:id Changes',
     'pages_revisions_editor' => 'Editor Type',
diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss
index 105b6a16f..a5f895f80 100644
--- a/resources/sass/_layout.scss
+++ b/resources/sass/_layout.scss
@@ -202,6 +202,15 @@ body.flexbox {
 /**
  * Min width utilities
  */
+.min-width-xxxxs {
+  min-width: 60px;
+}
+.min-width-xxxs {
+  min-width: 80px;
+}
+.min-width-xxs {
+  min-width: 100px;
+}
 .min-width-xs {
   min-width: 120px;
 }
diff --git a/resources/views/pages/parts/revision-table-row.blade.php b/resources/views/pages/parts/revisions-index-row.blade.php
similarity index 62%
rename from resources/views/pages/parts/revision-table-row.blade.php
rename to resources/views/pages/parts/revisions-index-row.blade.php
index 24301adc3..597b53234 100644
--- a/resources/views/pages/parts/revision-table-row.blade.php
+++ b/resources/views/pages/parts/revisions-index-row.blade.php
@@ -1,38 +1,43 @@
-<tr>
-    <td>{{ $revision->revision_number == 0 ? '' : $revision->revision_number }}</td>
-    <td>
+<div class="item-list-row flex-container-row items-center wrap">
+    <div class="flex fit-content min-width-xxxxs px-m py-xs">
+        <span class="hide-over-l">{{ trans('entities.pages_revisions_number') }}</span>
+        {{ $revision->revision_number == 0 ? '' : $revision->revision_number }}
+    </div>
+    <div class="flex-2 px-m py-xs min-width-s">
         {{ $revision->name }}
         <br>
-        <small class="text-muted">({{ $revision->is_markdown ? 'Markdown' : 'WYSIWYG' }})</small>
-    </td>
-    <td style="line-height: 0;" width="30">
-        @if($revision->createdBy)
-            <img class="avatar" src="{{ $revision->createdBy->getAvatar(30) }}" alt="{{ $revision->createdBy->name }}">
-        @endif
-    </td>
-    <td width="260">
-        @if($revision->createdBy) {{ $revision->createdBy->name }} @else {{ trans('common.deleted_user') }} @endif
-        <br>
-        <div class="text-muted">
-            <small>{{ $revision->created_at->formatLocalized('%e %B %Y %H:%M:%S') }}</small>
-            <small>({{ $revision->created_at->diffForHumans() }})</small>
+        <small class="text-muted">(<strong class="hide-over-l">{{ trans('entities.pages_revisions_editor') }}: </strong>{{ $revision->is_markdown ? 'Markdown' : 'WYSIWYG' }})</small>
+    </div>
+    <div class="flex-3 px-m py-xs min-width-l">
+        <div class="flex-container-row items-center gap-s">
+            @if($revision->createdBy)
+                <img class="avatar flex-none" height="30" width="30" src="{{ $revision->createdBy->getAvatar(30) }}" alt="{{ $revision->createdBy->name }}">
+            @endif
+            <div>
+                @if($revision->createdBy) {{ $revision->createdBy->name }} @else {{ trans('common.deleted_user') }} @endif
+                <br>
+                <div class="text-muted">
+                    <small>{{ $revision->created_at->formatLocalized('%e %B %Y %H:%M:%S') }}</small>
+                    <small>({{ $revision->created_at->diffForHumans() }})</small>
+                </div>
+            </div>
         </div>
-    </td>
-    <td>
+    </div>
+    <div class="flex-2 px-m py-xs min-width-m text-small">
         {{ $revision->summary }}
-    </td>
-    <td class="actions text-small text-right">
+    </div>
+    <div class="flex-2 px-m py-xs actions text-small text-l-right min-width-l">
         <a href="{{ $revision->getUrl('changes') }}" target="_blank" rel="noopener">{{ trans('entities.pages_revisions_changes') }}</a>
-        <span class="text-muted">&nbsp;|&nbsp;</span>
+        <span class="text-muted opacity-70">&nbsp;|&nbsp;</span>
 
 
-        @if ($index === 0)
+        @if ($current)
             <a target="_blank" rel="noopener" href="{{ $revision->page->getUrl() }}"><i>{{ trans('entities.pages_revisions_current') }}</i></a>
         @else
             <a href="{{ $revision->getUrl() }}" target="_blank" rel="noopener">{{ trans('entities.pages_revisions_preview') }}</a>
 
             @if(userCan('page-update', $revision->page))
-                <span class="text-muted">&nbsp;|&nbsp;</span>
+                <span class="text-muted opacity-70">&nbsp;|&nbsp;</span>
                 <div component="dropdown" class="dropdown-container">
                     <a refs="dropdown@toggle" href="#" aria-haspopup="true" aria-expanded="false">{{ trans('entities.pages_revisions_restore') }}</a>
                     <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
@@ -52,7 +57,7 @@
             @endif
 
             @if(userCan('page-delete', $revision->page))
-                <span class="text-muted">&nbsp;|&nbsp;</span>
+                <span class="text-muted opacity-70">&nbsp;|&nbsp;</span>
                 <div component="dropdown" class="dropdown-container">
                     <a refs="dropdown@toggle" href="#" aria-haspopup="true" aria-expanded="false">{{ trans('common.delete') }}</a>
                     <ul refs="dropdown@menu" class="dropdown-menu" role="menu">
@@ -71,5 +76,5 @@
                 </div>
             @endif
         @endif
-    </td>
-</tr>
\ No newline at end of file
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/pages/revisions.blade.php b/resources/views/pages/revisions.blade.php
index 3e7edad99..9f462e930 100644
--- a/resources/views/pages/revisions.blade.php
+++ b/resources/views/pages/revisions.blade.php
@@ -17,26 +17,36 @@
 
         <main class="card content-wrap">
             <h1 class="list-heading">{{ trans('entities.pages_revisions') }}</h1>
+
+            <p class="text-muted">{{ trans('entities.pages_revisions_desc') }}</p>
+
+            <div class="flex-container-row my-m items-center justify-space-between wrap gap-x-m gap-y-s">
+                {{ $revisions->links() }}
+                <div>
+                    @include('common.sort', $listOptions->getSortControlData())
+                </div>
+            </div>
+
             @if(count($revisions) > 0)
-
-                <table class="table">
-                    <tr>
-                        <th width="56">{{ trans('entities.pages_revisions_number') }}</th>
-                        <th>
-                            {{ trans('entities.pages_name') }} / {{ trans('entities.pages_revisions_editor') }}
-                        </th>
-                        <th colspan="2">{{ trans('entities.pages_revisions_created_by') }} / {{ trans('entities.pages_revisions_date') }}</th>
-                        <th>{{ trans('entities.pages_revisions_changelog') }}</th>
-                        <th class="text-right">{{ trans('common.actions') }}</th>
-                    </tr>
+                <div class="item-list">
+                    <div class="item-list-row flex-container-row items-center strong hide-under-l">
+                        <div class="flex fit-content min-width-xxxxs px-m py-xs">{{ trans('entities.pages_revisions_number') }}</div>
+                        <div class="flex-2 px-m py-xs">{{ trans('entities.pages_name') }} / {{ trans('entities.pages_revisions_editor') }}</div>
+                        <div class="flex-3 px-m py-xs">{{ trans('entities.pages_revisions_created_by') }} / {{ trans('entities.pages_revisions_date') }}</div>
+                        <div class="flex-2 px-m py-xs">{{ trans('entities.pages_revisions_changelog') }}</div>
+                        <div class="flex-2 px-m py-xs text-right">{{ trans('common.actions') }}</div>
+                    </div>
                     @foreach($revisions as $index => $revision)
-                        @include('pages.parts.revision-table-row', ['revision' => $revision])
+                        @include('pages.parts.revisions-index-row', ['revision' => $revision, 'current' => $page->revision_count === $revision->revision_number])
                     @endforeach
-                </table>
-
+                </div>
             @else
                 <p>{{ trans('entities.pages_revisions_none') }}</p>
             @endif
+
+            <div class="my-m">
+                {{ $revisions->links() }}
+            </div>
         </main>
 
     </div>

From f809bd3a62d3dcc32c46ed49196489ddcb7ae864 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Tue, 1 Nov 2022 14:53:36 +0000
Subject: [PATCH 15/20] Updated tests to align with recent list changes

---
 app/Http/Controllers/HomeController.php | 22 +++++++++-------------
 tests/Actions/AuditLogTest.php          |  2 +-
 tests/Entity/PageRevisionTest.php       |  6 +++---
 tests/Entity/TagTest.php                |  2 +-
 tests/Settings/RecycleBinTest.php       | 10 +++++-----
 tests/User/UserPreferencesTest.php      | 15 ---------------
 6 files changed, 19 insertions(+), 38 deletions(-)

diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php
index f38bd71df..c3c8d1066 100644
--- a/app/Http/Controllers/HomeController.php
+++ b/app/Http/Controllers/HomeController.php
@@ -10,13 +10,15 @@ use BookStack\Entities\Queries\TopFavourites;
 use BookStack\Entities\Repos\BookRepo;
 use BookStack\Entities\Repos\BookshelfRepo;
 use BookStack\Entities\Tools\PageContent;
+use BookStack\Util\SimpleListOptions;
+use Illuminate\Http\Request;
 
 class HomeController extends Controller
 {
     /**
      * Display the homepage.
      */
-    public function index(ActivityQueries $activities)
+    public function index(Request $request, ActivityQueries $activities)
     {
         $activity = $activities->latest(10);
         $draftPages = [];
@@ -61,33 +63,27 @@ class HomeController extends Controller
         if ($homepageOption === 'bookshelves' || $homepageOption === 'books') {
             $key = $homepageOption;
             $view = setting()->getForCurrentUser($key . '_view_type');
-            $sort = setting()->getForCurrentUser($key . '_sort', 'name');
-            $order = setting()->getForCurrentUser($key . '_sort_order', 'asc');
-
-            $sortOptions = [
-                'name'       => trans('common.sort_name'),
+            $listOptions = SimpleListOptions::fromRequest($request, $key)->withSortOptions([
+                'name' => trans('common.sort_name'),
                 'created_at' => trans('common.sort_created_at'),
                 'updated_at' => trans('common.sort_updated_at'),
-            ];
+            ]);
 
             $commonData = array_merge($commonData, [
                 'view'        => $view,
-                'sort'        => $sort,
-                'order'       => $order,
-                'sortOptions' => $sortOptions,
+                'listOptions' => $listOptions,
             ]);
         }
 
         if ($homepageOption === 'bookshelves') {
-            $shelves = app(BookshelfRepo::class)->getAllPaginated(18, $commonData['sort'], $commonData['order']);
+            $shelves = app(BookshelfRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
             $data = array_merge($commonData, ['shelves' => $shelves]);
 
             return view('home.shelves', $data);
         }
 
         if ($homepageOption === 'books') {
-            $bookRepo = app(BookRepo::class);
-            $books = $bookRepo->getAllPaginated(18, $commonData['sort'], $commonData['order']);
+            $books = app(BookRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
             $data = array_merge($commonData, ['books' => $books]);
 
             return view('home.books', $data);
diff --git a/tests/Actions/AuditLogTest.php b/tests/Actions/AuditLogTest.php
index 987e23a45..25fa2b796 100644
--- a/tests/Actions/AuditLogTest.php
+++ b/tests/Actions/AuditLogTest.php
@@ -51,7 +51,7 @@ class AuditLogTest extends TestCase
         $resp->assertSeeText($page->name);
         $resp->assertSeeText('page_create');
         $resp->assertSeeText($activity->created_at->toDateTimeString());
-        $this->withHtml($resp)->assertElementContains('.table-user-item', $admin->name);
+        $this->withHtml($resp)->assertElementContains('a[href*="users/' . $admin->id . '"]', $admin->name);
     }
 
     public function test_shows_name_for_deleted_items()
diff --git a/tests/Entity/PageRevisionTest.php b/tests/Entity/PageRevisionTest.php
index d00ec5ce5..0749888c8 100644
--- a/tests/Entity/PageRevisionTest.php
+++ b/tests/Entity/PageRevisionTest.php
@@ -195,12 +195,12 @@ class PageRevisionTest extends TestCase
         $this->createRevisions($page, 1, ['html' => 'new page html']);
 
         $resp = $this->asAdmin()->get($page->refresh()->getUrl('/revisions'));
-        $this->withHtml($resp)->assertElementContains('td', '(WYSIWYG)');
-        $this->withHtml($resp)->assertElementNotContains('td', '(Markdown)');
+        $this->withHtml($resp)->assertElementContains('.item-list-row > div:nth-child(2)', 'WYSIWYG)');
+        $this->withHtml($resp)->assertElementNotContains('.item-list-row > div:nth-child(2)', 'Markdown)');
 
         $this->createRevisions($page, 1, ['markdown' => '# Some markdown content']);
         $resp = $this->get($page->refresh()->getUrl('/revisions'));
-        $this->withHtml($resp)->assertElementContains('td', '(Markdown)');
+        $this->withHtml($resp)->assertElementContains('.item-list-row > div:nth-child(2)', 'Markdown)');
     }
 
     public function test_revision_restore_action_only_visible_with_permission()
diff --git a/tests/Entity/TagTest.php b/tests/Entity/TagTest.php
index ed5c798a5..ab06686e0 100644
--- a/tests/Entity/TagTest.php
+++ b/tests/Entity/TagTest.php
@@ -164,7 +164,7 @@ class TagTest extends TestCase
         $resp->assertSee('OtherTestContent');
         $resp->assertDontSee('OtherTagName');
         $resp->assertSee('Active Filter:');
-        $this->withHtml($resp)->assertElementCount('table .tag-item', 2);
+        $this->withHtml($resp)->assertElementCount('.item-list .tag-item', 2);
         $this->withHtml($resp)->assertElementContains('form[action$="/tags"]', 'Clear Filter');
     }
 
diff --git a/tests/Settings/RecycleBinTest.php b/tests/Settings/RecycleBinTest.php
index 3d27e9c8d..990df607e 100644
--- a/tests/Settings/RecycleBinTest.php
+++ b/tests/Settings/RecycleBinTest.php
@@ -62,11 +62,11 @@ class RecycleBinTest extends TestCase
 
         $viewReq = $this->asAdmin()->get('/settings/recycle-bin');
         $html = $this->withHtml($viewReq);
-        $html->assertElementContains('table.table', $page->name);
-        $html->assertElementContains('table.table', $editor->name);
-        $html->assertElementContains('table.table', $book->name);
-        $html->assertElementContains('table.table', $book->pages_count . ' Pages');
-        $html->assertElementContains('table.table', $book->chapters_count . ' Chapters');
+        $html->assertElementContains('.item-list-row', $page->name);
+        $html->assertElementContains('.item-list-row', $editor->name);
+        $html->assertElementContains('.item-list-row', $book->name);
+        $html->assertElementContains('.item-list-row', $book->pages_count . ' Pages');
+        $html->assertElementContains('.item-list-row', $book->chapters_count . ' Chapters');
     }
 
     public function test_recycle_bin_empty()
diff --git a/tests/User/UserPreferencesTest.php b/tests/User/UserPreferencesTest.php
index c65b11d7d..92e4158cd 100644
--- a/tests/User/UserPreferencesTest.php
+++ b/tests/User/UserPreferencesTest.php
@@ -29,21 +29,6 @@ class UserPreferencesTest extends TestCase
         $this->assertEquals('desc', setting()->getForCurrentUser('books_sort_order'));
     }
 
-    public function test_update_sort_preference_defaults()
-    {
-        $editor = $this->getEditor();
-        $this->actingAs($editor);
-
-        $updateRequest = $this->patch('/settings/users/' . $editor->id . '/change-sort/bookshelves', [
-            'sort'  => 'cat',
-            'order' => 'dog',
-        ]);
-        $updateRequest->assertStatus(302);
-
-        $this->assertEquals('name', setting()->getForCurrentUser('bookshelves_sort'));
-        $this->assertEquals('asc', setting()->getForCurrentUser('bookshelves_sort_order'));
-    }
-
     public function test_update_sort_bad_entity_type_handled()
     {
         $editor = $this->getEditor();

From 7101ec09ed6efdbc302e38859e988f3b333885ff Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Thu, 3 Nov 2022 12:49:05 +0000
Subject: [PATCH 16/20] Updated search term lists to flex layouts

---
 .../views/search/parts/term-list.blade.php    | 35 +++++++++----------
 1 file changed, 17 insertions(+), 18 deletions(-)

diff --git a/resources/views/search/parts/term-list.blade.php b/resources/views/search/parts/term-list.blade.php
index 3fbfa18fe..dfcc80269 100644
--- a/resources/views/search/parts/term-list.blade.php
+++ b/resources/views/search/parts/term-list.blade.php
@@ -2,25 +2,24 @@
 @type - Type of term (exact, tag)
 @currentList
 --}}
-<table component="add-remove-rows"
+<div component="add-remove-rows"
        option:add-remove-rows:remove-selector="button.text-neg"
-       option:add-remove-rows:row-selector="tr"
-       class="no-style">
+       option:add-remove-rows:row-selector=".flex-container-row"
+        class="flex-container-column gap-xs">
     @foreach(array_merge($currentList, ['']) as $term)
-        <tr @if(empty($term)) class="hidden" refs="add-remove-rows@model" @endif>
-            <td class="pb-s pr-m">
+        <div @if(empty($term)) refs="add-remove-rows@model" @endif
+            class="{{ $term ? '' : 'hidden' }} flex-container-row items-center gap-x-xs">
+            <div>
                 <input class="exact-input outline" type="text" name="{{$type}}[]" value="{{ $term }}">
-            </td>
-            <td>
-                <button type="button" class="text-neg text-button">@icon('close')</button>
-            </td>
-        </tr>
+            </div>
+            <div>
+                <button type="button" class="text-neg text-button icon-button p-xs">@icon('close')</button>
+            </div>
+        </div>
     @endforeach
-    <tr>
-        <td colspan="2">
-            <button refs="add-remove-rows@add" type="button" class="text-button">
-                @icon('add-circle'){{ trans('common.add') }}
-            </button>
-        </td>
-    </tr>
-</table>
\ No newline at end of file
+    <div class="flex py-xs">
+        <button refs="add-remove-rows@add" type="button" class="text-button">
+            @icon('add-circle'){{ trans('common.add') }}
+        </button>
+    </div>
+</div>
\ No newline at end of file

From 8ec6b07690b7dc171b9cee93efdffc1e36e464d9 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Thu, 3 Nov 2022 13:28:07 +0000
Subject: [PATCH 17/20] Updated role permission table to responsive format

---
 resources/js/components/permissions-table.js  |   8 +-
 .../parts/asset-permissions-row.blade.php     |  32 +++
 .../views/settings/roles/parts/form.blade.php | 186 ++----------------
 .../related-asset-permissions-row.blade.php   |  26 +++
 4 files changed, 84 insertions(+), 168 deletions(-)
 create mode 100644 resources/views/settings/roles/parts/asset-permissions-row.blade.php
 create mode 100644 resources/views/settings/roles/parts/related-asset-permissions-row.blade.php

diff --git a/resources/js/components/permissions-table.js b/resources/js/components/permissions-table.js
index df3c055ca..d33c9928f 100644
--- a/resources/js/components/permissions-table.js
+++ b/resources/js/components/permissions-table.js
@@ -3,6 +3,8 @@ class PermissionsTable {
 
     setup() {
         this.container = this.$el;
+        this.cellSelector = this.$opts.cellSelector || 'td,th';
+        this.rowSelector = this.$opts.rowSelector || 'tr';
 
         // Handle toggle all event
         for (const toggleAllElem of (this.$manyRefs.toggleAll || [])) {
@@ -27,15 +29,15 @@ class PermissionsTable {
 
     toggleRowClick(event) {
         event.preventDefault();
-        this.toggleAllInElement(event.target.closest('tr'));
+        this.toggleAllInElement(event.target.closest(this.rowSelector));
     }
 
     toggleColumnClick(event) {
         event.preventDefault();
 
-        const tableCell = event.target.closest('th,td');
+        const tableCell = event.target.closest(this.cellSelector);
         const colIndex = Array.from(tableCell.parentElement.children).indexOf(tableCell);
-        const tableRows = tableCell.closest('table').querySelectorAll('tr');
+        const tableRows = this.container.querySelectorAll(this.rowSelector);
         const inputsToToggle = [];
 
         for (let row of tableRows) {
diff --git a/resources/views/settings/roles/parts/asset-permissions-row.blade.php b/resources/views/settings/roles/parts/asset-permissions-row.blade.php
new file mode 100644
index 000000000..df179a985
--- /dev/null
+++ b/resources/views/settings/roles/parts/asset-permissions-row.blade.php
@@ -0,0 +1,32 @@
+<div class="item-list-row flex-container-row items-center wrap">
+    <div class="flex py-s px-m min-width-s">
+        <strong>{{ $title }}</strong> <br>
+        <a href="#" refs="permissions-table@toggle-row" class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
+    </div>
+    <div class="flex py-s px-m min-width-xxs">
+        <small class="hide-over-m bold">{{ trans('common.create') }}<br></small>
+        @if($permissionPrefix === 'page' || $permissionPrefix === 'chapter')
+            @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-own', 'label' => trans('settings.role_own')])
+            <br>
+        @endif
+        @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-all', 'label' => trans('settings.role_all')])
+    </div>
+    <div class="flex py-s px-m min-width-xxs">
+        <small class="hide-over-m bold">{{ trans('common.view') }}<br></small>
+        @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-own', 'label' => trans('settings.role_own')])
+        <br>
+        @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-all', 'label' => trans('settings.role_all')])
+    </div>
+    <div class="flex py-s px-m min-width-xxs">
+        <small class="hide-over-m bold">{{ trans('common.edit') }}<br></small>
+        @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-own', 'label' => trans('settings.role_own')])
+        <br>
+        @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-all', 'label' => trans('settings.role_all')])
+    </div>
+    <div class="flex py-s px-m min-width-xxs">
+        <small class="hide-over-m bold">{{ trans('common.delete') }}<br></small>
+        @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-own', 'label' => trans('settings.role_own')])
+        <br>
+        @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-all', 'label' => trans('settings.role_all')])
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/settings/roles/parts/form.blade.php b/resources/views/settings/roles/parts/form.blade.php
index 044b4ceb4..8534b7fdb 100644
--- a/resources/views/settings/roles/parts/form.blade.php
+++ b/resources/views/settings/roles/parts/form.blade.php
@@ -56,174 +56,30 @@
             <p class="text-warn">{{ trans('settings.role_asset_admins') }}</p>
         @endif
 
-        <table component="permissions-table" class="table toggle-switch-list compact permissions-table">
-            <tr>
-                <th width="20%">
+        <div component="permissions-table"
+             option:permissions-table:cell-selector=".item-list-row > div"
+             option:permissions-table:row-selector=".item-list-row"
+             class="item-list toggle-switch-list">
+            <div class="item-list-row flex-container-row items-center hide-under-m bold">
+                <div class="flex py-s px-m min-width-s">
                     <a href="#" refs="permissions-table@toggle-all" class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
-                </th>
-                <th width="20%" refs="permissions-table@toggle-column">{{ trans('common.create') }}</th>
-                <th width="20%" refs="permissions-table@toggle-column">{{ trans('common.view') }}</th>
-                <th width="20%" refs="permissions-table@toggle-column">{{ trans('common.edit') }}</th>
-                <th width="20%" refs="permissions-table@toggle-column">{{ trans('common.delete') }}</th>
-            </tr>
-            <tr>
-                <td>
-                    <div>{{ trans('entities.shelves') }}</div>
-                    <a href="#" refs="permissions-table@toggle-row" class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
-                </td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-create-all', 'label' => trans('settings.role_all')])
-                </td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-view-own', 'label' => trans('settings.role_own')])
-                    <br>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-view-all', 'label' => trans('settings.role_all')])
-                </td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-update-own', 'label' => trans('settings.role_own')])
-                    <br>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-update-all', 'label' => trans('settings.role_all')])
-                </td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-delete-own', 'label' => trans('settings.role_own')])
-                    <br>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'bookshelf-delete-all', 'label' => trans('settings.role_all')])
-                </td>
-            </tr>
-            <tr>
-                <td>
-                    <div>{{ trans('entities.books') }}</div>
-                    <a href="#" refs="permissions-table@toggle-row" class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
-                </td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'book-create-all', 'label' => trans('settings.role_all')])
-                </td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'book-view-own', 'label' => trans('settings.role_own')])
-                    <br>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'book-view-all', 'label' => trans('settings.role_all')])
-                </td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'book-update-own', 'label' => trans('settings.role_own')])
-                    <br>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'book-update-all', 'label' => trans('settings.role_all')])
-                </td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'book-delete-own', 'label' => trans('settings.role_own')])
-                    <br>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'book-delete-all', 'label' => trans('settings.role_all')])
-                </td>
-            </tr>
-            <tr>
-                <td>
-                    <div>{{ trans('entities.chapters') }}</div>
-                    <a href="#" refs="permissions-table@toggle-row" class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
-                </td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'chapter-create-own', 'label' => trans('settings.role_own')])
-                    <br>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'chapter-create-all', 'label' => trans('settings.role_all')])
-                </td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'chapter-view-own', 'label' => trans('settings.role_own')])
-                    <br>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'chapter-view-all', 'label' => trans('settings.role_all')])
-                </td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'chapter-update-own', 'label' => trans('settings.role_own')])
-                    <br>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'chapter-update-all', 'label' => trans('settings.role_all')])
-                </td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'chapter-delete-own', 'label' => trans('settings.role_own')])
-                    <br>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'chapter-delete-all', 'label' => trans('settings.role_all')])
-                </td>
-            </tr>
-            <tr>
-                <td>
-                    <div>{{ trans('entities.pages') }}</div>
-                    <a href="#" refs="permissions-table@toggle-row" class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
-                </td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'page-create-own', 'label' => trans('settings.role_own')])
-                    <br>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'page-create-all', 'label' => trans('settings.role_all')])
-                </td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'page-view-own', 'label' => trans('settings.role_own')])
-                    <br>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'page-view-all', 'label' => trans('settings.role_all')])
-                </td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'page-update-own', 'label' => trans('settings.role_own')])
-                    <br>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'page-update-all', 'label' => trans('settings.role_all')])
-                </td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'page-delete-own', 'label' => trans('settings.role_own')])
-                    <br>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'page-delete-all', 'label' => trans('settings.role_all')])
-                </td>
-            </tr>
-            <tr>
-                <td>
-                    <div>{{ trans('entities.images') }}</div>
-                    <a href="#" refs="permissions-table@toggle-row" class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
-                </td>
-                <td>@include('settings.roles.parts.checkbox', ['permission' => 'image-create-all', 'label' => ''])</td>
-                <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}<sup>1</sup></small></td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'image-update-own', 'label' => trans('settings.role_own')])
-                    <br>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'image-update-all', 'label' => trans('settings.role_all')])
-                </td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'image-delete-own', 'label' => trans('settings.role_own')])
-                    <br>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'image-delete-all', 'label' => trans('settings.role_all')])
-                </td>
-            </tr>
-            <tr>
-                <td>
-                    <div>{{ trans('entities.attachments') }}</div>
-                    <a href="#" refs="permissions-table@toggle-row" class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
-                </td>
-                <td>@include('settings.roles.parts.checkbox', ['permission' => 'attachment-create-all', 'label' => ''])</td>
-                <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}</small></td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'attachment-update-own', 'label' => trans('settings.role_own')])
-                    <br>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'attachment-update-all', 'label' => trans('settings.role_all')])
-                </td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'attachment-delete-own', 'label' => trans('settings.role_own')])
-                    <br>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'attachment-delete-all', 'label' => trans('settings.role_all')])
-                </td>
-            </tr>
-            <tr>
-                <td>
-                    <div>{{ trans('entities.comments') }}</div>
-                    <a href="#" refs="permissions-table@toggle-row" class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
-                </td>
-                <td>@include('settings.roles.parts.checkbox', ['permission' => 'comment-create-all', 'label' => ''])</td>
-                <td style="line-height:1.2;"><small class="faded">{{ trans('settings.role_controlled_by_asset') }}</small></td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'comment-update-own', 'label' => trans('settings.role_own')])
-                    <br>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'comment-update-all', 'label' => trans('settings.role_all')])
-                </td>
-                <td>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'comment-delete-own', 'label' => trans('settings.role_own')])
-                    <br>
-                    @include('settings.roles.parts.checkbox', ['permission' => 'comment-delete-all', 'label' => trans('settings.role_all')])
-                </td>
-            </tr>
-        </table>
+                </div>
+                <div refs="permissions-table@toggle-column" class="flex py-s px-m min-width-xxs">{{ trans('common.create') }}</div>
+                <div refs="permissions-table@toggle-column" class="flex py-s px-m min-width-xxs">{{ trans('common.view') }}</div>
+                <div refs="permissions-table@toggle-column" class="flex py-s px-m min-width-xxs">{{ trans('common.edit') }}</div>
+                <div refs="permissions-table@toggle-column" class="flex py-s px-m min-width-xxs">{{ trans('common.delete') }}</div>
+            </div>
+            @include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.shelves'), 'permissionPrefix' => 'bookshelf'])
+            @include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.books'), 'permissionPrefix' => 'book'])
+            @include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.chapters'), 'permissionPrefix' => 'chapter'])
+            @include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.pages'), 'permissionPrefix' => 'page'])
+            @include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.images'), 'permissionPrefix' => 'image', 'refMark' => '1'])
+            @include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.attachments'), 'permissionPrefix' => 'attachment'])
+            @include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.comments'), 'permissionPrefix' => 'comment'])
+        </div>
 
         <div>
-            <p class="text-muted text-small px-m">
+            <p class="text-muted text-small p-m">
                 <sup>1</sup> {{ trans('settings.role_asset_image_view_note') }}
             </p>
         </div>
diff --git a/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php b/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php
new file mode 100644
index 000000000..1ec3d2257
--- /dev/null
+++ b/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php
@@ -0,0 +1,26 @@
+<div class="item-list-row flex-container-row items-center wrap">
+    <div class="flex py-s px-m min-width-s">
+        <strong>{{ $title }}</strong> <br>
+        <a href="#" refs="permissions-table@toggle-row" class="text-small text-primary">{{ trans('common.toggle_all') }}</a>
+    </div>
+    <div class="flex py-s px-m min-width-xxs">
+        <small class="hide-over-m bold">{{ trans('common.create') }}<br></small>
+        @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-all', 'label' => ''])
+    </div>
+    <div class="flex py-s px-m min-width-xxs">
+        <small class="hide-over-m bold">{{ trans('common.view') }}<br></small>
+        <small class="faded">{{ trans('settings.role_controlled_by_asset') }}@if($refMark ?? false)<sup>{{ $refMark }}</sup>@endif</small>
+    </div>
+    <div class="flex py-s px-m min-width-xxs">
+        <small class="hide-over-m bold">{{ trans('common.edit') }}<br></small>
+        @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-own', 'label' => trans('settings.role_own')])
+        <br>
+        @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-all', 'label' => trans('settings.role_all')])
+    </div>
+    <div class="flex py-s px-m min-width-xxs">
+        <small class="hide-over-m bold">{{ trans('common.delete') }}<br></small>
+        @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-own', 'label' => trans('settings.role_own')])
+        <br>
+        @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-all', 'label' => trans('settings.role_all')])
+    </div>
+</div>
\ No newline at end of file

From 6364c541ea13aa012862ba0a895958494fa55eb0 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Thu, 3 Nov 2022 14:14:22 +0000
Subject: [PATCH 18/20] Fixed phpstan static usage warning, updated ci flows

CI flow updates to follow deprecation warnings
---
 .github/workflows/analyse-php.yml     | 4 ++--
 .github/workflows/test-migrations.yml | 4 ++--
 .github/workflows/test-php.yml        | 4 ++--
 app/Util/SimpleListOptions.php        | 2 +-
 4 files changed, 7 insertions(+), 7 deletions(-)

diff --git a/.github/workflows/analyse-php.yml b/.github/workflows/analyse-php.yml
index 191399d78..fd56a53ef 100644
--- a/.github/workflows/analyse-php.yml
+++ b/.github/workflows/analyse-php.yml
@@ -18,10 +18,10 @@ jobs:
     - name: Get Composer Cache Directory
       id: composer-cache
       run: |
-        echo "::set-output name=dir::$(composer config cache-files-dir)"
+        echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
 
     - name: Cache composer packages
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
         path: ${{ steps.composer-cache.outputs.dir }}
         key: ${{ runner.os }}-composer-8.1
diff --git a/.github/workflows/test-migrations.yml b/.github/workflows/test-migrations.yml
index e9b66a0a6..d762d7eab 100644
--- a/.github/workflows/test-migrations.yml
+++ b/.github/workflows/test-migrations.yml
@@ -21,10 +21,10 @@ jobs:
       - name: Get Composer Cache Directory
         id: composer-cache
         run: |
-          echo "::set-output name=dir::$(composer config cache-files-dir)"
+          echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
 
       - name: Cache composer packages
-        uses: actions/cache@v2
+        uses: actions/cache@v3
         with:
           path: ${{ steps.composer-cache.outputs.dir }}
           key: ${{ runner.os }}-composer-${{ matrix.php }}
diff --git a/.github/workflows/test-php.yml b/.github/workflows/test-php.yml
index 917038f59..4185e83c3 100644
--- a/.github/workflows/test-php.yml
+++ b/.github/workflows/test-php.yml
@@ -21,10 +21,10 @@ jobs:
     - name: Get Composer Cache Directory
       id: composer-cache
       run: |
-        echo "::set-output name=dir::$(composer config cache-files-dir)"
+        echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
 
     - name: Cache composer packages
-      uses: actions/cache@v2
+      uses: actions/cache@v3
       with:
         path: ${{ steps.composer-cache.outputs.dir }}
         key: ${{ runner.os }}-composer-${{ matrix.php }}
diff --git a/app/Util/SimpleListOptions.php b/app/Util/SimpleListOptions.php
index cb7e75a2d..81d8a5876 100644
--- a/app/Util/SimpleListOptions.php
+++ b/app/Util/SimpleListOptions.php
@@ -34,7 +34,7 @@ class SimpleListOptions
         $sort = setting()->getForCurrentUser($typeKey . '_sort', '');
         $order = setting()->getForCurrentUser($typeKey . '_sort_order', $sortDescDefault ? 'desc' : 'asc');
 
-        return new static($typeKey, $sort, $order, $search);
+        return new self($typeKey, $sort, $order, $search);
     }
 
     /**

From 37afd35b6f1eb1b5cbeab3c2334a821fed48dc08 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Thu, 3 Nov 2022 14:33:23 +0000
Subject: [PATCH 19/20] Fixed use of array unpacking syntax

Since it was using keyed arrays, unpacking is only supported in php8.1+
---
 resources/views/settings/audit.blade.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php
index 51ae8aba1..abb9c2771 100644
--- a/resources/views/settings/audit.blade.php
+++ b/resources/views/settings/audit.blade.php
@@ -66,7 +66,7 @@
         <div class="flex-container-row justify-space-between items-center wrap">
             <div class="flex-2 min-width-xl">{{ $activities->links() }}</div>
             <div class="flex-none min-width-m py-m">
-                @include('common.sort', [...$listOptions->getSortControlData(), 'useQuery' => true])
+                @include('common.sort', array_merge($listOptions->getSortControlData(), ['useQuery' => true]))
             </div>
         </div>
 

From 9e8240a7368f40fb234a5c18a9a45428ee3d88d9 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Thu, 3 Nov 2022 14:40:01 +0000
Subject: [PATCH 20/20] Addressed additional unsupported array spread operation

---
 app/Http/Controllers/TagController.php | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/app/Http/Controllers/TagController.php b/app/Http/Controllers/TagController.php
index a221437dd..6c2876043 100644
--- a/app/Http/Controllers/TagController.php
+++ b/app/Http/Controllers/TagController.php
@@ -29,10 +29,9 @@ class TagController extends Controller
         $tags = $this->tagRepo
             ->queryWithTotals($listOptions, $nameFilter)
             ->paginate(50)
-            ->appends(array_filter([
-                ...$listOptions->getPaginationAppends(),
+            ->appends(array_filter(array_merge($listOptions->getPaginationAppends(), [
                 'name'   => $nameFilter,
-            ]));
+            ])));
 
         $this->setPageTitle(trans('entities.tags'));