diff --git a/docs/en/04_Changelogs/4.0.0.md b/docs/en/04_Changelogs/4.0.0.md index e5f9a806a..04efd29b9 100644 --- a/docs/en/04_Changelogs/4.0.0.md +++ b/docs/en/04_Changelogs/4.0.0.md @@ -1209,6 +1209,7 @@ After (`mysite/_config/config.yml`): * Removed `CMSBatchAction_Delete` * Removed `CMSBatchAction_DeleteFromLive` * Removed `CMSMain.enabled_legacy_actions` config. +* `CMSmain.getCMSTreeTitle` is now ignored on extensions. Use `updateCMSTreeTitle` in extensions instead. * Removed ability to run tests via web requests (`http://mydomain.com/dev/tests`), use the standard CLI command instead (`vendor/bin/phpunit`). * Removed `dev/jstests/` controller (no replacement) @@ -1283,6 +1284,21 @@ A very small number of methods were chosen for deprecation, and will be removed * `BigSummary` is removed. Use `Summary` instead. * Most limit methods on `DBHTMLText` now plain text rather than attempt to manipulate the underlying HTML. * `FormField::Title` and `FormField::RightTitle` are now cast as plain text by default (but can be overridden). +* `Hierarchy` class has had much of it's functionality refactored out into `MarkedSet`: + * `isMarked` + * `isTreeOpened` + * `isExpanded` + * `markByID` + * `markPartialTree` + * `markExpanded` + * `markUnexpanded` + * `markToExpose` + * `markClosed` + * `markOpened` + * `markedNodeIDs` + * `getChildrenAsUL` replaced with `renderChildren`, which now takes a template name. + * `markingFilterMatches` (and made protected) + * `markChildren` (and made protected) * Removed `DataList::applyFilterContext` private method * Search filter classes (e.g. `ExactMatchFilter`) are now registered with `Injector` via a new `DataListFilter.` prefix convention. @@ -1403,6 +1419,12 @@ The below methods have been added or had their functionality updated to `DBDate` * Removed additional arguments from `DBMoney::getSymbol`. The result of this value is now localised based on the currency code assigned to the `DBMoney` instance * Removed `DBMoney::getAllowedCurrencies`. Apply validation to `MoneyField` instead. +* `Hierarchy` has lots of removed api: + - `parentStack()` removed. Use `getAncestors()` instead + - `doAllChildrenIncludingDeleted()` removed. Use `AllChildrenIncludingDeleted()` instead. + - `naturalNext` removed. + - `naturalPrev` removed. + - `markingFinished` removed. ### Filesystem API diff --git a/src/Forms/TreeDropdownField.php b/src/Forms/TreeDropdownField.php index 40fccea53..0eaa8077b 100644 --- a/src/Forms/TreeDropdownField.php +++ b/src/Forms/TreeDropdownField.php @@ -2,12 +2,13 @@ namespace SilverStripe\Forms; +use SilverStripe\Assets\Folder; use SilverStripe\Control\HTTPRequest; +use SilverStripe\Control\HTTPResponse; use SilverStripe\Core\Convert; -use SilverStripe\Core\Config\Config; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\Hierarchy\Hierarchy; -use SilverStripe\View\Requirements; +use SilverStripe\ORM\Hierarchy\MarkedSet; use SilverStripe\View\ViewableData; use Exception; use InvalidArgumentException; @@ -54,7 +55,6 @@ use InvalidArgumentException; */ class TreeDropdownField extends FormField { - private static $url_handlers = array( '$Action!/$ID' => '$Action' ); @@ -64,24 +64,98 @@ class TreeDropdownField extends FormField ); /** - * @ignore + * Class name for underlying object + * + * @var string */ - protected $sourceObject, $keyField, $labelField, $filterCallback, - $disableCallback, $searchCallback, $baseID = 0; + protected $sourceObject = null; + /** - * @var string default child method in Hierarchy->getChildrenAsUL + * Name of key field on underlying object + * + * @var string + */ + protected $keyField = null; + + /** + * Name of lavel field on underlying object + * + * @var string + */ + protected $labelField = null; + + /** + * Callback for filtering records + * + * @var callable + */ + protected $filterCallback = null; + + /** + * Callback for marking record as disabled + * + * @var callable + */ + protected $disableCallback = null; + + /** + * Callback for searching records. This callback takes the following arguments: + * - sourceObject Object class to search + * - labelField Label field + * - search Search text + * + * @var callable + */ + protected $searchCallback = null; + + /** + * Filter for base record + * + * @var int + */ + protected $baseID = 0; + + /** + * Default child method in Hierarchy->getChildrenAsUL + * + * @var string */ protected $childrenMethod = 'AllChildrenIncludingDeleted'; /** - * @var string default child counting method in Hierarchy->getChildrenAsUL + * Default child counting method in Hierarchy->getChildrenAsUL + * + * @var string */ protected $numChildrenMethod = 'numChildren'; /** - * Used by field search to leave only the relevant entries + * Current string value for search text to filter on + * + * @var string */ - protected $searchIds = null, $showSearch, $searchExpanded = array(); + protected $search = null; + + /** + * List of ids in current search result (keys are ids, values are true) + * + * @var array + */ + protected $searchIds = []; + + /** + * Determine if search should be shown + * + * @var bool + */ + protected $showSearch = false; + + /** + * List of ids which have their search expanded (keys are ids, values are true) + * + * @var array + */ + protected $searchExpanded = []; /** * CAVEAT: for search to work properly $labelField must be a database field, @@ -89,16 +163,7 @@ class TreeDropdownField extends FormField * * @param string $name the field name * @param string $title the field label - * @param string|array $sourceObject The object-type to list in the tree. This could - * be one of the following: - * - A DataObject class name with the {@link Hierarchy} extension. - * - An array of key/value pairs, like a {@link DropdownField} source. In - * this case, the field will act like show a flat list of tree items, - * without any hierarchy. This is most useful in conjunction with - * {@link TreeMultiselectField}, for presenting a set of checkboxes in - * a compact view. Note, that all value strings must be XML encoded - * safely prior to being passed in. - * + * @param string $sourceObject A DataObject class name with the {@link Hierarchy} extension. * @param string $keyField to field on the source class to save as the * field value (default ID). * @param string $labelField the field name to show as the human-readable @@ -109,19 +174,24 @@ class TreeDropdownField extends FormField public function __construct( $name, $title = null, - $sourceObject = 'SilverStripe\\Security\\Group', + $sourceObject = null, $keyField = 'ID', $labelField = 'TreeTitle', $showSearch = true ) { - + if (!is_a($sourceObject, DataObject::class, true)) { + throw new InvalidArgumentException("SourceObject must be a DataObject subclass"); + } + if (!DataObject::has_extension($sourceObject, Hierarchy::class)) { + throw new InvalidArgumentException("SourceObject must have Hierarchy extension"); + } $this->sourceObject = $sourceObject; $this->keyField = $keyField; $this->labelField = $labelField; $this->showSearch = $showSearch; - //Extra settings for Folders - if ($sourceObject == 'SilverStripe\\Assets\\Folder') { + // Extra settings for Folders + if (strcasecmp($sourceObject, Folder::class) === 0) { $this->childrenMethod = 'ChildFolders'; $this->numChildrenMethod = 'numChildFolders'; } @@ -194,6 +264,11 @@ class TreeDropdownField extends FormField return $this; } + /** + * Check if search is shown + * + * @return bool + */ public function getShowSearch() { return $this->showSearch; @@ -285,21 +360,11 @@ class TreeDropdownField extends FormField * Get the whole tree of a part of the tree via an AJAX request. * * @param HTTPRequest $request - * @return string + * @return HTTPResponse * @throws Exception */ public function tree(HTTPRequest $request) { - // Array sourceObject is an explicit list of values - construct a "flat tree" - if (is_array($this->sourceObject)) { - $output = ""; - return $output; - } - // Regular source specification $isSubTree = false; @@ -333,97 +398,61 @@ class TreeDropdownField extends FormField $this->populateIDs(); } + // Create marking set + $markingSet = MarkedSet::create($obj, $this->childrenMethod, $this->numChildrenMethod, 30); + + // Set filter on searched nodes if ($this->filterCallback || $this->search) { - $obj->setMarkingFilterFunction(array($this, "filterMarking")); + // Rely on filtering to limit tree + $markingSet->setMarkingFilterFunction(function ($node) { + return $this->filterMarking($node); + }); + $markingSet->setLimitingEnabled(false); } - $obj->markPartialTree( - $nodeCountThreshold = 30, - $context = null, - $this->childrenMethod, - $this->numChildrenMethod - ); - // allow to pass values to be selected within the ajax request - if (isset($_REQUEST['forceValue']) || $this->value) { - $forceValue = ( isset($_REQUEST['forceValue']) ? $_REQUEST['forceValue'] : $this->value); - $values = preg_split('/,\s*/', $forceValue); - if ($values) { - foreach ($values as $value) { - if (!$value || $value == 'unchanged') { - continue; - } + // Begin marking + $markingSet->markPartialTree(); - $obj->markToExpose($this->objectForKey($value)); + // Allow to pass values to be selected within the ajax request + $value = $request->requestVar('forceValue') ?: $this->value; + if ($value && ($values = preg_split('/,\s*/', $value))) { + foreach ($values as $value) { + if (!$value || $value == 'unchanged') { + continue; } + + $markingSet->markToExpose($this->objectForKey($value)); } } - $self = $this; - $titleFn = function (&$child) use (&$self) { - /** @var DataObject|Hierarchy $child */ - $keyField = $self->keyField; - $labelField = $self->labelField; - return sprintf( - '
  • %s', - Convert::raw2xml($self->getName()), - Convert::raw2xml($child->$keyField), - Convert::raw2xml($child->$keyField), - Convert::raw2xml($child->class), - Convert::raw2xml($child->markingClasses($self->numChildrenMethod)), - ($self->nodeIsDisabled($child)) ? 'disabled' : '', - (int)$child->ID, - $child->obj($labelField)->forTemplate() - ); + // Set title formatter + $customised = function (DataObject $child) use ($isSubTree) { + return [ + 'name' => $this->getName(), + 'id' => $child->obj($this->keyField), + 'title' => $child->obj($this->labelField), + 'disabled' => $this->nodeIsDisabled($child), + 'isSubTree' => $isSubTree + ]; }; - // Limit the amount of nodes shown for performance reasons. - // Skip the check if we're filtering the tree, since its not clear how many children will - // match the filter criteria until they're queried (and matched up with previously marked nodes). - $nodeThresholdLeaf = Config::inst()->get('SilverStripe\\ORM\\Hierarchy\\Hierarchy', 'node_threshold_leaf'); - if ($nodeThresholdLeaf && !$this->filterCallback && !$this->search) { - $className = $this->sourceObject; - $nodeCountCallback = function ($parent, $numChildren) use ($className, $nodeThresholdLeaf) { - if ($className === 'SilverStripe\\CMS\\Model\\SiteTree' - && $parent->ID - && $numChildren > $nodeThresholdLeaf - ) { - return sprintf( - '', - _t('LeftAndMain.TooManyPages', 'Too many pages') - ); - } - return null; - }; + // Determine output format + if ($request->requestVar('format') === 'json') { + // Format JSON output + $json = $markingSet + ->getChildrenAsArray($customised); + return HTTPResponse::create() + ->addHeader('Content-Type', 'application/json') + ->setBody(json_encode($json)); } else { - $nodeCountCallback = null; - } - - if ($isSubTree) { - $html = $obj->getChildrenAsUL( - "", - $titleFn, - null, - true, - $this->childrenMethod, - $this->numChildrenMethod, - true, // root call - null, - $nodeCountCallback + // Return basic html + $html = $markingSet->renderChildren( + [self::class . '_HTML', 'type' => 'Includes'], + $customised ); - return substr(trim($html), 4, -5); - } else { - $html = $obj->getChildrenAsUL( - 'class="tree"', - $titleFn, - null, - true, - $this->childrenMethod, - $this->numChildrenMethod, - true, // root call - null, - $nodeCountCallback - ); - return $html; + return HTTPResponse::create() + ->addHeader('Content-Type', 'text/html') + ->setBody($html); } } @@ -432,7 +461,7 @@ class TreeDropdownField extends FormField * If a filter function has been set, that will be called. And if search text is set, * filter on that too. Return true if all applicable conditions are true, false otherwise. * - * @param mixed $node + * @param DataObject $node * @return bool */ public function filterMarking($node) @@ -440,7 +469,8 @@ class TreeDropdownField extends FormField if ($this->filterCallback && !call_user_func($this->filterCallback, $node)) { return false; } - if ($this->search != "") { + + if ($this->search) { return isset($this->searchIds[$node->ID]) && $this->searchIds[$node->ID] ? true : false; } @@ -591,11 +621,10 @@ class TreeDropdownField extends FormField public function performReadonlyTransformation() { /** @var TreeDropdownField_Readonly $copy */ - $copy = $this->castedCopy('SilverStripe\\Forms\\TreeDropdownField_Readonly'); + $copy = $this->castedCopy(TreeDropdownField_Readonly::class); $copy->setKeyField($this->keyField); $copy->setLabelField($this->labelField); $copy->setSourceObject($this->sourceObject); - return $copy; } } diff --git a/src/ORM/Hierarchy/Hierarchy.php b/src/ORM/Hierarchy/Hierarchy.php index 9c2ac73fa..a9564dedd 100644 --- a/src/ORM/Hierarchy/Hierarchy.php +++ b/src/ORM/Hierarchy/Hierarchy.php @@ -3,11 +3,9 @@ namespace SilverStripe\ORM\Hierarchy; use SilverStripe\Admin\LeftAndMain; -use SilverStripe\CMS\Model\SiteTree; -use SilverStripe\Core\Config\Config; use SilverStripe\Control\Controller; -use SilverStripe\Core\Resettable; use SilverStripe\ORM\DataList; +use SilverStripe\ORM\SS_List; use SilverStripe\ORM\ValidationResult; use SilverStripe\ORM\ArrayList; use SilverStripe\ORM\DataObject; @@ -20,18 +18,25 @@ use Exception; * obvious example of this is SiteTree. * * @property int $ParentID - * @property DataObject $owner + * @property DataObject|Hierarchy $owner * @method DataObject Parent() */ -class Hierarchy extends DataExtension implements Resettable +class Hierarchy extends DataExtension { - protected $markedNodes; + /** + * Cache for {@see numChildren()} + * + * @var int + */ + protected $_cache_numChildren = null; - protected $markingFilter; - - /** @var int */ - protected $_cache_numChildren; + /** + * Cache for {@see Children()} + * + * @var SS_List + */ + protected $_cache_children = null; /** * The lower bounds for the amount of nodes to mark. If set, the logic will expand nodes until it reaches at least @@ -96,498 +101,41 @@ class Hierarchy extends DataExtension implements Resettable public function validate(ValidationResult $validationResult) { // The object is new, won't be looping. - if (!$this->owner->ID) { + /** @var DataObject|Hierarchy $owner */ + $owner = $this->owner; + if (!$owner->ID) { return; } // The object has no parent, won't be looping. - if (!$this->owner->ParentID) { + if (!$owner->ParentID) { return; } // The parent has not changed, skip the check for performance reasons. - if (!$this->owner->isChanged('ParentID')) { + if (!$owner->isChanged('ParentID')) { return; } // Walk the hierarchy upwards until we reach the top, or until we reach the originating node again. - $node = $this->owner; - while ($node) { - if ($node->ParentID==$this->owner->ID) { + $node = $owner; + while ($node && $node->ParentID) { + if ((int)$node->ParentID === (int)$owner->ID) { // Hierarchy is looping. $validationResult->addError( _t( 'Hierarchy.InfiniteLoopNotAllowed', 'Infinite loop found within the "{type}" hierarchy. Please change the parent to resolve this', 'First argument is the class that makes up the hierarchy.', - array('type' => $this->owner->class) + array('type' => $owner->class) ), 'bad', 'INFINITE_LOOP' ); break; } - $node = $node->ParentID ? $node->Parent() : null; - } - - // At this point the $validationResult contains the response. - } - - /** - * Returns the children of this DataObject as an XHTML UL. This will be called recursively on each child, so if they - * have children they will be displayed as a UL inside a LI. - * - * @param string $attributes Attributes to add to the UL - * @param string|callable $titleEval PHP code to evaluate to start each child - this should include '
  • ' - * @param string $extraArg Extra arguments that will be passed on to children, for if they overload this function - * @param bool $limitToMarked Display only marked children - * @param string $childrenMethod The name of the method used to get children from each object - * @param string $numChildrenMethod - * @param bool $rootCall Set to true for this first call, and then to false for calls inside the recursion. - * You should not change this. - * @param int $nodeCountThreshold See {@link self::$node_threshold_total} - * @param callable $nodeCountCallback Called with the node count, which gives the callback an opportunity to - * intercept the query. Useful e.g. to avoid excessive children listings (Arguments: $parent, $numChildren) - * @return string - */ - public function getChildrenAsUL( - $attributes = "", - $titleEval = '"
  • " . $child->Title', - $extraArg = null, - $limitToMarked = false, - $childrenMethod = "AllChildrenIncludingDeleted", - $numChildrenMethod = "numChildren", - $rootCall = true, - $nodeCountThreshold = null, - $nodeCountCallback = null - ) { - if (!is_numeric($nodeCountThreshold)) { - $nodeCountThreshold = Config::inst()->get(__CLASS__, 'node_threshold_total'); - } - - if ($limitToMarked && $rootCall) { - $this->markingFinished($numChildrenMethod); - } - - - if ($nodeCountCallback) { - $nodeCountWarning = $nodeCountCallback($this->owner, $this->owner->$numChildrenMethod()); - if ($nodeCountWarning) { - return $nodeCountWarning; - } - } - - - if ($this->owner->hasMethod($childrenMethod)) { - $children = $this->owner->$childrenMethod($extraArg); - } else { - $children = null; - user_error(sprintf( - "Can't find the method '%s' on class '%s' for getting tree children", - $childrenMethod, - get_class($this->owner) - ), E_USER_ERROR); - } - - $output = null; - if ($children) { - if ($attributes) { - $attributes = " $attributes"; - } - - $output = "\n"; - - foreach ($children as $child) { - if (!$limitToMarked || $child->isMarked()) { - $foundAChild = true; - if (is_callable($titleEval)) { - $output .= $titleEval($child, $numChildrenMethod); - } else { - $output .= eval("return $titleEval;"); - } - $output .= "\n"; - - $numChildren = $child->$numChildrenMethod(); - - if (// Always traverse into opened nodes (they might be exposed as parents of search results) - $child->isExpanded() - // Only traverse into children if we haven't reached the maximum node count already. - // Otherwise, the remaining nodes are lazy loaded via ajax. - && $child->isMarked() - ) { - // Additionally check if node count requirements are met - $nodeCountWarning = $nodeCountCallback ? $nodeCountCallback($child, $numChildren) : null; - if ($nodeCountWarning) { - $output .= $nodeCountWarning; - $child->markClosed(); - } else { - $output .= $child->getChildrenAsUL( - "", - $titleEval, - $extraArg, - $limitToMarked, - $childrenMethod, - $numChildrenMethod, - false, - $nodeCountThreshold - ); - } - } elseif ($child->isTreeOpened()) { - // Since we're not loading children, don't mark it as open either - $child->markClosed(); - } - $output .= "
  • \n"; - } - } - - $output .= "\n"; - } - - if (isset($foundAChild) && $foundAChild) { - return $output; - } - return null; - } - - /** - * Mark a segment of the tree, by calling mark(). - * - * The method performs a breadth-first traversal until the number of nodes is more than minCount. This is used to - * get a limited number of tree nodes to show in the CMS initially. - * - * This method returns the number of nodes marked. After this method is called other methods can check - * {@link isExpanded()} and {@link isMarked()} on individual nodes. - * - * @param int $nodeCountThreshold See {@link getChildrenAsUL()} - * @param mixed $context - * @param string $childrenMethod - * @param string $numChildrenMethod - * @return int The actual number of nodes marked. - */ - public function markPartialTree( - $nodeCountThreshold = 30, - $context = null, - $childrenMethod = "AllChildrenIncludingDeleted", - $numChildrenMethod = "numChildren" - ) { - if (!is_numeric($nodeCountThreshold)) { - $nodeCountThreshold = 30; - } - - $this->markedNodes = array($this->owner->ID => $this->owner); - $this->owner->markUnexpanded(); - - // foreach can't handle an ever-growing $nodes list - while (list($id, $node) = each($this->markedNodes)) { - $children = $this->markChildren($node, $context, $childrenMethod, $numChildrenMethod); - if ($nodeCountThreshold && sizeof($this->markedNodes) > $nodeCountThreshold) { - // Undo marking children as opened since they're lazy loaded - if ($children) { - foreach ($children as $child) { - $child->markClosed(); - } - } - break; - } - } - return sizeof($this->markedNodes); - } - - /** - * Filter the marking to only those object with $node->$parameterName == $parameterValue - * - * @param string $parameterName The parameter on each node to check when marking. - * @param mixed $parameterValue The value the parameter must be to be marked. - */ - public function setMarkingFilter($parameterName, $parameterValue) - { - $this->markingFilter = array( - "parameter" => $parameterName, - "value" => $parameterValue - ); - } - - /** - * Filter the marking to only those where the function returns true. The node in question will be passed to the - * function. - * - * @param string $funcName The name of the function to call - */ - public function setMarkingFilterFunction($funcName) - { - $this->markingFilter = array( - "func" => $funcName, - ); - } - - /** - * Returns true if the marking filter matches on the given node. - * - * @param DataObject $node Node to check - * @return bool - */ - public function markingFilterMatches($node) - { - if (!$this->markingFilter) { - return true; - } - - if (isset($this->markingFilter['parameter']) && $parameterName = $this->markingFilter['parameter']) { - if (is_array($this->markingFilter['value'])) { - $ret = false; - foreach ($this->markingFilter['value'] as $value) { - $ret = $ret||$node->$parameterName==$value; - if ($ret == true) { - break; - } - } - return $ret; - } else { - return ($node->$parameterName == $this->markingFilter['value']); - } - } elseif ($func = $this->markingFilter['func']) { - return call_user_func($func, $node); + $node = $node->Parent(); } } - /** - * Mark all children of the given node that match the marking filter. - * - * @param DataObject $node Parent node - * @param mixed $context - * @param string $childrenMethod The name of the instance method to call to get the object's list of children - * @param string $numChildrenMethod The name of the instance method to call to count the object's children - * @return DataList - */ - public function markChildren( - $node, - $context = null, - $childrenMethod = "AllChildrenIncludingDeleted", - $numChildrenMethod = "numChildren" - ) { - if ($node->hasMethod($childrenMethod)) { - $children = $node->$childrenMethod($context); - } else { - $children = null; - user_error(sprintf( - "Can't find the method '%s' on class '%s' for getting tree children", - $childrenMethod, - get_class($node) - ), E_USER_ERROR); - } - - $node->markExpanded(); - if ($children) { - foreach ($children as $child) { - $markingMatches = $this->markingFilterMatches($child); - if ($markingMatches) { - // Mark a child node as unexpanded if it has children and has not already been expanded - if ($child->$numChildrenMethod() && !$child->isExpanded()) { - $child->markUnexpanded(); - } else { - $child->markExpanded(); - } - $this->markedNodes[$child->ID] = $child; - } - } - } - - return $children; - } - - /** - * Ensure marked nodes that have children are also marked expanded. Call this after marking but before iterating - * over the tree. - * - * @param string $numChildrenMethod The name of the instance method to call to count the object's children - */ - protected function markingFinished($numChildrenMethod = "numChildren") - { - // Mark childless nodes as expanded. - if ($this->markedNodes) { - foreach ($this->markedNodes as $id => $node) { - if (!$node->isExpanded() && !$node->$numChildrenMethod()) { - $node->markExpanded(); - } - } - } - } - - /** - * Return CSS classes of 'unexpanded', 'closed', both, or neither, as well as a 'jstree-*' state depending on the - * marking of this DataObject. - * - * @param string $numChildrenMethod The name of the instance method to call to count the object's children - * @return string - */ - public function markingClasses($numChildrenMethod = "numChildren") - { - $classes = ''; - if (!$this->isExpanded()) { - $classes .= " unexpanded"; - } - - // Set jstree open state, or mark it as a leaf (closed) if there are no children - if (!$this->owner->$numChildrenMethod()) { - $classes .= " jstree-leaf closed"; - } elseif ($this->isTreeOpened()) { - $classes .= " jstree-open"; - } else { - $classes .= " jstree-closed closed"; - } - return $classes; - } - - /** - * Mark the children of the DataObject with the given ID. - * - * @param int $id ID of parent node - * @param bool $open If this is true, mark the parent node as opened - * @return bool - */ - public function markById($id, $open = false) - { - if (isset($this->markedNodes[$id])) { - $this->markChildren($this->markedNodes[$id]); - if ($open) { - $this->markedNodes[$id]->markOpened(); - } - return true; - } else { - return false; - } - } - - /** - * Expose the given object in the tree, by marking this page and all it ancestors. - * - * @param DataObject $childObj - */ - public function markToExpose($childObj) - { - if (is_object($childObj)) { - $stack = array_reverse($childObj->parentStack()); - foreach ($stack as $stackItem) { - $this->markById($stackItem->ID, true); - } - } - } - - /** - * Return the IDs of all the marked nodes. - * - * @return array - */ - public function markedNodeIDs() - { - return array_keys($this->markedNodes); - } - - /** - * Return an array of this page and its ancestors, ordered item -> root. - * - * @return SiteTree[] - */ - public function parentStack() - { - $p = $this->owner; - - while ($p) { - $stack[] = $p; - $p = $p->ParentID ? $p->Parent() : null; - } - - return $stack; - } - - /** - * Cache of DataObjects' marked statuses: [ClassName][ID] = bool - * @var array - */ - protected static $marked = array(); - - /** - * Cache of DataObjects' expanded statuses: [ClassName][ID] = bool - * @var array - */ - protected static $expanded = array(); - - /** - * Cache of DataObjects' opened statuses: [ClassName][ID] = bool - * @var array - */ - protected static $treeOpened = array(); - - /** - * Mark this DataObject as expanded. - */ - public function markExpanded() - { - self::$marked[$this->owner->baseClass()][$this->owner->ID] = true; - self::$expanded[$this->owner->baseClass()][$this->owner->ID] = true; - } - - /** - * Mark this DataObject as unexpanded. - */ - public function markUnexpanded() - { - self::$marked[$this->owner->baseClass()][$this->owner->ID] = true; - self::$expanded[$this->owner->baseClass()][$this->owner->ID] = false; - } - - /** - * Mark this DataObject's tree as opened. - */ - public function markOpened() - { - self::$marked[$this->owner->baseClass()][$this->owner->ID] = true; - self::$treeOpened[$this->owner->baseClass()][$this->owner->ID] = true; - } - - /** - * Mark this DataObject's tree as closed. - */ - public function markClosed() - { - if (isset(self::$treeOpened[$this->owner->baseClass()][$this->owner->ID])) { - unset(self::$treeOpened[$this->owner->baseClass()][$this->owner->ID]); - } - } - - /** - * Check if this DataObject is marked. - * - * @return bool - */ - public function isMarked() - { - $baseClass = $this->owner->baseClass(); - $id = $this->owner->ID; - return isset(self::$marked[$baseClass][$id]) ? self::$marked[$baseClass][$id] : false; - } - - /** - * Check if this DataObject is expanded. - * - * @return bool - */ - public function isExpanded() - { - $baseClass = $this->owner->baseClass(); - $id = $this->owner->ID; - return isset(self::$expanded[$baseClass][$id]) ? self::$expanded[$baseClass][$id] : false; - } - - /** - * Check if this DataObject's tree is opened. - * - * @return bool - */ - public function isTreeOpened() - { - $baseClass = $this->owner->baseClass(); - $id = $this->owner->ID; - return isset(self::$treeOpened[$baseClass][$id]) ? self::$treeOpened[$baseClass][$id] : false; - } /** * Get a list of this DataObject's and all it's descendants IDs. @@ -605,41 +153,39 @@ class Hierarchy extends DataExtension implements Resettable * Get a list of this DataObject's and all it's descendants ID, and put them in $idList. * * @param array $idList Array to put results in. + * @param DataObject|Hierarchy $node */ - public function loadDescendantIDListInto(&$idList) + protected function loadDescendantIDListInto(&$idList, $node = null) { - if ($children = $this->AllChildren()) { - foreach ($children as $child) { - if (in_array($child->ID, $idList)) { - continue; - } + if (!$node) { + $node = $this->owner; + } + $children = $node->AllChildren(); + foreach ($children as $child) { + if (!in_array($child->ID, $idList)) { $idList[] = $child->ID; - /** @var Hierarchy $ext */ - $ext = $child->getExtensionInstance('SilverStripe\ORM\Hierarchy\Hierarchy'); - $ext->setOwner($child); - $ext->loadDescendantIDListInto($idList); - $ext->clearOwner(); + $this->loadDescendantIDListInto($idList, $child); } } } /** - * Get the children for this DataObject. + * Get the children for this DataObject filtered by canView() * - * @return DataList + * @return SS_List */ public function Children() { - if (!(isset($this->_cache_children) && $this->_cache_children)) { - $result = $this->owner->stageChildren(false); - $children = array(); - foreach ($result as $record) { - if ($record->canView()) { - $children[] = $record; - } - } - $this->_cache_children = new ArrayList($children); + if ($this->_cache_children) { + return $this->_cache_children; } + + $this->_cache_children = $this + ->owner + ->stageChildren(false) + ->filterByCallback(function (DataObject $record) { + return $record->canView(); + }); return $this->_cache_children; } @@ -660,50 +206,24 @@ class Hierarchy extends DataExtension implements Resettable * - Modified children will be marked as "ModifiedOnStage" * - Everything else has "SameOnStage" set, as an indicator that this information has been looked up. * - * @param mixed $context * @return ArrayList */ - public function AllChildrenIncludingDeleted($context = null) + public function AllChildrenIncludingDeleted() { - return $this->doAllChildrenIncludingDeleted($context); - } + $stageChildren = $this->owner->stageChildren(true); - /** - * @see AllChildrenIncludingDeleted - * - * @param mixed $context - * @return ArrayList - */ - public function doAllChildrenIncludingDeleted($context = null) - { - if (!$this->owner) { - user_error('Hierarchy::doAllChildrenIncludingDeleted() called without $this->owner'); - } - - $baseClass = $this->owner->baseClass(); - if ($baseClass) { - $stageChildren = $this->owner->stageChildren(true); - - // Add live site content that doesn't exist on the stage site, if required. - if ($this->owner->hasExtension(Versioned::class)) { - // Next, go through the live children. Only some of these will be listed - $liveChildren = $this->owner->liveChildren(true, true); - if ($liveChildren) { - $merged = new ArrayList(); - $merged->merge($stageChildren); - $merged->merge($liveChildren); - $stageChildren = $merged; - } + // Add live site content that doesn't exist on the stage site, if required. + if ($this->owner->hasExtension(Versioned::class)) { + // Next, go through the live children. Only some of these will be listed + $liveChildren = $this->owner->liveChildren(true, true); + if ($liveChildren) { + $merged = new ArrayList(); + $merged->merge($stageChildren); + $merged->merge($liveChildren); + $stageChildren = $merged; } - - $this->owner->extend("augmentAllChildrenIncludingDeleted", $stageChildren, $context); - } else { - user_error( - "Hierarchy::AllChildren() Couldn't determine base class for '{$this->owner->class}'", - E_USER_ERROR - ); } - + $this->owner->extend("augmentAllChildrenIncludingDeleted", $stageChildren); return $stageChildren; } @@ -732,14 +252,9 @@ class Hierarchy extends DataExtension implements Resettable * Return the number of children that this page ever had, including pages that were deleted. * * @return int - * @throws Exception */ public function numHistoricalChildren() { - if (!$this->owner->hasExtension(Versioned::class)) { - throw new Exception('Hierarchy->AllHistoricalChildren() only works with Versioned extension applied'); - } - return $this->AllHistoricalChildren()->count(); } @@ -752,15 +267,19 @@ class Hierarchy extends DataExtension implements Resettable */ public function numChildren($cache = true) { - // Build the cache for this class if it doesn't exist. - if (!$cache || !is_numeric($this->_cache_numChildren)) { - // Hey, this is efficient now! - // We call stageChildren(), because Children() has canView() filtering - $this->_cache_numChildren = (int)$this->owner->stageChildren(true)->Count(); + // Load if caching + if ($cache && isset($this->_cache_numChildren)) { + return $this->_cache_numChildren; } - // If theres no value in the cache, it just means that it doesn't have any children. - return $this->_cache_numChildren; + // We call stageChildren(), because Children() has canView() filtering + $children = (int)$this->owner->stageChildren(true)->Count(); + + // Save if caching + if ($cache) { + $this->_cache_numChildren = $children; + } + return $children; } /** @@ -787,17 +306,16 @@ class Hierarchy extends DataExtension implements Resettable */ public function stageChildren($showAll = false) { - $baseClass = $this->owner->baseClass(); - $hide_from_hierarchy = $this->owner->config()->hide_from_hierarchy; - $hide_from_cms_tree = $this->owner->config()->hide_from_cms_tree; - $staged = $baseClass::get() + $hideFromHierarchy = $this->owner->config()->hide_from_hierarchy; + $hideFromCMSTree = $this->owner->config()->hide_from_cms_tree; + $staged = DataObject::get($this->ownerBaseClass) ->filter('ParentID', (int)$this->owner->ID) ->exclude('ID', (int)$this->owner->ID); - if ($hide_from_hierarchy) { - $staged = $staged->exclude('ClassName', $hide_from_hierarchy); + if ($hideFromHierarchy) { + $staged = $staged->exclude('ClassName', $hideFromHierarchy); } - if ($hide_from_cms_tree && $this->showingCMSTree()) { - $staged = $staged->exclude('ClassName', $hide_from_cms_tree); + if ($hideFromCMSTree && $this->showingCMSTree()) { + $staged = $staged->exclude('ClassName', $hideFromCMSTree); } if (!$showAll && DataObject::getSchema()->fieldSpec($this->owner, 'ShowInMenus')) { $staged = $staged->filter('ShowInMenus', 1); @@ -821,21 +339,20 @@ class Hierarchy extends DataExtension implements Resettable throw new Exception('Hierarchy->liveChildren() only works with Versioned extension applied'); } - $baseClass = $this->owner->baseClass(); - $hide_from_hierarchy = $this->owner->config()->hide_from_hierarchy; - $hide_from_cms_tree = $this->owner->config()->hide_from_cms_tree; - $children = $baseClass::get() + $hideFromHierarchy = $this->owner->config()->hide_from_hierarchy; + $hideFromCMSTree = $this->owner->config()->hide_from_cms_tree; + $children = DataObject::get($this->owner->baseClass()) ->filter('ParentID', (int)$this->owner->ID) ->exclude('ID', (int)$this->owner->ID) ->setDataQueryParam(array( 'Versioned.mode' => $onlyDeletedFromStage ? 'stage_unique' : 'stage', 'Versioned.stage' => 'Live' )); - if ($hide_from_hierarchy) { - $children = $children->exclude('ClassName', $hide_from_hierarchy); + if ($hideFromHierarchy) { + $children = $children->exclude('ClassName', $hideFromHierarchy); } - if ($hide_from_cms_tree && $this->showingCMSTree()) { - $children = $children->exclude('ClassName', $hide_from_cms_tree); + if ($hideFromCMSTree && $this->showingCMSTree()) { + $children = $children->exclude('ClassName', $hideFromCMSTree); } if (!$showAll && DataObject::getSchema()->fieldSpec($this->owner, 'ShowInMenus')) { $children = $children->filter('ShowInMenus', 1); @@ -857,23 +374,27 @@ class Hierarchy extends DataExtension implements Resettable if (empty($parentID)) { return null; } - $idSQL = $this->owner->getSchema()->sqlColumnForField($this->owner, 'ID'); - return DataObject::get_one($this->owner->class, array( + $idSQL = $this->owner->getSchema()->sqlColumnForField($this->ownerBaseClass, 'ID'); + return DataObject::get_one($this->ownerBaseClass, array( array($idSQL => $parentID), $filter )); } /** - * Return all the parents of this class in a set ordered from the lowest to highest parent. + * Return all the parents of this class in a set ordered from the closest to furtherest parent. * + * @param bool $includeSelf * @return ArrayList */ - public function getAncestors() + public function getAncestors($includeSelf = false) { $ancestors = new ArrayList(); - $object = $this->owner; + $object = $this->owner; + if ($includeSelf) { + $ancestors->push($object); + } while ($object = $object->getParent()) { $ancestors->push($object); } @@ -891,81 +412,14 @@ class Hierarchy extends DataExtension implements Resettable { $crumbs = array(); $ancestors = array_reverse($this->owner->getAncestors()->toArray()); + /** @var DataObject $ancestor */ foreach ($ancestors as $ancestor) { - $crumbs[] = $ancestor->Title; + $crumbs[] = $ancestor->getTitle(); } - $crumbs[] = $this->owner->Title; + $crumbs[] = $this->owner->getTitle(); return implode($separator, $crumbs); } - /** - * Get the next node in the tree of the type. If there is no instance of the className descended from this node, - * then search the parents. - * - * @todo Write! - * - * @param string $className Class name of the node to find - * @param DataObject $afterNode Used for recursive calls to this function - * @return DataObject - */ - public function naturalPrev($className, $afterNode = null) - { - return null; - } - - /** - * Get the next node in the tree of the type. If there is no instance of the className descended from this node, - * then search the parents. - * @param string $className Class name of the node to find. - * @param string|int $root ID/ClassName of the node to limit the search to - * @param DataObject $afterNode Used for recursive calls to this function - * @return DataObject - */ - public function naturalNext($className = null, $root = 0, $afterNode = null) - { - // If this node is not the node we are searching from, then we can possibly return this node as a solution - if ($afterNode && $afterNode->ID != $this->owner->ID) { - if (!$className || ($className && $this->owner->class == $className)) { - return $this->owner; - } - } - - $nextNode = null; - $baseClass = $this->owner->baseClass(); - - $children = $baseClass::get() - ->filter('ParentID', (int)$this->owner->ID) - ->sort('"Sort"', 'ASC'); - if ($afterNode) { - $children = $children->filter('Sort:GreaterThan', $afterNode->Sort); - } - - // Try all the siblings of this node after the given node - /*if( $siblings = DataObject::get( $this->owner->baseClass(), - "\"ParentID\"={$this->owner->ParentID}" . ( $afterNode ) ? "\"Sort\" - > {$afterNode->Sort}" : "" , '\"Sort\" ASC' ) ) $searchNodes->merge( $siblings );*/ - - if ($children) { - foreach ($children as $node) { - if ($nextNode = $node->naturalNext($className, $node->ID, $this->owner)) { - break; - } - } - - if ($nextNode) { - return $nextNode; - } - } - - // if this is not an instance of the root class or has the root id, search the parent - if (!(is_numeric($root) && $root == $this->owner->ID || $root == $this->owner->class) - && ($parent = $this->owner->Parent())) { - return $parent->naturalNext($className, $root, $this->owner); - } - - return null; - } - /** * Flush all Hierarchy caches: * - Children (instance) @@ -978,21 +432,5 @@ class Hierarchy extends DataExtension implements Resettable { $this->_cache_children = null; $this->_cache_numChildren = null; - self::$marked = array(); - self::$expanded = array(); - self::$treeOpened = array(); - } - - /** - * Reset global Hierarchy caches: - * - Marked - * - Expanded - * - TreeOpened - */ - public static function reset() - { - self::$marked = array(); - self::$expanded = array(); - self::$treeOpened = array(); } } diff --git a/src/ORM/Hierarchy/MarkedSet.php b/src/ORM/Hierarchy/MarkedSet.php new file mode 100644 index 000000000..198df1e54 --- /dev/null +++ b/src/ORM/Hierarchy/MarkedSet.php @@ -0,0 +1,812 @@ + itemInstance] + */ + protected $markedNodes; + + /** + * Optional filter callback for filtering nodes to mark + * + * Array with keys: + * - parameter + * - value + * - func + * + * @var array + * @temp made public + */ + public $markingFilter; + + /** + * @var DataObject + */ + protected $rootNode = null; + + /** + * Method to use for getting children. Defaults to 'AllChildrenIncludingDeleted' + * + * @var string + */ + protected $childrenMethod = null; + + /** + * Method to use for counting children. Defaults to `numChildren` + * + * @var string + */ + protected $numChildrenMethod = null; + + /** + * Minimum number of nodes to iterate over before stopping recursion + * + * @var int + */ + protected $nodeCountThreshold = null; + + /** + * Max number of nodes to return from a single children collection + * + * @var int + */ + protected $maxChildNodes; + + /** + * Enable limiting + * + * @var bool + */ + protected $enableLimiting = true; + + /** + * Create an empty set with the given class + * + * @param DataObject $rootNode Root node for this set. To collect the entire tree, + * pass in a singelton object. + * @param string $childrenMethod Override children method + * @param string $numChildrenMethod Override children counting method + * @param int $nodeCountThreshold Minimum threshold for number nodes to mark + * @param int $maxChildNodes Maximum threshold for number of child nodes to include + */ + public function __construct( + DataObject $rootNode, + $childrenMethod = null, + $numChildrenMethod = null, + $nodeCountThreshold = null, + $maxChildNodes = null + ) { + if (! $rootNode::has_extension(Hierarchy::class)) { + throw new InvalidArgumentException( + get_class($rootNode) . " does not have the Hierarchy extension" + ); + } + $this->rootNode = $rootNode; + if ($childrenMethod) { + $this->setChildrenMethod($childrenMethod); + } + if ($numChildrenMethod) { + $this->setNumChildrenMethod($numChildrenMethod); + } + if ($nodeCountThreshold) { + $this->setNodeCountThreshold($nodeCountThreshold); + } + if ($maxChildNodes) { + $this->setMaxChildNodes($maxChildNodes); + } + } + + /** + * Get total number of nodes to get. This acts as a soft lower-bounds for + * number of nodes to search until found. + * Defaults to value of node_threshold_total of hierarchy class. + * + * @return int + */ + public function getNodeCountThreshold() + { + return $this->nodeCountThreshold + ?: $this->rootNode->config()->get('node_threshold_total'); + } + + /** + * Max number of nodes that can be physically rendered at any level. + * Acts as a hard upper bound, after which nodes will be trimmed for + * performance reasons. + * + * @return int + */ + public function getMaxChildNodes() + { + return $this->maxChildNodes + ?: $this->rootNode->config()->get('node_threshold_leaf'); + } + + /** + * Set hard limit of number of nodes to get for this level + * + * @param int $count + * @return $this + */ + public function setMaxChildNodes($count) + { + $this->maxChildNodes = $count; + return $this; + } + + /** + * Set max node count + * + * @param int $total + * @return $this + */ + public function setNodeCountThreshold($total) + { + $this->nodeCountThreshold = $total; + return $this; + } + + /** + * Get method to use for getting children + * + * @return string + */ + public function getChildrenMethod() + { + return $this->childrenMethod ?: 'AllChildrenIncludingDeleted'; + } + + /** + * Get children from this node + * + * @param DataObject $node + * @return SS_List + */ + protected function getChildren(DataObject $node) + { + $method = $this->getChildrenMethod(); + return $node->$method() ?: ArrayList::create(); + } + + /** + * Set method to use for getting children + * + * @param string $method + * @throws InvalidArgumentException + * @return $this + */ + public function setChildrenMethod($method) + { + // Check method is valid + if (!$this->rootNode->hasMethod($method)) { + throw new InvalidArgumentException(sprintf( + "Can't find the method '%s' on class '%s' for getting tree children", + $method, + get_class($this->rootNode) + )); + } + $this->childrenMethod = $method; + return $this; + } + + /** + * Get method name for num children + * + * @return string + */ + public function getNumChildrenMethod() + { + return $this->numChildrenMethod ?: 'numChildren'; + } + + /** + * Count children + * + * @param DataObject $node + * @return int + */ + protected function getNumChildren(DataObject $node) + { + $method = $this->getNumChildrenMethod(); + return (int)$node->$method(); + } + + /** + * Set method name to get num children + * + * @param string $method + * @return $this + */ + public function setNumChildrenMethod($method) + { + // Check method is valid + if (!$this->rootNode->hasMethod($method)) { + throw new InvalidArgumentException(sprintf( + "Can't find the method '%s' on class '%s' for counting tree children", + $method, + get_class($this->rootNode) + )); + } + $this->numChildrenMethod = $method; + return $this; + } + + /** + * Returns the children of this DataObject as an XHTML UL. This will be called recursively on each child, so if they + * have children they will be displayed as a UL inside a LI. + * + * @param string $template Template for items in the list + * @param array|callable $context Additional arguments to add to template when rendering + * due to excessive line length. If callable, this will be executed with the current node dataobject + * @return string + */ + public function renderChildren( + $template = null, + $context = [] + ) { + // Default to HTML template + if (!$template) { + $template = [ + 'type' => 'Includes', + self::class . '_HTML' + ]; + } + $tree = $this->getSubtree($this->rootNode, 0); + $node = $this->renderSubtree($tree, $template, $context); + return (string)$node->getField('SubTree'); + } + + /** + * Get child data formatted as JSON + * + * @param callable $serialiseEval A callback that takes a DataObject as a single parameter, + * and should return an array containing a simple array representation. This result will + * replace the 'node' property at each point in the tree. + * @return array + */ + public function getChildrenAsArray($serialiseEval = null) + { + if (!$serialiseEval) { + $serialiseEval = function ($data) { + /** @var DataObject $node */ + $node = $data['node']; + return [ + 'id' => $node->ID, + 'title' => $node->getTitle() + ]; + }; + } + + $tree = $this->getSubtree($this->rootNode, 0); + + return $this->getSubtreeAsArray($tree, $serialiseEval); + } + + /** + * Render a node in the tree with the given template + * + * @param array $data array data for current node + * @param string|array $template Template to use + * @param array|callable $context Additional arguments to add to template when rendering + * due to excessive line length. If callable, this will be executed with the current node dataobject + * @return ArrayData Viewable object representing the root node. use getField('SubTree') to get HTML + */ + protected function renderSubtree($data, $template, $context = []) + { + // Render children + $childNodes = new ArrayList(); + foreach ($data['children'] as $child) { + $childData = $this->renderSubtree($child, $template, $context); + $childNodes->push($childData); + } + + // Build parent node + $parentNode = new ArrayData($data); + $parentNode->setField('children', $childNodes); // Replace raw array with template-friendly list + $parentNode->setField('markingClasses', $this->markingClasses($data['node'])); + + // Evaluate custom context + if (is_callable($context)) { + $context = call_user_func($context, $data['node']); + } + if ($context) { + foreach ($context as $key => $value) { + $parentNode->setField($key, $value); + } + } + + // Render + $subtree = $parentNode->renderWith($template); + $parentNode->setField('SubTree', $subtree); + return $parentNode; + } + + /** + * Return sub-tree as json array + * + * @param array $data + * @param callable $serialiseEval A callback that takes a DataObject as a single parameter, + * and should return an array containing a simple array representation. This result will + * replace the 'node' property at each point in the tree. + * @return mixed|string + */ + protected function getSubtreeAsArray($data, $serialiseEval) + { + $output = $data; + + // Serialise node + $output['node'] = $serialiseEval($data['node']); + + // Force serialisation of DBField instances + if (is_array($output['node'])) { + foreach ($output['node'] as $key => $value) { + if ($value instanceof DBField) { + $output['node'][$key] = $value->getSchemaValue(); + } + } + } elseif ($output['node'] instanceof DBField) { + $output['node'] = $output['node']->getSchemaValue(); + } + + // Replace children with serialised elements + $output['children'] = []; + foreach ($data['children'] as $child) { + $output['children'][] = $this->getSubtreeAsArray($child, $serialiseEval); + } + return $output; + } + + /** + * Get tree data for node + * + * @param DataObject $node + * @param int $depth + * @return array|string + */ + protected function getSubtree($node, $depth = 0) + { + // Check if this node is limited due to child count + $numChildren = $this->getNumChildren($node); + $limited = $this->isNodeLimited($node, $numChildren); + + // Build root rode + $expanded = $this->isExpanded($node); + $opened = $this->isTreeOpened($node); + $output = [ + 'node' => $node, + 'marked' => $this->isMarked($node), + 'expanded' => $expanded, + 'opened' => $opened, + 'depth' => $depth, + 'count' => $numChildren, // Count of DB children + 'limited' => $limited, // Flag whether 'items' has been limited + 'children' => [], // Children to return in this request + ]; + + // Don't iterate children if past limit + // or not expanded (requires subsequent request to get) + if ($limited || !$expanded) { + return $output; + } + + // Get children + $children = $this->getChildren($node); + foreach ($children as $child) { + // Recurse + if ($this->isMarked($child)) { + $output['children'][] = $this->getSubtree($child, $depth + 1); + } + } + return $output; + } + + /** + * Mark a segment of the tree, by calling mark(). + * + * The method performs a breadth-first traversal until the number of nodes is more than minCount. This is used to + * get a limited number of tree nodes to show in the CMS initially. + * + * This method returns the number of nodes marked. After this method is called other methods can check + * {@link isExpanded()} and {@link isMarked()} on individual nodes. + * + * @return $this + */ + public function markPartialTree() + { + $nodeCountThreshold = $this->getNodeCountThreshold(); + + // Add root node, not-expanded by default + /** @var DataObject|Hierarchy $rootNode */ + $rootNode = $this->rootNode; + $this->clearMarks(); + $this->markUnexpanded($rootNode); + + // Build markedNodes for this subtree until we reach the threshold + // foreach can't handle an ever-growing $nodes list + while (list(, $node) = each($this->markedNodes)) { + $children = $this->markChildren($node); + if ($nodeCountThreshold && sizeof($this->markedNodes) > $nodeCountThreshold) { + // Undo marking children as opened since they're lazy loaded + /** @var DataObject|Hierarchy $child */ + foreach ($children as $child) { + $this->markClosed($child); + } + break; + } + } + return $this; + } + + /** + * Filter the marking to only those object with $node->$parameterName == $parameterValue + * + * @param string $parameterName The parameter on each node to check when marking. + * @param mixed $parameterValue The value the parameter must be to be marked. + * @return $this + */ + public function setMarkingFilter($parameterName, $parameterValue) + { + $this->markingFilter = array( + "parameter" => $parameterName, + "value" => $parameterValue + ); + return $this; + } + + /** + * Filter the marking to only those where the function returns true. The node in question will be passed to the + * function. + * + * @param callable $callback Callback to filter + * @return $this + */ + public function setMarkingFilterFunction($callback) + { + $this->markingFilter = array( + "func" => $callback, + ); + return $this; + } + + /** + * Returns true if the marking filter matches on the given node. + * + * @param DataObject $node Node to check + * @return bool + */ + protected function markingFilterMatches(DataObject $node) + { + if (!$this->markingFilter) { + return true; + } + + // Func callback filter + if (isset($this->markingFilter['func'])) { + $func = $this->markingFilter['func']; + return call_user_func($func, $node); + } + + // Check object property filter + if (isset($this->markingFilter['parameter'])) { + $parameterName = $this->markingFilter['parameter']; + $value = $this->markingFilter['value']; + + if (is_array($value)) { + return in_array($node->$parameterName, $value); + } else { + return $node->$parameterName == $value; + } + } + + throw new LogicException("Invalid marking filter"); + } + + /** + * Mark all children of the given node that match the marking filter. + * + * @param DataObject $node Parent node + * @return array List of children marked by this operation + */ + protected function markChildren(DataObject $node) + { + $this->markExpanded($node); + + // If too many children leave closed + if ($this->isNodeLimited($node)) { + // Limited nodes are always expanded + $this->markClosed($node); + return []; + } + + // Iterate children if not limited + $children = $this->getChildren($node); + if (!$children) { + return []; + } + + // Mark all children + $markedChildren = []; + foreach ($children as $child) { + $markingMatches = $this->markingFilterMatches($child); + if (!$markingMatches) { + continue; + } + // Mark a child node as unexpanded if it has children and has not already been expanded + if ($this->getNumChildren($child) > 0 && !$this->isExpanded($child)) { + $this->markUnexpanded($child); + } else { + $this->markExpanded($child); + } + + $markedChildren[] = $child; + } + return $markedChildren; + } + + /** + * Return CSS classes of 'unexpanded', 'closed', both, or neither, as well as a 'jstree-*' state depending on the + * marking of this DataObject. + * + * @param DataObject $node + * @return string + */ + protected function markingClasses($node) + { + $classes = []; + if (!$this->isExpanded($node)) { + $classes[] = 'unexpanded'; + } + + // Set jstree open state, or mark it as a leaf (closed) if there are no children + if (!$this->getNumChildren($node)) { + // No children + $classes[] = "jstree-leaf closed"; + } elseif ($this->isTreeOpened($node)) { + // Open with children + $classes[] = "jstree-open"; + } else { + // Closed with children + $classes[] = "jstree-closed closed"; + } + return implode(' ', $classes); + } + + /** + * Mark the children of the DataObject with the given ID. + * + * @param int $id ID of parent node + * @param bool $open If this is true, mark the parent node as opened + * @return bool + */ + public function markById($id, $open = false) + { + if (isset($this->markedNodes[$id])) { + $this->markChildren($this->markedNodes[$id]); + if ($open) { + $this->markOpened($this->markedNodes[$id]); + } + return true; + } else { + return false; + } + } + + /** + * Expose the given object in the tree, by marking this page and all it ancestors. + * + * @param DataObject|Hierarchy $childObj + * @return $this + */ + public function markToExpose(DataObject $childObj) + { + if (!$childObj) { + return $this; + } + $stack = $childObj->getAncestors(true)->reverse(); + foreach ($stack as $stackItem) { + $this->markById($stackItem->ID, true); + } + return $this; + } + + /** + * Return the IDs of all the marked nodes. + * + * @refactor called from CMSMain + * @return array + */ + public function markedNodeIDs() + { + return array_keys($this->markedNodes); + } + + /** + * Cache of DataObjects' expanded statuses: [ClassName][ID] = bool + * @var array + */ + protected $expanded = []; + + /** + * Cache of DataObjects' opened statuses: [ID] = bool + * @var array + */ + protected $treeOpened = []; + + /** + * Reset marked nodes + */ + public function clearMarks() + { + $this->markedNodes = []; + $this->expanded = []; + $this->treeOpened = []; + } + + /** + * Mark this DataObject as expanded. + * + * @param DataObject $node + * @return $this + */ + public function markExpanded(DataObject $node) + { + $id = $node->ID ?: 0; + $this->markedNodes[$id] = $node; + $this->expanded[$id] = true; + return $this; + } + + /** + * Mark this DataObject as unexpanded. + * + * @param DataObject $node + * @return $this + */ + public function markUnexpanded(DataObject $node) + { + $id = $node->ID ?: 0; + $this->markedNodes[$id] = $node; + unset($this->expanded[$id]); + return $this; + } + + /** + * Mark this DataObject's tree as opened. + * + * @param DataObject $node + * @return $this + */ + public function markOpened(DataObject $node) + { + $id = $node->ID ?: 0; + $this->markedNodes[$id] = $node; + $this->treeOpened[$id] = true; + return $this; + } + + /** + * Mark this DataObject's tree as closed. + * + * @param DataObject $node + * @return $this + */ + public function markClosed(DataObject $node) + { + $id = $node->ID ?: 0; + $this->markedNodes[$id] = $node; + unset($this->treeOpened[$id]); + return $this; + } + + /** + * Check if this DataObject is marked. + * + * @param DataObject $node + * @return bool + */ + public function isMarked(DataObject $node) + { + $id = $node->ID ?: 0; + return !empty($this->markedNodes[$id]); + } + + /** + * Check if this DataObject is expanded. + * An expanded object has had it's children iterated through. + * + * @param DataObject $node + * @return bool + */ + public function isExpanded(DataObject $node) + { + $id = $node->ID ?: 0; + return !empty($this->expanded[$id]); + } + + /** + * Check if this DataObject's tree is opened. + * This is an expanded node which also should have children visually shown. + * + * @param DataObject $node + * @return bool + */ + public function isTreeOpened(DataObject $node) + { + $id = $node->ID ?: 0; + return !empty($this->treeOpened[$id]); + } + + /** + * Check if this node has too many children + * + * @param DataObject|Hierarchy $node + * @param int $count Children count (if already calculated) + * @return bool + */ + protected function isNodeLimited(DataObject $node, $count = null) + { + // Singleton root node isn't limited + if (!$node->ID) { + return false; + } + + // Check if limiting is enabled first + if (!$this->getLimitingEnabled()) { + return false; + } + + // Count children for this node and compare to max + if (!isset($count)) { + $count = $this->getNumChildren($node); + } + return $count > $this->getMaxChildNodes(); + } + + /** + * Toggle limiting on or off + * + * @param bool $enabled + * @return $this + */ + public function setLimitingEnabled($enabled) + { + $this->enableLimiting = $enabled; + return $this; + } + + /** + * Check if limiting is enabled + * + * @return bool + */ + public function getLimitingEnabled() + { + return $this->enableLimiting; + } +} diff --git a/templates/SilverStripe/Forms/Includes/TreeDropdownField_HTML.ss b/templates/SilverStripe/Forms/Includes/TreeDropdownField_HTML.ss new file mode 100644 index 000000000..c802b3061 --- /dev/null +++ b/templates/SilverStripe/Forms/Includes/TreeDropdownField_HTML.ss @@ -0,0 +1,28 @@ +<% if $depth == '0' && not $isSubTree %> + + <% end_if %> +<% end_if %> diff --git a/templates/SilverStripe/ORM/Hierarchy/Includes/MarkedSet_HTML.ss b/templates/SilverStripe/ORM/Hierarchy/Includes/MarkedSet_HTML.ss new file mode 100644 index 000000000..f2a4fdec7 --- /dev/null +++ b/templates/SilverStripe/ORM/Hierarchy/Includes/MarkedSet_HTML.ss @@ -0,0 +1,9 @@ +<% if $children || $limited %> + +<% end_if %> diff --git a/tests/php/Forms/TreeDropdownFieldTest.php b/tests/php/Forms/TreeDropdownFieldTest.php index de31346dc..cf7a9e23a 100644 --- a/tests/php/Forms/TreeDropdownFieldTest.php +++ b/tests/php/Forms/TreeDropdownFieldTest.php @@ -21,7 +21,8 @@ class TreeDropdownFieldTest extends SapphireTest // case insensitive search against keyword 'sub' for folders $request = new HTTPRequest('GET', 'url', array('search'=>'sub')); - $tree = $field->tree($request); + $response = $field->tree($request); + $tree = $response->getBody(); $folder1 = $this->objFromFixture(Folder::class, 'folder1'); $folder1Subfolder1 = $this->objFromFixture(Folder::class, 'folder1-subfolder1'); @@ -57,7 +58,8 @@ class TreeDropdownFieldTest extends SapphireTest // case insensitive search against keyword 'sub' for files $request = new HTTPRequest('GET', 'url', array('search'=>'sub')); - $tree = $field->tree($request); + $response = $field->tree($request); + $tree = $response->getBody(); $parser = new CSSContentParser($tree); diff --git a/tests/php/ORM/HierarchyTest.php b/tests/php/ORM/HierarchyTest.php index e9790c48b..330c07df4 100644 --- a/tests/php/ORM/HierarchyTest.php +++ b/tests/php/ORM/HierarchyTest.php @@ -4,8 +4,6 @@ namespace SilverStripe\ORM\Tests; use SilverStripe\ORM\ValidationException; use SilverStripe\Versioned\Versioned; -use SilverStripe\ORM\DataObject; -use SilverStripe\Dev\CSSContentParser; use SilverStripe\Dev\SapphireTest; class HierarchyTest extends SapphireTest @@ -42,11 +40,13 @@ class HierarchyTest extends SapphireTest */ public function testPreventLoop() { - $this->setExpectedException( - ValidationException::class, - sprintf('Infinite loop found within the "%s" hierarchy', HierarchyTest\TestObject::class) - ); + $this->expectException(ValidationException::class); + $this->expectExceptionMessage(sprintf( + 'Infinite loop found within the "%s" hierarchy', + HierarchyTest\TestObject::class + )); + /** @var HierarchyTest\TestObject $obj2 */ $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); $obj2aa = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2aa'); @@ -67,13 +67,14 @@ class HierarchyTest extends SapphireTest // Check that obj1-3 appear at the top level of the AllHistoricalChildren tree $this->assertEquals( array("Obj 1", "Obj 2", "Obj 3"), - singleton(HierarchyTest\TestObject::class)->AllHistoricalChildren()->column('Title') + HierarchyTest\TestObject::singleton()->AllHistoricalChildren()->column('Title') ); // Check numHistoricalChildren - $this->assertEquals(3, singleton(HierarchyTest\TestObject::class)->numHistoricalChildren()); + $this->assertEquals(3, HierarchyTest\TestObject::singleton()->numHistoricalChildren()); // Check that both obj 2 children are returned + /** @var HierarchyTest\TestObject $obj2 */ $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); $this->assertEquals( array("Obj 2a", "Obj 2b"), @@ -85,7 +86,11 @@ class HierarchyTest extends SapphireTest // Obj 3 has been deleted; let's bring it back from the grave - $obj3 = Versioned::get_including_deleted(HierarchyTest\TestObject::class, "\"Title\" = 'Obj 3'")->First(); + /** @var HierarchyTest\TestObject $obj3 */ + $obj3 = Versioned::get_including_deleted( + HierarchyTest\TestObject::class, + "\"Title\" = 'Obj 3'" + )->First(); // Check that all obj 3 children are returned $this->assertEquals( @@ -97,46 +102,30 @@ class HierarchyTest extends SapphireTest $this->assertEquals(4, $obj3->numHistoricalChildren()); } - /** - * Test that you can call Hierarchy::markExpanded/Unexpanded/Open() on a obj, and that - * calling Hierarchy::isMarked() on a different instance of that object will return true. - */ - public function testItemMarkingIsntRestrictedToSpecificInstance() - { - // Mark a few objs - $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2')->markExpanded(); - $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a')->markExpanded(); - $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2b')->markExpanded(); - $this->objFromFixture(HierarchyTest\TestObject::class, 'obj3')->markUnexpanded(); - - // Query some objs in a different context and check their m - $objs = DataObject::get(HierarchyTest\TestObject::class, '', '"ID" ASC'); - $marked = $expanded = array(); - foreach ($objs as $obj) { - if ($obj->isMarked()) { - $marked[] = $obj->Title; - } - if ($obj->isExpanded()) { - $expanded[] = $obj->Title; - } - } - - $this->assertEquals(array('Obj 2', 'Obj 3', 'Obj 2a', 'Obj 2b'), $marked); - $this->assertEquals(array('Obj 2', 'Obj 2a', 'Obj 2b'), $expanded); - } - public function testNumChildren() { - $this->assertEquals($this->objFromFixture(HierarchyTest\TestObject::class, 'obj1')->numChildren(), 0); - $this->assertEquals($this->objFromFixture(HierarchyTest\TestObject::class, 'obj2')->numChildren(), 2); - $this->assertEquals($this->objFromFixture(HierarchyTest\TestObject::class, 'obj3')->numChildren(), 4); - $this->assertEquals($this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a')->numChildren(), 2); - $this->assertEquals($this->objFromFixture(HierarchyTest\TestObject::class, 'obj2b')->numChildren(), 0); - $this->assertEquals($this->objFromFixture(HierarchyTest\TestObject::class, 'obj3a')->numChildren(), 2); - $this->assertEquals($this->objFromFixture(HierarchyTest\TestObject::class, 'obj3d')->numChildren(), 0); - + /** @var HierarchyTest\TestObject $obj1 */ $obj1 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj1'); - $this->assertEquals($obj1->numChildren(), 0); + /** @var HierarchyTest\TestObject $obj2 */ + $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); + /** @var HierarchyTest\TestObject $obj3 */ + $obj3 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj3'); + /** @var HierarchyTest\TestObject $obj2a */ + $obj2a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a'); + /** @var HierarchyTest\TestObject $obj2b */ + $obj2b = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2b'); + /** @var HierarchyTest\TestObject $obj3a */ + $obj3a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj3a'); + /** @var HierarchyTest\TestObject $obj3b */ + $obj3b = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj3d'); + + $this->assertEquals(0, $obj1->numChildren()); + $this->assertEquals(2, $obj2->numChildren()); + $this->assertEquals(4, $obj3->numChildren()); + $this->assertEquals(2, $obj2a->numChildren()); + $this->assertEquals(0, $obj2b->numChildren()); + $this->assertEquals(2, $obj3a->numChildren()); + $this->assertEquals(0, $obj3b->numChildren()); $obj1Child1 = new HierarchyTest\TestObject(); $obj1Child1->ParentID = $obj1->ID; $obj1Child1->write(); @@ -158,7 +147,9 @@ class HierarchyTest extends SapphireTest public function testLoadDescendantIDListIntoArray() { + /** @var HierarchyTest\TestObject $obj2 */ $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); + /** @var HierarchyTest\TestObject $obj2a */ $obj2a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a'); $obj2b = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2b'); $obj2aa = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2aa'); @@ -184,9 +175,13 @@ class HierarchyTest extends SapphireTest */ public function testLiveChildrenOnlyDeletedFromStage() { + /** @var HierarchyTest\TestObject $obj1 */ $obj1 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj1'); + /** @var HierarchyTest\TestObject $obj2 */ $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); + /** @var HierarchyTest\TestObject $obj2a */ $obj2a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a'); + /** @var HierarchyTest\TestObject $obj2b */ $obj2b = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2b'); // Get a published set of objects for our fixture @@ -212,9 +207,11 @@ class HierarchyTest extends SapphireTest public function testBreadcrumbs() { + /** @var HierarchyTest\TestObject $obj1 */ $obj1 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj1'); - $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); + /** @var HierarchyTest\TestObject $obj2a */ $obj2a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a'); + /** @var HierarchyTest\TestObject $obj2aa */ $obj2aa = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2aa'); $this->assertEquals('Obj 1', $obj1->getBreadcrumbs()); @@ -222,402 +219,9 @@ class HierarchyTest extends SapphireTest $this->assertEquals('Obj 2 » Obj 2a » Obj 2aa', $obj2aa->getBreadcrumbs()); } - /** - * @covers \SilverStripe\ORM\Hierarchy\Hierarchy::markChildren() - */ - public function testMarkChildrenDoesntUnmarkPreviouslyMarked() - { - $obj3 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj3'); - $obj3aa = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj3aa'); - $obj3ba = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj3ba'); - $obj3ca = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj3ca'); - - $obj3->markPartialTree(); - $obj3->markToExpose($obj3aa); - $obj3->markToExpose($obj3ba); - $obj3->markToExpose($obj3ca); - - $expected = << -
  • Obj 3a - -
  • -
  • Obj 3b - -
  • -
  • Obj 3c - -
  • -
  • Obj 3d -
  • - - -EOT; - - $this->assertSame($expected, $obj3->getChildrenAsUL()); - } - - public function testGetChildrenAsUL() - { - $obj1 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj1'); - $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); - $obj2a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a'); - $obj2aa = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2aa'); - - $nodeCountThreshold = 30; - - $root = new HierarchyTest\TestObject(); - $root->markPartialTree($nodeCountThreshold); - $html = $root->getChildrenAsUL( - "", - '"
  • ID . "\">" . $child->Title', - null, - false, - "AllChildrenIncludingDeleted", - "numChildren", - true, // rootCall - $nodeCountThreshold - ); - $this->assertTreeContains( - $html, - array($obj2), - 'Contains root elements' - ); - $this->assertTreeContains( - $html, - array($obj2, $obj2a), - 'Contains child elements (in correct nesting)' - ); - $this->assertTreeContains( - $html, - array($obj2, $obj2a, $obj2aa), - 'Contains grandchild elements (in correct nesting)' - ); - } - - public function testGetChildrenAsULMinNodeCount() - { - $obj1 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj1'); - $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); - $obj2a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a'); - - // Set low enough that it should be fulfilled by root only elements - $nodeCountThreshold = 3; - - $root = new HierarchyTest\TestObject(); - $root->markPartialTree($nodeCountThreshold); - $html = $root->getChildrenAsUL( - "", - '"
  • ID . "\">" . $child->Title', - null, - false, - "AllChildrenIncludingDeleted", - "numChildren", - true, - $nodeCountThreshold - ); - $this->assertTreeContains( - $html, - array($obj1), - 'Contains root elements' - ); - $this->assertTreeContains( - $html, - array($obj2), - 'Contains root elements' - ); - $this->assertTreeNotContains( - $html, - array($obj2, $obj2a), - 'Does not contains child elements because they exceed minNodeCount' - ); - } - - public function testGetChildrenAsULMinNodeCountWithMarkToExpose() - { - $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); - $obj2a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a'); - $obj2aa = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2aa'); - - // Set low enough that it should be fulfilled by root only elements - $nodeCountThreshold = 3; - - $root = new HierarchyTest\TestObject(); - $root->markPartialTree($nodeCountThreshold); - - // Mark certain node which should be included regardless of minNodeCount restrictions - $root->markToExpose($obj2aa); - - $html = $root->getChildrenAsUL( - "", - '"
  • ID . "\">" . $child->Title', - null, - false, - "AllChildrenIncludingDeleted", - "numChildren", - true, - $nodeCountThreshold - ); - $this->assertTreeContains( - $html, - array($obj2), - 'Contains root elements' - ); - $this->assertTreeContains( - $html, - array($obj2, $obj2a, $obj2aa), - 'Does contain marked children nodes regardless of configured threshold' - ); - } - - public function testGetChildrenAsULMinNodeCountWithFilters() - { - $obj1 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj1'); - $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); - $obj2a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a'); - $obj2aa = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2aa'); - - // Set low enough that it should fit all search matches without lazy loading - $nodeCountThreshold = 3; - - $root = new HierarchyTest\TestObject(); - - // Includes nodes by filter regardless of minNodeCount restrictions - $root->setMarkingFilterFunction( - function ($record) use ($obj2, $obj2a, $obj2aa) { - // Results need to include parent hierarchy, even if we just want to - // match the innermost node. - return in_array($record->ID, array($obj2->ID, $obj2a->ID, $obj2aa->ID)); - } - ); - $root->markPartialTree($nodeCountThreshold); - - $html = $root->getChildrenAsUL( - "", - '"
  • ID . "\">" . $child->Title', - null, - true, // limit to marked - "AllChildrenIncludingDeleted", - "numChildren", - true, - $nodeCountThreshold - ); - $this->assertTreeNotContains( - $html, - array($obj1), - 'Does not contain root elements which dont match the filter' - ); - $this->assertTreeContains( - $html, - array($obj2, $obj2a, $obj2aa), - 'Contains non-root elements which match the filter' - ); - } - - public function testGetChildrenAsULHardLimitsNodes() - { - $obj1 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj1'); - $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); - $obj2a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a'); - $obj2aa = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2aa'); - - // Set low enough that it should fit all search matches without lazy loading - $nodeCountThreshold = 3; - - $root = new HierarchyTest\TestObject(); - - // Includes nodes by filter regardless of minNodeCount restrictions - $root->setMarkingFilterFunction( - function ($record) use ($obj2, $obj2a, $obj2aa) { - // Results need to include parent hierarchy, even if we just want to - // match the innermost node. - return in_array($record->ID, array($obj2->ID, $obj2a->ID, $obj2aa->ID)); - } - ); - $root->markPartialTree($nodeCountThreshold); - - $html = $root->getChildrenAsUL( - "", - '"
  • ID . "\">" . $child->Title', - null, - true, // limit to marked - "AllChildrenIncludingDeleted", - "numChildren", - true, - $nodeCountThreshold - ); - $this->assertTreeNotContains( - $html, - array($obj1), - 'Does not contain root elements which dont match the filter' - ); - $this->assertTreeContains( - $html, - array($obj2, $obj2a, $obj2aa), - 'Contains non-root elements which match the filter' - ); - } - - public function testGetChildrenAsULNodeThresholdLeaf() - { - $obj1 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj1'); - $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); - $obj2a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a'); - $obj3 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj3'); - $obj3a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj3a'); - - $nodeCountThreshold = 99; - - $root = new HierarchyTest\TestObject(); - $root->markPartialTree($nodeCountThreshold); - $nodeCountCallback = function ($parent, $numChildren) { - // Set low enough that it the fixture structure should exceed it - if ($parent->ID && $numChildren > 2) { - return 'Exceeded!'; - } - }; - - $html = $root->getChildrenAsUL( - "", - '"
  • ID . "\">" . $child->Title', - null, - true, // limit to marked - "AllChildrenIncludingDeleted", - "numChildren", - true, - $nodeCountThreshold, - $nodeCountCallback - ); - $this->assertTreeContains( - $html, - array($obj1), - 'Does contain root elements regardless of count' - ); - $this->assertTreeContains( - $html, - array($obj3), - 'Does contain root elements regardless of count' - ); - $this->assertTreeContains( - $html, - array($obj2, $obj2a), - 'Contains children which do not exceed threshold' - ); - $this->assertTreeNotContains( - $html, - array($obj3, $obj3a), - 'Does not contain children which exceed threshold' - ); - } - - /** - * This test checks that deleted ('archived') child pages don't set a css class on the parent - * node that makes it look like it has children - */ - public function testGetChildrenAsULNodeDeletedOnLive() - { - $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); - $obj2a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a'); - $obj2aa = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2aa'); - $obj2ab = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2b'); - - // delete all children under obj2 - $obj2a->delete(); - $obj2aa->delete(); - $obj2ab->delete(); - // Don't pre-load all children - $nodeCountThreshold = 1; - - $childrenMethod = 'AllChildren'; - $numChildrenMethod = 'numChildren'; - - $root = new HierarchyTest\TestObject(); - $root->markPartialTree($nodeCountThreshold, null, $childrenMethod, $numChildrenMethod); - - // As in LeftAndMain::getSiteTreeFor() but simpler and more to the point for testing purposes - $titleFn = function (&$child, $numChildrenMethod = "") { - return '
  • "' . $child->Title; - }; - - $html = $root->getChildrenAsUL( - "", - $titleFn, - null, - true, // limit to marked - $childrenMethod, - $numChildrenMethod, - true, - $nodeCountThreshold - ); - - // Get the class attribute from the $obj2 node in the sitetree, class 'jstree-leaf' means it's a leaf node - $nodeClass = $this->getNodeClassFromTree($html, $obj2); - $this->assertEquals('jstree-leaf closed', $nodeClass, 'object2 should not have children in the sitetree'); - } - - /** - * This test checks that deleted ('archived') child pages _do_ set a css class on the parent - * node that makes it look like it has children when getting all children including deleted - */ - public function testGetChildrenAsULNodeDeletedOnStage() - { - $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); - $obj2a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a'); - $obj2aa = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2aa'); - $obj2ab = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2b'); - - // delete all children under obj2 - $obj2a->delete(); - $obj2aa->delete(); - $obj2ab->delete(); - // Don't pre-load all children - $nodeCountThreshold = 1; - - $childrenMethod = 'AllChildrenIncludingDeleted'; - $numChildrenMethod = 'numHistoricalChildren'; - - $root = new HierarchyTest\TestObject(); - $root->markPartialTree($nodeCountThreshold, null, $childrenMethod, $numChildrenMethod); - - // As in LeftAndMain::getSiteTreeFor() but simpler and more to the point for testing purposes - $titleFn = function (&$child, $numChildrenMethod = "") { - return '
  • "' . $child->Title; - }; - - $html = $root->getChildrenAsUL( - "", - $titleFn, - null, - true, // limit to marked - $childrenMethod, - $numChildrenMethod, - true, - $nodeCountThreshold - ); - - // Get the class attribute from the $obj2 node in the sitetree - $nodeClass = $this->getNodeClassFromTree($html, $obj2); - // Object2 can now be expanded - $this->assertEquals('unexpanded jstree-closed closed', $nodeClass, 'obj2 should have children in the sitetree'); - } - public function testNoHideFromHeirarchy() { + /** @var HierarchyTest\HideTestObject $obj4 */ $obj4 = $this->objFromFixture(HierarchyTest\HideTestObject::class, 'obj4'); $obj4->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); @@ -632,10 +236,9 @@ EOT; { HierarchyTest\HideTestObject::config()->update( 'hide_from_hierarchy', - [ - HierarchyTest\HideTestSubObject::class, - ] + [ HierarchyTest\HideTestSubObject::class ] ); + /** @var HierarchyTest\HideTestObject $obj4 */ $obj4 = $this->objFromFixture(HierarchyTest\HideTestObject::class, 'obj4'); $obj4->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); @@ -645,63 +248,11 @@ EOT; ->filter('ParentID', (int)$obj4->ID) ->exclude('ID', (int)$obj4->ID); + /** @var HierarchyTest\HideTestObject $child */ foreach ($children as $child) { $child->copyVersionToStage(Versioned::DRAFT, Versioned::LIVE); } $this->assertEquals($obj4->stageChildren()->Count(), 1); $this->assertEquals($obj4->liveChildren()->Count(), 1); } - - /** - * @param String $html [description] - * @param array $nodes Breadcrumb path as array - * @param String $message - */ - protected function assertTreeContains($html, $nodes, $message = null) - { - $parser = new CSSContentParser($html); - $xpath = '/'; - foreach ($nodes as $node) { - $xpath .= '/ul/li[@id="' . $node->ID . '"]'; - } - $match = $parser->getByXpath($xpath); - self::assertThat((bool)$match, self::isTrue(), $message); - } - - /** - * @param String $html [description] - * @param array $nodes Breadcrumb path as array - * @param String $message - */ - protected function assertTreeNotContains($html, $nodes, $message = null) - { - $parser = new CSSContentParser($html); - $xpath = '/'; - foreach ($nodes as $node) { - $xpath .= '/ul/li[@id="' . $node->ID . '"]'; - } - $match = $parser->getByXpath($xpath); - self::assertThat((bool)$match, self::isFalse(), $message); - } - - /** - * Get the HTML class attribute from a node in the sitetree - * - * @param $html - * @param $node - * @return string - */ - protected function getNodeClassFromTree($html, $node) - { - $parser = new CSSContentParser($html); - $xpath = '//ul/li[@id="' . $node->ID . '"]'; - $object = $parser->getByXpath($xpath); - - foreach ($object[0]->attributes() as $key => $attr) { - if ($key == 'class') { - return (string)$attr; - } - } - return ''; - } } diff --git a/tests/php/ORM/HierarchyTest/MarkedSetTest_HTML.ss b/tests/php/ORM/HierarchyTest/MarkedSetTest_HTML.ss new file mode 100644 index 000000000..04bee37dc --- /dev/null +++ b/tests/php/ORM/HierarchyTest/MarkedSetTest_HTML.ss @@ -0,0 +1,9 @@ +<% if $children || $limited %> + +<% end_if %> diff --git a/tests/php/ORM/MarkedSetTest.php b/tests/php/ORM/MarkedSetTest.php new file mode 100644 index 000000000..e85ebe025 --- /dev/null +++ b/tests/php/ORM/MarkedSetTest.php @@ -0,0 +1,444 @@ +markTestSkipped('MarkedSetTest requires the Versioned extension'); + } + } + + + /** + * Test that you can call MarkedSet::markExpanded/Unexpanded/Open() on a obj, and that + * calling MarkedSet::isMarked() on a different instance of that object will return true. + */ + public function testItemMarkingIsntRestrictedToSpecificInstance() + { + // Build new object + $set = new MarkedSet(HierarchyTest\TestObject::singleton()); + + // Mark a few objs + $set->markExpanded($this->objFromFixture(HierarchyTest\TestObject::class, 'obj2')); + $set->markExpanded($this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a')); + $set->markExpanded($this->objFromFixture(HierarchyTest\TestObject::class, 'obj2b')); + $set->markUnexpanded($this->objFromFixture(HierarchyTest\TestObject::class, 'obj3')); + + // Query some objs in a different context and check their m + $objs = DataObject::get(HierarchyTest\TestObject::class, '', '"ID" ASC'); + $marked = $expanded = array(); + foreach ($objs as $obj) { + if ($set->isMarked($obj)) { + $marked[] = $obj->Title; + } + if ($set->isExpanded($obj)) { + $expanded[] = $obj->Title; + } + } + $this->assertEquals(array('Obj 2', 'Obj 3', 'Obj 2a', 'Obj 2b'), $marked); + $this->assertEquals(array('Obj 2', 'Obj 2a', 'Obj 2b'), $expanded); + } + + /** + * @covers \SilverStripe\ORM\Hierarchy\MarkedSet::markChildren() + */ + public function testMarkChildrenDoesntUnmarkPreviouslyMarked() + { + $obj3 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj3'); + $obj3aa = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj3aa'); + $obj3ba = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj3ba'); + $obj3ca = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj3ca'); + + $set = new MarkedSet($obj3); + $set->markPartialTree(); + $set->markToExpose($obj3aa); + $set->markToExpose($obj3ba); + $set->markToExpose($obj3ca); + + $expected = << +
  • Obj 3a + +
  • +
  • Obj 3b + +
  • +
  • Obj 3c + +
  • +
  • Obj 3d +
  • + + +EOT; + + $this->assertHTMLSame($expected, $set->renderChildren()); + } + + public function testGetChildrenCustomTemplate() + { + $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); + $obj2a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a'); + $obj2aa = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2aa'); + + // Render marked tree + $set = new MarkedSet(HierarchyTest\TestObject::singleton(), 'AllChildrenIncludingDeleted', 'numChildren', 30); + $set->markPartialTree(); + $template = __DIR__ . '/HierarchyTest/MarkedSetTest_HTML.ss'; + $html = $set->renderChildren($template); + + $this->assertTreeContains( + $html, + array($obj2), + 'Contains root elements' + ); + $this->assertTreeContains( + $html, + array($obj2, $obj2a), + 'Contains child elements (in correct nesting)' + ); + $this->assertTreeContains( + $html, + array($obj2, $obj2a, $obj2aa), + 'Contains grandchild elements (in correct nesting)' + ); + } + + public function testGetChildrenAsULMinNodeCount() + { + $obj1 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj1'); + $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); + $obj2a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a'); + + // Render marked tree + $set = new MarkedSet(HierarchyTest\TestObject::singleton(), 'AllChildrenIncludingDeleted', 'numChildren'); + $set->setNodeCountThreshold(3); // Set low enough that it should be fulfilled by root only elements + $set->markPartialTree(); + $template = __DIR__ . '/HierarchyTest/MarkedSetTest_HTML.ss'; + $html = $set->renderChildren($template); + + $this->assertTreeContains( + $html, + array($obj1), + 'Contains root elements' + ); + $this->assertTreeContains( + $html, + array($obj2), + 'Contains root elements' + ); + $this->assertTreeNotContains( + $html, + array($obj2, $obj2a), + 'Does not contains child elements because they exceed minNodeCount' + ); + } + + public function testGetChildrenAsULMinNodeCountWithMarkToExpose() + { + $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); + $obj2a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a'); + $obj2aa = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2aa'); + + // Render marked tree + $set = new MarkedSet(HierarchyTest\TestObject::singleton(), 'AllChildrenIncludingDeleted', 'numChildren'); + $set->setNodeCountThreshold(3); // Set low enough that it should be fulfilled by root only elements + $set->markPartialTree(); + // Mark certain node which should be included regardless of minNodeCount restrictions + $set->markToExpose($obj2aa); + $template = __DIR__ . '/HierarchyTest/MarkedSetTest_HTML.ss'; + $html = $set->renderChildren($template); + + $this->assertTreeContains( + $html, + array($obj2), + 'Contains root elements' + ); + $this->assertTreeContains( + $html, + array($obj2, $obj2a, $obj2aa), + 'Does contain marked children nodes regardless of configured threshold' + ); + } + + public function testGetChildrenAsULMinNodeCountWithFilters() + { + $obj1 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj1'); + $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); + $obj2a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a'); + $obj2aa = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2aa'); + + // Render marked tree + $set = new MarkedSet(HierarchyTest\TestObject::singleton(), 'AllChildrenIncludingDeleted', 'numChildren'); + $set->setNodeCountThreshold(3); // Set low enough that it should be fulfilled by root only elements + // Includes nodes by filter regardless of minNodeCount restrictions + $set->setMarkingFilterFunction( + function ($record) use ($obj2, $obj2a, $obj2aa) { + // Results need to include parent hierarchy, even if we just want to + // match the innermost node. + return in_array($record->ID, array($obj2->ID, $obj2a->ID, $obj2aa->ID)); + } + ); + $set->markPartialTree(); + // Mark certain node which should be included regardless of minNodeCount restrictions + $template = __DIR__ . '/HierarchyTest/MarkedSetTest_HTML.ss'; + $html = $set->renderChildren($template); + + $this->assertTreeNotContains( + $html, + array($obj1), + 'Does not contain root elements which dont match the filter' + ); + $this->assertTreeContains( + $html, + array($obj2, $obj2a, $obj2aa), + 'Contains non-root elements which match the filter' + ); + } + + public function testGetChildrenAsULHardLimitsNodes() + { + $obj1 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj1'); + $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); + $obj2a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a'); + $obj2aa = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2aa'); + + // Render marked tree + $set = new MarkedSet(HierarchyTest\TestObject::singleton(), 'AllChildrenIncludingDeleted', 'numChildren'); + $set->setNodeCountThreshold(3); // Set low enough that it should miss out one node + + // Includes nodes by filter regardless of minNodeCount restrictions + $set->setMarkingFilterFunction( + function ($record) use ($obj2, $obj2a, $obj2aa) { + // Results need to include parent hierarchy, even if we just want to + // match the innermost node. + return in_array($record->ID, array($obj2->ID, $obj2a->ID, $obj2aa->ID)); + } + ); + $set->markPartialTree(); + $template = __DIR__ . '/HierarchyTest/MarkedSetTest_HTML.ss'; + $html = $set->renderChildren($template); + + $this->assertTreeNotContains( + $html, + array($obj1, $obj2aa), + 'Does not contain root elements which dont match the filter or are limited' + ); + $this->assertTreeContains( + $html, + array($obj2, $obj2a), + 'Contains non-root elements which match the filter' + ); + } + + public function testGetChildrenAsULNodeThresholdLeaf() + { + $obj1 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj1'); + $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); + $obj2a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a'); + $obj3 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj3'); + $obj3a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj3a'); + + // Render marked tree + $set = new MarkedSet(HierarchyTest\TestObject::singleton(), 'AllChildrenIncludingDeleted', 'numChildren'); + $set->setNodeCountThreshold(99); + $set->setMaxChildNodes(2); // Force certain children to exceed limits + $set->markPartialTree(); + $template = __DIR__ . '/HierarchyTest/MarkedSetTest_HTML.ss'; + $html = $set->renderChildren($template); + + $this->assertTreeContains( + $html, + array($obj1), + 'Does contain root elements regardless of count' + ); + $this->assertTreeContains( + $html, + array($obj3), + 'Does contain root elements regardless of count' + ); + $this->assertTreeContains( + $html, + array($obj2, $obj2a), + 'Contains children which do not exceed threshold' + ); + $this->assertTreeNotContains( + $html, + array($obj3, $obj3a), + 'Does not contain children which exceed threshold' + ); + } + + /** + * This test checks that deleted ('archived') child pages don't set a css class on the parent + * node that makes it look like it has children + */ + public function testGetChildrenAsULNodeDeletedOnLive() + { + $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); + $obj2a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a'); + $obj2aa = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2aa'); + $obj2ab = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2b'); + + // delete all children under obj2 + $obj2a->delete(); + $obj2aa->delete(); + $obj2ab->delete(); + + $set = new MarkedSet( + HierarchyTest\TestObject::singleton(), + 'AllChildren', + 'numChildren' + ); + // Don't pre-load all children + $set->setNodeCountThreshold(1); + $set->markPartialTree(); + $template = __DIR__ . '/HierarchyTest/MarkedSetTest_HTML.ss'; + $html = $set->renderChildren($template); + + // Get the class attribute from the $obj2 node in the sitetree, class 'jstree-leaf' means it's a leaf node + $nodeClass = $this->getNodeClassFromTree($html, $obj2); + $this->assertEquals('jstree-leaf closed', $nodeClass, 'object2 should not have children in the sitetree'); + } + + /** + * This test checks that deleted ('archived') child pages _do_ set a css class on the parent + * node that makes it look like it has children when getting all children including deleted + */ + public function testGetChildrenAsULNodeDeletedOnStage() + { + $obj2 = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2'); + $obj2a = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2a'); + $obj2aa = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2aa'); + $obj2ab = $this->objFromFixture(HierarchyTest\TestObject::class, 'obj2b'); + + // delete all children under obj2 + $obj2a->delete(); + $obj2aa->delete(); + $obj2ab->delete(); + + $set = new MarkedSet( + HierarchyTest\TestObject::singleton(), + 'AllChildrenIncludingDeleted', + 'numHistoricalChildren' + ); + // Don't pre-load all children + $set->setNodeCountThreshold(1); + $set->markPartialTree(); + $template = __DIR__ . '/HierarchyTest/MarkedSetTest_HTML.ss'; + $html = $set->renderChildren($template); + + // Get the class attribute from the $obj2 node in the sitetree + $nodeClass = $this->getNodeClassFromTree($html, $obj2); + // Object2 can now be expanded + $this->assertEquals('unexpanded jstree-closed closed', $nodeClass, 'obj2 should have children in the sitetree'); + } + + + /** + * @param String $html [description] + * @param array $nodes Breadcrumb path as array + * @param String $message + */ + protected function assertTreeContains($html, $nodes, $message = null) + { + $parser = new CSSContentParser($html); + $xpath = '/'; + foreach ($nodes as $node) { + $xpath .= '/ul/li[@data-id="' . $node->ID . '"]'; + } + $match = $parser->getByXpath($xpath); + self::assertThat((bool)$match, self::isTrue(), $message); + } + + /** + * @param String $html [description] + * @param array $nodes Breadcrumb path as array + * @param String $message + */ + protected function assertTreeNotContains($html, $nodes, $message = null) + { + $parser = new CSSContentParser($html); + $xpath = '/'; + foreach ($nodes as $node) { + $xpath .= '/ul/li[@data-id="' . $node->ID . '"]'; + } + $match = $parser->getByXpath($xpath); + self::assertThat((bool)$match, self::isFalse(), $message); + } + + /** + * Get the HTML class attribute from a node in the sitetree + * + * @param string$html + * @param DataObject $node + * @return string + */ + protected function getNodeClassFromTree($html, $node) + { + $parser = new CSSContentParser($html); + $xpath = '//ul/li[@data-id="' . $node->ID . '"]'; + $object = $parser->getByXpath($xpath); + + foreach ($object[0]->attributes() as $key => $attr) { + if ($key == 'class') { + return (string)$attr; + } + } + return ''; + } + + protected function assertHTMLSame($expected, $actual, $message = '') + { + // Trim each line, strip empty lines + $expected = implode("\n", array_filter(array_map('trim', explode("\n", $expected)))); + $actual = implode("\n", array_filter(array_map('trim', explode("\n", $actual)))); + $this->assertXmlStringEqualsXmlString($expected, $actual, $message); + } +}