diff --git a/css/GridFieldExtensions.css b/css/GridFieldExtensions.css index 7aec1b7..785faa7 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 c565963..e5eb80c 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -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')); +``` diff --git a/javascript/GridFieldExtensions.js b/javascript/GridFieldExtensions.js index 2d7accf..3500cc9 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 0000000..cb3015c --- /dev/null +++ b/src/GridFieldNestedForm.php @@ -0,0 +1,488 @@ +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; + } +} diff --git a/src/GridFieldNestedFormItemRequest.php b/src/GridFieldNestedFormItemRequest.php new file mode 100644 index 0000000..fdd104a --- /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 0000000..bd5c4f1 --- /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 0000000..c7b975f --- /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 0000000..f0a6be4 --- /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 0000000..2b48e47 --- /dev/null +++ b/tests/Stub/StubHierarchy.php @@ -0,0 +1,20 @@ + 'Varchar' + ]; +}