Compare commits
27 Commits
4d7f88aa94
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ddbd4c42a6 | |||
| ca57a4044e | |||
| cc397e9982 | |||
| 8136c01403 | |||
| c88e52ab01 | |||
| f2f690ef98 | |||
| 8be9442cb7 | |||
| f9a7637e44 | |||
| d079198103 | |||
| 7ba4348de1 | |||
| 019b08084b | |||
| 2f78aa2087 | |||
| 8057272b9c | |||
| dc22739647 | |||
| 2d3c716852 | |||
| 269964d4cc | |||
| ac8e3b9544 | |||
| 717095848b | |||
| a905079abc | |||
| e032d5d2b4 | |||
| b63d366296 | |||
| 89888235d1 | |||
| 4ebebd5ffb | |||
| 9b637160b2 | |||
| e85d5d71f9 | |||
| ce3205646f | |||
| 4ce18d9e5f |
@@ -13,7 +13,7 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var THRESHOLD = 110; // ms; a press on a card held longer than this is a drag, not a click
|
||||
var THRESHOLD = 120; // ms; a press on a card held longer than this is a drag, not a click
|
||||
var pressStart = 0;
|
||||
|
||||
function onDown(e) {
|
||||
|
||||
@@ -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"] });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
@@ -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() {
|
||||
|
||||
42
Asset/js/shortcut-labels.js
Normal file
42
Asset/js/shortcut-labels.js
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* OrganonTweaks -- show the keyboard shortcuts Kanboard already binds, next to the task Actions
|
||||
* menu items. Display only: this adds NO key bindings, it just surfaces the existing ones (listed
|
||||
* in Kanboard's own "Keyboard shortcuts" help). Items are matched by their FontAwesome icon class,
|
||||
* so it is independent of the URL format (pretty vs query-string) and the interface language.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var HINTS = [
|
||||
{ icon: "fa-edit", key: "e" }, // Edit the task
|
||||
{ icon: "fa-plus", key: "s" }, // Add a sub-task
|
||||
{ icon: "fa-comment-o", key: "c" }, // Add a comment
|
||||
{ icon: "fa-code-fork", key: "l" } // Add internal link
|
||||
];
|
||||
|
||||
function annotate() {
|
||||
var sidebar = document.querySelector(".sidebar");
|
||||
if (!sidebar) { return; }
|
||||
|
||||
HINTS.forEach(function (hint) {
|
||||
var icon = sidebar.querySelector("li a i." + hint.icon);
|
||||
if (!icon) { return; }
|
||||
|
||||
var link = icon.closest("a");
|
||||
if (!link || link.querySelector(".organon-kbd")) { return; }
|
||||
|
||||
var kbd = document.createElement("span");
|
||||
kbd.className = "organon-kbd";
|
||||
kbd.textContent = hint.key;
|
||||
kbd.style.cssText = "margin-left:8px;padding:0 5px;border:1px solid #ccc;" +
|
||||
"border-radius:3px;font-family:monospace;font-size:0.85em;color:#777;background:#f7f7f7;";
|
||||
link.appendChild(kbd);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState !== "loading") {
|
||||
annotate();
|
||||
} else {
|
||||
document.addEventListener("DOMContentLoaded", annotate);
|
||||
}
|
||||
})();
|
||||
57
Asset/js/sortable-handle-fix.js
Normal file
57
Asset/js/sortable-handle-fix.js
Normal 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
|
||||
});
|
||||
})();
|
||||
67
Asset/js/title-click-edit.js
Normal file
67
Asset/js/title-click-edit.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* OrganonTweaks -- clicking a task title opens the edit modal instead of the read-only card view,
|
||||
* reusing Kanboard's own modal opener (KB.modal.open).
|
||||
* - Board: the title is a link, and a hidden marker rendered next to it (only when the user may
|
||||
* edit) carries the server-generated edit URL.
|
||||
* - Card view: the title (#task-summary > h2) opens the same modal via #task-view's data-edit-url,
|
||||
* but only when the user may edit -- detected by the presence of the sidebar "Edit" action.
|
||||
*
|
||||
* A capture-phase delegated click runs before Kanboard's own handlers, survives the board's AJAX
|
||||
* re-renders, and stops both the link navigation and the card's own click.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function openEdit(url) {
|
||||
if (url && window.KB && KB.modal) {
|
||||
KB.modal.open(url, "large", false);
|
||||
}
|
||||
}
|
||||
|
||||
// The sidebar "Edit the task" action (fa-edit) only renders when the current user may edit.
|
||||
function canEdit() {
|
||||
return !!document.querySelector(".sidebar a i.fa-edit");
|
||||
}
|
||||
|
||||
document.addEventListener("click", function (e) {
|
||||
if (!e.target || !e.target.closest) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Board card title -> open the edit modal via the per-card marker URL.
|
||||
var link = e.target.closest(".task-board-title a");
|
||||
if (link) {
|
||||
var card = link.closest(".task-board");
|
||||
var marker = card ? card.querySelector(".organon-edit-url") : null;
|
||||
if (marker) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openEdit(marker.getAttribute("data-url"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Card-view title -> open the same edit modal (#task-view carries the edit URL).
|
||||
var title = e.target.closest("#task-summary > h2");
|
||||
if (title && canEdit()) {
|
||||
var taskView = document.querySelector("#task-view");
|
||||
if (taskView && taskView.getAttribute("data-edit-url")) {
|
||||
openEdit(taskView.getAttribute("data-edit-url"));
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
|
||||
// Make the card-view title look clickable (the board title is already a link).
|
||||
function markTitle() {
|
||||
var title = document.querySelector("#task-summary > h2");
|
||||
if (title && canEdit()) {
|
||||
title.style.cursor = "pointer";
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState !== "loading") {
|
||||
markTitle();
|
||||
} else {
|
||||
document.addEventListener("DOMContentLoaded", markTitle);
|
||||
}
|
||||
})();
|
||||
@@ -21,6 +21,13 @@ class ConfigController extends BaseController
|
||||
'organon_tweaks_emphasize_duedate_default' => (int) $this->configModel->get('organon_tweaks_emphasize_duedate_default', 0),
|
||||
'organon_tweaks_show_all_filter' => (int) $this->configModel->get('organon_tweaks_show_all_filter', 1),
|
||||
'organon_tweaks_month_filters' => (int) $this->configModel->get('organon_tweaks_month_filters', 1),
|
||||
'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),
|
||||
'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(),
|
||||
)));
|
||||
@@ -37,6 +44,13 @@ class ConfigController extends BaseController
|
||||
$emphasizeDueDateDefault = isset($values['organon_tweaks_emphasize_duedate_default']) ? 1 : 0;
|
||||
$showAllFilter = isset($values['organon_tweaks_show_all_filter']) ? 1 : 0;
|
||||
$monthFilters = isset($values['organon_tweaks_month_filters']) ? 1 : 0;
|
||||
$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;
|
||||
$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,
|
||||
@@ -46,6 +60,13 @@ class ConfigController extends BaseController
|
||||
'organon_tweaks_emphasize_duedate_default' => $emphasizeDueDateDefault,
|
||||
'organon_tweaks_show_all_filter' => $showAllFilter,
|
||||
'organon_tweaks_month_filters' => $monthFilters,
|
||||
'organon_tweaks_recurring_filters' => $recurringFilters,
|
||||
'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 {
|
||||
@@ -68,6 +89,12 @@ class ConfigController extends BaseController
|
||||
$projectHelper->removeGroup($projectHelper::GROUP_MONTH);
|
||||
}
|
||||
|
||||
if ($recurringFilters === 1) {
|
||||
$projectHelper->addGroupToAllProjects($projectHelper::GROUP_RECURRING);
|
||||
} else {
|
||||
$projectHelper->removeGroup($projectHelper::GROUP_RECURRING);
|
||||
}
|
||||
|
||||
$this->response->redirect($this->helper->url->to('ConfigController', 'show', array('plugin' => 'OrganonTweaks')));
|
||||
}
|
||||
}
|
||||
|
||||
118
Controller/DoneController.php
Normal file
118
Controller/DoneController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
61
Controller/OrganonSortController.php
Normal file
61
Controller/OrganonSortController.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Kanboard\Plugin\OrganonTweaks\Controller;
|
||||
|
||||
use Kanboard\Controller\BaseController;
|
||||
use Kanboard\Core\Controller\AccessForbiddenException;
|
||||
use Kanboard\Plugin\OrganonTweaks\Helper\OrganonSortHelper;
|
||||
|
||||
/**
|
||||
* Per-column persistent sort, driven from the column's sort control on the board. Project-member
|
||||
* (task-update) gated + CSRF, mirroring core TaskReorderController; returns to the board.
|
||||
*/
|
||||
class OrganonSortController extends BaseController
|
||||
{
|
||||
private function authorize()
|
||||
{
|
||||
$project = $this->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);
|
||||
}
|
||||
}
|
||||
100
Filter/TaskRecurringFilter.php
Normal file
100
Filter/TaskRecurringFilter.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace Kanboard\Plugin\OrganonTweaks\Filter;
|
||||
|
||||
use Kanboard\Core\Filter\FilterInterface;
|
||||
use Kanboard\Filter\BaseFilter;
|
||||
use Kanboard\Model\TaskModel;
|
||||
use PicoDb\Database;
|
||||
|
||||
/**
|
||||
* Filter tasks by recurrence: `recurring:XY`, one attribute with one composite value so it can
|
||||
* INTERSECT the two axes (stacking two values of the same attribute would only OR them).
|
||||
*
|
||||
* X = system : evt (event-triggered = Kanboard native) | cal (calendar = RecoReco) | any
|
||||
* Y = role : ori (original/template) | dup (duplicate/copy) | all
|
||||
*
|
||||
* Conditions are authoritative -- taken straight from how core classifies these cards:
|
||||
* - evtori = recurrence_status PENDING, evtdup = recurrence_status PROCESSED. This is exactly how
|
||||
* core colors the board refresh icon (app/Template/board/task_footer.php: normal icon = PENDING,
|
||||
* fa-inverse = PROCESSED), so the filter matches what the board shows.
|
||||
* - calori = RecoReco metadata recoreco_enabled=1, caldup = recoreco_clone=1.
|
||||
* - *all / any* = the OR-unions of the above.
|
||||
*
|
||||
* Cross-plugin: the calendar side reads RecoReco metadata keys. If RecoReco is not installed the
|
||||
* metadata sets are empty, so `cal*` matches nothing and `evt*` still works (event-only degrade).
|
||||
*
|
||||
* One attribute + one value -> single-value path, so the predicates below are grouped in a scoped
|
||||
* beginOr/closeOr and the whole group still ANDs with any other search attribute.
|
||||
*/
|
||||
class TaskRecurringFilter extends BaseFilter implements FilterInterface
|
||||
{
|
||||
private $db;
|
||||
|
||||
public function setDatabase(Database $db)
|
||||
{
|
||||
$this->db = $db;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAttributes()
|
||||
{
|
||||
return array('recurring');
|
||||
}
|
||||
|
||||
public function apply()
|
||||
{
|
||||
$value = strtolower(trim((string) $this->value));
|
||||
$system = substr($value, 0, 3); // evt | cal | any
|
||||
$role = substr($value, 3, 3); // ori | dup | all
|
||||
|
||||
if (! in_array($system, array('evt', 'cal', 'any'), true) ||
|
||||
! in_array($role, array('ori', 'dup', 'all'), true)) {
|
||||
return $this; // unknown/typo value -> no-op
|
||||
}
|
||||
|
||||
$wantEvt = $system === 'evt' || $system === 'any';
|
||||
$wantCal = $system === 'cal' || $system === 'any';
|
||||
$wantOri = $role === 'ori' || $role === 'all';
|
||||
$wantDup = $role === 'dup' || $role === 'all';
|
||||
|
||||
$this->query->beginOr();
|
||||
|
||||
if ($wantEvt && $wantOri) {
|
||||
$this->query->eq(TaskModel::TABLE.'.recurrence_status', TaskModel::RECURRING_STATUS_PENDING);
|
||||
}
|
||||
|
||||
if ($wantEvt && $wantDup) {
|
||||
$this->query->eq(TaskModel::TABLE.'.recurrence_status', TaskModel::RECURRING_STATUS_PROCESSED);
|
||||
}
|
||||
|
||||
if ($wantCal && $wantOri) {
|
||||
$this->query->in(TaskModel::TABLE.'.id', $this->metaIds('recoreco_enabled'));
|
||||
}
|
||||
|
||||
if ($wantCal && $wantDup) {
|
||||
$this->query->in(TaskModel::TABLE.'.id', $this->metaIds('recoreco_clone'));
|
||||
}
|
||||
|
||||
$this->query->closeOr();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Task ids carrying a RecoReco metadata flag set to 1. Returns a non-matching sentinel when the
|
||||
* set is empty so the IN clause matches nothing (RecoReco absent -> event-only degrade).
|
||||
*
|
||||
* @param string $name
|
||||
* @return array
|
||||
*/
|
||||
private function metaIds($name)
|
||||
{
|
||||
$ids = $this->db->table('task_has_metadata')
|
||||
->eq('name', $name)
|
||||
->eq('value', '1')
|
||||
->findAllByColumn('task_id');
|
||||
|
||||
return ! empty($ids) ? $ids : array(-1);
|
||||
}
|
||||
}
|
||||
112
Helper/OrganonDoneHelper.php
Normal file
112
Helper/OrganonDoneHelper.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,17 +27,27 @@ class OrganonProjectHelper extends Base
|
||||
// Config toggles for the auto-managed custom-filter groups.
|
||||
const SHOW_ALL_FILTER_KEY = 'organon_tweaks_show_all_filter';
|
||||
const MONTH_FILTERS_KEY = 'organon_tweaks_month_filters';
|
||||
const RECURRING_FILTERS_KEY = 'organon_tweaks_recurring_filters';
|
||||
|
||||
// Reserved custom-filter groups: each row is [name, query, append]. The NAME is reserved by the
|
||||
// plugin (fixed, non-translated strings so dedup is stable across locales); a user's own filter
|
||||
// with a reserved name is absorbed into the canonical form. "Show all tasks" is a REPLACE filter
|
||||
// (append 0) so it clears; the rest are APPEND (1) so clicks stack (distinct attributes AND).
|
||||
// Names are prefixed by group so the dropdown -- which core sorts alphabetically by name
|
||||
// (CustomFilterModel::getAll -> asc(name)) -- clusters them: Board < Month < Recurring.
|
||||
const GROUP_SHOW_ALL = array(
|
||||
array('Show all tasks', '', 0),
|
||||
array('Board: show all tasks', '', 0),
|
||||
);
|
||||
const GROUP_MONTH = array(
|
||||
array('Due this month', 'duemonth:this', 1),
|
||||
array('Around this month', 'aroundmonth:this', 1),
|
||||
array('Month: due this month', 'duemonth:this', 1),
|
||||
array('Month: around this month', 'aroundmonth:this', 1),
|
||||
);
|
||||
const GROUP_RECURRING = array(
|
||||
array('Recurring: all', 'recurring:anyall', 1),
|
||||
array('Recurring: event', 'recurring:evtall', 1),
|
||||
array('Recurring: calendar', 'recurring:calall', 1),
|
||||
array('Recurring: templates', 'recurring:anyori', 1),
|
||||
array('Recurring: duplicates', 'recurring:anydup', 1),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -135,6 +145,10 @@ class OrganonProjectHelper extends Base
|
||||
if ((int) $this->configModel->get(self::MONTH_FILTERS_KEY, 1) === 1) {
|
||||
$this->ensureGroup($project_id, self::GROUP_MONTH);
|
||||
}
|
||||
|
||||
if ((int) $this->configModel->get(self::RECURRING_FILTERS_KEY, 1) === 1) {
|
||||
$this->ensureGroup($project_id, self::GROUP_RECURRING);
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureGroup($project_id, array $rows)
|
||||
|
||||
176
Helper/OrganonSortHelper.php
Normal file
176
Helper/OrganonSortHelper.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace Kanboard\Plugin\OrganonTweaks\Helper;
|
||||
|
||||
use Kanboard\Core\Base;
|
||||
use Kanboard\Model\TaskModel;
|
||||
|
||||
/**
|
||||
* Per-column persistent sort. Kanboard's native column sort is a one-shot physical reorder (writes
|
||||
* task positions); this stores the chosen sort per column and, when "persistent" is on, re-applies it
|
||||
* whenever a card is dropped in or created -- so the column stays sorted. Adds a number-aware title
|
||||
* sort on top of the native criteria.
|
||||
*
|
||||
* Everything lives in project metadata, per column, as literal strings:
|
||||
* organon_sort_by_<col> = id | priority | assignee-priority | assignee | due-date | title
|
||||
* organon_sort_dir_<col> = asc | desc
|
||||
* organon_sort_stick_<col> = 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Title sort, 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.
|
||||
*
|
||||
* Each title is reduced to a comparison slug -- lowercased, accent-stripped, spaces -> dashes --
|
||||
* then compared with strnatcasecmp. The dash is deliberate: strnatcasecmp SKIPS whitespace, so a
|
||||
* plain space would make "Luiz Eduardo" sort after "Luiza..." (it would compare "LuizEduardo" vs
|
||||
* "Luiza..."); a dash is not whitespace, so the separator is honoured, while strnatcasecmp still
|
||||
* groups digit runs ("Task 2" < "Task 10"). The result is case-insensitive, accent-insensitive,
|
||||
* whitespace-correct and number-aware.
|
||||
*/
|
||||
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();
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$row['sortkey'] = str_replace(' ', '-', $this->stripAccents(mb_strtolower((string) $row['title'], 'UTF-8')));
|
||||
}
|
||||
unset($row);
|
||||
|
||||
usort($rows, function ($a, $b) use ($direction) {
|
||||
$cmp = strnatcasecmp($a['sortkey'], $b['sortkey']);
|
||||
|
||||
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++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a string for sorting: strip accents to their base letter so accented names sort by that
|
||||
* base (Avila near A, not after Z). Uses ext-intl Normalizer when available (deterministic and
|
||||
* locale-independent), falling back to iconv //TRANSLIT (locale-dependent), then the raw string
|
||||
* (still sorts, just accent-naive). Case is already lowered by the caller.
|
||||
*
|
||||
* @param string $s
|
||||
* @return string
|
||||
*/
|
||||
private function stripAccents($s)
|
||||
{
|
||||
if (class_exists('Normalizer')) {
|
||||
$decomposed = \Normalizer::normalize($s, \Normalizer::FORM_D);
|
||||
|
||||
if ($decomposed !== false) {
|
||||
return preg_replace('/\p{Mn}/u', '', $decomposed);
|
||||
}
|
||||
}
|
||||
|
||||
$translit = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s);
|
||||
|
||||
return $translit !== false ? $translit : $s;
|
||||
}
|
||||
}
|
||||
123
Plugin.php
123
Plugin.php
@@ -3,6 +3,8 @@
|
||||
namespace Kanboard\Plugin\OrganonTweaks;
|
||||
|
||||
use Kanboard\Core\Plugin\Base;
|
||||
use Kanboard\Model\SubtaskModel;
|
||||
use Kanboard\Model\TaskModel;
|
||||
|
||||
class Plugin extends Base
|
||||
{
|
||||
@@ -17,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');
|
||||
|
||||
@@ -58,26 +69,116 @@ class Plugin extends Base
|
||||
));
|
||||
}
|
||||
|
||||
// Auto-managed shared custom filters (v1.5 "Show all tasks", v1.6 month filters). Ensured
|
||||
// lazily whenever a project header renders (covers old + new boards); each group is gated by
|
||||
// its own config toggle inside OrganonProjectHelper::ensureBoardFilters(). The toggles also
|
||||
// bulk add/remove the filters (ConfigController).
|
||||
// Tweak: show the keyboard shortcuts Kanboard already binds next to the task Actions menu
|
||||
// items (display only -- no new bindings). Done in JS since those menu <li> have no hook.
|
||||
if ((int) $this->configModel->get('organon_tweaks_shortcut_labels', 1) === 1) {
|
||||
$this->hook->on('template:layout:js', array(
|
||||
'template' => 'plugins/OrganonTweaks/Asset/js/shortcut-labels.js',
|
||||
));
|
||||
}
|
||||
|
||||
// Tweak: clicking a task title opens the edit modal (instead of the read-only card view).
|
||||
// JS reuses Kanboard's own modal opener. On the board the title is a link, so a hidden marker
|
||||
// (rendered by the hook below, only when the user may edit) carries the server-generated edit
|
||||
// URL for the JS to open; on the card view the JS reads #task-view's data-edit-url.
|
||||
if ((int) $this->configModel->get('organon_tweaks_title_click_edit', 1) === 1) {
|
||||
$this->hook->on('template:layout:js', array(
|
||||
'template' => 'plugins/OrganonTweaks/Asset/js/title-click-edit.js',
|
||||
));
|
||||
$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']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
// OrganonProjectHelper::ensureBoardFilters(). The toggles also bulk add/remove the filters.
|
||||
$showAllFilters = (int) $this->configModel->get('organon_tweaks_show_all_filter', 1) === 1;
|
||||
$monthFilters = (int) $this->configModel->get('organon_tweaks_month_filters', 1) === 1;
|
||||
if ($showAllFilters || $monthFilters) {
|
||||
$recurringFilters = (int) $this->configModel->get('organon_tweaks_recurring_filters', 1) === 1;
|
||||
if ($showAllFilters || $monthFilters || $recurringFilters) {
|
||||
$this->template->hook->attach('template:project:header:before', 'organonTweaks:project_header/board_filters');
|
||||
}
|
||||
|
||||
// Custom board-search keywords, registered into the task lexer by extending its factory
|
||||
// service (Pimple preserves factory status on extend, so the lexer stays per-request fresh).
|
||||
// `duemonth:VALUE` = a whole calendar month; `aroundmonth:VALUE` = that month padded by 10
|
||||
// days on each side. Always available to type, independent of the bookmark toggles above.
|
||||
// Guarded so a future Kanboard service rename no-ops (keyword absent) instead of breaking.
|
||||
// `duemonth:VALUE` / `aroundmonth:VALUE` = a calendar month (padded by 10 days for around);
|
||||
// `recurring:XY` = classify recurrence (X system evt/cal/any, Y role ori/dup/all). Always
|
||||
// available to type, independent of the bookmark toggles above. Guarded so a future Kanboard
|
||||
// service rename no-ops (keyword absent) instead of breaking.
|
||||
if (isset($this->container['taskLexer'])) {
|
||||
$this->container->extend('taskLexer', function ($builder, $c) {
|
||||
return $builder
|
||||
->withFilter((new \Kanboard\Plugin\OrganonTweaks\Filter\TaskMonthRangeFilter())->setAttribute('duemonth')->setPadDays(0))
|
||||
->withFilter((new \Kanboard\Plugin\OrganonTweaks\Filter\TaskMonthRangeFilter())->setAttribute('aroundmonth')->setPadDays(10));
|
||||
->withFilter((new \Kanboard\Plugin\OrganonTweaks\Filter\TaskMonthRangeFilter())->setAttribute('aroundmonth')->setPadDays(10))
|
||||
->withFilter((new \Kanboard\Plugin\OrganonTweaks\Filter\TaskRecurringFilter())->setDatabase($c['db']));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -85,7 +186,7 @@ class Plugin extends Base
|
||||
public function getHelpers()
|
||||
{
|
||||
return array(
|
||||
'Plugin\OrganonTweaks\Helper' => array('OrganonColumnHelper', 'OrganonProjectHelper'),
|
||||
'Plugin\OrganonTweaks\Helper' => array('OrganonColumnHelper', 'OrganonProjectHelper', 'OrganonSortHelper', 'OrganonDoneHelper'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,7 +207,7 @@ class Plugin extends Base
|
||||
|
||||
public function getPluginVersion()
|
||||
{
|
||||
return '1.6.0';
|
||||
return '2.3.0';
|
||||
}
|
||||
|
||||
public function getPluginHomepage()
|
||||
|
||||
165
README.md
165
README.md
@@ -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
|
||||
@@ -52,12 +59,37 @@ board stays where you were.
|
||||
Dropping a card sometimes leaks a trailing "click" that opens the card -- an unwanted side
|
||||
effect of the drag. A real click is a quick press-release; a drag is a press held much
|
||||
longer. So `Asset/js/click-guard.js` times the press on a card (`mousedown`/`touchstart` to
|
||||
the following `click`) and, if it was held longer than **100 ms**, swallows the click in the
|
||||
the following `click`) and, if it was held longer than **120 ms**, swallows the click in the
|
||||
capture phase before Kanboard's open handler -- so a drag never opens the card. Normal
|
||||
clicks (well under 100 ms) still open cards as usual. The 100 ms threshold is fixed.
|
||||
clicks (well under 120 ms) still open cards as usual. The 120 ms threshold is fixed.
|
||||
|
||||
- **On by default.** Toggle it under "Settings -> Organon Tweaks".
|
||||
|
||||
### Show keyboard shortcuts on the task menu
|
||||
|
||||
Kanboard already binds handy keys on the task view (`e` = edit, `s` = new sub-task, `c` = new
|
||||
comment, `l` = new internal link) but never shows them, so they go undiscovered. This tweak adds a
|
||||
small key hint next to each of those items in the task **Actions** sidebar. It is **display only**:
|
||||
it adds no new bindings, it just surfaces the ones Kanboard already has.
|
||||
|
||||
- Implemented in `Asset/js/shortcut-labels.js`, matching the menu items by their FontAwesome icon
|
||||
class, so it is independent of the URL format and the interface language.
|
||||
- **On by default.** Toggle it under "Settings -> Organon Tweaks".
|
||||
|
||||
### Click a title to edit
|
||||
|
||||
By default, clicking a card title (on the board) or the title on the card view lands you on the
|
||||
read-only card view. With this tweak on, clicking the title opens the **edit** form directly,
|
||||
reusing Kanboard's own modal -- so from the board you edit and, on Save, come straight back to the
|
||||
board. It applies only where you may edit (a read-only viewer keeps the normal title), and
|
||||
shared/public boards are untouched.
|
||||
|
||||
- Implemented in `Asset/js/title-click-edit.js` -- a capture-phase click that reuses `KB.modal.open`.
|
||||
On the board, a hidden marker (`Template/board/title_edit.php`, rendered by
|
||||
`template:board:private:task:before-title` only when the user may edit) carries the
|
||||
server-generated edit URL; on the card view the title reuses `#task-view`'s `data-edit-url`.
|
||||
- **On by default.** Toggle it under "Settings -> Organon Tweaks".
|
||||
|
||||
### Enhance the recurrence dialog
|
||||
|
||||
Kanboard's native "Edit recurrence" dialog is a plain form. This makes two small usability
|
||||
@@ -71,13 +103,109 @@ edits when it opens (there is no template hook, so it is done in JS,
|
||||
|
||||
- **On by default.** Toggle it under "Settings -> Organon Tweaks".
|
||||
|
||||
### Persistent column sort
|
||||
|
||||
Kanboard's native column sort is a one-shot physical reorder: it rewrites task positions once, so
|
||||
the order already survives refreshes, but a newly created or dropped-in card lands out of order
|
||||
until you sort again -- and there is no sort-by-title. This tweak replaces the per-column sort
|
||||
control with one that:
|
||||
|
||||
- adds a **number-aware Title sort** (A to Z / Z to A), so "Task 2" sorts before "Task 10";
|
||||
- adds a **Persistent sort: ON / OFF** toggle. When ON, the column is re-sorted (server-side,
|
||||
physically) whenever a card is dropped into it or created in it, so it **stays** sorted; when
|
||||
OFF it behaves exactly like the native one-shot sort.
|
||||
|
||||
The header icon shows the state: the native double-arrow when persistent is OFF, and a **red
|
||||
single arrow** (up for ascending, down for descending) when it is ON.
|
||||
|
||||
- Reorders reuse core's `TaskReorderModel` for the native criteria (id / priority / assignee /
|
||||
due date) and add the title sort; the sticky re-apply runs on the move / creation events.
|
||||
- The control is injected via the `template:board:column:dropdown` hook + `Asset/js/relocate.js`
|
||||
(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":
|
||||
Global (per Kanboard instance) and admin-only, under "Settings -> Organon Tweaks", grouped as on
|
||||
the page:
|
||||
|
||||
**Board card readability**
|
||||
|
||||
- **Always show the comment icon on board cards** -- default on.
|
||||
- **Keep the board scroll position across refreshes** -- default on.
|
||||
- **Emphasize due date by default** -- default off (overridable per board under Settings -> Integrations).
|
||||
|
||||
**Board filters** (shared custom filters added to every board)
|
||||
|
||||
- **Add a "Show all tasks" filter to every board** -- default on.
|
||||
- **Add month filters to every board** -- default on.
|
||||
- **Add recurring filters to every board** -- default on.
|
||||
|
||||
**Card interaction**
|
||||
|
||||
- **Open a card only on a quick click** -- default on.
|
||||
- **Show keyboard shortcuts on the task menu** -- default on.
|
||||
- **Clicking a task title opens the edit form** -- default on.
|
||||
|
||||
**Board columns**
|
||||
|
||||
- **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.
|
||||
- **Enhance the recurrence dialog** -- default on.
|
||||
|
||||
## Requirements
|
||||
@@ -93,3 +221,22 @@ name). No build step and no database migration.
|
||||
## License
|
||||
|
||||
AGPL-3.0. See LICENSE.
|
||||
|
||||
## More Kanboard plugins by Dr. Beco
|
||||
|
||||
All free and AGPL-3.0, at [code.beco.cc](https://code.beco.cc/beco):
|
||||
|
||||
- **[RecoReco](https://code.beco.cc/beco/RecoReco)** -- calendar-scheduled recurring cards (yearly,
|
||||
monthly, weekly, daily), driven by the card due date.
|
||||
- **[FinanceBuddy](https://code.beco.cc/beco/FinanceBuddy)** -- attach a money value (debit/credit
|
||||
and installments) to cards, shown on the card and totalled per column.
|
||||
- **[QualKard](https://code.beco.cc/beco/QualKard)** -- flashcard spaced repetition: grade a card by
|
||||
dragging it to a column, and it comes back when due (needs RecoReco).
|
||||
- **[WorkspaceOrg](https://code.beco.cc/beco/WorkspaceOrg)** -- group your projects into personal
|
||||
per-user workspaces, shown on a "My workspaces" dashboard page with a badge on each project.
|
||||
- **[BulkMoveTasks](https://code.beco.cc/beco/BulkMoveTasks)** -- move every task from one board
|
||||
column to another in a single action.
|
||||
- **[ShrinkVertically](https://code.beco.cc/beco/ShrinkVertically)** -- shrink vertically-collapsed
|
||||
board columns so the horizontal scrollbar stays within reach.
|
||||
- **[TweakDrag](https://code.beco.cc/beco/TweakDrag)** -- board drag and touch niceties:
|
||||
drag-to-scroll, a wider column gap, and smoother card dragging.
|
||||
|
||||
23
Template/board/done_badge.php
Normal file
23
Template/board/done_badge.php
Normal 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>
|
||||
35
Template/board/mark_all_confirm.php
Normal file
35
Template/board/mark_all_confirm.php
Normal 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>
|
||||
19
Template/board/mark_all_item.php
Normal file
19
Template/board/mark_all_item.php
Normal 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>
|
||||
69
Template/board/sort_control.php
Normal file
69
Template/board/sort_control.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
// Per-column persistent-sort control. Rendered by template:board:column:dropdown (so it sits in the
|
||||
// column header, as its own dropdown); relocate.js hides the native fa-sort menu in its favour.
|
||||
// Icon: native double-arrow when persistent is off, a red single arrow (up=asc / down=desc) when on.
|
||||
if ($column['nb_tasks'] <= 0 || ! $this->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)')),
|
||||
);
|
||||
?>
|
||||
<span class="dropdown organontweaks-sort">
|
||||
<a href="#" class="dropdown-menu" title="<?= t('Sort this column') ?>">
|
||||
<?php if ($sticky && $applied): ?>
|
||||
<?php /* Persistent ON: a single red arrow for the direction (8593 up / 8595 down),
|
||||
written as HTML entities so the source stays ASCII. OFF = the native
|
||||
double-arrow (fa-sort) below -- the natural, non-sticky state. */ ?>
|
||||
<span class="organontweaks-sort-arrow" style="color: #c0392b;"><?= $desc ? '↓' : '↑' ?></span>
|
||||
<?php else: ?>
|
||||
<i class="fa fa-sort fa-fw" aria-hidden="true"></i>
|
||||
<?php endif ?>
|
||||
</a>
|
||||
<ul>
|
||||
<li>
|
||||
<?= $this->url->link(
|
||||
'<i class="fa fa-thumb-tack fa-fw"></i> '.($sticky ? t('Persistent sort: ON') : t('Persistent sort: OFF')),
|
||||
'OrganonSortController',
|
||||
'toggle',
|
||||
array('plugin' => 'OrganonTweaks', 'project_id' => $pid, 'column_id' => $cid),
|
||||
true
|
||||
) ?>
|
||||
</li>
|
||||
<li class="no-hover"><strong><?= t('Sort by') ?></strong></li>
|
||||
<?php foreach ($criteria as $c): ?>
|
||||
<?php $active = ($curBy === $c[0] && $curDir === $c[1]); ?>
|
||||
<li>
|
||||
<?= $this->url->link(
|
||||
'<i class="fa '.($active ? 'fa-check' : '').' fa-fw"></i> '.$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
|
||||
) ?>
|
||||
</li>
|
||||
<?php endforeach ?>
|
||||
</ul>
|
||||
</span>
|
||||
3
Template/board/title_edit.php
Normal file
3
Template/board/title_edit.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<?php if ($this->user->hasProjectAccess('TaskModificationController', 'edit', $task['project_id'])): ?>
|
||||
<span class="organon-edit-url" style="display:none" data-url="<?= $this->url->href('TaskModificationController', 'edit', array('task_id' => $task['id'])) ?>"></span>
|
||||
<?php endif ?>
|
||||
@@ -6,10 +6,13 @@
|
||||
<?= $this->form->csrf() ?>
|
||||
|
||||
<fieldset>
|
||||
<legend><?= t('Board card readability') ?></legend>
|
||||
|
||||
<?= $this->form->checkbox('organon_tweaks_always_comment_icon', t('Always show the comment icon on board cards'), 1, isset($values['organon_tweaks_always_comment_icon']) && $values['organon_tweaks_always_comment_icon'] == 1) ?>
|
||||
<p class="form-help">
|
||||
<?= t('Show a comment bubble with a "+" on cards that have no comments yet, so you can add the first comment straight from the board. Cards that already have comments keep showing the count as usual.') ?>
|
||||
</p>
|
||||
<p class="form-help"><?= t('A "+" bubble on cards with no comments yet, to add the first one from the board.') ?></p>
|
||||
|
||||
<?= $this->form->checkbox('organon_tweaks_emphasize_duedate_default', t('Emphasize due date by default'), 1, isset($values['organon_tweaks_emphasize_duedate_default']) && $values['organon_tweaks_emphasize_duedate_default'] == 1) ?>
|
||||
<p class="form-help"><?= t('Enlarge and bold the due date; the default for boards set to "Use default" (overridable per board under Settings -> Integrations).') ?></p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
@@ -19,39 +22,57 @@
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<?= $this->form->checkbox('organon_tweaks_quick_click_only', t('Open a card only on a quick click'), 1, isset($values['organon_tweaks_quick_click_only']) && $values['organon_tweaks_quick_click_only'] == 1) ?>
|
||||
<p class="form-help">
|
||||
<?= t('A press on a card held longer than 100 ms is treated as a drag, so releasing it does not open the card. Fixes a drag sometimes opening the card on release. Normal clicks are well under 100 ms.') ?>
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<?= $this->form->checkbox('organon_tweaks_enhance_recurrence', t('Enhance the native recurrence dialog'), 1, isset($values['organon_tweaks_enhance_recurrence']) && $values['organon_tweaks_enhance_recurrence'] == 1) ?>
|
||||
<p class="form-help">
|
||||
<?= t('In the native "Edit recurrence" dialog, replace the Generate recurrent task dropdown with Yes/No radio buttons (one click), and add a [+7] button next to the factor that adds 7 (one week of days).') ?>
|
||||
<?= t('In the native "Edit recurrence" dialog, replace the Generate recurrent task dropdown with Yes/No radios, and add a [+7] button on the factor to add a week at once.') ?>
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<?= $this->form->checkbox('organon_tweaks_emphasize_duedate_default', t('Emphasize due date by default'), 1, isset($values['organon_tweaks_emphasize_duedate_default']) && $values['organon_tweaks_emphasize_duedate_default'] == 1) ?>
|
||||
<p class="form-help">
|
||||
<?= t('Default for boards set to "Use default": enlarge and bold the due date on the board. Each board can override this under its own Settings -> Integrations.') ?>
|
||||
</p>
|
||||
</fieldset>
|
||||
<legend><?= t('Board filters') ?></legend>
|
||||
|
||||
<fieldset>
|
||||
<?= $this->form->checkbox('organon_tweaks_show_all_filter', t('Add a "Show all tasks" filter to every board'), 1, isset($values['organon_tweaks_show_all_filter']) && $values['organon_tweaks_show_all_filter'] == 1) ?>
|
||||
<p class="form-help">
|
||||
<?= t('Keep a shared "Show all tasks" custom filter (an empty query, so open and closed tasks are shown) on every board, old and new. It appears in the board filter bookmark menu as a one-click way to clear the filter. Turning this off removes it from all boards; turning it on recreates it everywhere.') ?>
|
||||
</p>
|
||||
<p class="form-help"><?= t('One-click clear that shows open and closed tasks.') ?></p>
|
||||
|
||||
<?= $this->form->checkbox('organon_tweaks_month_filters', t('Add month filters to every board'), 1, isset($values['organon_tweaks_month_filters']) && $values['organon_tweaks_month_filters'] == 1) ?>
|
||||
<p class="form-help"><?= t('"Due this month" and "Around this month" (duemonth: / aroundmonth: keywords).') ?></p>
|
||||
|
||||
<?= $this->form->checkbox('organon_tweaks_recurring_filters', t('Add recurring filters to every board'), 1, isset($values['organon_tweaks_recurring_filters']) && $values['organon_tweaks_recurring_filters'] == 1) ?>
|
||||
<p class="form-help"><?= t('Adds recurrence bookmarks. Also a recurring:XY search keyword -- X = evt/cal/any (native/calendar/both), Y = ori/dup/all (template/duplicate/both). Example: recurring:evtori.') ?></p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<?= $this->form->checkbox('organon_tweaks_month_filters', t('Add month filters to every board'), 1, isset($values['organon_tweaks_month_filters']) && $values['organon_tweaks_month_filters'] == 1) ?>
|
||||
<p class="form-help">
|
||||
<?= t('Keep shared "Due this month" and "Around this month" custom filters on every board (using the duemonth: and aroundmonth: search keywords this plugin adds). They are append filters, so clicking them stacks onto the current filter. Turning this off removes them from all boards; turning it on recreates them everywhere.') ?>
|
||||
</p>
|
||||
<legend><?= t('Card interaction') ?></legend>
|
||||
|
||||
<?= $this->form->checkbox('organon_tweaks_quick_click_only', t('Open a card only on a quick click'), 1, isset($values['organon_tweaks_quick_click_only']) && $values['organon_tweaks_quick_click_only'] == 1) ?>
|
||||
<p class="form-help"><?= t('A press held longer than 120 ms is treated as a drag, so releasing it does not open the card. Fixes a drag sometimes opening the card on release.') ?></p>
|
||||
|
||||
<?= $this->form->checkbox('organon_tweaks_shortcut_labels', t('Show keyboard shortcuts on the task menu'), 1, isset($values['organon_tweaks_shortcut_labels']) && $values['organon_tweaks_shortcut_labels'] == 1) ?>
|
||||
<p class="form-help"><?= t('Adds the existing key hints (e, s, c, l) next to the task Actions items.') ?></p>
|
||||
|
||||
<?= $this->form->checkbox('organon_tweaks_title_click_edit', t('Clicking a task title opens the edit form'), 1, isset($values['organon_tweaks_title_click_edit']) && $values['organon_tweaks_title_click_edit'] == 1) ?>
|
||||
<p class="form-help"><?= t('On the board and the card view, click the title to open the edit modal (only where you may edit).') ?></p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend><?= t('Board columns') ?></legend>
|
||||
|
||||
<?= $this->form->checkbox('organon_tweaks_persistent_sort', t('Per-column persistent sort'), 1, isset($values['organon_tweaks_persistent_sort']) && $values['organon_tweaks_persistent_sort'] == 1) ?>
|
||||
<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">
|
||||
|
||||
29
Template/layout/done_style.php
Normal file
29
Template/layout/done_style.php
Normal 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>
|
||||
@@ -22,3 +22,4 @@
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-blue"><?= t('Save') ?></button>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
24
Template/task/done_badge.php
Normal file
24
Template/task/done_badge.php
Normal 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>
|
||||
Reference in New Issue
Block a user