This commit is contained in:
Niklas Forsdahl 2024-05-07 18:21:42 +03:00 committed by GitHub
commit 98a6c6e41b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1012 additions and 0 deletions

View File

@ -223,3 +223,27 @@
.grid-field-inline-new--multi-class-list__visible {
display: block;
}
/**
* GridFieldNestedForm
*/
.grid-field tr.nested-gridfield td.gridfield-holder {
padding-left: 60px;
}
.grid-field.nested.empty-title .grid-field__title-row th {
padding: 0;
}
.grid-field.nested table tbody tr:not(.nested-gridfield) {
border-left: 1px solid #dbe0e9;
}
.grid-field.nested table tbody tr:not(.nested-gridfield).last {
border-bottom: 1px solid #dbe0e9;
}
.ss-gridfield-orderable.has-nested > .grid-field__table > .ss-gridfield-items > .ss-gridfield-item.ui-droppable-active.ui-state-highlight {
border: 0;
background-color: #fbf9ee;
}

View File

@ -152,3 +152,17 @@ $paginator->setItemsPerPage(500);
The first shown record will be maintained across page size changes, and the number of pages and current page will be
recalculated on each request, based on the current first shown record and page size.
Nested GridFields
-----------------
The `GridFieldNestedForm` component allows you to nest GridFields in the UI. It can be used with `DataObject` subclasses
with the `Hierarchy` extension, or by specifying the relation used for nesting.
```php
// Basic usage, defaults to the Children-method for Hierarchy objects.
$grid->getConfig()->addComponent(GridFieldNestedForm::create());
// Usage with custom relation
$grid->getConfig()->addComponent(GridFieldNestedForm::create()->setRelationName('MyRelation'));
```

View File

@ -1,4 +1,7 @@
(function($) {
let preventReorderUpdate = false;
let updateTimeouts = [];
$.entwine("ss", function($) {
/**
* GridFieldAddExistingSearchButton
@ -510,5 +513,173 @@
this.parent().find('.ss-gridfield-pagesize-submit').trigger('click');
}
});
/**
* GridFieldNestedForm
*/
$('.grid-field .col-listChildrenLink button').entwine({
onclick: function(e) {
let gridField = $(this).closest('.grid-field');
let currState = gridField.getState();
let toggleState = false;
let pjaxTarget = $(this).attr('data-pjax-target');
if ($(this).hasClass('font-icon-right-dir')) {
toggleState = true;
}
if (typeof currState['GridFieldNestedForm'] == 'undefined' || currState['GridFieldNestedForm'] == null) {
currState['GridFieldNestedForm'] = {};
}
currState['GridFieldNestedForm'][$(this).attr('data-pjax-target')] = toggleState;
gridField.setState('GridFieldNestedForm', currState['GridFieldNestedForm']);
if (toggleState) {
if (!$(this).closest('tr').next('.nested-gridfield').length) {
// add loading indicator until the nested gridfield is loaded
let colspan = gridField.find('.grid-field__title-row th').attr('colspan');
let loadingCell = $('<td />')
.addClass('ss-gridfield-item loading')
.attr('colspan', colspan);
$(this).closest('tr').after($('<tr class="nested-gridfield" />').append(loadingCell));
let data = {};
let stateInput = gridField.find('input.gridstate').first();
data[stateInput.attr('name')] = JSON.stringify(currState);
if (window.location.search) {
let searchParams = window.location.search.replace('?', '').split('&');
for (let i = 0; i < searchParams.length; i++) {
let parts = searchParams[i].split('=');
data[parts[0]] = parts[1];
}
}
$.ajax({
type: 'POST',
url: $(this).attr('data-url'),
data: data,
headers: {
'X-Pjax': pjaxTarget
},
success: function(data) {
if (data && data[pjaxTarget]) {
gridField.find(`[data-pjax-fragment="${pjaxTarget}"]`).replaceWith(data[pjaxTarget]);
}
}
});
}
else {
$(this).closest('tr').next('.nested-gridfield').show();
$.ajax({
url: $(this).attr('data-toggle')+'1'
});
}
$(this).removeClass('font-icon-right-dir');
$(this).addClass('font-icon-down-dir');
$(this).attr('aria-expanded', 'true');
}
else {
$.ajax({
url: $(this).attr('data-toggle')+'0'
});
$(this).closest('tr').next('.nested-gridfield').hide();
$(this).removeClass('font-icon-down-dir');
$(this).addClass('font-icon-right-dir');
$(this).attr('aria-expanded', 'false');
}
e.preventDefault();
e.stopPropagation();
return false;
}
});
// move nested gridfields onto their own rows below this row, to make it look nicer
$('.col-listChildrenLink > .grid-field.nested').entwine({
onadd: function() {
let nrOfColumns = $(this).closest('tr').children('td').length;
let evenOrOdd = 'even';
if ($(this).closest('tr').hasClass('odd')) {
evenOrOdd = 'odd';
}
if ($(this).closest('.grid-field').hasClass('editable-gridfield')) {
$(this).find('tr').removeClass('even').removeClass('odd').addClass(evenOrOdd);
}
if ($(this).closest('tr').next('tr.nested-gridfield').length) {
$(this).closest('tr').next('tr.nested-gridfield').remove();
}
// add a new table row, with one table cell which spans all columns
$(this).closest('tr').after('<tr class="nested-gridfield '+evenOrOdd+'"><td class="gridfield-holder" colspan="'+nrOfColumns+'"></td></tr>');
// move this field into the newly created row
$(this).appendTo($(this).closest('tr').next('tr').find('td').first());
$(this).show();
this._super();
}
});
$('.ss-gridfield-orderable.has-nested > .grid-field__table > tbody, .ss-gridfield-orderable.nested > .grid-field__table > tbody').entwine({
onadd: function() {
this._super();
let gridField = this.getGridField();
if (gridField.data("url-movetoparent")) {
let parentID = 0;
let parentItem = gridField.closest('.nested-gridfield').prev('.ss-gridfield-item');
if (parentItem && parentItem.length) {
parentID = parentItem.attr('data-id');
}
this.sortable('option', 'connectWith', '.ss-gridfield-orderable tbody');
this.sortable('option', 'start', function(e, ui) {
if (ui.item.find('.col-listChildrenLink').length && ui.item.next('.ui-sortable-placeholder').next('.nested-gridfield').length) {
if (ui.item.find('.col-listChildrenLink a').hasClass('font-icon-down-dir')) {
ui.item.find('.col-listChildrenLink a').removeClass('font-icon-down-dir');
ui.item.find('.col-listChildrenLink a').addClass('font-icon-right-dir');
}
ui.item.next('.ui-sortable-placeholder').next('.nested-gridfield').remove();
let pjaxFragment = ui.item.find('.col-listChildrenLink a').attr('data-pjax-target');
ui.item.find('.col-listChildrenLink').append(`<div class="nested-container" data-pjax-fragment="${pjaxFragment}" style="display:none;"></div>`);
}
});
this.sortable('option', 'receive', function(e, ui) {
preventReorderUpdate = true;
while (updateTimeouts.length) {
let timeout = updateTimeouts.shift();
window.clearTimeout(timeout);
}
let childID = ui.item.attr('data-id');
let parentIntoChild = $(e.target).closest('.grid-field[data-name*="-GridFieldNestedForm-'+childID+'"]').length;
if (parentIntoChild) {
// parent dragged into child, cancel sorting
ui.sender.sortable("cancel");
e.preventDefault();
e.stopPropagation();
window.setTimeout(function() {
preventReorderUpdate = false;
}, 500);
return false;
}
let sortInput = ui.item.find('input.ss-orderable-hidden-sort');
let sortName = sortInput.attr('name');
let index = sortName.indexOf('[GridFieldEditableColumns]');
sortInput.attr('name', gridField.attr('data-name')+sortName.substring(index));
gridField.find('> .grid-field__table > tbody').rebuildSort();
gridField.reload({
url: gridField.data("url-movetoparent"),
data: [
{ name: "move[id]", value: childID},
{ name: "move[parent]", value: parentID}
]
}, function() {
preventReorderUpdate = false;
});
});
let updateCallback = this.sortable('option', 'update');
this.sortable('option', 'update', function(e, ui) {
if (!preventReorderUpdate) {
let timeout = window.setTimeout(function() {
updateCallback(e, ui);
}, 500);
updateTimeouts.push(timeout);
}
});
}
}
});
});
})(jQuery);

488
src/GridFieldNestedForm.php Normal file
View File

@ -0,0 +1,488 @@
<?php
namespace Symbiote\GridFieldExtensions;
use Exception;
use SilverStripe\Admin\ModelAdmin;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Director;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
use SilverStripe\Control\HTTPResponse_Exception;
use SilverStripe\Control\RequestHandler;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Forms\Form;
use SilverStripe\Forms\GridField\AbstractGridFieldComponent;
use SilverStripe\Forms\GridField\GridField;
use SilverStripe\Forms\GridField\GridField_ColumnProvider;
use SilverStripe\Forms\GridField\GridField_DataManipulator;
use SilverStripe\Forms\GridField\GridField_HTMLProvider;
use SilverStripe\Forms\GridField\GridField_SaveHandler;
use SilverStripe\Forms\GridField\GridField_URLHandler;
use SilverStripe\Forms\GridField\GridFieldStateAware;
use SilverStripe\ORM\DataList;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DataObjectInterface;
use SilverStripe\ORM\Filterable;
use SilverStripe\ORM\Hierarchy\Hierarchy;
use SilverStripe\ORM\SS_List;
use SilverStripe\Versioned\Versioned;
use SilverStripe\View\ViewableData;
use Symbiote\GridFieldExtensions\GridFieldOrderableRows;
/**
* Gridfield component for nesting GridFields
*/
class GridFieldNestedForm extends AbstractGridFieldComponent implements
GridField_URLHandler,
GridField_ColumnProvider,
GridField_SaveHandler,
GridField_HTMLProvider,
GridField_DataManipulator
{
use Configurable, GridFieldStateAware;
/**
* The key used in the post data to identify nested form data
*/
const POST_KEY = 'GridFieldNestedForm';
private static $allowed_actions = [
'handleNestedItem'
];
/**
* The default max nesting level. Nesting further than this will throw an exception.
*/
private static int $default_max_nesting_level = 10;
private string $name;
private bool $expandNested = false;
private bool $forceCloseNested = false;
private GridField $gridField;
private string $relationName = 'Children';
private bool $inlineEditable = false;
/**
* @var callable|string
*/
private $canExpandCallback = null;
private int $maxNestingLevel = 0;
public function __construct($name = 'NestedForm')
{
$this->name = $name;
}
/**
* Get the grid field that this component is attached to
*/
public function getGridField(): GridField
{
return $this->gridField;
}
/**
* Get the relation name to use for the nested grid fields
*/
public function getRelationName(): string
{
return $this->relationName;
}
/**
* Set the relation name to use for the nested grid fields
*/
public function setRelationName(string $relationName): static
{
$this->relationName = $relationName;
return $this;
}
/**
* Get whether the nested grid fields should be inline editable
*/
public function getInlineEditable(): bool
{
return $this->inlineEditable;
}
/**
* Set whether the nested grid fields should be inline editable
*/
public function setInlineEditable(bool $editable): static
{
$this->inlineEditable = $editable;
return $this;
}
/**
* Set whether the nested grid fields should be expanded by default
*/
public function setExpandNested(bool $expandNested): static
{
$this->expandNested = $expandNested;
return $this;
}
/**
* Set whether the nested grid fields should be forced closed on load
*/
public function setForceClosedNested(bool $forceClosed): static
{
$this->forceCloseNested = $forceClosed;
return $this;
}
/**
* Set a callback function to check which items in this grid that should show the expand link
* for nested gridfields. The callback should return a boolean value.
* You can either pass a callable or a method name as a string.
*/
public function setCanExpandCallback(callable|string $callback): static
{
$this->canExpandCallback = $callback;
return $this;
}
/**
* Set the maximum nesting level allowed for nested grid fields
*/
public function setMaxNestingLevel(int $level): static
{
$this->maxNestingLevel = $level;
return $this;
}
/**
* Get the max nesting level allowed for this grid field.
*/
public function getMaxNestingLevel(): int
{
return $this->maxNestingLevel ?: static::config()->get('default_max_nesting_level');
}
/**
* Check if we are currently at the max nesting level allowed.
*/
protected function atMaxNestingLevel(GridField $gridField): bool
{
$level = 0;
$controller = $gridField->getForm()->getController();
$maxLevel = $this->getMaxNestingLevel();
while ($level < $maxLevel && $controller && $controller instanceof GridFieldNestedFormItemRequest) {
$controller = $controller->getController();
$level++;
}
return $level >= $maxLevel;
}
public function getColumnMetadata($gridField, $columnName)
{
return ['title' => ''];
}
public function getColumnsHandled($gridField)
{
return ['ToggleNested'];
}
public function getColumnAttributes($gridField, $record, $columnName)
{
return ['class' => 'col-listChildrenLink grid-field__col-compact'];
}
public function augmentColumns($gridField, &$columns)
{
if (!in_array('ToggleNested', $columns)) {
array_splice($columns, 0, 0, 'ToggleNested');
}
}
public function getColumnContent($gridField, $record, $columnName)
{
if ($this->atMaxNestingLevel($gridField)) {
return '';
}
$gridField->addExtraClass('has-nested');
if ($record->ID && $record->exists()) {
$this->gridField = $gridField;
$relationName = $this->getRelationName();
if (!$record->hasMethod($relationName)) {
throw new Exception('Invalid relation name');
}
if ($this->canExpandCallback) {
if (is_callable($this->canExpandCallback)
&& !call_user_func($this->canExpandCallback, $record)
) {
return '';
} elseif (is_string($this->canExpandCallback)
&& $record->hasMethod($this->canExpandCallback)
&& !$record->{$this->canExpandCallback}($record)
) {
return '';
}
}
$toggle = 'closed';
$className = str_replace('\\', '-', get_class($record));
$state = $gridField->State->GridFieldNestedForm;
$stateRelation = $className.'-'.$record->ID.'-'.$this->relationName;
$openState = $state && (int)$state->getData($stateRelation) === 1;
$forceExpand = $this->expandNested && $record->$relationName()->count() > 0;
if (!$this->forceCloseNested
&& ($forceExpand || $openState)
) {
$toggle = 'open';
}
GridFieldExtensions::include_requirements();
return ViewableData::create()->customise([
'Toggle' => $toggle,
'Link' => $this->Link($record->ID),
'ToggleLink' => $this->ToggleLink($record->ID),
'PjaxFragment' => $stateRelation,
'NestedField' => ($toggle == 'open') ? $this->handleNestedItem($gridField, null, $record): ' '
])->renderWith('Symbiote\GridFieldExtensions\GridFieldNestedForm');
}
}
public function getURLHandlers($gridField)
{
return [
'nested/$RecordID/$NestedAction' => 'handleNestedItem',
'toggle/$RecordID' => 'toggleNestedItem',
'POST movetoparent' => 'handleMoveToParent'
];
}
public function getHTMLFragments($gridField)
{
/*
* If we have a DataObject with the hierarchy extension, we want to allow moving items to a new parent.
* This is enabled by setting the data-url-movetoparent attribute on the grid field, so that the client
* javascript can handle the move.
* Implemented in getHTMLFragments since this attribute needs to be added before any rendering happens.
*/
if (is_a($gridField->getModelClass(), DataObject::class, true)
&& DataObject::has_extension($gridField->getModelClass(), Hierarchy::class)
) {
$gridField->setAttribute('data-url-movetoparent', $gridField->Link('movetoparent'));
}
return [];
}
/**
* Handle moving a record to a new parent
*/
public function handleMoveToParent(GridField $gridField, $request): string
{
$move = $request->postVar('move');
/** @var DataList */
$list = $gridField->getList();
$id = isset($move['id']) ? (int) $move['id'] : null;
if (!$id) {
throw new HTTPResponse_Exception('Missing ID', 404);
}
$to = isset($move['parent']) ? (int)$move['parent'] : null;
// should be possible either on parent or child grid field, or nested grid field from parent
$parent = $to ? $list->byID($to) : null;
if (!$parent
&& $to
&& $gridField->getForm()->getController() instanceof GridFieldNestedFormItemRequest
&& $gridField->getForm()->getController()->getRecord()->ID == $to
) {
$parent = $gridField->getForm()->getController()->getRecord();
}
$child = $list->byID($id);
// we need either a parent or a child, or a move to top level at this stage
if (!($parent || $child || $to === 0)) {
throw new HTTPResponse_Exception('Invalid request', 400);
}
// parent or child might be from another grid field, so we need to search via DataList in some cases
if (!$parent && $to) {
$parent = DataList::create($gridField->getModelClass())->byID($to);
}
if (!$child) {
$child = DataList::create($gridField->getModelClass())->byID($id);
}
if ($child) {
if (!$child->canEdit()) {
throw new HTTPResponse_Exception('Not allowed', 403);
}
if ($child->hasExtension(Hierarchy::class)) {
$child->ParentID = $parent ? $parent->ID : 0;
}
// validate that the record is still valid
$validationResult = $child->validate();
if ($validationResult->isValid()) {
if ($child->hasExtension(Versioned::class)) {
$child->writeToStage(Versioned::DRAFT);
} else {
$child->write();
}
// reorder items at the same time, if applicable
/** @var GridFieldOrderableRows */
$orderableRows = $gridField->getConfig()->getComponentByType(GridFieldOrderableRows::class);
if ($orderableRows) {
$orderableRows->setImmediateUpdate(true);
try {
$orderableRows->handleReorder($gridField, $request);
} catch (Exception $e) {
}
}
} else {
$messages = $validationResult->getMessages();
$message = array_pop($messages);
throw new HTTPResponse_Exception($message['message'], 400);
}
}
return $gridField->FieldHolder();
}
/**
* Handle the request to show a nested item
*/
public function handleNestedItem(
GridField $gridField,
HTTPRequest|null $request = null,
ViewableData|null $record = null
): HTTPResponse|RequestHandler|Form {
if ($this->atMaxNestingLevel($gridField)) {
throw new Exception('Max nesting level reached');
}
$list = $gridField->getList();
if (!$record && $request && $list instanceof Filterable) {
$recordID = $request->param('RecordID');
$record = $list->byID($recordID);
}
if (!$record) {
return '';
}
$relationName = $this->getRelationName();
if (!$record->hasMethod($relationName)) {
throw new Exception('Invalid relation name');
}
$manager = $this->getStateManager();
$stateRequest = $request ?: $gridField->getForm()->getRequestHandler()->getRequest();
if ($gridStateStr = $manager->getStateFromRequest($gridField, $stateRequest)) {
$gridField->getState(false)->setValue($gridStateStr);
}
$this->gridField = $gridField;
$itemRequest = GridFieldNestedFormItemRequest::create(
$gridField,
$this,
$record,
$gridField->getForm()->getController(),
$this->name
);
if ($request) {
$pjaxFragment = $request->getHeader('X-Pjax');
$targetPjaxFragment = str_replace('\\', '-', get_class($record)).'-'.$record->ID.'-'.$this->relationName;
if ($pjaxFragment == $targetPjaxFragment) {
$pjaxReturn = [$pjaxFragment => $itemRequest->ItemEditForm()->Fields()->first()->forTemplate()];
$response = new HTTPResponse(json_encode($pjaxReturn));
$response->addHeader('Content-Type', 'text/json');
return $response;
} else {
return $itemRequest->ItemEditForm();
}
} else {
return $itemRequest->ItemEditForm()->Fields()->first();
}
}
/**
* Handle the request to toggle a nested item in the gridfield state
*/
public function toggleNestedItem(
GridField $gridField,
HTTPRequest|null $request = null,
ViewableData|null $record = null
) {
$list = $gridField->getList();
if (!$record && $request && $list instanceof Filterable) {
$recordID = $request->param('RecordID');
$record = $list->byID($recordID);
}
$manager = $this->getStateManager();
if ($gridStateStr = $manager->getStateFromRequest($gridField, $request)) {
$gridField->getState(false)->setValue($gridStateStr);
}
$className = str_replace('\\', '-', get_class($record));
$state = $gridField->getState()->GridFieldNestedForm;
$stateRelation = $className.'-'.$record->ID.'-'.$this->getRelationName();
$state->$stateRelation = (int)$request->getVar('toggle');
}
/**
* Get the link for the nested grid field
*/
public function Link($action = null): string
{
$link = Director::absoluteURL(Controller::join_links($this->gridField->Link('nested'), $action));
$manager = $this->getStateManager();
return $manager->addStateToURL($this->gridField, $link);
}
/**
* Get the link for the toggle action
*/
public function ToggleLink($action = null): string
{
$link = Director::absoluteURL(Controller::join_links($this->gridField->Link('toggle'), $action, '?toggle='));
$manager = $this->getStateManager();
return $manager->addStateToURL($this->gridField, $link);
}
public function handleSave(GridField $gridField, DataObjectInterface $record)
{
$postKey = self::POST_KEY;
$value = $gridField->Value();
if (isset($value['GridState']) && $value['GridState']) {
// set grid state from value, to store open/closed toggle state for nested forms
$gridField->getState(false)->setValue($value['GridState']);
}
$manager = $this->getStateManager();
$request = $gridField->getForm()->getRequestHandler()->getRequest();
if ($gridStateStr = $manager->getStateFromRequest($gridField, $request)) {
$gridField->getState(false)->setValue($gridStateStr);
}
foreach ($request->postVars() as $key => $val) {
$list = $gridField->getList();
if ($list instanceof Filterable
&& preg_match("/{$gridField->getName()}-{$postKey}-(\d+)/", $key, $matches)
) {
$recordID = $matches[1];
$nestedData = $val;
$record = $list->byID($recordID);
if ($record) {
/** @var GridField */
$nestedGridField = $this->handleNestedItem($gridField, null, $record);
$nestedGridField->setValue($nestedData);
$nestedGridField->saveInto($record);
}
}
}
}
public function getManipulatedData(GridField $gridField, SS_List $dataList)
{
if ($this->relationName == 'Children'
&& is_a($gridField->getModelClass(), DataObject::class, true)
&& DataObject::has_extension($gridField->getModelClass(), Hierarchy::class)
&& $gridField->getForm()->getController() instanceof ModelAdmin
&& $dataList instanceof Filterable
) {
$dataList = $dataList->filter('ParentID', 0);
}
return $dataList;
}
}

View File

@ -0,0 +1,176 @@
<?php
namespace Symbiote\GridFieldExtensions;
use SilverStripe\Control\Controller;
use SilverStripe\Control\Director;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\Form;
use SilverStripe\Forms\GridField\GridField;
use SilverStripe\Forms\GridField\GridFieldAddNewButton;
use SilverStripe\Forms\GridField\GridFieldConfig_RecordEditor;
use SilverStripe\Forms\GridField\GridFieldDataColumns;
use SilverStripe\Forms\GridField\GridFieldDetailForm;
use SilverStripe\Forms\GridField\GridFieldDetailForm_ItemRequest;
use SilverStripe\Forms\GridField\GridFieldEditButton;
use SilverStripe\Forms\GridField\GridFieldFilterHeader;
use SilverStripe\Forms\GridField\GridFieldPageCount;
use SilverStripe\Forms\GridField\GridFieldSortableHeader;
use SilverStripe\ORM\ArrayList;
use SilverStripe\ORM\HasManyList;
use SilverStripe\ORM\Hierarchy\Hierarchy;
use SilverStripe\View\ArrayData;
use Symbiote\GridFieldExtensions\GridFieldAddNewInlineButton;
use Symbiote\GridFieldExtensions\GridFieldEditableColumns;
use Symbiote\GridFieldExtensions\GridFieldOrderableRows;
/**
* Request handler class for nested grid field forms.
*/
class GridFieldNestedFormItemRequest extends GridFieldDetailForm_ItemRequest
{
public function Link($action = null)
{
return Controller::join_links($this->component->Link($this->record->ID), $action);
}
public function ItemEditForm()
{
$config = new GridFieldConfig_RecordEditor();
/** @var GridFieldDetailForm */
$detailForm = $config->getComponentByType(GridFieldDetailForm::class);
$detailForm->setItemEditFormCallback(function (Form $form, $itemRequest) {
$breadcrumbs = $itemRequest->Breadcrumbs(false);
if ($breadcrumbs && $breadcrumbs->exists()) {
$form->Backlink = $breadcrumbs->first()->Link;
}
});
$relationName = $this->component->getRelationName();
$list = $this->record->$relationName();
if ($relationName == 'Children' && $this->record->hasExtension(Hierarchy::class)) {
// we really need a HasManyList for Hierarchy objects,
// otherwise adding new items will not properly set the ParentID
$list = HasManyList::create(get_class($this->record), 'ParentID')
->setDataQueryParam($this->record->getInheritableQueryParams())
->forForeignID($this->record->ID);
}
$relationClass = $list->dataClass();
if ($this->record->hasMethod('getNestedConfig')) {
$config = $this->record->getNestedConfig();
} else {
$canEdit = $this->record->canEdit();
if (!$canEdit) {
$config->removeComponentsByType(GridFieldAddNewButton::class);
}
$config->removeComponentsByType(GridFieldPageCount::class);
if ($relationClass == get_class($this->record)) {
$config->removeComponentsByType(GridFieldSortableHeader::class);
$config->removeComponentsByType(GridFieldFilterHeader::class);
if ($this->gridField->getConfig()->getComponentByType(GridFieldOrderableRows::class)) {
$config->addComponent(new GridFieldOrderableRows());
}
}
if ($this->record->hasExtension(Hierarchy::class) && $relationClass == get_class($this->record)) {
$config->addComponent($nestedForm = new GridFieldNestedForm(), GridFieldOrderableRows::class);
// use max nesting level from parent component
$nestedForm->setMaxNestingLevel($this->component->getMaxNestingLevel());
/** @var GridFieldOrderableRows */
$orderableRows = $config->getComponentByType(GridFieldOrderableRows::class);
if ($orderableRows) {
$orderableRows->setReorderColumnNumber(1);
}
}
if ($this->component->getInlineEditable() && $canEdit) {
$config->removeComponentsByType(GridFieldDataColumns::class);
$config->addComponent(new GridFieldEditableColumns(), GridFieldEditButton::class);
$config->addComponent(new GridFieldAddNewInlineButton('buttons-before-left'));
$config->removeComponentsByType(GridFieldAddNewButton::class);
/** @var GridFieldNestedForm */
$nestedForm = $config->getComponentByType(GridFieldNestedForm::class);
if ($nestedForm) {
$nestedForm->setInlineEditable(true);
}
}
}
$this->record->invokeWithExtensions('updateNestedConfig', $config);
$title = _t(get_class($this->record).'.'.strtoupper($relationName), ' ');
$fields = new FieldList(
$gridField = new GridField(
sprintf(
'%s-%s-%s',
$this->component->getGridField()->getName(),
GridFieldNestedForm::POST_KEY,
$this->record->ID
),
$title,
$list,
$config
)
);
if (!trim($title)) {
$gridField->addExtraClass('empty-title');
}
$gridField->setModelClass($relationClass);
$gridField->setAttribute('data-class', str_replace('\\', '-', $relationClass));
$gridField->addExtraClass('nested');
$form = new Form($this, 'ItemEditForm', $fields, new FieldList());
$className = str_replace('\\', '-', get_class($this->record));
$state = $this->gridField->getState()->GridFieldNestedForm;
if ($state) {
$stateRelation = $className.'-'.$this->record->ID.'-'.$relationName;
$state->$stateRelation = 1;
}
$this->record->extend('updateNestedForm', $form);
return $form;
}
public function Breadcrumbs($unlinked = false)
{
if (!$this->popupController->hasMethod('Breadcrumbs')) {
return null;
}
/** @var ArrayList $items */
$items = $this->popupController->Breadcrumbs($unlinked);
if (!$items) {
$items = ArrayList::create();
}
if ($this->record && $this->record->ID) {
$title = ($this->record->Title) ? $this->record->Title : "#{$this->record->ID}";
$items->push(ArrayData::create([
'Title' => $title,
'Link' => parent::Link()
]));
} else {
$items->push(ArrayData::create([
'Title' => _t(
'SilverStripe\\Forms\\GridField\\GridField.NewRecord',
'New {type}',
['type' => $this->record->i18n_singular_name()]
),
'Link' => false
]));
}
foreach ($items as $item) {
if ($item->Link) {
$item->Link = $this->gridField->addAllStateToUrl(Director::absoluteURL($item->Link));
}
}
$this->extend('updateBreadcrumbs', $items);
return $items;
}
}

View File

@ -0,0 +1,12 @@
<button
class="btn btn-secondary btn--no-text btn--icon-large <% if $Toggle == 'open' %>font-icon-down-dir<% else %>font-icon-right-dir<% end_if %> cms-panel-link list-children-link"
aria-expanded="<% if $Toggle == 'open' %>true<% else %>false<% end_if %>"
data-pjax-target="$PjaxFragment"
data-url="$Link"
data-toggle="$ToggleLink"
></button>
<% if $Toggle == 'open' %>
$NestedField
<% else %>
<div class="nested-container" data-pjax-fragment="$PjaxFragment" style="display:none;"></div>
<% end_if %>

View File

@ -0,0 +1,88 @@
<?php
namespace Symbiote\GridFieldExtensions\Tests;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Forms\FieldList;
use SilverStripe\Forms\Form;
use SilverStripe\Forms\GridField\GridField;
use SilverStripe\Forms\GridField\GridFieldConfig_RecordEditor;
use SilverStripe\ORM\ArrayList;
use Symbiote\GridFieldExtensions\GridFieldNestedForm;
use Symbiote\GridFieldExtensions\Tests\Stub\StubHierarchy;
use Symbiote\GridFieldExtensions\Tests\Stub\StubOrdered;
use Symbiote\GridFieldExtensions\Tests\Stub\StubParent;
use Symbiote\GridFieldExtensions\Tests\Stub\TestController;
class GridFieldNestedFormTest extends SapphireTest
{
protected static $fixture_file = 'GridFieldNestedFormTest.yml';
protected static $extra_dataobjects = [
StubHierarchy::class,
StubParent::class,
StubOrdered::class
];
public function testHierarchy()
{
// test that GridFieldNestedForm works with hierarchy objects
$parent = $this->objFromFixture(StubHierarchy::class, 'item1');
$list = new ArrayList([$parent]);
$config = new GridFieldConfig_RecordEditor();
$config->addComponent($nestedForm = new GridFieldNestedForm());
$controller = new TestController('Test');
$form = new Form($controller, 'TestForm', new FieldList(
$gridField = new GridField(__FUNCTION__, 'test', $list, $config)
), new FieldList());
$request = new HTTPRequest('GET', '/');
$itemEditForm = $nestedForm->handleNestedItem($gridField, $request, $parent);
$this->assertNotNull($itemEditForm);
$nestedGridField = $itemEditForm->Fields()->first();
$this->assertNotNull($nestedGridField);
$list = $nestedGridField->getList();
$this->assertEquals(1, $list->count());
$child1 = $this->objFromFixture(StubHierarchy::class, 'item1_1');
$this->assertEquals($child1->ID, $list->first()->ID);
$nestedForm = $nestedGridField->getConfig()->getComponentByType(GridFieldNestedForm::class);
$itemEditForm = $nestedForm->handleNestedItem($gridField, $request, $child1);
$this->assertNotNull($itemEditForm);
$nestedGridField = $itemEditForm->Fields()->first();
$this->assertNotNull($nestedGridField);
$list = $nestedGridField->getList();
$this->assertEquals(1, $list->count());
$child2 = $this->objFromFixture(StubHierarchy::class, 'item1_1_1');
$this->assertEquals($child2->ID, $list->first()->ID);
}
public function testHasManyRelation()
{
// test that GridFieldNestedForm works with HasMany relations
$parent = $this->objFromFixture(StubParent::class, 'parent1');
$list = new ArrayList([$parent]);
$config = new GridFieldConfig_RecordEditor();
$config->addComponent($nestedForm = new GridFieldNestedForm());
$nestedForm->setRelationName('MyHasMany');
$controller = new TestController('Test');
$form = new Form($controller, 'TestForm', new FieldList(
$gridField = new GridField(__FUNCTION__, 'test', $list, $config)
), new FieldList());
$request = new HTTPRequest('GET', '/');
$itemEditForm = $nestedForm->handleNestedItem($gridField, $request, $parent);
$this->assertNotNull($itemEditForm);
$nestedGridField = $itemEditForm->Fields()->first();
$this->assertNotNull($nestedGridField);
$list = $nestedGridField->getList();
$this->assertEquals(2, $list->count());
$child1 = $this->objFromFixture(StubOrdered::class, 'child1');
$this->assertEquals($child1->ID, $list->first()->ID);
}
}

View File

@ -0,0 +1,19 @@
Symbiote\GridFieldExtensions\Tests\Stub\StubHierarchy:
item1:
Title: 'Item 1'
item1_1:
Title: 'Item 1.1'
ParentID: =>Symbiote\GridFieldExtensions\Tests\Stub\StubHierarchy.item1
item1_1_1:
Title: 'Item 1.1.1'
ParentID: =>Symbiote\GridFieldExtensions\Tests\Stub\StubHierarchy.item1_1
Symbiote\GridFieldExtensions\Tests\Stub\StubParent:
parent1:
Title: 'Parent 1'
Symbiote\GridFieldExtensions\Tests\Stub\StubOrdered:
child1:
Title: 'Child 1'
ParentID: =>Symbiote\GridFieldExtensions\Tests\Stub\StubParent.parent1
child2:
Title: 'Child 2'
ParentID: =>Symbiote\GridFieldExtensions\Tests\Stub\StubParent.parent1

View File

@ -0,0 +1,20 @@
<?php
namespace Symbiote\GridFieldExtensions\Tests\Stub;
use SilverStripe\Dev\TestOnly;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\Hierarchy\Hierarchy;
class StubHierarchy extends DataObject implements TestOnly
{
private static $table_name = 'StubHierarchy';
private static $extensions = [
Hierarchy::class
];
private static $db = [
'Title' => 'Varchar'
];
}