3 Commits

14 changed files with 411 additions and 21 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
* `$("#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 cases lose the horizontal scroll and snap the board back to the first column:
* 1. AJAX rebuild -- dropping a card / periodic polling replaces #board-container
* (BoardDragAndDrop.refresh), and the new element starts at scrollLeft 0.
* 2. Full page reload -- e.g. clicking the Done/Todo badge, which navigates and redirects back to
* the board; a fresh page starts at scrollLeft 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.
* We remember the last position and restore it: in-memory for the AJAX rebuild (observing the STABLE
* parent, since the container itself is replaced), and in sessionStorage (keyed per board) so it
* survives a full reload as well.
*/
(function () {
"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() {
var container = document.getElementById("board-container");
if (!container) {
@@ -20,20 +43,27 @@
}
var parent = container.parentNode;
var lastScrollLeft = container.scrollLeft;
var stored = readStored();
var lastScrollLeft = stored || 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).
// Restore across a full page reload (badge click, etc.), not only AJAX rebuilds.
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) {
var c = document.getElementById("board-container");
if (c && e.target === c) {
lastScrollLeft = c.scrollLeft;
writeStored(lastScrollLeft);
}
}, true);
// When the board is rebuilt, #board-container becomes a new element at scrollLeft 0;
// restore the remembered position before the browser paints it.
// When the board is rebuilt, #board-container becomes a new element at scrollLeft 0; restore
// the remembered position before the browser paints it.
if (window.MutationObserver) {
new MutationObserver(function () {
var c = document.getElementById("board-container");

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

@@ -111,6 +111,19 @@ class Plugin extends Base
});
}
// Tweak: a Done/Todo status badge on the card face (top-right, below the header) and in the
// task view (4th column, near the due date). Two-state toggle; single source of truth per
// mode -- close-mode ON => Done == closed (reads is_active, so native Close/Open stay in
// sync); close-mode OFF => a task-metadata marker (organon_done). Server-rendered, no JS.
if ((int) $this->configModel->get('organon_tweaks_done_badge', 0) === 1) {
$this->template->hook->attach('template:board:private:task:before-title', 'organonTweaks:board/done_badge');
$this->template->hook->attach('template:task:details:fourth-column', 'organonTweaks:task/done_badge');
$this->template->hook->attach('template:layout:head', 'organonTweaks:layout/done_style');
// Bulk "Mark all as Done/Todo" entry in the column header dropdown (moved into the native
// menu by relocate.js). Opens a two-button modal that confirms + picks the direction.
$this->template->hook->attach('template:board:column:dropdown', 'organonTweaks:board/mark_all_item');
}
// Auto-managed shared custom filters (v1.5 "Show all tasks", v1.6 month filters, v1.7
// recurring filters). Ensured lazily whenever a project header renders (covers old + new
// boards); each group is gated by its own config toggle inside
@@ -141,7 +154,7 @@ class Plugin extends Base
public function getHelpers()
{
return array(
'Plugin\OrganonTweaks\Helper' => array('OrganonColumnHelper', 'OrganonProjectHelper', 'OrganonSortHelper'),
'Plugin\OrganonTweaks\Helper' => array('OrganonColumnHelper', 'OrganonProjectHelper', 'OrganonSortHelper', 'OrganonDoneHelper'),
);
}
@@ -162,7 +175,7 @@ class Plugin extends Base
public function getPluginVersion()
{
return '1.9.1';
return '2.1.2';
}
public function getPluginHomepage()

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
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 Done/Todo badge, which navigates and redirects back to the board) does
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
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".
### Open a card only on a quick click
@@ -117,6 +120,31 @@ single arrow** (up for ascending, down for descending) when it is ON.
(which also hides the native sort menu). Per-column state lives in project metadata.
- **On by default.** Toggle it under "Settings -> Organon Tweaks".
### Done/Todo badge
A two-state toggle badge for marking a card done, shown on the board card face (above the title) and
on the task view (4th column, near the due date). It reads **Todo** (black on light red) while the
task is pending; one click flips it to **Done** (black on light green). The card itself is not
recolored -- only the badge.
- Rendered server-side (no JavaScript) via `template:board:private:task:before-title`
(`Template/board/done_badge.php`), `template:task:details:fourth-column`
(`Template/task/done_badge.php`), and a small head `<style>` (`Template/layout/done_style.php`).
Shown only to users who may edit the task; on the board it appears on expanded (non-collapsed) cards.
- **Single source of truth, no drift with native Close/Open.** With *"Marking Done also closes the
task"* **on**, "Done" *means* the task is closed: the badge reads the task's own open/closed status,
so closing or reopening a task by any route (the badge, the native sidebar, bulk actions, the API)
always keeps the badge correct. A closed "Done" card is hidden from the board by Kanboard's default
open-only filter, but reappears if you clear the filter (or use the "Board: show all tasks" filter).
- With that option **off**, the badge is an independent marker stored in task metadata
(`organon_done`) that never touches the open/closed status.
- **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
Global (per Kanboard instance) and admin-only, under "Settings -> Organon Tweaks", grouped as on
@@ -143,6 +171,11 @@ the page:
- **Per-column persistent sort** -- default on.
**Done badge**
- **Show a Done/Todo badge on cards** -- default off.
- **Marking Done also closes the task** -- default off.
Standalone:
- **Keep the board scroll position across refreshes** -- default on.

View File

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

View File

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

View File

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

View File

@@ -62,6 +62,16 @@
<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>
<div class="form-actions">
<button type="submit" class="btn btn-blue"><?= t('Save') ?></button>
</div>

View File

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

View File

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

View File

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