From 7243da796b97dbc54d80034e7fa23175aa518463 Mon Sep 17 00:00:00 2001 From: Guy Sartorelli Date: Thu, 30 Jan 2025 16:12:58 +1300 Subject: [PATCH] API Refactor SiteTree filtering to work like GridField filters CMSMain uses an entirely separate code path for building the filter form and for the actual filter functionality compared with the gridfield filter used literally everywhere else in the CMS. This aims to unify the two as much as possible, so that filtering pages in CMSMain is the same as filtering everywhere else. --- code/Controllers/CMSMain.php | 196 ++------------- code/Controllers/CMSSiteTreeFilter.php | 226 +----------------- .../CMSSiteTreeFilter_ChangedPages.php | 18 +- .../CMSSiteTreeFilter_DeletedPages.php | 21 +- .../CMSSiteTreeFilter_PublishedPages.php | 39 +-- code/Controllers/CMSSiteTreeFilter_Search.php | 15 +- .../CMSSiteTreeFilter_StatusDeletedPages.php | 24 +- .../CMSSiteTreeFilter_StatusDraftPages.php | 15 +- ...TreeFilter_StatusRemovedFromDraftPages.php | 14 +- code/Model/SiteTree.php | 77 +++++- code/Search/SiteTreeSearchContext.php | 49 ++++ .../Controllers/Includes/CMSMain_Filter.ss | 6 - .../Controllers/Includes/CMSMain_LeftPanel.ss | 6 +- .../features/manage-page-permissions.feature | 13 +- .../behat/features/search-for-a-page.feature | 51 ++-- tests/php/Controllers/CMSMainTest.php | 197 ++++++++------- .../php/Controllers/CMSSiteTreeFilterTest.php | 198 +++++---------- .../php/Search/SiteTreeSearchContextTest.php | 77 ++++++ .../php/Search/SiteTreeSearchContextTest.yml | 34 +++ 19 files changed, 484 insertions(+), 792 deletions(-) create mode 100644 code/Search/SiteTreeSearchContext.php delete mode 100644 templates/SilverStripe/CMS/Controllers/Includes/CMSMain_Filter.ss create mode 100644 tests/php/Search/SiteTreeSearchContextTest.php create mode 100644 tests/php/Search/SiteTreeSearchContextTest.yml diff --git a/code/Controllers/CMSMain.php b/code/Controllers/CMSMain.php index 28513ecd4d..b4fa8bb80a 100644 --- a/code/Controllers/CMSMain.php +++ b/code/Controllers/CMSMain.php @@ -2,7 +2,6 @@ namespace SilverStripe\CMS\Controllers; -use InvalidArgumentException; use LogicException; use Psr\SimpleCache\CacheInterface; use SilverStripe\Admin\AdminRootController; @@ -13,11 +12,9 @@ use SilverStripe\CMS\BatchActions\CMSBatchAction_Archive; use SilverStripe\CMS\BatchActions\CMSBatchAction_Publish; use SilverStripe\CMS\BatchActions\CMSBatchAction_Unpublish; -use SilverStripe\CMS\Controllers\CMSSiteTreeFilter_Search; use SilverStripe\CMS\Forms\CMSMainAddForm; use SilverStripe\CMS\Model\CurrentRecordIdentifier; use SilverStripe\CMS\Model\SiteTree; -use SilverStripe\CMS\Search\SearchForm; use SilverStripe\Control\Controller; use SilverStripe\Control\Director; use SilverStripe\Control\HTTPRequest; @@ -33,9 +30,6 @@ use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Manifest\ModuleResource; use SilverStripe\Core\Manifest\ModuleResourceLoader; -use SilverStripe\Forms\DateField; -use SilverStripe\Forms\DropdownField; -use SilverStripe\Forms\FieldGroup; use SilverStripe\Forms\FieldList; use SilverStripe\Forms\Form; use SilverStripe\Forms\FormAction; @@ -66,11 +60,10 @@ use SilverStripe\Security\Security; use SilverStripe\Security\SecurityToken; use SilverStripe\SiteConfig\SiteConfig; -use SilverStripe\Versioned\ChangeSet; -use SilverStripe\Versioned\ChangeSetItem; use SilverStripe\Versioned\Versioned; use SilverStripe\VersionedAdmin\Controllers\CMSPageHistoryViewerController; use SilverStripe\Model\ArrayData; +use SilverStripe\ORM\Search\SearchContextForm; use SilverStripe\Security\Permission; use SilverStripe\Security\PermissionCheckable; use SilverStripe\Versioned\RecursivePublishable; @@ -135,7 +128,7 @@ class CMSMain extends LeftAndMain implements CurrentRecordIdentifier, Permission 'submit', 'EditForm', 'schema', - 'SearchForm', + 'getSearchForm', 'TreeAsUL', 'getshowdeletedsubtree', 'savetreenode', @@ -150,6 +143,7 @@ class CMSMain extends LeftAndMain implements CurrentRecordIdentifier, Permission private static array $url_handlers = [ 'EditForm/$ID' => 'EditForm', + 'GET SearchForm' => 'getSearchForm', ]; private static array $casting = [ @@ -496,11 +490,10 @@ public function AddForm(): CMSMainAddForm public function TreeAsUL() { $modelClass = $this->getModelClass(); - $filter = $this->getSearchFilter(); DataObject::singleton($modelClass)->prepopulateTreeDataCache(null, [ - 'childrenMethod' => $filter ? $filter->getChildrenMethod() : 'AllChildrenIncludingDeleted', - 'numChildrenMethod' => $filter ? $filter->getNumChildrenMethod() : 'numChildren', + 'childrenMethod' => 'AllChildrenIncludingDeleted', + 'numChildrenMethod' => 'numChildren', ]); $html = $this->getTreeFor($modelClass); @@ -530,21 +523,6 @@ public function getTreeFor( $nodeCountThreshold = null ) { $nodeCountThreshold = is_null($nodeCountThreshold) ? Config::inst()->get($className, 'node_threshold_total') : $nodeCountThreshold; - // Provide better defaults from filter - $filter = $this->getSearchFilter(); - if ($filter) { - if (!$childrenMethod) { - $childrenMethod = $filter->getChildrenMethod(); - } - if (!$numChildrenMethod) { - $numChildrenMethod = $filter->getNumChildrenMethod(); - } - if (!$filterFunction) { - $filterFunction = function ($node) use ($filter) { - return $filter->isRecordIncluded($node); - }; - } - } // Build set from node and begin marking $record = ($rootID) ? $this->getRecord($rootID) : null; @@ -627,15 +605,6 @@ public function getTreeNodeClasses(DataObject $node): string } } - // Get additional filter classes - $filter = $this->getSearchFilter(); - if ($filter && ($filterClasses = $filter->getRecordClasses($node))) { - if (is_array($filterClasses)) { - $filterClasses = implode(' ', $filterClasses); - } - $classes .= ' ' . $filterClasses; - } - return trim($classes ?? ''); } @@ -895,109 +864,16 @@ public function ExtraTreeTools(): string } /** - * Returns the search form schema for the current model + * Returns a Form for record searching for use in templates. */ - public function getSearchFieldSchema(): string + public function getSearchForm(): SearchContextForm { - $schemaUrl = $this->Link('schema/SearchForm'); - - $singleton = DataObject::singleton($this->getModelClass()); - $context = $singleton->getDefaultSearchContext(); + $context = DataObject::singleton($this->getModelClass())->getDefaultSearchContext(); $params = $this->getRequest()->requestVar('q') ?: []; $context->setSearchParams($params); - - $placeholder = _t(SearchForm::class . '.FILTERLABELTEXT2', 'Search "{model}"', ['model' => $singleton->i18n_plural_name()]); - $searchParams = $context->getSearchParams(); - $searchParams = array_combine(array_map(function ($key) { - return 'Search__' . $key; - }, array_keys($searchParams ?? [])), $searchParams ?? []); - - $schema = [ - 'formSchemaUrl' => $schemaUrl, - 'name' => 'Term', - 'placeholder' => $placeholder, - 'filters' => $searchParams ?: new \stdClass // stdClass maps to empty json object '{}' - ]; - - return json_encode($schema); - } - - /** - * Returns a Form for record searching for use in templates. - * - * Can be modified from a decorator by a 'updateSearchForm' method - */ - public function getSearchForm(): Form - { - $modelClass = $this->getModelClass(); - $singleton = DataObject::singleton($modelClass); - // Create the fields - $dateFrom = DateField::create( - 'Search__LastEditedFrom', - _t(SearchForm::class . '.FILTERDATEFROM', 'From') - )->setLocale(Security::getCurrentUser()->Locale); - $dateTo = DateField::create( - 'Search__LastEditedTo', - _t(SearchForm::class . '.FILTERDATETO', 'To') - )->setLocale(Security::getCurrentUser()->Locale); - $filters = CMSSiteTreeFilter::get_all_filters(); - // Remove 'All records' as we set that to empty/default value - unset($filters[CMSSiteTreeFilter_Search::class]); - $recordFilter = DropdownField::create( - 'Search__FilterClass', - _t(SearchForm::class . '.RECORD_STATUS', '{model} status', ['model' => $singleton->i18n_singular_name()]), - $filters - ); - $recordFilter->setEmptyString(_t( - SearchForm::class . '.RECORDS_ALLOPT', - 'All {model}', - ['model' => mb_strtolower($singleton->i18n_plural_name())] - )); - $classes = DropdownField::create( - 'Search__ClassName', - _t( - SearchForm::class . '.RECORD_TYPEOPT', - '{model} type', - 'Dropdown for limiting search to a record type', - ['model' => $singleton->i18n_singular_name()] - ), - $this->getRecordTypes() - ); - $classes->setEmptyString(_t(SearchForm::class . '.RECORD_TYPEANYOPT', 'Any')); - - // Group the Datefields - $dateGroup = FieldGroup::create( - _t(SearchForm::class . '.RECORD_FILTERDATEHEADING', 'Last edited'), - [$dateFrom, $dateTo] - )->setName('Search__LastEdited') - ->addExtraClass('fieldgroup--fill-width'); - - // Create the Field list - $fields = new FieldList( - $recordFilter, - $classes, - $dateGroup - ); - - // Create the form - $form = Form::create( - $this, - 'SearchForm', - $fields, - new FieldList() - ); - $form->addExtraClass('cms-search-form'); - $form->setFormMethod('GET'); - $form->setFormAction(CMSMain::singleton()->Link()); - $form->disableSecurityToken(); - $form->unsetValidator(); - - // Load the form with previously sent search data - $form->loadDataFrom($this->getRequest()->getVars()); - + $form = SearchContextForm::create($this, $context); // Allow decorators to modify the form $this->extend('updateSearchForm', $form); - return $form; } @@ -1658,58 +1534,21 @@ public function childfilter(HTTPRequest $request): HTTPResponse ->setBody(json_encode($disallowedChildren)); } - /** - * Safely reconstruct a selected filter from a given set of query parameters - * - * @param array $params Query parameters to use, or null if none present - * @return CMSSiteTreeFilter The filter class - * @throws InvalidArgumentException if invalid filter class is passed. - */ - protected function getQueryFilter($params) - { - if (empty($params['FilterClass'])) { - return null; - } - $filterClass = $params['FilterClass']; - if (!is_subclass_of($filterClass, CMSSiteTreeFilter::class)) { - throw new InvalidArgumentException("Invalid filter class passed: {$filterClass}"); - } - return $filterClass::create($params); - } - - /** - * Returns the records meet a certain criteria as {@see CMSSiteTreeFilter} or the subrecords of a parent record - * defaulting to no filter and show all records in first level. - * Doubles as search results, if any search parameters are set through {@link SearchForm()}. - * - * @param array $params Search filter criteria - * @param int $parentID Optional parent node to filter on (can't be combined with other search criteria) - * @return SS_List - * @throws InvalidArgumentException if invalid filter class is passed. - */ - public function getList($params = [], $parentID = 0) - { - if ($filter = $this->getQueryFilter($params)) { - return $filter->getFilteredPages(); - } else { - $list = DataObject::get($this->getModelClass()); - $parentID = is_numeric($parentID) ? $parentID : 0; - return $list->filter("ParentID", $parentID); - } - } - /** * @return Form */ public function ListViewForm() { - $params = $this->getRequest()->requestVar('q'); + $modelClass = $this->getModelClass(); $parentID = $this->getRequest()->requestVar('ParentID'); - // Set default filter if other params are set - if ($params && empty($params['FilterClass'])) { - $params['FilterClass'] = CMSSiteTreeFilter_Search::class; + $params = $this->getRequest()->requestVar('q') ?? []; + if ($params) { + $form = $this->getSearchForm(); + $params = $form->prepareValuesForSearchContext($params); + $list = DataObject::singleton($modelClass)->getDefaultSearchContext()->getQuery($params); + } else { + $list = DataObject::singleton($modelClass)::get()->filter('ParentID', is_numeric($parentID) ? $parentID : 0); } - $list = $this->getList($params, $parentID); $gridFieldConfig = GridFieldConfig::create()->addComponents( Injector::inst()->create(GridFieldSortableHeader::class), Injector::inst()->create(GridFieldDataColumns::class), @@ -1729,7 +1568,6 @@ public function ListViewForm() $columns = $gridField->getConfig()->getComponentByType(GridFieldDataColumns::class); // Set up columns and sorting for list view GridField - $modelClass = $this->getModelClass(); $fields = [ 'getTreeTitle' => _t($modelClass . '.TREETITLE', 'Title'), 'i18n_singular_name' => _t($modelClass . '.TREETYPE', 'Record Type'), diff --git a/code/Controllers/CMSSiteTreeFilter.php b/code/Controllers/CMSSiteTreeFilter.php index ca77dbcba4..ca65c4bb26 100644 --- a/code/Controllers/CMSSiteTreeFilter.php +++ b/code/Controllers/CMSSiteTreeFilter.php @@ -2,77 +2,23 @@ namespace SilverStripe\CMS\Controllers; -use SilverStripe\Admin\LeftAndMain_SearchFilter; -use SilverStripe\CMS\Model\SiteTree; use SilverStripe\Core\ClassInfo; -use SilverStripe\Core\Convert; use SilverStripe\Core\Injector\Injectable; -use SilverStripe\Forms\DateField; use SilverStripe\ORM\DataList; -use SilverStripe\Model\List\SS_List; -use SilverStripe\Versioned\Versioned; /** - * Base class for filtering the subtree for certain node statuses. - * - * The simplest way of building a CMSSiteTreeFilter is to create a pagesToBeShown() method that - * returns an Iterator of maps, each entry containing the 'ID' and 'ParentID' of the pages to be - * included in the tree. The result of a DB::query() can then be returned directly. - * - * If you wish to make a more complex tree, you can overload includeInTree($page) to return true/ - * false depending on whether the given page should be included. Note that you will need to include - * parent helper pages yourself. + * Base class for filtering againt SiteTree for certain statuses (e.g. archived or draft only). */ -abstract class CMSSiteTreeFilter implements LeftAndMain_SearchFilter +abstract class CMSSiteTreeFilter { use Injectable; - /** - * Search parameters, mostly properties on {@link SiteTree}. - * Caution: Unescaped data. - * - * @var array - */ - protected $params = []; - - /** - * List of filtered items and all their parents - * - * @var array - */ - protected $_cache_ids = null; - - - /** - * Subset of $_cache_ids which include only items that appear directly in search results. - * When highlighting these, item IDs in this subset should be visually distinguished from - * others in the complete set. - * - * @var array - */ - protected $_cache_highlight_ids = null; - - /** - * @var array - */ - protected $_cache_expanded = []; - - /** - * @var string - */ - protected $childrenMethod = null; - - /** - * @var string - */ - protected $numChildrenMethod = 'numChildren'; - /** * Returns a sorted array of all implementators of CMSSiteTreeFilter, suitable for use in a dropdown. * - * @return array + * @return array */ - public static function get_all_filters() + public static function get_all_filters(): array { // get all filter instances $filters = ClassInfo::subclassesFor(CMSSiteTreeFilter::class); @@ -96,169 +42,13 @@ public static function get_all_filters() return $filterMap; } - public function __construct($params = null) - { - if ($params) { - $this->params = $params; - } - } - - public function getChildrenMethod() - { - return $this->childrenMethod; - } - - public function getNumChildrenMethod() - { - return $this->numChildrenMethod; - } - - public function getRecordClasses($page) - { - if ($this->_cache_ids === null) { - $this->populateIDs(); - } - - // If directly selected via filter, apply highlighting - if (!empty($this->_cache_highlight_ids[$page->ID])) { - return 'filtered-item'; - } - - return null; - } - - /** - * Gets the list of filtered pages - * - * @see {@link ModelData::getStatusFlags()} - * @return SS_List - */ - abstract public function getFilteredPages(); - /** - * @return array Map of Page IDs to their respective ParentID values. + * Get a title for this filter to display to the user (e.g. in a dropdown field). */ - public function pagesIncluded() - { - return $this->mapIDs($this->getFilteredPages()); - } + abstract public static function title(): string; /** - * Populate the IDs of the pages returned by pagesIncluded(), also including - * the necessary parent helper pages. + * Gets the list of filtered pages based on an existing list. */ - protected function populateIDs() - { - $parents = []; - $this->_cache_ids = []; - $this->_cache_highlight_ids = []; - - if ($pages = $this->pagesIncluded()) { - // And keep a record of parents we don't need to get - // parents of themselves, as well as IDs to mark - foreach ($pages as $pageArr) { - $parents[$pageArr['ParentID']] = true; - $this->_cache_ids[$pageArr['ID']] = true; - $this->_cache_highlight_ids[$pageArr['ID']] = true; - } - - while (!empty($parents)) { - $q = Versioned::get_including_deleted(SiteTree::class) - ->byIDs(array_keys($parents ?? [])); - $list = $q->map('ID', 'ParentID'); - $parents = []; - foreach ($list as $id => $parentID) { - if ($parentID) { - $parents[$parentID] = true; - } - $this->_cache_ids[$id] = true; - $this->_cache_expanded[$id] = true; - } - } - } - } - - public function isRecordIncluded($page) - { - if ($this->_cache_ids === null) { - $this->populateIDs(); - } - - return !empty($this->_cache_ids[$page->ID]); - } - - /** - * Applies the default filters to a specified DataList of pages - * - * @param DataList $query Unfiltered query - * @return DataList Filtered query - */ - protected function applyDefaultFilters($query) - { - $sng = SiteTree::singleton(); - foreach ($this->params as $name => $val) { - if (empty($val)) { - continue; - } - - switch ($name) { - case 'Term': - $query = $query->filterAny([ - 'URLSegment:PartialMatch' => Convert::raw2url($val), - 'Title:PartialMatch' => $val, - 'MenuTitle:PartialMatch' => $val, - 'Content:PartialMatch' => $val - ]); - break; - - case 'URLSegment': - $query = $query->filter([ - 'URLSegment:PartialMatch' => Convert::raw2url($val), - ]); - break; - - case 'LastEditedFrom': - $fromDate = new DateField(null, null, $val); - $query = $query->filter("LastEdited:GreaterThanOrEqual", $fromDate->dataValue().' 00:00:00'); - break; - - case 'LastEditedTo': - $toDate = new DateField(null, null, $val); - $query = $query->filter("LastEdited:LessThanOrEqual", $toDate->dataValue().' 23:59:59'); - break; - - case 'ClassName': - if ($val != 'All') { - $query = $query->filter('ClassName', $val); - } - break; - - default: - $field = $sng->dbObject($name); - if ($field) { - $filter = $field->defaultSearchFilter(); - $filter->setValue($val); - $query = $query->alterDataQuery([$filter, 'apply']); - } - } - } - return $query; - } - - /** - * Maps a list of pages to an array of associative arrays with ID and ParentID keys - * - * @param SS_List $pages - * @return array - */ - protected function mapIDs($pages) - { - $ids = []; - if ($pages) { - foreach ($pages as $page) { - $ids[] = ['ID' => $page->ID, 'ParentID' => $page->ParentID]; - } - } - return $ids; - } + abstract public function getFilteredPages(DataList $list): DataList; } diff --git a/code/Controllers/CMSSiteTreeFilter_ChangedPages.php b/code/Controllers/CMSSiteTreeFilter_ChangedPages.php index 5d180eccc9..4eca081a30 100644 --- a/code/Controllers/CMSSiteTreeFilter_ChangedPages.php +++ b/code/Controllers/CMSSiteTreeFilter_ChangedPages.php @@ -3,7 +3,7 @@ namespace SilverStripe\CMS\Controllers; use SilverStripe\CMS\Model\SiteTree; -use SilverStripe\ORM\DataObject; +use SilverStripe\ORM\DataList; use SilverStripe\Versioned\Versioned; /** @@ -11,20 +11,18 @@ */ class CMSSiteTreeFilter_ChangedPages extends CMSSiteTreeFilter { - - public static function title() + public static function title(): string { return _t(__CLASS__ . '.Title', "Modified pages"); } - public function getFilteredPages() + public function getFilteredPages(DataList $list): DataList { - $table = DataObject::singleton(SiteTree::class)->baseTable(); - $liveTable = DataObject::singleton(SiteTree::class)->stageTable($table, Versioned::LIVE); - $pages = Versioned::get_by_stage(SiteTree::class, Versioned::DRAFT); - $pages = $this->applyDefaultFilters($pages) - ->leftJoin($liveTable, "\"$liveTable\".\"ID\" = \"$table\".\"ID\"") + $table = SiteTree::singleton()->baseTable(); + $liveTable = SiteTree::singleton()->stageTable($table, Versioned::LIVE); + $list = Versioned::updateListToAlsoIncludeStage($list, Versioned::DRAFT); + $list = $list->leftJoin($liveTable, "\"$liveTable\".\"ID\" = \"$table\".\"ID\"") ->where("\"$table\".\"Version\" <> \"$liveTable\".\"Version\""); - return $pages; + return $list; } } diff --git a/code/Controllers/CMSSiteTreeFilter_DeletedPages.php b/code/Controllers/CMSSiteTreeFilter_DeletedPages.php index 926f2b749f..d04343b640 100644 --- a/code/Controllers/CMSSiteTreeFilter_DeletedPages.php +++ b/code/Controllers/CMSSiteTreeFilter_DeletedPages.php @@ -2,7 +2,7 @@ namespace SilverStripe\CMS\Controllers; -use SilverStripe\CMS\Model\SiteTree; +use SilverStripe\ORM\DataList; use SilverStripe\Versioned\Versioned; /** @@ -12,26 +12,13 @@ */ class CMSSiteTreeFilter_DeletedPages extends CMSSiteTreeFilter { - - /** - * @var string - */ - protected $childrenMethod = "AllHistoricalChildren"; - - /** - * @var string - */ - protected $numChildrenMethod = 'numHistoricalChildren'; - - public static function title() + public static function title(): string { return _t(__CLASS__ . '.Title', "All pages, including archived"); } - public function getFilteredPages() + public function getFilteredPages(DataList $list): DataList { - $pages = Versioned::get_including_deleted(SiteTree::class); - $pages = $this->applyDefaultFilters($pages); - return $pages; + return Versioned::updateListToAlsoIncludeDeleted($list); } } diff --git a/code/Controllers/CMSSiteTreeFilter_PublishedPages.php b/code/Controllers/CMSSiteTreeFilter_PublishedPages.php index 5c120aeb01..6ddccb8a3c 100644 --- a/code/Controllers/CMSSiteTreeFilter_PublishedPages.php +++ b/code/Controllers/CMSSiteTreeFilter_PublishedPages.php @@ -3,7 +3,7 @@ namespace SilverStripe\CMS\Controllers; use SilverStripe\CMS\Model\SiteTree; -use SilverStripe\Model\List\SS_List; +use SilverStripe\ORM\DataList; use SilverStripe\Versioned\Versioned; /** @@ -14,41 +14,22 @@ */ class CMSSiteTreeFilter_PublishedPages extends CMSSiteTreeFilter { - - /** - * @return string - */ - public static function title() + public static function title(): string { return _t(__CLASS__ . '.Title', "Published pages"); } - /** - * @var string - */ - protected $childrenMethod = "AllHistoricalChildren"; - - /** - * @var string - */ - protected $numChildrenMethod = 'numHistoricalChildren'; - /** * Filters out all pages who's status who's status that doesn't exist on live - * - * @see {@link ModelData::getStatusFlags()} - * @return SS_List */ - public function getFilteredPages() + public function getFilteredPages(DataList $list): DataList { - $pages = Versioned::get_including_deleted(SiteTree::class) - ->innerJoin( - 'SiteTree_Live', - '"SiteTree_Versions"."RecordID" = "SiteTree_Live"."ID"' - ); - - $pages = $this->applyDefaultFilters($pages); - - return $pages; + $list = Versioned::updateListToAlsoIncludeDeleted($list); + $baseTable = SiteTree::singleton()->baseTable(); + $liveTable = SiteTree::singleton()->stageTable($baseTable, Versioned::LIVE); + return $list->innerJoin( + $liveTable, + "\"{$baseTable}_Versions\".\"RecordID\" = \"$liveTable\".\"ID\"" + ); } } diff --git a/code/Controllers/CMSSiteTreeFilter_Search.php b/code/Controllers/CMSSiteTreeFilter_Search.php index 64ba79587f..a889a5e568 100644 --- a/code/Controllers/CMSSiteTreeFilter_Search.php +++ b/code/Controllers/CMSSiteTreeFilter_Search.php @@ -2,14 +2,12 @@ namespace SilverStripe\CMS\Controllers; -use SilverStripe\CMS\Model\SiteTree; -use SilverStripe\Model\List\SS_List; +use SilverStripe\ORM\DataList; use SilverStripe\Versioned\Versioned; class CMSSiteTreeFilter_Search extends CMSSiteTreeFilter { - - public static function title() + public static function title(): string { return _t(__CLASS__ . '.Title', "All pages"); } @@ -17,14 +15,9 @@ public static function title() /** * Retun an array of maps containing the keys, 'ID' and 'ParentID' for each page to be displayed * in the search. - * - * @return SS_List */ - public function getFilteredPages() + public function getFilteredPages(DataList $list): DataList { - // Filter default records - $pages = Versioned::get_by_stage(SiteTree::class, Versioned::DRAFT); - $pages = $this->applyDefaultFilters($pages); - return $pages; + return Versioned::updateListToAlsoIncludeStage($list, Versioned::DRAFT); } } diff --git a/code/Controllers/CMSSiteTreeFilter_StatusDeletedPages.php b/code/Controllers/CMSSiteTreeFilter_StatusDeletedPages.php index 9d117f4584..ddd357d84a 100644 --- a/code/Controllers/CMSSiteTreeFilter_StatusDeletedPages.php +++ b/code/Controllers/CMSSiteTreeFilter_StatusDeletedPages.php @@ -2,8 +2,7 @@ namespace SilverStripe\CMS\Controllers; -use SilverStripe\CMS\Model\SiteTree; -use SilverStripe\Model\List\SS_List; +use SilverStripe\ORM\DataList; use SilverStripe\Versioned\Versioned; /** @@ -11,31 +10,16 @@ */ class CMSSiteTreeFilter_StatusDeletedPages extends CMSSiteTreeFilter { - - /** - * @var string - */ - protected $childrenMethod = "AllHistoricalChildren"; - - /** - * @var string - */ - protected $numChildrenMethod = 'numHistoricalChildren'; - - public static function title() + public static function title(): string { return _t(__CLASS__ . '.Title', 'Archived pages'); } /** * Filters out all pages who's status is set to "Deleted". - * - * @see {@link ModelData::getStatusFlags()} - * @return SS_List */ - public function getFilteredPages() + public function getFilteredPages(DataList $list): DataList { - $pages = Versioned::getArchivedOnly(SiteTree::class); - return $this->applyDefaultFilters($pages); + return Versioned::updateListToOnlyIncludeArchived($list); } } diff --git a/code/Controllers/CMSSiteTreeFilter_StatusDraftPages.php b/code/Controllers/CMSSiteTreeFilter_StatusDraftPages.php index 75a7e43818..4b3354c5ab 100644 --- a/code/Controllers/CMSSiteTreeFilter_StatusDraftPages.php +++ b/code/Controllers/CMSSiteTreeFilter_StatusDraftPages.php @@ -2,8 +2,7 @@ namespace SilverStripe\CMS\Controllers; -use SilverStripe\CMS\Model\SiteTree; -use SilverStripe\Model\List\SS_List; +use SilverStripe\ORM\DataList; use SilverStripe\Versioned\Versioned; /** @@ -11,28 +10,22 @@ */ class CMSSiteTreeFilter_StatusDraftPages extends CMSSiteTreeFilter { - - public static function title() + public static function title(): string { return _t(__CLASS__ . '.Title', 'Draft pages'); } /** * Filters out all pages who's status is set to "Draft". - * - * @see {@link ModelData::getStatusFlags()} - * @return SS_List */ - public function getFilteredPages() + public function getFilteredPages(DataList $list): DataList { - $pages = SiteTree::get(); // Get all pages existing in draft but not live // Don't just use withVersionedMode - that would just get the latest draft versions // including records which have since been published. - $pages = $pages->setDataQueryParam([ + return $list->setDataQueryParam([ 'Versioned.mode' => 'stage_unique', 'Versioned.stage' => Versioned::DRAFT, ]); - return $this->applyDefaultFilters($pages); } } diff --git a/code/Controllers/CMSSiteTreeFilter_StatusRemovedFromDraftPages.php b/code/Controllers/CMSSiteTreeFilter_StatusRemovedFromDraftPages.php index b3b407e536..cbb2298725 100644 --- a/code/Controllers/CMSSiteTreeFilter_StatusRemovedFromDraftPages.php +++ b/code/Controllers/CMSSiteTreeFilter_StatusRemovedFromDraftPages.php @@ -2,8 +2,7 @@ namespace SilverStripe\CMS\Controllers; -use SilverStripe\CMS\Model\SiteTree; -use SilverStripe\Model\List\SS_List; +use SilverStripe\ORM\DataList; use SilverStripe\Versioned\Versioned; /** @@ -11,27 +10,22 @@ */ class CMSSiteTreeFilter_StatusRemovedFromDraftPages extends CMSSiteTreeFilter { - - public static function title() + public static function title(): string { return _t(__CLASS__ . '.Title', 'Live but removed from draft'); } /** * Filters out all pages who's status is set to "Removed from draft". - * - * @return SS_List */ - public function getFilteredPages() + public function getFilteredPages(DataList $list): DataList { - $pages = SiteTree::get(); // Get all pages removed from stage but not live // Don't just use withVersionedMode - that would just get the latest live versions // including records which were not removed from draft. - $pages = $pages->setDataQueryParam([ + return $list->setDataQueryParam([ 'Versioned.mode' => 'stage_unique', 'Versioned.stage' => Versioned::LIVE, ]); - return $this->applyDefaultFilters($pages); } } diff --git a/code/Model/SiteTree.php b/code/Model/SiteTree.php index 7a4678dee8..15950fa8e1 100755 --- a/code/Model/SiteTree.php +++ b/code/Model/SiteTree.php @@ -7,10 +7,13 @@ use SilverStripe\Admin\CMSEditLinkExtension; use SilverStripe\Assets\Shortcodes\FileLinkTracking; use SilverStripe\CMS\Controllers\CMSMain; +use SilverStripe\CMS\Controllers\CMSSiteTreeFilter; +use SilverStripe\CMS\Controllers\CMSSiteTreeFilter_Search; use SilverStripe\CMS\Controllers\ContentController; use SilverStripe\CMS\Controllers\ModelAsController; use SilverStripe\CMS\Controllers\RootURLController; use SilverStripe\CMS\Forms\SiteTreeURLSegmentField; +use SilverStripe\CMS\Search\SiteTreeSearchContext; use SilverStripe\Control\ContentNegotiator; use SilverStripe\Control\Controller; use SilverStripe\Control\Director; @@ -50,6 +53,8 @@ use SilverStripe\ORM\HiddenClass; use SilverStripe\ORM\Hierarchy\Hierarchy; use SilverStripe\Core\Validation\ValidationResult; +use SilverStripe\Forms\DateField; +use SilverStripe\Forms\EmailField; use SilverStripe\Forms\HiddenField; use SilverStripe\Security\Group; use SilverStripe\Security\InheritedPermissions; @@ -64,6 +69,7 @@ use SilverStripe\Versioned\RecursivePublishable; use SilverStripe\Versioned\Versioned; use SilverStripe\Model\ArrayData; +use SilverStripe\ORM\Filters\WithinRangeFilter; use SilverStripe\Security\PermissionCheckable; use SilverStripe\View\HTML; use SilverStripe\View\Parsers\HTMLValue; @@ -289,8 +295,29 @@ class SiteTree extends DataObject implements PermissionProvider, i18nEntityProvi ]; private static $searchable_fields = [ - 'Title', - 'Content', + // See also searchableFields() which adds `FilterClass` + 'ClassName' => [ + 'general' => false, + ], + 'LastEdited' => [ + 'general' => false, + 'filter' => WithinRangeFilter::class, + 'field' => DateField::class, + ], + // The below fields are excluded from the filter form but + // are included in the general search + 'Title' => [ + 'field' => HiddenField::class, + ], + 'URLSegment' => [ + 'field' => HiddenField::class, + ], + 'MenuTitle' => [ + 'field' => HiddenField::class, + ], + 'Content' => [ + 'field' => HiddenField::class, + ], ]; private static $field_labels = [ @@ -2844,6 +2871,52 @@ public function isHomePage(): bool return $this->URLSegment === RootURLController::get_homepage_link(); } + public function getDefaultSearchContext() + { + return SiteTreeSearchContext::create( + static::class, + $this->scaffoldSearchFields(), + $this->defaultSearchFilters() + ); + } + + public function searchableFields() + { + $fields = parent::searchableFields(); + // Push `FilterClass` to the top of the fields list + $fields = array_merge([ + 'FilterClass' => [ + 'filter' => '', + 'general' => false, + 'title' => _t(__CLASS__ . '.Filter_Status', 'Page status'), + 'field' => DropdownField::class, + ] + ], $fields); + return $fields; + } + + public function scaffoldSearchFields($_params = null) + { + $fields = parent::scaffoldSearchFields($_params); + + // Update "Status" sources + $filters = CMSSiteTreeFilter::get_all_filters(); + // Remove 'All records' as we set that to empty/default value + unset($filters[CMSSiteTreeFilter_Search::class]); + $fields->dataFieldByName('FilterClass')?->setSource($filters)->setEmptyString(CMSSiteTreeFilter_Search::singleton()->title()); + + // Update "Page type" sources + $classNames = []; + $validClasses = ClassInfo::getValidSubClasses(SiteTree::class); + $this->invokeWithExtensions('updateAllowedSubClasses', $validClasses); + foreach ($validClasses as $class) { + $classNames[$class] = SiteTree::singleton($class)->i18n_singular_name(); + } + $fields->dataFieldByName('ClassName')?->setSource($classNames)->setEmptyString(_t(__CLASS__ . '.Filter_ClassName_Any', 'Any')); + + return $fields; + } + /** * Updates the list of status updates sent in response to the CMSMain::savetreenode action */ diff --git a/code/Search/SiteTreeSearchContext.php b/code/Search/SiteTreeSearchContext.php new file mode 100644 index 0000000000..efca8a5ff4 --- /dev/null +++ b/code/Search/SiteTreeSearchContext.php @@ -0,0 +1,49 @@ +setSearchParams($origSearchParams); + return $query; + } + + protected function individualFieldSearch(DataList $query, array $searchableFields, string $searchField, $searchPhrase): DataList + { + if ($searchField === 'FilterClass') { + $filter = $this->getQueryFilter($searchPhrase); + return $filter->getFilteredPages($query); + } + + return parent::individualFieldSearch($query, $searchableFields, $searchField, $searchPhrase); + } + + private function getQueryFilter($filterClass): CMSSiteTreeFilter + { + if (!is_subclass_of($filterClass, CMSSiteTreeFilter::class)) { + throw new InvalidArgumentException("Invalid filter class passed: {$filterClass}"); + } + return $filterClass::create(); + } +} diff --git a/templates/SilverStripe/CMS/Controllers/Includes/CMSMain_Filter.ss b/templates/SilverStripe/CMS/Controllers/Includes/CMSMain_Filter.ss deleted file mode 100644 index 7564e527a6..0000000000 --- a/templates/SilverStripe/CMS/Controllers/Includes/CMSMain_Filter.ss +++ /dev/null @@ -1,6 +0,0 @@ -
- <% if not $TreeIsFiltered %> - - <% end_if %> -
- diff --git a/templates/SilverStripe/CMS/Controllers/Includes/CMSMain_LeftPanel.ss b/templates/SilverStripe/CMS/Controllers/Includes/CMSMain_LeftPanel.ss index 12e1feba9b..e1e5c56b58 100644 --- a/templates/SilverStripe/CMS/Controllers/Includes/CMSMain_LeftPanel.ss +++ b/templates/SilverStripe/CMS/Controllers/Includes/CMSMain_LeftPanel.ss @@ -12,13 +12,11 @@ <%-- Full breadcrumbs (useful for tree view which isn't available when viewing an edit form) --%> <% include SilverStripe\\Admin\\CMSBreadcrumbs %> <% end_if %> - <% include SilverStripe\\CMS\\Controllers\\CMSMain_Filter %> + $SearchForm.FilterButton($TreeIsFiltered)
-
-
-
+ $SearchForm.Placeholder($TreeIsFiltered) $RecordList
diff --git a/tests/behat/features/manage-page-permissions.feature b/tests/behat/features/manage-page-permissions.feature index c18501fe60..c038699c96 100644 --- a/tests/behat/features/manage-page-permissions.feature +++ b/tests/behat/features/manage-page-permissions.feature @@ -14,13 +14,12 @@ Feature: Manage page permissions Then I should see an edit page form And I click the "Settings" CMS tab - # BUG: https://github.com/silverstripe/silverstripe-cms/issues/1897 - # Scenario: I can open view permissions to everyone - # Given I select "Anyone" from "Who can view this page?" input group - # And I press the "Save" button - # When I am not logged in - # And I go to the homepage - # Then I should see "Welcome" + Scenario: I can open view permissions to everyone + Given I select "Anyone" from "Who can view this page?" input group + And I press the "Save" button + When I am not logged in + And I go to the homepage + Then I should see "Welcome" Scenario: I can limit page view permissions to logged-in users Given I select "Logged-in users" from "Who can view this page?" input group And I press the "Publish" button diff --git a/tests/behat/features/search-for-a-page.feature b/tests/behat/features/search-for-a-page.feature index e94fc79f1e..00c58098d6 100644 --- a/tests/behat/features/search-for-a-page.feature +++ b/tests/behat/features/search-for-a-page.feature @@ -11,11 +11,11 @@ Feature: Search for a page And the "group" "EDITOR" has permissions "Access to 'Pages' section" And I am logged in as a member of "EDITOR" group And I go to "/admin/pages" - And I press the "Filter" button + And I press the "Open search and filter" button Scenario: I can search for a page by its title Given I fill in "Search" with "About Us" - And I press the "Enter" key in the "SearchBox__Term" field + And I press the "Enter" key in the "SearchBox__q" field Then I should see "About Us" in the cms list But I should not see "Contact Us" in the cms list @@ -23,35 +23,34 @@ Feature: Search for a page Given a "Virtual Page" "My Virtual Page" When I press the "Advanced" button And I select "Virtual Page" from "Page type" - And I press the "Enter" key in the "SearchBox__Term" field + And I press the "Enter" key in the "SearchBox__q" field Then I should see "My Virtual Page" in the cms list But I should not see "Contact Us" in the cms list - # BUG: https://github.com/silverstripe/silverstripe-admin/issues/631 - # Scenario: I can search for a page by its oldest last edited date - # Given a "page" "Recent Page" - # And a "page" "Old Page" was last edited "7 days ago" - # When I press the "Advanced" button - # And I fill in "From" with "the date of 5 days ago" - # And I press the "Search" button - # Then I should see "Recent Page" in the cms list - # But I should not see "Old Page" in the cms list + Scenario: I can search for a page by its oldest last edited date + Given a "page" "Recent Page" was last edited "08-08-2007" + And a "page" "Old Page" was last edited "03-03-2007" + And I take a screenshot after every step + When I press the "Advanced" button + And I fill in "Search__LastEdited_SearchFrom" with "06-06-2007" + And I press the "Search" button + Then I should see "Recent Page" in the cms list + But I should not see "Old Page" in the cms list - # BUG: https://github.com/silverstripe/silverstripe-admin/issues/631 - # Scenario: I can search for a page by its newest last edited date - # Given a "page" "Recent Page" - # And a "page" "Old Page" was last edited "7 days ago" - # When I press the "Advanced" button - # And I fill in "To" with "the date of 5 days ago" - # And I press the "Search" button - # Then I should not see "Recent Page" in the cms list - # But I should see "Old Page" in the cms list + Scenario: I can search for a page by its newest last edited date + Given a "page" "Recent Page" was last edited "08-08-2007" + And a "page" "Old Page" was last edited "03-03-2007" + When I press the "Advanced" button + And I fill in "Search__LastEdited_SearchTo" with "06-06-2007" + And I press the "Search" button + Then I should not see "Recent Page" in the cms list + But I should see "Old Page" in the cms list Scenario: I can include deleted pages in my search Given a "page" "Deleted Page" And the "page" "Deleted Page" is unpublished And the "page" "Deleted Page" is deleted - When I press the "Enter" key in the "SearchBox__Term" field + When I press the "Enter" key in the "SearchBox__q" field Then I should not see "Deleted Page" in the cms list When I press the "Advanced" button And I select "All pages, including archived" from "Page status" @@ -62,7 +61,7 @@ Feature: Search for a page Given a "page" "Deleted Page" And the "page" "Deleted Page" is unpublished And the "page" "Deleted Page" is deleted - When I press the "Enter" key in the "SearchBox__Term" field + When I press the "Enter" key in the "SearchBox__q" field Then I should not see "Deleted Page" in the cms list When I press the "Advanced" button And I select "Archived pages" from "Page status" @@ -73,7 +72,7 @@ Feature: Search for a page Scenario: I can include draft pages in my search Given a "page" "Draft Page" And the "page" "Draft Page" is not published - When I press the "Enter" key in the "SearchBox__Term" field + When I press the "Enter" key in the "SearchBox__q" field Then I should see "Draft Page" in the cms list When I press the "Advanced" button And I select "Draft pages" from "Page status" @@ -90,7 +89,7 @@ Feature: Search for a page Then I should see "Saved" in the "button#Form_EditForm_action_save" element When I go to "/admin/pages" - And I press the "Filter" button + And I press the "Open search and filter" button And I press the "Advanced" button And I select "Modified pages" from "Page status" And I press the "Search" button @@ -101,7 +100,7 @@ Feature: Search for a page Given a "page" "Live Page" And the "page" "Live Page" is published And the "page" "Live Page" is deleted - When I press the "Enter" key in the "SearchBox__Term" field + When I press the "Enter" key in the "SearchBox__q" field Then I should not see "Live Page" in the cms list When I press the "Advanced" button And I select "Live but removed from draft" from "Page status" diff --git a/tests/php/Controllers/CMSMainTest.php b/tests/php/Controllers/CMSMainTest.php index 0312f74e37..17289009c8 100644 --- a/tests/php/Controllers/CMSMainTest.php +++ b/tests/php/Controllers/CMSMainTest.php @@ -6,8 +6,13 @@ use PHPUnit\Framework\Attributes\DataProvider; use Psr\SimpleCache\CacheInterface; use ReflectionMethod; +use ReflectionProperty; use SilverStripe\Admin\CMSBatchActionHandler; use SilverStripe\CMS\Controllers\CMSMain; +use SilverStripe\CMS\Controllers\CMSSiteTreeFilter_PublishedPages; +use SilverStripe\CMS\Controllers\CMSSiteTreeFilter_Search; +use SilverStripe\CMS\Controllers\CMSSiteTreeFilter_StatusDeletedPages; +use SilverStripe\CMS\Controllers\CMSSiteTreeFilter_StatusRemovedFromDraftPages; use SilverStripe\CMS\Model\RedirectorPage; use SilverStripe\CMS\Model\SiteTree; use SilverStripe\CMS\Tests\Controllers\CMSMainTest\TestHierarchicalDataObject; @@ -24,6 +29,7 @@ use SilverStripe\Dev\FunctionalTest; use SilverStripe\Dev\TestOnly; use SilverStripe\Forms\FieldList; +use SilverStripe\ORM\DataList; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DB; use SilverStripe\Security\Member; @@ -541,24 +547,65 @@ public function testGetNewItem() } /** - * Tests filtering in {@see CMSMain::getList()} + * Note this is not intended to be exhaustive - we're just validating + * that the search filter stuff is generally applied to ListViewForm. + * More robust testing of the filtering functionality is done separately. */ - public function testGetList() + public static function provideListViewForm(): array { - $controller = CMSMain::create(); - $controller->setRequest(Controller::curr()->getRequest()); + return [ + 'include all pages' => [ + 'params' => [], + 'limit' => 5, + 'expectedPreLimitCount' => 26, // Note this explicitly excludes records with parents! + 'expectedTitles' => ['Home', 'Page 10', 'Page 11', 'Page 13', 'Page 14'], + ], + 'filter by terms' => [ + 'params' => ['q' => 'Page 4'], + 'limit' => null, + 'expectedPreLimitCount' => 3, + 'expectedTitles' => ['Page 14', 'Page 24', 'Page 4'], + ], + 'deleted pages only' => [ + 'params' => [ + 'FilterClass' => CMSSiteTreeFilter_StatusDeletedPages::class, + ], + 'limit' => null, + 'expectedPreLimitCount' => 1, + 'expectedTitles' => ['Page 1'], + ], + 'pages removed from draft only' => [ + 'params' => [ + 'FilterClass' => CMSSiteTreeFilter_StatusRemovedFromDraftPages::class, + ], + 'limit' => null, + 'expectedPreLimitCount' => 1, + 'expectedTitles' => ['Page 12'], + ], + 'published pages only' => [ + 'params' => [ + 'FilterClass' => CMSSiteTreeFilter_PublishedPages::class, + ], + 'limit' => null, + 'expectedPreLimitCount' => 2, + 'expectedTitles' => ['Page 11', 'Page 12'], + ], + ]; + } - // Test all pages (stage) - $pages = $controller->getList()->sort('Title'); - $this->assertEquals(28, $pages->count()); - $this->assertEquals( - ['Home', 'Page 1', 'Page 10', 'Page 11', 'Page 12'], - $pages->Limit(5)->column('Title') - ); + #[DataProvider('provideListViewForm')] + public function testListViewForm(array $params, ?int $limit, int $expectedPreLimitCount, array $expectedTitles): void + { + $request = Controller::curr()->getRequest(); + $requestVarsReflection = new ReflectionProperty($request, 'getVars'); + $requestVarsReflection->setValue($request, ['q' => $params]); + $controller = CMSMain::create()->setRequest($request); + + /** @var DataList $pages */ + $pages = $controller->ListViewForm()->Fields()->dataFieldByName('Record')->getList(); // Change state of tree $page1 = $this->objFromFixture(SiteTree::class, 'page1'); - $page3 = $this->objFromFixture(SiteTree::class, 'page3'); $page11 = $this->objFromFixture(SiteTree::class, 'page11'); $page12 = $this->objFromFixture(SiteTree::class, 'page12'); // Deleted @@ -570,62 +617,48 @@ public function testGetList() $page12->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); $page12->delete(); - // Re-test all pages (stage) - $pages = $controller->getList()->sort('Title'); - $this->assertEquals(26, $pages->count()); - $this->assertEquals( - ['Home', 'Page 10', 'Page 11', 'Page 13', 'Page 14'], - $pages->Limit(5)->column('Title') - ); + $this->assertSame($expectedTitles, $pages->sort('Title')->limit($limit)->column('Title')); + $this->assertCount($expectedPreLimitCount, $pages); + } - // Test deleted page filter - $params = [ - 'FilterClass' => 'SilverStripe\\CMS\\Controllers\\CMSSiteTreeFilter_StatusDeletedPages', + public static function provideListViewFormParentID(): array + { + return [ + [ + 'includeFilter' => true, + ], + [ + 'includeFilter' => false, + ], ]; - $pages = $controller->getList($params); - $this->assertEquals(1, $pages->count()); - $this->assertEquals( - ['Page 1'], - $pages->column('Title') - ); + } - // Test live, but not on draft filter - $params = [ - 'FilterClass' => 'SilverStripe\\CMS\\Controllers\\CMSSiteTreeFilter_StatusRemovedFromDraftPages', - ]; - $pages = $controller->getList($params); - $this->assertEquals(1, $pages->count()); - $this->assertEquals( - ['Page 12'], - $pages->column('Title') - ); + #[DataProvider('provideListViewFormParentID')] + public function testListViewFormParentID(bool $includeFilter): void + { + $page3 = $this->objFromFixture(SiteTree::class, 'page3'); + $page11 = $this->objFromFixture(SiteTree::class, 'page11'); + $page12 = $this->objFromFixture(SiteTree::class, 'page12'); + $page11->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); + $page12->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); - // Test live pages filter - $params = [ - 'FilterClass' => 'SilverStripe\\CMS\\Controllers\\CMSSiteTreeFilter_PublishedPages', - ]; - $pages = $controller->getList($params); - $this->assertEquals(2, $pages->count()); - $this->assertEquals( - ['Page 11', 'Page 12'], - $pages->column('Title') - ); + $request = Controller::curr()->getRequest(); + $params = ['ParentID' => $page3->ID]; + if ($includeFilter) { + $params['q'] = ['FilterClass' => CMSSiteTreeFilter_PublishedPages::class]; + } + $requestVarsReflection = new ReflectionProperty($request, 'getVars'); + $requestVarsReflection->setValue($request, $params); + $controller = CMSMain::create()->setRequest($request); - // Test that parentID is ignored when filtering - $pages = $controller->getList($params, $page3->ID); - $this->assertEquals(2, $pages->count()); - $this->assertEquals( - ['Page 11', 'Page 12'], - $pages->column('Title') - ); + /** @var DataList $pages */ + $pages = $controller->ListViewForm()->Fields()->dataFieldByName('Record')->getList(); - // Test that parentID is respected when not filtering - $pages = $controller->getList([], $page3->ID); - $this->assertEquals(2, $pages->count()); - $this->assertEquals( - ['Page 3.1', 'Page 3.2'], - $pages->column('Title') - ); + if ($includeFilter) { + $this->assertSame(['Page 11', 'Page 12'], $pages->column('Title')); + } else { + $this->assertSame(['Page 3.1', 'Page 3.2'], $pages->column('Title')); + } } /** @@ -730,46 +763,6 @@ public function testTreeHintsCache() $this->assertNotNull($hints); } - public function testSearchField() - { - $cms = CMSMain::create(); - $searchSchema = $cms->getSearchFieldSchema(); - - $this->assertJsonStringEqualsJsonString( - json_encode([ - 'formSchemaUrl' => 'admin/pages/schema/SearchForm', - 'name' => 'Term', - 'placeholder' => 'Search "Pages"', - 'filters' => new \stdClass - ]), - $searchSchema - ); - - $request = new HTTPRequest( - 'GET', - 'admin/pages/schema/SearchForm', - ['q' => [ - 'Term' => 'test', - 'FilterClass' => 'SilverStripe\CMS\Controllers\CMSSiteTreeFilter_Search' - ]] - ); - $cms->setRequest($request); - $searchSchema = $cms->getSearchFieldSchema(); - - $this->assertJsonStringEqualsJsonString( - json_encode([ - 'formSchemaUrl' => 'admin/pages/schema/SearchForm', - 'name' => 'Term', - 'placeholder' => 'Search "Pages"', - 'filters' => [ - 'Search__Term' => 'test', - 'Search__FilterClass' => 'SilverStripe\CMS\Controllers\CMSSiteTreeFilter_Search' - ] - ]), - $searchSchema - ); - } - public function testCanOrganiseTree() { $cms = CMSMain::create(); diff --git a/tests/php/Controllers/CMSSiteTreeFilterTest.php b/tests/php/Controllers/CMSSiteTreeFilterTest.php index fad67818e2..5dce12bc3d 100644 --- a/tests/php/Controllers/CMSSiteTreeFilterTest.php +++ b/tests/php/Controllers/CMSSiteTreeFilterTest.php @@ -16,61 +16,21 @@ class CMSSiteTreeFilterTest extends SapphireTest { protected static $fixture_file = 'CMSSiteTreeFilterTest.yml'; - public function testSearchFilterEmpty() + public function testSearchFilterDefault() { - $page1 = $this->objFromFixture(SiteTree::class, 'page1'); + $page1ID = $this->idFromFixture(SiteTree::class, 'page1'); $page2 = $this->objFromFixture(SiteTree::class, 'page2'); + $page3ID = $this->idFromFixture(SiteTree::class, 'page3'); - $f = new CMSSiteTreeFilter_Search(); - $results = $f->pagesIncluded(); - - $this->assertTrue($f->isRecordIncluded($page1)); - $this->assertTrue($f->isRecordIncluded($page2)); - } - - public function testSearchFilterByTitle() - { - $page1 = $this->objFromFixture(SiteTree::class, 'page1'); - $page2 = $this->objFromFixture(SiteTree::class, 'page2'); + $page2->delete(); - $f = new CMSSiteTreeFilter_Search(['Title' => 'Page 1']); - $results = $f->pagesIncluded(); - - $this->assertTrue($f->isRecordIncluded($page1)); - $this->assertFalse($f->isRecordIncluded($page2)); - $this->assertEquals(1, count($results ?? [])); - $this->assertEquals( - ['ID' => $page1->ID, 'ParentID' => 0], - $results[0] - ); - } - - public function testUrlSegmentFilter() - { - $page = $this->objFromFixture(SiteTree::class, 'page8'); - - $filter = CMSSiteTreeFilter_Search::create(['Term' => 'lake-wanaka+adventure']); - $this->assertTrue($filter->isRecordIncluded($page)); - - $filter = CMSSiteTreeFilter_Search::create(['URLSegment' => 'lake-wanaka+adventure']); - $this->assertTrue($filter->isRecordIncluded($page)); - } + $f = new CMSSiteTreeFilter_Search(); + $results = $f->getFilteredPages(SiteTree::get()); - public function testIncludesParentsForNestedMatches() - { - $parent = $this->objFromFixture(SiteTree::class, 'page3'); - $child = $this->objFromFixture(SiteTree::class, 'page3b'); - - $f = new CMSSiteTreeFilter_Search(['Title' => 'Page 3b']); - $results = $f->pagesIncluded(); - - $this->assertTrue($f->isRecordIncluded($parent)); - $this->assertTrue($f->isRecordIncluded($child)); - $this->assertEquals(1, count($results ?? [])); - $this->assertEquals( - ['ID' => $child->ID, 'ParentID' => $parent->ID], - $results[0] - ); + $this->assertTrue(in_array($page1ID, $results->column('ID'))); + $this->assertTrue(in_array($page3ID, $results->column('ID'))); + // Deleted page is not included + $this->assertFalse(in_array($page2->ID, $results->column('ID'))); } public function testChangedPagesFilter() @@ -88,33 +48,23 @@ public function testChangedPagesFilter() $changedPageVersion = $changedPage->Version; // Check that only changed pages are returned - $f = new CMSSiteTreeFilter_ChangedPages(['Term' => 'Changed']); - $results = $f->pagesIncluded(); - - $this->assertTrue($f->isRecordIncluded($changedPage)); - $this->assertFalse($f->isRecordIncluded($unchangedPage)); - $this->assertEquals(1, count($results ?? [])); - $this->assertEquals( - ['ID' => $changedPage->ID, 'ParentID' => 0], - $results[0] - ); + $f = new CMSSiteTreeFilter_ChangedPages(); + $results = $f->getFilteredPages(SiteTree::get()); - // Check that only changed pages are returned - $f = new CMSSiteTreeFilter_ChangedPages(['Term' => 'No Matches']); - $results = $f->pagesIncluded(); - $this->assertEquals(0, count($results ?? [])); + $this->assertSame([$changedPage->ID], $results->column('ID')); + $this->assertSame('Changed', $results->first()->Title); - // If we roll back to an earlier version than what's on the published site, we should still show the changed + // If we roll back to an earlier version than what's on the published site, we should show the currently "modified" version $changedPage->Title = 'Changed 2'; $changedPage->write(); $changedPage->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); $changedPage->rollbackRecursive($changedPageVersion); - $f = new CMSSiteTreeFilter_ChangedPages(['Term' => 'Changed']); - $results = $f->pagesIncluded(); + $f = new CMSSiteTreeFilter_ChangedPages(); + $results = $f->getFilteredPages(SiteTree::get()); - $this->assertEquals(1, count($results ?? [])); - $this->assertEquals(['ID' => $changedPage->ID, 'ParentID' => 0], $results[0]); + $this->assertSame([$changedPage->ID], $results->column('ID')); + $this->assertSame('Changed', $results->first()->Title); } public function testDeletedPagesFilter() @@ -129,95 +79,63 @@ public function testDeletedPagesFilter() ['"SiteTree_Live"."ID"' => $deletedPageID] ); - $f = new CMSSiteTreeFilter_DeletedPages(['Term' => 'Page']); - $this->assertTrue($f->isRecordIncluded($deletedPage)); - - // Check that only changed pages are returned - $f = new CMSSiteTreeFilter_DeletedPages(['Term' => 'No Matches']); - $this->assertFalse($f->isRecordIncluded($deletedPage)); + $f = new CMSSiteTreeFilter_DeletedPages(); + $results = $f->getFilteredPages(SiteTree::get()); + // Check this page is included even though it was deleted + $this->assertTrue(in_array($deletedPageID, $results->column('ID'))); } public function testStatusDraftPagesFilter() { $draftPage = $this->objFromFixture(SiteTree::class, 'page4'); - $draftPage = Versioned::get_one_by_stage( - SiteTree::class, - 'Stage', - sprintf('"SiteTree"."ID" = %d', $draftPage->ID) - ); // Check draft page is shown - $f = new CMSSiteTreeFilter_StatusDraftPages(['Term' => 'Page']); - $this->assertTrue($f->isRecordIncluded($draftPage)); - - // Check filter respects parameters - $f = new CMSSiteTreeFilter_StatusDraftPages(['Term' => 'No Match']); - $this->assertFalse($f->isRecordIncluded($draftPage)); - - // Ensures empty array returned if no data to show $f = new CMSSiteTreeFilter_StatusDraftPages(); - $draftPage->delete(); - $this->assertFalse($f->isRecordIncluded($draftPage)); - } - - public function testDateFromToLastSameDate() - { - $draftPage = $this->objFromFixture(SiteTree::class, 'page4'); - // Grab the date - $date = substr($draftPage->LastEdited ?? '', 0, 10); - // Filter with that date - $filter = new CMSSiteTreeFilter_Search([ - 'LastEditedFrom' => $date, - 'LastEditedTo' => $date, - ]); - $this->assertTrue( - $filter->isRecordIncluded($draftPage), - 'Using the same date for from and to should show find that page' - ); + $results = $f->getFilteredPages(SiteTree::get()); + $this->assertTrue(in_array($draftPage->ID, $results->column('ID'))); + + // Published and modified pages not shown + $draftPage->publishSingle(); + $results = $f->getFilteredPages(SiteTree::get()); + $this->assertFalse(in_array($draftPage->ID, $results->column('ID'))); + $draftPage->Title = 'modified'; + $draftPage->write(); + $results = $f->getFilteredPages(SiteTree::get()); + $this->assertFalse(in_array($draftPage->ID, $results->column('ID'))); } public function testStatusRemovedFromDraftFilter() { - $removedDraftPage = $this->objFromFixture(SiteTree::class, 'page6'); - $removedDraftPage->publishRecursive(); - $removedDraftPage->deleteFromStage('Stage'); - $removedDraftPage = Versioned::get_one_by_stage( - SiteTree::class, - 'Live', - sprintf('"SiteTree"."ID" = %d', $removedDraftPage->ID) - ); - - // Check live-only page is included - $f = new CMSSiteTreeFilter_StatusRemovedFromDraftPages(['LastEditedFrom' => '2000-01-01 00:00']); - $this->assertTrue($f->isRecordIncluded($removedDraftPage)); + $publishedPage = $this->objFromFixture(SiteTree::class, 'page3'); + $publishedPage->publishSingle(); + $archivePage = $this->objFromFixture(SiteTree::class, 'page1'); + $archivePage->doArchive(); - // Check filter is respected - $f = new CMSSiteTreeFilter_StatusRemovedFromDraftPages(['LastEditedTo' => '1999-01-01 00:00']); - $this->assertFalse($f->isRecordIncluded($removedDraftPage)); - - // Ensures empty array returned if no data to show + // Draft, published, and archive pages not included $f = new CMSSiteTreeFilter_StatusRemovedFromDraftPages(); - $removedDraftPage->delete(); - $this->assertFalse($f->isRecordIncluded($removedDraftPage)); + $results = $f->getFilteredPages(SiteTree::get()); + $this->assertSame([], $results->column('ID')); + + // Page is included when draft gets deleted + $publishedPage->deleteFromStage('Stage'); + $results = $f->getFilteredPages(SiteTree::get()); + $this->assertSame([$publishedPage->ID], $results->column('ID')); } public function testStatusDeletedFilter() { - $deletedPage = $this->objFromFixture(SiteTree::class, 'page7'); - $deletedPage->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); - $deletedPageID = $deletedPage->ID; - - // Can't use straight $blah->delete() as that blows it away completely and test fails - $deletedPage->deleteFromStage(Versioned::LIVE); - $deletedPage->deleteFromStage(Versioned::DRAFT); - $checkParentExists = Versioned::get_latest_version(SiteTree::class, $deletedPageID); - - // Check deleted page is included + $publishedPage = $this->objFromFixture(SiteTree::class, 'page4'); + $publishedPage->publishSingle(); + $notInDraftPage = $this->objFromFixture(SiteTree::class, 'page5'); + $notInDraftPage->publishSingle(); + $notInDraftPage->deleteFromStage('Stage'); + $archivedPage = $this->objFromFixture(SiteTree::class, 'page6'); + $archivedPage->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); + $archivedPage->doArchive(); + + // Check ONLY the archived page is included $f = new CMSSiteTreeFilter_StatusDeletedPages(['Title' => 'Page']); - $this->assertTrue($f->isRecordIncluded($checkParentExists)); - - // Check filter is respected - $f = new CMSSiteTreeFilter_StatusDeletedPages(['Title' => 'Bobby']); - $this->assertFalse($f->isRecordIncluded($checkParentExists)); + $results = $f->getFilteredPages(SiteTree::get()); + $this->assertSame([$archivedPage->ID], $results->column('ID')); } } diff --git a/tests/php/Search/SiteTreeSearchContextTest.php b/tests/php/Search/SiteTreeSearchContextTest.php new file mode 100644 index 0000000000..68db447913 --- /dev/null +++ b/tests/php/Search/SiteTreeSearchContextTest.php @@ -0,0 +1,77 @@ +idFromFixture(SiteTree::class, 'page1'); + $page2 = $this->objFromFixture(SiteTree::class, 'page2'); + $page3ID = $this->idFromFixture(SiteTree::class, 'page3'); + + $page2->delete(); + + $searchContext = new SiteTreeSearchContext(SiteTree::class); + $results = $searchContext->getQuery([]); + + $this->assertTrue(in_array($page1ID, $results->column('ID'))); + $this->assertTrue(in_array($page3ID, $results->column('ID'))); + $this->assertFalse(in_array($page2->ID, $results->column('ID'))); + } + + public function testSearchFilterByTitle() + { + $page = $this->objFromFixture(SiteTree::class, 'page1'); + $searchContext = new SiteTreeSearchContext(SiteTree::class); + $results = $searchContext->getQuery(['q' => 'Home']); + $this->assertSame([$page->ID], $results->column('ID')); + } + + public function testUrlSegmentFilter() + { + $page = $this->objFromFixture(SiteTree::class, 'page8'); + $searchContext = new SiteTreeSearchContext(SiteTree::class); + $results = $searchContext->getQuery(['q' => 'lake wanaka']); + $this->assertSame([$page->ID], $results->column('ID')); + } + + public function testChangedPagesFilter() + { + /** @var Page $unchangedPage */ + $unchangedPage = $this->objFromFixture(SiteTree::class, 'page1'); + $unchangedPage->publishRecursive(); + + /** @var Page $changedPage */ + $changedPage = $this->objFromFixture(SiteTree::class, 'page2'); + $changedPage->Title = 'Original'; + $changedPage->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); + $changedPage->Title = 'Changed'; + $changedPage->write(); + + // Check that only changed pages are returned + $searchContext = new SiteTreeSearchContext(SiteTree::class); + $results = $searchContext->getQuery(['q' => 'Changed']); + + $this->assertSame([$changedPage->ID], $results->column('ID')); + $this->assertSame('Changed', $results->first()->Title); + + // Check that filter doesn't override other query portions + $results = $searchContext->getQuery(['q' => 'No Matches']); + $this->assertCount(0, $results); + } +} diff --git a/tests/php/Search/SiteTreeSearchContextTest.yml b/tests/php/Search/SiteTreeSearchContextTest.yml new file mode 100644 index 0000000000..bb68379e05 --- /dev/null +++ b/tests/php/Search/SiteTreeSearchContextTest.yml @@ -0,0 +1,34 @@ +SilverStripe\CMS\Model\SiteTree: + page1: + Title: Home + page2: + Title: Page 2 + page3: + Title: Page 3 + page4: + Title: Page 4 + page5: + Title: Page 5 + Content: 'Default text' + page6: + Title: Page 6 + page7: + Title: Page 7 + page7a: + Parent: =>SilverStripe\CMS\Model\SiteTree.page7 + Title: Page 7a + page2a: + Parent: =>SilverStripe\CMS\Model\SiteTree.page2 + Title: Page 2a + page2b: + Parent: =>SilverStripe\CMS\Model\SiteTree.page2 + Title: Page 2b + page3a: + Parent: =>SilverStripe\CMS\Model\SiteTree.page3 + Title: Page 3a + page3b: + Parent: =>SilverStripe\CMS\Model\SiteTree.page3 + Title: Page 3b + page8: + Title: EncodedUrlSegment + URLSegment: lake-wanaka+adventure