diff --git a/Asset/css/skeleton.css b/Asset/css/skeleton.css deleted file mode 100644 index b112526..0000000 --- a/Asset/css/skeleton.css +++ /dev/null @@ -1 +0,0 @@ -/* Skeleton plugin styles -- add CSS here. Loaded via template:layout:css. */ diff --git a/Asset/css/workspaceorg.css b/Asset/css/workspaceorg.css new file mode 100644 index 0000000..25b3c88 --- /dev/null +++ b/Asset/css/workspaceorg.css @@ -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; +} diff --git a/Asset/js/skeleton.js b/Asset/js/skeleton.js deleted file mode 100644 index 7a9d9f2..0000000 --- a/Asset/js/skeleton.js +++ /dev/null @@ -1 +0,0 @@ -// Skeleton plugin script -- add JS here. Loaded via template:layout:js. diff --git a/Controller/WorkspaceOrgController.php b/Controller/WorkspaceOrgController.php new file mode 100644 index 0000000..af4b0e0 --- /dev/null +++ b/Controller/WorkspaceOrgController.php @@ -0,0 +1,120 @@ +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(); + } +} diff --git a/Helper/WorkspaceOrgHelper.php b/Helper/WorkspaceOrgHelper.php new file mode 100644 index 0000000..2626c46 --- /dev/null +++ b/Helper/WorkspaceOrgHelper.php @@ -0,0 +1,278 @@ +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))); + } +} diff --git a/Plugin.php b/Plugin.php index 78240ed..1365ed9 100644 --- a/Plugin.php +++ b/Plugin.php @@ -1,35 +1,49 @@ 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() diff --git a/README.md b/README.md index a9d069b..4e03fc6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/Template/dashboard/sidebar.php b/Template/dashboard/sidebar.php new file mode 100644 index 0000000..f9c2c34 --- /dev/null +++ b/Template/dashboard/sidebar.php @@ -0,0 +1,3 @@ +
  • app->checkMenuSelection('WorkspaceOrgController', 'index') ?>> + url->link(t('My workspaces'), 'WorkspaceOrgController', 'index', array('plugin' => 'WorkspaceOrg')) ?> +
  • diff --git a/Template/layout/header.php b/Template/layout/header.php deleted file mode 100644 index 4c07070..0000000 --- a/Template/layout/header.php +++ /dev/null @@ -1 +0,0 @@ -
    Skeleton
    diff --git a/Template/project/badge.php b/Template/project/badge.php new file mode 100644 index 0000000..836150f --- /dev/null +++ b/Template/project/badge.php @@ -0,0 +1,14 @@ +WorkspaceOrgHelper->badge($project['id']); + +if ($workspace === '') { + return; +} +?> +[text->e($workspace) ?>] diff --git a/Template/workspace/create.php b/Template/workspace/create.php new file mode 100644 index 0000000..e48e38b --- /dev/null +++ b/Template/workspace/create.php @@ -0,0 +1,12 @@ + + +
    + form->csrf() ?> + + form->label(t('Name'), 'name') ?> + form->text('name', $values, $errors, array('autofocus', 'required', 'maxlength="50"')) ?> + + modal->submitButtons() ?> +
    diff --git a/Template/workspace/edit.php b/Template/workspace/edit.php new file mode 100644 index 0000000..4dfea05 --- /dev/null +++ b/Template/workspace/edit.php @@ -0,0 +1,13 @@ + + +
    + form->csrf() ?> + form->hidden('old', $values) ?> + + form->label(t('Name'), 'name') ?> + form->text('name', $values, $errors, array('autofocus', 'required', 'maxlength="50"')) ?> + + modal->submitButtons() ?> +
    diff --git a/Template/workspace/index.php b/Template/workspace/index.php new file mode 100644 index 0000000..f7e63ab --- /dev/null +++ b/Template/workspace/index.php @@ -0,0 +1,48 @@ + + + $projects): ?> +
    +

    + text->e($workspace) ?> + + + modal->medium('edit', t('Rename'), 'WorkspaceOrgController', 'edit', array('plugin' => 'WorkspaceOrg', 'name' => $workspace)) ?> + modal->confirm('trash-o', t('Remove'), 'WorkspaceOrgController', 'confirm', array('plugin' => 'WorkspaceOrg', 'name' => $workspace)) ?> + + +

    + + +

    + + + + + + + + +
    + url->link($this->text->e($project['name']), 'BoardViewController', 'show', array('project_id' => $project['id'])) ?> + +
    + form->csrf() ?> + + + +
    +
    + +
    + diff --git a/Template/workspace/remove.php b/Template/workspace/remove.php new file mode 100644 index 0000000..2915f09 --- /dev/null +++ b/Template/workspace/remove.php @@ -0,0 +1,11 @@ + + +
    +

    + +

    + + modal->confirmButtons('WorkspaceOrgController', 'remove', array('plugin' => 'WorkspaceOrg', 'name' => $name)) ?> +