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