]> BookStack Code Mirror - bookstack/commitdiff
Converted search filters to not be vue based
authorDan Brown <redacted>
Sat, 27 Jun 2020 12:29:00 +0000 (13:29 +0100)
committerDan Brown <redacted>
Sat, 27 Jun 2020 12:29:00 +0000 (13:29 +0100)
15 files changed:
app/Entities/SearchOptions.php [new file with mode: 0644]
app/Entities/SearchService.php
app/Http/Controllers/SearchController.php
resources/js/components/add-remove-rows.js [new file with mode: 0644]
resources/js/components/optional-input.js [new file with mode: 0644]
resources/js/vues/search.js [deleted file]
resources/js/vues/vues.js
resources/lang/en/entities.php
resources/sass/_layout.scss
resources/views/search/all.blade.php
resources/views/search/form/boolean-filter.blade.php [new file with mode: 0644]
resources/views/search/form/date-filter.blade.php [new file with mode: 0644]
resources/views/search/form/term-list.blade.php [new file with mode: 0644]
resources/views/search/form/type-filter.blade.php [new file with mode: 0644]
tests/Entity/SearchOptionsTest.php [new file with mode: 0644]

diff --git a/app/Entities/SearchOptions.php b/app/Entities/SearchOptions.php
new file mode 100644 (file)
index 0000000..af91569
--- /dev/null
@@ -0,0 +1,137 @@
+<?php namespace BookStack\Entities;
+
+use Illuminate\Http\Request;
+
+class SearchOptions
+{
+
+    /**
+     * @var array
+     */
+    public $searches = [];
+
+    /**
+     * @var array
+     */
+    public $exacts = [];
+
+    /**
+     * @var array
+     */
+    public $tags = [];
+
+    /**
+     * @var array
+     */
+    public $filters = [];
+
+    /**
+     * Create a new instance from a search string.
+     */
+    public static function fromString(string $search): SearchOptions
+    {
+        $decoded = static::decode($search);
+        $instance = new static();
+        foreach ($decoded as $type => $value) {
+            $instance->$type = $value;
+        }
+        return $instance;
+    }
+
+    /**
+     * Create a new instance from a request.
+     * Will look for a classic string term and use that
+     * Otherwise we'll use the details from an advanced search form.
+     */
+    public static function fromRequest(Request $request): SearchOptions
+    {
+        if ($request->has('term')) {
+            return static::fromString($request->get('term'));
+        }
+
+        $instance = new static();
+        $inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']);
+        $instance->searches = explode(' ', $inputs['search'] ?? []);
+        $instance->exacts = array_filter($inputs['exact'] ?? []);
+        $instance->tags = array_filter($inputs['tags'] ?? []);
+        foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
+            if (empty($filterVal)) {
+                continue;
+            }
+            $instance->filters[$filterKey] = $filterVal === 'true' ? '' : $filterVal;
+        }
+        if (isset($inputs['types']) && count($inputs['types']) < 4) {
+            $instance->filters['type'] = implode('|', $inputs['types']);
+        }
+        return $instance;
+    }
+
+    /**
+     * Decode a search string into an array of terms.
+     */
+    protected static function decode(string $searchString): array
+    {
+        $terms = [
+            'searches' => [],
+            'exacts' => [],
+            'tags' => [],
+            'filters' => []
+        ];
+
+        $patterns = [
+            'exacts' => '/"(.*?)"/',
+            'tags' => '/\[(.*?)\]/',
+            'filters' => '/\{(.*?)\}/'
+        ];
+
+        // Parse special terms
+        foreach ($patterns as $termType => $pattern) {
+            $matches = [];
+            preg_match_all($pattern, $searchString, $matches);
+            if (count($matches) > 0) {
+                $terms[$termType] = $matches[1];
+                $searchString = preg_replace($pattern, '', $searchString);
+            }
+        }
+
+        // Parse standard terms
+        foreach (explode(' ', trim($searchString)) as $searchTerm) {
+            if ($searchTerm !== '') {
+                $terms['searches'][] = $searchTerm;
+            }
+        }
+
+        // Split filter values out
+        $splitFilters = [];
+        foreach ($terms['filters'] as $filter) {
+            $explodedFilter = explode(':', $filter, 2);
+            $splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
+        }
+        $terms['filters'] = $splitFilters;
+
+        return $terms;
+    }
+
+    /**
+     * Encode this instance to a search string.
+     */
+    public function toString(): string
+    {
+        $string = implode(' ', $this->searches ?? []);
+
+        foreach ($this->exacts as $term) {
+            $string .= ' "' . $term . '"';
+        }
+
+        foreach ($this->tags as $term) {
+            $string .= " [{$term}]";
+        }
+
+        foreach ($this->filters as $filterName => $filterVal) {
+            $string .= ' {' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}';
+        }
+
+        return $string;
+    }
+
+}
\ No newline at end of file
index ee9b87786a57a1e059ed050621f0c694427b17cb..11b731cd0153591e2cfd7b6b71f88504f088cd92 100644 (file)
@@ -39,10 +39,6 @@ class SearchService
 
     /**
      * SearchService constructor.
-     * @param SearchTerm $searchTerm
-     * @param EntityProvider $entityProvider
-     * @param Connection $db
-     * @param PermissionService $permissionService
      */
     public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider, Connection $db, PermissionService $permissionService)
     {
@@ -54,7 +50,6 @@ class SearchService
 
     /**
      * Set the database connection
-     * @param Connection $connection
      */
     public function setConnection(Connection $connection)
     {
@@ -63,23 +58,18 @@ class SearchService
 
     /**
      * Search all entities in the system.
-     * @param string $searchString
-     * @param string $entityType
-     * @param int $page
-     * @param int $count - Count of each entity to search, Total returned could can be larger and not guaranteed.
-     * @param string $action
-     * @return array[int, Collection];
+     * The provided count is for each entity to search,
+     * Total returned could can be larger and not guaranteed.
      */
-    public function searchEntities($searchString, $entityType = 'all', $page = 1, $count = 20, $action = 'view')
+    public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20, string $action = 'view'): array
     {
-        $terms = $this->parseSearchString($searchString);
         $entityTypes = array_keys($this->entityProvider->all());
         $entityTypesToSearch = $entityTypes;
 
         if ($entityType !== 'all') {
             $entityTypesToSearch = $entityType;
-        } else if (isset($terms['filters']['type'])) {
-            $entityTypesToSearch = explode('|', $terms['filters']['type']);
+        } else if (isset($searchOpts->filters['type'])) {
+            $entityTypesToSearch = explode('|', $searchOpts->filters['type']);
         }
 
         $results = collect();
@@ -90,8 +80,8 @@ class SearchService
             if (!in_array($entityType, $entityTypes)) {
                 continue;
             }
-            $search = $this->searchEntityTable($terms, $entityType, $page, $count, $action);
-            $entityTotal = $this->searchEntityTable($terms, $entityType, $page, $count, $action, true);
+            $search = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action);
+            $entityTotal = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action, true);
             if ($entityTotal > $page * $count) {
                 $hasMore = true;
             }
@@ -103,29 +93,26 @@ class SearchService
             'total' => $total,
             'count' => count($results),
             'has_more' => $hasMore,
-            'results' => $results->sortByDesc('score')->values()
+            'results' => $results->sortByDesc('score')->values(),
         ];
     }
 
 
     /**
      * Search a book for entities
-     * @param integer $bookId
-     * @param string $searchString
-     * @return Collection
      */
-    public function searchBook($bookId, $searchString)
+    public function searchBook(int $bookId, string $searchString): Collection
     {
-        $terms = $this->parseSearchString($searchString);
+        $opts = SearchOptions::fromString($searchString);
         $entityTypes = ['page', 'chapter'];
-        $entityTypesToSearch = isset($terms['filters']['type']) ? explode('|', $terms['filters']['type']) : $entityTypes;
+        $entityTypesToSearch = isset($opts->filters['type']) ? explode('|', $opts->filters['type']) : $entityTypes;
 
         $results = collect();
         foreach ($entityTypesToSearch as $entityType) {
             if (!in_array($entityType, $entityTypes)) {
                 continue;
             }
-            $search = $this->buildEntitySearchQuery($terms, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
+            $search = $this->buildEntitySearchQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
             $results = $results->merge($search);
         }
         return $results->sortByDesc('score')->take(20);
@@ -133,30 +120,23 @@ class SearchService
 
     /**
      * Search a book for entities
-     * @param integer $chapterId
-     * @param string $searchString
-     * @return Collection
      */
-    public function searchChapter($chapterId, $searchString)
+    public function searchChapter(int $chapterId, string $searchString): Collection
     {
-        $terms = $this->parseSearchString($searchString);
-        $pages = $this->buildEntitySearchQuery($terms, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
+        $opts = SearchOptions::fromString($searchString);
+        $pages = $this->buildEntitySearchQuery($opts, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
         return $pages->sortByDesc('score');
     }
 
     /**
      * Search across a particular entity type.
-     * @param array $terms
-     * @param string $entityType
-     * @param int $page
-     * @param int $count
-     * @param string $action
-     * @param bool $getCount Return the total count of the search
+     * Setting getCount = true will return the total
+     * matching instead of the items themselves.
      * @return \Illuminate\Database\Eloquent\Collection|int|static[]
      */
-    public function searchEntityTable($terms, $entityType = 'page', $page = 1, $count = 20, $action = 'view', $getCount = false)
+    public function searchEntityTable(SearchOptions $searchOpts, string $entityType = 'page', int $page = 1, int $count = 20, string $action = 'view', bool $getCount = false)
     {
-        $query = $this->buildEntitySearchQuery($terms, $entityType, $action);
+        $query = $this->buildEntitySearchQuery($searchOpts, $entityType, $action);
         if ($getCount) {
             return $query->count();
         }
@@ -167,22 +147,18 @@ class SearchService
 
     /**
      * Create a search query for an entity
-     * @param array $terms
-     * @param string $entityType
-     * @param string $action
-     * @return EloquentBuilder
      */
-    protected function buildEntitySearchQuery($terms, $entityType = 'page', $action = 'view')
+    protected function buildEntitySearchQuery(SearchOptions $searchOpts, string $entityType = 'page', string $action = 'view'): EloquentBuilder
     {
         $entity = $this->entityProvider->get($entityType);
         $entitySelect = $entity->newQuery();
 
         // Handle normal search terms
-        if (count($terms['search']) > 0) {
+        if (count($searchOpts->searches) > 0) {
             $subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score'));
             $subQuery->where('entity_type', '=', $entity->getMorphClass());
-            $subQuery->where(function (Builder $query) use ($terms) {
-                foreach ($terms['search'] as $inputTerm) {
+            $subQuery->where(function (Builder $query) use ($searchOpts) {
+                foreach ($searchOpts->searches as $inputTerm) {
                     $query->orWhere('term', 'like', $inputTerm .'%');
                 }
             })->groupBy('entity_type', 'entity_id');
@@ -193,9 +169,9 @@ class SearchService
         }
 
         // Handle exact term matching
-        if (count($terms['exact']) > 0) {
-            $entitySelect->where(function (EloquentBuilder $query) use ($terms, $entity) {
-                foreach ($terms['exact'] as $inputTerm) {
+        if (count($searchOpts->exacts) > 0) {
+            $entitySelect->where(function (EloquentBuilder $query) use ($searchOpts, $entity) {
+                foreach ($searchOpts->exacts as $inputTerm) {
                     $query->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
                         $query->where('name', 'like', '%'.$inputTerm .'%')
                             ->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
@@ -205,12 +181,12 @@ class SearchService
         }
 
         // Handle tag searches
-        foreach ($terms['tags'] as $inputTerm) {
+        foreach ($searchOpts->tags as $inputTerm) {
             $this->applyTagSearch($entitySelect, $inputTerm);
         }
 
         // Handle filters
-        foreach ($terms['filters'] as $filterTerm => $filterValue) {
+        foreach ($searchOpts->filters as $filterTerm => $filterValue) {
             $functionName = Str::camel('filter_' . $filterTerm);
             if (method_exists($this, $functionName)) {
                 $this->$functionName($entitySelect, $entity, $filterValue);
@@ -220,60 +196,10 @@ class SearchService
         return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, $action);
     }
 
-
-    /**
-     * Parse a search string into components.
-     * @param $searchString
-     * @return array
-     */
-    protected function parseSearchString($searchString)
-    {
-        $terms = [
-            'search' => [],
-            'exact' => [],
-            'tags' => [],
-            'filters' => []
-        ];
-
-        $patterns = [
-            'exact' => '/"(.*?)"/',
-            'tags' => '/\[(.*?)\]/',
-            'filters' => '/\{(.*?)\}/'
-        ];
-
-        // Parse special terms
-        foreach ($patterns as $termType => $pattern) {
-            $matches = [];
-            preg_match_all($pattern, $searchString, $matches);
-            if (count($matches) > 0) {
-                $terms[$termType] = $matches[1];
-                $searchString = preg_replace($pattern, '', $searchString);
-            }
-        }
-
-        // Parse standard terms
-        foreach (explode(' ', trim($searchString)) as $searchTerm) {
-            if ($searchTerm !== '') {
-                $terms['search'][] = $searchTerm;
-            }
-        }
-
-        // Split filter values out
-        $splitFilters = [];
-        foreach ($terms['filters'] as $filter) {
-            $explodedFilter = explode(':', $filter, 2);
-            $splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
-        }
-        $terms['filters'] = $splitFilters;
-
-        return $terms;
-    }
-
     /**
      * Get the available query operators as a regex escaped list.
-     * @return mixed
      */
-    protected function getRegexEscapedOperators()
+    protected function getRegexEscapedOperators(): string
     {
         $escapedOperators = [];
         foreach ($this->queryOperators as $operator) {
@@ -284,11 +210,8 @@ class SearchService
 
     /**
      * Apply a tag search term onto a entity query.
-     * @param EloquentBuilder $query
-     * @param string $tagTerm
-     * @return mixed
      */
-    protected function applyTagSearch(EloquentBuilder $query, $tagTerm)
+    protected function applyTagSearch(EloquentBuilder $query, string $tagTerm): EloquentBuilder
     {
         preg_match("/^(.*?)((".$this->getRegexEscapedOperators().")(.*?))?$/", $tagTerm, $tagSplit);
         $query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) {
@@ -318,7 +241,6 @@ class SearchService
 
     /**
      * Index the given entity.
-     * @param Entity $entity
      */
     public function indexEntity(Entity $entity)
     {
index a5d57741d5295d9a315ba190c3dec2e9a57a3cc6..8105843b576acb9072651c878190a5489968f7b4 100644 (file)
@@ -6,6 +6,7 @@ use BookStack\Entities\Bookshelf;
 use BookStack\Entities\Entity;
 use BookStack\Entities\Managers\EntityContext;
 use BookStack\Entities\SearchService;
+use BookStack\Entities\SearchOptions;
 use Illuminate\Http\Request;
 
 class SearchController extends Controller
@@ -33,20 +34,22 @@ class SearchController extends Controller
      */
     public function search(Request $request)
     {
-        $searchTerm = $request->get('term');
-        $this->setPageTitle(trans('entities.search_for_term', ['term' => $searchTerm]));
+        $searchOpts = SearchOptions::fromRequest($request);
+        $fullSearchString = $searchOpts->toString();
+        $this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
 
         $page = intval($request->get('page', '0')) ?: 1;
-        $nextPageLink = url('/search?term=' . urlencode($searchTerm) . '&page=' . ($page+1));
+        $nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page+1));
 
-        $results = $this->searchService->searchEntities($searchTerm, 'all', $page, 20);
+        $results = $this->searchService->searchEntities($searchOpts, 'all', $page, 20);
 
         return view('search.all', [
             'entities'   => $results['results'],
             'totalResults' => $results['total'],
-            'searchTerm' => $searchTerm,
+            'searchTerm' => $fullSearchString,
             'hasNextPage' => $results['has_more'],
-            'nextPageLink' => $nextPageLink
+            'nextPageLink' => $nextPageLink,
+            'options' => $searchOpts,
         ]);
     }
 
@@ -84,7 +87,7 @@ class SearchController extends Controller
         // Search for entities otherwise show most popular
         if ($searchTerm !== false) {
             $searchTerm .= ' {type:'. implode('|', $entityTypes) .'}';
-            $entities = $this->searchService->searchEntities($searchTerm, 'all', 1, 20, $permission)['results'];
+            $entities = $this->searchService->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20, $permission)['results'];
         } else {
             $entities = $this->viewService->getPopular(20, 0, $entityTypes, $permission);
         }
diff --git a/resources/js/components/add-remove-rows.js b/resources/js/components/add-remove-rows.js
new file mode 100644 (file)
index 0000000..81eeb43
--- /dev/null
@@ -0,0 +1,31 @@
+import {onChildEvent} from "../services/dom";
+
+/**
+ * AddRemoveRows
+ * Allows easy row add/remove controls onto a table.
+ * Needs a model row to use when adding a new row.
+ * @extends {Component}
+ */
+class AddRemoveRows {
+    setup() {
+        this.modelRow = this.$refs.model;
+        this.addButton = this.$refs.add;
+        this.removeSelector = this.$opts.removeSelector;
+        this.setupListeners();
+    }
+
+    setupListeners() {
+        this.addButton.addEventListener('click', e => {
+            const clone = this.modelRow.cloneNode(true);
+            clone.classList.remove('hidden');
+            this.modelRow.parentNode.insertBefore(clone, this.modelRow);
+        });
+
+        onChildEvent(this.$el, this.removeSelector, 'click', (e) => {
+            const row = e.target.closest('tr');
+            row.remove();
+        });
+    }
+}
+
+export default AddRemoveRows;
\ No newline at end of file
diff --git a/resources/js/components/optional-input.js b/resources/js/components/optional-input.js
new file mode 100644 (file)
index 0000000..eab58e4
--- /dev/null
@@ -0,0 +1,28 @@
+import {onSelect} from "../services/dom";
+
+class OptionalInput {
+    setup() {
+        this.removeButton = this.$refs.remove;
+        this.showButton = this.$refs.show;
+        this.input = this.$refs.input;
+        this.setupListeners();
+    }
+
+    setupListeners() {
+        onSelect(this.removeButton, () => {
+            this.input.value = '';
+            this.input.classList.add('hidden');
+            this.removeButton.classList.add('hidden');
+            this.showButton.classList.remove('hidden');
+        });
+
+        onSelect(this.showButton, () => {
+            this.input.classList.remove('hidden');
+            this.removeButton.classList.remove('hidden');
+            this.showButton.classList.add('hidden');
+        });
+    }
+
+}
+
+export default OptionalInput;
\ No newline at end of file
diff --git a/resources/js/vues/search.js b/resources/js/vues/search.js
deleted file mode 100644 (file)
index c0b828b..0000000
+++ /dev/null
@@ -1,193 +0,0 @@
-import * as Dates from "../services/dates";
-
-let data = {
-    terms: '',
-    termString : '',
-    search: {
-        type: {
-            page: true,
-            chapter: true,
-            book: true,
-            bookshelf: true,
-        },
-        exactTerms: [],
-        tagTerms: [],
-        option: {},
-        dates: {
-            updated_after: false,
-            updated_before: false,
-            created_after: false,
-            created_before: false,
-        }
-    }
-};
-
-let computed = {
-
-};
-
-let methods = {
-
-    appendTerm(term) {
-        this.termString += ' ' + term;
-        this.termString = this.termString.replace(/\s{2,}/g, ' ');
-        this.termString = this.termString.replace(/^\s+/, '');
-        this.termString = this.termString.replace(/\s+$/, '');
-    },
-
-    exactParse(searchString) {
-        this.search.exactTerms = [];
-        let exactFilter = /"(.+?)"/g;
-        let matches;
-        while ((matches = exactFilter.exec(searchString)) !== null) {
-            this.search.exactTerms.push(matches[1]);
-        }
-    },
-
-    exactChange() {
-        let exactFilter = /"(.+?)"/g;
-        this.termString = this.termString.replace(exactFilter, '');
-        let matchesTerm = this.search.exactTerms.filter(term =>  term.trim() !== '').map(term => `"${term}"`).join(' ');
-        this.appendTerm(matchesTerm);
-    },
-
-    addExact() {
-        this.search.exactTerms.push('');
-        setTimeout(() => {
-            let exactInputs = document.querySelectorAll('.exact-input');
-            exactInputs[exactInputs.length - 1].focus();
-        }, 100);
-    },
-
-    removeExact(index) {
-        this.search.exactTerms.splice(index, 1);
-        this.exactChange();
-    },
-
-    tagParse(searchString) {
-        this.search.tagTerms = [];
-        let tagFilter = /\[(.+?)\]/g;
-        let matches;
-        while ((matches = tagFilter.exec(searchString)) !== null) {
-            this.search.tagTerms.push(matches[1]);
-        }
-    },
-
-    tagChange() {
-        let tagFilter = /\[(.+?)\]/g;
-        this.termString = this.termString.replace(tagFilter, '');
-        let matchesTerm = this.search.tagTerms.filter(term => {
-            return term.trim() !== '';
-        }).map(term => {
-            return `[${term}]`
-        }).join(' ');
-        this.appendTerm(matchesTerm);
-    },
-
-    addTag() {
-        this.search.tagTerms.push('');
-        setTimeout(() => {
-            let tagInputs = document.querySelectorAll('.tag-input');
-            tagInputs[tagInputs.length - 1].focus();
-        }, 100);
-    },
-
-    removeTag(index) {
-        this.search.tagTerms.splice(index, 1);
-        this.tagChange();
-    },
-
-    typeParse(searchString) {
-        let typeFilter = /{\s?type:\s?(.*?)\s?}/;
-        let match = searchString.match(typeFilter);
-        let type = this.search.type;
-        if (!match) {
-            type.page = type.book = type.chapter = type.bookshelf = true;
-            return;
-        }
-        let splitTypes = match[1].replace(/ /g, '').split('|');
-        type.page = (splitTypes.indexOf('page') !== -1);
-        type.chapter = (splitTypes.indexOf('chapter') !== -1);
-        type.book = (splitTypes.indexOf('book') !== -1);
-        type.bookshelf = (splitTypes.indexOf('bookshelf') !== -1);
-    },
-
-    typeChange() {
-        let typeFilter = /{\s?type:\s?(.*?)\s?}/;
-        let type = this.search.type;
-        if (type.page === type.chapter === type.book === type.bookshelf) {
-            this.termString = this.termString.replace(typeFilter, '');
-            return;
-        }
-        let selectedTypes = Object.keys(type).filter(type => this.search.type[type]).join('|');
-        let typeTerm = '{type:'+selectedTypes+'}';
-        if (this.termString.match(typeFilter)) {
-            this.termString = this.termString.replace(typeFilter, typeTerm);
-            return;
-        }
-        this.appendTerm(typeTerm);
-    },
-
-    optionParse(searchString) {
-        let optionFilter = /{([a-z_\-:]+?)}/gi;
-        let matches;
-        while ((matches = optionFilter.exec(searchString)) !== null) {
-            this.search.option[matches[1].toLowerCase()] = true;
-        }
-    },
-
-    optionChange(optionName) {
-        let isChecked = this.search.option[optionName];
-        if (isChecked) {
-            this.appendTerm(`{${optionName}}`);
-        } else {
-            this.termString = this.termString.replace(`{${optionName}}`, '');
-        }
-    },
-
-    updateSearch(e) {
-        e.preventDefault();
-        window.location = window.baseUrl('/search?term=' + encodeURIComponent(this.termString));
-    },
-
-    enableDate(optionName) {
-        this.search.dates[optionName.toLowerCase()] = Dates.getCurrentDay();
-        this.dateChange(optionName);
-    },
-
-    dateParse(searchString) {
-        let dateFilter = /{([a-z_\-]+?):([a-z_\-0-9]+?)}/gi;
-        let dateTags = Object.keys(this.search.dates);
-        let matches;
-        while ((matches = dateFilter.exec(searchString)) !== null) {
-            if (dateTags.indexOf(matches[1]) === -1) continue;
-            this.search.dates[matches[1].toLowerCase()] = matches[2];
-        }
-    },
-
-    dateChange(optionName) {
-        let dateFilter = new RegExp('{\\s?'+optionName+'\\s?:([a-z_\\-0-9]+?)}', 'gi');
-        this.termString = this.termString.replace(dateFilter, '');
-        if (!this.search.dates[optionName]) return;
-        this.appendTerm(`{${optionName}:${this.search.dates[optionName]}}`);
-    },
-
-    dateRemove(optionName) {
-        this.search.dates[optionName] = false;
-        this.dateChange(optionName);
-    }
-
-};
-
-function created() {
-    this.termString = document.querySelector('[name=searchTerm]').value;
-    this.typeParse(this.termString);
-    this.exactParse(this.termString);
-    this.tagParse(this.termString);
-    this.optionParse(this.termString);
-    this.dateParse(this.termString);
-}
-
-export default {
-    data, computed, methods, created
-};
index ec192372d1d8559d39ccfef697f6804fb20cdfa1..125d541cea5e55672d0790bd418c4df0ea7e184a 100644 (file)
@@ -4,7 +4,6 @@ function exists(id) {
     return document.getElementById(id) !== null;
 }
 
-import searchSystem from "./search";
 import entityDashboard from "./entity-dashboard";
 import codeEditor from "./code-editor";
 import imageManager from "./image-manager";
@@ -13,7 +12,6 @@ import attachmentManager from "./attachment-manager";
 import pageEditor from "./page-editor";
 
 let vueMapping = {
-    'search-system': searchSystem,
     'entity-dashboard': entityDashboard,
     'code-editor': codeEditor,
     'image-manager': imageManager,
index 6bbc723b0abfc1e6b1f69e270bbb9d2afdbe851b..bb5c0078dcb959274e464a02783a686733d23b88 100644 (file)
@@ -47,7 +47,8 @@ return [
     'search_no_pages' => 'No pages matched this search',
     'search_for_term' => 'Search for :term',
     'search_more' => 'More Results',
-    'search_filters' => 'Search Filters',
+    'search_advanced' => 'Advanced Search',
+    'search_terms' => 'Search Terms',
     'search_content_type' => 'Content Type',
     'search_exact_matches' => 'Exact Matches',
     'search_tags' => 'Tag Searches',
index 595713feba11a4f08d92e05198b46b148a07da10..4d044245a07ea70ff49ea7b72d3ba5a32e54abaf 100644 (file)
@@ -141,7 +141,7 @@ body.flexbox {
 }
 
 .hidden {
-  display: none;
+  display: none !important;
 }
 
 .float {
index f19e560a2d5f633b01bf063c074d5396a5819419..df137bd2a3bcbef73662ff1355b921df8d76f14c 100644 (file)
 @extends('simple-layout')
 
 @section('body')
-    <input type="hidden" name="searchTerm" value="{{$searchTerm}}">
-
-    <div class="container" id="search-system">
-
-        <div class="my-s">
-            &nbsp;
-        </div>
+    <div class="container mt-xl" id="search-system">
 
         <div class="grid right-focus reverse-collapse gap-xl">
             <div>
                 <div>
-                    <h5>{{ trans('entities.search_filters') }}</h5>
+                    <h5>{{ trans('entities.search_advanced') }}</h5>
 
-                    <form v-on:submit="updateSearch" v-cloak class="v-cloak anim fadeIn">
-                        <h6 class="text-muted">{{ trans('entities.search_content_type') }}</h6>
+                    <form method="get" action="{{ url('/search') }}">
+                        <h6>{{ trans('entities.search_terms') }}</h6>
+                        <input type="text" name="search" value="{{ implode(' ', $options->searches) }}">
+
+                        <h6>{{ trans('entities.search_content_type') }}</h6>
                         <div class="form-group">
-                            <label class="inline checkbox text-page"><input type="checkbox" v-on:change="typeChange" v-model="search.type.page" value="page">{{ trans('entities.page') }}</label>
-                            <label class="inline checkbox text-chapter"><input type="checkbox" v-on:change="typeChange" v-model="search.type.chapter" value="chapter">{{ trans('entities.chapter') }}</label>
+
+                            <?php
+                            $types = explode('|', $options->filters['type'] ?? '');
+                            $hasTypes = $types[0] !== '';
+                            ?>
+                            @include('search.form.type-filter', ['checked' => !$hasTypes || in_array('page', $types), 'entity' => 'page', 'transKey' => 'page'])
+                            @include('search.form.type-filter', ['checked' => !$hasTypes || in_array('chapter', $types), 'entity' => 'chapter', 'transKey' => 'chapter'])
                             <br>
-                            <label class="inline checkbox text-book"><input type="checkbox" v-on:change="typeChange" v-model="search.type.book" value="book">{{ trans('entities.book') }}</label>
-                            <label class="inline checkbox text-bookshelf"><input type="checkbox" v-on:change="typeChange" v-model="search.type.bookshelf" value="bookshelf">{{ trans('entities.shelf') }}</label>
+                                @include('search.form.type-filter', ['checked' => !$hasTypes || in_array('book', $types), 'entity' => 'book', 'transKey' => 'book'])
+                                @include('search.form.type-filter', ['checked' => !$hasTypes || in_array('bookshelf', $types), 'entity' => 'bookshelf', 'transKey' => 'shelf'])
                         </div>
 
-                        <h6 class="text-muted">{{ trans('entities.search_exact_matches') }}</h6>
-                        <table cellpadding="0" cellspacing="0" border="0" class="no-style">
-                            <tr v-for="(term, i) in search.exactTerms">
-                                <td style="padding: 0 12px 6px 0;">
-                                    <input class="exact-input outline" v-on:input="exactChange" type="text" v-model="search.exactTerms[i]"></td>
-                                <td>
-                                    <button type="button" class="text-neg text-button" v-on:click="removeExact(i)">
-                                        @icon('close')
-                                    </button>
-                                </td>
-                            </tr>
-                            <tr>
-                                <td colspan="2">
-                                    <button type="button" class="text-button" v-on:click="addExact">
-                                        @icon('add-circle'){{ trans('common.add') }}
-                                    </button>
-                                </td>
-                            </tr>
-                        </table>
-
-                        <h6 class="text-muted">{{ trans('entities.search_tags') }}</h6>
-                        <table cellpadding="0" cellspacing="0" border="0" class="no-style">
-                            <tr v-for="(term, i) in search.tagTerms">
-                                <td style="padding: 0 12px 6px 0;">
-                                    <input class="tag-input outline" v-on:input="tagChange" type="text" v-model="search.tagTerms[i]"></td>
-                                <td>
-                                    <button type="button" class="text-neg text-button" v-on:click="removeTag(i)">
-                                        @icon('close')
-                                    </button>
-                                </td>
-                            </tr>
-                            <tr>
-                                <td colspan="2">
-                                    <button type="button" class="text-button" v-on:click="addTag">
-                                        @icon('add-circle'){{ trans('common.add') }}
-                                    </button>
-                                </td>
-                            </tr>
-                        </table>
+                        <h6>{{ trans('entities.search_exact_matches') }}</h6>
+                        @include('search.form.term-list', ['type' => 'exact', 'currentList' => $options->exacts])
+
+                        <h6>{{ trans('entities.search_tags') }}</h6>
+                        @include('search.form.term-list', ['type' => 'tags', 'currentList' => $options->tags])
 
                         @if(signedInUser())
-                            <h6 class="text-muted">{{ trans('entities.search_options') }}</h6>
-                            <label class="checkbox">
-                                <input type="checkbox" v-on:change="optionChange('viewed_by_me')"
-                                       v-model="search.option.viewed_by_me" value="page">
+                            <h6>{{ trans('entities.search_options') }}</h6>
+
+                            @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'viewed_by_me', 'value' => null])
                                 {{ trans('entities.search_viewed_by_me') }}
-                            </label>
-                            <label class="checkbox">
-                                <input type="checkbox" v-on:change="optionChange('not_viewed_by_me')"
-                                       v-model="search.option.not_viewed_by_me" value="page">
+                            @endcomponent
+                            @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'not_viewed_by_me', 'value' => null])
                                 {{ trans('entities.search_not_viewed_by_me') }}
-                            </label>
-                            <label class="checkbox">
-                                <input type="checkbox" v-on:change="optionChange('is_restricted')"
-                                       v-model="search.option.is_restricted" value="page">
+                            @endcomponent
+                            @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'is_restricted', 'value' => null])
                                 {{ trans('entities.search_permissions_set') }}
-                            </label>
-                            <label class="checkbox">
-                                <input type="checkbox" v-on:change="optionChange('created_by:me')"
-                                       v-model="search.option['created_by:me']" value="page">
+                            @endcomponent
+                            @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'created_by', 'value' => 'me'])
                                 {{ trans('entities.search_created_by_me') }}
-                            </label>
-                            <label class="checkbox">
-                                <input type="checkbox" v-on:change="optionChange('updated_by:me')"
-                                       v-model="search.option['updated_by:me']" value="page">
+                            @endcomponent
+                            @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'updated_by', 'value' => 'me'])
                                 {{ trans('entities.search_updated_by_me') }}
-                            </label>
+                            @endcomponent
                         @endif
 
-                        <h6 class="text-muted">{{ trans('entities.search_date_options') }}</h6>
-                        <table cellpadding="0" cellspacing="0" border="0" class="no-style form-table">
-                            <tr>
-                                <td width="200">{{ trans('entities.search_updated_after') }}</td>
-                                <td width="80">
-                                    <button type="button" class="text-button" v-if="!search.dates.updated_after"
-                                            v-on:click="enableDate('updated_after')">{{ trans('entities.search_set_date') }}</button>
-
-                                </td>
-                            </tr>
-                            <tr v-if="search.dates.updated_after">
-                                <td>
-                                    <input v-if="search.dates.updated_after" class="tag-input"
-                                           v-on:input="dateChange('updated_after')" type="date" v-model="search.dates.updated_after"
-                                           pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}">
-                                </td>
-                                <td>
-                                    <button v-if="search.dates.updated_after" type="button" class="text-neg text-button"
-                                            v-on:click="dateRemove('updated_after')">
-                                        @icon('close')
-                                    </button>
-                                </td>
-                            </tr>
-                            <tr>
-                                <td>{{ trans('entities.search_updated_before') }}</td>
-                                <td>
-                                    <button type="button" class="text-button" v-if="!search.dates.updated_before"
-                                            v-on:click="enableDate('updated_before')">{{ trans('entities.search_set_date') }}</button>
-
-                                </td>
-                            </tr>
-                            <tr v-if="search.dates.updated_before">
-                                <td>
-                                    <input v-if="search.dates.updated_before" class="tag-input"
-                                           v-on:input="dateChange('updated_before')" type="date" v-model="search.dates.updated_before"
-                                           pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}">
-                                </td>
-                                <td>
-                                    <button v-if="search.dates.updated_before" type="button" class="text-neg text-button"
-                                            v-on:click="dateRemove('updated_before')">
-                                        @icon('close')
-                                    </button>
-                                </td>
-                            </tr>
-                            <tr>
-                                <td>{{ trans('entities.search_created_after') }}</td>
-                                <td>
-                                    <button type="button" class="text-button" v-if="!search.dates.created_after"
-                                            v-on:click="enableDate('created_after')">{{ trans('entities.search_set_date') }}</button>
-
-                                </td>
-                            </tr>
-                            <tr v-if="search.dates.created_after">
-                                <td>
-                                    <input v-if="search.dates.created_after" class="tag-input"
-                                           v-on:input="dateChange('created_after')" type="date" v-model="search.dates.created_after"
-                                           pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}">
-                                </td>
-                                <td>
-                                    <button v-if="search.dates.created_after" type="button" class="text-neg text-button"
-                                            v-on:click="dateRemove('created_after')">
-                                        @icon('close')
-                                    </button>
-                                </td>
-                            </tr>
-                            <tr>
-                                <td>{{ trans('entities.search_created_before') }}</td>
-                                <td>
-                                    <button type="button" class="text-button" v-if="!search.dates.created_before"
-                                            v-on:click="enableDate('created_before')">{{ trans('entities.search_set_date') }}</button>
-
-                                </td>
-                            </tr>
-                            <tr v-if="search.dates.created_before">
-                                <td>
-                                    <input v-if="search.dates.created_before" class="tag-input"
-                                           v-on:input="dateChange('created_before')" type="date" v-model="search.dates.created_before"
-                                           pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}">
-                                </td>
-                                <td>
-                                    <button v-if="search.dates.created_before" type="button" class="text-neg text-button"
-                                            v-on:click="dateRemove('created_before')">
-                                        @icon('close')
-                                    </button>
-                                </td>
-                            </tr>
-                        </table>
-
+                        <h6>{{ trans('entities.search_date_options') }}</h6>
+                        @include('search.form.date-filter', ['name' => 'updated_after', 'filters' => $options->filters])
+                        @include('search.form.date-filter', ['name' => 'updated_before', 'filters' => $options->filters])
+                        @include('search.form.date-filter', ['name' => 'created_after', 'filters' => $options->filters])
+                        @include('search.form.date-filter', ['name' => 'created_before', 'filters' => $options->filters])
 
                         <button type="submit" class="button">{{ trans('entities.search_update') }}</button>
                     </form>
                 </div>
             </div>
             <div>
-                <div v-pre class="card content-wrap">
+                <div class="card content-wrap">
                     <h1 class="list-heading">{{ trans('entities.search_results') }}</h1>
+
                     <form action="{{ url('/search') }}" method="GET"  class="search-box flexible hide-over-l">
                         <input value="{{$searchTerm}}" type="text" name="term" placeholder="{{ trans('common.search') }}">
                         <button type="submit">@icon('search')</button>
-                        <button v-if="searching" v-cloak class="search-box-cancel text-neg" v-on:click="clearSearch" type="button">@icon('close')</button>
                     </form>
+
                     <h6 class="text-muted">{{ trans_choice('entities.search_total_results_found', $totalResults, ['count' => $totalResults]) }}</h6>
                     <div class="book-contents">
                         @include('partials.entity-list', ['entities' => $entities, 'showPath' => true])
                     </div>
+
                     @if($hasNextPage)
                         <div class="text-right mt-m">
                             <a href="{{ $nextPageLink }}" class="button outline">{{ trans('entities.search_more') }}</a>
diff --git a/resources/views/search/form/boolean-filter.blade.php b/resources/views/search/form/boolean-filter.blade.php
new file mode 100644 (file)
index 0000000..1dc9bf0
--- /dev/null
@@ -0,0 +1,12 @@
+{{--
+$filters - Array of search filter values
+$name - Name of filter to limit use.
+$value - Value of filter to use
+--}}
+<label class="checkbox">
+    <input type="checkbox"
+           name="filters[{{ $name }}]"
+           @if (isset($filters[$name]) && (!$value || ($value && $value === $filters[$name]))) checked="checked" @endif
+           value="{{ $value ?: 'true' }}">
+    {{ $slot }}
+</label>
\ No newline at end of file
diff --git a/resources/views/search/form/date-filter.blade.php b/resources/views/search/form/date-filter.blade.php
new file mode 100644 (file)
index 0000000..05ab4c1
--- /dev/null
@@ -0,0 +1,29 @@
+{{--
+@filters - Active search filters
+@name - Name of filter
+--}}
+<table class="no-style form-table mb-xs">
+    <tr>
+        <td width="200">{{ trans('entities.search_' . $name) }}</td>
+        <td width="80"></td>
+    </tr>
+    <tr component="optional-input">
+        <td>
+            <button type="button" refs="optional-input@show"
+                    class="text-button {{ ($filters[$name] ?? false) ? 'hidden' : '' }}">{{ trans('entities.search_set_date') }}</button>
+            <input class="tag-input {{ ($filters[$name] ?? false) ? '' : 'hidden' }}"
+                   refs="optional-input@input"
+                   value="{{ $filters[$name] ?? '' }}"
+                   type="date"
+                   name="filters[{{ $name }}]"
+                   pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}">
+        </td>
+        <td>
+            <button type="button"
+                    refs="optional-input@remove"
+                    class="text-neg text-button {{ ($filters[$name] ?? false) ? '' : 'hidden' }}">
+                @icon('close')
+            </button>
+        </td>
+    </tr>
+</table>
\ No newline at end of file
diff --git a/resources/views/search/form/term-list.blade.php b/resources/views/search/form/term-list.blade.php
new file mode 100644 (file)
index 0000000..435de73
--- /dev/null
@@ -0,0 +1,25 @@
+{{--
+@type - Type of term (exact, tag)
+@currentList
+--}}
+<table component="add-remove-rows"
+       option:add-remove-rows:remove-selector="button.text-neg"
+       class="no-style">
+    @foreach(array_merge($currentList, ['']) as $term)
+        <tr @if(empty($term)) class="hidden" refs="add-remove-rows@model" @endif>
+            <td class="pb-s pr-m">
+                <input class="exact-input outline" type="text" name="{{$type}}[]" value="{{ $term }}">
+            </td>
+            <td>
+                <button type="button" class="text-neg text-button">@icon('close')</button>
+            </td>
+        </tr>
+    @endforeach
+    <tr>
+        <td colspan="2">
+            <button refs="add-remove-rows@add" type="button" class="text-button">
+                @icon('add-circle'){{ trans('common.add') }}
+            </button>
+        </td>
+    </tr>
+</table>
\ No newline at end of file
diff --git a/resources/views/search/form/type-filter.blade.php b/resources/views/search/form/type-filter.blade.php
new file mode 100644 (file)
index 0000000..b885ebd
--- /dev/null
@@ -0,0 +1,10 @@
+{{--
+@checked - If the option should be pre-checked
+@entity - Entity Name
+@transKey - Translation Key
+--}}
+<label class="inline checkbox text-{{$entity}}">
+    <input type="checkbox" name="types[]"
+           @if($checked) checked @endif
+           value="{{$entity}}">{{ trans('entities.' . $transKey) }}
+</label>
\ No newline at end of file
diff --git a/tests/Entity/SearchOptionsTest.php b/tests/Entity/SearchOptionsTest.php
new file mode 100644 (file)
index 0000000..727db55
--- /dev/null
@@ -0,0 +1,43 @@
+<?php namespace Tests\Entity;
+
+use BookStack\Entities\SearchOptions;
+use Tests\TestCase;
+
+class SearchOptionsTest extends TestCase
+{
+    public function test_from_string_parses_a_search_string_properly()
+    {
+        $options = SearchOptions::fromString('cat "dog" [tag=good] {is_tree}');
+
+        $this->assertEquals(['cat'], $options->searches);
+        $this->assertEquals(['dog'], $options->exacts);
+        $this->assertEquals(['tag=good'], $options->tags);
+        $this->assertEquals(['is_tree' => ''], $options->filters);
+    }
+
+    public function test_to_string_includes_all_items_in_the_correct_format()
+    {
+        $expected = 'cat "dog" [tag=good] {is_tree}';
+        $options = new SearchOptions;
+        $options->searches = ['cat'];
+        $options->exacts = ['dog'];
+        $options->tags = ['tag=good'];
+        $options->filters = ['is_tree' => ''];
+
+        $output = $options->toString();
+        foreach (explode(' ', $expected) as $term) {
+            $this->assertStringContainsString($term, $output);
+        }
+    }
+
+    public function test_correct_filter_values_are_set_from_string()
+    {
+        $opts = SearchOptions::fromString('{is_tree} {name:dan} {cat:happy}');
+
+        $this->assertEquals([
+            'is_tree' => '',
+            'name' => 'dan',
+            'cat' => 'happy',
+        ], $opts->filters);
+    }
+}