mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
Merge pull request #7167 from open-sausages/pulls/4.0/tree-search-in-forest
API TreeDropdown tree parameter
This commit is contained in:
commit
3a7f9e8eb5
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user