Compare commits

..

8 Commits

59 changed files with 1065 additions and 502 deletions

View File

@ -6,6 +6,6 @@ class HomeController extends \BaseController
{
public function index($f3)
{
$this->renderView('/ui/views/admin/index.html');
$this->renderView('views/admin/index.html');
}
}

View File

@ -2,8 +2,13 @@
namespace Admin;
use CheckCSRF;
class TicketOptionsController extends \BaseController
{
use CheckCSRF;
public function listPriorities()
{
$this->requireLogin();
@ -12,7 +17,7 @@ class TicketOptionsController extends \BaseController
$model = new \TicketPriority($this->getDB());
$priorities = $model->findAll();
$this->renderView('/ui/views/admin/priorities/index.html', [
$this->renderView('views/admin/priorities/index.html', [
'priorities' => $priorities
]);
}
@ -21,13 +26,15 @@ class TicketOptionsController extends \BaseController
{
$this->requireLogin();
$this->requireAdmin(); // Added admin check
$this->renderView('/ui/views/admin/priorities/create.html');
$this->renderView('views/admin/priorities/create.html');
}
public function createPriority()
public function createPriority($f3)
{
$this->requireLogin();
$this->requireAdmin(); // Added admin check
$this->checkCSRF($f3, '/admin/priority/create');
$p = new \TicketPriority($this->getDB());
$p->name = $this->f3->get('POST.name');
$p->sort_order = $this->f3->get('POST.sort_order');
@ -51,7 +58,7 @@ class TicketOptionsController extends \BaseController
return;
}
$this->renderView('/ui/views/admin/priorities/edit.html', [
$this->renderView('views/admin/priorities/edit.html', [
'priority' => $priority
]);
}
@ -60,6 +67,7 @@ class TicketOptionsController extends \BaseController
{
$this->requireLogin();
$this->requireAdmin();
$this->checkCSRF($f3, '/admin/priority/', $params['id'] . '/edit');
$priorityId = $params['id'];
$model = new \TicketPriority($this->getDB());

View File

@ -2,7 +2,7 @@
class AttachmentController {
use RequiresAuth;
use RequiresAuth, CheckCSRF;
// list attachments
public function index($f3){
@ -25,14 +25,15 @@ class AttachmentController {
$f3->set('ticket_id', $ticket_id);
$f3->set('attachments', $attachments);
$f3->set('content', '../ui/views/attachment/index.html');
// echo \Template::instance()->render('../ui/templates/layout.html');
$f3->set('content', 'views/attachment/index.html');
// echo \Template::instance()->render('templates/layout.html');
echo \Template::instance()->render($f3->get('content'));
}
// handle file upload
public function upload($f3){
$this->check_access($f3);
$this->checkCSRF($f3, '/ticket/'.$f3->get('PARAMS.id')); // not ideal for AJAX
$ticket_id = (int) $f3->get('PARAMS.id');
$uploaded_by = $f3->get('SESSION.user.id');
@ -80,6 +81,9 @@ class AttachmentController {
);
$f3->reroute('/ticket/'.$ticket_id.'');
// ideal ajax response:
// $f3->json(['success' => true, 'message' => 'file upload success.', 'filename' => $original_name, 'version' => $new_version]);
}
// download attachment

View File

@ -2,6 +2,7 @@
class AuthController {
use CheckCSRF;
public function showLoginForm($f3){
@ -10,12 +11,15 @@ class AuthController {
$f3->clear('SESSION.login_error');
// this can be in our controller base
$f3->set('content', '../ui/views/login.html');
echo \Template::instance()->render('../ui/templates/layout.html');
$f3->set('content', 'views/login.html');
echo \Template::instance()->render('templates/layout.html');
$f3->clear('error');
}
public function login($f3){
// CSRF
$this->checkCSRF($f3, '/login');
$username = $f3->get('POST.username');
$password = $f3->get('POST.password');

View File

@ -66,7 +66,7 @@ abstract class BaseController
$this->f3->set('content', $viewPath);
// render tempalte
echo \Template::instance()->render('../ui/templates/layout.html');
echo \Template::instance()->render('templates/layout.html');
// clear SESSION.error
$this->f3->clear('SESSION.error');

View File

@ -2,6 +2,8 @@
class CommentController {
use CheckCSRF;
/**
* Add a new comment to a ticket.
* Expects POST data: comment (text)
@ -13,6 +15,8 @@ class CommentController {
$f3->reroute('/login');
}
$this->checkCSRF($f3, '/ticket/' . $f3->get('PARAMS.id'));
$ticket_id = (int) $f3->get('PARAMS.id');
$comment_text = $f3->get('POST.comment');
$current_user_id = $f3->get('SESSION.user.id');
@ -86,6 +90,6 @@ class CommentController {
$comments = $results;
$f3->set('comments', $comments);
echo \Template::instance()->render('../ui/views/comments/view.html');
echo \Template::instance()->render('views/comments/view.html');
}
}

View File

@ -6,6 +6,6 @@ class DashboardController extends BaseController {
$this->requireLogin();
$this->renderView('/ui/views/dashboard.html');
$this->renderView('views/dashboard.html');
}
}

View File

@ -4,7 +4,7 @@ class HomeController extends BaseController {
public function display($f3){
$this->renderView('/ui/views/home.html');
$this->renderView('views/home.html');
}
// ...

View File

@ -2,7 +2,7 @@
class KBController implements CRUD {
use RequiresAuth;
use RequiresAuth, CheckCSRF;
public function index($f3){
@ -39,8 +39,8 @@ class KBController implements CRUD {
// render
$f3->set('articles', $articles);
$f3->set('content', '../ui/views/kb/index.html');
echo \Template::instance()->render('../ui/templates/layout.html');
$f3->set('content', 'views/kb/index.html');
echo \Template::instance()->render('templates/layout.html');
$f3->clear('SESSION.error');
}
@ -56,8 +56,8 @@ class KBController implements CRUD {
$f3->set('all_tags', $all_tags);
// render
$f3->set('content', '../ui/views/kb/create.html');
echo \Template::instance()->render('../ui/templates/layout.html');
$f3->set('content', 'views/kb/create.html');
echo \Template::instance()->render('templates/layout.html');
$f3->clear('SESSION.error');
}
@ -66,6 +66,8 @@ class KBController implements CRUD {
public function create($f3){
$this->check_access($f3);
$this->checkCSRF($f3, '/kb/create');
$title = $f3->get('POST.title');
$content = $f3->get('POST.content');
$created_by = $f3->get('SESSION.user.id');
@ -120,8 +122,8 @@ class KBController implements CRUD {
);
// render
$f3->set('content', '../ui/views/kb/view.html');
echo \Template::instance()->render('../ui/templates/layout.html');
$f3->set('content', 'views/kb/view.html');
echo \Template::instance()->render('templates/layout.html');
$f3->clear('SESSION.error');
}
@ -150,8 +152,8 @@ class KBController implements CRUD {
// render
$f3->set('js', 'kb_edit.js');
$f3->set('content', '../ui/views/kb/edit.html');
echo \Template::instance()->render('../ui/templates/layout.html');
$f3->set('content', 'views/kb/edit.html');
echo \Template::instance()->render('templates/layout.html');
$f3->clear('SESSION.error');
}
@ -160,7 +162,10 @@ class KBController implements CRUD {
* Handle POST to edit existing article
*/
public function update($f3){
$this->check_access($f3);
$this->checkCSRF($f3, '/kb/' . $f3->get('PARAMS.id') . '/edit');
$article_id = $f3->get('PARAMS.id');
$db = $f3->get('DB');

View File

@ -16,8 +16,8 @@
$f3->set('projects', $projects);
$f3->set('content', '../ui/views/project/index.html');
echo \Template::instance()->render('../ui/templates/layout.html');
$f3->set('content', 'views/project/index.html');
echo \Template::instance()->render('templates/layout.html');
$f3->clear('SESSION.error');
}
@ -25,8 +25,8 @@
// create a new project
public function createForm($f3){
$this->check_access($f3);
$f3->set('content', '../ui/views/project/create.html');
echo \Template::instance()->render('../ui/templates/layout.html');
$f3->set('content', 'views/project/create.html');
echo \Template::instance()->render('templates/layout.html');
}
@ -47,16 +47,16 @@
$project = $result[0];
$f3->set('project', $project);
$f3->set('content', '../ui/views/project/view.html');
echo \Template::instance()->render('../ui/templates/layout.html');
$f3->set('content', 'views/project/view.html');
echo \Template::instance()->render('templates/layout.html');
}
// update project details
public function editForm($f3){
$this->check_access($f3);
$f3->set('content', '../ui/views/project/edit.html');
echo \Template::instance()->render('../ui/templates/layout.html');
$f3->set('content', 'views/project/edit.html');
echo \Template::instance()->render('templates/layout.html');
}

View File

@ -2,7 +2,7 @@
class TagController implements CRUD {
use RequiresAuth;
use RequiresAuth, CheckCSRF;
/**
* List all tags
@ -14,19 +14,20 @@ class TagController implements CRUD {
$tags = $db->exec('SELECT * FROM tags ORDER BY name ASC');
$f3->set('tags', $tags);
$f3->set('content', '../ui/views/tag/index.html');
echo \Template::instance()->render('../ui/templates/layout.html');
$f3->set('content', 'views/tag/index.html');
echo \Template::instance()->render('templates/layout.html');
}
public function createForm($f3){
$this->check_access($f3);
$f3->set('content', '../ui/views/tag/create.html');
echo \Template::instance()->render('../ui/templates/layout.html');
$f3->set('content', 'views/tag/create.html');
echo \Template::instance()->render('templates/layout.html');
}
public function create($f3){
$this->check_access($f3);
$this->checkCSRF($f3, '/tag/create');
$name = $f3->get('POST.name');
$color = $f3->get('POST.color');

View File

@ -2,8 +2,12 @@
class ThemeController
{
use CheckCSRF;
function toggle($f3)
{
$this->checkCSRF($f3, $f3->get('HEADERS.Referer') ?: '/');
$current = $f3->get('SESSION.theme') ?: 'light';
$new_theme = ($current === 'light') ? 'dark' : 'light';
$f3->set('SESSION.theme', $new_theme);

View File

@ -2,7 +2,7 @@
class TicketController extends BaseController implements CRUD {
use RequiresAuth;
use RequiresAuth, CheckCSRF;
// list all tickts
public function index($f3){
@ -21,7 +21,7 @@ class TicketController extends BaseController implements CRUD {
}
// render
$this->renderView('../ui/views/ticket/index.html',
$this->renderView('views/ticket/index.html',
['tickets' => $tickets]
);
@ -37,14 +37,35 @@ class TicketController extends BaseController implements CRUD {
$ticket_mapper = new Ticket($this->getDB());
$ticket = $ticket_mapper->findById($ticket_id);
if(!$ticket){
$this->f3->set('SESSION.error', 'Ticket not found');
$this->f3->reroute('/tickets');
return;
}
$assigned_user = $ticket->getAssignedUser();
$ticket_history = $ticket->getHistory();
$map_statuses = array_column((new TicketStatus($this->getDB()))->findAll(), 'name', 'id');
$map_priorities = array_column((new TicketStatus($this->getDB()))->findAll(), 'name', 'id');
$map_users = array_column($this->getDB()->exec('SELECT id, display_name FROM users'), 'display_name', 'id');
// render
$this->renderView('../ui/views/ticket/view.html', [
$this->renderView('views/ticket/view.html', [
'ticket' => $ticket,
'assigned_user' => $assigned_user,
'attachments' => $ticket->attachments(),
'comments' => $ticket->comments(),
'parent_tickets' => $ticket->getParentTickets(),
'child_tickets' => $ticket->getChildTickets(),
'ticket_meta' => $ticket->getMetaAssoc()
'ticket_meta' => $ticket->getMetaAssoc(),
'ticket_history' => $ticket_history,
'map' => [
'statuses' => $map_statuses,
'priorities' => $map_priorities,
'users' => $map_users
]
]);
}
@ -56,10 +77,19 @@ class TicketController extends BaseController implements CRUD {
$priorities = (new TicketPriority($db))->findAll();
$statuses = (new TicketStatus($db))->findAll();
$all_tags_model = new \Tag($this->getDB());
$all_tags = $all_tags_model->find([], ['order' => 'name ASC']); // get all tags
// TODO: this needs moving into a model?
$users = $this->getDB()->exec('SELECT id, username, display_name FROM users ORDER BY display_name ASC');
$users = array_merge([['id'=>'-1', 'display_name'=>'--']], $users);
$this->requireLogin();
$this->renderView('../ui/views/ticket/create.html',[
$this->renderView('views/ticket/create.html',[
'priorities' => $priorities,
'statuses' => $statuses
'statuses' => $statuses,
'users' => $users,
'all_tags' => $all_tags
]);
}
@ -68,6 +98,7 @@ class TicketController extends BaseController implements CRUD {
public function create($f3){
$this->requireLogin();
$this->checkCSRF($f3, '/ticket/create');
$data = [
'title' => $this->f3->get('POST.title'),
@ -75,18 +106,29 @@ class TicketController extends BaseController implements CRUD {
'description' => $this->f3->get('POST.description'),
'priority_id' => $this->f3->get('POST.priority_id'),
'status_id' => $this->f3->get('POST.status_id'),
'created_by' => $this->f3->get('SESSION.user.id')
'created_by' => $this->f3->get('SESSION.user.id'),
'assigned_to' => $this->f3->get('POST.assigned_to') == '-1' ? null : $this->f3->get('POST.assigned_to')
];
$ticket_mapper = new Ticket($this->getDB());
$new_ticket_id = $ticket_mapper->createTicket($data);
// custom field
$meta_keys = $this->f3->get('POST.meta_key');
$meta_values = $this->f3->get('POST.meta_value');
$meta_assoc = $ticket_mapper->assocMetaFromKeyValue($meta_keys, $meta_values);
$ticket_mapper->setCustomFields($meta_assoc);
// $meta_keys = $this->f3->get('POST.meta_key');
// $meta_values = $this->f3->get('POST.meta_value');
// $meta_assoc = $ticket_mapper->assocMetaFromKeyValue($meta_keys, $meta_values);
// $ticket_mapper->setCustomFields($meta_assoc);
$new_ticket = $ticket_mapper->findById($new_ticket_id);
if($new_ticket){
// TAG handling for create
$posted_tags = $this->f3->get('POST.tags');
if(!empty($posted_tags) && is_array($posted_tags)){
$new_ticket->setTags($posted_tags);
}
}
$this->f3->set('SESSION.message', 'Ticket #' . $new_ticket_id . ' created successfully.');
$this->f3->reroute('/ticket/' . $new_ticket_id);
}
@ -112,12 +154,29 @@ class TicketController extends BaseController implements CRUD {
// dropdowns
$priorities = (new TicketPriority($this->getDB()))->findAll();
$statuses = (new TicketStatus($this->getDB()))->findAll();
$all_tags_model = new \Tag($this->getDB());
$all_tags = $all_tags_model->find([], ['order' => 'name ASC']);
$this->renderView('../ui/views/ticket/edit.html',[
// TODO: this needs moving into a model?
$users = $this->getDB()->exec('SELECT id, username, display_name FROM users ORDER BY display_name ASC');
$users = array_merge([['id'=>'-1', 'display_name'=>'--']], $users);
// paradox - empty($ticket->tags) was returning true, when there were items present
$current_ticket_tag_ids = [];
if(count($ticket->tags) > 0){
foreach($ticket->tags as $current_tag_data){
$current_ticket_tag_ids[] = $current_tag_data['id'];
}
}
$this->renderView('views/ticket/edit.html',[
'ticket' => $ticket,
'ticket_meta' => $ticket->getMeta(),
'priorities' => $priorities,
'statuses' => $statuses
'statuses' => $statuses,
'users' => $users,
'all_tags' => $all_tags,
'current_ticket_tag_ids' => $current_ticket_tag_ids
]
);
return;
@ -128,38 +187,49 @@ class TicketController extends BaseController implements CRUD {
{
$this->requireLogin();
$this->checkCSRF($f3, '/ticket/create');
$ticket_id = $this->f3->get('PARAMS.id');
$ticket_id = $f3->get('PARAMS.id');
$ticket_mapper = new Ticket($this->getDB());
$ticket = $ticket_mapper->findById($ticket_id);
if(!$ticket){
$this->f3->set('SESSION.error', 'Ticket not found.');
$this->f3->reroute('/tickets');
$f3->set('SESSION.error', 'Ticket not found.');
$f3->reroute('/tickets');
}
$data = [
'title' => $this->f3->get('POST.title'),
'created_at' => $this->f3->get('POST.created_at'),
'description' => $this->f3->get('POST.description'),
'priority_id' => $this->f3->get('POST.priority_id'),
'status_id' => $this->f3->get('POST.status_id'),
'updated_by' => $this->f3->get('SESSION.user.id')
'title' => $f3->get('POST.title'),
'created_at' => $f3->get('POST.created_at'),
'description' => $f3->get('POST.description'),
'priority_id' => $f3->get('POST.priority_id'),
'status_id' => $f3->get('POST.status_id'),
'updated_by' => $f3->get('SESSION.user.id') ,
'assigned_to' => $f3->get('POST.assigned_to') == -1 ? null : $f3->get('POST.assigned_to')
];
$ticket->updateTicket($data);
// deal with meta data / custom fields
$meta_keys = $this->f3->get('POST.meta_key');
$meta_values = $this->f3->get('POST.meta_value');
$meta_assoc = $ticket->assocMetaFromKeyValue($meta_keys, $meta_values);
$ticket->setCustomFields($meta_assoc);
// $meta_keys = $this->f3->get('POST.meta_key');
// $meta_values = $this->f3->get('POST.meta_value');
// $meta_assoc = $ticket->assocMetaFromKeyValue($meta_keys, $meta_values);
// $ticket->setCustomFields($meta_assoc);
$posted_tags = $f3->get('POST.tags');
if(is_array($posted_tags)){
$ticket->setTags($posted_tags);
} elseif (empty($posted_tags)){
$ticket->setTags([]);
}
$f3->set('SESSION.message', 'Ticket #' . $ticket_id . ' updated successfully.') ;
$f3->reroute('/ticket/' . $ticket_id);
}
// subtask
public function addSubtask($f3){
$this->requireLogin();
$this->checkCSRF($f3, '/ticket/create');
$parent_id = (int) $f3->get('PARAMS.id');
$child_id = (int) $f3->get('POST.child_ticket_id');

View File

@ -2,7 +2,7 @@
class UserController implements CRUD {
use RequiresAuth;
use RequiresAuth, CheckCSRF;
// list all users (admin only)
@ -19,8 +19,8 @@ class UserController implements CRUD {
);
$f3->set('users', $users);
$f3->set('content', '../ui/views/user/index.html');
echo \Template::instance()->render('../ui/templates/layout.html');
$f3->set('content', 'views/user/index.html');
echo \Template::instance()->render('templates/layout.html');
}
public function editForm($f3){
@ -37,13 +37,14 @@ class UserController implements CRUD {
$f3->reroute('/users');
}
$f3->set('edit_user', $rows[0]);
$f3->set('content', '../ui/views/user/edit.html');
echo \Template::instance()->render('../ui/templates/layout.html');
$f3->set('content', 'views/user/edit.html');
echo \Template::instance()->render('templates/layout.html');
}
public function update($f3){
$this->check_access($f3);
$this->checkCSRF($f3, '/user/' . $f3->get('PARAMS.id') . '/edit');
$user_id = (int) $f3->get('PARAMS.id');
$new_username = $f3->get('POST.username');

26
app/debug.php Normal file
View File

@ -0,0 +1,26 @@
<?php
function debug_print($obj)
{
printf('<pre>%s</pre>', print_r($obj, 1));
}
function debug_var_dump($obj)
{
printf('<pre>%s</pre>', var_dump_str($obj));
}
function var_dump_str()
{
$argc = func_num_args();
$argv = func_get_args();
if ($argc > 0) {
ob_start();
call_user_func_array('var_dump', $argv);
$result = ob_get_contents();
ob_end_clean();
return $result;
}
return '';
}

View File

@ -14,7 +14,11 @@ class BulmaFormHelper extends \Prefab {
static public function render($node) {
$attr = $node['@attrib'] ?? [];
$type = strtoupper($attr['type']) ?? null;
if(isset($attr['type'])){
$type = strtoupper($attr['type']);
} else {
$type = null;
}
// all *
$label = $attr['label'] ?? '';

View File

@ -0,0 +1,36 @@
<?php
class CSRFHelper {
const TOKEN_NAME = 'csrf_token';
public static function token():string {
$f3 = \Base::instance();
if(!$f3->exists('SESSION.' . self::TOKEN_NAME)) {
$token = bin2hex(random_bytes(32));
$f3->set('SESSION.' . self::TOKEN_NAME, $token);
}
return $f3->get('SESSION.' . self::TOKEN_NAME);
}
public static function verify(?string $submitted_token): bool {
$f3 = \Base::instance();
$session_token = $f3->get('SESSION.' . self::TOKEN_NAME);
if(empty($submitted_token) || empty($session_token)){
return false;
}
if(hash_equals($session_token, $submitted_token)){
$f3->clear('SESSION.' . self::TOKEN_NAME);
return true;
}
return false;
}
public static function field(): string {
return '<input type="hidden" name="'.self::TOKEN_NAME.'" value="'.self::token().'">';
}
}

View File

@ -31,7 +31,29 @@ class IconsHelper extends \Prefab {
static public function icons($node){
// debug_print($node);
$required = ['type', 'path'];
$check = self::checkAttributes($node, $required);
if(!is_null($check)){
return sprintf('<div class="notification is-danger is-light">%s</div>', $check);
}
$attr = $node['@attrib'];
$type = $attr['type'];
$path = $attr['path'];
$selected = $attr['selected'];
switch($attr['type']){
case 'status-selector':
// $selected = Base::instance()->get('GET.status') ?: null;
return '<?php echo \IconsHelper::instance()->renderStatusSelector(
Base::instance()->get("GET.status") ?: null, "'.$path.'"); ?>';
return self::renderStatusSelector($selected, $path);
default:
return '<div class="notification">unknown icon selector type</div>';
}
$tpl = Template::instance();
$f3 = Base::instance();
@ -43,6 +65,42 @@ class IconsHelper extends \Prefab {
}
private static function checkAttributes($node, array $required){
if(isset($node['@attrib'])){
$attr = $node['@attrib'];
$errors = [];
foreach($required as $key){
if(empty($attr[$key])){
$errors[] = "Error: '$key' is missing.";
}
}
return empty($errors) ? null : implode(" ", $errors);
}
return "Error: '@attrib' is missing";
}
public static function renderStatusSelector($current_status, $path)
{
$output = '<div class="block"><div class="field has-addons">';
foreach (self::$status_icons as $k => $icon) {
$active = ($current_status == $k);
$url = $path . ($active ? '' : '/?status=' . $k);
$class = 'button' . ($active ? ' is-inverted' : '');
$output .= '<p class="control">';
$output .= '<a href="' . $url . '" class="' . $class . '">';
$output .= '<span class="icon is-small"><i class="fas fa-' . $icon[0] . '"></i></span>';
$output .= '<span>' . self::$status_names[$k] . '</span>';
$output .= '</a>';
$output .= '</p>';
}
$output .= '</div></div>';
return $output;
}
static function do_the_switch($type, $value){
if($value !== null) {

View File

@ -63,6 +63,7 @@ class Ticket extends \DB\SQL\Mapper {
public function getTagsForTickets(array $tickets)
{
if(empty($tickets)) return [];
$tag_mapper = new Tag($this->db, 'ticket');
$tickets = $tag_mapper->getTagsFor($tickets);
@ -74,8 +75,34 @@ class Ticket extends \DB\SQL\Mapper {
$this->status_name = 'SELECT name FROM ticket_statuses WHERE tickets.status_id = ticket_statuses.id';
$this->priority_name = 'SELECT name FROM ticket_priorities WHERE tickets.priority_id = ticket_priorities.id';
$this->load(['id = ?', $id]);
$this->tags = (new Tag($this->db,'ticket'))->getTagsForID($id, 'ticket_id');
return $this->dry() ? null : $this;
if($this->dry()){
return null;
}
$tag_model = new Tag($this->db, 'ticket');
$this->tags = $tag_model->getTagsForID($this->id, 'ticket_id');
return $this;
}
public function setTags(array $tags_ids):void {
if($this->dry() || !$this->id){
// can't set tags for a ticket that hasn't been saved or loaded
return;
}
// remove existing tags - TODO: shouldn't this be in the tag model?
$this->db->exec('DELETE FROM ticket_tags WHERE ticket_id = ?', [$this->id]);
if(!empty($tags_ids)){
$sql_insert_tag = 'INSERT INTO ticket_tags (ticket_id, tag_id) VALUES (?,?)';
foreach($tags_ids as $tag_id){
if(filter_var($tag_id, FILTER_VALIDATE_INT)){
$this->db->exec($sql_insert_tag, [$this->id, (int)$tag_id]);
}
}
}
// refresh tags
$tag_model = new Tag($this->db, 'ticket');
$this->tags = $tag_model->getTagsForID($this->id, 'ticket_id');
}
public function createTicket(array $data): int
@ -93,18 +120,30 @@ class Ticket extends \DB\SQL\Mapper {
$this->updated_at = date('Y-m-d H:i:s');
$this->save();
$this->logCreate();
return (int)$this->id;
}
public function updateTicket(array $data): void
{
$this->logDiff($data);
if(isset($data['title'])){ $this->title = $data['title']; }
if(isset($data['description'])) { $this->description = $data['description']; }
if(isset($data['priority_id'])) { $this->priority_id = $data['priority_id']; }
if(isset($data['status_id'])) { $this->status_id = $data['status_id']; }
if(isset($data['updated_by'])) { $this->updated_by = $data['updated_by']; }
if(isset($data['assigned_to'])) {
if($data['assigned_to'] == '-1'){
$this->assigned_to = null;
} else {
$this->assigned_to = $data['assigned_to'];
}
}
$this->created_at = ($data['created_at'] == '' ? date('Y-m-d H:i:s') : $data['created_at']) ?? date('Y-m-d H:i:s');
$this->updated_at = date('Y-m-d H:i:s');
$this->save();
}
@ -220,4 +259,93 @@ class Ticket extends \DB\SQL\Mapper {
}
}
public function getAssignedUser()
{
if(!$this->assigned_to){
return null;
}
$sql = '
SELECT id, username, display_name
FROM users
WHERE id =?
';
$user = $this->db->exec($sql, [$this->assigned_to]);
return $user[0];
}
/**
* Logs a change to the ticket history.
* @param int $user_id - the ID of the user making the change.
* @param string $field_changed - the name of the field that was changed.
* @param string|null $old_value - the old value
* @param string|null $new_value - the new value
*/
public function logHistory(int $user_id, string $field_changed,
$old_value = null, $new_value = null,
$changed_at = null): void
{
if($this->dry() || !$this->id) return;
$history = new \DB\SQL\Mapper($this->db, 'ticket_history');
$history->ticket_id = $this->id;
$history->user_id = $user_id;
$history->field_changed = $field_changed;
$history->old_value = $old_value === null ? null : (string)$old_value;
$history->new_value = $new_value === null ? null : (string)$new_value;
if($changed_at == null){
$history->changed_at = date('Y-m-d H:i:s');
} else {
$history->changed_at = $changed_at;
}
$history->save();
}
public function getHistory(): array {
if($this->dry() || !$this->id) return[];
$sql = 'SELECT th.*, u.display_name as user_display_name
FROM ticket_history th
JOIN users u ON th.user_id = u.id
WHERE th.ticket_id = ?
ORDER BY th.changed_at DESC';
return $this->db->exec($sql, [$this->id]);
}
/**
* called from create
*/
public function logCreate(){
$changed_at = date('Y-m-d H:i:s');
$user_making_change = \Base::instance()->get('SESSION.user.id');
$this->logHistory($user_making_change, 'ticket_created', null, $this->title, $changed_at);
if($this->status_id) $this->logHistory($user_making_change, 'status_id', null, $this->status_id, $changed_at);
if($this->priority_id) $this->logHistory($user_making_change, 'priority_id', null, $this->priority_id, $changed_at);
if($this->assigned_to) $this->logHistory($user_making_change, 'assigned_to', null, $this->assigned_to, $changed_at);
}
/**
* called from update
*/
public function logDiff($data){
$user_making_change = \Base::instance()->get('SESSION.user.id');
$changed_at = date('Y-m-d H:i:s');
$checks = ['title', 'description', 'priority_id', 'status_id', 'assigned_to', 'updated_by'];
// loop instead
foreach($checks as $check){
if(isset($data[$check]) && $this->$check != $data[$check]){
if($check == 'description'){
$old_hash = hash('sha256', $this->$check);
$new_hash = hash('sha256', $data[$check]);
$this->logHistory($user_making_change, $check, $old_hash, $new_hash, $changed_at);
} else {
$this->logHistory($user_making_change, $check, $this->$check, $data[$check], $changed_at);
}
}
}
}
}

13
app/traits/CheckCSRF.php Normal file
View File

@ -0,0 +1,13 @@
<?php
trait CheckCSRF {
public function checkCSRF($f3, $reroute){
if(!\CSRFHelper::verify($f3->get('POST.' . \CSRFHelper::TOKEN_NAME))){
$f3->set('SESSION.error', 'CSRF token validation failed.');
$f3->reroute($reroute);
return;
}
}
}

View File

@ -5,15 +5,15 @@ This issue list defines the work required to bring the `tp_servicedesk` Fat-Free
---
## 🧩 Ticketing System
- [ ] Display assigned user in ticket view
- [ ] Add user assignment capability in ticket create/edit forms
- [x] Display assigned user in ticket view
- [x] Add user assignment capability in ticket create/edit forms
- [ ] Implement ticket filtering (by status, assignee, project)
- [ ] Improve UI feedback for ticket soft-delete
- [ ] Ensure metadata, attachments, and comments update properly on edit
- [ ] Add tag support for tickets (UI and DB updates)
- [ ] Implement ticket history for status/priority changes
- [ ] Add comment thread display on ticket edit view
- [ ] Update "new ticket" template to match the edit form
- [x] Update "new ticket" template to match the edit form
- [ ] Enable linking tickets using markdown shortcodes (e.g. `#ticket-id` autocomplete)
---
@ -65,8 +65,8 @@ This issue list defines the work required to bring the `tp_servicedesk` Fat-Free
- [ ] Create fake/test data seeder
- [ ] Add DB integrity checks for tickets, users, and project FK links
- [ ] Manually test:
- [ ] Login/logout
- [ ] Ticket create/edit/delete
- [x] Login/logout
- [x] Ticket create/edit/delete
- [ ] KB CRUD
- [ ] Attachment upload/download
- [ ] Audit all routes and controllers for authentication and access checks
@ -97,7 +97,7 @@ This issue list defines the work required to bring the `tp_servicedesk` Fat-Free
---
## 🔐 Security & Session
- [ ] Add CSRF protection for all POST forms
- [x] Add CSRF protection for all POST forms
- [ ] Enforce permission checks on:
- [ ] Ticket edits
- [ ] Comment deletion

View File

@ -0,0 +1,98 @@
<div class="block">
<input type="hidden" id="csrf-token-clipboard" value="{{ \CSRFHelper::token() }}">
<style>
#upload-area {
height: 250px;
border: 2px dashed #aaa;
display: flex;
align-items: center;
margin-top: 10px;
color: #555;
font-family: sans-serif;
text-align: center;
}
#upload-area.hover {
background-color: #f0f0f0;
border-color: #444;
}
#preview {
max-width: 100%;
margin-top: 10px;
}
</style>
<div id="upload-area" contenteditable="true">
Paste or drag an image here
</div>
<img id="preview" alt="image preview" hidden>
<p id="status"></p>
<script>
const area = document.getElementById('upload-area');
const preview = document.getElementById('preview');
const status = document.getElementById('status');
const csrfTokenClipboard = document.getElementById('csrf-token-clipboard').value;
async function uploadImage(file) {
const reader = new FileReader();
reader.onload = () => {
preview.src = reader.result;
preview.hidden = false;
};
reader.readAsDataURL(file);
const formData = new FormData();
formData.append('attachment', file);
formData.append('csrf_token', csrfTokenClipboard);
try {
const res = await fetch('/ticket/{{@ticket->id}}/attachments/upload', {
method: 'POST',
body: formData
});
if(res.ok){
const responseData = await res.json(); // assuming json returned
statusMsg.textContent = responseData || 'Upload success, no message';
window.location.reload();
} else {
const errorData = await res.json();
statusMsg.textContent = 'Upload failed: ' + (errorData.error || res.statusText);
}
} catch (e) {
status.textContent = 'upload failed.'
}
}
// paste
area.addEventListener('paste', (e) => {
for (let item of e.clipboardData.items) {
if (item.type.startsWith('image/')) {
uploadImage(item.getAsFile());
}
}
});
// drag and drop
area.addEventListener('dragover', (e) => {
e.preventDefault();
area.classList.add('hover');
});
area.addEventListener('dragLeave', () => {
area.classList.remove('hover');
});
area.addEventListener('drop', (e) => {
e.preventDefault();
area.classList.remove('hover');
const files = e.dataTransfer.files;
for (let file of files) {
if (file.type.startsWith('image/')) {
uploadImage(file);
}
}
});
</script>
</div>

View File

@ -55,7 +55,8 @@
<div class="navbar-end">
<div class="navbar-item">
<div class="buttons">
<form id="theme-toggle-form" method="post" action="/toggle-theme" style="display:inline">
<form id="theme-toggle-form" method="POST" action="/toggle-theme" style="display:inline">
{{ \CSRFHelper::field() | raw }}
<button type="submit" id="theme-toggle-button" class="button is-small" aria-label="Toggle Theme">
<span class="icon">
<i class="fas fa-circle-half-stroke" id="theme-icon"></i>
@ -92,7 +93,7 @@
<!-- Main Content Area -->
<div class="container">
<include href="ui/session/error.html">
<include href="session/error.html">
</div>
<main class="section" id="page">
<div class="container">

View File

@ -0,0 +1,6 @@
<h1 class="title">Create Ticket Priority</h1>
<p>TODO:</p>
<form method="POST">
{{ \CSRFHelper::field() | raw }}
</form>

View File

@ -1,9 +1,8 @@
<div class="box">
<div class="content">
<h4 class="title is-4">Attachments</h4>
<div class="block">
<check if="isset( {{@attachments }})">
<check if="count({{@attachments}}) > 0">
<div class="block">
<h4 class="title is-4">Attachments</h4>
<div class="block">
<check if="isset( {{@attachments }})">
<check if="count({{@attachments}}) > 0">
<table class="table is-fullwidth is-narrow is-striped is-hoverable">
<thead>
<tr>
@ -34,23 +33,23 @@
<img src="/attachment/{{@attach.id}}/view">
</repeat>
</div>
</check>
</check>
<div class="block">
<form action="/ticket/{{@PARAMS.id}}/attachments/upload" method="POST" enctype="multipart/form-data">
<div class="field has-addons">
<div class="control has-icons-left"><!-- is-expanded -->
<input class="input" type="file" name="attachment" required>
<span class="icon is-small is-left">
<i class="fas fa-file"></i>
</span>
</div>
<div class="control">
<button class="button" type="submit">Upload</button>
</div>
</check>
<div class="block">
<form action="/ticket/{{@PARAMS.id}}/attachments/upload" method="POST" enctype="multipart/form-data">
{{ \CSRFHelper::field() | raw }}
<div class="field has-addons">
<div class="control has-icons-left"><!-- is-expanded -->
<input class="input" type="file" name="attachment" required>
<span class="icon is-small is-left">
<i class="fas fa-file"></i>
</span>
</div>
</form>
</div>
<div class="control">
<button class="button" type="submit">Upload</button>
</div>
</div>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,42 @@
<hr>
<div class="block" id="comments">
<h3 class="title is-3">Comments</h3>
<check if="{{ !empty(@comments) }}">
<div class="list">
<repeat group="{{ @comments }}" value="{{ @comment}}">
<div class="list-item">
<div class="list-item-image">
<figure class="image is-48x48">
<img class="is-rounded" src="https://placehold.co/200x200/66d1ff/FFF?text=TP">
<!-- <img class="is-rounded"
src="https://loremflickr.com/200/200/dog?{{ (int)rand()}}">-->
</figure>
</div>
<div class="list-item-content">
<div class="list-item-title is-flex is-justify-content-space-between">
<span>{{ @comment.author_name}}</span>
<span class="has-text-weight-normal has-text-grey">{{ @comment.created_at }}</span>
</div>
<div class="list-item-description">
<parsedown>{{ @comment.comment | raw }}</parsedown>
</div>
</div>
</div>
</repeat>
</div>
</check>
<div class="block">
<form action="/ticket/{{@PARAMS.id}}/comment" method="POST">
{{ \CSRFHelper::field() | raw }}
<div class="field">
<label class="label">Add comment:</label>
<div class="control">
<textarea class="textarea" name="comment" rows="4" cols="50"></textarea>
</div>
</div>
<div class="field is-clearfix">
<button class="button is-primary is-pulled-right" type="submit">Submit Comment</button>
</div>
</form>
</div>
</div>

View File

@ -0,0 +1,3 @@
<h1 class="title">Dashboard</h1>
<parsedown href="issues.md"></parsedown>

View File

@ -3,6 +3,7 @@
<div class="content">
<form action="/kb/create" method="POST">
{{ \CSRFHelper::field() | raw }}
<bulma type="H_FIELD_INPUT" label="Title:" name="title" value=""></bulma>

View File

@ -2,6 +2,7 @@
<form action="/kb/{{@article.id}}/update" method="POST">
{{ \CSRFHelper::field() | raw }}
<bulma type="H_FIELD_INPUT" label="Title:" name="title" value="{{@article.title}}"></bulma>

View File

@ -7,7 +7,7 @@
</check>
<form action="/login" method="POST">
{{ \CSRFHelper::field() | raw }}
<div class="field">
<p class="control has-icons-left has-icons-right">
<input name="username" class="input" type="text" placeholder="Username">

View File

@ -2,6 +2,7 @@
<div class="content">
<form action="/tag/create" method="POST">
{{ \CSRFHelper::field() | raw }}
<div class="field">
<label class="label">Name</label>

View File

@ -0,0 +1,83 @@
<!-- Ticket - View -->
<!-- made to look more in line with view-->
<form action="/ticket/create" method="POST">
{{ \CSRFHelper::field() | raw }}
<div class="is-flex">
<div class="is-flex-grow-1">
<bulma type="FIELD_INPUT" name="title" value="" class="mr-3"></bulma>
</div>
<div class="field is-grouped">
<div class="control">
<a class="button is-secondary" href="/tickets">Cancel</a>
</div>
<div class="control">
<button class="button is-primary" type="submit">Create Ticket</button>
</div>
</div>
</div>
<hr>
<div class="block">
<div class="columns">
<div class="column is-two-thirds">
<div class="block">
<bulma type="FIELD_INPUT" name="created_at" value="" label="Created At:"></bulma>
</div>
<div class="tabs">
<ul>
<li class="is-active">
<a data-tab="write"><span class="icon is-small">
<i class="fas fa-pen" aria-hidden="true"></i></span><span>Write</span>
</a>
</li>
<li>
<a data-tab="preview"><span class="icon is-small">
<i class="fas fa-magnifying-glass"
aria-hidden="true"></i></span><span>Preview</span>
</a>
</li>
</ul>
</div>
<div id="tab-write" class="tab-content block">
<bulma type="FIELD_TEXTAREA" name="description" value="" rows="20"></bulma>
</div>
<div id="tab-preview" class="tab-content content">
<div id="preview-output"></div>
</div>
</div>
<div class="column">
<bulma type="FIELD_SELECT" label="Priority:" name="priority_id" options="{{@priorities}}"
option_value="id" option_name="name" selected="0"></bulma>
<bulma type="FIELD_SELECT" label="Status:" name="status_id" options="{{@statuses}}" option_value="id"
option_name="name" selected="0"></bulma>
<bulma type="FIELD_SELECT" label="Assigned User:" name="assigned_to" options="{{@users}}"
option_value="id" option_name="display_name" selected="0"></bulma>
<!-- field select multiple (TODO: move this to a custom bulma tag)-->
<div class="field">
<label class="label">Tags</label>
<div class="control">
<div class="select is-multiple" style="width:100%">
<select name="tags[]" multiple size="5" style="width:100%">
<repeat group="{{ @all_tags }}" value="{{@tag_option}}">
<option value="{{ @tag_option.id}}">{{ @tag_option.name }}</option>
</repeat>
</select>
</div>
<p class="help">Hold Ctrl to select multiple.</p>
</div>
</div>
</div>
</div>
</div>
</form>

View File

@ -0,0 +1,173 @@
<!-- Ticket - Edit -->
<!-- made to look more in line with view-->
<form action="/ticket/{{ @PARAMS.id }}/update" method="POST">
{{ \CSRFHelper::field() | raw }}
<div class="is-flex">
<div class="is-flex-grow-1">
<bulma type="FIELD_INPUT" name="title" value="{{@ticket.title}}" class="mr-3"></bulma>
</div>
<div class="field is-grouped">
<div class="control">
<a class="button is-secondary" href="/ticket/{{ @ticket.id }}">Cancel</a>
</div>
<div class="control">
<button class="button is-primary" type="submit">Save</button>
</div>
</div>
</div>
<hr>
<div class="block">
<div class="columns">
<div class="column is-two-thirds">
<div class="block">
<bulma type="FIELD_INPUT" label="Created At:" name="created_at" value="{{@ticket.created_at}}">
</bulma>
</div>
<div class="tabs">
<ul>
<li class="is-active">
<a data-tab="write"><span class="icon is-small">
<i class="fas fa-pen" aria-hidden="true"></i></span><span>Write</span>
</a>
</li>
<li>
<a data-tab="preview"><span class="icon is-small">
<i class="fas fa-magnifying-glass"
aria-hidden="true"></i></span><span>Preview</span>
</a>
</li>
</ul>
</div>
<div id="tab-write" class="tab-content block">
<bulma type="FIELD_TEXTAREA" name="description" value="{{@ticket.description}}" rows="20"></bulma>
</div>
<div id="tab-preview" class="tab-content content">
<div id="preview-output"></div>
</div>
</div>
<div class="column">
<div class="block">
<!-- priority and status -->
<bulma type="FIELD_SELECT" label="Priority:" name="priority_id" options="{{@priorities}}"
option_value="id" option_name="name" selected="{{@ticket.priority_id}}"></bulma>
<bulma type="FIELD_SELECT" label="Status:" name="status_id" options="{{@statuses}}"
option_value="id" option_name="name" selected="{{@ticket.status_id}}"></bulma>
<bulma type="FIELD_SELECT" label="Assigned User:" name="assigned_to" options="{{@users}}"
option_value="id" option_name="display_name" selected="{{@ticket.assigned_to}}"></bulma>
<!-- tags - need to implement a bulma-->
<div class="field">
<label class="label">Tags</label>
<div class="control">
<div class="select is-multiple" style="width:100%;">
<select name="tags[]" id="tags" multiple size="8" style="width:100%;">
<repeat group="{{ @all_tags }}" value="{{ @tag_option }}">
<option value="{{ @tag_option.id}}" {{ in_array(@tag_option.id,
@current_ticket_tag_ids) ? 'selected' : '' }}>
{{ @tag_option.name }}
</option>
</repeat>
</select>
</div>
<p class="help">Hold Ctrl to select multiple tags.</p>
</div>
</div>
<!-- additional data -->
<div class="block">
</div>
<!-- meta data -->
<div class="block">
<table class="table is-bordered is-fullwidth">
<thead>
<tr>
<th class="has-width-100">Property</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<repeat group="{{ @ticket }}" key="{{ @key }}" value="{{ @value }}">
<check if="{{ @key !== 'description'}}">
<tr>
<td>{{@key}}</td>
<td>{{@value}}</td>
</tr>
</check>
</repeat>
</tbody>
</table>
</div>
<!-- form to add child ticket relationships -->
<div class="box">
<h4 class="title is-4">Linked Tickets</h4>
<!-- parent -->
<?php
/*
<check if="{{ @parent_tickets }}">
<div class="block">
<h4 class="title">Parent Tickets</h4>
<ul>
<repeat group="{{ @parent_tickets }}" value="{{ @p }}">
<li><a href="/ticket/{{ @p.id }}">{{ @p.title }}</a></li>
</repeat>
</ul>
</div>
</check>
<!-- child tickets -->
<check if="{{ @child_tickets }}">
<div class="block">
<h4 class="title">Child Tickets</h4>
<ul>
<repeat group="{{ @child_tickets }}" value="{{ @c }}">
<li><a href="/ticket/{{ @c.id }}">{{ @c.title }}</a></li>
</repeat>
</div>
</check>
<form action="/ticket/{{ @ticket.id }}/add-subtask" method="POST">
<div class="field">
<label class="label">Add existing ticket as child ticket (ID):</label>
<div class="control">
<input class="input" type="number" placeholder="Child Ticket ID" required
name="child_ticket_id">
</div>
</div>
<div class="field">
<div class="control">
<button class="button is-link" type="submit">Link</button>
</div>
</div>
</form>
*/ ?>
</div>
</div>
</div>
</div>
</form>
<hr>
<div>
<exclude>
<include href="views/attachment/index.html"></include>
<include href="views/comments/view.html"></include>
</exclude>
<!--
<div class="block" id="attachments"></div>
<div class="block" id="comments"></div>
-->
</div>

View File

@ -15,7 +15,7 @@
options="statuses" option_value="id" option_name="name"
selected="{{@ticket.status_id}}"></bulma>
<include href="/ui/parts/clipboard.html"></include>
<include href="parts/clipboard.html"></include>
<div class="block">
<h3 class="title is-5">Custom Fields</h3>

View File

@ -20,33 +20,23 @@
</div>
</div>
</div>
<div class="block">
<div class="field has-addons">
<!-- TODO: move this into a template -->
<repeat group="{{ IconsHelper::$status_icons}}" key="{{ @k }}" value="{{ @icon }}">
<p class="control">
<a href="{{ @PATH }}/?status={{ @k }}" class="button">
<span class="icon is-small"><i class="fas fa-{{ @icon[0] }}"></i></span>
<span>{{ IconsHelper::$status_names[@k] }}</span>
</a>
</p>
</repeat>
</div>
<icons type="status-selector" selected="{{ @GET.status ?: null }}" path="tickets"></icons>
</div>
<hr>
<div id="ticket_list">
<repeat group="{{@tickets}}" value="{{@ticket}}">
<include href="/ui/partials/ticket_item.html"></include>
<include href="partials/ticket_item.html"></include>
</repeat>
</div>
<?php
/*
<div id="ticket_list">
<repeat group="{{@tickets}}" value="{{@ticket}}">
<include href="/ui/views/ticket/index_row.html"></include>
</repeat>
</div>
*/
?>
<exclude>
<div id="ticket_list">
<repeat group="{{@tickets}}" value="{{@ticket}}">
<include href="views/ticket/index_row.html"></include>
</repeat>
</div>
</exclude>

View File

@ -0,0 +1,176 @@
<!-- Ticket - View -->
<div class="is-flex">
<h1 class="title is-flex-grow-1">{{ @ticket.title }}</h1>
<div class="field is-grouped">
<p class="control"><a class="button" href="/ticket/{{ @ticket.id}}/edit">edit ticket</a></p>
<p class="control"><a class="button is-primary" href="/ticket/create">new ticket</a></p>
</div>
</div>
<hr>
<div class="content">
<div class="columns">
<div class="column is-two-thirds">
<!-- content -->
<div class="block">
<p>{{ @ticket.created_at }}</p>
<div class="content">
<parsedown>{{ @ticket.description | raw }}</parsedown>
</div>
</div>
<hr>
<include href="views/attachment/index.html">
<include href="views/comments/view.html">
</div>
<div class="column">
<!-- meta data -->
<div class="block">
<h6 class="title is-6">Tags
<span class="icon"><i class="fas fa-cog"></i></span></h6>
<!-- tags -->
<div class="block">
<div class="tags">
<repeat group="{{ @ticket.tags }}" value="{{@tag}}">
<span class="tag is-{{@tag.color}}">{{@tag.name}}</span>
</repeat>
</div>
</div>
</div>
<hr>
<div class="block">
<h6 class="title is-6">Projects
<span class="icon"><i class="fas fa-cog"></i></span></h6>
<hr>
<h6 class="title is-6">Assigned User:</h6>
<check if="{{ @assigned_user }}">
<div class="block">
<p>{{ @assigned_user.display_name ?: @assigned_user.username }}</p>
</div>
</check>
<h6 class="title is-6">Participants</h6>
<h6 class="title is-6">Time Tracker?</h6>
<h6 class="title is-6">Dependencies</h6>
<p>Reference:</p>
<a href="#" class="button"><span class="icon">
<i class="fas fa-trash"></i></span><span>Delete</span></a>
</div>
<!-- form to add child ticket relationships -->
<div class="box">
<h3>Linked Tickets</h3>
<!-- parent -->
<check if="{{ @parent_tickets }}">
<div class="block">
<h4 class="title">Parent Tickets</h4>
<ul>
<repeat group="{{ @parent_tickets }}" value="{{ @p }}">
<li><a href="/ticket/{{ @p.id }}">{{ @p.title }}</a></li>
</repeat>
</ul>
</div>
</check>
<!-- child tickets -->
<check if="{{ @child_tickets }}">
<div class="block">
<h4 class="title">Child Tickets</h4>
<ul>
<repeat group="{{ @child_tickets }}" value="{{ @c }}">
<li><a href="/ticket/{{ @c.id }}">{{ @c.title }}</a></li>
</repeat>
</div>
</check>
<form action="/ticket/{{ @ticket.id }}/add-subtask" method="POST">
{{ \CSRFHelper::field() | raw }}
<div class="field">
<label class="label">Add existing ticket as child ticket (ID):</label>
<div class="control">
<input class="input" type="number" placeholder="Child Ticket ID" required
name="child_ticket_id">
</div>
</div>
<div class="field">
<div class="control">
<button class="button is-link" type="submit">Link</button>
</div>
</div>
</form>
</div>
</div>
</div>
<hr>
<div class="block" id="ticket-history">
<h4 class="title is-4">Ticket History</h4>
<check if="{{ !empty(@ticket_history) }}">
<div class="list is-hoverable">
<repeat group="{{ @ticket_history }}" value="{{ @entry }}">
<div class="list-item">
<div class="list-item-content">
<div class="list-item-title">
<small class="has-text-grey">
{{ date('Y-m-d H:i:s', strtotime(@entry.changed_at)) }} by {{ @entry.user_display_name }}
</small>
</div>
<div class="list-item-description">
<!-- creation -->
<check if="{{ @entry.field_changed == 'ticket_created' }}">
Ticket created: {{ @entry.new_value}}
</check>
<!-- status -->
<check if="{{ @entry.field_changed == 'status_id' }}">
Status changed
<check if="{{ @entry.old_value !== null }}">
from <strong>{{ @map['status'][@entry.old_value] ?? 'Unknown' }}</strong>
</check>
to <strong>{{ @map['status'][@entry.new_value] ?? 'Unknown' }}</strong>
</check>
<!-- priority -->
<check if="{{ @entry.field_changed == 'priority_id' }}">
Priority changed
<check if="{{ @entry.old_value !== null }}">
from <strong>{{ @map['priorities'][@entry.old_value] ?? 'Unknown'}}</strong>
</check>
to <strong>{{ @map['priorities'][@entry.new_value] ?? 'Unknown' }}</strong>
</check>
<!-- assignment -->
<check if="{{ @entry.field_changed == 'assigned_to' }}">
Assignment changed
<check if="{{ @entry.old_value !== null && @entry.old_value != 0 }}">
from <strong>{{ @map['users'][@entry.old_value] ?? 'Unassigned' }}</strong>
</check>
<check if="{{ @entry.old_value === null || @entry.old_value == 0}}">
from <strong>Unassigned</strong>
</check>
to
<check if="{{ @entry.new_value !== null && @entry.new_value != 0}}">
<strong>{{ @map['users'][@entry.new_value] ?? 'Unassigned' }}</strong>
</check>
<check if="{{ @entry.new_value === null || @entry.new_value == 0}}">
<strong>Unassigned</strong>
</check>
</check>
<check if="{{ @entry.field_changed == 'title'}}">
Title changed from "{{ @entry.old_value}}" to "{{ @entry.new_value}}".
</check>
<check if="{{ @entry.field_changed == 'description'}}">
Description updated old sha256({{@entry.old_value}}).
</check>
</div>
</div>
</div>
</repeat>
</div>
<false>
<p>No history entries for this ticket.</p>
</false>
</check>
<!--
<div class="block" id="attachments"></div>
<div class="block" id="comments"></div>
-->
</div>

View File

@ -1,6 +1,5 @@
<form method="POST" action="/user/{{@edit_user.id}}/update">
{{ \CSRFHelper::field() | raw }}
<div class="field">
<label class="label">Username</label>
<div class="control">

View File

@ -1,5 +1,7 @@
<?php
require '../app/debug.php';
require '../lib/autoload.php';
$f3 = \Base::instance();

View File

@ -1,89 +0,0 @@
<div class="block">
<style>
#upload-area {
height: 250px;
border: 2px dashed #aaa;
display: flex;
align-items: center;
margin-top: 10px;
color: #555;
font-family: sans-serif;
text-align: center;
}
#upload-area.hover {
background-color: #f0f0f0;
border-color: #444;
}
#preview {
max-width:100%;
margin-top: 10px;
}
</style>
<div id="upload-area" contenteditable="true">
Paste or drag an image here
</div>
<img id="preview" alt="image preview" hidden>
<p id="status"></p>
<script>
const area = document.getElementById('upload-area');
const preview = document.getElementById('preview');
const status = document.getElementById('status');
async function uploadImage(file){
const reader = new FileReader();
reader.onload = () => {
preview.src = reader.result;
preview.hidden = false;
};
reader.readAsDataURL(file);
const formData = new FormData();
formData.append('attachment', file);
try {
const res = await fetch('/ticket/{{@ticket->id}}/attachments/upload', {
method: 'POST',
body: formData
});
const text = await res.text();
status.textContent = text;
} catch (e) {
status.textContent = 'upload failed.'
}
}
// paste
area.addEventListener('paste', (e) => {
for(let item of e.clipboardData.items){
if(item.type.startsWith('image/')){
uploadImage(item.getAsFile());
}
}
});
// drag and drop
area.addEventListener('dragover', (e) => {
e.preventDefault();
area.classList.add('hover');
});
area.addEventListener('dragLeave', ()=> {
area.classList.remove('hover');
});
area.addEventListener('drop', (e) => {
e.preventDefault();
area.classList.remove('hover');
const files = e.dataTransfer.files;
for(let file of files){
if(file.type.startsWith('image/')){
uploadImage(file);
}
}
});
</script>
</div>

View File

@ -1,2 +0,0 @@
<h1 class="title">Create Ticket Priority</h1>
<p>TODO:</p>

View File

@ -1,42 +0,0 @@
<hr>
<div class="box" id="comments">
<h3 class="title is-3">Comments</h3>
<check if="{{ !empty(@comments) }}">
<div class="list">
<repeat group="{{ @comments }}" value="{{ @comment}}">
<div class="list-item">
<div class="list-item-image">
<figure class="image is-48x48">
<img class="is-rounded"
src="https://placehold.co/200x200/66d1ff/FFF?text=TP">
<!-- <img class="is-rounded"
src="https://loremflickr.com/200/200/dog?{{ (int)rand()}}">-->
</figure>
</div>
<div class="list-item-content">
<div class="list-item-title is-flex is-justify-content-space-between">
<span>{{ @comment.author_name}}</span>
<span class="has-text-weight-normal has-text-grey">{{ @comment.created_at }}</span>
</div>
<div class="list-item-description">
<parsedown>{{ @comment.comment | raw }}</parsedown>
</div>
</div>
</div>
</repeat>
</div>
</check>
<div class="block">
<form action="/ticket/{{@PARAMS.id}}/comment" method="POST">
<div class="field">
<label class="label">Add comment:</label>
<div class="control">
<textarea class="textarea" name="comment" rows="4" cols="50"></textarea>
</div>
</div>
<div class="field is-clearfix">
<button class="button is-primary is-pulled-right" type="submit">Submit Comment</button>
</div>
</form>
</div>
</div>

View File

@ -1,5 +0,0 @@
<h1 class="title">Dashboard</h1>
<parsedown href="issues.md"></parsedown>
<p>109</p>

View File

@ -1,141 +0,0 @@
<!-- Ticket - Edit -->
<!-- made to look more in line with view-->
<form action="/ticket/{{ @PARAMS.id }}/update" method="POST">
<div class="is-flex">
<div class="is-flex-grow-1">
<bulma type="FIELD_INPUT" name="title" value="{{@ticket.title}}" class="mr-3"></bulma>
</div>
<div class="field is-grouped">
<!-- <p class="control"><a class="button" href="/ticket/{{ @ticket.id}}/edit">edit ticket</a></p>
<p class="control"><a class="button is-primary" href="/ticket/create">new ticket</a></p>-->
<div class="control">
<a class="button is-secondary" href="/ticket/{{ @ticket.id }}">Cancel</a>
</div>
<div class="control">
<button class="button is-primary" type="submit">Save</button>
</div>
</div>
</div>
<hr>
<div class="block">
<div class="columns">
<div class="column is-two-thirds">
<div class="block">
<bulma type="FIELD_INPUT" label="Created At:" name="created_at" value="{{@ticket.created_at}}"></bulma>
</div>
<div class="tabs">
<ul>
<li class="is-active">
<a data-tab="write"><span class="icon is-small">
<i class="fas fa-pen" aria-hidden="true"></i></span><span>Write</span>
</a>
</li>
<li>
<a data-tab="preview"><span class="icon is-small">
<i class="fas fa-magnifying-glass" aria-hidden="true"></i></span><span>Preview</span>
</a>
</li>
</ul>
</div>
<div id="tab-write" class="tab-content block">
<bulma type="FIELD_TEXTAREA" name="description" value="{{@ticket.description}}" rows="20"></bulma>
</div>
<div id="tab-preview" class="tab-content content">
<div id="preview-output"></div>
</div>
</div>
<div class="column">
<div class="block">
<!-- priority and status -->
<bulma type="FIELD_SELECT" label="Priority:" name="priority_id"
options="{{@priorities}}" option_value="id" option_name="name"
selected="{{@ticket.priority_id}}"></bulma>
<bulma type="FIELD_SELECT" label="Status:" name="status_id"
options="{{@statuses}}" option_value="id" option_name="name"
selected="{{@ticket.status_id}}"></bulma>
</div>
<!-- meta data -->
<div class="block">
<table class="table is-bordered is-fullwidth">
<thead>
<tr><th class="has-width-100">Property</th><th>Value</th></tr>
</thead>
<tbody>
<repeat group="{{ @ticket }}" key="{{ @key }}" value="{{ @value }}">
<check if="{{ @key !== 'description'}}">
<tr><td>{{@key}}</td> <td>{{@value}}</td></tr>
</check>
</repeat>
</tbody>
</table>
</div>
<!-- form to add child ticket relationships -->
<div class="box">
<h4 class="title is-4">Linked Tickets</h4>
<!-- parent -->
<?php
/*
<check if="{{ @parent_tickets }}">
<div class="block">
<h4 class="title">Parent Tickets</h4>
<ul>
<repeat group="{{ @parent_tickets }}" value="{{ @p }}">
<li><a href="/ticket/{{ @p.id }}">{{ @p.title }}</a></li>
</repeat>
</ul>
</div>
</check>
<!-- child tickets -->
<check if="{{ @child_tickets }}">
<div class="block">
<h4 class="title">Child Tickets</h4>
<ul>
<repeat group="{{ @child_tickets }}" value="{{ @c }}">
<li><a href="/ticket/{{ @c.id }}">{{ @c.title }}</a></li>
</repeat>
</div>
</check>
<form action="/ticket/{{ @ticket.id }}/add-subtask" method="POST">
<div class="field">
<label class="label">Add existing ticket as child ticket (ID):</label>
<div class="control">
<input class="input" type="number" placeholder="Child Ticket ID" required
name="child_ticket_id">
</div>
</div>
<div class="field">
<div class="control">
<button class="button is-link" type="submit">Link</button>
</div>
</div>
</form>
*/ ?>
</div>
</div>
</div>
</div>
</form>
<hr>
<div>
<include href="../ui/views/attachment/index.html">
<include href="../ui/views/comments/view.html">
<!--
<div class="block" id="attachments"></div>
<div class="block" id="comments"></div>
-->
</div>

View File

@ -1,99 +0,0 @@
<!-- Ticket - View -->
<div class="is-flex">
<h1 class="title is-flex-grow-1">{{ @ticket.title }}</h1>
<div class="field is-grouped">
<p class="control"><a class="button" href="/ticket/{{ @ticket.id}}/edit">edit ticket</a></p>
<p class="control"><a class="button is-primary" href="/ticket/create">new ticket</a></p>
</div>
</div>
<hr>
<div class="content">
<div class="columns">
<div class="column is-two-thirds">
<div class="block">
<p>{{ @ticket.created_at }}</p>
<div class="content">
<parsedown>{{ @ticket.description | raw }}</parsedown>
</div>
</div>
</div>
<div class="column">
<!-- meta data -->
<div class="block">
<div class="tags">
<repeat group="{{ @ticket.tags }}" value="{{@tag}}">
<span class="tag is-{{@tag.color}}">{{@tag.name}}</span>
</repeat>
</div>
<table class="table is-bordered is-fullwidth">
<thead>
<tr><th class="has-width-100">Property</th><th>Value</th></tr>
</thead>
<tbody>
<repeat group="{{ @ticket }}" key="{{ @key }}" value="{{ @value }}">
<check if="{{ @key !== 'description'}}">
<tr><td>{{@key}}</td> <td>{{@value}}</td></tr>
</check>
</repeat>
</tbody>
</table>
</div>
<!-- form to add child ticket relationships -->
<div class="box">
<h3>Linked Tickets</h3>
<!-- parent -->
<check if="{{ @parent_tickets }}">
<div class="block">
<h4 class="title">Parent Tickets</h4>
<ul>
<repeat group="{{ @parent_tickets }}" value="{{ @p }}">
<li><a href="/ticket/{{ @p.id }}">{{ @p.title }}</a></li>
</repeat>
</ul>
</div>
</check>
<!-- child tickets -->
<check if="{{ @child_tickets }}">
<div class="block">
<h4 class="title">Child Tickets</h4>
<ul>
<repeat group="{{ @child_tickets }}" value="{{ @c }}">
<li><a href="/ticket/{{ @c.id }}">{{ @c.title }}</a></li>
</repeat>
</div>
</check>
<form action="/ticket/{{ @ticket.id }}/add-subtask" method="POST">
<div class="field">
<label class="label">Add existing ticket as child ticket (ID):</label>
<div class="control">
<input class="input" type="number" placeholder="Child Ticket ID" required
name="child_ticket_id">
</div>
</div>
<div class="field">
<div class="control">
<button class="button is-link" type="submit">Link</button>
</div>
</div>
</form>
</div>
</div>
</div>
<hr>
<include href="../ui/views/attachment/index.html">
<include href="../ui/views/comments/view.html">
<!--
<div class="block" id="attachments"></div>
<div class="block" id="comments"></div>
-->
</div>