From e41f2fdacfc2133763359cebea3391f7e12f738e Mon Sep 17 00:00:00 2001 From: Andrew Short Date: Sun, 30 Dec 2012 16:12:06 +1100 Subject: [PATCH] Implemented a drag-and-drop orderable rows component. --- README.md | 1 + code/GridFieldOrderableRows.php | 289 ++++++++++++++++++ composer.json | 2 +- css/GridFieldExtensions.css | 47 +++ javascript/GridFieldExtensions.js | 82 +++++ templates/GridFieldOrderableRowsDragHandle.ss | 1 + 6 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 code/GridFieldOrderableRows.php create mode 100644 templates/GridFieldOrderableRowsDragHandle.ss diff --git a/README.md b/README.md index 3f7bbfa..afd29cc 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ SilverStripe Grid Field Extensions Module This module provides a number of useful grid field components: * `GridFieldAddExistingSearchButton` - a more advanced search form for adding items. +* `GridFieldOrderableRows` - drag and drop re-ordering of rows. Maintainer Contacts ------------------- diff --git a/code/GridFieldOrderableRows.php b/code/GridFieldOrderableRows.php new file mode 100644 index 0000000..c4845bd --- /dev/null +++ b/code/GridFieldOrderableRows.php @@ -0,0 +1,289 @@ +sortField = $sortField; + } + + /** + * @return string + */ + public function getSortField() { + return $this->sortField; + } + + /** + * Sets the field used to specify the sort. + * + * @param string $sortField + */ + public function setSortField($field) { + $this->sortField = $field; + } + + /** + * Gets the table which contains the sort field. + * + * @param DataList $list + * @return string + */ + public function getSortTable(DataList $list) { + $field = $this->getSortField(); + + if($list instanceof ManyManyList) { + // @todo These should be publically accesible. + $reflector = new ReflectionObject($list); + + $extra = $reflector->getProperty('extraFields'); + $extra->setAccessible(true); + + $table = $reflector->getProperty('joinTable'); + $table->setAccessible(true); + + if(array_key_exists($field, $extra->getValue($list))) { + return $table->getValue($list); + } + } + + $classes = ClassInfo::dataClassesFor($list->dataClass()); + + foreach($classes as $class) { + if(singleton($class)->hasOwnTableDatabaseField($field)) { + return $class; + } + } + + throw new Exception("Couldn't find the sort field '$field'"); + } + + public function getURLHandlers($grid) { + return array( + 'POST reorder' => 'handleReorder', + 'POST movetopage' => 'handleMoveToPage' + ); + } + + public function getHTMLFragments($field) { + Requirements::css('gridfieldextensions/css/GridFieldExtensions.css'); + Requirements::javascript('gridfieldextensions/javascript/GridFieldExtensions.js'); + + $field->addExtraClass('ss-gridfield-orderable'); + } + + public function augmentColumns($grid, &$cols) { + if(!in_array('Reorder', $cols) && $grid->getState()->GridFieldOrderableRows->enabled) { + array_unshift($cols, 'Reorder'); + } + } + + public function getColumnsHandled($grid) { + return array('Reorder'); + } + + public function getColumnContent($grid, $record, $col) { + return ViewableData::create()->renderWith('GridFieldOrderableRowsDragHandle'); + } + + public function getColumnAttributes($grid, $record, $col) { + return array('class' => 'col-reorder'); + } + + public function getColumnMetadata($grid, $col) { + return array('title' => ''); + } + + public function getManipulatedData(GridField $grid, SS_List $list) { + $state = $grid->getState(); + $sorted = (bool) ((string) $state->GridFieldSortableHeader->SortColumn); + + // If the data has not been sorted by the user, then sort it by the + // sort column, otherwise disable reordering. + $state->GridFieldOrderableRows->enabled = !$sorted; + + if(!$sorted) { + return $list->sort($this->getSortField()); + } else { + return $list; + } + } + + /** + * Handles requests to reorder a set of IDs in a specific order. + */ + public function handleReorder($grid, $request) { + if(!singleton($grid->getModelClass())->canEdit()) { + $this->httpError(403); + } + + $ids = $request->postVar('order'); + $list = $grid->getList(); + $field = $this->getSortField(); + + if(!is_array($ids)) { + $this->httpError(400); + } + + $items = $list->byIDs($ids)->sort($field); + + // Ensure that each provided ID corresponded to an actual object. + if(count($items) != count($ids)) { + $this->httpError(404); + } + + // Populate each object we are sorting with a sort value. + $this->populateSortValues($items); + + // Generate the current sort values. + $current = $items->map('ID', $field)->toArray(); + + // Perform the actual re-ordering. + $this->reorderItems($list, $current, $ids); + + return $grid->FieldHolder(); + } + + /** + * Handles requests to move an item to the previous or next page. + */ + public function handleMoveToPage(GridField $grid, $request) { + if(!$paginator = $grid->getConfig()->getComponentByType('GridFieldPaginator')) { + $this->httpError(404, 'Paginator component not found'); + } + + $move = $request->postVar('move'); + $field = $this->getSortField(); + + $list = $grid->getList(); + $manip = $grid->getManipulatedList(); + + $existing = $manip->map('ID', $field)->toArray(); + $values = $existing; + $order = array(); + + $id = isset($move['id']) ? (int) $move['id'] : null; + $to = isset($move['page']) ? $move['page'] : null; + + if(!isset($values[$id])) { + $this->httpError(400, 'Invalid item ID'); + } + + $this->populateSortValues($list); + + $page = ((int) $grid->getState()->GridFieldPaginator->currentPage) ?: 1; + $per = $paginator->getItemsPerPage(); + + if($to == 'prev') { + $swap = $list->limit(1, ($page - 1) * $per - 1)->first(); + $values[$swap->ID] = $swap->$field; + + $order[] = $id; + $order[] = $swap->ID; + + foreach($existing as $_id => $sort) { + if($id != $_id) $order[] = $_id; + } + } elseif($to == 'next') { + $swap = $list->limit(1, $page * $per)->first(); + $values[$swap->ID] = $swap->$field; + + foreach($existing as $_id => $sort) { + if($id != $_id) $order[] = $_id; + } + + $order[] = $swap->ID; + $order[] = $id; + } else { + $this->httpError(400, 'Invalid page target'); + } + + $this->reorderItems($list, $values, $order); + + return $grid->FieldHolder(); + } + + protected function reorderItems($list, array $values, array $order) { + // Get a list of sort values that can be used. + $pool = array_values($values); + sort($pool); + + // Loop through each item, and update the sort values which do not + // match to order the objects. + foreach(array_values($order) as $pos => $id) { + if($values[$id] != $pool[$pos]) { + DB::query(sprintf( + 'UPDATE "%s" SET "%s" = %d WHERE %s', + $this->getSortTable($list), + $this->getSortField(), + $pool[$pos], + $this->getSortTableClauseForIds($list, $id) + )); + } + } + } + + protected function populateSortValues(DataList $list) { + $field = $this->getSortField(); + $table = $this->getSortTable($list); + $clause = $this->getSortTableClauseForIds($list, 0); + + foreach($list->where($clause)->column('ID') as $id) { + $max = DB::query(sprintf('SELECT MAX("%s") + 1 FROM "%s"', $field, $table)); + $max = $max->value(); + + DB::query(sprintf( + 'UPDATE "%s" SET "%s" = %d WHERE %s', + $table, + $field, + $max, + $this->getSortTableClauseForIds($list, $id) + )); + } + } + + protected function getSortTableClauseForIds(DataList $list, $ids) { + if(is_array($ids)) { + $value = 'IN (' . implode(', ', array_map('intval', $ids)) . ')'; + } else { + $value = '= ' . (int) $ids; + } + + if($list instanceof ManyManyList) { + $reflector = new ReflectionObject($list); + + $extra = $reflector->getProperty('extraFields'); + $extra->setAccessible(true); + + $key = $reflector->getProperty('localKey'); + $key->setAccessible(true); + + if(array_key_exists($this->getSortField(), $extra->getValue($list))) { + return sprintf('"%s" %s', $key->getValue($list), $value); + } + } + + return "\"ID\" $value"; + } + +} diff --git a/composer.json b/composer.json index 72b4c0c..7f3b8b4 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,6 @@ } ], "require": { - "silverstripe/framework": "3.*" + "silverstripe/framework": ">=3.1" } } diff --git a/css/GridFieldExtensions.css b/css/GridFieldExtensions.css index 010b6c4..798f548 100644 --- a/css/GridFieldExtensions.css +++ b/css/GridFieldExtensions.css @@ -53,3 +53,50 @@ margin-top: 12px; padding: 6px; } + +/** + * GridFieldOrderableRows + */ + +.ss-gridfield-orderable thead tr th.col-Reorder span { + padding: 0 !important; +} + +.ss-gridfield-orderable .col-reorder { + padding: 0 !important; + width: 16px !important; +} + +.ss-gridfield-orderable .col-reorder .handle { + cursor: move; + display: none; +} + +.ss-gridfield-orderable tbody tr:hover .col-reorder .handle { + display: block; +} + +.ss-gridfield-orderhelper { + border-bottom: 1px solid rgba(0, 0, 0, .1); + border-top: 1px solid rgba(0, 0, 0, .1); + box-shadow: 0 0 8px rgba(0, 0, 0, .4); +} + +.ss-gridfield-orderable tfoot .ui-droppable { + padding-left: 12px; + padding-right: 12px; +} + +.ss-gridfield-orderable tfoot .ui-droppable-active { + background-color: #D4CF90 !important; +} + +.ss-gridfield-orderable tfoot .ss-gridfield-previouspage { + background-position: -16px 9px !important; + margin-left: 0; +} + +.ss-gridfield-orderable tfoot .ss-gridfield-nextpage { + background-position: -40px 9px !important; + margin-right: 0; +} diff --git a/javascript/GridFieldExtensions.js b/javascript/GridFieldExtensions.js index ccd6c6b..8a14691 100644 --- a/javascript/GridFieldExtensions.js +++ b/javascript/GridFieldExtensions.js @@ -71,5 +71,87 @@ return false; } }); + + /** + * GridFieldOrderableRows + */ + + $(".ss-gridfield-orderable tbody").entwine({ + onadd: function() { + var self = this; + + var helper = function(e, row) { + return row.clone() + .addClass("ss-gridfield-orderhelper") + .width("auto") + .find(".col-buttons") + .remove() + .end(); + }; + + var update = function() { + var grid = self.getGridField(); + + var data = grid.getItems().map(function() { + return { name: "order[]", value: $(this).data("id") }; + }); + + grid.reload({ + url: grid.data("url") + "/reorder", + data: data.get() + }); + }; + + this.sortable({ + handle: ".handle", + helper: helper, + opacity: .7, + update: update + }); + }, + onremove: function() { + this.sortable("destroy"); + } + }); + + $(".ss-gridfield-orderable .ss-gridfield-previouspage, .ss-gridfield-orderable .ss-gridfield-nextpage").entwine({ + onadd: function() { + var grid = this.getGridField(); + + if(this.is(":disabled")) { + return false; + } + + var drop = function(e, ui) { + var page; + + if($(this).hasClass("ss-gridfield-previouspage")) { + page = "prev"; + } else { + page = "next"; + } + + grid.find("tbody").sortable("cancel"); + grid.reload({ + url: grid.data("url") + "/movetopage", + data: [ + { name: "move[id]", value: ui.draggable.data("id") }, + { name: "move[page]", value: page } + ] + }); + }; + + this.droppable({ + accept: ".ss-gridfield-item", + activeClass: "ui-droppable-active ui-state-highlight", + disabled: this.prop("disabled"), + drop: drop, + tolerance: "pointer" + }); + }, + onremove: function() { + if(this.hasClass("ui-droppable")) this.droppable("destroy"); + } + }); }); })(jQuery); diff --git a/templates/GridFieldOrderableRowsDragHandle.ss b/templates/GridFieldOrderableRowsDragHandle.ss new file mode 100644 index 0000000..4899111 --- /dev/null +++ b/templates/GridFieldOrderableRowsDragHandle.ss @@ -0,0 +1 @@ +