diff --git a/application/controllers/Services.php b/application/controllers/Services.php index da365b168f..d3de3bd2a3 100644 --- a/application/controllers/Services.php +++ b/application/controllers/Services.php @@ -106,7 +106,7 @@ public function search(): void $keyword = request('keyword', ''); - $order_by = request('order_by', 'update_datetime DESC'); + $order_by = request('order_by', 'row_order ASC'); $limit = request('limit', 1000); @@ -227,4 +227,45 @@ public function destroy(): void json_exception($e); } } -} + + /** + * Arrange service order + */ + public function sort() + { + try { + + if (cannot('edit', PRIV_SERVICES)) + { + abort(403, 'Forbidden'); + } + + $service_id = request('service_id'); + if (($service_id = filter_var($service_id, FILTER_VALIDATE_INT)) === FALSE) + { + abort(400,'Invalid ID value'); + } + + $insertAfterId = request('after'); + if (($insertAfterId = filter_var($insertAfterId,FILTER_VALIDATE_INT)) === FALSE) + { + abort(400,'Invalid after value, must be ID or -1'); + } + + if ($insertAfterId <= 0) + { + $insertAfterId = FALSE; + } + + $service = $this->services_model->find($service_id); + + $service['row_order'] = $this->services_model->set_service_order($service['id'], $insertAfterId); + + return json_response($service); + } + catch (Throwable $e) + { + json_exception($e); + } + } +} \ No newline at end of file diff --git a/application/core/EA_Model.php b/application/core/EA_Model.php index 6d43caad2e..4d57cda587 100644 --- a/application/core/EA_Model.php +++ b/application/core/EA_Model.php @@ -245,4 +245,161 @@ function quote_order_by(?string $order_by): ?string return implode(', ', $quoted_parts); } + + /** + * Sort column orders from table + * + * @param string $table Table + * @param string $column Column to sort (Default is row_order) + */ + public function sort_column(string $table, string $column = 'row_order') + { + if (empty($table)) + throw new InvalidArgumentException("Table parameter must be defined"); + if (empty($column)) + throw new InvalidArgumentException("Column parameter must be defined"); + + + $rows = $this->db + ->select(['id', $column]) + ->from($table) + ->order_by($this->quote_order_by($column)) + ->get() + ->result_array(); + + for($i=0; $i < count($rows); $i++) + { + if ($rows[$i][$column] != $i) + { + $rows[$i][$column] = $i; + if (! $this->db->update($table, [$column => $i], [ 'id'=> $rows[$i]['id'] ])) + { + throw new RuntimeException('Could not sort table '.$table . ": Db error"); + } + } + else { + + } + } + } + + + /** + * Inserts entry order after defined entry + * + * @param string $table Table + * @param array $entry Entity to insert + * @param mixed|bool $afterId ID of entry where to insert at. Or false to set at beginning + * @param string [$order_column] Ordering Column name (Default: row_order) + * + * @throws RuntimeException + * @throws InvalidArgumentException + */ + + public function insert_row_order_after(string $table, array &$entry, $afterId, string $order_column='row_order') + { + if (empty($table)) + throw new InvalidArgumentException("Table parameter must be defined"); + if (empty($order_column)) + throw new InvalidArgumentException("Column parameter must be defined"); + + if (!array_key_exists('id', $entry)) + throw new InvalidArgumentException('Entry does not contain ID column'); + if (!array_key_exists($order_column,$entry)) + throw new InvalidArgumentException('Entry does not contain sorting column'); + + + // Get position of desired entry: + if (is_int($afterId) && $afterId > 0) + { + $position = $this->db->from($table) + ->select([$order_column]) + ->where('id',$afterId) + ->get() + ->row_array(); + if ($position === false) + { + throw new InvalidArgumentException("Could not found service with ID $afterId"); + } + $position = intval($position[$order_column]); + } + else { + $position = FALSE; + } + + if (! $this->insert_row_order($table,$entry,$position,$order_column)) + { + throw new RuntimeException('Could not update order, database error'); + } + + } + + + /** + * Inserts entry to specified order in table + * + * @param string $table Table + * @param array $entry Entry, should be associative array containing columns 'Id' and desired $column sort value. + * @param int|bool $position Position to set entry to. If set to False, it will be positioned to first. + * @param string $column Column name that contains sorting data (default is 'row_order'). + * + * @return bool TRUE on success, FALSE on failure + * @throws InvalidArgumentException + */ + + protected function insert_row_order(string $table, array &$entry, $position, string $column = 'row_order') + { + if (empty($table)) + throw new InvalidArgumentException("Table parameter must be defined"); + if (empty($column)) + throw new InvalidArgumentException("Column parameter must be defined"); + + if (!array_key_exists('id', $entry)) + throw new InvalidArgumentException('Entry does not contain ID column'); + if (!array_key_exists($column,$entry)) + throw new InvalidArgumentException('Entry does not contain sorting column'); + + + if (is_int($position)) + { + $newOr = $position +1; + } + else + { + $newOr = 0; + } + + $this->db->update($table, [$column => $newOr ], [ 'id'=> $entry['id'] ]); + + + $rows = $this->db + ->select(['id', $column]) + ->from($table) + ->where($column .'>=', $newOr) + ->order_by($this->quote_order_by($column)) + ->get() + ->result_array(); + + // Move entries after inserted: + foreach ($rows as $row) + { + $id = $row['id']; + if ($id == $entry['id']) + { + continue; + } + + $newOr++; + if ($this->db->update($table, [$column => $newOr], [ 'id'=> $row['id'] ]) === FALSE) + { // Failed! + return FALSE; + } + + } + + // And fix empty gaps: + $this->sort_column($table,$column); + + return TRUE; + } } diff --git a/application/migrations/061_add_sort_column_to_service_table.php b/application/migrations/061_add_sort_column_to_service_table.php new file mode 100644 index 0000000000..89f4c5c5c4 --- /dev/null +++ b/application/migrations/061_add_sort_column_to_service_table.php @@ -0,0 +1,45 @@ + + * @copyright Copyright (c) 2013 - 2020, Alex Tselegidis + * @license http://opensource.org/licenses/GPL-3.0 - GPLv3 + * @link http://easyappointments.org + * @since v1.4.0 + * ---------------------------------------------------------------------------- */ + +class Migration_Add_sort_column_to_service_table extends EA_Migration { + /** + * Upgrade method. + */ + public function up():void + { + if ( ! $this->db->field_exists('row_order', 'services')) + { + $fields = [ + 'row_order' => [ + 'type' => 'INT', + 'constraint' => '11', + 'default' => '0', + 'after' => 'id' + ] + ]; + + $this->dbforge->add_column('services', $fields); + } + } + + /** + * Downgrade method. + */ + public function down():void + { + if ( ! $this->db->field_exists('row_order', 'services')) + { + $this->dbforge->drop_column('services', 'row_order'); + } + } +} diff --git a/application/models/Services_model.php b/application/models/Services_model.php index 1d25e1f1ea..e27756b658 100644 --- a/application/models/Services_model.php +++ b/application/models/Services_model.php @@ -29,6 +29,7 @@ class Services_model extends EA_Model 'attendants_number' => 'integer', 'is_private' => 'boolean', 'id_service_categories' => 'integer', + 'row_order' => 'integer', ]; /** @@ -37,6 +38,7 @@ class Services_model extends EA_Model protected array $api_resource = [ 'id' => 'id', 'name' => 'name', + 'order' => 'row_order', 'duration' => 'duration', 'price' => 'price', 'currency' => 'currency', @@ -164,6 +166,7 @@ protected function insert(array $service): int { $service['create_datetime'] = date('Y-m-d H:i:s'); $service['update_datetime'] = date('Y-m-d H:i:s'); + $service['row_order'] = $this->db->count_all('services'); if (!$this->db->insert('services', $service)) { throw new RuntimeException('Could not insert service.'); @@ -188,7 +191,7 @@ protected function update(array $service): int if (!$this->db->update('services', $service, ['id' => $service['id']])) { throw new RuntimeException('Could not update service.'); } - + return $service['id']; } @@ -202,6 +205,7 @@ protected function update(array $service): int public function delete(int $service_id): void { $this->db->delete('services', ['id' => $service_id]); + $this->sort_column('services'); } /** @@ -286,7 +290,7 @@ public function get_available_services(bool $without_private = false): array ->from('services') ->join('services_providers', 'services_providers.id_services = services.id', 'inner') ->join('service_categories', 'service_categories.id = services.id_service_categories', 'left') - ->order_by('name ASC') + ->order_by('row_order,name ASC') ->get() ->result_array(); @@ -320,6 +324,10 @@ public function get( if ($order_by !== null) { $this->db->order_by($this->quote_order_by($order_by)); } + else + { + $this->db->order_by('row_order'); + } $services = $this->db->get('services', $limit, $offset)->result_array(); @@ -352,6 +360,11 @@ public function query(): CI_DB_query_builder */ public function search(string $keyword, ?int $limit = null, ?int $offset = null, ?string $order_by = null): array { + if ($order_by === NULL) + { + $order_by = 'row_order'; + } + $services = $this->db ->select() ->from('services') @@ -410,6 +423,7 @@ public function api_encode(array &$service): void $encoded_resource = [ 'id' => array_key_exists('id', $service) ? (int) $service['id'] : null, 'name' => $service['name'], + 'order' => $service['row_order'], 'duration' => (int) $service['duration'], 'price' => (float) $service['price'], 'currency' => $service['currency'], @@ -443,7 +457,13 @@ public function api_decode(array &$service, ?array $base = null): void $decoded_resource['name'] = $service['name']; } - if (array_key_exists('duration', $service)) { + if (array_key_exists('order', $service)) + { + $decoded_resource['row_order'] = $service['order']; + } + + if (array_key_exists('duration', $service)) + { $decoded_resource['duration'] = $service['duration']; } @@ -481,4 +501,22 @@ public function api_decode(array &$service, ?array $base = null): void $service = $decoded_resource; } + + /** + * Sort service + * + * @param int $service_id Service ID + * @param int|bool $afterServiceId ID of service that service should be inserted after, or FALSE to set to beginning + * + * @return int New place in table + */ + + public function set_service_order(int $service_id, $afterServiceId) + { + $service = $this->find($service_id); + + $this->insert_row_order_after('services',$service,$afterServiceId); + + return $this->value($service['id'],'row_order'); + } } diff --git a/application/views/layouts/backend_layout.php b/application/views/layouts/backend_layout.php index 1fca409faf..451f293594 100644 --- a/application/views/layouts/backend_layout.php +++ b/application/views/layouts/backend_layout.php @@ -50,6 +50,7 @@ + diff --git a/assets/js/http/services_http_client.js b/assets/js/http/services_http_client.js index 6211d5c5d4..c477b51aca 100644 --- a/assets/js/http/services_http_client.js +++ b/assets/js/http/services_http_client.js @@ -122,6 +122,26 @@ App.Http.Services = (function () { return $.post(url, data); } + /** + * Sort service + * + * @param {Number} serviceId + * + * @param {Number} afterId + * + * @return {Object} + */ + function sort(serviceId, afterId) + { + const url = App.Utils.Url.siteUrl('services/sort'); + const data = { + csrf_token: vars('csrf_token'), + service_id: serviceId, + after : afterId + }; + return $.post(url,data); + } + return { save, store, @@ -129,5 +149,6 @@ App.Http.Services = (function () { destroy, search, find, + sort }; })(); diff --git a/assets/js/pages/services.js b/assets/js/pages/services.js index d755763eda..9ee96e45cd 100644 --- a/assets/js/pages/services.js +++ b/assets/js/pages/services.js @@ -89,6 +89,60 @@ App.Pages.Services = (function () { $('#edit-service, #delete-service').prop('disabled', false); }); + /** + * Event: Sort service by dragging + */ + + const sorting = new Sortable(document.querySelector('.results'), { + onStart : function(ev) { + if (ev.from.querySelector('hr') == null) + return; + if (! (ev.item.nextSibling && ev.item.nextSibling.tagName == 'HR')) + return; + ev.item.dataset['hadHR'] = true; + ev.item.nextSibling.remove(); + }, + onUpdate : async function(ev){ + resetForm(); + let afterId; + const currentItemId = ev.item.dataset['id']; + const hadHR = ev.item.dataset['hadHR'] == 'true'; +; + + if (ev.newIndex == 0) + afterId = -1; + else if (ev.item.previousSibling !== null) { + let prevItem =ev.item.previousSibling; + if (prevItem.tagName == 'HR') + prevItem = prevItem.previousSibling; + + if (! prevItem.classList.contains('service-row')){ + window.App.Utils.Message.show(lang('unexpected_issues'),'Failed to get previous service to sort!'); + throw 'Failed to get previous service to sort'; + } + + afterId = parseInt(prevItem.dataset['id']); + + } + try { + await sort(currentItemId, afterId); + if (hadHR) + { + const newHr = document.createElement('HR'); + if (ev.oldIndex < ev.newIndex) + ev.item.before(newHr); + else + ev.item.after(newHr); + delete ev.item.dataset.hadHR; + } + } + catch (err){ + $services.find('.form-message').addClass('alert-danger').text(lang('error')).show(); + return false; + } + } + }); + /** * Event: Add New Service Button "Click" */ @@ -209,6 +263,44 @@ App.Pages.Services = (function () { }); } + /** + * Sort service record + * @async + * + * @param {Number} serviceId Id of service to sort + * + * @param {Number} afterId Id of service to place after + * + * @return {Promise} + */ + function sort(serviceId, afterId){ + + return App.Http.Services.sort(serviceId, afterId) + .then(response => { + App.Layouts.Backend.displayNotification(lang('service_saved')); + return response.row_order; + }); + } + + /** + * Sort service record + * @async + * + * @param {Number} serviceId Id of service to sort + * + * @param {Number} afterId Id of service to place after + * + * @return {Promise} + */ + function sort(serviceId, afterId){ + + return App.Http.Services.sort(serviceId, afterId) + .then(response => { + App.Layouts.Backend.displayNotification(lang('service_saved')); + return response.row_order; + }); + } + /** * Delete a service record from database. * @@ -437,5 +529,6 @@ App.Pages.Services = (function () { display, select, addEventListeners, + sort, }; })(); diff --git a/gulpfile.js b/gulpfile.js index c107f3f7ee..abc0a2448f 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -185,6 +185,9 @@ function vendor(done) { gulp.dest('assets/vendor/flatpickr'), ); + // sortablejs + gulp.src(['node_modules/sortablejs/Sortable.min.js']).pipe(gulp.dest('assets/vendor/sortablejs')); + gulp.src(['node_modules/flatpickr/dist/themes/material_green.css']) .pipe(css()) .pipe(rename({suffix: '.min'})) diff --git a/package-lock.json b/package-lock.json index 20760033f3..34c0f822b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "moment": "^2.30.1", "moment-timezone": "^0.6.0", "select2": "^4.1.0-rc.0", + "sortablejs": "^1.15.2", "tippy.js": "^6.3.7", "trumbowyg": "^2.31.0" }, @@ -5403,6 +5404,11 @@ "node": ">=8" } }, + "node_modules/sortablejs": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.2.tgz", + "integrity": "sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA==" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index 18f63b0f06..a4caa60576 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "moment": "^2.30.1", "moment-timezone": "^0.6.0", "select2": "^4.1.0-rc.0", + "sortablejs": "^1.15.2", "tippy.js": "^6.3.7", "trumbowyg": "^2.31.0" },