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"
},