From a1be4229956b122492fe82422619666d15d6335a Mon Sep 17 00:00:00 2001 From: Robert Date: Sat, 25 Jul 2026 16:08:10 -0400 Subject: [PATCH] V-0.0.1 --- README.md | 29 ++ api/database.php | 87 ++++ api/tasks.php | 787 +++++++++++++++++++++++++++++++++++ assets/css/kanban.css | 192 +++++++++ assets/js/api/KanbanAPI.js | 138 ++++++ assets/js/kanban.js | 38 ++ assets/js/main.js | 22 + assets/js/view/Column.js | 93 +++++ assets/js/view/Complexity.js | 20 + assets/js/view/Dialog.js | 291 +++++++++++++ assets/js/view/DropZone.js | 97 +++++ assets/js/view/Item.js | 242 +++++++++++ assets/js/view/Kanban.js | 44 ++ assets/js/view/Priority.js | 21 + index.html | 119 ++++++ 15 files changed, 2220 insertions(+) create mode 100644 README.md create mode 100644 api/database.php create mode 100644 api/tasks.php create mode 100644 assets/css/kanban.css create mode 100644 assets/js/api/KanbanAPI.js create mode 100644 assets/js/kanban.js create mode 100644 assets/js/main.js create mode 100644 assets/js/view/Column.js create mode 100644 assets/js/view/Complexity.js create mode 100644 assets/js/view/Dialog.js create mode 100644 assets/js/view/DropZone.js create mode 100644 assets/js/view/Item.js create mode 100644 assets/js/view/Kanban.js create mode 100644 assets/js/view/Priority.js create mode 100644 index.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..177dc18 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# mykanban2 + +## A lite weight KanBan Board + +## Uses PHP, JavaScript, PDO with SQLite3 DB. + +``` +In the Index.html file, you can modify the assignee's: + + + + + +In assets/js/view/Kanban.js, you may modify the Columns: +static columns() { + return [ + { id: 1, title: "Todo/Backlog" }, + { id: 2, title: "Ready" }, + { id: 3, title: "Work in Progress" }, + { id: 4, title: "QA" }, + { id: 5, title: "Completed" }, + { id: 6, title: "Archived" } + ]; + } +``` diff --git a/api/database.php b/api/database.php new file mode 100644 index 0000000..b2e1084 --- /dev/null +++ b/api/database.php @@ -0,0 +1,87 @@ +setAttribute( + PDO::ATTR_ERRMODE, + PDO::ERRMODE_EXCEPTION + ); + + $pdo->setAttribute( + PDO::ATTR_DEFAULT_FETCH_MODE, + PDO::FETCH_ASSOC + ); + + $pdo->exec('PRAGMA foreign_keys = ON'); + $pdo->exec('PRAGMA busy_timeout = 5000'); + $pdo->exec('PRAGMA journal_mode = WAL'); + + createSchema($pdo); + + return $pdo; +} + +function createSchema(PDO $pdo): void +{ + $pdo->exec( + <<<'SQL' + CREATE TABLE IF NOT EXISTS kanban_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + + column_id INTEGER NOT NULL + CHECK (column_id BETWEEN 1 AND 6), + + position INTEGER NOT NULL DEFAULT 0, + + assign_to TEXT NOT NULL DEFAULT 'me', + content TEXT NOT NULL, + description TEXT, + tags TEXT, + color TEXT NOT NULL DEFAULT 'yellow', + + due_at TEXT, + start_date TEXT, + + priority INTEGER NOT NULL DEFAULT 0 + CHECK (priority BETWEEN 0 AND 10), + + complexity INTEGER NOT NULL DEFAULT 0 + CHECK (complexity BETWEEN 0 AND 10), + + estimate_hours REAL, + time_spent_hours REAL, + + created_at TEXT NOT NULL + DEFAULT CURRENT_TIMESTAMP, + + updated_at TEXT NOT NULL + DEFAULT CURRENT_TIMESTAMP + ); + + CREATE INDEX IF NOT EXISTS + idx_kanban_tasks_column_position + ON kanban_tasks (column_id, position); + SQL + ); +} diff --git a/api/tasks.php b/api/tasks.php new file mode 100644 index 0000000..ec8c3fc --- /dev/null +++ b/api/tasks.php @@ -0,0 +1,787 @@ + 'Method not allowed.'], + 405 + ); + } +} catch (InvalidArgumentException $exception) { + jsonResponse( + ['error' => $exception->getMessage()], + 422 + ); +} catch (Throwable $exception) { + error_log($exception->__toString()); + + jsonResponse( + ['error' => 'An internal server error occurred.'], + 500 + ); +} + +function getTasks(PDO $pdo): never +{ + $id = filter_input( + INPUT_GET, + 'id', + FILTER_VALIDATE_INT + ); + + if ($id !== null && $id !== false) { + $task = findTask($pdo, $id); + + if ($task === null) { + jsonResponse( + ['error' => 'Task not found.'], + 404 + ); + } + + jsonResponse(['task' => $task]); + } + + $statement = $pdo->query( + <<<'SQL' + SELECT * + FROM kanban_tasks + ORDER BY column_id ASC, position ASC, id ASC + SQL + ); + + $tasks = array_map( + 'formatTask', + $statement->fetchAll() + ); + + jsonResponse(['tasks' => $tasks]); +} + +function createTask(PDO $pdo): never +{ + $input = getJsonInput(); + + $columnId = requireInteger( + $input, + 'columnId', + 1, + 6 + ); + + $content = trim((string) ($input['content'] ?? '')); + + if ($content === '') { + throw new InvalidArgumentException( + 'Task name is required.' + ); + } + + $pdo->beginTransaction(); + + try { + $positionStatement = $pdo->prepare( + <<<'SQL' + SELECT COALESCE(MAX(position), -1) + 1 + FROM kanban_tasks + WHERE column_id = :column_id + SQL + ); + + $positionStatement->execute([ + 'column_id' => $columnId, + ]); + + $position = (int) $positionStatement->fetchColumn(); + + $statement = $pdo->prepare( + <<<'SQL' + INSERT INTO kanban_tasks ( + column_id, + position, + assign_to, + content, + description, + tags, + color, + due_at, + start_date, + priority, + complexity, + estimate_hours, + time_spent_hours + ) VALUES ( + :column_id, + :position, + :assign_to, + :content, + :description, + :tags, + :color, + :due_at, + :start_date, + :priority, + :complexity, + :estimate_hours, + :time_spent_hours + ) + SQL + ); + + $statement->execute([ + 'column_id' => $columnId, + 'position' => $position, + 'assign_to' => cleanString( + $input['assign'] ?? 'me', + 'me' + ), + 'content' => $content, + 'description' => nullableString( + $input['description'] ?? null + ), + 'tags' => nullableString( + $input['tags'] ?? null + ), + 'color' => cleanString( + $input['color'] ?? 'yellow', + 'yellow' + ), + 'due_at' => nullableString( + $input['due'] ?? null + ), + 'start_date' => nullableString( + $input['start'] ?? null + ), + 'priority' => boundedInteger( + $input['priority'] ?? 0, + 0, + 10 + ), + 'complexity' => boundedInteger( + $input['complexity'] ?? 0, + 0, + 10 + ), + 'estimate_hours' => nullableNumber( + $input['estimate'] ?? null + ), + 'time_spent_hours' => nullableNumber( + $input['timeSpent'] ?? null + ), + ]); + + $taskId = (int) $pdo->lastInsertId(); + + $pdo->commit(); + + jsonResponse( + ['task' => findTask($pdo, $taskId)], + 201 + ); + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + throw $exception; + } +} + +function updateTask(PDO $pdo): never +{ + $input = getJsonInput(); + + $taskId = requireInteger( + $input, + 'id', + 1 + ); + + $pdo->beginTransaction(); + + try { + $currentTask = findRawTask($pdo, $taskId); + + if ($currentTask === null) { + $pdo->rollBack(); + + jsonResponse( + ['error' => 'Task not found.'], + 404 + ); + } + + $currentColumn = (int) $currentTask['column_id']; + $currentPosition = (int) $currentTask['position']; + + $targetColumn = array_key_exists('columnId', $input) + ? boundedInteger($input['columnId'], 1, 6) + : $currentColumn; + + $targetPosition = array_key_exists('position', $input) + ? max(0, (int) $input['position']) + : $currentPosition; + + if ( + $targetColumn !== $currentColumn || + $targetPosition !== $currentPosition + ) { + moveTask( + $pdo, + $taskId, + $currentColumn, + $currentPosition, + $targetColumn, + $targetPosition + ); + } + + $fields = []; + $parameters = [ + 'id' => $taskId, + ]; + + addField( + $fields, + $parameters, + $input, + 'content', + 'content', + static function (mixed $value): string { + $content = trim((string) $value); + + if ($content === '') { + throw new InvalidArgumentException( + 'Task name is required.' + ); + } + + return $content; + } + ); + + addField( + $fields, + $parameters, + $input, + 'assign', + 'assign_to', + static fn(mixed $value): string => + cleanString($value, 'me') + ); + + addField( + $fields, + $parameters, + $input, + 'description', + 'description', + 'nullableString' + ); + + addField( + $fields, + $parameters, + $input, + 'tags', + 'tags', + 'nullableString' + ); + + addField( + $fields, + $parameters, + $input, + 'color', + 'color', + static fn(mixed $value): string => + cleanString($value, 'yellow') + ); + + addField( + $fields, + $parameters, + $input, + 'due', + 'due_at', + 'nullableString' + ); + + addField( + $fields, + $parameters, + $input, + 'start', + 'start_date', + 'nullableString' + ); + + addField( + $fields, + $parameters, + $input, + 'priority', + 'priority', + static fn(mixed $value): int => + boundedInteger($value, 0, 10) + ); + + addField( + $fields, + $parameters, + $input, + 'complexity', + 'complexity', + static fn(mixed $value): int => + boundedInteger($value, 0, 10) + ); + + addField( + $fields, + $parameters, + $input, + 'estimate', + 'estimate_hours', + 'nullableNumber' + ); + + addField( + $fields, + $parameters, + $input, + 'timeSpent', + 'time_spent_hours', + 'nullableNumber' + ); + + if ($fields !== []) { + $fields[] = 'updated_at = CURRENT_TIMESTAMP'; + + $sql = sprintf( + 'UPDATE kanban_tasks + SET %s + WHERE id = :id', + implode(', ', $fields) + ); + + $statement = $pdo->prepare($sql); + $statement->execute($parameters); + } + + $pdo->commit(); + + jsonResponse([ + 'task' => findTask($pdo, $taskId), + ]); + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + throw $exception; + } +} + +function moveTask( + PDO $pdo, + int $taskId, + int $currentColumn, + int $currentPosition, + int $targetColumn, + int $targetPosition +): void { + /* + * First remove the task from its current ordering. + */ + $closeGap = $pdo->prepare( + <<<'SQL' + UPDATE kanban_tasks + SET position = position - 1 + WHERE column_id = :column_id + AND position > :position + AND id != :id + SQL + ); + + $closeGap->execute([ + 'column_id' => $currentColumn, + 'position' => $currentPosition, + 'id' => $taskId, + ]); + + /* + * When moving downward in the same column, removing the + * task causes the requested position to decrease by one. + */ + if ( + $targetColumn === $currentColumn && + $targetPosition > $currentPosition + ) { + $targetPosition--; + } + + $countStatement = $pdo->prepare( + <<<'SQL' + SELECT COUNT(*) + FROM kanban_tasks + WHERE column_id = :column_id + AND id != :id + SQL + ); + + $countStatement->execute([ + 'column_id' => $targetColumn, + 'id' => $taskId, + ]); + + $numberOfTasks = (int) $countStatement->fetchColumn(); + + $targetPosition = min( + max(0, $targetPosition), + $numberOfTasks + ); + + /* + * Create room in the destination column. + */ + $openGap = $pdo->prepare( + <<<'SQL' + UPDATE kanban_tasks + SET position = position + 1 + WHERE column_id = :column_id + AND position >= :position + AND id != :id + SQL + ); + + $openGap->execute([ + 'column_id' => $targetColumn, + 'position' => $targetPosition, + 'id' => $taskId, + ]); + + $moveStatement = $pdo->prepare( + <<<'SQL' + UPDATE kanban_tasks + SET column_id = :column_id, + position = :position, + updated_at = CURRENT_TIMESTAMP + WHERE id = :id + SQL + ); + + $moveStatement->execute([ + 'column_id' => $targetColumn, + 'position' => $targetPosition, + 'id' => $taskId, + ]); +} + +function deleteTask(PDO $pdo): never +{ + $input = getJsonInput(); + + $taskId = isset($input['id']) + ? (int) $input['id'] + : 0; + + if ($taskId < 1) { + throw new InvalidArgumentException( + 'A valid task ID is required.' + ); + } + + $pdo->beginTransaction(); + + try { + $task = findRawTask($pdo, $taskId); + + if ($task === null) { + $pdo->rollBack(); + + jsonResponse( + ['error' => 'Task not found.'], + 404 + ); + } + + $statement = $pdo->prepare( + 'DELETE FROM kanban_tasks WHERE id = :id' + ); + + $statement->execute([ + 'id' => $taskId, + ]); + + $reorderStatement = $pdo->prepare( + <<<'SQL' + UPDATE kanban_tasks + SET position = position - 1 + WHERE column_id = :column_id + AND position > :position + SQL + ); + + $reorderStatement->execute([ + 'column_id' => (int) $task['column_id'], + 'position' => (int) $task['position'], + ]); + + $pdo->commit(); + + jsonResponse([ + 'success' => true, + 'id' => $taskId, + ]); + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + throw $exception; + } +} + +function findTask(PDO $pdo, int $id): ?array +{ + $task = findRawTask($pdo, $id); + + return $task === null + ? null + : formatTask($task); +} + +function findRawTask(PDO $pdo, int $id): ?array +{ + $statement = $pdo->prepare( + 'SELECT * + FROM kanban_tasks + WHERE id = :id' + ); + + $statement->execute([ + 'id' => $id, + ]); + + $task = $statement->fetch(); + + return $task === false ? null : $task; +} + +function formatTask(array $task): array +{ + return [ + 'id' => (int) $task['id'], + 'columnId' => (int) $task['column_id'], + 'status' => (int) $task['column_id'], + 'position' => (int) $task['position'], + 'assign' => $task['assign_to'], + 'content' => $task['content'], + 'description' => $task['description'] ?? '', + 'tags' => $task['tags'] ?? '', + 'color' => $task['color'], + 'due' => $task['due_at'] ?? '', + 'start' => $task['start_date'] ?? '', + 'priority' => (int) $task['priority'], + 'complexity' => (int) $task['complexity'], + 'estimate' => $task['estimate_hours'] === null + ? null + : (float) $task['estimate_hours'], + 'timeSpent' => $task['time_spent_hours'] === null + ? null + : (float) $task['time_spent_hours'], + ]; +} + +function addField( + array &$fields, + array &$parameters, + array $input, + string $inputName, + string $columnName, + callable $filter +): void { + if (!array_key_exists($inputName, $input)) { + return; + } + + $parameterName = 'field_' . $inputName; + + $fields[] = sprintf( + '%s = :%s', + $columnName, + $parameterName + ); + + $parameters[$parameterName] = $filter( + $input[$inputName] + ); +} + +function getJsonInput(): array +{ + $body = file_get_contents('php://input'); + + if ($body === false || trim($body) === '') { + return []; + } + + try { + $input = json_decode( + $body, + true, + 512, + JSON_THROW_ON_ERROR + ); + } catch (JsonException) { + throw new InvalidArgumentException( + 'Invalid JSON request body.' + ); + } + + if (!is_array($input)) { + throw new InvalidArgumentException( + 'The JSON body must be an object.' + ); + } + + return $input; +} + +function requireInteger( + array $input, + string $name, + int $minimum, + ?int $maximum = null +): int { + if (!array_key_exists($name, $input)) { + throw new InvalidArgumentException( + sprintf('%s is required.', $name) + ); + } + + return boundedInteger( + $input[$name], + $minimum, + $maximum + ); +} + +function boundedInteger( + mixed $value, + int $minimum, + ?int $maximum = null +): int { + if ( + filter_var( + $value, + FILTER_VALIDATE_INT + ) === false + ) { + throw new InvalidArgumentException( + 'An integer value was expected.' + ); + } + + $number = (int) $value; + + if ($number < $minimum) { + throw new InvalidArgumentException( + 'The integer value is too small.' + ); + } + + if ($maximum !== null && $number > $maximum) { + throw new InvalidArgumentException( + 'The integer value is too large.' + ); + } + + return $number; +} + +function nullableString(mixed $value): ?string +{ + if ($value === null) { + return null; + } + + $value = trim((string) $value); + + return $value === '' ? null : $value; +} + +function cleanString( + mixed $value, + string $default +): string { + $value = trim((string) $value); + + return $value === '' ? $default : $value; +} + +function nullableNumber(mixed $value): ?float +{ + if ($value === null || $value === '') { + return null; + } + + if (!is_numeric($value)) { + throw new InvalidArgumentException( + 'A numeric value was expected.' + ); + } + + $number = (float) $value; + + if ($number < 0) { + throw new InvalidArgumentException( + 'The number cannot be negative.' + ); + } + + return $number; +} + +function jsonResponse( + array $data, + int $status = 200 +): never { + http_response_code($status); + + echo json_encode( + $data, + JSON_THROW_ON_ERROR | + JSON_UNESCAPED_SLASHES | + JSON_UNESCAPED_UNICODE + ); + + exit; +} diff --git a/assets/css/kanban.css b/assets/css/kanban.css new file mode 100644 index 0000000..1817ac2 --- /dev/null +++ b/assets/css/kanban.css @@ -0,0 +1,192 @@ +.kanban { + display: flex; + padding: 4px; + background: #e8fcf8; + border-radius: 5px; +} + +.kanban * { + font-family: sans-serif; +} + +.kanban__column { + flex: 1; +} + +.kanban__column:not(:last-child) { + margin-right: 4px; +} + +.kanban__column-title { + margin-bottom: 20px; + font-size: 20px; + color: black; + user-select: none; +} + +.kanban__item { + border-radius: 8px; + border: 1px solid #000; + margin-bottom: 4px +} + +.kanban__item-input { + font-size: 14px; + padding: 10px 5px; + box-sizing: border-box; + border-radius: 5px; + cursor: pointer; +} + +.kanban__dropzone { + height: 20px; + transition: background 0.15s, height 0.15s; +} + +.kanban__dropzone--active { + height: 90px; + background: rgba(0, 0, 0, 0.25); +} + +.kanban__add-item { + float: left; + background-color: #e8fcf8; + font-size: 16px; + color: black; + border: none; + cursor: pointer; +} + +.kanban__hide-item { + float: right; + background-color: #e8fcf8; + font-size: 16px; + color: black; + border: none; + cursor: pointer; +} + +.kanban__number { + float: left; +} + +.kanban__assign { + float: right; +} + +.form-row { + display: flex; + flex-direction: row; + margin: 0.2rem; +} + +.form-row-buttons { + display: flex; + flex-direction: row; + justify-content: space-between; + margin: 0.2rem; +} + +#edit-button, +#save-button, +#cancel-button { + margin: 0.2rem 2px 0.1rem 0rem; + background-color: white; + border-radius: 0.2rem; + width: 49.2%; + border: 0.25rem solid black; + padding: 0.7rem 2.7rem; + border-radius: 0.3rem; + font-size: 1rem; +} + +#delete-button { + margin: 0.2rem 2px 0.1rem 0rem; + background-color: red; + border-radius: 0.2rem; + width: 49.2%; + border: 0.25rem solid black; + padding: 0.5rem 2.7rem; + border-radius: 0.3rem; + font-size: 1rem; +} + +.datetime { + font-size: .65rem; +} + +.color-picker-square { + display: inline-block; + width: 18px; + height: 18px; + margin-right: 5px; + border: 1px solid #000; +} + +.kanban__item.color-yellow, .task-summary-container.color-yellow, .color-picker-square.color-yellow { + background-color: rgb(245, 247, 196); + border-color: rgb(223, 227, 45); +} +.kanban__item.color-blue, .task-summary-container.color-blue, .color-picker-square.color-blue { + background-color: rgb(219, 235, 255); + border-color: rgb(168, 207, 255); +} +.kanban__item.color-green, .task-summary-container.color-green, .color-picker-square.color-green { + background-color: rgb(189, 244, 203); + border-color: rgb(74, 227, 113); +} + +.kanban__item.color-purple, .task-summary-container.color-purple, .color-picker-square.color-purple { + background-color: rgb(223, 176, 255); + border-color: rgb(205, 133, 254); +} + +.kanban__item.color-red, .task-summary-container.color-red, .color-picker-square.color-red { + background-color: rgb(255, 187, 187); + border-color: rgb(255, 151, 151); +} +.kanban__item.color-orange, .task-summary-container.color-orange, .color-picker-square.color-orange { + background-color: rgb(255, 215, 179); + border-color: rgb(255, 172, 98); +} +.kanban__item.color-grey, .task-summary-container.color-grey, .color-picker-square.color-grey { + background-color: rgb(238, 238, 238); + border-color: rgb(204, 204, 204); +} + +.kanban__item.color-brown, .task-summary-container.color-brown, .color-picker-square.color-brown { + background-color: #d7ccc8; + border-color: #4e342e; +} +.kanban__item.color-deep_orange, .task-summary-container.color-deep_orange, .color-picker-square.color-deep_orange { + background-color: #ffab91; + border-color: #e64a19; +} +.kanban__item.color-dark_grey, .task-summary-container.color-dark_grey, .color-picker-square.color-dark_grey { + background-color: #cfd8dc; + border-color: #455a64; +} +.kanban__item.color-pink, .task-summary-container.color-pink, .color-picker-square.color-pink { + background-color: #f48fb1; + border-color: #d81b60; +} +.kanban__item.color-teal, .task-summary-container.color-teal, .color-picker-square.color-teal { + background-color: #80cbc4; + border-color: #00695c; +} +.kanban__item.color-cyan, .task-summary-container.color-cyan, .color-picker-square.color-cyan { + background-color: #b2ebf2; + border-color: #00bcd4; +} +.kanban__item.color-lime, .task-summary-container.color-lime, .color-picker-square.color-lime { + background-color: #e6ee9c; + border-color: #afb42b; +} +.kanban__item.color-light_green, .task-summary-container.color-light_green, .color-picker-square.color-light_green { + background-color: #dcedc8; + border-color: #689f38; +} +.kanban__item.color-amber, .task-summary-container.color-amber, .color-picker-square.color-amber { + background-color: #ffe082; + border-color: #ffa000; +} diff --git a/assets/js/api/KanbanAPI.js b/assets/js/api/KanbanAPI.js new file mode 100644 index 0000000..eb26a19 --- /dev/null +++ b/assets/js/api/KanbanAPI.js @@ -0,0 +1,138 @@ +export default class KanbanAPI { + static endpoint = "./api/tasks.php"; + + static tasks = []; + + static async load() { + const response = await KanbanAPI.request( + KanbanAPI.endpoint + ); + + KanbanAPI.tasks = Array.isArray(response.tasks) + ? response.tasks + : []; + + return KanbanAPI.tasks; + } + + static getItems(columnId) { + const numericColumnId = Number(columnId); + + return KanbanAPI.tasks + .filter( + task => + Number(task.columnId) === numericColumnId + ) + .sort( + (first, second) => + Number(first.position) - + Number(second.position) + ); + } + + static getItem(itemId) { + const numericItemId = Number(itemId); + + return KanbanAPI.tasks.find( + task => Number(task.id) === numericItemId + ) ?? null; + } + + static async insertItem(itemData) { + const response = await KanbanAPI.request( + KanbanAPI.endpoint, + { + method: "POST", + body: JSON.stringify(itemData) + } + ); + + KanbanAPI.tasks.push(response.task); + + return response.task; + } + + static async updateItem(itemId, newProps) { + const response = await KanbanAPI.request( + KanbanAPI.endpoint, + { + method: "PUT", + body: JSON.stringify({ + id: Number(itemId), + ...newProps + }) + } + ); + + const index = KanbanAPI.tasks.findIndex( + task => Number(task.id) === Number(itemId) + ); + + if (index !== -1) { + KanbanAPI.tasks[index] = response.task; + } else { + KanbanAPI.tasks.push(response.task); + } + + /* + * Reloading after a move guarantees that every task has + * the correct position after SQLite reorders the column. + */ + if ( + newProps.columnId !== undefined || + newProps.position !== undefined + ) { + await KanbanAPI.load(); + } + + return response.task; + } + + static async deleteItem(itemId) { + const response = await KanbanAPI.request( + KanbanAPI.endpoint, + { + method: "DELETE", + body: JSON.stringify({ + id: Number(itemId) + }) + } + ); + + KanbanAPI.tasks = KanbanAPI.tasks.filter( + task => Number(task.id) !== Number(itemId) + ); + + return response.success === true; + } + + static async request(url, options = {}) { + const response = await fetch(url, { + ...options, + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + ...(options.headers ?? {}) + } + }); + + let data; + + try { + data = await response.json(); + } catch { + throw new Error( + `The server returned an invalid response (${response.status}).` + ); + } + + if (!response.ok) { + throw new Error( + data.error ?? + `Request failed with status ${response.status}.` + ); + } + + return data; + } +} diff --git a/assets/js/kanban.js b/assets/js/kanban.js new file mode 100644 index 0000000..ae824e0 --- /dev/null +++ b/assets/js/kanban.js @@ -0,0 +1,38 @@ +import Dialog from "./view/Dialog.js"; + +document + .getElementById("cancel-button") + .addEventListener("click", () => { + Dialog.close_task(); + }); + +document + .getElementById("save-button") + .addEventListener("click", () => { + Dialog.save_task(); + }); + +document + .getElementById("edit-button") + .addEventListener("click", () => { + Dialog.edit_task(); + }); + +document + .getElementById("delete-button") + .addEventListener("click", () => { + Dialog.delete_task(); + }); + +document + .getElementById("task-color") + .addEventListener("change", () => { + Dialog.display_color(); + }); + +document + .getElementById("new-task") + .addEventListener("cancel", event => { + event.preventDefault(); + Dialog.close_task(); + }); diff --git a/assets/js/main.js b/assets/js/main.js new file mode 100644 index 0000000..b805a56 --- /dev/null +++ b/assets/js/main.js @@ -0,0 +1,22 @@ +// https://dcode.domenade.com/tutorials/build-a-kanban-board-with-javascript-no-frameworks +// https://github.com/dcode-youtube/kanban-board/blob/main/index.html + +import Kanban from "./view/Kanban.js"; +import KanbanAPI from "./api/KanbanAPI.js"; + +async function startKanban() { + const root = document.querySelector(".kanban"); + + try { + await KanbanAPI.load(); + + new Kanban(root); + } catch (error) { + console.error(error); + + root.textContent = + `Unable to load the Kanban board: ${error.message}`; + } +} + +startKanban(); diff --git a/assets/js/view/Column.js b/assets/js/view/Column.js new file mode 100644 index 0000000..f1d564c --- /dev/null +++ b/assets/js/view/Column.js @@ -0,0 +1,93 @@ +import KanbanAPI from "../api/KanbanAPI.js"; +import DropZone from "./DropZone.js"; +import Item from "./Item.js"; +import Dialog from "./Dialog.js"; + +export default class Column { + constructor(id, title) { + this.id = Number(id); + this.collapsed = false; + + this.elements = {}; + this.elements.root = Column.createRoot(); + this.elements.title = + this.elements.root.querySelector( + ".kanban__column-title" + ); + this.elements.items = + this.elements.root.querySelector( + ".kanban__column-items" + ); + this.elements.addItem = + this.elements.root.querySelector( + ".kanban__add-item" + ); + this.elements.hideItem = + this.elements.root.querySelector( + ".kanban__hide-item" + ); + + this.elements.root.dataset.id = String(this.id); + this.elements.title.textContent = title; + + this.elements.items.appendChild( + DropZone.createDropZone() + ); + + this.elements.addItem.addEventListener("click", () => { + Dialog.new_task(this.id); + }); + + this.elements.hideItem.addEventListener("click", () => { + this.collapsed = !this.collapsed; + this.elements.items.hidden = this.collapsed; + + this.elements.hideItem.setAttribute( + "aria-expanded", + String(!this.collapsed) + ); + }); + + KanbanAPI.getItems(this.id).forEach(item => { + this.renderItem(item); + }); + } + + renderItem(data) { + const item = new Item(data); + this.elements.items.appendChild(item.elements.root); + } + + static createRoot() { + const template = document.createElement("template"); + + template.innerHTML = ` +
+ + + + + + +
+
+ `; + + return template.content.firstElementChild; + } +} diff --git a/assets/js/view/Complexity.js b/assets/js/view/Complexity.js new file mode 100644 index 0000000..ea57b41 --- /dev/null +++ b/assets/js/view/Complexity.js @@ -0,0 +1,20 @@ +export default class Complexity { + static get_complexity(level) { + const complexity = [ + "c-VerySimple", + "c-Basic", + "c-Elementary", + "c-Straightforward", + "c-Moderate", + "c-Intermediate", + "c-Complex", + "c-Challenging", + "c-Advanced", + "c-HighlyAdvanced", + "c-ExtremelyComplex", + ]; + + const numLevel = Number(level); + return complexity[numLevel] ?? "c-Basic"; + } +} diff --git a/assets/js/view/Dialog.js b/assets/js/view/Dialog.js new file mode 100644 index 0000000..781f13a --- /dev/null +++ b/assets/js/view/Dialog.js @@ -0,0 +1,291 @@ +import KanbanAPI from "../api/KanbanAPI.js"; +import Item from "./Item.js"; + +export default class Dialog { + static new_task(status) { + Dialog.reset(); + + const dialog = Dialog.getDialog(); + + document.getElementById("task-status").value = + String(status); + + document.getElementById("save-button").hidden = false; + document.getElementById("edit-button").hidden = true; + document.getElementById("delete-button").hidden = true; + + Dialog.display_color(); + Dialog.open(dialog); + + document.getElementById("task-name").focus(); + } + + static modify_task(itemId) { + const data = KanbanAPI.getItem(itemId); + + if (!data) { + alert("This task could not be found."); + return; + } + + Dialog.reset(); + + Dialog.setValue("edit-id", data.id); + Dialog.setValue("task-assign", data.assign ?? "me"); + Dialog.setValue( + "task-status", + data.columnId ?? data.status + ); + Dialog.setValue("task-name", data.content); + Dialog.setValue("task-description", data.description); + Dialog.setValue("task-tags", data.tags); + Dialog.setValue("task-color", data.color || "yellow"); + Dialog.setValue("task-due", data.due); + Dialog.setValue("task-start", data.start); + Dialog.setValue("task-priority", data.priority ?? 0); + Dialog.setValue("task-complexity", data.complexity ?? 0); + Dialog.setValue("task-estimate", data.estimate); + Dialog.setValue("task-time-spent", data.timeSpent); + + document.getElementById("save-button").hidden = true; + document.getElementById("edit-button").hidden = false; + document.getElementById("delete-button").hidden = false; + + Dialog.display_color(); + Dialog.open(Dialog.getDialog()); + } + + static async edit_task() { + const editId = Dialog.getEditId(); + + if (editId === null) { + alert("No task has been selected for editing."); + return; + } + + const data = Dialog.readForm(); + + try { + const updatedItem = await KanbanAPI.updateItem( + editId, + data + ); + + Dialog.replaceRenderedItem(updatedItem); + Dialog.close_task(); + } catch (error) { + console.error(error); + alert(error.message); + } + } + + static async save_task() { + const data = Dialog.readForm(); + + if (data.content === "") { + alert("Please enter a task name."); + document.getElementById("task-name").focus(); + return; + } + + try { + const newItem = await KanbanAPI.insertItem(data); + + Dialog.appendRenderedItem(newItem); + Dialog.close_task(); + } catch (error) { + console.error(error); + alert(error.message); + } + } + + static async delete_task() { + const editId = Dialog.getEditId(); + + if (editId === null) { + return; + } + + const confirmed = confirm( + "Are you sure you want to delete this item?" + ); + + if (!confirmed) { + return; + } + + const deleted = await KanbanAPI.deleteItem(editId); + + if (deleted) { + const itemElement = document.querySelector( + `.kanban__item[data-id="${editId}"]` + ); + + itemElement?.remove(); + } + + Dialog.close_task(); + } + + static close_task() { + const dialog = Dialog.getDialog(); + + if (dialog.open) { + dialog.close(); + } + + Dialog.reset(); + } + + static readForm() { + const columnId = Number( + document.getElementById("task-status").value + ); + + return { + columnId, + status: columnId, + assign: document.getElementById("task-assign").value, + content: document + .getElementById("task-name") + .value + .trim(), + description: document + .getElementById("task-description") + .value + .trim(), + tags: document + .getElementById("task-tags") + .value + .trim(), + color: document.getElementById("task-color").value, + due: document.getElementById("task-due").value, + start: document.getElementById("task-start").value, + priority: Number( + document.getElementById("task-priority").value + ), + complexity: Number( + document.getElementById("task-complexity").value + ), + estimate: Dialog.numberOrNull("task-estimate"), + timeSpent: Dialog.numberOrNull("task-time-spent") + }; + } + + static appendRenderedItem(data) { + const column = document.querySelector( + `.kanban__column[data-id="${data.columnId}"]` + ); + + if (!column) { + return; + } + + const item = new Item(data); + + column + .querySelector(".kanban__column-items") + .appendChild(item.elements.root); + } + + static replaceRenderedItem(data) { + const existingElement = document.querySelector( + `.kanban__item[data-id="${data.id}"]` + ); + + const targetColumn = document.querySelector( + `.kanban__column[data-id="${data.columnId}"]` + ); + + if (!targetColumn) { + return; + } + + const newItem = new Item(data); + + if ( + existingElement && + existingElement.closest(".kanban__column") === + targetColumn + ) { + existingElement.replaceWith(newItem.elements.root); + return; + } + + existingElement?.remove(); + + targetColumn + .querySelector(".kanban__column-items") + .appendChild(newItem.elements.root); + } + + static reset() { + const fields = [ + "task-name", + "task-description", + "task-tags", + "task-due", + "task-start", + "task-estimate", + "task-time-spent" + ]; + + for (const fieldId of fields) { + document.getElementById(fieldId).value = ""; + } + + Dialog.setValue("edit-id", "NAN"); + Dialog.setValue("task-color", "yellow"); + Dialog.setValue("task-assign", "me"); + Dialog.setValue("task-priority", 0); + Dialog.setValue("task-complexity", 0); + + Dialog.display_color(); + } + + static display_color() { + const colorDisplay = + document.getElementById("color-display"); + + const selectedColor = + document.getElementById("task-color").value; + + colorDisplay.className = + `color-picker-square color-${selectedColor}`; + } + + static getEditId() { + const value = document.getElementById("edit-id").value; + + if (value === "" || value === "NAN") { + return null; + } + + const id = Number(value); + + return Number.isInteger(id) ? id : null; + } + + static getDialog() { + return document.getElementById("new-task"); + } + + static open(dialog) { + if (!dialog.open) { + dialog.showModal(); + } + } + + static setValue(id, value) { + document.getElementById(id).value = + value === undefined || value === null + ? "" + : String(value); + } + + static numberOrNull(id) { + const value = document.getElementById(id).value; + + return value === "" ? null : Number(value); + } +} diff --git a/assets/js/view/DropZone.js b/assets/js/view/DropZone.js new file mode 100644 index 0000000..5e6319f --- /dev/null +++ b/assets/js/view/DropZone.js @@ -0,0 +1,97 @@ +import KanbanAPI from "../api/KanbanAPI.js"; + +export default class DropZone { + static createDropZone() { + const dropZone = document.createElement("div"); + dropZone.className = "kanban__dropzone"; + + dropZone.addEventListener("dragover", event => { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + + dropZone.classList.add( + "kanban__dropzone--active" + ); + }); + + dropZone.addEventListener("dragleave", () => { + dropZone.classList.remove( + "kanban__dropzone--active" + ); + }); + + dropZone.addEventListener("drop", async event => { + event.preventDefault(); + + dropZone.classList.remove( + "kanban__dropzone--active" + ); + + const itemId = Number( + event.dataTransfer.getData("text/plain") + ); + + if (!Number.isInteger(itemId)) { + return; + } + + const columnElement = + dropZone.closest(".kanban__column"); + + if (!columnElement) { + return; + } + + const droppedItemElement = + document.querySelector( + `.kanban__item[data-id="${itemId}"]` + ); + + if (!droppedItemElement) { + return; + } + + if (droppedItemElement.contains(dropZone)) { + return; + } + + const columnId = + Number(columnElement.dataset.id); + + const dropZones = Array.from( + columnElement.querySelectorAll( + ".kanban__dropzone" + ) + ); + + const position = dropZones.indexOf(dropZone); + + try { + const updatedItem = await KanbanAPI.updateItem( + itemId, + { + columnId, + position + } + ); + + const parentItem = + dropZone.closest(".kanban__item"); + + if (parentItem) { + parentItem.after(droppedItemElement); + } else { + dropZone.after(droppedItemElement); + } + + droppedItemElement.dataset.columnId = + String(updatedItem.columnId); + } catch (error) { + console.error(error); + alert(error.message); + } + }); + + return dropZone; + } +} diff --git a/assets/js/view/Item.js b/assets/js/view/Item.js new file mode 100644 index 0000000..f58a6f4 --- /dev/null +++ b/assets/js/view/Item.js @@ -0,0 +1,242 @@ +import DropZone from "./DropZone.js"; +import KanbanAPI from "../api/KanbanAPI.js"; +import Dialog from "./Dialog.js"; +import Priority from "./Priority.js"; +import Complexity from "./Complexity.js"; + +export default class Item { + constructor(data) { + this.data = { ...data }; + this.content = data.content || ""; + + const bottomDropZone = DropZone.createDropZone(); + + this.elements = {}; + this.elements.root = Item.createRoot(); + this.elements.input = + this.elements.root.querySelector( + ".kanban__item-input" + ); + + this.elements.root.dataset.id = String(data.id); + this.elements.root.className = + `kanban__item color-${data.color || "yellow"}`; + + this.elements.root.querySelector( + ".kanban__number" + ).textContent = String(data.id); + + this.elements.root.querySelector( + ".kanban__assign" + ).textContent = data.assign || ""; + + this.elements.input.textContent = this.content; + + this.renderDescription(data.description); + this.renderTags(data.tags); + this.renderDates(data); + this.renderTaskInformation(data); + this.renderComplexity(data.complexity); + this.renderPriority(data.priority); + + this.elements.root.appendChild(bottomDropZone); + + this.elements.input.addEventListener("blur", () => { + this.saveInlineContent(); + }); + + this.elements.root.addEventListener("dblclick", event => { + if (event.target.closest(".kanban__dropzone")) { + return; + } + + Dialog.modify_task(data.id); + }); + + this.elements.root.addEventListener("dragstart", event => { + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData( + "text/plain", + String(data.id) + ); + + this.elements.root.classList.add( + "kanban__item--dragging" + ); + }); + + this.elements.root.addEventListener("dragend", () => { + this.elements.root.classList.remove( + "kanban__item--dragging" + ); + }); + + this.elements.input.addEventListener("drop", event => { + event.preventDefault(); + }); + } + + async saveInlineContent() { + const newContent = + this.elements.input.textContent.trim(); + + if (newContent === this.content) { + return; + } + + if (newContent === "") { + this.elements.input.textContent = this.content; + return; + } + + try { + await KanbanAPI.updateItem(this.data.id, { + content: newContent + }); + + this.content = newContent; + this.data.content = newContent; + } catch (error) { + console.error(error); + this.elements.input.textContent = this.content; + } + } + + renderDescription(description) { + if (!description) { + return; + } + + const indicator = document.createElement("span"); + const icon = document.createElement("i"); + + icon.textContent = "✍"; + icon.title = description; + + indicator.appendChild(icon); + this.elements.root.appendChild(indicator); + } + + renderTags(tags) { + if (!tags) { + return; + } + + const tagElement = document.createElement("span"); + tagElement.className = "kanban__tags"; + tagElement.textContent = tags; + + this.elements.root.appendChild(tagElement); + } + + renderDates(data) { + if (Item.isValidDate(data.start)) { + const startElement = document.createElement("span"); + startElement.className = "date"; + startElement.textContent = + `| Start: ${Item.formatDate(data.start)}`; + + this.elements.root.appendChild(startElement); + } + + if (Item.isValidDate(data.due)) { + const dueElement = document.createElement("span"); + dueElement.className = "datetime"; + dueElement.textContent = + `| Due: ${new Date(data.due).toLocaleString()}`; + + this.elements.root.appendChild(dueElement); + } + } + + renderTaskInformation(data) { + const information = []; + + if (data.estimate !== null && data.estimate !== undefined) { + information.push(`| Estimate: ${data.estimate}h`); + } + + if ( + data.timeSpent !== null && + data.timeSpent !== undefined + ) { + information.push(`| Spent: ${data.timeSpent}h`); + } + + if (information.length === 0) { + return; + } + + const informationElement = + document.createElement("span"); + + informationElement.className = + "kanban__task-information"; + + informationElement.textContent = + information.join(" | "); + + this.elements.root.appendChild(informationElement); + } + + renderPriority(priority) { + const priorityElement = + document.createElement("span"); + + priorityElement.className = "kanban__priority"; + priorityElement.textContent = + Priority.get_priority(Number(priority)); + + this.elements.root.appendChild(priorityElement); + } + + renderComplexity(level) { + const complexityElement = + document.createElement("div"); + + complexityElement.className = "kanban__complexity"; + complexityElement.textContent = + Complexity.get_complexity(Number(level)); + + this.elements.root.appendChild(complexityElement); + } + + static isValidDate(value) { + if (!value) { + return false; + } + + const date = new Date(value); + + return !Number.isNaN(date.getTime()); + } + + static formatDate(value) { + const [year, month, day] = value.split("-"); + + if (!year || !month || !day) { + return value; + } + + return new Date( + Number(year), + Number(month) - 1, + Number(day) + ).toLocaleDateString(); + } + + static createRoot() { + const template = document.createElement("template"); + + template.innerHTML = ` +
+
+
+
+
+ `; + + return template.content.firstElementChild; + } +} diff --git a/assets/js/view/Kanban.js b/assets/js/view/Kanban.js new file mode 100644 index 0000000..1b99879 --- /dev/null +++ b/assets/js/view/Kanban.js @@ -0,0 +1,44 @@ +import Column from "./Column.js"; + +export default class Kanban { + constructor(root) { + if (!root) { + throw new Error("Kanban root element was not found."); + } + + this.root = root; + + const statusSelect = + document.getElementById("task-status"); + + statusSelect.replaceChildren(); + + for (const column of Kanban.columns()) { + const columnView = new Column( + column.id, + column.title + ); + + this.root.appendChild( + columnView.elements.root + ); + + const option = document.createElement("option"); + option.value = String(column.id); + option.textContent = column.title; + + statusSelect.appendChild(option); + } + } + + static columns() { + return [ + { id: 1, title: "Todo/Backlog" }, + { id: 2, title: "Ready" }, + { id: 3, title: "Work in Progress" }, + { id: 4, title: "QA" }, + { id: 5, title: "Completed" }, + { id: 6, title: "Archived" } + ]; + } +} diff --git a/assets/js/view/Priority.js b/assets/js/view/Priority.js new file mode 100644 index 0000000..df0c65a --- /dev/null +++ b/assets/js/view/Priority.js @@ -0,0 +1,21 @@ +export default class Priority { + static get_priority(level) { + const priorities = [ + "p-None", + "p-Low", + "p-Minor", + "p-Moderate", + "p-Medium", + "p-Average", + "p-Important", + "p-High", + "p-Critical", + "p-Top", + "p-Urgent" + ]; + + const numericLevel = Number(level); + + return priorities[numericLevel] ?? "p-None"; + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..21f85b7 --- /dev/null +++ b/index.html @@ -0,0 +1,119 @@ + + + + + + + Kanban Board + + + + + + + New Task + + + + + + + + + + + + + + + + + + + + + + + +    + + + + + + +    + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +