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)')),
+);
+?>
+
+
+
+ -
+ = $this->url->link(
+ ' '.($sticky ? t('Persistent sort: ON') : t('Persistent sort: OFF')),
+ 'OrganonSortController',
+ 'toggle',
+ array('plugin' => 'OrganonTweaks', 'project_id' => $pid, 'column_id' => $cid),
+ true
+ ) ?>
+
+ - = t('Sort by') ?>
+
+
+ -
+ = $this->url->link(
+ ' '.$this->text->e($c[2]),
+ 'OrganonSortController',
+ 'sort',
+ array('plugin' => 'OrganonTweaks', 'project_id' => $pid, 'column_id' => $cid, 'swimlane_id' => $sid, 'sort' => $c[0], 'direction' => $c[1]),
+ true
+ ) ?>
+
+
+
+
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 @@
= t('On the board and the card view, click the title to open the edit modal (only where you may edit).') ?>
+
+