From db3edd7f1644788cfd3717b89ebcdce6d21565fa Mon Sep 17 00:00:00 2001 From: Niklas Forsdahl Date: Fri, 12 Apr 2024 16:45:29 +0300 Subject: [PATCH] NEW Initial nested gridfield component --- css/GridFieldExtensions.css | 20 ++ javascript/GridFieldExtensions.js | 159 +++++++++ src/GridFieldNestedForm.php | 330 ++++++++++++++++++ src/GridFieldNestedForm_ItemRequest.php | 115 ++++++ .../GridFieldNestedForm.ss | 6 + 5 files changed, 630 insertions(+) create mode 100644 src/GridFieldNestedForm.php create mode 100644 src/GridFieldNestedForm_ItemRequest.php create mode 100755 templates/Symbiote/GridFieldExtensions/GridFieldNestedForm.ss diff --git a/css/GridFieldExtensions.css b/css/GridFieldExtensions.css index 7aec1b7..2bd2760 100644 --- a/css/GridFieldExtensions.css +++ b/css/GridFieldExtensions.css @@ -223,3 +223,23 @@ .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 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/javascript/GridFieldExtensions.js b/javascript/GridFieldExtensions.js index 2d7accf..886308f 100644 --- a/javascript/GridFieldExtensions.js +++ b/javascript/GridFieldExtensions.js @@ -510,5 +510,164 @@ this.parent().find('.ss-gridfield-pagesize-submit').trigger('click'); } }); + + /** + * GridFieldNestedForm + */ + $('.grid-field .col-listChildrenLink a').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) { + 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('href'), + 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'); + } + 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'); + } + 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, or widget dragged to root, 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..ee424dd --- /dev/null +++ b/src/GridFieldNestedForm.php @@ -0,0 +1,330 @@ +name = $name; + } + + public function getGridField() + { + return $this->gridField; + } + + public function getRelationName() + { + return $this->relationName; + } + + public function setRelationName($relationName) + { + $this->relationName = $relationName; + return $this; + } + + public function getInlineEditable() + { + return $this->inlineEditable; + } + + public function setInlineEditable($editable) + { + $this->inlineEditable = $editable; + return $this; + } + + public function setExpandNested($expandNested) + { + $this->expandNested = $expandNested; + return $this; + } + + public function setForceClosedNested($forceClosed) + { + $this->forceCloseNested = $forceClosed; + return $this; + } + + public function setCanExpandCheck($checkFunction) + { + $this->canExpandCheck = $checkFunction; + return $this; + } + + 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) + { + $gridField->addExtraClass('has-nested'); + if ($record->ID && $record->exists()) { + $this->gridField = $gridField; + $this->record = $record; + $relationName = $this->getRelationName(); + if (!$record->hasMethod($relationName)) { + return ''; + } + if ($this->canExpandCheck) { + if (is_callable($this->canExpandCheck) && !call_user_func($this->canExpandCheck, $record)) { + return ''; + } elseif (is_string($this->canExpandCheck) && $record->hasMethod($this->canExpandCheck) && !$this->record->{$this->canExpandCheck}($record)) { + return ''; + } + } + $toggle = 'closed'; + $className = str_replace('\\', '-', get_class($record)); + $state = $gridField->State->GridFieldNestedForm; + $stateRelation = $className.'-'.$record->ID.'-'.$this->relationName; + if (!$this->forceCloseNested && (($this->expandNested && $record->$relationName()->count() > 0) || ($state && (int)$state->getData($stateRelation) === 1))) { + $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' + ]; + } + + /** + * @param GridField $field + */ + public function getHTMLFragments($field) + { + if (DataObject::has_extension($field->getModelClass(), Hierarchy::class)) { + $field->setAttribute('data-url-movetoparent', $field->Link('movetoparent')); + } + } + + public function handleMoveToParent(GridField $gridField, $request) + { + $move = $request->postVar('move'); + /** @var DataList */ + $list = $gridField->getList(); + $id = isset($move['id']) ? (int) $move['id'] : null; + $to = isset($move['parent']) ? (int)$move['parent'] : null; + $parent = null; + if ($id) { + // 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 GridFieldNestedForm_ItemRequest && $gridField->getForm()->getController()->getRecord()->ID == $to) { + $parent = $gridField->getForm()->getController()->getRecord(); + } + $child = $list->byID($id); + if ($parent || $child || $to === 0) { + if (!$parent && $to) { + $parent = DataList::create($gridField->getModelClass())->byID($to); + } + if (!$child) { + $child = DataList::create($gridField->getModelClass())->byID($id); + } + if ($child) { + if ($child->hasExtension(Hierarchy::class)) { + $child->ParentID = $parent ? $parent->ID : 0; + } + $validationResult = $child->validate(); + if ($validationResult->isValid()) { + if ($child->hasExtension(Versioned::class)) { + $child->writeToStage(Versioned::DRAFT); + } else { + $child->write(); + } + + /** @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(); + } + + public function handleNestedItem(GridField $gridField, $request = null, $record = null) + { + if (!$record && $request) { + $recordID = $request->param('RecordID'); + $record = $gridField->getList()->byID($recordID); + } + if (!$record) { + return ''; + } + $relationName = $this->getRelationName(); + if (!$record->hasMethod($relationName)) { + return ''; + } + $manager = $this->getStateManager(); + if ($gridStateStr = $manager->getStateFromRequest($gridField, $request ?: $gridField->getForm()->getRequestHandler()->getRequest())) { + $gridField->getState(false)->setValue($gridStateStr); + } + $this->gridField = $gridField; + $this->record = $record; + $itemRequest = new GridFieldNestedForm_ItemRequest($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(); + } + } + + public function toggleNestedItem(GridField $gridField, $request = null, $record = null) + { + if (!$record) { + $recordID = $request->param('RecordID'); + $record = $gridField->getList()->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'); + } + + public function Link($action = null) + { + $link = Director::absoluteURL(Controller::join_links($this->gridField->Link('nested'), $action)); + $manager = $this->getStateManager(); + return $manager->addStateToURL($this->gridField, $link); + } + + public function ToggleLink($action = null) + { + $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) + { + $value = $gridField->Value(); + if (!isset($value[self::POST_KEY]) || !is_array($value[self::POST_KEY])) { + return; + } + + 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(); + if ($gridStateStr = $manager->getStateFromRequest($gridField, $gridField->getForm()->getRequestHandler()->getRequest())) { + $gridField->getState(false)->setValue($gridStateStr); + } + foreach ($value[self::POST_KEY] as $recordID => $nestedData) { + $record = $gridField->getList()->byID($recordID); + if ($record) { + $nestedGridField = $this->handleNestedItem($gridField, null, $record); + $nestedGridField->setValue($nestedData); + $nestedGridField->saveInto($record); + } + } + } + + public function getManipulatedData(GridField $gridField, SS_List $dataList) + { + if ($this->relationName == 'Children' && DataObject::has_extension($gridField->getModelClass(), Hierarchy::class) && $gridField->getForm()->getController() instanceof ModelAdmin) { + $dataList = $dataList->filter('ParentID', 0); + } + return $dataList; + } +} diff --git a/src/GridFieldNestedForm_ItemRequest.php b/src/GridFieldNestedForm_ItemRequest.php new file mode 100644 index 0000000..fcdc5bd --- /dev/null +++ b/src/GridFieldNestedForm_ItemRequest.php @@ -0,0 +1,115 @@ +component->Link($this->record->ID), $action); + } + + public function ItemEditForm() + { + $config = new GridFieldConfig_RecordEditor(); + $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->record->hasExtension(Hierarchy::class)) { + $config->addComponent(new GridFieldNestedForm(), GridFieldOrderableRows::class); + /** @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); + + $fields = new FieldList( + $gridField = new GridField($this->component->getGridField()->getName().'['.GridFieldNestedForm::POST_KEY.']['.$this->record->ID.']', _t(get_class($this->record).'.'.strtoupper($relationName), ' '), $list, $config) + ); + $gridField->setModelClass($relationClass); + $gridField->setAttribute('data-class', str_replace('\\', '-', $relationClass)); + $gridField->addExtraClass('nested'); + $form = new Form($this, 'ItemEditForm', $fields, new FieldList()); + $form->setStrictFormMethodCheck(false); + $form->disableSecurityToken(); + + $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); + + $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..0e2f09d --- /dev/null +++ b/templates/Symbiote/GridFieldExtensions/GridFieldNestedForm.ss @@ -0,0 +1,6 @@ + +<% if $Toggle == 'open' %> + $NestedField +<% else %> + +<% end_if %> \ No newline at end of file