diff --git a/README.md b/README.md
index 3f539cdf..a831c6d6 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,8 @@ This module provides a number of useful grid field components:
features.
* `GridFieldTitleHeader` - a simple header which displays column titles.
* `GridFieldConfigurablePaginator` - a paginator for GridField that allows customisable page sizes.
+* `GridFieldNestedForm` - allows nesting of GridFields for managing relation records directly within
+ a parent GridField.
## Installation
diff --git a/css/GridFieldExtensions.css b/css/GridFieldExtensions.css
index 7aec1b72..785faa79 100644
--- a/css/GridFieldExtensions.css
+++ b/css/GridFieldExtensions.css
@@ -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;
+}
diff --git a/docs/en/index.md b/docs/en/index.md
index c5659636..6d2dbd04 100644
--- a/docs/en/index.md
+++ b/docs/en/index.md
@@ -152,3 +152,46 @@ $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'));
+```
+
+You can define your own custom GridField config for the nested GridField configuration by implementing a `getNestedConfig`
+on your nested model (should return a `GridField_Config` object).
+```php
+class NestedObject extends DataObject
+{
+ private static $has_one = [
+ 'Parent' => ParentObject::class
+ ];
+
+ public function getNestedConfig(): GridFieldConfig
+ {
+ $config = new GridFieldConfig_RecordViewer();
+ return $config;
+ }
+}
+```
+
+You can also modify the default config (a `GridFieldConfig_RecordEditor`) via an extension to the nested model class, by implementing
+`updateNestedConfig`, which will get the config object as the first parameter.
+```php
+class NestedObjectExtension extends DataExtension
+{
+ public function updateNestedConfig(GridFieldConfig &$config)
+ {
+ $config->removeComponentsByType(GridFieldPaginator::class);
+ }
+}
+```
diff --git a/javascript/GridFieldExtensions.js b/javascript/GridFieldExtensions.js
index 2d7accf4..3500cc9e 100644
--- a/javascript/GridFieldExtensions.js
+++ b/javascript/GridFieldExtensions.js
@@ -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 = $('
')
+ .addClass('ss-gridfield-item loading')
+ .attr('colspan', colspan);
+ $(this).closest('tr').after($(' ').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(' ');
+ // 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(`
`);
+ }
+ });
+ 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);
diff --git a/src/GridFieldNestedForm.php b/src/GridFieldNestedForm.php
new file mode 100644
index 00000000..9f4fffcf
--- /dev/null
+++ b/src/GridFieldNestedForm.php
@@ -0,0 +1,491 @@
+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 ($gridField->getConfig()->getComponentsByType(GridFieldNestedForm::class)->count() > 1) {
+ throw new Exception('Only one GridFieldNestedForm component allowed per GridField');
+ }
+ 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;
+ }
+}
diff --git a/src/GridFieldNestedFormItemRequest.php b/src/GridFieldNestedFormItemRequest.php
new file mode 100644
index 00000000..fdd104a5
--- /dev/null
+++ b/src/GridFieldNestedFormItemRequest.php
@@ -0,0 +1,176 @@
+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;
+ }
+}
diff --git a/templates/Symbiote/GridFieldExtensions/GridFieldNestedForm.ss b/templates/Symbiote/GridFieldExtensions/GridFieldNestedForm.ss
new file mode 100755
index 00000000..bd5c4f1b
--- /dev/null
+++ b/templates/Symbiote/GridFieldExtensions/GridFieldNestedForm.ss
@@ -0,0 +1,12 @@
+
+<% if $Toggle == 'open' %>
+ $NestedField
+<% else %>
+
+<% end_if %>
\ No newline at end of file
diff --git a/tests/GridFieldNestedFormTest.php b/tests/GridFieldNestedFormTest.php
new file mode 100644
index 00000000..c7b975f5
--- /dev/null
+++ b/tests/GridFieldNestedFormTest.php
@@ -0,0 +1,88 @@
+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);
+ }
+}
diff --git a/tests/GridFieldNestedFormTest.yml b/tests/GridFieldNestedFormTest.yml
new file mode 100644
index 00000000..f0a6be45
--- /dev/null
+++ b/tests/GridFieldNestedFormTest.yml
@@ -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
diff --git a/tests/Stub/StubHierarchy.php b/tests/Stub/StubHierarchy.php
new file mode 100644
index 00000000..2b48e479
--- /dev/null
+++ b/tests/Stub/StubHierarchy.php
@@ -0,0 +1,20 @@
+ 'Varchar'
+ ];
+}