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 = "
\n";
- foreach ($this->sourceObject as $k => $v) {
- $output .= '- ' . $v . '';
- }
- $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 %>
+
+<% else_if $depth > 0 %>
+ <% if $limited || $children %>
+
+ <% end_if %>
+<% end_if %>
+
+<% if $limited %>
+ - <%t SilverStripe\\ORM\\Hierarchy.LIMITED_TITLE 'Too many children ({count}}' count=$count %>
+<% else_if $children %>
+ <% loop $children %>
+ -
+ {$title}
+ $SubTree
+
+ <% end_loop %>
+<% end_if %>
+
+<% if $depth == '0' && not $isSubTree %>
+
+<% else_if $depth > 0 %>
+ <% if $limited || $children %>
+
+ <% 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 %>
+
+ <% if $limited %>
+ - <%t SilverStripe\\ORM\\Hierarchy.LIMITED_TITLE 'Too many children ({count}}' count=$count %>
+ <% else_if $children %>
+ <% loop $children %>- $node.Title.XML $SubTree
<% end_loop %>
+ <% end_if %>
+
+<% 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 %>
+
+ <% if $limited %>
+ - Exceeded!
+ <% else_if $children %>
+ <% loop $children %>- $node.Title.XML $SubTree
<% end_loop %>
+ <% end_if %>
+
+<% 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 3aa
+
+ - Obj 3ab
+
+
+
+ Obj 3b
+
+ - Obj 3ba
+
+ - Obj 3bb
+
+
+
+ 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);
+ }
+}