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 $serialised = $serialiseEval($data['node']); // Force serialisation of DBField instances if (is_array($serialised)) { foreach ($serialised as $key => $value) { if ($value instanceof DBField) { $serialised[$key] = $value->getSchemaValue(); } } // Merge with top level array unset($output['node']); $output = array_merge($output, $serialised); } else { if ($serialised instanceof DBField) { $serialised = $serialised->getSchemaValue(); } // Replace node with serialised value $output['node'] = $serialised; } // 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; } }