From d079198103ea141c01c792d617a089eb73de06b9 Mon Sep 17 00:00:00 2001 From: Ruben Carlo Benante Date: Mon, 13 Jul 2026 10:25:28 -0300 Subject: [PATCH] feature : persistent sort --- Asset/js/relocate.js | 20 ++++ Controller/ConfigController.php | 3 + Controller/OrganonSortController.php | 61 ++++++++++++ Helper/OrganonSortHelper.php | 140 +++++++++++++++++++++++++++ Plugin.php | 36 ++++++- Template/board/sort_control.php | 69 +++++++++++++ Template/config/show.php | 7 ++ 7 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 Controller/OrganonSortController.php create mode 100644 Helper/OrganonSortHelper.php create mode 100644 Template/board/sort_control.php diff --git a/Asset/js/relocate.js b/Asset/js/relocate.js index 14ebccd..376a472 100644 --- a/Asset/js/relocate.js +++ b/Asset/js/relocate.js @@ -70,9 +70,29 @@ } } + function hideNativeSort() { + // Only when our persistent-sort control is present (the tweak is on); otherwise leave the + // native fa-sort menu alone. + if (!document.querySelector(".organontweaks-sort")) { + return; + } + + var sorts = document.querySelectorAll("#board .fa-sort"); + + for (var i = 0; i < sorts.length; i++) { + var dd = sorts[i].closest(".dropdown"); + + // Skip our own control (its OFF-state icon is also fa-sort). + if (dd && !dd.classList.contains("organontweaks-sort")) { + dd.style.display = "none"; + } + } + } + function apply() { relocateColumnMenuItems(); repositionCommentIcons(); + hideNativeSort(); } function init() { diff --git a/Controller/ConfigController.php b/Controller/ConfigController.php index 7ca854e..3c2bc3d 100644 --- a/Controller/ConfigController.php +++ b/Controller/ConfigController.php @@ -24,6 +24,7 @@ class ConfigController extends BaseController 'organon_tweaks_recurring_filters' => (int) $this->configModel->get('organon_tweaks_recurring_filters', 1), 'organon_tweaks_shortcut_labels' => (int) $this->configModel->get('organon_tweaks_shortcut_labels', 1), 'organon_tweaks_title_click_edit' => (int) $this->configModel->get('organon_tweaks_title_click_edit', 1), + 'organon_tweaks_persistent_sort' => (int) $this->configModel->get('organon_tweaks_persistent_sort', 1), ), 'errors' => array(), ))); @@ -43,6 +44,7 @@ class ConfigController extends BaseController $recurringFilters = isset($values['organon_tweaks_recurring_filters']) ? 1 : 0; $shortcutLabels = isset($values['organon_tweaks_shortcut_labels']) ? 1 : 0; $titleClickEdit = isset($values['organon_tweaks_title_click_edit']) ? 1 : 0; + $persistentSort = isset($values['organon_tweaks_persistent_sort']) ? 1 : 0; if ($this->configModel->save(array( 'organon_tweaks_always_comment_icon' => $alwaysCommentIcon, @@ -55,6 +57,7 @@ class ConfigController extends BaseController 'organon_tweaks_recurring_filters' => $recurringFilters, 'organon_tweaks_shortcut_labels' => $shortcutLabels, 'organon_tweaks_title_click_edit' => $titleClickEdit, + 'organon_tweaks_persistent_sort' => $persistentSort, ))) { $this->flash->success(t('Settings saved successfully.')); } else { diff --git a/Controller/OrganonSortController.php b/Controller/OrganonSortController.php new file mode 100644 index 0000000..b7f043f --- /dev/null +++ b/Controller/OrganonSortController.php @@ -0,0 +1,61 @@ +getProject(); + + if (! $this->helper->user->hasProjectAccess('TaskModificationController', 'update', $project['id'])) { + throw new AccessForbiddenException(); + } + + return $project; + } + + private function sorter() + { + return new OrganonSortHelper($this->container); + } + + private function backToBoard(array $project) + { + $this->response->redirect($this->helper->url->to('BoardViewController', 'show', array('project_id' => $project['id']))); + } + + public function sort() + { + $this->checkCSRFParam(); + $project = $this->authorize(); + + $this->sorter()->applySort( + $project['id'], + $this->request->getIntegerParam('swimlane_id'), + $this->request->getIntegerParam('column_id'), + $this->request->getStringParam('sort'), + $this->request->getStringParam('direction') + ); + + $this->backToBoard($project); + } + + public function toggle() + { + $this->checkCSRFParam(); + $project = $this->authorize(); + + $this->sorter()->toggle($project['id'], $this->request->getIntegerParam('column_id')); + + $this->backToBoard($project); + } +} diff --git a/Helper/OrganonSortHelper.php b/Helper/OrganonSortHelper.php new file mode 100644 index 0000000..b4d6af9 --- /dev/null +++ b/Helper/OrganonSortHelper.php @@ -0,0 +1,140 @@ + = id | priority | assignee-priority | assignee | due-date | title + * organon_sort_dir_ = asc | desc + * organon_sort_stick_ = on | off (default off = native one-shot behavior) + */ +class OrganonSortHelper extends Base +{ + const BY_PREFIX = 'organon_sort_by_'; + const DIR_PREFIX = 'organon_sort_dir_'; + const STICK_PREFIX = 'organon_sort_stick_'; + + // The sort criteria this control offers. Native ones dispatch to core TaskReorderModel; 'title' + // is our own number-aware sort. Any value not here is ignored (no reorder, nothing stored). + const CRITERIA = array('id', 'priority', 'assignee-priority', 'assignee', 'due-date', 'title'); + + public function isSticky($project_id, $column_id) + { + return $this->projectMetadataModel->get((int) $project_id, self::STICK_PREFIX.(int) $column_id, 'off') === 'on'; + } + + public function sortBy($project_id, $column_id) + { + return (string) $this->projectMetadataModel->get((int) $project_id, self::BY_PREFIX.(int) $column_id, ''); + } + + public function sortDir($project_id, $column_id) + { + return $this->projectMetadataModel->get((int) $project_id, self::DIR_PREFIX.(int) $column_id, 'asc') === 'desc' ? 'desc' : 'asc'; + } + + /** + * Sort the column now and record the choice (for the icon + the sticky re-apply). + */ + public function applySort($project_id, $swimlane_id, $column_id, $sort, $direction) + { + if (! in_array($sort, self::CRITERIA, true)) { + return; + } + + $direction = $direction === 'desc' ? 'desc' : 'asc'; + $this->reorder((int) $project_id, (int) $swimlane_id, (int) $column_id, $sort, $direction); + $this->projectMetadataModel->save((int) $project_id, array( + self::BY_PREFIX.(int) $column_id => $sort, + self::DIR_PREFIX.(int) $column_id => $direction, + )); + } + + /** + * Re-apply the stored sort (called on card drop / creation when persistent is on). No-op if the + * column has no stored sort yet. + */ + public function reapply($project_id, $swimlane_id, $column_id) + { + $sort = $this->sortBy($project_id, $column_id); + + if ($sort !== '' && in_array($sort, self::CRITERIA, true)) { + $this->reorder((int) $project_id, (int) $swimlane_id, (int) $column_id, $sort, $this->sortDir($project_id, $column_id)); + } + } + + /** + * Flip the persistent flag for a column; returns the new state. + * + * @return string 'on' | 'off' + */ + public function toggle($project_id, $column_id) + { + $new = $this->isSticky($project_id, $column_id) ? 'off' : 'on'; + $this->projectMetadataModel->save((int) $project_id, array(self::STICK_PREFIX.(int) $column_id => $new)); + + return $new; + } + + /** + * Dispatch the physical reorder: native criteria reuse core TaskReorderModel; 'title' is ours. + */ + private function reorder($project_id, $swimlane_id, $column_id, $sort, $direction) + { + switch ($sort) { + case 'id': + $this->taskReorderModel->reorderByTaskId($project_id, $swimlane_id, $column_id, $direction); + break; + case 'priority': + $this->taskReorderModel->reorderByPriority($project_id, $swimlane_id, $column_id, $direction); + break; + case 'assignee-priority': + $this->taskReorderModel->reorderByAssigneeAndPriority($project_id, $swimlane_id, $column_id, $direction); + break; + case 'assignee': + $this->taskReorderModel->reorderByAssignee($project_id, $swimlane_id, $column_id, $direction); + break; + case 'due-date': + $this->taskReorderModel->reorderByDueDate($project_id, $swimlane_id, $column_id, $direction); + break; + case 'title': + $this->reorderByTitle($project_id, $swimlane_id, $column_id, $direction); + break; + } + } + + /** + * Number-aware title sort (strnatcasecmp, so "Task 2" precedes "Task 10"), then write positions. + * Mirrors the native reorder scope (all tasks in the column+swimlane, no status filter). Core's + * TaskReorderModel::reorderTasks is protected, so the 3-line position write is replicated here. + */ + private function reorderByTitle($project_id, $swimlane_id, $column_id, $direction) + { + $rows = $this->db->table(TaskModel::TABLE) + ->columns('id', 'title') + ->eq('project_id', $project_id) + ->eq('swimlane_id', $swimlane_id) + ->eq('column_id', $column_id) + ->findAll(); + + usort($rows, function ($a, $b) use ($direction) { + $cmp = strnatcasecmp((string) $a['title'], (string) $b['title']); + + return $direction === 'desc' ? -$cmp : $cmp; + }); + + $position = 1; + foreach ($rows as $row) { + $this->db->table(TaskModel::TABLE)->eq('id', $row['id'])->update(array('position' => $position)); + $position++; + } + } +} diff --git a/Plugin.php b/Plugin.php index 0e2910d..7647298 100644 --- a/Plugin.php +++ b/Plugin.php @@ -3,6 +3,7 @@ namespace Kanboard\Plugin\OrganonTweaks; use Kanboard\Core\Plugin\Base; +use Kanboard\Model\TaskModel; class Plugin extends Base { @@ -77,6 +78,39 @@ class Plugin extends Base $this->template->hook->attach('template:board:private:task:before-title', 'organonTweaks:board/title_edit'); } + // Tweak: per-column persistent sort. A sort control (number-aware title sort + a + // "Persistent sort: ON/OFF" toggle) replaces the native column sort. Sorting always reorders + // physically (writes positions, like native); when persistent is ON, the column is re-sorted + // whenever a card is dropped in or created, so it stays sorted. Persistent OFF = native + // one-shot. Control injected via the column dropdown hook + relocate.js (which also hides the + // native fa-sort menu). + if ((int) $this->configModel->get('organon_tweaks_persistent_sort', 1) === 1) { + $this->template->hook->attach('template:board:column:dropdown', 'organonTweaks:board/sort_control'); + + $container = $this->container; + $reapply = function ($project_id, $swimlane_id, $column_id) use ($container) { + $helper = new \Kanboard\Plugin\OrganonTweaks\Helper\OrganonSortHelper($container); + if ($helper->isSticky($project_id, $column_id)) { + $helper->reapply($project_id, $swimlane_id, $column_id); + } + }; + + // Card dropped into a column -> re-apply that column's sort (the plugin on() wrapper drops + // the event, so subscribe on the dispatcher to receive the TaskEvent). + $this->dispatcher->addListener(TaskModel::EVENT_MOVE_COLUMN, function ($event) use ($reapply) { + $task = $event['task']; + $reapply((int) $task['project_id'], (int) $task['swimlane_id'], (int) $event['dst_column_id']); + }); + + // New card created in a column -> re-apply that column's sort. + $this->hook->on('model:task:creation:aftersave', function ($task_id) use ($container, $reapply) { + $task = $container['taskFinderModel']->getById($task_id); + if (! empty($task)) { + $reapply((int) $task['project_id'], (int) $task['swimlane_id'], (int) $task['column_id']); + } + }); + } + // Auto-managed shared custom filters (v1.5 "Show all tasks", v1.6 month filters, v1.7 // recurring filters). Ensured lazily whenever a project header renders (covers old + new // boards); each group is gated by its own config toggle inside @@ -107,7 +141,7 @@ class Plugin extends Base public function getHelpers() { return array( - 'Plugin\OrganonTweaks\Helper' => array('OrganonColumnHelper', 'OrganonProjectHelper'), + 'Plugin\OrganonTweaks\Helper' => array('OrganonColumnHelper', 'OrganonProjectHelper', 'OrganonSortHelper'), ); } diff --git a/Template/board/sort_control.php b/Template/board/sort_control.php new file mode 100644 index 0000000..1a7a3ea --- /dev/null +++ b/Template/board/sort_control.php @@ -0,0 +1,69 @@ +user->hasProjectAccess('TaskModificationController', 'update', $column['project_id'])) { + return; +} + +$pid = $column['project_id']; +$cid = $column['id']; +$sid = $swimlane['id']; + +$sticky = $this->OrganonSortHelper->isSticky($pid, $cid); +$curBy = $this->OrganonSortHelper->sortBy($pid, $cid); +$curDir = $this->OrganonSortHelper->sortDir($pid, $cid); +$applied = $curBy !== ''; +$desc = $curDir === 'desc'; + +$criteria = array( + array('title', 'asc', t('Title (A to Z)')), + array('title', 'desc', t('Title (Z to A)')), + array('id', 'asc', t('Id (ASC)')), + array('id', 'desc', t('Id (DESC)')), + array('priority', 'asc', t('Priority (ASC)')), + array('priority', 'desc', t('Priority (DESC)')), + array('due-date', 'asc', t('Due date (ASC)')), + array('due-date', 'desc', t('Due date (DESC)')), + array('assignee', 'asc', t('Assignee (A-Z)')), + array('assignee', 'desc', t('Assignee (Z-A)')), + array('assignee-priority', 'asc', t('Assignee + priority (ASC)')), + array('assignee-priority', 'desc', t('Assignee + priority (DESC)')), +); +?> + + + + + + + + + + + diff --git a/Template/config/show.php b/Template/config/show.php index 5c3ef64..502d738 100644 --- a/Template/config/show.php +++ b/Template/config/show.php @@ -55,6 +55,13 @@

+
+ + + form->checkbox('organon_tweaks_persistent_sort', t('Per-column persistent sort'), 1, isset($values['organon_tweaks_persistent_sort']) && $values['organon_tweaks_persistent_sort'] == 1) ?> +

+
+