Merge development into main: Custom Fields wiring (#47)

- Wire Custom Fields into ticket creation and viewing (#47)
This commit is contained in:
2026-09-11 13:17:33 -04:00
4 changed files with 300 additions and 1 deletions
+87
View File
@@ -0,0 +1,87 @@
<?php
/**
* Save custom field values for a ticket.
*
* POST { ticket_id, values: { [field_id]: value, ... } }
*
* Only fields applicable to the ticket's current category (or category-less
* fields) are considered; anything else in `values` is ignored rather than
* persisted, so a value typed for a field that no longer applies (e.g. the
* category changed) can't linger as orphaned/misleading data.
*/
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
apiRespond(['success' => false, 'error' => 'Method not allowed']);
}
$data = json_decode(file_get_contents('php://input'), true);
$ticketId = isset($data['ticket_id']) ? trim((string)$data['ticket_id']) : '';
$values = is_array($data['values'] ?? null) ? $data['values'] : [];
if ($ticketId === '') {
http_response_code(400);
apiRespond(['success' => false, 'error' => 'ticket_id required']);
}
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
http_response_code(404);
apiRespond(['success' => false, 'error' => 'Ticket not found']);
}
$fieldModel = new CustomFieldModel($conn);
$definitions = $fieldModel->getAllDefinitions($ticket['category'], true);
$errors = [];
$toSave = [];
foreach ($definitions as $def) {
$fieldId = (int)$def['field_id'];
$raw = $values[$fieldId] ?? ($values[(string)$fieldId] ?? null);
if ($def['field_type'] === 'checkbox') {
$normalized = !empty($raw) ? '1' : '0';
} else {
$normalized = is_scalar($raw) ? trim((string)$raw) : '';
}
if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
$errors[] = $def['field_label'] . ' is required';
continue;
}
if ($def['field_type'] === 'select' && $normalized !== '') {
$allowedOptions = $def['field_options']['options'] ?? [];
if (!in_array($normalized, $allowedOptions, true)) {
$errors[] = $def['field_label'] . ' has an invalid selection';
continue;
}
}
if ($def['field_type'] === 'number' && $normalized !== '' && !is_numeric($normalized)) {
$errors[] = $def['field_label'] . ' must be a number';
continue;
}
$toSave[$fieldId] = $normalized;
}
if (!empty($errors)) {
http_response_code(422);
apiRespond(['success' => false, 'error' => implode('; ', $errors)]);
}
$fieldModel->setValues($ticketId, $toSave);
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
(new AuditLogModel($conn))->log($userId, 'update', 'ticket', $ticketId, [
'reason' => 'custom fields updated',
]);
apiRespond(['success' => true]);
+52
View File
@@ -7,6 +7,7 @@ require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/UserModel.php';
require_once dirname(__DIR__) . '/models/WorkflowModel.php';
require_once dirname(__DIR__) . '/models/TemplateModel.php';
require_once dirname(__DIR__) . '/models/CustomFieldModel.php';
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
@@ -18,6 +19,7 @@ class TicketController
private $userModel;
private $workflowModel;
private $templateModel;
private $customFieldModel;
private $conn;
public function __construct($conn)
@@ -29,6 +31,7 @@ class TicketController
$this->userModel = new UserModel($conn);
$this->workflowModel = new WorkflowModel($conn);
$this->templateModel = new TemplateModel($conn);
$this->customFieldModel = new CustomFieldModel($conn);
}
public function view($id)
@@ -60,6 +63,11 @@ class TicketController
// Get allowed status transitions for this ticket
$allowedTransitions = $this->workflowModel->getAllowedTransitions($ticket['status']);
// Custom fields applicable to this ticket's category, with any
// already-saved values for it
$customFieldDefs = $this->customFieldModel->getAllDefinitions($ticket['category'], true);
$customFieldValues = $this->customFieldModel->getValuesForTicket($id);
// Make $conn available to view for visibility groups
$conn = $this->conn;
@@ -73,6 +81,12 @@ class TicketController
$currentUser = $GLOBALS['currentUser'] ?? null;
$userId = $currentUser['user_id'] ?? null;
// All active custom field definitions (every category, plus
// category-less ones) — the create form renders them all and toggles
// visibility client-side as the Category select changes, since the
// ticket doesn't exist yet to scope the query to one category.
$allCustomFieldDefs = $this->customFieldModel->getAllDefinitions(null, true);
// Check if form was submitted
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Validate CSRF token
@@ -131,6 +145,38 @@ class TicketController
return;
}
// Custom fields applicable to the submitted category — validate
// is_required server-side (the form is novalidate, and a field
// hidden by the client-side category toggle must not silently
// bypass a requirement that applies to the category actually
// submitted).
$submittedCustomFields = is_array($_POST['custom_fields'] ?? null) ? $_POST['custom_fields'] : [];
$applicableFieldDefs = array_filter(
$allCustomFieldDefs,
fn($def) => $def['category'] === null || $def['category'] === $ticketData['category']
);
$customFieldsToSave = [];
foreach ($applicableFieldDefs as $def) {
$fieldId = (int)$def['field_id'];
$raw = $submittedCustomFields[$fieldId] ?? null;
$normalized = $def['field_type'] === 'checkbox'
? (!empty($raw) ? '1' : '0')
: (is_scalar($raw) ? trim((string)$raw) : '');
if (!empty($def['is_required']) && $def['field_type'] !== 'checkbox' && $normalized === '') {
$error = $def['field_label'] . ' is required';
$templates = $this->templateModel->getAllTemplates();
$allUsers = $this->userModel->getAllUsers();
$conn = $this->conn;
include dirname(__DIR__) . '/views/CreateTicketView.php';
return;
}
if ($normalized !== '') {
$customFieldsToSave[$fieldId] = $normalized;
}
}
// Create ticket with user tracking
$result = $this->ticketModel->createTicket($ticketData, $userId);
@@ -144,6 +190,12 @@ class TicketController
require_once dirname(__DIR__) . '/models/StatsModel.php';
(new StatsModel($this->conn))->invalidateCache();
// Persist custom field values for the fields applicable to
// this ticket's category
if (!empty($customFieldsToSave)) {
$this->customFieldModel->setValues($result['ticket_id'], $customFieldsToSave);
}
// Auto-link as duplicate if requested from create form
$linkDupOfRaw = trim($_POST['link_duplicate_of'] ?? '');
if ($linkDupOfRaw !== '' && ctype_digit($linkDupOfRaw)) {
+61 -1
View File
@@ -124,7 +124,7 @@ include __DIR__ . '/layout_header.php';
<div class="lt-form-group">
<label class="lt-label" for="category">Category</label>
<select id="category" name="category" class="lt-select">
<select id="category" name="category" class="lt-select" data-action="toggle-custom-fields">
<option value="Hardware">Hardware</option>
<option value="Software">Software</option>
<option value="Network">Network</option>
@@ -211,6 +211,55 @@ include __DIR__ . '/layout_header.php';
</div>
</div>
<?php if (!empty($allCustomFieldDefs)) : ?>
<!-- ── SECTION 5b: Custom Fields ─────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Additional Fields</div>
<div class="lt-section-body">
<?php foreach ($allCustomFieldDefs as $cfDef) : ?>
<div class="lt-form-group custom-field-group"
data-custom-field-category="<?= htmlspecialchars($cfDef['category'] ?? '', ENT_QUOTES, 'UTF-8') ?>">
<?php
$cfName = 'custom_fields[' . (int)$cfDef['field_id'] . ']';
$cfId = 'custom_field_' . (int)$cfDef['field_id'];
?>
<label class="lt-label" for="<?= $cfId ?>">
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?><?= $cfDef['is_required'] ? ' *' : '' ?>
</label>
<?php if ($cfDef['field_type'] === 'textarea') : ?>
<textarea id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input lt-textarea" rows="3"
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>></textarea>
<?php elseif ($cfDef['field_type'] === 'select') : ?>
<select id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-select"
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
<option value="">— Select —</option>
<?php foreach (($cfDef['field_options']['options'] ?? []) as $opt) : ?>
<option value="<?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?>"><?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach ?>
</select>
<?php elseif ($cfDef['field_type'] === 'checkbox') : ?>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="<?= $cfId ?>" name="<?= $cfName ?>" value="1">
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?>
</label>
<?php elseif ($cfDef['field_type'] === 'date') : ?>
<input type="date" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
<?php elseif ($cfDef['field_type'] === 'number') : ?>
<input type="number" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
<?php else : ?>
<input type="text" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
<?= $cfDef['is_required'] ? 'data-custom-field-required="1"' : '' ?>>
<?php endif ?>
</div>
<?php endforeach ?>
<p class="lt-form-hint">Fields shown depend on the selected Category.</p>
</div>
</div>
<?php endif ?>
<!-- ── SECTION 6: Description ───────────────────────────── -->
<div class="lt-frame lt-mb-md">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
@@ -316,6 +365,15 @@ include __DIR__ . '/layout_header.php';
.catch(function () { /* silent — duplicate check is non-critical */ });
}
// ── Custom fields: show only the selected category's fields ──
function toggleCustomFields() {
var category = document.getElementById('category').value;
document.querySelectorAll('.custom-field-group').forEach(function (group) {
var fieldCategory = group.getAttribute('data-custom-field-category');
group.classList.toggle('is-hidden', fieldCategory !== '' && fieldCategory !== category);
});
}
// ── Visibility groups toggle ──────────────────────────────
var visibilityHints = {
'public': 'Everyone who is logged in can view this ticket.',
@@ -387,9 +445,11 @@ include __DIR__ . '/layout_header.php';
switch (target.getAttribute('data-action')) {
case 'load-template': loadTemplate(); break;
case 'toggle-visibility-groups': toggleVisibilityGroups(); break;
case 'toggle-custom-fields': toggleCustomFields(); break;
}
});
toggleCustomFields();
if (window.lt) lt.keys.initDefaults();
}());
</script>
+100
View File
@@ -397,6 +397,12 @@ document.addEventListener('DOMContentLoaded', function() {
role="tab" data-tab="dependencies-panel" aria-selected="false" aria-controls="dependencies-panel">
Dependencies
</button>
<?php if (!empty($customFieldDefs)) : ?>
<button type="button" class="lt-tab" id="custom-fields-tab-btn"
role="tab" data-tab="custom-fields-panel" aria-selected="false" aria-controls="custom-fields-panel">
Custom Fields
</button>
<?php endif ?>
<button type="button" class="lt-tab" id="activity-tab-btn"
role="tab" data-tab="activity-panel" aria-selected="false" aria-controls="activity-panel">
Activity
@@ -682,6 +688,60 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
</div>
<?php if (!empty($customFieldDefs)) : ?>
<!-- ═══════════════════════════════════════════════════════════
TAB PANEL: CUSTOM FIELDS
═══════════════════════════════════════════════════════════ -->
<div id="custom-fields-panel" class="lt-tab-panel" role="tabpanel" aria-labelledby="custom-fields-tab-btn">
<div class="lt-frame">
<span class="lt-frame-bl">╚</span><span class="lt-frame-br">╝</span>
<div class="lt-section-header">Custom Fields</div>
<div class="lt-section-body">
<div id="customFieldsMsg" class="lt-msg is-hidden lt-mb-md" role="alert" aria-live="polite"></div>
<?php foreach ($customFieldDefs as $cfDef) :
$cfValue = $customFieldValues[$cfDef['field_name']]['field_value'] ?? '';
$cfName = 'custom_fields[' . (int)$cfDef['field_id'] . ']';
$cfId = 'ticket_custom_field_' . (int)$cfDef['field_id'];
?>
<div class="lt-form-group">
<label class="lt-label" for="<?= $cfId ?>">
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?><?= $cfDef['is_required'] ? ' *' : '' ?>
</label>
<?php if ($cfDef['field_type'] === 'textarea') : ?>
<textarea id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input lt-textarea" rows="3"
><?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?></textarea>
<?php elseif ($cfDef['field_type'] === 'select') : ?>
<select id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-select">
<option value="">— Select —</option>
<?php foreach (($cfDef['field_options']['options'] ?? []) as $opt) : ?>
<option value="<?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?>"
<?= $opt === $cfValue ? 'selected' : '' ?>><?= htmlspecialchars($opt, ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach ?>
</select>
<?php elseif ($cfDef['field_type'] === 'checkbox') : ?>
<label class="lt-filter-option">
<input type="checkbox" class="lt-checkbox" id="<?= $cfId ?>" name="<?= $cfName ?>" value="1"
<?= $cfValue === '1' ? 'checked' : '' ?>>
<?= htmlspecialchars($cfDef['field_label'], ENT_QUOTES, 'UTF-8') ?>
</label>
<?php elseif ($cfDef['field_type'] === 'date') : ?>
<input type="date" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
value="<?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?>">
<?php elseif ($cfDef['field_type'] === 'number') : ?>
<input type="number" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
value="<?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?>">
<?php else : ?>
<input type="text" id="<?= $cfId ?>" name="<?= $cfName ?>" class="lt-input"
value="<?= htmlspecialchars($cfValue, ENT_QUOTES, 'UTF-8') ?>">
<?php endif ?>
</div>
<?php endforeach ?>
<button type="button" id="saveCustomFieldsBtn" class="lt-btn lt-btn-primary lt-btn-sm">SAVE CUSTOM FIELDS</button>
</div>
</div>
</div>
<?php endif ?>
<!-- ═══════════════════════════════════════════════════════════
TAB PANEL: ACTIVITY
═══════════════════════════════════════════════════════════ -->
@@ -1013,6 +1073,46 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
// Save custom fields button
var saveCustomFieldsBtn = document.getElementById('saveCustomFieldsBtn');
if (saveCustomFieldsBtn) {
saveCustomFieldsBtn.addEventListener('click', function () {
var panel = document.getElementById('custom-fields-panel');
var msg = document.getElementById('customFieldsMsg');
var values = {};
panel.querySelectorAll('[name^="custom_fields["]').forEach(function (el) {
var m = el.name.match(/custom_fields\[(\d+)\]/);
if (!m) return;
var fieldId = m[1];
if (el.type === 'checkbox') {
values[fieldId] = el.checked ? '1' : '0';
} else {
values[fieldId] = el.value;
}
});
saveCustomFieldsBtn.disabled = true;
msg.classList.add('is-hidden');
lt.api.post('/api/ticket_custom_fields.php', {
ticket_id: window.ticketData.id,
values: values
}).then(function (data) {
saveCustomFieldsBtn.disabled = false;
if (data.success) {
lt.toast.success('Custom fields saved', 3000);
} else {
msg.textContent = data.error || 'Failed to save custom fields';
msg.className = 'lt-msg lt-msg-danger lt-mb-md';
}
}).catch(function (error) {
saveCustomFieldsBtn.disabled = false;
msg.textContent = 'Failed to save custom fields: ' + error.message;
msg.className = 'lt-msg lt-msg-danger lt-mb-md';
});
});
}
// Settings save/cancel
// Load user preference toggles on settings modal open
(function() {