mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-08-27 20:51:57 +00:00
Merge branch 'search_preview' into development
This commit is contained in:
commit
ffc9c28ad5
@ -11,7 +11,7 @@ use Illuminate\Http\Request;
|
|||||||
|
|
||||||
class SearchController extends Controller
|
class SearchController extends Controller
|
||||||
{
|
{
|
||||||
protected $searchRunner;
|
protected SearchRunner $searchRunner;
|
||||||
|
|
||||||
public function __construct(SearchRunner $searchRunner)
|
public function __construct(SearchRunner $searchRunner)
|
||||||
{
|
{
|
||||||
@ -69,7 +69,7 @@ class SearchController extends Controller
|
|||||||
* Search for a list of entities and return a partial HTML response of matching entities.
|
* Search for a list of entities and return a partial HTML response of matching entities.
|
||||||
* Returns the most popular entities if no search is provided.
|
* Returns the most popular entities if no search is provided.
|
||||||
*/
|
*/
|
||||||
public function searchEntitiesAjax(Request $request)
|
public function searchForSelector(Request $request)
|
||||||
{
|
{
|
||||||
$entityTypes = $request->filled('types') ? explode(',', $request->get('types')) : ['page', 'chapter', 'book'];
|
$entityTypes = $request->filled('types') ? explode(',', $request->get('types')) : ['page', 'chapter', 'book'];
|
||||||
$searchTerm = $request->get('term', false);
|
$searchTerm = $request->get('term', false);
|
||||||
@ -83,7 +83,25 @@ class SearchController extends Controller
|
|||||||
$entities = (new Popular())->run(20, 0, $entityTypes);
|
$entities = (new Popular())->run(20, 0, $entityTypes);
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('search.parts.entity-ajax-list', ['entities' => $entities, 'permission' => $permission]);
|
return view('search.parts.entity-selector-list', ['entities' => $entities, 'permission' => $permission]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for a list of entities and return a partial HTML response of matching entities
|
||||||
|
* to be used as a result preview suggestion list for global system searches.
|
||||||
|
*/
|
||||||
|
public function searchSuggestions(Request $request)
|
||||||
|
{
|
||||||
|
$searchTerm = $request->get('term', '');
|
||||||
|
$entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 5)['results'];
|
||||||
|
|
||||||
|
foreach ($entities as $entity) {
|
||||||
|
$entity->setAttribute('preview_content', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('search.parts.entity-suggestion-list', [
|
||||||
|
'entities' => $entities->slice(0, 5)
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import {onSelect} from "../services/dom";
|
import {onSelect} from "../services/dom";
|
||||||
|
import {KeyboardNavigationHandler} from "../services/keyboard-navigation";
|
||||||
import {Component} from "./component";
|
import {Component} from "./component";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -17,8 +18,9 @@ export class Dropdown extends Component {
|
|||||||
this.direction = (document.dir === 'rtl') ? 'right' : 'left';
|
this.direction = (document.dir === 'rtl') ? 'right' : 'left';
|
||||||
this.body = document.body;
|
this.body = document.body;
|
||||||
this.showing = false;
|
this.showing = false;
|
||||||
this.setupListeners();
|
|
||||||
this.hide = this.hide.bind(this);
|
this.hide = this.hide.bind(this);
|
||||||
|
this.setupListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
show(event = null) {
|
show(event = null) {
|
||||||
@ -52,7 +54,7 @@ export class Dropdown extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set listener to hide on mouse leave or window click
|
// Set listener to hide on mouse leave or window click
|
||||||
this.menu.addEventListener('mouseleave', this.hide.bind(this));
|
this.menu.addEventListener('mouseleave', this.hide);
|
||||||
window.addEventListener('click', event => {
|
window.addEventListener('click', event => {
|
||||||
if (!this.menu.contains(event.target)) {
|
if (!this.menu.contains(event.target)) {
|
||||||
this.hide();
|
this.hide();
|
||||||
@ -97,33 +99,25 @@ export class Dropdown extends Component {
|
|||||||
this.showing = false;
|
this.showing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
getFocusable() {
|
|
||||||
return Array.from(this.menu.querySelectorAll('[tabindex]:not([tabindex="-1"]),[href],button,input:not([type=hidden])'));
|
|
||||||
}
|
|
||||||
|
|
||||||
focusNext() {
|
|
||||||
const focusable = this.getFocusable();
|
|
||||||
const currentIndex = focusable.indexOf(document.activeElement);
|
|
||||||
let newIndex = currentIndex + 1;
|
|
||||||
if (newIndex >= focusable.length) {
|
|
||||||
newIndex = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
focusable[newIndex].focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
focusPrevious() {
|
|
||||||
const focusable = this.getFocusable();
|
|
||||||
const currentIndex = focusable.indexOf(document.activeElement);
|
|
||||||
let newIndex = currentIndex - 1;
|
|
||||||
if (newIndex < 0) {
|
|
||||||
newIndex = focusable.length - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
focusable[newIndex].focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
setupListeners() {
|
setupListeners() {
|
||||||
|
const keyboardNavHandler = new KeyboardNavigationHandler(this.container, (event) => {
|
||||||
|
this.hide();
|
||||||
|
this.toggle.focus();
|
||||||
|
if (!this.bubbleEscapes) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}, (event) => {
|
||||||
|
if (event.target.nodeName === 'INPUT') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
this.hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.moveMenu) {
|
||||||
|
keyboardNavHandler.shareHandlingToEl(this.menu);
|
||||||
|
}
|
||||||
|
|
||||||
// Hide menu on option click
|
// Hide menu on option click
|
||||||
this.container.addEventListener('click', event => {
|
this.container.addEventListener('click', event => {
|
||||||
const possibleChildren = Array.from(this.menu.querySelectorAll('a'));
|
const possibleChildren = Array.from(this.menu.querySelectorAll('a'));
|
||||||
@ -136,37 +130,7 @@ export class Dropdown extends Component {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
this.show(event);
|
this.show(event);
|
||||||
if (event instanceof KeyboardEvent) {
|
if (event instanceof KeyboardEvent) {
|
||||||
this.focusNext();
|
keyboardNavHandler.focusNext();
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keyboard navigation
|
|
||||||
const keyboardNavigation = event => {
|
|
||||||
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
|
||||||
this.focusNext();
|
|
||||||
event.preventDefault();
|
|
||||||
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
|
||||||
this.focusPrevious();
|
|
||||||
event.preventDefault();
|
|
||||||
} else if (event.key === 'Escape') {
|
|
||||||
this.hide();
|
|
||||||
this.toggle.focus();
|
|
||||||
if (!this.bubbleEscapes) {
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.container.addEventListener('keydown', keyboardNavigation);
|
|
||||||
if (this.moveMenu) {
|
|
||||||
this.menu.addEventListener('keydown', keyboardNavigation);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide menu on enter press or escape
|
|
||||||
this.menu.addEventListener('keydown ', event => {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
this.hide();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -115,7 +115,7 @@ export class EntitySelector extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
searchUrl() {
|
searchUrl() {
|
||||||
return `/ajax/search/entities?types=${encodeURIComponent(this.entityTypes)}&permission=${encodeURIComponent(this.entityPermission)}`;
|
return `/search/entity-selector?types=${encodeURIComponent(this.entityTypes)}&permission=${encodeURIComponent(this.entityPermission)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
searchEntities(searchTerm) {
|
searchEntities(searchTerm) {
|
||||||
|
82
resources/js/components/global-search.js
Normal file
82
resources/js/components/global-search.js
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import {htmlToDom} from "../services/dom";
|
||||||
|
import {debounce} from "../services/util";
|
||||||
|
import {KeyboardNavigationHandler} from "../services/keyboard-navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends {Component}
|
||||||
|
*/
|
||||||
|
class GlobalSearch {
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.container = this.$el;
|
||||||
|
this.input = this.$refs.input;
|
||||||
|
this.suggestions = this.$refs.suggestions;
|
||||||
|
this.suggestionResultsWrap = this.$refs.suggestionResults;
|
||||||
|
this.loadingWrap = this.$refs.loading;
|
||||||
|
this.button = this.$refs.button;
|
||||||
|
|
||||||
|
this.setupListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupListeners() {
|
||||||
|
const updateSuggestionsDebounced = debounce(this.updateSuggestions.bind(this), 200, false);
|
||||||
|
|
||||||
|
// Handle search input changes
|
||||||
|
this.input.addEventListener('input', () => {
|
||||||
|
const value = this.input.value;
|
||||||
|
if (value.length > 0) {
|
||||||
|
this.loadingWrap.style.display = 'block';
|
||||||
|
this.suggestionResultsWrap.style.opacity = '0.5';
|
||||||
|
updateSuggestionsDebounced(value);
|
||||||
|
} else {
|
||||||
|
this.hideSuggestions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Allow double click to show auto-click suggestions
|
||||||
|
this.input.addEventListener('dblclick', () => {
|
||||||
|
this.input.setAttribute('autocomplete', 'on');
|
||||||
|
this.button.focus();
|
||||||
|
this.input.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
new KeyboardNavigationHandler(this.container, () => {
|
||||||
|
this.hideSuggestions();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {String} search
|
||||||
|
*/
|
||||||
|
async updateSuggestions(search) {
|
||||||
|
const {data: results} = await window.$http.get('/search/suggest', {term: search});
|
||||||
|
if (!this.input.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultDom = htmlToDom(results);
|
||||||
|
|
||||||
|
this.suggestionResultsWrap.innerHTML = '';
|
||||||
|
this.suggestionResultsWrap.style.opacity = '1';
|
||||||
|
this.loadingWrap.style.display = 'none';
|
||||||
|
this.suggestionResultsWrap.append(resultDom);
|
||||||
|
if (!this.container.classList.contains('search-active')) {
|
||||||
|
this.showSuggestions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuggestions() {
|
||||||
|
this.container.classList.add('search-active');
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
this.suggestions.classList.add('search-suggestions-animation');
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
hideSuggestions() {
|
||||||
|
this.container.classList.remove('search-active');
|
||||||
|
this.suggestions.classList.remove('search-suggestions-animation');
|
||||||
|
this.suggestionResultsWrap.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GlobalSearch;
|
@ -1,5 +1,6 @@
|
|||||||
import * as Dates from "../services/dates";
|
import * as Dates from "../services/dates";
|
||||||
import {onSelect} from "../services/dom";
|
import {onSelect} from "../services/dom";
|
||||||
|
import {debounce} from "../services/util";
|
||||||
import {Component} from "./component";
|
import {Component} from "./component";
|
||||||
|
|
||||||
export class PageEditor extends Component {
|
export class PageEditor extends Component {
|
||||||
@ -66,7 +67,8 @@ export class PageEditor extends Component {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Changelog controls
|
// Changelog controls
|
||||||
this.changelogInput.addEventListener('change', this.updateChangelogDisplay.bind(this));
|
const updateChangelogDebounced = debounce(this.updateChangelogDisplay.bind(this), 300, false);
|
||||||
|
this.changelogInput.addEventListener('input', updateChangelogDebounced);
|
||||||
|
|
||||||
// Draft Controls
|
// Draft Controls
|
||||||
onSelect(this.saveDraftButton, this.saveDraft.bind(this));
|
onSelect(this.saveDraftButton, this.saveDraft.bind(this));
|
||||||
|
89
resources/js/services/keyboard-navigation.js
Normal file
89
resources/js/services/keyboard-navigation.js
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Handle common keyboard navigation events within a given container.
|
||||||
|
*/
|
||||||
|
export class KeyboardNavigationHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Element} container
|
||||||
|
* @param {Function|null} onEscape
|
||||||
|
* @param {Function|null} onEnter
|
||||||
|
*/
|
||||||
|
constructor(container, onEscape = null, onEnter = null) {
|
||||||
|
this.containers = [container];
|
||||||
|
this.onEscape = onEscape;
|
||||||
|
this.onEnter = onEnter;
|
||||||
|
container.addEventListener('keydown', this.#keydownHandler.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Also share the keyboard event handling to the given element.
|
||||||
|
* Only elements within the original container are considered focusable though.
|
||||||
|
* @param {Element} element
|
||||||
|
*/
|
||||||
|
shareHandlingToEl(element) {
|
||||||
|
this.containers.push(element);
|
||||||
|
element.addEventListener('keydown', this.#keydownHandler.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus on the next focusable element within the current containers.
|
||||||
|
*/
|
||||||
|
focusNext() {
|
||||||
|
const focusable = this.#getFocusable();
|
||||||
|
const currentIndex = focusable.indexOf(document.activeElement);
|
||||||
|
let newIndex = currentIndex + 1;
|
||||||
|
if (newIndex >= focusable.length) {
|
||||||
|
newIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
focusable[newIndex].focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focus on the previous existing focusable element within the current containers.
|
||||||
|
*/
|
||||||
|
focusPrevious() {
|
||||||
|
const focusable = this.#getFocusable();
|
||||||
|
const currentIndex = focusable.indexOf(document.activeElement);
|
||||||
|
let newIndex = currentIndex - 1;
|
||||||
|
if (newIndex < 0) {
|
||||||
|
newIndex = focusable.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
focusable[newIndex].focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {KeyboardEvent} event
|
||||||
|
*/
|
||||||
|
#keydownHandler(event) {
|
||||||
|
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
||||||
|
this.focusNext();
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
|
||||||
|
this.focusPrevious();
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
if (this.onEscape) {
|
||||||
|
this.onEscape(event);
|
||||||
|
} else if (document.activeElement) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
} else if (event.key === 'Enter' && this.onEnter) {
|
||||||
|
this.onEnter(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an array of focusable elements within the current containers.
|
||||||
|
* @returns {Element[]}
|
||||||
|
*/
|
||||||
|
#getFocusable() {
|
||||||
|
const focusable = [];
|
||||||
|
const selector = '[tabindex]:not([tabindex="-1"]),[href],button:not([tabindex="-1"]),input:not([type=hidden])';
|
||||||
|
for (const container of this.containers) {
|
||||||
|
focusable.push(...container.querySelectorAll(selector))
|
||||||
|
}
|
||||||
|
return focusable;
|
||||||
|
}
|
||||||
|
}
|
@ -6,9 +6,9 @@
|
|||||||
* N milliseconds. If `immediate` is passed, trigger the function on the
|
* N milliseconds. If `immediate` is passed, trigger the function on the
|
||||||
* leading edge, instead of the trailing.
|
* leading edge, instead of the trailing.
|
||||||
* @attribution https://davidwalsh.name/javascript-debounce-function
|
* @attribution https://davidwalsh.name/javascript-debounce-function
|
||||||
* @param func
|
* @param {Function} func
|
||||||
* @param wait
|
* @param {Number} wait
|
||||||
* @param immediate
|
* @param {Boolean} immediate
|
||||||
* @returns {Function}
|
* @returns {Function}
|
||||||
*/
|
*/
|
||||||
export function debounce(func, wait, immediate) {
|
export function debounce(func, wait, immediate) {
|
||||||
|
@ -16,23 +16,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.anim.searchResult {
|
.search-suggestions-animation{
|
||||||
opacity: 0;
|
animation-name: searchSuggestions;
|
||||||
transform: translate3d(580px, 0, 0);
|
animation-duration: 120ms;
|
||||||
animation-name: searchResult;
|
|
||||||
animation-duration: 220ms;
|
|
||||||
animation-fill-mode: forwards;
|
animation-fill-mode: forwards;
|
||||||
animation-timing-function: cubic-bezier(.62, .28, .23, .99);
|
animation-timing-function: cubic-bezier(.62, .28, .23, .99);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes searchResult {
|
@keyframes searchSuggestions {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: .5;
|
||||||
transform: translate3d(400px, 0, 0);
|
transform: scale(0.9);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate3d(0, 0, 0);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -86,11 +86,13 @@
|
|||||||
.card-title a {
|
.card-title a {
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.card-footer-link {
|
.card-footer-link, button.card-footer-link {
|
||||||
display: block;
|
display: block;
|
||||||
padding: $-s $-m;
|
padding: $-s $-m;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
border-top: 1px solid;
|
border-top: 1px solid;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
@include lightDark(border-color, #DDD, #555);
|
@include lightDark(border-color, #DDD, #555);
|
||||||
border-radius: 0 0 3px 3px;
|
border-radius: 0 0 3px 3px;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
@ -99,6 +101,11 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@include lightDark(background-color, #f2f2f2, #2d2d2d);
|
@include lightDark(background-color, #f2f2f2, #2d2d2d);
|
||||||
}
|
}
|
||||||
|
&:focus {
|
||||||
|
@include lightDark(background-color, #eee, #222);
|
||||||
|
outline: 1px dotted #666;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card.border-card {
|
.card.border-card {
|
||||||
|
@ -412,7 +412,7 @@ div[editor-type="markdown"] .title-input.page-title input[type="text"] {
|
|||||||
.search-box {
|
.search-box {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
button {
|
button[tabindex="-1"] {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
@include lightDark(color, #666, #AAA);
|
@include lightDark(color, #666, #AAA);
|
||||||
|
@ -108,21 +108,6 @@ header .search-box {
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
button {
|
|
||||||
z-index: 1;
|
|
||||||
left: 16px;
|
|
||||||
top: 10px;
|
|
||||||
color: #FFF;
|
|
||||||
opacity: 0.6;
|
|
||||||
@include lightDark(color, rgba(255, 255, 255, 0.8), #AAA);
|
|
||||||
@include rtl {
|
|
||||||
left: auto;
|
|
||||||
right: 16px;
|
|
||||||
}
|
|
||||||
svg {
|
|
||||||
margin-block-end: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
input::placeholder {
|
input::placeholder {
|
||||||
color: #FFF;
|
color: #FFF;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
@ -130,10 +115,67 @@ header .search-box {
|
|||||||
@include between($l, $xl) {
|
@include between($l, $xl) {
|
||||||
max-width: 200px;
|
max-width: 200px;
|
||||||
}
|
}
|
||||||
&:focus-within button {
|
&:focus-within #header-search-box-button {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#header-search-box-button {
|
||||||
|
z-index: 1;
|
||||||
|
inset-inline-start: 16px;
|
||||||
|
top: 10px;
|
||||||
|
color: #FFF;
|
||||||
|
opacity: 0.6;
|
||||||
|
@include lightDark(color, rgba(255, 255, 255, 0.8), #AAA);
|
||||||
|
svg {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search-suggestions {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: -$-s;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: -1;
|
||||||
|
margin-left: -$-xxl;
|
||||||
|
margin-right: -$-xxl;
|
||||||
|
padding-top: 56px;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: $bs-hover;
|
||||||
|
transform-origin: top center;
|
||||||
|
opacity: .5;
|
||||||
|
transform: scale(0.9);
|
||||||
|
.entity-item-snippet p {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.entity-item-snippet {
|
||||||
|
font-size: .8rem;
|
||||||
|
}
|
||||||
|
.entity-list-item-name {
|
||||||
|
font-size: .9rem;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.global-search-loading {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header .search-box.search-active:focus-within {
|
||||||
|
.global-search-suggestions {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
@include lightDark(background-color, #EEE, #333);
|
||||||
|
@include lightDark(border-color, #DDD, #111);
|
||||||
|
}
|
||||||
|
#header-search-box-button, input {
|
||||||
|
@include lightDark(color, #444, #AAA);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
@ -19,12 +19,25 @@
|
|||||||
|
|
||||||
<div class="flex-container-column items-center justify-center hide-under-l">
|
<div class="flex-container-column items-center justify-center hide-under-l">
|
||||||
@if (hasAppAccess())
|
@if (hasAppAccess())
|
||||||
<form action="{{ url('/search') }}" method="GET" class="search-box" role="search">
|
<form component="global-search" action="{{ url('/search') }}" method="GET" class="search-box" role="search">
|
||||||
<button id="header-search-box-button" type="submit" aria-label="{{ trans('common.search') }}" tabindex="-1">@icon('search') </button>
|
<button id="header-search-box-button"
|
||||||
<input id="header-search-box-input" type="text" name="term"
|
refs="global-search@button"
|
||||||
|
type="submit"
|
||||||
|
aria-label="{{ trans('common.search') }}"
|
||||||
|
tabindex="-1">@icon('search')</button>
|
||||||
|
<input id="header-search-box-input"
|
||||||
|
refs="global-search@input"
|
||||||
|
type="text"
|
||||||
|
name="term"
|
||||||
data-shortcut="global_search"
|
data-shortcut="global_search"
|
||||||
|
autocomplete="off"
|
||||||
aria-label="{{ trans('common.search') }}" placeholder="{{ trans('common.search') }}"
|
aria-label="{{ trans('common.search') }}" placeholder="{{ trans('common.search') }}"
|
||||||
value="{{ isset($searchTerm) ? $searchTerm : '' }}">
|
value="{{ $searchTerm ?? '' }}">
|
||||||
|
<div refs="global-search@suggestions" class="global-search-suggestions card">
|
||||||
|
<div refs="global-search@loading" class="text-center px-m global-search-loading">@include('common.loading-icon')</div>
|
||||||
|
<div refs="global-search@suggestion-results" class="px-m"></div>
|
||||||
|
<button class="text-button card-footer-link" type="submit">{{ trans('common.view_all') }}</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
@ -0,0 +1,21 @@
|
|||||||
|
<div class="entity-list">
|
||||||
|
@if(count($entities) > 0)
|
||||||
|
@foreach($entities as $index => $entity)
|
||||||
|
|
||||||
|
@include('entities.list-item', [
|
||||||
|
'entity' => $entity,
|
||||||
|
'showPath' => true,
|
||||||
|
'locked' => false,
|
||||||
|
])
|
||||||
|
|
||||||
|
@if($index !== count($entities) - 1)
|
||||||
|
<hr>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@endforeach
|
||||||
|
@else
|
||||||
|
<div class="text-muted px-m py-m">
|
||||||
|
{{ trans('common.no_items') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
@ -184,8 +184,6 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::get('/ajax/tags/suggest/names', [TagController::class, 'getNameSuggestions']);
|
Route::get('/ajax/tags/suggest/names', [TagController::class, 'getNameSuggestions']);
|
||||||
Route::get('/ajax/tags/suggest/values', [TagController::class, 'getValueSuggestions']);
|
Route::get('/ajax/tags/suggest/values', [TagController::class, 'getValueSuggestions']);
|
||||||
|
|
||||||
Route::get('/ajax/search/entities', [SearchController::class, 'searchEntitiesAjax']);
|
|
||||||
|
|
||||||
// Comments
|
// Comments
|
||||||
Route::post('/comment/{pageId}', [CommentController::class, 'savePageComment']);
|
Route::post('/comment/{pageId}', [CommentController::class, 'savePageComment']);
|
||||||
Route::put('/comment/{id}', [CommentController::class, 'update']);
|
Route::put('/comment/{id}', [CommentController::class, 'update']);
|
||||||
@ -199,6 +197,8 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::get('/search/book/{bookId}', [SearchController::class, 'searchBook']);
|
Route::get('/search/book/{bookId}', [SearchController::class, 'searchBook']);
|
||||||
Route::get('/search/chapter/{bookId}', [SearchController::class, 'searchChapter']);
|
Route::get('/search/chapter/{bookId}', [SearchController::class, 'searchChapter']);
|
||||||
Route::get('/search/entity/siblings', [SearchController::class, 'searchSiblings']);
|
Route::get('/search/entity/siblings', [SearchController::class, 'searchSiblings']);
|
||||||
|
Route::get('/search/entity-selector', [SearchController::class, 'searchForSelector']);
|
||||||
|
Route::get('/search/suggest', [SearchController::class, 'searchSuggestions']);
|
||||||
|
|
||||||
// User Search
|
// User Search
|
||||||
Route::get('/search/users/select', [UserSearchController::class, 'forSelect']);
|
Route::get('/search/users/select', [UserSearchController::class, 'forSelect']);
|
||||||
|
@ -190,7 +190,7 @@ class EntitySearchTest extends TestCase
|
|||||||
$this->get('/search?term=' . urlencode('danzorbhsing {created_before:2037-01-01}'))->assertDontSee($page->name);
|
$this->get('/search?term=' . urlencode('danzorbhsing {created_before:2037-01-01}'))->assertDontSee($page->name);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_ajax_entity_search()
|
public function test_entity_selector_search()
|
||||||
{
|
{
|
||||||
$page = $this->entities->newPage(['name' => 'my ajax search test', 'html' => 'ajax test']);
|
$page = $this->entities->newPage(['name' => 'my ajax search test', 'html' => 'ajax test']);
|
||||||
$notVisitedPage = $this->entities->page();
|
$notVisitedPage = $this->entities->page();
|
||||||
@ -198,38 +198,38 @@ class EntitySearchTest extends TestCase
|
|||||||
// Visit the page to make popular
|
// Visit the page to make popular
|
||||||
$this->asEditor()->get($page->getUrl());
|
$this->asEditor()->get($page->getUrl());
|
||||||
|
|
||||||
$normalSearch = $this->get('/ajax/search/entities?term=' . urlencode($page->name));
|
$normalSearch = $this->get('/search/entity-selector?term=' . urlencode($page->name));
|
||||||
$normalSearch->assertSee($page->name);
|
$normalSearch->assertSee($page->name);
|
||||||
|
|
||||||
$bookSearch = $this->get('/ajax/search/entities?types=book&term=' . urlencode($page->name));
|
$bookSearch = $this->get('/search/entity-selector?types=book&term=' . urlencode($page->name));
|
||||||
$bookSearch->assertDontSee($page->name);
|
$bookSearch->assertDontSee($page->name);
|
||||||
|
|
||||||
$defaultListTest = $this->get('/ajax/search/entities');
|
$defaultListTest = $this->get('/search/entity-selector');
|
||||||
$defaultListTest->assertSee($page->name);
|
$defaultListTest->assertSee($page->name);
|
||||||
$defaultListTest->assertDontSee($notVisitedPage->name);
|
$defaultListTest->assertDontSee($notVisitedPage->name);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_ajax_entity_search_shows_breadcrumbs()
|
public function test_entity_selector_search_shows_breadcrumbs()
|
||||||
{
|
{
|
||||||
$chapter = $this->entities->chapter();
|
$chapter = $this->entities->chapter();
|
||||||
$page = $chapter->pages->first();
|
$page = $chapter->pages->first();
|
||||||
$this->asEditor();
|
$this->asEditor();
|
||||||
|
|
||||||
$pageSearch = $this->get('/ajax/search/entities?term=' . urlencode($page->name));
|
$pageSearch = $this->get('/search/entity-selector?term=' . urlencode($page->name));
|
||||||
$pageSearch->assertSee($page->name);
|
$pageSearch->assertSee($page->name);
|
||||||
$pageSearch->assertSee($chapter->getShortName(42));
|
$pageSearch->assertSee($chapter->getShortName(42));
|
||||||
$pageSearch->assertSee($page->book->getShortName(42));
|
$pageSearch->assertSee($page->book->getShortName(42));
|
||||||
|
|
||||||
$chapterSearch = $this->get('/ajax/search/entities?term=' . urlencode($chapter->name));
|
$chapterSearch = $this->get('/search/entity-selector?term=' . urlencode($chapter->name));
|
||||||
$chapterSearch->assertSee($chapter->name);
|
$chapterSearch->assertSee($chapter->name);
|
||||||
$chapterSearch->assertSee($chapter->book->getShortName(42));
|
$chapterSearch->assertSee($chapter->book->getShortName(42));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_ajax_entity_search_reflects_items_without_permission()
|
public function test_entity_selector_search_reflects_items_without_permission()
|
||||||
{
|
{
|
||||||
$page = $this->entities->page();
|
$page = $this->entities->page();
|
||||||
$baseSelector = 'a[data-entity-type="page"][data-entity-id="' . $page->id . '"]';
|
$baseSelector = 'a[data-entity-type="page"][data-entity-id="' . $page->id . '"]';
|
||||||
$searchUrl = '/ajax/search/entities?permission=update&term=' . urlencode($page->name);
|
$searchUrl = '/search/entity-selector?permission=update&term=' . urlencode($page->name);
|
||||||
|
|
||||||
$resp = $this->asEditor()->get($searchUrl);
|
$resp = $this->asEditor()->get($searchUrl);
|
||||||
$this->withHtml($resp)->assertElementContains($baseSelector, $page->name);
|
$this->withHtml($resp)->assertElementContains($baseSelector, $page->name);
|
||||||
@ -457,4 +457,25 @@ class EntitySearchTest extends TestCase
|
|||||||
$this->withHtml($resp)->assertElementExists('form input[name="filters[updated_by]"][value="me"][checked="checked"]');
|
$this->withHtml($resp)->assertElementExists('form input[name="filters[updated_by]"][value="me"][checked="checked"]');
|
||||||
$this->withHtml($resp)->assertElementExists('form input[name="filters[created_by]"][value="me"][checked="checked"]');
|
$this->withHtml($resp)->assertElementExists('form input[name="filters[created_by]"][value="me"][checked="checked"]');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_search_suggestion_endpoint()
|
||||||
|
{
|
||||||
|
$this->entities->newPage(['name' => 'My suggestion page', 'html' => '<p>My supercool suggestion page</p>']);
|
||||||
|
|
||||||
|
// Test specific search
|
||||||
|
$resp = $this->asEditor()->get('/search/suggest?term="supercool+suggestion"');
|
||||||
|
$resp->assertSee('My suggestion page');
|
||||||
|
$resp->assertDontSee('My supercool suggestion page');
|
||||||
|
$resp->assertDontSee('No items available');
|
||||||
|
$this->withHtml($resp)->assertElementCount('a', 1);
|
||||||
|
|
||||||
|
// Test search limit
|
||||||
|
$resp = $this->asEditor()->get('/search/suggest?term=et');
|
||||||
|
$this->withHtml($resp)->assertElementCount('a', 5);
|
||||||
|
|
||||||
|
// Test empty state
|
||||||
|
$resp = $this->asEditor()->get('/search/suggest?term=spaghettisaurusrex');
|
||||||
|
$this->withHtml($resp)->assertElementCount('a', 0);
|
||||||
|
$resp->assertSee('No items available');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user