main
Robert 2 hours ago
commit a1be422995
  1. 29
      README.md
  2. 87
      api/database.php
  3. 787
      api/tasks.php
  4. 192
      assets/css/kanban.css
  5. 138
      assets/js/api/KanbanAPI.js
  6. 38
      assets/js/kanban.js
  7. 22
      assets/js/main.js
  8. 93
      assets/js/view/Column.js
  9. 20
      assets/js/view/Complexity.js
  10. 291
      assets/js/view/Dialog.js
  11. 97
      assets/js/view/DropZone.js
  12. 242
      assets/js/view/Item.js
  13. 44
      assets/js/view/Kanban.js
  14. 21
      assets/js/view/Priority.js
  15. 119
      index.html

@ -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:
<span class="form-row">
<label class="form-row-label" for="task-assign">Assignee</label>
<select class="form-row-input" name="task-assign" id="task-assign">
<option value="me">Me</option>
<option value="dev">Developers</option>
<option value="other">Other</option>
</select>
</span>
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" }
];
}
```

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
function getDatabase(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$databaseDirectory = dirname(__DIR__) . '/data';
$databaseFile = $databaseDirectory . '/kanban.sqlite';
if (!is_dir($databaseDirectory)) {
if (!mkdir($databaseDirectory, 0775, true)) {
throw new RuntimeException(
'Unable to create the database directory.'
);
}
}
$pdo = new PDO('sqlite:' . $databaseFile);
$pdo->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
);
}

@ -0,0 +1,787 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/database.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
try {
$pdo = getDatabase();
switch ($_SERVER['REQUEST_METHOD']) {
case 'GET':
getTasks($pdo);
break;
case 'POST':
createTask($pdo);
break;
case 'PUT':
case 'PATCH':
updateTask($pdo);
break;
case 'DELETE':
deleteTask($pdo);
break;
default:
jsonResponse(
['error' => '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;
}

@ -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;
}

@ -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;
}
}

@ -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();
});

@ -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();

@ -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 = `
<div class="kanban__column">
<button
class="kanban__add-item"
type="button"
title="Add task"
aria-label="Add task"
>
<i aria-hidden="true" style="color: blue">&#10010;</i>
</button>
<span class="kanban__column-title"></span>
<button
class="kanban__hide-item"
type="button"
title="Show or hide column"
aria-label="Show or hide column"
aria-expanded="true"
>
<i aria-hidden="true" style="color: black;">&#10008;</i>
</button>
<div class="kanban__column-items"></div>
</div>
`;
return template.content.firstElementChild;
}
}

@ -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";
}
}

@ -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);
}
}

@ -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;
}
}

@ -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 = `
<div class="kanban__item" draggable="true">
<div class="kanban__number"></div>
<div class="kanban__assign"></div>
<div class="kanban__item-input"
contenteditable="true"></div>
</div>
`;
return template.content.firstElementChild;
}
}

@ -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" }
];
}
}

@ -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";
}
}

@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kanban Board</title>
<link rel="stylesheet" href="./assets/css/kanban.css">
<script src="./assets/js/main.js" type="module"></script>
<script defer src="./assets/js/kanban.js" type="module"></script>
</head>
<body>
<dialog id="new-task">
<strong>New Task</strong>
<span class="form-row">
<label class="form-row-label" for="task-name">Task</label>
<input class="form-row-input" type="text" name="task-name" id="task-name">
</span>
<span class="form-row">
<label class="form-row-label" for="task-description">Description</label>
<textarea class="form-row-input" name="task-description" id="task-description" cols="70" rows="10"></textarea>
</span>
<span class="form-row">
<label class="form-row-label" for="task-tags">Tags</label>
<input class="form-row-input" type="text" name="task-tags" id="task-tags">
</span>
<span class="form-row">
<label class="form-row-label" for="task-color"><span id="color-display"></span>Color</label>
<select name="task-color" id="task-color" class="form-row-input">
<option value="yellow">Yellow</option>
<option value="blue">Blue</option>
<option value="green">Green</option>
<option value="purple">Purple</option>
<option value="red">Red</option>
<option value="orange">Orange</option>
<option value="grey">Grey</option>
<option value="brown">Brown</option>
<option value="deep_orange">Deep Orange</option>
<option value="dark_grey">Dark Grey</option>
<option value="pink">Pink</option>
<option value="teal">Teal</option>
<option value="cyan">Cyan</option>
<option value="lime">Lime</option>
<option value="light_green">Light Green</option>
<option value="amber">Amber</option>
</select>
</span>
<span class="form-row">
<label class="form-row-label" for="task-assign">Assignee</label>
<select class="form-row-input" name="task-assign" id="task-assign">
<option value="me">Me</option>
<option value="dev">Developers</option>
<option value="other">Other</option>
</select>
</span>
<span class="form-row">
<label class="form-row-label" for="task-priority">Priority</label>
<select class="form-row-input" name="task-priority" id="task-priority">
<option value="0">Not a Priority</option>
<option value="1">Low Priority</option>
<option value="2">Minor Priority</option>
<option value="3">Moderate Priority</option>
<option value="4">Medium Priority</option>
<option value="5">Average Priority</option>
<option value="6">Important Priority</option>
<option value="7">High Priority</option>
<option value="8">Critical Priority</option>
<option value="9">Top Priority</option>
<option value="10">Urgent Priority</option>
</select>
&nbsp;&nbsp;
<label class="form-row-label" for="task-complexity">Complexity</label>
<select class="form-row-input" name="task-complexity" id="task-complexity">
<option value="0">Very Simple</option>
<option value="1">Basic</option>
<option value="2">Elementary</option>
<option value="3">Straightforward</option>
<option value="4">Moderate</option>
<option value="5">Intermediate</option>
<option value="6">Complex</option>
<option value="7">Challenging</option>
<option value="8">Advanced</option>
<option value="9">Highly Advanced</option>
<option value="10">Extremely Complex</option>
</select>
</span>
<span class="form-row">
<label class="form-row-label" for="task-due">Due Date</label>
<input class="form-row-input" type="datetime-local" name="task-due" id="task-due">
&nbsp;&nbsp;
<label class="form-row-label" for="task-start">Start Date</label>
<input class="form-row-input" type="date" name="task-start" id="task-start">
</span>
<span class="form-row">
<label class="form-row-label" for="task-estimate">Original estimate in hours</label>
<input class="form-row-input" type="number" min="0" max="9999" name="task-estimate" id="task-estimate" style="width: 4em">
</span>
<span class="form-row">
<label class="form-row-label" for="task-time-spent">Time spent in hours</label>
<input class="form-row-input" type="number" min="0" max="9999" name="task-time-spent" id="task-time-spent" style="width: 4em">
</span>
<span class="form-row">
<label class="form-row-label" for="task-status">Column</label>
<select class="form-row-input" name="task-status" id="task-status">
</select>
</span>
<span class="form-row-buttons">
<input type="hidden" id="edit-id" value="NAN" />
<button type="button" id="delete-button" hidden>Delete</button>
<button type="button" id="cancel-button">Cancel</button>
<button type="button" id="save-button">Save</button>
<button type="button" id="edit-button" hidden>Update</button>
</span>
</dialog>
<div class="kanban"></div>
</body>
</html>
Loading…
Cancel
Save