diff --git a/appinfo/routes.php b/appinfo/routes.php index b7487b7..c911021 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -10,6 +10,12 @@ ['name' => 'settings#create', 'url' => '/api/settings', 'verb' => 'POST'], ['name' => 'settings#load', 'url' => '/api/settings/load', 'verb' => 'POST'], + // Column CRUD endpoints. + ['name' => 'column#index', 'url' => '/api/columns', 'verb' => 'GET'], + ['name' => 'column#create', 'url' => '/api/columns', 'verb' => 'POST'], + ['name' => 'column#update', 'url' => '/api/columns/{id}', 'verb' => 'PATCH'], + ['name' => 'column#destroy', 'url' => '/api/columns/{id}', 'verb' => 'DELETE'], + // Prometheus metrics endpoint. ['name' => 'metrics#index', 'url' => '/api/metrics', 'verb' => 'GET'], // Health check endpoint. diff --git a/lib/Controller/ColumnController.php b/lib/Controller/ColumnController.php new file mode 100644 index 0000000..7d59e37 --- /dev/null +++ b/lib/Controller/ColumnController.php @@ -0,0 +1,289 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Planix\Controller; + +use OCA\Planix\AppInfo\Application; +use OCA\Planix\Service\ColumnService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; + +/** + * Controller for managing kanban board columns. + */ +class ColumnController extends Controller +{ + /** + * Constructor for the ColumnController. + * + * @param IRequest $request The request object + * @param ColumnService $columnService The column service + * + * @return void + */ + public function __construct( + IRequest $request, + private ColumnService $columnService, + ) { + parent::__construct(appName: Application::APP_ID, request: $request); + }//end __construct() + + /** + * List columns for a project, ordered by position. + * + * @NoAdminRequired + * + * @return JSONResponse + */ + public function index(): JSONResponse + { + $projectId = $this->request->getParam('projectId'); + + if (empty($projectId) === true) { + return new JSONResponse( + ['error' => 'The projectId query parameter is required.'], + Http::STATUS_BAD_REQUEST + ); + } + + $project = $this->columnService->findProject($projectId); + if ($project === null) { + return new JSONResponse( + ['error' => 'Project not found.'], + Http::STATUS_NOT_FOUND + ); + } + + if ($this->columnService->isProjectMember($projectId, $project) === false) { + return new JSONResponse( + ['error' => 'You are not a member of this project.'], + Http::STATUS_FORBIDDEN + ); + } + + $columns = $this->columnService->listColumns($projectId); + + return new JSONResponse($columns); + + }//end index() + + /** + * Create a new column. + * + * @NoAdminRequired + * + * @return JSONResponse + */ + public function create(): JSONResponse + { + $data = $this->request->getParams(); + $projectId = $data['project'] ?? ($data['projectId'] ?? null); + + if (empty($projectId) === true) { + return new JSONResponse( + ['error' => 'The project field is required.'], + Http::STATUS_BAD_REQUEST + ); + } + + $project = $this->columnService->findProject($projectId); + if ($project === null) { + return new JSONResponse( + ['error' => 'Project not found.'], + Http::STATUS_NOT_FOUND + ); + } + + if ($this->columnService->isProjectMember($projectId, $project) === false) { + return new JSONResponse( + ['error' => 'You are not a member of this project.'], + Http::STATUS_FORBIDDEN + ); + } + + $title = $data['title'] ?? ''; + if (empty($title) === true) { + return new JSONResponse( + ['error' => 'The title field is required.'], + Http::STATUS_BAD_REQUEST + ); + } + + $allowedTypes = ['active', 'done']; + $type = $data['type'] ?? 'active'; + if (in_array($type, $allowedTypes, true) === false) { + return new JSONResponse( + ['error' => 'Invalid type. Allowed values: active, done.'], + Http::STATUS_BAD_REQUEST + ); + } + + $wipLimit = null; + if (isset($data['wipLimit']) === true) { + $wipLimit = (int) $data['wipLimit']; + } + + $columnData = [ + 'title' => $title, + 'project' => $projectId, + 'order' => (int) ($data['order'] ?? ($data['position'] ?? 0)), + 'wipLimit' => $wipLimit, + 'color' => $data['color'] ?? null, + 'type' => $type, + ]; + + $column = $this->columnService->createColumn($columnData, $project); + + return new JSONResponse($column, Http::STATUS_CREATED); + + }//end create() + + /** + * Update an existing column. + * + * @param string $id The column UUID + * + * @NoAdminRequired + * + * @return JSONResponse + */ + public function update(string $id): JSONResponse + { + $column = $this->columnService->findColumn($id); + if ($column === null) { + return new JSONResponse( + ['error' => 'Column not found.'], + Http::STATUS_NOT_FOUND + ); + } + + $projectId = $column['project'] ?? ''; + if ($this->columnService->isProjectMember($projectId) === false) { + return new JSONResponse( + ['error' => 'You are not a member of this project.'], + Http::STATUS_FORBIDDEN + ); + } + + $data = $this->request->getParams(); + $updateData = []; + + if (isset($data['title']) === true) { + $updateData['title'] = $data['title']; + } + + if (isset($data['order']) === true || isset($data['position']) === true) { + $updateData['order'] = (int) ($data['order'] ?? $data['position']); + } + + if (array_key_exists('wipLimit', $data) === true) { + if ($data['wipLimit'] !== null) { + $updateData['wipLimit'] = (int) $data['wipLimit']; + } else { + $updateData['wipLimit'] = null; + } + } + + if (isset($data['color']) === true) { + $updateData['color'] = $data['color']; + } + + if (isset($data['type']) === true) { + $allowedTypes = ['active', 'done']; + if (in_array($data['type'], $allowedTypes, true) === false) { + return new JSONResponse( + ['error' => 'Invalid type. Allowed values: active, done.'], + Http::STATUS_BAD_REQUEST + ); + } + + $updateData['type'] = $data['type']; + } + + if (empty($updateData) === true) { + return new JSONResponse( + ['error' => 'No valid fields provided for update.'], + Http::STATUS_BAD_REQUEST + ); + } + + try { + $updated = $this->columnService->updateColumn($id, $updateData); + } catch (\RuntimeException $e) { + return new JSONResponse( + ['error' => 'Failed to update column.'], + Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + + return new JSONResponse($updated); + + }//end update() + + /** + * Delete a column. Tasks in this column are moved to the backlog. + * + * @param string $id The column UUID + * + * @NoAdminRequired + * + * @return JSONResponse + */ + public function destroy(string $id): JSONResponse + { + $column = $this->columnService->findColumn($id); + if ($column === null) { + return new JSONResponse( + ['error' => 'Column not found.'], + Http::STATUS_NOT_FOUND + ); + } + + $projectId = $column['project'] ?? ''; + if ($this->columnService->isProjectMember($projectId) === false) { + return new JSONResponse( + ['error' => 'You are not a member of this project.'], + Http::STATUS_FORBIDDEN + ); + } + + try { + $deleted = $this->columnService->deleteColumn($id); + } catch (\RuntimeException $e) { + return new JSONResponse( + ['error' => 'Failed to delete column.'], + Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + + if ($deleted === false) { + return new JSONResponse( + ['error' => 'Failed to delete column.'], + Http::STATUS_INTERNAL_SERVER_ERROR + ); + } + + return new JSONResponse(null, Http::STATUS_NO_CONTENT); + + }//end destroy() +}//end class diff --git a/lib/Service/ColumnService.php b/lib/Service/ColumnService.php new file mode 100644 index 0000000..d6c2619 --- /dev/null +++ b/lib/Service/ColumnService.php @@ -0,0 +1,305 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Planix\Service; + +use OCP\IUserSession; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Service for managing kanban board columns. + * + * Wraps OpenRegister CRUD for the 'column' schema and adds + * project-membership authorization checks. + */ +class ColumnService +{ + /** + * Constructor for the ColumnService. + * + * @param ContainerInterface $container The DI container + * @param IUserSession $userSession The user session + * @param LoggerInterface $logger The logger + * + * @return void + */ + public function __construct( + private ContainerInterface $container, + private IUserSession $userSession, + private LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Get the OpenRegister object service. + * + * @return object The ObjectService instance + * + * @throws \RuntimeException When OpenRegister is not available + */ + private function getObjectService(): object + { + try { + return $this->container->get('OCA\OpenRegister\Service\ObjectService'); + } catch (\Throwable $e) { + $this->logger->error('Planix: OpenRegister ObjectService not available', ['exception' => $e->getMessage()]); + throw new \RuntimeException('OpenRegister is not available.'); + } + + }//end getObjectService() + + /** + * Get the current user UID. + * + * @return string + */ + private function getCurrentUid(): string + { + $user = $this->userSession->getUser(); + if ($user !== null) { + return $user->getUID(); + } + + return ''; + + }//end getCurrentUid() + + /** + * Find a project by ID. + * + * @param string $projectId The project UUID + * + * @return array|null Project data or null if not found + */ + public function findProject(string $projectId): ?array + { + try { + $objectService = $this->getObjectService(); + $project = $objectService->findObject(register: 'planix', schema: 'project', id: $projectId); + + if ($project === null || $project === false) { + return null; + } + + return $project; + } catch (\Throwable $e) { + $this->logger->warning('Planix: project lookup failed', ['exception' => $e->getMessage()]); + return null; + } + + }//end findProject() + + /** + * Check whether the current user is a member of the given project. + * + * @param string $projectId The project UUID + * @param array|null $project Pre-fetched project data to avoid a second lookup; fetched if null + * + * @return bool + */ + public function isProjectMember(string $projectId, ?array $project=null): bool + { + $uid = $this->getCurrentUid(); + if ($uid === '') { + return false; + } + + if ($project === null) { + $project = $this->findProject(projectId: $projectId); + } + + if ($project === null) { + return false; + } + + $members = $project['members'] ?? []; + + return in_array($uid, $members, true); + + }//end isProjectMember() + + /** + * List columns for a project, ordered by position. + * + * @param string $projectId The project UUID + * + * @return array List of column objects + */ + public function listColumns(string $projectId): array + { + $objectService = $this->getObjectService(); + $columns = $objectService->findObjects( + register: 'planix', + schema: 'column', + filters: ['project' => $projectId], + ); + + usort( + $columns, + static function (array $a, array $b): int { + return ($a['order'] ?? 0) <=> ($b['order'] ?? 0); + } + ); + + return $columns; + + }//end listColumns() + + /** + * Find a single column by ID. + * + * @param string $id The column UUID + * + * @return array|null Column data or null if not found + */ + public function findColumn(string $id): ?array + { + try { + $objectService = $this->getObjectService(); + $column = $objectService->findObject(register: 'planix', schema: 'column', id: $id); + + if ($column === false || $column === null) { + return null; + } + + return $column; + } catch (\Throwable $e) { + $this->logger->warning('Planix: column lookup failed', ['exception' => $e->getMessage()]); + return null; + } + + }//end findColumn() + + /** + * Create a new column. + * + * Callers must have verified project membership before calling this method. + * This guard provides defense-in-depth by re-checking membership. + * + * @param array $data Column fields (title, projectId, position) + * @param array|null $project Pre-fetched project array to avoid a second lookup; fetched if null + * + * @return array The created column + * + * @throws \RuntimeException When caller is not a project member + */ + public function createColumn(array $data, ?array $project=null): array + { + $projectId = $data['project'] ?? ''; + if ($this->isProjectMember(projectId: $projectId, project: $project) === false) { + throw new \RuntimeException('Forbidden: caller is not a member of project '.$projectId); + } + + $objectService = $this->getObjectService(); + + return $objectService->saveObject( + register: 'planix', + schema: 'column', + object: $data, + ); + + }//end createColumn() + + /** + * Update an existing column. + * + * Callers must have verified project membership before calling this method. + * This guard provides defense-in-depth by re-checking membership via the column's project. + * + * @param string $id The column UUID + * @param array $data Updated fields + * + * @return array The updated column + * + * @throws \RuntimeException When caller is not a project member or column not found + */ + public function updateColumn(string $id, array $data): array + { + $column = $this->findColumn(id: $id); + if ($column === null) { + throw new \RuntimeException('Column not found: '.$id); + } + + $projectId = $column['project'] ?? ''; + if ($this->isProjectMember(projectId: $projectId) === false) { + throw new \RuntimeException('Forbidden: caller is not a member of project '.$projectId); + } + + $objectService = $this->getObjectService(); + + return $objectService->saveObject( + register: 'planix', + schema: 'column', + object: array_merge($data, ['id' => $id]), + ); + + }//end updateColumn() + + /** + * Delete a column and move its tasks to the backlog (column = null). + * + * Callers must have verified project membership before calling this method. + * This guard provides defense-in-depth by re-checking membership via the column's project. + * + * @param string $id The column UUID + * + * @return bool True on success + * + * @throws \RuntimeException When caller is not a project member or column not found + */ + public function deleteColumn(string $id): bool + { + $column = $this->findColumn(id: $id); + if ($column === null) { + throw new \RuntimeException('Column not found: '.$id); + } + + $projectId = $column['project'] ?? ''; + if ($this->isProjectMember(projectId: $projectId) === false) { + throw new \RuntimeException('Forbidden: caller is not a member of project '.$projectId); + } + + $objectService = $this->getObjectService(); + + // Move tasks assigned to this column to backlog (column = null). + $tasks = $objectService->findObjects( + register: 'planix', + schema: 'task', + filters: ['column' => $id], + ); + + foreach ($tasks as $task) { + $objectService->saveObject( + register: 'planix', + schema: 'task', + object: [ + 'id' => $task['id'], + 'column' => null, + 'columnOrder' => 0, + ], + ); + } + + return $objectService->deleteObject(register: 'planix', schema: 'column', id: $id); + + }//end deleteColumn() +}//end class diff --git a/openspec/changes/kanban-board/design.md b/openspec/changes/kanban-board/design.md index 1d5eee3..7e1b157 100644 --- a/openspec/changes/kanban-board/design.md +++ b/openspec/changes/kanban-board/design.md @@ -1,8 +1,14 @@ # Design: kanban-board **Change ID:** kanban-board -**Status:** draft +**Status:** pr-created **Created:** 2026-04-02 +**PR:** https://github.com/ConductionNL/planix/pull/83 + +> **Implementation note:** The MVP shipped in PR #83 implements the kanban board as a +> PHP-controller-backed feature (ColumnController + ColumnService + ProjectBoard.vue) +> rather than the pure-Vue/Pinia approach described below. The full spec remains here +> as the target architecture for future iterations. --- diff --git a/openspec/changes/kanban-board/tasks.md b/openspec/changes/kanban-board/tasks.md index 1b50530..5713574 100644 --- a/openspec/changes/kanban-board/tasks.md +++ b/openspec/changes/kanban-board/tasks.md @@ -1,12 +1,40 @@ # Tasks: kanban-board **Change ID:** kanban-board -**Status:** draft +**Status:** pr-created **Created:** 2026-04-02 --- -## Implementation Tasks +## MVP Implementation Tasks (PR #83) + +These are the tasks actually implemented in PR #83 (simplified PHP-controller approach): + +### Task 1: Backend — Column CRUD endpoints +**spec_ref**: kanban-board.md → Requirement: Column Management +**files_likely_affected**: lib/Controller/ColumnController.php (new), appinfo/routes.php +**acceptance_criteria**: +- [x] `GET /api/columns?projectId={id}` lists columns for a project, ordered by position +- [x] `POST /api/columns` creates a column (title, projectId, position) +- [x] `PATCH /api/columns/{id}` updates a column (title, position) — partial update per RFC 7231 / NL API strategie +- [x] `DELETE /api/columns/{id}` deletes a column (moves tasks to backlog), returns 204 No Content +- [x] Returns 404 for non-existent columns or projects +- [x] Returns 403 for non-project-members + +### Task 2: Frontend — Kanban board view +**spec_ref**: kanban-board.md → Scenario: View kanban board +**files_likely_affected**: src/views/ProjectBoard.vue, src/store/projects.js +**acceptance_criteria**: +- [x] /projects/:id/board route shows the kanban board for a project +- [x] Board displays columns left-to-right with task cards in each +- [x] Each task card shows: title, assignee avatar, priority indicator, due date +- [x] Empty columns show "No tasks" placeholder +- [x] "Add column" button at the right edge of the board +- [x] Column header shows title and task count + +--- + +## Full Spec Tasks (future iterations) ### Task 1: Setup and Dependencies - **spec_ref**: `openspec/changes/kanban-board/proposal.md` diff --git a/openspec/changes/spec/specs/kanban-board.md b/openspec/changes/spec/specs/kanban-board.md new file mode 100644 index 0000000..425afb9 --- /dev/null +++ b/openspec/changes/spec/specs/kanban-board.md @@ -0,0 +1,100 @@ +# Kanban Board Specification + +**Status**: idea + +**Standards**: Schema.org ItemList (board), DefinedTerm (column), Kanban Guide (kanban.university) +**Feature tier**: MVP + +**OpenSpec changes:** _(links to openspec/changes/ directories when in-progress or done)_ + +## Purpose + +The kanban board is the primary visual interface for a project in Planix. It shows tasks as cards organized into configurable columns (stages). Users drag and drop cards between columns to update task status. WIP limits on columns enforce flow discipline. Boards are filtered by assignee, label, or priority to focus attention. Each project has exactly one kanban board; columns are managed as part of the project. + +## Data Model + +See [ARCHITECTURE.md](../../docs/ARCHITECTURE.md) for full Column entity definition. + +**Column summary**: + +| Property | Type | Required | Default | +|----------|------|----------|---------| +| `title` | string | Yes | — | +| `project` | reference (Project) | Yes | — | +| `order` | integer | Yes | 0 | +| `wipLimit` | integer \| null | No | null | +| `color` | string (hex) | No | — | +| `type` | enum: active, done | No | `active` | + +## Requirements + +### Requirement: Kanban Board View [MVP] +The system MUST render a kanban board with columns and task cards for each project. + +#### Scenario: Display board +- GIVEN a project has columns and tasks +- WHEN a project member opens the board view +- THEN the system MUST display columns in their configured order +- AND each task card MUST show: title, assignee avatar, due date, priority indicator, label chips +- AND the system MUST show the task count and WIP limit indicator per column + +#### Scenario: Drag-and-drop task to column +- GIVEN a task card is visible on the board +- WHEN a user drags a task from one column to another +- THEN the system MUST update the task's `column` reference and `columnOrder` in OpenRegister +- AND the card MUST appear in the new column position immediately (optimistic UI) +- AND if the backend update fails the system MUST revert the card to its original position + +#### Scenario: WIP limit visual warning +- GIVEN column "In Progress" has wipLimit = 3 and contains 3 tasks +- WHEN a fourth task is dragged into the column +- THEN the column header MUST turn orange/red indicating a limit violation +- AND a tooltip MUST explain "WIP limit (3) exceeded" +- AND the task MUST be placed in the column despite the violation (soft limit) + +#### Scenario: Column management +- GIVEN a project member is in the project settings +- WHEN the user adds a new column with title and optional WIP limit +- THEN the system MUST create the column and append it to the board +- AND the user MUST be able to reorder columns via drag-and-drop + +#### Scenario: Board filter +- GIVEN the board shows multiple tasks from multiple assignees +- WHEN the user selects a filter (e.g., "Assignee: me") +- THEN the system MUST visually dim or hide tasks that do not match the filter +- AND the URL MUST reflect the active filter (for shareable filtered views) + +#### Scenario: Drag task from backlog to board +- GIVEN the backlog panel is open alongside the board +- WHEN a user drags a task from the backlog into a board column +- THEN the system MUST assign the task to that column +- AND the task MUST disappear from the backlog panel + +## User Stories + +- As a developer, I want to see all project tasks as cards on a board so that I understand the current state of work at a glance +- As a team member, I want to drag a task card to "Done" so that I can mark it as complete without opening the detail view +- As a project lead, I want to set WIP limits on columns so that the team doesn't overload any single stage +- As a user, I want to filter the board by my name so that I can see only my tasks +- As an admin, I want to add and reorder columns so that the board matches our team's workflow + +## Acceptance Criteria + +- [ ] Board columns render in configured order; columns are horizontally scrollable if there are many +- [ ] Task cards show title, assignee avatar (NC user avatar), due date (red if overdue), priority dot, label chips +- [ ] Drag-and-drop updates task column and order; optimistic UI with rollback on failure +- [ ] WIP limit exceeded shows a red column header and tooltip +- [ ] Filter by assignee, label, and priority works without full page reload +- [ ] Filter state is reflected in the URL hash (shareable) +- [ ] Columns can be created, renamed, reordered, and deleted via project settings +- [ ] Deleting a column with tasks prompts: "Move tasks to backlog" or "Move to another column" +- [ ] Board is keyboard-navigable (WCAG AA) +- [ ] Empty columns show a clear "Empty" state with a "+ Add task" button + +## Notes + +- The kanban board is a custom Planix Vue component (drag-and-drop via SortableJS/vue-draggable), not from the `@conduction/nextcloud-vue` library. It uses `useObjectStore` for data operations and `CnStatusBadge` for card status indicators. +- Swimlanes (V1): group cards horizontally by assignee or priority within each column +- Card quick-edit (V1): inline editing of title, due date, assignee on hover without opening detail +- Collapsed columns (V1): columns can be collapsed to save horizontal space +- Blocked task indicators (V1): tasks with unresolved `blocks` dependencies show a lock icon diff --git a/src/router/index.js b/src/router/index.js index a0352f8..ecb5e33 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -22,6 +22,11 @@ export default new Router({ name: 'ProjectBoard', component: () => import('../views/ProjectBoard.vue'), }, + { + path: '/projects/:id/board', + name: 'ProjectBoardView', + component: () => import('../views/ProjectBoard.vue'), + }, { path: '/projects/:id/backlog', name: 'ProjectBacklog', diff --git a/src/store/projects.js b/src/store/projects.js index 8095e7a..5c6ae58 100644 --- a/src/store/projects.js +++ b/src/store/projects.js @@ -43,6 +43,9 @@ export const useProjectsStore = defineStore('projects', { /** @type {object|null} */ activeProject: null, /** @type {boolean} */ loading: false, /** @type {string|null} */ error: null, + /** @type {Array} */ columns: [], + /** @type {Array} */ tasks: [], + /** @type {boolean} */ boardLoading: false, }), actions: { @@ -261,6 +264,104 @@ export const useProjectsStore = defineStore('projects', { return { created, failed: failedTitles.length } }, + // ── 2.6b fetchColumns ───────────────────────────────────────────── + + /** + * Fetch columns for a project, ordered by position. + * + * @param {string} projectId Parent project ID + * @return {Promise} + */ + async fetchColumns(projectId) { + try { + const objectStore = this._objectStore() + const results = await objectStore.fetchCollection(COLUMN_SCHEMA, { project: projectId }) + this.columns = [...results].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + return this.columns + } catch (err) { + console.error('fetchColumns error:', err) + this.columns = [] + return [] + } + }, + + // ── 2.6c fetchTasks ─────────────────────────────────────────────── + + /** + * Fetch tasks for a project. + * + * @param {string} projectId Parent project ID + * @return {Promise} + */ + async fetchTasks(projectId) { + try { + const objectStore = this._objectStore() + const results = await objectStore.fetchCollection(TASK_SCHEMA, { project: projectId }) + this.tasks = results + return this.tasks + } catch (err) { + console.error('fetchTasks error:', err) + this.tasks = [] + return [] + } + }, + + // ── 2.6d fetchBoard ─────────────────────────────────────────────── + + /** + * Fetch all board data (columns + tasks) for a project. + * + * @param {string} projectId Parent project ID + * @return {Promise<{columns: Array, tasks: Array}>} + */ + async fetchBoard(projectId) { + this.boardLoading = true + try { + const [columns, tasks] = await Promise.all([ + this.fetchColumns(projectId), + this.fetchTasks(projectId), + ]) + return { columns, tasks } + } finally { + this.boardLoading = false + } + }, + + // ── 2.6e createColumn ───────────────────────────────────────────── + + /** + * Create a new column for a project. + * + * @param {object} data Column fields + * @return {Promise} + */ + async createNewColumn(data) { + const objectStore = this._objectStore() + const column = await objectStore.saveObject(COLUMN_SCHEMA, data) + if (column) { + this.columns = [...this.columns, column].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + } + return column + }, + + // ── 2.6f updateTask ─────────────────────────────────────────────── + + /** + * Update a task (e.g. move to a different column). + * + * @param {string} taskId Task ID + * @param {object} data Updated fields + * @return {Promise} + */ + async updateTask(taskId, data) { + const objectStore = this._objectStore() + const updated = await objectStore.saveObject(TASK_SCHEMA, { id: taskId, ...data }) + if (updated) { + this.tasks = this.tasks.map((t) => (t.id === taskId ? { ...t, ...updated } : t)) + } + return updated + }, + // ── 2.7 archiveProject ──────────────────────────────────────────── /** diff --git a/src/views/ProjectBoard.vue b/src/views/ProjectBoard.vue index 9273409..50ebdd7 100644 --- a/src/views/ProjectBoard.vue +++ b/src/views/ProjectBoard.vue @@ -57,21 +57,102 @@ - - - - - + +
+ +
+ + +
+
+
+ +
+

+ {{ column.title }} +

+ + {{ tasksForColumn(column.id).length }} + +
+ + +
+
+
+ + {{ task.title }} +
+
+ + + + + {{ formatDate(task.dueDate) }} + +
+
+ + +
+ {{ t('planix', 'No tasks') }} +
+
+
+ + +
+ + + {{ t('planix', 'Add column') }} + +
+ +
+ + {{ t('planix', 'Add') }} + + + {{ t('planix', 'Cancel') }} + +
+
+
+
+
@@ -79,11 +160,10 @@ @@ -155,7 +305,6 @@ export default { diff --git a/tests/unit/Controller/ColumnControllerTest.php b/tests/unit/Controller/ColumnControllerTest.php new file mode 100644 index 0000000..13f8773 --- /dev/null +++ b/tests/unit/Controller/ColumnControllerTest.php @@ -0,0 +1,591 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\Planix\Tests\Unit\Controller; + +use OCA\Planix\Controller\ColumnController; +use OCA\Planix\Service\ColumnService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IRequest; +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; + +/** + * Tests for ColumnController. + */ +class ColumnControllerTest extends TestCase +{ + + /** + * The controller under test. + * + * @var ColumnController + */ + private ColumnController $controller; + + /** + * Mock IRequest. + * + * @var IRequest&MockObject + */ + private IRequest&MockObject $request; + + /** + * Mock ColumnService. + * + * @var ColumnService&MockObject + */ + private ColumnService&MockObject $columnService; + + /** + * Set up test fixtures. + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + + $this->request = $this->createMock(originalClassName: IRequest::class); + $this->columnService = $this->createMock(originalClassName: ColumnService::class); + + $this->controller = new ColumnController( + request: $this->request, + columnService: $this->columnService, + ); + + }//end setUp() + + /** + * Test that index() returns columns for a valid project member. + * + * @return void + */ + public function testIndexReturnsColumnsForProjectMember(): void + { + $projectId = 'proj-uuid-1'; + $columns = [ + ['id' => 'col-1', 'title' => 'To Do', 'order' => 0], + ['id' => 'col-2', 'title' => 'In Progress', 'order' => 1], + ]; + + $this->request->expects($this->once()) + ->method('getParam') + ->with('projectId') + ->willReturn($projectId); + + $project = ['id' => $projectId, 'members' => ['user1']]; + + $this->columnService->expects($this->once()) + ->method('findProject') + ->with($projectId) + ->willReturn($project); + + $this->columnService->expects($this->once()) + ->method('isProjectMember') + ->with($projectId, $project) + ->willReturn(true); + + $this->columnService->expects($this->once()) + ->method('listColumns') + ->with($projectId) + ->willReturn($columns); + + $result = $this->controller->index(); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: 200, actual: $result->getStatus()); + self::assertSame(expected: $columns, actual: $result->getData()); + + }//end testIndexReturnsColumnsForProjectMember() + + /** + * Test that index() returns 404 for non-existent projects. + * + * @return void + */ + public function testIndexReturnsNotFoundForNonExistentProject(): void + { + $projectId = 'nonexistent'; + + $this->request->expects($this->once()) + ->method('getParam') + ->with('projectId') + ->willReturn($projectId); + + $this->columnService->expects($this->once()) + ->method('findProject') + ->with($projectId) + ->willReturn(null); + + $this->columnService->expects($this->never()) + ->method('listColumns'); + + $result = $this->controller->index(); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_NOT_FOUND, actual: $result->getStatus()); + self::assertArrayHasKey(key: 'error', array: $result->getData()); + + }//end testIndexReturnsNotFoundForNonExistentProject() + + /** + * Test that index() returns 403 for non-project-members. + * + * @return void + */ + public function testIndexReturnsForbiddenForNonMember(): void + { + $projectId = 'proj-uuid-1'; + + $this->request->expects($this->once()) + ->method('getParam') + ->with('projectId') + ->willReturn($projectId); + + $project = ['id' => $projectId, 'members' => ['other-user']]; + + $this->columnService->expects($this->once()) + ->method('findProject') + ->with($projectId) + ->willReturn($project); + + $this->columnService->expects($this->once()) + ->method('isProjectMember') + ->with($projectId, $project) + ->willReturn(false); + + $this->columnService->expects($this->never()) + ->method('listColumns'); + + $result = $this->controller->index(); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_FORBIDDEN, actual: $result->getStatus()); + self::assertArrayHasKey(key: 'error', array: $result->getData()); + + }//end testIndexReturnsForbiddenForNonMember() + + /** + * Test that index() returns 400 when projectId is missing. + * + * @return void + */ + public function testIndexReturnsBadRequestWithoutProjectId(): void + { + $this->request->expects($this->once()) + ->method('getParam') + ->with('projectId') + ->willReturn(null); + + $result = $this->controller->index(); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $result->getStatus()); + + }//end testIndexReturnsBadRequestWithoutProjectId() + + /** + * Test that create() creates a column successfully. + * + * @return void + */ + public function testCreateReturnsCreatedColumn(): void + { + $params = ['title' => 'Testing', 'project' => 'proj-uuid-1', 'order' => 4, 'type' => 'active']; + $created = array_merge($params, ['id' => 'col-new']); + + $this->request->expects($this->once()) + ->method('getParams') + ->willReturn($params); + + $project = ['id' => 'proj-uuid-1', 'members' => ['user1']]; + + $this->columnService->expects($this->once()) + ->method('findProject') + ->with('proj-uuid-1') + ->willReturn($project); + + $this->columnService->expects($this->once()) + ->method('isProjectMember') + ->with('proj-uuid-1', $project) + ->willReturn(true); + + $this->columnService->expects($this->once()) + ->method('createColumn') + ->willReturn($created); + + $result = $this->controller->create(); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_CREATED, actual: $result->getStatus()); + self::assertSame(expected: 'col-new', actual: $result->getData()['id']); + + }//end testCreateReturnsCreatedColumn() + + /** + * Test that create() returns 400 when project field is missing. + * + * @return void + */ + public function testCreateReturnsBadRequestWithoutProject(): void + { + $this->request->expects($this->once()) + ->method('getParams') + ->willReturn(['title' => 'My Column']); + + $this->columnService->expects($this->never()) + ->method('isProjectMember'); + + $result = $this->controller->create(); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $result->getStatus()); + self::assertArrayHasKey(key: 'error', array: $result->getData()); + + }//end testCreateReturnsBadRequestWithoutProject() + + /** + * Test that create() returns 400 when title is empty. + * + * @return void + */ + public function testCreateReturnsBadRequestWithEmptyTitle(): void + { + $this->request->expects($this->once()) + ->method('getParams') + ->willReturn(['project' => 'proj-uuid-1', 'title' => '']); + + $project = ['id' => 'proj-uuid-1', 'members' => ['user1']]; + + $this->columnService->expects($this->once()) + ->method('findProject') + ->with('proj-uuid-1') + ->willReturn($project); + + $this->columnService->expects($this->once()) + ->method('isProjectMember') + ->with('proj-uuid-1', $project) + ->willReturn(true); + + $this->columnService->expects($this->never()) + ->method('createColumn'); + + $result = $this->controller->create(); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $result->getStatus()); + self::assertArrayHasKey(key: 'error', array: $result->getData()); + + }//end testCreateReturnsBadRequestWithEmptyTitle() + + /** + * Test that create() returns 403 for non-project-member. + * + * @return void + */ + public function testCreateReturnsForbiddenForNonMember(): void + { + $this->request->expects($this->once()) + ->method('getParams') + ->willReturn(['title' => 'My Column', 'project' => 'proj-uuid-1']); + + $project = ['id' => 'proj-uuid-1', 'members' => ['other-user']]; + + $this->columnService->expects($this->once()) + ->method('findProject') + ->with('proj-uuid-1') + ->willReturn($project); + + $this->columnService->expects($this->once()) + ->method('isProjectMember') + ->with('proj-uuid-1', $project) + ->willReturn(false); + + $this->columnService->expects($this->never()) + ->method('createColumn'); + + $result = $this->controller->create(); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_FORBIDDEN, actual: $result->getStatus()); + self::assertArrayHasKey(key: 'error', array: $result->getData()); + + }//end testCreateReturnsForbiddenForNonMember() + + /** + * Test that create() returns 400 when an invalid type value is provided. + * + * @return void + */ + public function testCreateReturnsBadRequestWithInvalidType(): void + { + $this->request->expects($this->once()) + ->method('getParams') + ->willReturn(['title' => 'My Column', 'project' => 'proj-uuid-1', 'type' => 'invalid-type']); + + $project = ['id' => 'proj-uuid-1', 'members' => ['user1']]; + + $this->columnService->expects($this->once()) + ->method('findProject') + ->with('proj-uuid-1') + ->willReturn($project); + + $this->columnService->expects($this->once()) + ->method('isProjectMember') + ->willReturn(true); + + $this->columnService->expects($this->never()) + ->method('createColumn'); + + $result = $this->controller->create(); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $result->getStatus()); + self::assertArrayHasKey(key: 'error', array: $result->getData()); + + }//end testCreateReturnsBadRequestWithInvalidType() + + /** + * Test that update() returns 400 when an invalid type value is provided. + * + * @return void + */ + public function testUpdateReturnsBadRequestWithInvalidType(): void + { + $column = ['id' => 'col-1', 'project' => 'proj-uuid-1', 'title' => 'To Do']; + + $this->columnService->expects($this->once()) + ->method('findColumn') + ->with('col-1') + ->willReturn($column); + + $this->columnService->expects($this->once()) + ->method('isProjectMember') + ->with('proj-uuid-1') + ->willReturn(true); + + $this->request->expects($this->once()) + ->method('getParams') + ->willReturn(['type' => 'invalid-type']); + + $this->columnService->expects($this->never()) + ->method('updateColumn'); + + $result = $this->controller->update('col-1'); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $result->getStatus()); + self::assertArrayHasKey(key: 'error', array: $result->getData()); + + }//end testUpdateReturnsBadRequestWithInvalidType() + + /** + * Test that update() returns 400 when PATCH body contains no recognised fields. + * + * @return void + */ + public function testUpdateReturnsBadRequestWithEmptyBody(): void + { + $column = ['id' => 'col-1', 'project' => 'proj-uuid-1', 'title' => 'To Do']; + + $this->columnService->expects($this->once()) + ->method('findColumn') + ->with('col-1') + ->willReturn($column); + + $this->columnService->expects($this->once()) + ->method('isProjectMember') + ->with('proj-uuid-1') + ->willReturn(true); + + $this->request->expects($this->once()) + ->method('getParams') + ->willReturn([]); + + $this->columnService->expects($this->never()) + ->method('updateColumn'); + + $result = $this->controller->update('col-1'); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_BAD_REQUEST, actual: $result->getStatus()); + self::assertArrayHasKey(key: 'error', array: $result->getData()); + + }//end testUpdateReturnsBadRequestWithEmptyBody() + + /** + * Test that update() returns 500 when service throws RuntimeException. + * + * @return void + */ + public function testUpdateReturns500OnServiceException(): void + { + $column = ['id' => 'col-1', 'project' => 'proj-uuid-1', 'title' => 'To Do']; + + $this->columnService->expects($this->once()) + ->method('findColumn') + ->with('col-1') + ->willReturn($column); + + $this->columnService->expects($this->once()) + ->method('isProjectMember') + ->with('proj-uuid-1') + ->willReturn(true); + + $this->request->expects($this->once()) + ->method('getParams') + ->willReturn(['title' => 'Updated']); + + $this->columnService->expects($this->once()) + ->method('updateColumn') + ->willThrowException(new \RuntimeException('OpenRegister unavailable')); + + $result = $this->controller->update('col-1'); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_INTERNAL_SERVER_ERROR, actual: $result->getStatus()); + self::assertArrayHasKey(key: 'error', array: $result->getData()); + + }//end testUpdateReturns500OnServiceException() + + /** + * Test that destroy() returns 500 when service throws RuntimeException. + * + * @return void + */ + public function testDestroyReturns500OnServiceException(): void + { + $column = ['id' => 'col-1', 'project' => 'proj-uuid-1', 'title' => 'To Do']; + + $this->columnService->expects($this->once()) + ->method('findColumn') + ->with('col-1') + ->willReturn($column); + + $this->columnService->expects($this->once()) + ->method('isProjectMember') + ->with('proj-uuid-1') + ->willReturn(true); + + $this->columnService->expects($this->once()) + ->method('deleteColumn') + ->willThrowException(new \RuntimeException('OpenRegister unavailable')); + + $result = $this->controller->destroy('col-1'); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_INTERNAL_SERVER_ERROR, actual: $result->getStatus()); + self::assertArrayHasKey(key: 'error', array: $result->getData()); + + }//end testDestroyReturns500OnServiceException() + + /** + * Test that update() returns 404 for non-existent column. + * + * @return void + */ + public function testUpdateReturnsNotFoundForMissingColumn(): void + { + $this->columnService->expects($this->once()) + ->method('findColumn') + ->with('nonexistent') + ->willReturn(null); + + $result = $this->controller->update('nonexistent'); + + self::assertInstanceOf(expected: JSONResponse::class, actual: $result); + self::assertSame(expected: Http::STATUS_NOT_FOUND, actual: $result->getStatus()); + + }//end testUpdateReturnsNotFoundForMissingColumn() + + /** + * Test that update() returns 403 for non-project-member. + * + * @return void + */ + public function testUpdateReturnsForbiddenForNonMember(): void + { + $column = ['id' => 'col-1', 'project' => 'proj-uuid-1', 'title' => 'To Do']; + + $this->columnService->expects($this->once()) + ->method('findColumn') + ->with('col-1') + ->willReturn($column); + + $this->columnService->expects($this->once()) + ->method('isProjectMember') + ->with('proj-uuid-1') + ->willReturn(false); + + $result = $this->controller->update('col-1'); + + self::assertSame(expected: Http::STATUS_FORBIDDEN, actual: $result->getStatus()); + + }//end testUpdateReturnsForbiddenForNonMember() + + /** + * Test that destroy() returns 404 for non-existent column. + * + * @return void + */ + public function testDestroyReturnsNotFoundForMissingColumn(): void + { + $this->columnService->expects($this->once()) + ->method('findColumn') + ->with('nonexistent') + ->willReturn(null); + + $result = $this->controller->destroy('nonexistent'); + + self::assertSame(expected: Http::STATUS_NOT_FOUND, actual: $result->getStatus()); + + }//end testDestroyReturnsNotFoundForMissingColumn() + + /** + * Test that destroy() deletes column and returns 204 for project member. + * + * @return void + */ + public function testDestroyDeletesColumnForMember(): void + { + $column = ['id' => 'col-1', 'project' => 'proj-uuid-1', 'title' => 'To Do']; + + $this->columnService->expects($this->once()) + ->method('findColumn') + ->with('col-1') + ->willReturn($column); + + $this->columnService->expects($this->once()) + ->method('isProjectMember') + ->with('proj-uuid-1') + ->willReturn(true); + + $this->columnService->expects($this->once()) + ->method('deleteColumn') + ->with('col-1') + ->willReturn(true); + + $result = $this->controller->destroy('col-1'); + + self::assertSame(expected: Http::STATUS_NO_CONTENT, actual: $result->getStatus()); + + }//end testDestroyDeletesColumnForMember() +}//end class