12 Commits
v1.8 ... v2.1.2

21 changed files with 859 additions and 127 deletions

View File

@@ -1,18 +1,41 @@
/* /*
* OrganonTweaks -- keep the board's horizontal scroll position across refreshes. * OrganonTweaks -- keep the board's horizontal scroll position across refreshes AND full reloads.
* *
* When you drop a card (and on Kanboard's periodic AJAX polling) the board is rebuilt with * Two cases lose the horizontal scroll and snap the board back to the first column:
* `$("#board-container").replaceWith(data)` (BoardDragAndDrop.refresh). The brand-new * 1. AJAX rebuild -- dropping a card / periodic polling replaces #board-container
* element starts at scrollLeft 0, so the board snaps back to the first column -- a jarring * (BoardDragAndDrop.refresh), and the new element starts at scrollLeft 0.
* jump. This remembers the last scroll position and restores it the moment a replacement * 2. Full page reload -- e.g. clicking the Done/Todo badge, which navigates and redirects back to
* container appears, so the view stays put. * the board; a fresh page starts at scrollLeft 0.
* *
* We observe the STABLE parent (the container itself is replaced) and read the scroll from * We remember the last position and restore it: in-memory for the AJAX rebuild (observing the STABLE
* whichever #board-container is current, so it keeps working after every rebuild. * parent, since the container itself is replaced), and in sessionStorage (keyed per board) so it
* survives a full reload as well.
*/ */
(function () { (function () {
"use strict"; "use strict";
function boardKey() {
var m = location.href.match(/board\/(\d+)/) || location.href.match(/project_id=(\d+)/);
return "organon-board-scroll-" + (m ? m[1] : location.pathname);
}
function readStored() {
try {
var v = window.sessionStorage.getItem(boardKey());
return v ? parseInt(v, 10) : 0;
} catch (e) {
return 0;
}
}
function writeStored(value) {
try {
window.sessionStorage.setItem(boardKey(), value);
} catch (e) {
// sessionStorage unavailable (private mode / disabled) -- degrade to AJAX-only restore.
}
}
function init() { function init() {
var container = document.getElementById("board-container"); var container = document.getElementById("board-container");
if (!container) { if (!container) {
@@ -20,20 +43,27 @@
} }
var parent = container.parentNode; var parent = container.parentNode;
var lastScrollLeft = container.scrollLeft; var stored = readStored();
var lastScrollLeft = stored || container.scrollLeft;
var lastContainer = container; var lastContainer = container;
// Remember the position whenever the board is scrolled (capture: scroll does not // Restore across a full page reload (badge click, etc.), not only AJAX rebuilds.
// bubble, and this survives the container being replaced). if (stored && container.scrollLeft !== stored) {
container.scrollLeft = stored;
}
// Remember the position whenever the board is scrolled (capture: scroll does not bubble, and
// this survives the container being replaced).
document.addEventListener("scroll", function (e) { document.addEventListener("scroll", function (e) {
var c = document.getElementById("board-container"); var c = document.getElementById("board-container");
if (c && e.target === c) { if (c && e.target === c) {
lastScrollLeft = c.scrollLeft; lastScrollLeft = c.scrollLeft;
writeStored(lastScrollLeft);
} }
}, true); }, true);
// When the board is rebuilt, #board-container becomes a new element at scrollLeft 0; // When the board is rebuilt, #board-container becomes a new element at scrollLeft 0; restore
// restore the remembered position before the browser paints it. // the remembered position before the browser paints it.
if (window.MutationObserver) { if (window.MutationObserver) {
new MutationObserver(function () { new MutationObserver(function () {
var c = document.getElementById("board-container"); var c = document.getElementById("board-container");

View File

@@ -18,7 +18,7 @@
"use strict"; "use strict";
function relocateColumnMenuItems() { 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++) { for (var i = 0; i < items.length; i++) {
var li = items[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() { function apply() {
relocateColumnMenuItems(); relocateColumnMenuItems();
repositionCommentIcons(); repositionCommentIcons();
hideNativeSort();
} }
function init() { function init() {

View File

@@ -1,39 +0,0 @@
/*
* OrganonTweaks -- the "Save & Close" button on the task edit form. It is a plain type=button (so it
* never becomes the Enter default -- Enter still triggers the core Save), and on click it repoints
* the form to the plugin's TaskSaveCloseController and lets Kanboard's own modal submit it. Kanboard
* keeps ownership of the POST, CSRF, error re-render, and the X-Ajax-Redirect navigation; we only
* change the destination, so the controller redirects to the board instead of the card view.
*/
(function () {
"use strict";
document.addEventListener("click", function (e) {
if (!e.target || !e.target.closest) {
return;
}
var btn = e.target.closest("[data-organon-save-close]");
if (!btn) {
return;
}
e.preventDefault();
var form = btn.closest("form");
var url = btn.getAttribute("data-url");
if (!form || !url) {
return;
}
form.setAttribute("action", url);
if (window.KB && KB.modal && KB.modal.isOpen && KB.modal.isOpen() && KB.modal.submitForm) {
KB.modal.submitForm();
} else if (form.requestSubmit) {
form.requestSubmit();
} else {
form.submit();
}
}, false);
})();

View File

@@ -24,7 +24,9 @@ class ConfigController extends BaseController
'organon_tweaks_recurring_filters' => (int) $this->configModel->get('organon_tweaks_recurring_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_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_title_click_edit' => (int) $this->configModel->get('organon_tweaks_title_click_edit', 1),
'organon_tweaks_save_close_button' => (int) $this->configModel->get('organon_tweaks_save_close_button', 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),
), ),
'errors' => array(), 'errors' => array(),
))); )));
@@ -44,7 +46,9 @@ class ConfigController extends BaseController
$recurringFilters = isset($values['organon_tweaks_recurring_filters']) ? 1 : 0; $recurringFilters = isset($values['organon_tweaks_recurring_filters']) ? 1 : 0;
$shortcutLabels = isset($values['organon_tweaks_shortcut_labels']) ? 1 : 0; $shortcutLabels = isset($values['organon_tweaks_shortcut_labels']) ? 1 : 0;
$titleClickEdit = isset($values['organon_tweaks_title_click_edit']) ? 1 : 0; $titleClickEdit = isset($values['organon_tweaks_title_click_edit']) ? 1 : 0;
$saveCloseButton = isset($values['organon_tweaks_save_close_button']) ? 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;
if ($this->configModel->save(array( if ($this->configModel->save(array(
'organon_tweaks_always_comment_icon' => $alwaysCommentIcon, 'organon_tweaks_always_comment_icon' => $alwaysCommentIcon,
@@ -57,7 +61,9 @@ class ConfigController extends BaseController
'organon_tweaks_recurring_filters' => $recurringFilters, 'organon_tweaks_recurring_filters' => $recurringFilters,
'organon_tweaks_shortcut_labels' => $shortcutLabels, 'organon_tweaks_shortcut_labels' => $shortcutLabels,
'organon_tweaks_title_click_edit' => $titleClickEdit, 'organon_tweaks_title_click_edit' => $titleClickEdit,
'organon_tweaks_save_close_button' => $saveCloseButton, 'organon_tweaks_persistent_sort' => $persistentSort,
'organon_tweaks_done_badge' => $doneBadge,
'organon_tweaks_done_closes_task' => $doneClosesTask,
))) { ))) {
$this->flash->success(t('Settings saved successfully.')); $this->flash->success(t('Settings saved successfully.'));
} else { } 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,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);
}
}

View File

@@ -1,33 +0,0 @@
<?php
namespace Kanboard\Plugin\OrganonTweaks\Controller;
use Kanboard\Controller\TaskModificationController;
/**
* "Save & Close": the exact core task update, but on success it redirects to the BOARD instead of
* reopening the card view. The plugin's "Save & Close" button repoints the edit form here (via JS),
* so everything else -- validation, permission checks, CSRF, and the modal error re-render -- is
* inherited unchanged from core TaskModificationController. This mirrors core update() line for line
* except the one redirect target (kept in sync on Kanboard upgrades).
*/
class TaskSaveCloseController extends TaskModificationController
{
public function update()
{
$task = $this->getTask();
$values = $this->request->getValues();
$values['id'] = $task['id'];
$values['project_id'] = $task['project_id'];
list($valid, $errors) = $this->taskValidator->validateModification($values);
if ($valid && $this->updateTask($task, $values, $errors)) {
$this->flash->success(t('Task updated successfully.'));
$this->response->redirect($this->helper->url->to('BoardViewController', 'show', array('project_id' => $task['project_id'])), true);
} else {
$this->flash->failure(t('Unable to update your task.'));
$this->edit($values, $errors);
}
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Kanboard\Plugin\OrganonTweaks\Helper;
use Kanboard\Core\Base;
/**
* 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';
/**
* 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;
}
}

View 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;
}
}

View File

@@ -3,6 +3,7 @@
namespace Kanboard\Plugin\OrganonTweaks; namespace Kanboard\Plugin\OrganonTweaks;
use Kanboard\Core\Plugin\Base; use Kanboard\Core\Plugin\Base;
use Kanboard\Model\TaskModel;
class Plugin extends Base class Plugin extends Base
{ {
@@ -77,15 +78,50 @@ class Plugin extends Base
$this->template->hook->attach('template:board:private:task:before-title', 'organonTweaks:board/title_edit'); $this->template->hook->attach('template:board:private:task:before-title', 'organonTweaks:board/title_edit');
} }
// Tweak: a "Save & Close" button on the task edit form that saves and returns to the board // Tweak: per-column persistent sort. A sort control (number-aware title sort + a
// (core only reopens the card view). The button posts to a small plugin controller that // "Persistent sort: ON/OFF" toggle) replaces the native column sort. Sorting always reorders
// reuses core's update and changes only the redirect; the JS repoints the form action, so // physically (writes positions, like native); when persistent is ON, the column is re-sorted
// Kanboard's modal still owns the submit, CSRF, and error re-render. // whenever a card is dropped in or created, so it stays sorted. Persistent OFF = native
if ((int) $this->configModel->get('organon_tweaks_save_close_button', 1) === 1) { // one-shot. Control injected via the column dropdown hook + relocate.js (which also hides the
$this->template->hook->attach('template:task:form:bottom-before-buttons', 'organonTweaks:task_form/save_close_button'); // native fa-sort menu).
$this->hook->on('template:layout:js', array( if ((int) $this->configModel->get('organon_tweaks_persistent_sort', 1) === 1) {
'template' => 'plugins/OrganonTweaks/Asset/js/save-close.js', $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');
} }
// Auto-managed shared custom filters (v1.5 "Show all tasks", v1.6 month filters, v1.7 // Auto-managed shared custom filters (v1.5 "Show all tasks", v1.6 month filters, v1.7
@@ -118,7 +154,7 @@ class Plugin extends Base
public function getHelpers() public function getHelpers()
{ {
return array( return array(
'Plugin\OrganonTweaks\Helper' => array('OrganonColumnHelper', 'OrganonProjectHelper'), 'Plugin\OrganonTweaks\Helper' => array('OrganonColumnHelper', 'OrganonProjectHelper', 'OrganonSortHelper', 'OrganonDoneHelper'),
); );
} }
@@ -139,7 +175,7 @@ class Plugin extends Base
public function getPluginVersion() public function getPluginVersion()
{ {
return '1.8.0'; return '2.1.2';
} }
public function getPluginHomepage() public function getPluginHomepage()

123
README.md
View File

@@ -39,12 +39,15 @@ have comments keep showing the count as before.
When you drop a card (or Kanboard auto-refreshes the board via polling), it rebuilds the 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 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 the far left -- so the view jumps back to the first column. A **full page reload** (for
horizontal scroll position and restores it as soon as the rebuilt board appears, so the example clicking the Done/Todo badge, which navigates and redirects back to the board) does
board stays where you were. the same. This tweak remembers the horizontal scroll position 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 - 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. itself is replaced) and restores `scrollLeft` on whichever `#board-container` is current
after an AJAX rebuild, and persists the position per board in `sessionStorage` so it also
survives a full page reload.
- **On by default.** Toggle it under "Settings -> Organon Tweaks". - **On by default.** Toggle it under "Settings -> Organon Tweaks".
### Open a card only on a quick click ### Open a card only on a quick click
@@ -52,12 +55,37 @@ board stays where you were.
Dropping a card sometimes leaks a trailing "click" that opens the card -- an unwanted side 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 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 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 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". - **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 ### Enhance the recurrence dialog
Kanboard's native "Edit recurrence" dialog is a plain form. This makes two small usability Kanboard's native "Edit recurrence" dialog is a plain form. This makes two small usability
@@ -71,13 +99,86 @@ edits when it opens (there is no template hook, so it is done in JS,
- **On by default.** Toggle it under "Settings -> Organon Tweaks". - **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.
- **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".
## Settings ## 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. - **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. - **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. - **Enhance the recurrence dialog** -- default on.
## Requirements ## Requirements
@@ -94,7 +195,7 @@ name). No build step and no database migration.
AGPL-3.0. See LICENSE. AGPL-3.0. See LICENSE.
## More Kanboard plugins by Ruben (drbeco) ## More Kanboard plugins by Dr. Beco
All free and AGPL-3.0, at [code.beco.cc](https://code.beco.cc/beco): All free and AGPL-3.0, at [code.beco.cc](https://code.beco.cc/beco):
@@ -102,6 +203,10 @@ All free and AGPL-3.0, at [code.beco.cc](https://code.beco.cc/beco):
monthly, weekly, daily), driven by the card due date. monthly, weekly, daily), driven by the card due date.
- **[FinanceBuddy](https://code.beco.cc/beco/FinanceBuddy)** -- attach a money value (debit/credit - **[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. 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 - **[BulkMoveTasks](https://code.beco.cc/beco/BulkMoveTasks)** -- move every task from one board
column to another in a single action. column to another in a single action.
- **[ShrinkVertically](https://code.beco.cc/beco/ShrinkVertically)** -- shrink vertically-collapsed - **[ShrinkVertically](https://code.beco.cc/beco/ShrinkVertically)** -- shrink vertically-collapsed

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

@@ -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 ? '&#8595;' : '&#8593;' ?></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>

View File

@@ -22,13 +22,6 @@
</p> </p>
</fieldset> </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 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>
</fieldset>
<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) ?> <?= $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"> <p class="form-help">
@@ -52,14 +45,31 @@
<fieldset> <fieldset>
<legend><?= t('Card interaction') ?></legend> <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) ?> <?= $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> <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) ?> <?= $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> <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>
<?= $this->form->checkbox('organon_tweaks_save_close_button', t('Add a "Save & Close" button to the task edit form'), 1, isset($values['organon_tweaks_save_close_button']) && $values['organon_tweaks_save_close_button'] == 1) ?> <fieldset>
<p class="form-help"><?= t('Saves the task and returns to the board, instead of reopening the card view.') ?></p> <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>
</fieldset> </fieldset>
<div class="form-actions"> <div class="form-actions">

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

@@ -22,3 +22,4 @@
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-blue"><?= t('Save') ?></button> <button type="submit" class="btn btn-blue"><?= t('Save') ?></button>
</div> </div>
<hr>

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,8 +0,0 @@
<?php $task_id = isset($values['id']) ? (int) $values['id'] : 0 ?>
<?php if ($task_id > 0): ?>
<button type="button" class="btn"
data-organon-save-close="1"
data-url="<?= $this->url->href('TaskSaveCloseController', 'update', array('plugin' => 'OrganonTweaks', 'task_id' => $task_id)) ?>">
<?= t('Save & Close') ?>
</button>
<?php endif ?>

View File

@@ -1 +1 @@
OrganonTweaks v1.8 OrganonTweaks v2.1.2