My WorkspaceOrg main page, personal per-user project workspaces -- dashboard page, badges, CRUD + assign

This commit is contained in:
2026-07-12 21:54:03 -03:00
parent 52355428c4
commit 5728cf37b2
14 changed files with 638 additions and 67 deletions

View File

@@ -1 +0,0 @@
/* Skeleton plugin styles -- add CSS here. Loaded via template:layout:css. */

View File

@@ -0,0 +1,53 @@
/* WorkspaceOrg -- the [Workspace] badge shown before a project title. */
.wso-badge {
display: inline-block;
margin-right: 4px;
padding: 0 6px;
border-radius: 3px;
background: #34495e;
color: #fff;
font-size: 0.85em;
font-weight: bold;
vertical-align: middle;
}
/* "My workspaces" dashboard page. */
.wso-group {
margin-bottom: 25px;
}
.wso-group-title {
border-bottom: 1px solid #ddd;
padding-bottom: 4px;
margin-bottom: 8px;
}
.wso-group-actions {
margin-left: 12px;
font-size: 0.7em;
font-weight: normal;
}
.wso-group-actions a {
margin-right: 10px;
}
.wso-empty {
margin: 4px 0 0 4px;
color: #999;
font-style: italic;
}
.wso-table td {
vertical-align: middle;
}
.wso-move {
width: 220px;
text-align: right;
}
.wso-move-form {
display: inline;
margin: 0;
}

View File

@@ -1 +0,0 @@
// Skeleton plugin script -- add JS here. Loaded via template:layout:js.

View File

@@ -0,0 +1,120 @@
<?php
namespace Kanboard\Plugin\WorkspaceOrg\Controller;
use Kanboard\Controller\BaseController;
use Kanboard\Plugin\WorkspaceOrg\Helper\WorkspaceOrgHelper;
/**
* The "My workspaces" dashboard page and its workspace CRUD + project assignment. Every action works
* on the current user's own workspaces (user metadata), so no admin/manager gate is needed -- any
* logged-in user organizes their own projects. State changes go through getValues() (CSRF-checked)
* or checkCSRFParam() for the confirm link, mirroring core's TagController.
*/
class WorkspaceOrgController extends BaseController
{
private function workspaces()
{
return new WorkspaceOrgHelper($this->container);
}
private function backToIndex()
{
$this->response->redirect($this->helper->url->to('WorkspaceOrgController', 'index', array('plugin' => 'WorkspaceOrg')));
}
public function index()
{
$helper = $this->workspaces();
$this->response->html($this->helper->layout->dashboard('workspaceOrg:workspace/index', array(
'title' => t('My workspaces'),
'user' => $this->getUser(),
'groups' => $helper->grouped(),
'options' => $helper->options(),
'default' => WorkspaceOrgHelper::DEFAULT_WORKSPACE,
)));
}
public function create(array $values = array(), array $errors = array())
{
$this->response->html($this->template->render('workspaceOrg:workspace/create', array(
'values' => $values,
'errors' => $errors,
)));
}
public function save()
{
$values = $this->request->getValues();
$name = isset($values['name']) ? $values['name'] : '';
if ($name !== '' && $this->workspaces()->addWorkspace($name)) {
$this->flash->success(t('Workspace created successfully.'));
$this->backToIndex();
} else {
$this->create($values, array('name' => array(t('Invalid name, or a workspace with this name already exists.'))));
}
}
public function edit(array $values = array(), array $errors = array())
{
if (empty($values)) {
$name = $this->request->getStringParam('name');
$values = array('old' => $name, 'name' => $name);
}
$this->response->html($this->template->render('workspaceOrg:workspace/edit', array(
'values' => $values,
'errors' => $errors,
)));
}
public function update()
{
$values = $this->request->getValues();
$old = isset($values['old']) ? $values['old'] : '';
$name = isset($values['name']) ? $values['name'] : '';
if ($name !== '' && $this->workspaces()->renameWorkspace($old, $name)) {
$this->flash->success(t('Workspace renamed successfully.'));
$this->backToIndex();
} else {
$this->edit($values, array('name' => array(t('Invalid name, or a workspace with this name already exists.'))));
}
}
public function confirm()
{
$this->response->html($this->template->render('workspaceOrg:workspace/remove', array(
'name' => $this->request->getStringParam('name'),
)));
}
public function remove()
{
$this->checkCSRFParam();
if ($this->workspaces()->removeWorkspace($this->request->getStringParam('name'))) {
$this->flash->success(t('Workspace removed. Its projects moved to Default.'));
} else {
$this->flash->failure(t('Unable to remove this workspace.'));
}
$this->backToIndex();
}
public function assign()
{
$values = $this->request->getValues();
$project_id = isset($values['project_id']) ? (int) $values['project_id'] : 0;
$workspace = isset($values['workspace']) ? $values['workspace'] : WorkspaceOrgHelper::DEFAULT_WORKSPACE;
if ($project_id > 0) {
$this->workspaces()->assign($project_id, $workspace);
$this->flash->success(t('Project moved.'));
}
$this->backToIndex();
}
}

View File

@@ -0,0 +1,278 @@
<?php
namespace Kanboard\Plugin\WorkspaceOrg\Helper;
use Kanboard\Core\Base;
/**
* WorkspaceOrg helper: the per-user workspace list and project->workspace map, both stored as JSON
* in the current user's metadata (no global state, no schema migration).
*
* "Default" is not a stored workspace -- it is simply the bucket for any project that is not filed,
* so it can never be renamed or removed. The names list holds only the user's custom workspaces.
*/
class WorkspaceOrgHelper extends Base
{
const NAMES_KEY = 'workspaceorg_names'; // JSON array of the user's custom workspace names
const MAP_KEY = 'workspaceorg_map'; // JSON object { project_id: workspace_name }
const DEFAULT_WORKSPACE = 'Default';
const MAX_NAME_LENGTH = 50;
/**
* Current user id (workspaces are always personal to whoever is logged in).
*
* @return int
*/
private function uid()
{
return (int) $this->userSession->getId();
}
/**
* The user's custom workspace names, de-duplicated and sorted A-Z (Default not included).
*
* @return string[]
*/
public function names()
{
$raw = $this->userMetadataModel->get($this->uid(), self::NAMES_KEY, '');
$names = $raw === '' ? array() : json_decode($raw, true);
if (! is_array($names)) {
$names = array();
}
$names = array_values(array_unique(array_filter($names, 'is_string')));
natcasesort($names);
return array_values($names);
}
/**
* The project_id -> workspace_name map.
*
* @return array
*/
public function map()
{
$raw = $this->userMetadataModel->get($this->uid(), self::MAP_KEY, '');
$map = $raw === '' ? array() : json_decode($raw, true);
return is_array($map) ? $map : array();
}
/**
* The workspace a project is filed under, validated against the current names; falls back to
* Default when unassigned or pointing at a deleted workspace.
*
* @param int $project_id
* @return string
*/
public function workspaceOf($project_id)
{
$project_id = (int) $project_id;
$map = $this->map();
if (isset($map[$project_id]) && in_array($map[$project_id], $this->names(), true)) {
return $map[$project_id];
}
return self::DEFAULT_WORKSPACE;
}
/**
* Badge text before a project title: the workspace name, or '' for Default (badge hidden).
*
* @param int $project_id
* @return string
*/
public function badge($project_id)
{
$workspace = $this->workspaceOf($project_id);
return $workspace === self::DEFAULT_WORKSPACE ? '' : $workspace;
}
/**
* The options for a "move to" dropdown: custom workspaces A-Z, then Default last.
*
* @return string[]
*/
public function options()
{
$options = $this->names();
$options[] = self::DEFAULT_WORKSPACE;
return $options;
}
/**
* The current user's active projects grouped by workspace, ready for the page: an ordered map
* of workspace_name => list of array('id','name'), custom workspaces A-Z then Default last, and
* projects sorted A-Z within each.
*
* @return array
*/
public function grouped()
{
$groups = array();
foreach ($this->names() as $name) {
$groups[$name] = array();
}
$groups[self::DEFAULT_WORKSPACE] = array();
foreach ($this->projectUserRoleModel->getActiveProjectsByUser($this->uid()) as $project_id => $project_name) {
$groups[$this->workspaceOf($project_id)][] = array('id' => (int) $project_id, 'name' => $project_name);
}
foreach ($groups as &$list) {
usort($list, function ($a, $b) {
return strnatcasecmp($a['name'], $b['name']);
});
}
unset($list);
return $groups;
}
/**
* Create a new (empty) workspace. Rejects blanks, duplicates, and the reserved "Default".
*
* @param string $name
* @return bool
*/
public function addWorkspace($name)
{
$name = $this->clean($name);
if ($name === '' || $this->isDefault($name) || $this->exists($name)) {
return false;
}
$names = $this->names();
$names[] = $name;
$this->saveNames($names);
return true;
}
/**
* Rename a workspace and rewrite it across the assignment map.
*
* @param string $old
* @param string $new
* @return bool
*/
public function renameWorkspace($old, $new)
{
$new = $this->clean($new);
if ($new === '' || $this->isDefault($old) || $this->isDefault($new) || ! $this->exists($old)) {
return false;
}
if (strcasecmp($old, $new) !== 0 && $this->exists($new)) {
return false; // would collide with a different existing workspace
}
$names = array();
foreach ($this->names() as $name) {
$names[] = $name === $old ? $new : $name;
}
$this->saveNames($names);
$map = $this->map();
foreach ($map as $project_id => $workspace) {
if ($workspace === $old) {
$map[$project_id] = $new;
}
}
$this->saveMap($map);
return true;
}
/**
* Remove a workspace; its projects fall back to Default (dropped from the map).
*
* @param string $name
* @return bool
*/
public function removeWorkspace($name)
{
if ($this->isDefault($name) || ! $this->exists($name)) {
return false;
}
$names = array_values(array_filter($this->names(), function ($n) use ($name) {
return $n !== $name;
}));
$this->saveNames($names);
$map = $this->map();
foreach ($map as $project_id => $workspace) {
if ($workspace === $name) {
unset($map[$project_id]);
}
}
$this->saveMap($map);
return true;
}
/**
* File a project into a workspace. Default (or any unknown name) means "unassigned" -> removed
* from the map.
*
* @param int $project_id
* @param string $workspace
*/
public function assign($project_id, $workspace)
{
$project_id = (int) $project_id;
$map = $this->map();
if ($this->isDefault($workspace) || ! $this->exists($workspace)) {
unset($map[$project_id]);
} else {
$map[$project_id] = $workspace;
}
$this->saveMap($map);
}
private function clean($name)
{
return trim(mb_substr((string) $name, 0, self::MAX_NAME_LENGTH));
}
private function isDefault($name)
{
return strcasecmp((string) $name, self::DEFAULT_WORKSPACE) === 0;
}
private function exists($name)
{
foreach ($this->names() as $existing) {
if (strcasecmp($existing, $name) === 0) {
return true;
}
}
return false;
}
private function saveNames(array $names)
{
natcasesort($names);
$this->userMetadataModel->save($this->uid(), array(self::NAMES_KEY => json_encode(array_values($names))));
}
private function saveMap(array $map)
{
$this->userMetadataModel->save($this->uid(), array(self::MAP_KEY => json_encode($map, JSON_FORCE_OBJECT)));
}
}

View File

@@ -1,35 +1,49 @@
<?php
namespace Kanboard\Plugin\Skeleton;
namespace Kanboard\Plugin\WorkspaceOrg;
use Kanboard\Core\Plugin\Base;
/**
* WorkspaceOrg -- group your projects into personal workspaces.
*
* Each user files their own projects into their own named workspaces (a per-user folder). Everything
* is stored in user metadata -- nothing global, no database migration. A "My workspaces" page on the
* dashboard lists projects grouped by workspace and lets you add/rename/remove workspaces and move
* projects between them; a [Workspace] badge appears before the project title on the dashboard and
* the project list.
*/
class Plugin extends Base
{
public function initialize()
{
// 1. Render a visible word at the top of every page (the demo output).
$this->template->hook->attach('template:layout:top', 'skeleton:layout/header');
// "My workspaces" entry in the dashboard sidebar (visible to every logged-in user).
$this->template->hook->attach('template:dashboard:sidebar', 'workspaceOrg:dashboard/sidebar');
// [Workspace] badge before the project title -- this hook fires on the dashboard overview,
// the dashboard "My projects" tab, and the Projects management list (all via project_title).
$this->template->hook->attach('template:dashboard:project:before-title', 'workspaceOrg:project/badge');
// 2. Load the plugin stylesheet (currently empty -- proves the CSS hook fires).
$this->hook->on('template:layout:css', array(
'template' => 'plugins/Skeleton/Asset/css/skeleton.css',
'template' => 'plugins/WorkspaceOrg/Asset/css/workspaceorg.css',
));
}
// 3. Load the plugin script (currently empty -- proves the JS hook fires).
$this->hook->on('template:layout:js', array(
'template' => 'plugins/Skeleton/Asset/js/skeleton.js',
));
public function getHelpers()
{
return array(
'Plugin\WorkspaceOrg\Helper' => array('WorkspaceOrgHelper'),
);
}
public function getPluginName()
{
return 'Skeleton';
return 'WorkspaceOrg';
}
public function getPluginDescription()
{
return t('Reusable skeleton/template for building Kanboard plugins.');
return t('Group your projects into personal per-user workspaces, shown on a "My workspaces" dashboard page with a badge on each project.');
}
public function getPluginAuthor()
@@ -44,7 +58,7 @@ class Plugin extends Base
public function getPluginHomepage()
{
return 'https://code.beco.cc/beco/kanboard-plugin-skeleton';
return 'https://code.beco.cc/beco/WorkspaceOrg';
}
public function getCompatibleVersion()

112
README.md
View File

@@ -1,19 +1,43 @@
# Skeleton -- a Kanboard plugin template
# WorkspaceOrg -- group your projects into personal workspaces
A minimal, working Kanboard plugin that you copy and rename as the starting point for a
real plugin. By itself it does only one trivial thing: it renders the word "Skeleton" at
the top of every page. It changes no data and runs no database migration.
A small Kanboard plugin that lets each user file their projects into their own named
**workspaces** (think folders: "UNIBRA", "UPE", "Home"), and see them grouped on a
**My workspaces** dashboard page. Kanboard's project list is otherwise flat -- this gives you
structure without touching how projects themselves work.
## What it demonstrates
Everything is **per user**: your workspaces and how you file your projects are yours alone.
Two people can file the same shared project differently, and neither sees the other's grouping.
- A complete `Plugin.php` registration class with all the metadata Kanboard shows in
Settings -> Plugins (name, description, author, version, homepage, compatible version).
- A template hook (`template:layout:top`) that injects a template into the page.
- Asset hooks (`template:layout:css` and `template:layout:js`) that load a stylesheet and
a script. They are empty for now but prove the injection path works -- handy when a real
plugin needs custom CSS or JS.
- The standard plugin directory layout, with stub folders (`Controller/`, `Model/`,
`Schema/`, `Locale/`, `Test/`) ready to grow into.
## What it does
- Adds a **My workspaces** entry to the dashboard sidebar (next to Overview / My projects /
My tasks / My subtasks). Visible to every logged-in user -- no admin rights needed.
- That page lists your projects **grouped by workspace**, and lets you:
- **add / rename / remove** workspaces (each via a small modal, like Kanboard's Tags page);
- **move a project** to another workspace with an inline dropdown on its row.
- Shows a **`[Workspace]`** badge before the project title on the dashboard overview, the
dashboard "My projects" tab, and the Projects management list -- so you can see at a glance
where each project is filed.
## The "Default" workspace
Every project starts in **Default**, which is simply "not filed yet". Default is not a real
stored workspace -- it is the leftover bucket -- so it can never be renamed or removed, and it
always sorts last. Projects in Default show **no badge** (to keep unfiled projects clean).
Removing a workspace moves its projects back to Default; it never deletes anything.
## Ordering
On the My workspaces page, workspaces are listed alphabetically (A-Z) with Default last, and the
projects within each workspace are alphabetical too.
## How it stores data
No database migration. Everything lives in **user metadata**: your workspace names
(`workspaceorg_names`) and your project-to-workspace map (`workspaceorg_map`), both as JSON.
Nothing is global and nothing touches the projects themselves -- uninstalling the plugin leaves
that metadata harmlessly in place and changes nothing about your projects.
## Requirements
@@ -21,46 +45,30 @@ the top of every page. It changes no data and runs no database migration.
## Installation
No build step and no dependencies.
1. Copy this folder into your Kanboard installation as `plugins/Skeleton/`.
2. Reload any page. The word "Skeleton" appears at the top.
3. Confirm it under Settings -> Plugins.
To uninstall, delete `plugins/Skeleton/`. Nothing else is left behind.
## Directory layout
```
Skeleton/
Plugin.php Registration and hook wiring (the only required file).
README.md
LICENSE AGPL-3.0.
Template/
layout/header.php The visible "Skeleton" word.
Asset/
css/skeleton.css Loaded via template:layout:css.
js/skeleton.js Loaded via template:layout:js.
Controller/ Stub for future request handlers.
Model/ Stub for future business logic / DB access.
Schema/ Stub for future database migrations.
Locale/ Stub for future translations (e.g. pt_BR/, fr_FR/).
Test/ Stub for future unit tests.
```
## How to fork this into a new plugin
1. Copy the folder and rename it, e.g. `plugins/MyPlugin/`. The folder name must match the
namespace and start with a capital letter.
2. In `Plugin.php`, change the namespace from `Kanboard\Plugin\Skeleton` to
`Kanboard\Plugin\MyPlugin`.
3. Update the metadata methods (`getPluginName`, `getPluginDescription`, `getPluginAuthor`,
`getPluginVersion`, `getPluginHomepage`, `getCompatibleVersion`).
4. Update the hook target paths: the lowercase prefix in `'skeleton:layout/header'` and the
`plugins/Skeleton/Asset/...` asset paths must match the new plugin name.
5. Replace `Template/layout/header.php` with your real template, or attach to a different
hook. See the Kanboard plugin hooks documentation for the full list of hook points.
Copy this folder into your Kanboard installation as `plugins/WorkspaceOrg/`. The directory name
must be exactly `WorkspaceOrg` (Kanboard derives the plugin namespace from the folder name). No
build step and no database migration. Reload, then open **My workspaces** from the dashboard.
## License
AGPL-3.0. See LICENSE.
## More Kanboard plugins by Ruben (drbeco)
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).
- **[OrganonTweaks](https://code.beco.cc/beco/OrganonTweaks)** -- an umbrella of small
quality-of-life board tweaks: remove an empty column, always show the comment icon, emphasize due
dates, extra search keywords and shared board filters, and more.
- **[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.

View File

@@ -0,0 +1,3 @@
<li <?= $this->app->checkMenuSelection('WorkspaceOrgController', 'index') ?>>
<?= $this->url->link(t('My workspaces'), 'WorkspaceOrgController', 'index', array('plugin' => 'WorkspaceOrg')) ?>
</li>

View File

@@ -1 +0,0 @@
<div class="skeleton-plugin-marker">Skeleton</div>

View File

@@ -0,0 +1,14 @@
<?php
// [Workspace] badge before a project title (dashboard overview, My projects tab, Projects list).
// Hidden for projects in Default. Reflects the CURRENT user's personal workspace assignment.
if (empty($project['id'])) {
return;
}
$workspace = $this->WorkspaceOrgHelper->badge($project['id']);
if ($workspace === '') {
return;
}
?>
<span class="wso-badge" title="<?= $this->text->e(t('Workspace')) ?>">[<?= $this->text->e($workspace) ?>]</span>

View File

@@ -0,0 +1,12 @@
<div class="page-header">
<h2><?= t('Add workspace') ?></h2>
</div>
<form method="post" action="<?= $this->url->href('WorkspaceOrgController', 'save', array('plugin' => 'WorkspaceOrg')) ?>" autocomplete="off">
<?= $this->form->csrf() ?>
<?= $this->form->label(t('Name'), 'name') ?>
<?= $this->form->text('name', $values, $errors, array('autofocus', 'required', 'maxlength="50"')) ?>
<?= $this->modal->submitButtons() ?>
</form>

View File

@@ -0,0 +1,13 @@
<div class="page-header">
<h2><?= t('Rename workspace') ?></h2>
</div>
<form method="post" action="<?= $this->url->href('WorkspaceOrgController', 'update', array('plugin' => 'WorkspaceOrg')) ?>" autocomplete="off">
<?= $this->form->csrf() ?>
<?= $this->form->hidden('old', $values) ?>
<?= $this->form->label(t('Name'), 'name') ?>
<?= $this->form->text('name', $values, $errors, array('autofocus', 'required', 'maxlength="50"')) ?>
<?= $this->modal->submitButtons() ?>
</form>

View File

@@ -0,0 +1,48 @@
<div class="page-header">
<h2><?= t('My workspaces') ?></h2>
<ul>
<li>
<?= $this->modal->medium('plus', t('Add workspace'), 'WorkspaceOrgController', 'create', array('plugin' => 'WorkspaceOrg')) ?>
</li>
</ul>
</div>
<?php foreach ($groups as $workspace => $projects): ?>
<div class="wso-group">
<h3 class="wso-group-title">
<?= $this->text->e($workspace) ?>
<?php if ($workspace !== $default): ?>
<span class="wso-group-actions">
<?= $this->modal->medium('edit', t('Rename'), 'WorkspaceOrgController', 'edit', array('plugin' => 'WorkspaceOrg', 'name' => $workspace)) ?>
<?= $this->modal->confirm('trash-o', t('Remove'), 'WorkspaceOrgController', 'confirm', array('plugin' => 'WorkspaceOrg', 'name' => $workspace)) ?>
</span>
<?php endif ?>
</h3>
<?php if (empty($projects)): ?>
<p class="wso-empty"><?= t('(no projects yet)') ?></p>
<?php else: ?>
<table class="table-striped wso-table">
<?php foreach ($projects as $project): ?>
<tr>
<td class="wso-project">
<?= $this->url->link($this->text->e($project['name']), 'BoardViewController', 'show', array('project_id' => $project['id'])) ?>
</td>
<td class="wso-move">
<form method="post" class="wso-move-form" action="<?= $this->url->href('WorkspaceOrgController', 'assign', array('plugin' => 'WorkspaceOrg')) ?>">
<?= $this->form->csrf() ?>
<input type="hidden" name="project_id" value="<?= (int) $project['id'] ?>">
<select name="workspace" onchange="this.form.submit()">
<?php foreach ($options as $option): ?>
<option value="<?= $this->text->e($option) ?>"<?= $option === $workspace ? ' selected="selected"' : '' ?>><?= $this->text->e($option) ?></option>
<?php endforeach ?>
</select>
<noscript><button type="submit" class="btn btn-blue"><?= t('Move') ?></button></noscript>
</form>
</td>
</tr>
<?php endforeach ?>
</table>
<?php endif ?>
</div>
<?php endforeach ?>

View File

@@ -0,0 +1,11 @@
<div class="page-header">
<h2><?= t('Remove workspace') ?></h2>
</div>
<div class="confirm">
<p class="alert alert-info">
<?= t('Do you really want to remove the workspace "%s"? Its projects will move to Default.', $name) ?>
</p>
<?= $this->modal->confirmButtons('WorkspaceOrgController', 'remove', array('plugin' => 'WorkspaceOrg', 'name' => $name)) ?>
</div>