From 8833b5bc3bc717c0303fb1a61a15c97f10b283ae Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Thu, 31 Dec 2020 17:25:20 +0000
Subject: [PATCH] Added user-select input

---
 app/Http/Controllers/UserSearchController.php | 31 ++++++++
 resources/js/components/breadcrumb-listing.js | 55 -------------
 resources/js/components/dropdown-search.js    | 79 +++++++++++++++++++
 resources/js/components/dropdown.js           |  1 +
 resources/js/components/index.js              |  6 +-
 resources/js/components/user-select.js        | 24 ++++++
 resources/lang/en/entities.php                |  1 +
 resources/sass/_components.scss               | 61 ++++++++++++++
 resources/sass/_header.scss                   | 52 +-----------
 resources/sass/_layout.scss                   |  3 +
 .../components/user-select-list.blade.php     |  6 ++
 .../views/components/user-select.blade.php    | 30 +++++++
 .../views/form/entity-permissions.blade.php   | 26 +++---
 .../partials/breadcrumb-listing.blade.php     | 21 +++--
 routes/web.php                                |  3 +
 15 files changed, 271 insertions(+), 128 deletions(-)
 create mode 100644 app/Http/Controllers/UserSearchController.php
 delete mode 100644 resources/js/components/breadcrumb-listing.js
 create mode 100644 resources/js/components/dropdown-search.js
 create mode 100644 resources/js/components/user-select.js
 create mode 100644 resources/views/components/user-select-list.blade.php
 create mode 100644 resources/views/components/user-select.blade.php

diff --git a/app/Http/Controllers/UserSearchController.php b/app/Http/Controllers/UserSearchController.php
new file mode 100644
index 000000000..1ff056cd2
--- /dev/null
+++ b/app/Http/Controllers/UserSearchController.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace BookStack\Http\Controllers;
+
+use BookStack\Auth\User;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Http\Request;
+
+class UserSearchController extends Controller
+{
+    /**
+     * Search users in the system, with the response formatted
+     * for use in a select-style list.
+     */
+    public function forSelect(Request $request)
+    {
+        $search = $request->get('search', '');
+        $query = User::query()->orderBy('name', 'desc')
+            ->take(20);
+
+        if (!empty($search)) {
+            $query->where(function(Builder $query) use ($search) {
+                $query->where('email', 'like', '%' . $search . '%')
+                    ->orWhere('name', 'like', '%' . $search . '%');
+            });
+        }
+
+        $users = $query->get();
+        return view('form.user-select-list', compact('users'));
+    }
+}
diff --git a/resources/js/components/breadcrumb-listing.js b/resources/js/components/breadcrumb-listing.js
deleted file mode 100644
index 3ce4bc77e..000000000
--- a/resources/js/components/breadcrumb-listing.js
+++ /dev/null
@@ -1,55 +0,0 @@
-
-class BreadcrumbListing {
-
-    setup() {
-        this.elem = this.$el;
-        this.searchInput = this.$refs.searchInput;
-        this.loadingElem = this.$refs.loading;
-        this.entityListElem = this.$refs.entityList;
-
-        this.entityType = this.$opts.entityType;
-        this.entityId = Number(this.$opts.entityId);
-
-        this.elem.addEventListener('show', this.onShow.bind(this));
-        this.searchInput.addEventListener('input', this.onSearch.bind(this));
-    }
-
-    onShow() {
-        this.loadEntityView();
-    }
-
-    onSearch() {
-        const input = this.searchInput.value.toLowerCase().trim();
-        const listItems = this.entityListElem.querySelectorAll('.entity-list-item');
-        for (let listItem of listItems) {
-            const match = !input || listItem.textContent.toLowerCase().includes(input);
-            listItem.style.display = match ? 'flex' : 'none';
-            listItem.classList.toggle('hidden', !match);
-        }
-    }
-
-    loadEntityView() {
-        this.toggleLoading(true);
-
-        const params = {
-            'entity_id': this.entityId,
-            'entity_type': this.entityType,
-        };
-
-        window.$http.get('/search/entity/siblings', params).then(resp => {
-            this.entityListElem.innerHTML = resp.data;
-        }).catch(err => {
-            console.error(err);
-        }).then(() => {
-            this.toggleLoading(false);
-            this.onSearch();
-        });
-    }
-
-    toggleLoading(show = false) {
-        this.loadingElem.style.display = show ? 'block' : 'none';
-    }
-
-}
-
-export default BreadcrumbListing;
\ No newline at end of file
diff --git a/resources/js/components/dropdown-search.js b/resources/js/components/dropdown-search.js
new file mode 100644
index 000000000..8c81aae3c
--- /dev/null
+++ b/resources/js/components/dropdown-search.js
@@ -0,0 +1,79 @@
+import {debounce} from "../services/util";
+
+class DropdownSearch {
+
+    setup() {
+        this.elem = this.$el;
+        this.searchInput = this.$refs.searchInput;
+        this.loadingElem = this.$refs.loading;
+        this.listContainerElem = this.$refs.listContainer;
+
+        this.localSearchSelector = this.$opts.localSearchSelector;
+        this.url = this.$opts.url;
+
+        this.elem.addEventListener('show', this.onShow.bind(this));
+        this.searchInput.addEventListener('input', this.onSearch.bind(this));
+
+        this.runAjaxSearch = debounce(this.runAjaxSearch, 300, false);
+    }
+
+    onShow() {
+        this.loadList();
+    }
+
+    onSearch() {
+        const input = this.searchInput.value.toLowerCase().trim();
+        if (this.localSearchSelector) {
+            this.runLocalSearch(input);
+        } else {
+            this.toggleLoading(true);
+            this.runAjaxSearch(input);
+        }
+    }
+
+    runAjaxSearch(searchTerm) {
+        this.loadList(searchTerm);
+    }
+
+    runLocalSearch(searchTerm) {
+        const listItems = this.listContainerElem.querySelectorAll(this.localSearchSelector);
+        for (let listItem of listItems) {
+            const match = !searchTerm || listItem.textContent.toLowerCase().includes(searchTerm);
+            listItem.style.display = match ? 'flex' : 'none';
+            listItem.classList.toggle('hidden', !match);
+        }
+    }
+
+    async loadList(searchTerm = '') {
+        this.listContainerElem.innerHTML = '';
+        this.toggleLoading(true);
+
+        try {
+            const resp = await window.$http.get(this.getAjaxUrl(searchTerm));
+            this.listContainerElem.innerHTML = resp.data;
+        } catch (err) {
+            console.error(err);
+        }
+
+        this.toggleLoading(false);
+        if (this.localSearchSelector) {
+            this.onSearch();
+        }
+    }
+
+    getAjaxUrl(searchTerm = null) {
+        if (!searchTerm) {
+            return this.url;
+        }
+
+        const joiner = this.url.includes('?') ? '&' : '?';
+        return `${this.url}${joiner}search=${encodeURIComponent(searchTerm)}`;
+    }
+
+    toggleLoading(show = false) {
+        this.loadingElem.style.display = show ? 'block' : 'none';
+    }
+
+}
+
+export default DropdownSearch;
\ No newline at end of file
diff --git a/resources/js/components/dropdown.js b/resources/js/components/dropdown.js
index 7b1ce3055..22402d483 100644
--- a/resources/js/components/dropdown.js
+++ b/resources/js/components/dropdown.js
@@ -17,6 +17,7 @@ class DropDown {
         this.body = document.body;
         this.showing = false;
         this.setupListeners();
+        this.hide = this.hide.bind(this);
     }
 
     show(event = null) {
diff --git a/resources/js/components/index.js b/resources/js/components/index.js
index 87c496c91..91ccdaf3a 100644
--- a/resources/js/components/index.js
+++ b/resources/js/components/index.js
@@ -5,7 +5,6 @@ import attachments from "./attachments.js"
 import autoSuggest from "./auto-suggest.js"
 import backToTop from "./back-to-top.js"
 import bookSort from "./book-sort.js"
-import breadcrumbListing from "./breadcrumb-listing.js"
 import chapterToggle from "./chapter-toggle.js"
 import codeEditor from "./code-editor.js"
 import codeHighlighter from "./code-highlighter.js"
@@ -13,6 +12,7 @@ import collapsible from "./collapsible.js"
 import customCheckbox from "./custom-checkbox.js"
 import detailsHighlighter from "./details-highlighter.js"
 import dropdown from "./dropdown.js"
+import dropdownSearch from "./dropdown-search.js"
 import dropzone from "./dropzone.js"
 import editorToolbox from "./editor-toolbox.js"
 import entityPermissionsEditor from "./entity-permissions-editor.js"
@@ -48,6 +48,7 @@ import tagManager from "./tag-manager.js"
 import templateManager from "./template-manager.js"
 import toggleSwitch from "./toggle-switch.js"
 import triLayout from "./tri-layout.js"
+import userSelect from "./user-select.js"
 import wysiwygEditor from "./wysiwyg-editor.js"
 
 const componentMapping = {
@@ -58,7 +59,6 @@ const componentMapping = {
     "auto-suggest": autoSuggest,
     "back-to-top": backToTop,
     "book-sort": bookSort,
-    "breadcrumb-listing": breadcrumbListing,
     "chapter-toggle": chapterToggle,
     "code-editor": codeEditor,
     "code-highlighter": codeHighlighter,
@@ -66,6 +66,7 @@ const componentMapping = {
     "custom-checkbox": customCheckbox,
     "details-highlighter": detailsHighlighter,
     "dropdown": dropdown,
+    "dropdown-search": dropdownSearch,
     "dropzone": dropzone,
     "editor-toolbox": editorToolbox,
     "entity-permissions-editor": entityPermissionsEditor,
@@ -101,6 +102,7 @@ const componentMapping = {
     "template-manager": templateManager,
     "toggle-switch": toggleSwitch,
     "tri-layout": triLayout,
+    "user-select": userSelect,
     "wysiwyg-editor": wysiwygEditor,
 };
 
diff --git a/resources/js/components/user-select.js b/resources/js/components/user-select.js
new file mode 100644
index 000000000..477c11d6b
--- /dev/null
+++ b/resources/js/components/user-select.js
@@ -0,0 +1,24 @@
+import {onChildEvent} from "../services/dom";
+
+class UserSelect {
+
+    setup() {
+
+        this.input = this.$refs.input;
+        this.userInfoContainer = this.$refs.userInfo;
+
+        this.hide = this.$el.components.dropdown.hide;
+
+        onChildEvent(this.$el, 'a.dropdown-search-item', 'click', this.selectUser.bind(this));
+    }
+
+    selectUser(event, userEl) {
+        const id = userEl.getAttribute('data-id');
+        this.input.value = id;
+        this.userInfoContainer.innerHTML = userEl.innerHTML;
+        this.hide();
+    }
+
+}
+
+export default UserSelect;
\ No newline at end of file
diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php
index 5e6a63deb..6b0153844 100644
--- a/resources/lang/en/entities.php
+++ b/resources/lang/en/entities.php
@@ -40,6 +40,7 @@ return [
     'permissions_intro' => 'Once enabled, These permissions will take priority over any set role permissions.',
     'permissions_enable' => 'Enable Custom Permissions',
     'permissions_save' => 'Save Permissions',
+    'permissions_owner' => 'Owner',
 
     // Search
     'search_results' => 'Search Results',
diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss
index eb40741d1..ede26c51c 100644
--- a/resources/sass/_components.scss
+++ b/resources/sass/_components.scss
@@ -724,4 +724,65 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   .template-item-actions button:first-child {
     border-top: 0;
   }
+}
+
+.dropdown-search-dropdown {
+  box-shadow: $bs-med;
+  overflow: hidden;
+  min-height: 100px;
+  width: 240px;
+  display: none;
+  position: absolute;
+  z-index: 80;
+  right: -$-m;
+  @include rtl {
+    right: auto;
+    left: -$-m;
+  }
+  .dropdown-search-search .svg-icon {
+    position: absolute;
+    left: $-s;
+    @include rtl {
+      right: $-s;
+      left: auto;
+    }
+    top: 11px;
+    fill: #888;
+    pointer-events: none;
+  }
+  .dropdown-search-list {
+    max-height: 400px;
+    overflow-y: scroll;
+    text-align: start;
+  }
+  .dropdown-search-item {
+    padding: $-s $-m;
+    &:hover,&:focus {
+      background-color: #F2F2F2;
+      text-decoration: none;
+    }
+  }
+  input {
+    padding-inline-start: $-xl;
+    border-radius: 0;
+    border: 0;
+    border-bottom: 1px solid #DDD;
+  }
+}
+
+@include smaller-than($m) {
+  .dropdown-search-dropdown {
+    position: fixed;
+    right: auto;
+    left: $-m;
+  }
+  .dropdown-search-dropdown .dropdown-search-list {
+    max-height: 240px;
+  }
+}
+
+.custom-select-input {
+  max-width: 280px;
+  border: 1px solid #DDD;
+  border-radius: 4px;
 }
\ No newline at end of file
diff --git a/resources/sass/_header.scss b/resources/sass/_header.scss
index e19bb4f61..246ef4b5b 100644
--- a/resources/sass/_header.scss
+++ b/resources/sass/_header.scss
@@ -269,9 +269,9 @@ header .search-box {
   }
 }
 
-.breadcrumb-listing {
+.dropdown-search {
   position: relative;
-  .breadcrumb-listing-toggle {
+  .dropdown-search-toggle {
     padding: 6px;
     border: 1px solid transparent;
     border-radius: 4px;
@@ -284,54 +284,6 @@ header .search-box {
   }
 }
 
-.breadcrumb-listing-dropdown {
-  box-shadow: $bs-med;
-  overflow: hidden;
-  min-height: 100px;
-  width: 240px;
-  display: none;
-  position: absolute;
-  z-index: 80;
-  right: -$-m;
-  @include rtl {
-    right: auto;
-    left: -$-m;
-  }
-  .breadcrumb-listing-search .svg-icon {
-    position: absolute;
-    left: $-s;
-    @include rtl {
-      right: $-s;
-      left: auto;
-    }
-    top: 11px;
-    fill: #888;
-    pointer-events: none;
-  }
-  .breadcrumb-listing-entity-list {
-    max-height: 400px;
-    overflow-y: scroll;
-    text-align: start;
-  }
-  input {
-    padding-inline-start: $-xl;
-    border-radius: 0;
-    border: 0;
-    border-bottom: 1px solid #DDD;
-  }
-}
-
-@include smaller-than($m) {
-  .breadcrumb-listing-dropdown {
-    position: fixed;
-    right: auto;
-    left: $-m;
-  }
-  .breadcrumb-listing-dropdown .breadcrumb-listing-entity-list {
-    max-height: 240px;
-  }
-}
-
 .faded {
   a, button, span, span > div {
     color: #666;
diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss
index c4e412f0e..e5ed608eb 100644
--- a/resources/sass/_layout.scss
+++ b/resources/sass/_layout.scss
@@ -153,6 +153,9 @@ body.flexbox {
 .justify-center {
   justify-content: center;
 }
+.items-center {
+  align-items: center;
+}
 
 
 /**
diff --git a/resources/views/components/user-select-list.blade.php b/resources/views/components/user-select-list.blade.php
new file mode 100644
index 000000000..2c49e965d
--- /dev/null
+++ b/resources/views/components/user-select-list.blade.php
@@ -0,0 +1,6 @@
+@foreach($users as $user)
+    <a href="#" class="flex-container-row items-center dropdown-search-item" data-id="{{ $user->id }}">
+        <img class="avatar mr-m" src="{{ $user->getAvatar(30) }}" alt="{{ $user->name }}">
+        <span>{{ $user->name }}</span>
+    </a>
+@endforeach
\ No newline at end of file
diff --git a/resources/views/components/user-select.blade.php b/resources/views/components/user-select.blade.php
new file mode 100644
index 000000000..c6a30f53d
--- /dev/null
+++ b/resources/views/components/user-select.blade.php
@@ -0,0 +1,30 @@
+<div class="dropdown-search custom-select-input" components="dropdown dropdown-search user-select"
+     option:dropdown-search:url="/search/users/select"
+>
+    <input refs="user-select@input" type="hidden" name="{{ $name }}" value="{{ $user->id }}">
+    <div refs="dropdown@toggle"
+         class="dropdown-search-toggle flex-container-row items-center"
+         aria-haspopup="true" aria-expanded="false" tabindex="0">
+        <div refs="user-select@user-info" class="flex-container-row items-center px-s">
+            <img class="avatar mr-m" src="{{ $user->getAvatar(30) }}" alt="{{ $user->name }}">
+            <span>{{ $user->name }}</span>
+        </div>
+        <span style="font-size: 1.5rem; margin-left: auto;">
+            @icon('caret-down')
+        </span>
+    </div>
+    <div refs="dropdown@menu" class="dropdown-search-dropdown card" role="menu">
+        <div class="dropdown-search-search">
+            @icon('search')
+            <input refs="dropdown-search@searchInput"
+                   aria-label="{{ trans('common.search') }}"
+                   autocomplete="off"
+                   placeholder="{{ trans('common.search') }}"
+                   type="text">
+        </div>
+        <div refs="dropdown-search@loading" class="text-center">
+            @include('partials.loading-icon')
+        </div>
+        <div refs="dropdown-search@listContainer" class="dropdown-search-list"></div>
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/form/entity-permissions.blade.php b/resources/views/form/entity-permissions.blade.php
index 16e105e10..35490bed9 100644
--- a/resources/views/form/entity-permissions.blade.php
+++ b/resources/views/form/entity-permissions.blade.php
@@ -2,20 +2,26 @@
     {!! csrf_field() !!}
     <input type="hidden" name="_method" value="PUT">
 
-    <p class="mb-none">{{ trans('entities.permissions_intro') }}</p>
-
-    <div class="grid half">
-        <div class="form-group">
-            @include('form.checkbox', [
-                'name' => 'restricted',
-                'label' => trans('entities.permissions_enable'),
-            ])
+    <div class="grid half left-focus v-center">
+        <div>
+            <p class="mb-none mt-m">{{ trans('entities.permissions_intro') }}</p>
+            <div>
+                @include('form.checkbox', [
+                    'name' => 'restricted',
+                    'label' => trans('entities.permissions_enable'),
+                ])
+            </div>
         </div>
-        <div class="form-group">
-            <label for="owner">Owner</label>
+        <div>
+            <div class="form-group">
+                <label for="owner">{{ trans('entities.permissions_owner') }}</label>
+                @include('components.user-select', ['user' => $model->ownedBy, 'name' => 'owned_by'])
+            </div>
         </div>
     </div>
 
+    <hr>
+
     <table permissions-table class="table permissions-table toggle-switch-list" style="{{ !$model->restricted ? 'display: none' : '' }}">
         <tr>
             <th>{{ trans('common.role') }}</th>
diff --git a/resources/views/partials/breadcrumb-listing.blade.php b/resources/views/partials/breadcrumb-listing.blade.php
index 160fa3c23..2a559aa7d 100644
--- a/resources/views/partials/breadcrumb-listing.blade.php
+++ b/resources/views/partials/breadcrumb-listing.blade.php
@@ -1,24 +1,23 @@
-<div class="breadcrumb-listing" components="dropdown breadcrumb-listing"
-     option:breadcrumb-listing:entity-type="{{ $entity->getType() }}"
-     option:breadcrumb-listing:entity-id="{{ $entity->id }}"
-     breadcrumb-listing="{{ $entity->getType() }}:{{ $entity->id }}">
-    <div class="breadcrumb-listing-toggle" refs="dropdown@toggle"
+<div class="dropdown-search" components="dropdown dropdown-search"
+     option:dropdown-search:url="/search/entity/siblings?entity_type={{$entity->getType()}}&entity_id={{ $entity->id }}"
+     option:dropdown-search:local-search-selector=".entity-list-item"
+>
+    <div class="dropdown-search-toggle" refs="dropdown@toggle"
          aria-haspopup="true" aria-expanded="false" tabindex="0">
         <div class="separator">@icon('chevron-right')</div>
     </div>
-    <div refs="dropdown@menu" class="breadcrumb-listing-dropdown card" role="menu">
-        <div class="breadcrumb-listing-search">
+    <div refs="dropdown@menu" class="dropdown-search-dropdown card" role="menu">
+        <div class="dropdown-search-search">
             @icon('search')
-            <input refs="breadcrumb-listing@searchInput"
+            <input refs="dropdown-search@searchInput"
                    aria-label="{{ trans('common.search') }}"
                    autocomplete="off"
-                   name="entity-search"
                    placeholder="{{ trans('common.search') }}"
                    type="text">
         </div>
-        <div refs="breadcrumb-listing@loading">
+        <div refs="dropdown-search@loading">
             @include('partials.loading-icon')
         </div>
-        <div refs="breadcrumb-listing@entityList" class="breadcrumb-listing-entity-list px-m"></div>
+        <div refs="dropdown-search@listContainer" class="dropdown-search-list px-m"></div>
     </div>
 </div>
\ No newline at end of file
diff --git a/routes/web.php b/routes/web.php
index afefcb99e..e8f217862 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -148,6 +148,9 @@ Route::group(['middleware' => 'auth'], function () {
     Route::get('/search/chapter/{bookId}', 'SearchController@searchChapter');
     Route::get('/search/entity/siblings', 'SearchController@searchSiblings');
 
+    // User Search
+    Route::get('/search/users/select', 'UserSearchController@forSelect');
+
     Route::get('/templates', 'PageTemplateController@list');
     Route::get('/templates/{templateId}', 'PageTemplateController@get');