6 Commits

15 changed files with 690 additions and 25 deletions

View File

@@ -1,18 +1,108 @@
/*
* OrganonTweaks -- keep the board's horizontal scroll position across refreshes.
* OrganonTweaks -- keep the board's scroll position (horizontal AND vertical) across board AJAX
* rebuilds and full page reloads.
*
* When you drop a card (and on Kanboard's periodic AJAX polling) the board is rebuilt with
* `$("#board-container").replaceWith(data)` (BoardDragAndDrop.refresh). The brand-new
* element starts at scrollLeft 0, so the board snaps back to the first column -- a jarring
* jump. This remembers the last scroll position and restores it the moment a replacement
* container appears, so the view stays put.
* Two events reset the scroll and jump the board around:
* 1. AJAX rebuild -- dropping a card / periodic polling replaces #board-container
* (BoardDragAndDrop.refresh); the new element starts at scroll 0.
* 2. Full page reload -- e.g. clicking the Todo/Done badge or the "Mark all" action, which
* navigate and redirect back to the board; a fresh page starts at scroll 0.
*
* We observe the STABLE parent (the container itself is replaced) and read the scroll from
* whichever #board-container is current, so it keeps working after every rebuild.
* Axes restored (all persisted per board in sessionStorage, so they survive a full reload):
* - horizontal: #board-container.scrollLeft (key :hx)
* - page vertical (expanded mode): window.scrollY (key :vy)
* - per-column vertical (compact mode): each native .board-task-list-compact scrollTop, keyed by
* its data-swimlane-id + data-column-id (key :vcol:<sw>:<col>)
*
* The per-column container is native (core board.css: .board-task-list-compact { overflow-y:auto });
* ShrinkVertically only re-tunes its max-height, so this works with or without that plugin. Doing all
* axes every time is safe: in expanded mode there are no compact lists (that loop is a no-op) and the
* page owns the vertical scroll; in compact mode the columns own it and the page barely moves.
*/
(function () {
"use strict";
function baseKey() {
var m = location.href.match(/board\/(\d+)/) || location.href.match(/project_id=(\d+)/);
return "organon-board-scroll-" + (m ? m[1] : location.pathname);
}
function readNum(suffix) {
try {
var v = window.sessionStorage.getItem(baseKey() + suffix);
return v !== null ? parseInt(v, 10) : null;
} catch (e) {
return null;
}
}
function writeNum(suffix, value) {
try {
window.sessionStorage.setItem(baseKey() + suffix, value);
} catch (e) {
// sessionStorage unavailable (private mode / disabled) -- degrade to AJAX-only restore.
}
}
// While we restore programmatically, the browser fires scroll events; suppress the save briefly so
// a not-yet-scrollable column (whose scrollTop stays 0) does not overwrite the stored position.
var suppress = 0;
function suppressBriefly() {
suppress++;
window.setTimeout(function () {
if (suppress > 0) {
suppress--;
}
}, 150);
}
function colSuffix(el) {
return ":vcol:" + (el.getAttribute("data-swimlane-id") || "0") + ":" + (el.getAttribute("data-column-id") || "0");
}
// Restore the vertical axes. Called on load, again on the next frame (after ShrinkVertically has
// settled the column heights), and on every board rebuild.
function restoreVertical() {
var y = readNum(":vy");
if (y !== null && Math.round(window.scrollY) !== y) {
suppressBriefly();
window.scrollTo(window.scrollX, y);
}
var lists = document.querySelectorAll("#board .board-task-list-compact");
for (var i = 0; i < lists.length; i++) {
var el = lists[i];
var s = readNum(colSuffix(el));
if (s !== null && el.scrollTop !== s) {
suppressBriefly();
el.scrollTop = s;
}
}
}
// Core adds the .board-task-list-compact class via JS AFTER load (BoardVerticalScrolling.render),
// and re-adds it on every board render -- so the per-column scroll containers often do not exist
// yet when we first restore (this is why compact restore worked on F5, where the browser restores
// element scroll natively, but not after a badge/redirect navigation). Re-run the vertical restore
// whenever the board mutates or a class changes, rAF-coalesced. Idempotent (only sets scrollTop on
// a mismatch), so it lands once the compact lists appear and never fights the user afterwards.
var restoreScheduled = false;
function scheduleRestore() {
if (restoreScheduled) {
return;
}
restoreScheduled = true;
var run = function () {
restoreScheduled = false;
restoreVertical();
};
if (window.requestAnimationFrame) {
window.requestAnimationFrame(run);
} else {
window.setTimeout(run, 16);
}
}
function init() {
var container = document.getElementById("board-container");
if (!container) {
@@ -20,30 +110,61 @@
}
var parent = container.parentNode;
var lastScrollLeft = container.scrollLeft;
var lastContainer = container;
// Remember the position whenever the board is scrolled (capture: scroll does not
// bubble, and this survives the container being replaced).
// --- horizontal ---
var storedX = readNum(":hx");
var lastScrollLeft = storedX !== null ? storedX : container.scrollLeft;
var lastContainer = container;
if (storedX !== null && container.scrollLeft !== storedX) {
suppressBriefly();
container.scrollLeft = storedX;
}
// --- vertical (page + per-column), now and again after layout settles ---
restoreVertical();
if (window.requestAnimationFrame) {
window.requestAnimationFrame(restoreVertical);
}
// Save on scroll. Capture phase: scroll does not bubble, but a capture listener on document
// still receives it from #board-container and from any per-column list.
document.addEventListener("scroll", function (e) {
if (suppress > 0) {
return;
}
var t = e.target;
var c = document.getElementById("board-container");
if (c && e.target === c) {
if (t === c) {
lastScrollLeft = c.scrollLeft;
writeNum(":hx", c.scrollLeft);
} else if (t && t.nodeType === 1 && t.classList && t.classList.contains("board-task-list-compact")) {
writeNum(colSuffix(t), t.scrollTop);
}
}, true);
// When the board is rebuilt, #board-container becomes a new element at scrollLeft 0;
// restore the remembered position before the browser paints it.
// Page vertical scroll targets the document, so listen on window.
window.addEventListener("scroll", function () {
if (suppress === 0) {
writeNum(":vy", Math.round(window.scrollY));
}
});
// Observe the STABLE parent for: (a) #board-container being replaced on an AJAX rebuild
// (childList) -> restore horizontal; and (b) class changes anywhere below (attributes) -> the
// moment core adds .board-task-list-compact, restore the per-column verticals. subtree covers
// the deep .board-task-list elements. Both funnel into the rAF-coalesced scheduleRestore.
if (window.MutationObserver) {
new MutationObserver(function () {
var c = document.getElementById("board-container");
if (c && c !== lastContainer) {
lastContainer = c;
if (c.scrollLeft !== lastScrollLeft) {
suppressBriefly();
c.scrollLeft = lastScrollLeft;
}
}
}).observe(parent, { childList: true });
scheduleRestore();
}).observe(parent, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });
}
}

View File

@@ -18,7 +18,7 @@
"use strict";
function relocateColumnMenuItems() {
var items = document.querySelectorAll(".organontweaks-remove-column-item");
var items = document.querySelectorAll(".organontweaks-remove-column-item, .organontweaks-markall-item");
for (var i = 0; i < items.length; i++) {
var li = items[i];

View File

@@ -0,0 +1,57 @@
/*
* OrganonTweaks -- fix a core Kanboard touch bug on reorder tables.
*
* Kanboard inits the subtask / board-column / swimlane reorder sortables with
* handle:"td:first i", which matches EVERY <i> in a row's first cell -- so the gear/caret menu
* icons and the subtask status checkbox become drag handles too. On touch devices the bundled
* jQuery UI Touch Punch then captures the press on those icons, preventDefault()s the native
* tap-click, and only re-fires a click if the finger did not move at all -- so almost every real
* tap is swallowed and the menu never opens (the gear "drags" instead of opening).
*
* Fix: re-scope the handle option to the real drag icon (.draggable-row-handle) on every such
* sortable, so only the arrows drag and the other first-cell icons are plain clicks again.
* jQuery UI reads options.handle at press time, so this takes effect immediately, no re-init.
* Kanboard re-inits the sortable whenever it re-renders a table (subtask add/edit, column
* reorder, and so on), which re-applies the bad handle -- so we re-apply the fix after every
* render via a debounced MutationObserver. Always on (no setting). Harmless on desktop, where
* the mouse never takes the Touch Punch path.
*/
(function () {
"use strict";
var $j = window.jQuery;
if (! $j) {
return;
}
function fixHandles() {
$j(".ui-sortable").each(function () {
try {
var $s = $j(this);
if ($s.sortable("option", "handle") === "td:first i") {
$s.sortable("option", "handle", ".draggable-row-handle");
}
} catch (e) {
// element is not an initialized sortable -- skip it
}
});
}
var pending = false;
function schedule() {
if (pending) {
return;
}
pending = true;
window.setTimeout(function () {
pending = false;
fixHandles();
}, 0);
}
$j(fixHandles); // initial pass on DOM ready
new MutationObserver(schedule).observe(document.body, {
childList: true,
subtree: true
});
})();

View File

@@ -25,6 +25,9 @@ class ConfigController extends BaseController
'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),
'organon_tweaks_done_badge' => (int) $this->configModel->get('organon_tweaks_done_badge', 0),
'organon_tweaks_done_closes_task' => (int) $this->configModel->get('organon_tweaks_done_closes_task', 0),
'organon_tweaks_done_autosubtasks' => (int) $this->configModel->get('organon_tweaks_done_autosubtasks', 0),
),
'errors' => array(),
)));
@@ -45,6 +48,9 @@ class ConfigController extends BaseController
$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;
$doneBadge = isset($values['organon_tweaks_done_badge']) ? 1 : 0;
$doneClosesTask = isset($values['organon_tweaks_done_closes_task']) ? 1 : 0;
$doneAutoSubtasks = isset($values['organon_tweaks_done_autosubtasks']) ? 1 : 0;
if ($this->configModel->save(array(
'organon_tweaks_always_comment_icon' => $alwaysCommentIcon,
@@ -58,6 +64,9 @@ class ConfigController extends BaseController
'organon_tweaks_shortcut_labels' => $shortcutLabels,
'organon_tweaks_title_click_edit' => $titleClickEdit,
'organon_tweaks_persistent_sort' => $persistentSort,
'organon_tweaks_done_badge' => $doneBadge,
'organon_tweaks_done_closes_task' => $doneClosesTask,
'organon_tweaks_done_autosubtasks' => $doneAutoSubtasks,
))) {
$this->flash->success(t('Settings saved successfully.'));
} else {

View File

@@ -0,0 +1,118 @@
<?php
namespace Kanboard\Plugin\OrganonTweaks\Controller;
use Kanboard\Controller\BaseController;
use Kanboard\Core\Controller\AccessForbiddenException;
use Kanboard\Model\TaskModel;
use Kanboard\Plugin\OrganonTweaks\Helper\OrganonDoneHelper;
/**
* Toggle a task's Done/Todo badge (OrganonTweaks).
*
* One-click CSRF link from the board card face and the task view. Single source of truth per mode:
* - close-mode on: Done == closed, so this just closes/opens the task (no metadata); native
* Close/Open stay in sync automatically because the badge reads is_active.
* - close-mode off: this flips the 'organon_done' metadata marker and never touches open/closed.
* Redirects back to wherever it was clicked (board or task view).
*
* Also provides the per-column bulk action (confirmColumn opens a two-button modal; markColumn sets
* EVERY task in the column+swimlane to the chosen state -- overwrite, not a per-task toggle).
*/
class DoneController extends BaseController
{
public function toggle()
{
$task = $this->getTask();
$this->checkCSRFParam();
$helper = new OrganonDoneHelper($this->container);
$done = $helper->isDone($task);
if ($helper->closesTask()) {
if ($done) {
$this->taskStatusModel->open($task['id']);
} else {
$this->taskStatusModel->close($task['id']);
}
} else {
if ($done) {
$this->taskMetadataModel->remove($task['id'], OrganonDoneHelper::DONE_KEY);
} else {
$this->taskMetadataModel->save($task['id'], array(OrganonDoneHelper::DONE_KEY => 'on'));
}
}
if ($this->request->getStringParam('from') === 'board') {
$this->response->redirect($this->helper->url->to('BoardViewController', 'show', array('project_id' => $task['project_id'])), true);
} else {
$this->response->redirect($this->helper->url->to('TaskViewController', 'show', array('task_id' => $task['id'])), true);
}
}
/**
* Open the "Mark all as Done/Todo" modal for a column: one modal that both confirms and lets the
* user pick the direction (two buttons). No state is tracked -- the direction is chosen here.
*/
public function confirmColumn()
{
$project = $this->getProject();
$this->checkColumnWriteAccess($project['id']);
$this->response->html($this->template->render('organonTweaks:board/mark_all_confirm', array(
'project_id' => $project['id'],
'column_id' => $this->request->getIntegerParam('column_id'),
'swimlane_id' => $this->request->getIntegerParam('swimlane_id'),
)));
}
/**
* Set EVERY task in the column+swimlane to Done or Todo (direction param). Overwrites uniformly --
* already-done tasks are harmlessly re-set. Mode-aware, like the badge: close-mode closes/opens
* all; marker-mode sets/clears the metadata on all.
*/
public function markColumn()
{
$project = $this->getProject();
$this->checkColumnWriteAccess($project['id']);
$this->checkCSRFParam();
$column_id = $this->request->getIntegerParam('column_id');
$swimlane_id = $this->request->getIntegerParam('swimlane_id');
$done = $this->request->getStringParam('direction') === 'done';
$helper = new OrganonDoneHelper($this->container);
$closes = $helper->closesTask();
$tasks = $this->db->table(TaskModel::TABLE)
->columns('id')
->eq('project_id', $project['id'])
->eq('column_id', $column_id)
->eq('swimlane_id', $swimlane_id)
->findAll();
foreach ($tasks as $task) {
if ($closes) {
$done ? $this->taskStatusModel->close($task['id']) : $this->taskStatusModel->open($task['id']);
} elseif ($done) {
$this->taskMetadataModel->save($task['id'], array(OrganonDoneHelper::DONE_KEY => 'on'));
} else {
$this->taskMetadataModel->remove($task['id'], OrganonDoneHelper::DONE_KEY);
}
}
$this->flash->success(t('All tasks updated.'));
$this->response->redirect($this->helper->url->to('BoardViewController', 'show', array('project_id' => $project['id'])), true);
}
/**
* Guard the bulk action to users who may modify tasks on this board (the menu item is already
* gated the same way; this backstops a direct request).
*/
private function checkColumnWriteAccess($project_id)
{
if (! $this->helper->user->hasProjectAccess('TaskModificationController', 'update', $project_id)) {
throw new AccessForbiddenException();
}
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace Kanboard\Plugin\OrganonTweaks\Helper;
use Kanboard\Core\Base;
use Kanboard\Model\SubtaskModel;
/**
* Done/Todo badge helper.
*
* "Done" has a single source of truth PER MODE (no drift with native Close/Open):
* - close-mode on ("check done also closes tasks"): Done == the task is CLOSED (is_active == 0).
* The badge reads the task's own is_active, so however a task is closed/reopened (badge, native
* sidebar, bulk, API) the badge always matches. No metadata is stored in this mode.
* - close-mode off (marker only): Done == the task metadata DONE_KEY == 'on', independent of the
* open/closed status. Stored as the non-falsy string 'on' (never '0'/'1'): MetadataModel::get()
* uses `?:` and PHP treats "0" as falsy, so a stored "0" would read back as the default.
*
* Templates cannot read models directly, so the board/task-view badge templates call isDone() here,
* passing the task array (which carries both id and is_active) so the close-mode read needs no query.
*/
class OrganonDoneHelper extends Base
{
const DONE_KEY = 'organon_done';
const CLOSES_KEY = 'organon_tweaks_done_closes_task';
const AUTOSUB_KEY = 'organon_tweaks_done_autosubtasks';
const ALLDONE_KEY = 'organon_subtasks_alldone';
/**
* Is this task Done? (mode-aware: closed status in close-mode, else the metadata marker)
*
* @param array $task a task array carrying 'id' and 'is_active'
* @return bool
*/
public function isDone(array $task)
{
if ($this->closesTask()) {
return isset($task['is_active']) && (int) $task['is_active'] === 0;
}
return $this->taskMetadataModel->get((int) $task['id'], self::DONE_KEY, '') === 'on';
}
/**
* Should toggling Done also close (and reopen) the task? Global setting, default off.
*
* @return bool
*/
public function closesTask()
{
return (int) $this->configModel->get(self::CLOSES_KEY, 0) === 1;
}
/**
* Mark a task Done in the current mode. Idempotent -- a no-op if it is already Done. Close-mode
* closes the task; marker-mode sets the DONE_KEY metadata.
*
* @param int $task_id
*/
public function markDone($task_id)
{
$task = $this->taskFinderModel->getById((int) $task_id);
if (empty($task) || $this->isDone($task)) {
return;
}
if ($this->closesTask()) {
$this->taskStatusModel->close((int) $task_id);
} else {
$this->taskMetadataModel->save((int) $task_id, array(self::DONE_KEY => 'on'));
}
}
/**
* Recompute whether the task's subtasks are ALL done and, only on the up-transition
* (was-not-all-done -> now-all-done), auto-mark the task Done. The last-seen all-done state is kept
* in ALLDONE_KEY ('on'/'off') to detect that edge -- so a manual unmark is honored (nothing
* re-fires) and title edits / refreshes never re-mark. Never auto-unmarks.
*
* @param int $task_id
* @param int $exclude_id a subtask id to exclude from the counts (the row being deleted --
* EVENT_DELETE fires BEFORE the row is removed, so it still counts)
*/
public function syncSubtasksDone($task_id, $exclude_id = 0)
{
$task_id = (int) $task_id;
$exclude_id = (int) $exclude_id;
$totalQuery = $this->db->table(SubtaskModel::TABLE)->eq('task_id', $task_id);
$doneQuery = $this->db->table(SubtaskModel::TABLE)->eq('task_id', $task_id)->eq('status', SubtaskModel::STATUS_DONE);
if ($exclude_id > 0) {
$totalQuery->neq('id', $exclude_id);
$doneQuery->neq('id', $exclude_id);
}
$total = $totalQuery->count();
$done = $doneQuery->count();
$allDone = $total > 0 && $done === $total;
$prev = $this->taskMetadataModel->get($task_id, self::ALLDONE_KEY, 'off') === 'on';
// Save the new state BEFORE marking: in close-mode markDone() -> close() -> closeAll() re-fires
// subtask events into this method; with the marker already 'on', that re-entry sees no
// transition and is a no-op (no loop).
$this->taskMetadataModel->save($task_id, array(self::ALLDONE_KEY => $allDone ? 'on' : 'off'));
if ($allDone && ! $prev) {
$this->markDone($task_id);
}
}
}

View File

@@ -3,6 +3,7 @@
namespace Kanboard\Plugin\OrganonTweaks;
use Kanboard\Core\Plugin\Base;
use Kanboard\Model\SubtaskModel;
use Kanboard\Model\TaskModel;
class Plugin extends Base
@@ -18,6 +19,15 @@ class Plugin extends Base
'template' => 'plugins/OrganonTweaks/Asset/js/relocate.js',
));
// Fix a core Kanboard touch bug: the subtask / board-column / swimlane reorder sortables
// use handle:"td:first i", which makes the gear/caret menu icons and the subtask status
// checkbox (all <i> in the first cell) drag handles too, so on touch devices Touch Punch
// swallows their taps -- the gear "drags" instead of opening. Re-scope the handle to the
// real drag icon (.draggable-row-handle) in JS. Always on; harmless on desktop.
$this->hook->on('template:layout:js', array(
'template' => 'plugins/OrganonTweaks/Asset/js/sortable-handle-fix.js',
));
// Settings page for the plugin's tweaks.
$this->template->hook->attach('template:config:sidebar', 'organonTweaks:config/sidebar');
@@ -111,6 +121,41 @@ class Plugin extends Base
});
}
// Tweak: a Done/Todo status badge on the card face (top-right, below the header) and in the
// task view (4th column, near the due date). Two-state toggle; single source of truth per
// mode -- close-mode ON => Done == closed (reads is_active, so native Close/Open stay in
// sync); close-mode OFF => a task-metadata marker (organon_done). Server-rendered, no JS.
if ((int) $this->configModel->get('organon_tweaks_done_badge', 0) === 1) {
$this->template->hook->attach('template:board:private:task:before-title', 'organonTweaks:board/done_badge');
$this->template->hook->attach('template:task:details:fourth-column', 'organonTweaks:task/done_badge');
$this->template->hook->attach('template:layout:head', 'organonTweaks:layout/done_style');
// Bulk "Mark all as Done/Todo" entry in the column header dropdown (moved into the native
// menu by relocate.js). Opens a two-button modal that confirms + picks the direction.
$this->template->hook->attach('template:board:column:dropdown', 'organonTweaks:board/mark_all_item');
// Sub-tweak (opt-in): auto-mark a card Done when its LAST subtask is completed. On every
// subtask change, recompute all-done and mark Done only on the up-transition
// (state-comparison in the helper), so a manual unmark is honored -- nothing re-fires.
// Dispatcher direct (the plugin on() wrapper drops the event). DELETE fires before the row
// is removed, so its id is excluded from the recount.
if ((int) $this->configModel->get('organon_tweaks_done_autosubtasks', 0) === 1) {
$container = $this->container;
$sync = function ($task_id, $exclude_id = 0) use ($container) {
$helper = new \Kanboard\Plugin\OrganonTweaks\Helper\OrganonDoneHelper($container);
$helper->syncSubtasksDone((int) $task_id, (int) $exclude_id);
};
$this->dispatcher->addListener(SubtaskModel::EVENT_UPDATE, function ($event) use ($sync) {
$sync($event['subtask']['task_id']);
});
$this->dispatcher->addListener(SubtaskModel::EVENT_CREATE, function ($event) use ($sync) {
$sync($event['subtask']['task_id']);
});
$this->dispatcher->addListener(SubtaskModel::EVENT_DELETE, function ($event) use ($sync) {
$sync($event['subtask']['task_id'], $event['subtask']['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
@@ -141,7 +186,7 @@ class Plugin extends Base
public function getHelpers()
{
return array(
'Plugin\OrganonTweaks\Helper' => array('OrganonColumnHelper', 'OrganonProjectHelper', 'OrganonSortHelper'),
'Plugin\OrganonTweaks\Helper' => array('OrganonColumnHelper', 'OrganonProjectHelper', 'OrganonSortHelper', 'OrganonDoneHelper'),
);
}
@@ -162,7 +207,7 @@ class Plugin extends Base
public function getPluginVersion()
{
return '1.9.1';
return '2.3.0';
}
public function getPluginHomepage()

View File

@@ -39,12 +39,19 @@ have comments keep showing the count as before.
When you drop a card (or Kanboard auto-refreshes the board via polling), it rebuilds the
board with `$("#board-container").replaceWith(...)`, and the new element starts scrolled to
the far left -- so the view jumps back to the first column. This tweak remembers the
horizontal scroll position and restores it as soon as the rebuilt board appears, so the
board stays where you were.
the far left -- so the view jumps back to the first column. A **full page reload** (for
example clicking the Todo/Done badge or the "Mark all" action, which navigate and redirect back to
the board) does the same. This tweak remembers the scroll position -- **horizontal and vertical** --
and restores it in both cases, so the board stays where you were.
- Implemented in `Asset/js/keep-scroll.js`: it observes the stable parent (the container
itself is replaced), reads/writes `scrollLeft` on whichever `#board-container` is current.
- Implemented in `Asset/js/keep-scroll.js`: it observes the stable parent (the container itself is
replaced) and restores, on whichever `#board-container` is current after an AJAX rebuild and again
after a full reload (persisted per board in `sessionStorage`):
- the **horizontal** position (`#board-container.scrollLeft`);
- the **page vertical** position in expanded mode (`window.scrollY`);
- the **per-column vertical** position in compact/collapsed mode -- each native
`.board-task-list-compact` list, keyed by its `data-swimlane-id` + `data-column-id`, so every
column keeps its own place independently (works with or without ShrinkVertically).
- **On by default.** Toggle it under "Settings -> Organon Tweaks".
### Open a card only on a quick click
@@ -117,6 +124,54 @@ single arrow** (up for ascending, down for descending) when it is ON.
(which also hides the native sort menu). Per-column state lives in project metadata.
- **On by default.** Toggle it under "Settings -> Organon Tweaks".
### Done/Todo badge
A two-state toggle badge for marking a card done, shown on the board card face (above the title) and
on the task view (4th column, near the due date). It reads **Todo** (black on light red) while the
task is pending; one click flips it to **Done** (black on light green). The card itself is not
recolored -- only the badge.
- Rendered server-side (no JavaScript) via `template:board:private:task:before-title`
(`Template/board/done_badge.php`), `template:task:details:fourth-column`
(`Template/task/done_badge.php`), and a small head `<style>` (`Template/layout/done_style.php`).
Shown only to users who may edit the task; on the board it appears on expanded (non-collapsed) cards.
- **Single source of truth, no drift with native Close/Open.** With *"Marking Done also closes the
task"* **on**, "Done" *means* the task is closed: the badge reads the task's own open/closed status,
so closing or reopening a task by any route (the badge, the native sidebar, bulk actions, the API)
always keeps the badge correct. A closed "Done" card is hidden from the board by Kanboard's default
open-only filter, but reappears if you clear the filter (or use the "Board: show all tasks" filter).
- With that option **off**, the badge is an independent marker stored in task metadata
(`organon_done`) that never touches the open/closed status.
- **Auto-mark on subtasks (opt-in).** When every subtask on a card is completed, the badge flips to
Done automatically (honoring the mode above -- marker or close). It only nudges at the moment the
last subtask completes: recomputing all-done on each subtask change and acting only on the
not-all-done -> all-done transition, so if you then set the badge back to Todo it is respected (the
subtasks stay done -- the description may hold unfinished business). It never auto-reverts to Todo.
Off by default; enable it in the Done badge settings.
- **Bulk per-column action.** The board column header dropdown gains a **Mark all as Done/Todo** entry
(`Template/board/mark_all_item.php`, moved into the menu by `relocate.js`). It opens a single modal
that both confirms and picks the direction -- **Mark all Done** or **Mark all Todo** -- then sets
every task in that column/swimlane to the chosen state (an overwrite, not a per-task toggle), honoring
the same close-mode/marker-mode semantics as the badge.
- **Off by default.** Toggle it under "Settings -> Organon Tweaks".
### Fix the subtask / column / swimlane menus on touch devices
On phones and tablets the little **gear menu** (edit / remove / convert) on a subtask row -- and the
same gear on the board **Columns** and **Swimlanes** config tables -- was almost impossible to tap:
roughly one tap in fifty opened it, while dragging to reorder worked fine. The cause is an upstream
Kanboard bug: those reorder tables set the drag handle to *every* icon in the row's first cell
(`handle: "td:first i"`), so the gear, its caret and the subtask status checkbox all count as drag
handles. On touch, Kanboard's bundled jQuery UI Touch Punch then treats a tap on them as a drag and
swallows the click. Desktop (mouse) is unaffected.
- `Asset/js/sortable-handle-fix.js` re-scopes those sortables' `handle` to the real drag icon
(`.draggable-row-handle`), so only the four-arrows drag and the gear/caret/checkbox are plain taps
again -- reordering still works. It re-applies after Kanboard re-renders a table (a debounced
MutationObserver), and only touches sortables whose handle is the buggy `td:first i`, so it is inert
everywhere else.
- **Always on** (no setting) -- it only corrects a broken interaction and does nothing on desktop.
## Settings
Global (per Kanboard instance) and admin-only, under "Settings -> Organon Tweaks", grouped as on
@@ -143,6 +198,11 @@ the page:
- **Per-column persistent sort** -- default on.
**Done badge**
- **Show a Done/Todo badge on cards** -- default off.
- **Marking Done also closes the task** -- default off.
Standalone:
- **Keep the board scroll position across refreshes** -- default on.

View File

@@ -0,0 +1,23 @@
<?php
/**
* Done/Todo badge on the board card face. Rendered by template:board:private:task:before-title; the
* CSS in layout/done_style.php pins it to the top-right (below the header) and colors the two states.
* Only shown to users who may edit the task (a read-only viewer sees no badge). Expanded cards only
* (collapsed cards do not fire this hook).
*/
if (! $this->user->hasProjectAccess('TaskModificationController', 'edit', $task['project_id'])) {
return;
}
$done = $this->OrganonDoneHelper->isDone($task);
?>
<span class="organontweaks-done<?= $done ? ' is-done' : '' ?>">
<?= $this->url->link(
$done ? '<i class="fa fa-check-square"></i> '.t('Done') : '<i class="fa fa-square"></i> '.t('Todo'),
'DoneController',
'toggle',
array('plugin' => 'OrganonTweaks', 'task_id' => $task['id'], 'project_id' => $task['project_id'], 'from' => 'board'),
true,
'',
$done ? t('Mark as not done') : t('Mark as done')
) ?>
</span>

View File

@@ -0,0 +1,35 @@
<?php
/**
* The "Mark all as Done/Todo" modal: one modal serving both purposes -- confirm and pick direction.
* Two buttons set every task in the column to the same state; cancel closes the modal. Each button is
* a CSRF link to DoneController::markColumn.
*/
?>
<div class="page-header">
<h2><?= t('Mark all tasks in this column') ?></h2>
</div>
<p class="confirm">
<?= t('Set every task in this column to the same state. Already-done tasks are overwritten (no harm).') ?>
</p>
<div class="form-actions">
<?= $this->url->link(t('Mark all Done'), 'DoneController', 'markColumn', array(
'plugin' => 'OrganonTweaks',
'project_id' => $project_id,
'column_id' => $column_id,
'swimlane_id' => $swimlane_id,
'direction' => 'done',
), true, 'btn btn-blue') ?>
<?= $this->url->link(t('Mark all Todo'), 'DoneController', 'markColumn', array(
'plugin' => 'OrganonTweaks',
'project_id' => $project_id,
'column_id' => $column_id,
'swimlane_id' => $swimlane_id,
'direction' => 'todo',
), true, 'btn btn-red') ?>
<?= t('or') ?>
<?= $this->url->link(t('cancel'), 'BoardViewController', 'show', array('project_id' => $project_id), false, 'close-popover') ?>
</div>

View File

@@ -0,0 +1,19 @@
<?php
/**
* "Mark all as Done/Todo" entry in the board column header dropdown. Like the "Remove this Column"
* item, the column hook renders outside the native menu <ul>, so this is hidden and moved into the
* menu by Asset/js/relocate.js. Shown only on non-empty columns to users who may modify tasks.
* Clicking opens a modal (DoneController::confirmColumn) that both confirms and picks the direction.
*/
if ($column['nb_tasks'] <= 0 || ! $this->user->hasProjectAccess('TaskModificationController', 'update', $column['project_id'])) {
return;
}
?>
<li class="organontweaks-markall-item" style="display: none;">
<?= $this->modal->medium('check-square-o', t('Mark all as Done/Todo'), 'DoneController', 'confirmColumn', array(
'plugin' => 'OrganonTweaks',
'project_id' => $column['project_id'],
'column_id' => $column['id'],
'swimlane_id' => $swimlane['id'],
)) ?>
</li>

View File

@@ -62,6 +62,19 @@
<p class="form-help"><?= t('Replaces the native column sort with a control that adds a number-aware Title sort and a "Persistent sort: ON/OFF" toggle. When ON, the column is re-sorted whenever a card is dropped in or created, so it stays sorted (a red up/down arrow marks it). OFF is the native one-shot sort.') ?></p>
</fieldset>
<fieldset>
<legend><?= t('Done badge') ?></legend>
<?= $this->form->checkbox('organon_tweaks_done_badge', t('Show a Done/Todo badge on cards'), 1, isset($values['organon_tweaks_done_badge']) && $values['organon_tweaks_done_badge'] == 1) ?>
<p class="form-help"><?= t('A two-state toggle badge on the board card (above the title) and the task view (near the due date): "Todo" (red) until you click it, then "Done" (green). Off by default.') ?></p>
<?= $this->form->checkbox('organon_tweaks_done_closes_task', t('Marking Done also closes the task'), 1, isset($values['organon_tweaks_done_closes_task']) && $values['organon_tweaks_done_closes_task'] == 1) ?>
<p class="form-help"><?= t('When on, "Done" means the task is closed (so it leaves the board unless you clear the status filter), and clicking Todo reopens it -- native Close/Open stay in sync. When off, the badge is an independent marker that never changes the open/closed status.') ?></p>
<?= $this->form->checkbox('organon_tweaks_done_autosubtasks', t('Auto-mark Done when the last subtask is completed'), 1, isset($values['organon_tweaks_done_autosubtasks']) && $values['organon_tweaks_done_autosubtasks'] == 1) ?>
<p class="form-help"><?= t('When every subtask on a card is done, flip the badge to Done automatically (honoring the mode above). It only nudges at that moment -- if you then set it back to Todo, that is respected (the subtasks stay done).') ?></p>
</fieldset>
<div class="form-actions">
<button type="submit" class="btn btn-blue"><?= t('Save') ?></button>
</div>

View File

@@ -0,0 +1,29 @@
<?php
/**
* Done/Todo badge styling (emitted into the head when the feature is enabled). NATURAL document flow --
* the badge renders above the title (board card) or as a list item (task view); no absolute
* positioning, so it adapts to any card / column / avatar / username size. Two states via .is-done:
* Todo = black filled box + black text on a light red (#ef9a9a); Done = checked box, black on light
* green (#a5d6a7). Both states use black text (core's `.task-board a { color:#000 }` forces the icon
* black anyway, so the backgrounds are kept light for contrast). Colors are tunable.
*/
?>
<style>
span.organontweaks-done a {
display: inline-block;
text-decoration: none;
padding: 3px 10px;
border-radius: 3px;
background: #ef9a9a;
color: #000;
font-weight: bold;
}
span.organontweaks-done.is-done a {
background: #a5d6a7;
color: #000;
}
.organontweaks-done {
display: inline-block;
margin: 1px 0;
}
</style>

View File

@@ -0,0 +1,24 @@
<?php
/**
* Done/Todo badge in the task view, 4th column (near the due date). Rendered by
* template:task:details:fourth-column as a list item, matching the surrounding <li> fields. Same
* two-state toggle as the board badge; only shown to users who may edit the task.
*/
if (! $this->user->hasProjectAccess('TaskModificationController', 'edit', $task['project_id'])) {
return;
}
$done = $this->OrganonDoneHelper->isDone($task);
?>
<li>
<span class="organontweaks-done<?= $done ? ' is-done' : '' ?>">
<?= $this->url->link(
$done ? '<i class="fa fa-check-square"></i> '.t('Done') : '<i class="fa fa-square"></i> '.t('Todo'),
'DoneController',
'toggle',
array('plugin' => 'OrganonTweaks', 'task_id' => $task['id'], 'project_id' => $task['project_id'], 'from' => 'task'),
true,
'',
$done ? t('Mark as not done') : t('Mark as done')
) ?>
</span>
</li>

View File

@@ -1 +1 @@
OrganonTweaks v1.9.1
OrganonTweaks v2.3.0