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) {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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));
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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