commit
a1be422995
@ -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">✚</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;">✘</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> |
||||
|
||||
<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"> |
||||
|
||||
<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…
Reference in new issue