From ccda816f909b3004c3a8e00dec02fa37e35d1667 Mon Sep 17 00:00:00 2001 From: Christopher Joe Date: Tue, 11 Jul 2017 16:38:55 +1200 Subject: [PATCH] API added flatList argument for generating the json tree list with a context string property --- src/Forms/TreeDropdownField.php | 172 +++++++++++++++------- tests/php/Forms/TreeDropdownFieldTest.php | 64 +++++++- 2 files changed, 178 insertions(+), 58 deletions(-) diff --git a/src/Forms/TreeDropdownField.php b/src/Forms/TreeDropdownField.php index 5b3ee1a31..c5c3945c0 100644 --- a/src/Forms/TreeDropdownField.php +++ b/src/Forms/TreeDropdownField.php @@ -154,10 +154,19 @@ class TreeDropdownField extends FormField /** * List of ids in current search result (keys are ids, values are true) + * This includes parents of search result children which may not be an actual result * * @var array */ protected $searchIds = []; + + /** + * List of ids which matches the search result + * This excludes parents of search result children + * + * @var array + */ + protected $realSearchIds = []; /** * Determine if search should be shown @@ -378,9 +387,15 @@ class TreeDropdownField extends FormField $isSubTree = false; $this->search = $request->requestVar('search'); + $flatlist = $request->requestVar('flatList'); $id = (is_numeric($request->latestParam('ID'))) ? (int)$request->latestParam('ID') : (int)$request->requestVar('ID'); + + // pre-process the tree - search needs to operate globally, not locally as marking filter does + if ($this->search) { + $this->populateIDs(); + } /** @var DataObject|Hierarchy $obj */ $obj = null; @@ -402,11 +417,6 @@ class TreeDropdownField extends FormField } } - // pre-process the tree - search needs to operate globally, not locally as marking filter does - if ($this->search) { - $this->populateIDs(); - } - // Create marking set $markingSet = MarkedSet::create($obj, $this->childrenMethod, $this->numChildrenMethod, 30); @@ -455,6 +465,11 @@ class TreeDropdownField extends FormField // Format JSON output $json = $markingSet ->getChildrenAsArray($customised); + + if ($flatlist) { + // format and filter $json here + $json['children'] = $this->flattenChildrenArray($json['children']); + } return HTTPResponse::create() ->addHeader('Content-Type', 'application/json') ->setBody(json_encode($json)); @@ -554,7 +569,38 @@ class TreeDropdownField extends FormField { return $this->sourceObject; } - + + /** + * Flattens a given list of children array items, so the data is no longer + * structured in a hierarchy + * + * NOTE: uses {@link TreeDropdownField::$realSearchIds} to filter items by if there is a search + * + * @param array $children - the list of children, which could contain their own children + * @param array $parentTitles - a list of parent titles, which we use to construct the contextString + * @return array - flattened list of children + */ + protected function flattenChildrenArray($children, $parentTitles = []) + { + $output = []; + + foreach ($children as $child) { + $childTitles = array_merge($parentTitles, [$child['title']]); + $grandChildren = $child['children']; + $contextString = implode('/', $parentTitles); + + $child['contextString'] = ($contextString !== '') ? $contextString .'/' : ''; + $child['children'] = []; + + if (!$this->search || in_array($child['id'], $this->realSearchIds)) { + $output[] = $child; + } + $output = array_merge($output, $this->flattenChildrenArray($grandChildren, $childTitles)); + } + + return $output; + } + /** * Populate $this->searchIds with the IDs of the pages matching the searched parameter and their parents. * Reverse-constructs the tree starting from the leaves. Initially taken from CMSSiteTreeFilter, but modified @@ -563,57 +609,70 @@ class TreeDropdownField extends FormField protected function populateIDs() { // get all the leaves to be displayed + $res = $this->getSearchResults(); + + if (!$res) { + return; + } + + // iteratively fetch the parents in bulk, until all the leaves can be accessed using the tree control + foreach ($res as $row) { + if ($row->ParentID) { + $parents[$row->ParentID] = true; + } + $this->searchIds[$row->ID] = true; + } + $this->realSearchIds = $res->column(); + + $sourceObject = $this->sourceObject; + + while (!empty($parents)) { + $items = DataObject::get($sourceObject) + ->filter("ID", array_keys($parents)); + $parents = array(); + + foreach ($items as $item) { + if ($item->ParentID) { + $parents[$item->ParentID] = true; + } + $this->searchIds[$item->ID] = true; + $this->searchExpanded[$item->ID] = true; + } + } + } + + /** + * Get the DataObjects that matches the searched parameter. + * + * @return DataList + */ + protected function getSearchResults() + { if ($this->searchCallback) { - $res = call_user_func($this->searchCallback, $this->sourceObject, $this->labelField, $this->search); + return call_user_func($this->searchCallback, $this->sourceObject, $this->labelField, $this->search); + } + + $sourceObject = $this->sourceObject; + $filters = array(); + if (singleton($sourceObject)->hasDatabaseField($this->labelField)) { + $filters["{$this->labelField}:PartialMatch"] = $this->search; } else { - $sourceObject = $this->sourceObject; - $filters = array(); - if (singleton($sourceObject)->hasDatabaseField($this->labelField)) { - $filters["{$this->labelField}:PartialMatch"] = $this->search; - } else { - if (singleton($sourceObject)->hasDatabaseField('Title')) { - $filters["Title:PartialMatch"] = $this->search; - } - if (singleton($sourceObject)->hasDatabaseField('Name')) { - $filters["Name:PartialMatch"] = $this->search; - } + if (singleton($sourceObject)->hasDatabaseField('Title')) { + $filters["Title:PartialMatch"] = $this->search; } - - if (empty($filters)) { - throw new InvalidArgumentException(sprintf( - 'Cannot query by %s.%s, not a valid database column', - $sourceObject, - $this->labelField - )); - } - $res = DataObject::get($this->sourceObject)->filterAny($filters); - } - - if ($res) { - // iteratively fetch the parents in bulk, until all the leaves can be accessed using the tree control - foreach ($res as $row) { - if ($row->ParentID) { - $parents[$row->ParentID] = true; - } - $this->searchIds[$row->ID] = true; - } - - $sourceObject = $this->sourceObject; - - while (!empty($parents)) { - $items = DataObject::get($sourceObject) - ->filter("ID", array_keys($parents)); - $parents = array(); - - foreach ($items as $item) { - if ($item->ParentID) { - $parents[$item->ParentID] = true; - } - $this->searchIds[$item->ID] = true; - $this->searchExpanded[$item->ID] = true; - } + if (singleton($sourceObject)->hasDatabaseField('Name')) { + $filters["Name:PartialMatch"] = $this->search; } } + + if (empty($filters)) { + throw new InvalidArgumentException(sprintf( + 'Cannot query by %s.%s, not a valid database column', + $sourceObject, + $this->labelField + )); + } + return DataObject::get($this->sourceObject)->filterAny($filters); } /** @@ -680,9 +739,12 @@ class TreeDropdownField extends FormField public function getSchemaDataDefaults() { $data = parent::getSchemaDataDefaults(); - $data['data']['urlTree'] = $this->Link('tree'); - $data['data']['emptyString'] = $this->getEmptyString(); - $data['data']['hasEmptyDefault'] = $this->getHasEmptyDefault(); + $data['data'] = array_merge($data['data'], [ + 'urlTree' => $this->Link('tree'), + 'showSearch' => $this->showSearch, + 'emptyString' => $this->getEmptyString(), + 'hasEmptyDefault' => $this->getHasEmptyDefault(), + ]); return $data; } diff --git a/tests/php/Forms/TreeDropdownFieldTest.php b/tests/php/Forms/TreeDropdownFieldTest.php index fc77c7145..d370e226a 100644 --- a/tests/php/Forms/TreeDropdownFieldTest.php +++ b/tests/php/Forms/TreeDropdownFieldTest.php @@ -15,9 +15,65 @@ class TreeDropdownFieldTest extends SapphireTest protected static $fixture_file = 'TreeDropdownFieldTest.yml'; + public function testTreeSearchJson() + { + $field = new TreeDropdownField('TestTree', 'Test tree', Folder::class); + + // case insensitive search against keyword 'sub' for folders + $request = new HTTPRequest('GET', 'url', array('search'=>'sub', 'format' => 'json')); + $request->setSession(new Session([])); + $response = $field->tree($request); + $tree = json_decode($response->getBody(), true); + + $folder1 = $this->objFromFixture(Folder::class, 'folder1'); + $folder1Subfolder1 = $this->objFromFixture(Folder::class, 'folder1-subfolder1'); + + $this->assertContains( + $folder1->Name, + array_column($tree['children'], 'title'), + $folder1->Name.' is found in the json' + ); + + $filtered = array_filter($tree['children'], function ($entry) use ($folder1) { + return $folder1->Name === $entry['title']; + }); + $folder1Tree = array_pop($filtered); + + $this->assertContains( + $folder1Subfolder1->Name, + array_column($folder1Tree['children'], 'title'), + $folder1Subfolder1->Name.' is found in the folder1 entry in the json' + ); + } + + public function testTreeSearchJsonFlatlist() + { + $field = new TreeDropdownField('TestTree', 'Test tree', Folder::class); + + // case insensitive search against keyword 'sub' for folders + $request = new HTTPRequest('GET', 'url', array('search'=>'sub', 'format' => 'json', 'flatList' => '1')); + $request->setSession(new Session([])); + $response = $field->tree($request); + $tree = json_decode($response->getBody(), true); + + $folder1 = $this->objFromFixture(Folder::class, 'folder1'); + $folder1Subfolder1 = $this->objFromFixture(Folder::class, 'folder1-subfolder1'); + + $this->assertNotContains( + $folder1->Name, + array_column($tree['children'], 'title'), + $folder1->Name.' is not found in the json' + ); + + $this->assertContains( + $folder1Subfolder1->Name, + array_column($tree['children'], 'title'), + $folder1Subfolder1->Name.' is found in the json' + ); + } + public function testTreeSearch() { - $field = new TreeDropdownField('TestTree', 'Test tree', Folder::class); // case insensitive search against keyword 'sub' for folders @@ -30,7 +86,8 @@ class TreeDropdownFieldTest extends SapphireTest $folder1Subfolder1 = $this->objFromFixture(Folder::class, 'folder1-subfolder1'); $parser = new CSSContentParser($tree); - $cssPath = 'ul.tree li#selector-TestTree-'.$folder1->ID.' li#selector-TestTree-'.$folder1Subfolder1->ID.' a span.item'; + $cssPath = 'ul.tree li#selector-TestTree-'.$folder1->ID.' li#selector-TestTree-'. + $folder1Subfolder1->ID.' a span.item'; $firstResult = $parser->getBySelector($cssPath); $this->assertEquals( $folder1Subfolder1->Name, @@ -67,7 +124,8 @@ class TreeDropdownFieldTest extends SapphireTest $parser = new CSSContentParser($tree); // Even if we used File as the source object, folders are still returned because Folder is a File - $cssPath = 'ul.tree li#selector-TestTree-'.$folder1->ID.' li#selector-TestTree-'.$folder1Subfolder1->ID.' a span.item'; + $cssPath = 'ul.tree li#selector-TestTree-'.$folder1->ID.' li#selector-TestTree-'. + $folder1Subfolder1->ID.' a span.item'; $firstResult = $parser->getBySelector($cssPath); $this->assertEquals( $folder1Subfolder1->Name,